diff --git a/Cargo.lock b/Cargo.lock index a9cb6df..cf38116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,58 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -902,6 +954,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -916,6 +974,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1433,6 +1492,12 @@ dependencies = [ "libc", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2387,6 +2452,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2816,6 +2892,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2854,6 +2931,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-core", ] @@ -2878,6 +2956,7 @@ name = "tuidal" version = "0.1.0" dependencies = [ "anyhow", + "axum", "crossterm", "dirs", "image", diff --git a/Cargo.toml b/Cargo.toml index 509ed96..e5bf298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,3 +17,4 @@ ratatui-image = { version = "6", default-features = false } image = "0.25" reqwest = { version = "0.12", features = ["json"] } open = "5" +axum = "0.8" diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..3cf154d --- /dev/null +++ b/src/api.rs @@ -0,0 +1,271 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::Json, + routing::{get, post}, + Router, +}; +use serde::Deserialize; +use std::sync::{Arc, RwLock}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::app::{ApiCommand, ApiStatus, ApiTrack, AppEvent}; +use crate::tidal::{FavAlbum, Playlist, Mix, Quality, TidalClient, Track}; + +pub const PORT: u16 = 7837; + +#[derive(Clone)] +struct ApiState { + tx: UnboundedSender, + status: Arc>, + tidal_script: String, + tidal_quality: Quality, + tidal_python: String, +} + +impl ApiState { + fn tidal(&self) -> TidalClient { + TidalClient::with_path_and_quality( + self.tidal_script.clone(), + self.tidal_quality, + self.tidal_python.clone(), + ) + } +} + +fn log(msg: &str) { + use std::io::Write; + if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open("/tmp/tuidal-api.log") { + let _ = writeln!(f, "{}", msg); + } +} + +pub async fn start_server( + tx: UnboundedSender, + status: Arc>, + tidal_script: String, + tidal_quality: Quality, + tidal_python: String, +) { + log("start_server: task started"); + let state = ApiState { tx, status, tidal_script, tidal_quality, tidal_python }; + log("start_server: state created"); + + let router = Router::new() + .route("/status", get(handle_status)) + .route("/play-pause", post(handle_play_pause)) + .route("/next", post(handle_next)) + .route("/previous", post(handle_previous)) + .route("/volume-up", post(handle_volume_up)) + .route("/volume-down", post(handle_volume_down)) + .route("/volume", post(handle_volume_set)) + .route("/seek-forward", post(handle_seek_forward)) + .route("/seek-backward", post(handle_seek_backward)) + .route("/shuffle", post(handle_shuffle)) + .route("/repeat", post(handle_repeat)) + .route("/play-track", post(handle_play_track)) + .route("/just-play", post(handle_just_play)) + .route("/queue", get(handle_queue)) + .route("/search", get(handle_search)); + log("start_server: base routes registered"); + + let router = router + .route("/library", get(handle_library)) + .route("/library/favorites", get(handle_library_favorites)) + .route("/library/favorite-albums", get(handle_library_fav_albums)); + log("start_server: library static routes registered"); + + let router = router + .route("/library/playlist/{uuid}", get(handle_library_playlist)) + .route("/library/mix/{id}", get(handle_library_mix)) + .route("/library/album/{id}", get(handle_library_album)); + log("start_server: library param routes registered"); + + let router = router.with_state(state); + log("start_server: with_state done"); + + log(&format!("start_server: binding 127.0.0.1:{PORT}")); + let listener = match tokio::net::TcpListener::bind(("127.0.0.1", PORT)).await { + Ok(l) => { log(&format!("start_server: bound OK on port {PORT}")); l } + Err(e) => { log(&format!("start_server: bind FAILED: {e}")); return; } + }; + log("start_server: calling axum::serve"); + if let Err(e) = axum::serve(listener, router).await { + log(&format!("start_server: serve error: {e}")); + } +} + +// ── Status ──────────────────────────────────────────────────────────────────── + +async fn handle_status(State(s): State) -> Json { + Json(s.status.read().unwrap().clone()) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn send_cmd(s: &ApiState, cmd: ApiCommand) -> StatusCode { + match s.tx.send(AppEvent::ApiCmd(cmd)) { + Ok(_) => StatusCode::NO_CONTENT, + Err(_) => StatusCode::SERVICE_UNAVAILABLE, + } +} + +// ── Playback controls ───────────────────────────────────────────────────────── + +async fn handle_play_pause(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::PlayPause) +} +async fn handle_next(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::Next) +} +async fn handle_previous(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::Prev) +} +async fn handle_volume_up(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::VolumeUp) +} +async fn handle_volume_down(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::VolumeDown) +} + +#[derive(Deserialize)] +struct VolumeQuery { level: u8 } + +async fn handle_volume_set(State(s): State, Query(q): Query) -> StatusCode { + send_cmd(&s, ApiCommand::VolumeSet(q.level)) +} +async fn handle_seek_forward(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::SeekForward) +} +async fn handle_seek_backward(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::SeekBackward) +} +async fn handle_shuffle(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::ToggleShuffle) +} +async fn handle_repeat(State(s): State) -> StatusCode { + send_cmd(&s, ApiCommand::CycleRepeat) +} + +// ── Play track ──────────────────────────────────────────────────────────────── + +async fn handle_play_track( + State(s): State, + Json(track): Json, +) -> StatusCode { + send_cmd(&s, ApiCommand::PlayTrack(track)) +} + +#[derive(Deserialize)] +struct JustPlayQuery { q: String } + +async fn handle_just_play( + State(s): State, + Query(p): Query, +) -> StatusCode { + match s.tidal().search(&p.q, 1).await { + Ok(tracks) if !tracks.is_empty() => { + let t = &tracks[0]; + let api_track = ApiTrack { + id: t.id, + title: t.title.clone(), + artist: t.artist_names(), + album: t.album.title.clone(), + duration: t.duration, + }; + send_cmd(&s, ApiCommand::PlayTrack(api_track)) + } + _ => StatusCode::NOT_FOUND, + } +} + +// ── Queue ───────────────────────────────────────────────────────────────────── + +async fn handle_queue(State(s): State) -> Json { + let st = s.status.read().unwrap(); + Json(serde_json::json!({ + "tracks": st.queue, + "current_index": st.queue_index, + })) +} + +// ── Search ──────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct SearchQuery { + q: String, + #[serde(default = "default_limit")] + limit: usize, +} +fn default_limit() -> usize { 20 } + +async fn handle_search( + State(s): State, + Query(p): Query, +) -> Result>, StatusCode> { + s.tidal().search(&p.q, p.limit).await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +// ── Library ─────────────────────────────────────────────────────────────────── + +async fn handle_library( + State(s): State, +) -> Result, StatusCode> { + let client = s.tidal(); + let playlists = client.get_user_playlists().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mixes = client.get_user_mixes().await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(serde_json::json!({ "playlists": playlists, "mixes": mixes }))) +} + +async fn handle_library_favorites( + State(s): State, +) -> Result>, StatusCode> { + s.tidal().get_favorite_tracks().await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn handle_library_fav_albums( + State(s): State, +) -> Result>, StatusCode> { + s.tidal().get_favorite_albums().await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn handle_library_playlist( + State(s): State, + Path(uuid): Path, +) -> Result>, StatusCode> { + s.tidal().get_playlist_tracks(&uuid).await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn handle_library_mix( + State(s): State, + Path(id): Path, +) -> Result>, StatusCode> { + s.tidal().get_mix_tracks(&id).await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +async fn handle_library_album( + State(s): State, + Path(id): Path, +) -> Result>, StatusCode> { + s.tidal().get_album_tracks(id).await + .map(Json) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +// Suppress unused-import warnings for types used only in return positions +const _: fn() = || { + let _: Playlist; + let _: Mix; +}; diff --git a/src/app.rs b/src/app.rs index 7fbe6ba..f3a092c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,12 @@ +use crate::i18n::Lang; use crate::player::{Player, TrackInfo}; -use crate::tidal::{Quality, TidalClient, Track, StreamInfo, CoverInfo, Playlist, Mix, FavAlbum}; +use crate::tidal::{Quality, TidalClient, Track, Artist, Album, StreamInfo, CoverInfo, Playlist, Mix, FavAlbum}; +use serde::Deserialize as DeserializeAttr; use tokio::sync::mpsc::UnboundedSender; use image::DynamicImage; use ratatui_image::protocol::StatefulProtocol; use ratatui_image::picker::Picker; +use serde::Serialize; #[derive(Debug, Clone, PartialEq)] pub enum InputMode { @@ -19,12 +22,6 @@ pub enum Tab { Library, } -#[derive(Debug, Clone, PartialEq)] -pub enum LibraryMode { - List, - Tracks, -} - #[derive(Debug, Clone, PartialEq)] pub enum CollectionView { Tracks, @@ -45,6 +42,58 @@ pub enum AppEvent { PlaylistTracksLoaded(Vec), FavTracksLoaded(Vec), FavAlbumsLoaded(Vec), + ApiCmd(ApiCommand), +} + +pub enum ApiCommand { + PlayPause, + Next, + Prev, + VolumeUp, + VolumeDown, + VolumeSet(u8), + SeekForward, + SeekBackward, + PlayTrack(ApiTrack), + ToggleShuffle, + CycleRepeat, +} + +#[derive(Debug, Clone, DeserializeAttr)] +pub struct ApiTrack { + pub id: u64, + pub title: String, + pub artist: String, + pub album: String, + pub duration: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RepeatMode { Off, One, All } + +impl Default for RepeatMode { + fn default() -> Self { RepeatMode::All } +} + +#[derive(Clone, Default, Serialize)] +pub struct ApiStatus { + pub state: String, + pub title: Option, + pub artist: Option, + pub album: Option, + pub duration: Option, + pub elapsed: u64, + pub volume: u8, + pub progress: f64, + pub bit_depth: Option, + pub sample_rate: Option, + pub codec: Option, + pub shuffle: bool, + pub repeat: RepeatMode, + pub authenticated: bool, + pub queue: Vec, + pub queue_index: Option, } pub struct App { @@ -68,7 +117,6 @@ pub struct App { pub device_code: Option, pub user_code: Option, pub auth_url: Option, - pub poll_handle: Option>, pub event_tx: Option>, @@ -83,10 +131,13 @@ pub struct App { pub playlists: Vec, pub mixes: Vec, pub library_selected: usize, - pub library_mode: LibraryMode, pub fav_albums: Vec, pub fav_album_selected: usize, pub collection_view: CollectionView, + + pub lang: Lang, + pub shuffle: bool, + pub repeat: RepeatMode, } impl App { @@ -108,7 +159,6 @@ impl App { device_code: None, user_code: None, auth_url: None, - poll_handle: None, event_tx: None, cover_info: None, cover_image: None, @@ -120,13 +170,20 @@ impl App { playlists: Vec::new(), mixes: Vec::new(), library_selected: 0, - library_mode: LibraryMode::List, fav_albums: Vec::new(), fav_album_selected: 0, collection_view: CollectionView::Tracks, + lang: Lang::Es, + shuffle: false, + repeat: RepeatMode::All, } } + pub fn cycle_lang(&mut self) { + self.lang = self.lang.cycle(); + self.status_msg = self.lang.lang_changed(); + } + fn tx(&self) -> UnboundedSender { self.event_tx.clone().expect("event_tx no inicializado") } @@ -135,9 +192,9 @@ impl App { match event { AppEvent::SearchDone(Ok(results)) => { self.status_msg = if results.is_empty() { - "Sin resultados".to_string() + self.lang.strings().status_no_results.to_string() } else { - format!("{} resultados", results.len()) + self.lang.results_count(results.len()) }; self.search_results = results; self.selected = 0; @@ -145,7 +202,7 @@ impl App { self.loading = false; } AppEvent::SearchDone(Err(e)) => { - self.status_msg = format!("✗ Error búsqueda: {e}"); + self.status_msg = self.lang.search_error(&e); self.loading = false; } AppEvent::StreamReady { track, info, queue_index, generation } => { @@ -172,7 +229,7 @@ impl App { self.load_cover_bg(track.id); } AppEvent::StreamError(e) => { - self.status_msg = format!("✗ Error stream: {e}"); + self.status_msg = self.lang.stream_error(&e); self.loading = false; } AppEvent::AuthStarted { url, code, device_code } => { @@ -185,9 +242,9 @@ impl App { format!("https://{}", url) }; if let Err(e) = open::that(&url_to_open) { - self.status_msg = format!("No se pudo abrir browser ({}): {}", e, url); + self.status_msg = self.lang.browser_failed(&e.to_string(), &url); } else { - self.status_msg = format!("Browser abierto. Código: {code}"); + self.status_msg = self.lang.browser_opened(&code); } self.loading = false; } @@ -196,11 +253,11 @@ impl App { self.device_code = None; self.user_code = None; self.auth_url = None; - self.status_msg = "✓ Autenticado con Tidal".to_string(); + self.status_msg = self.lang.strings().status_auth_done.to_string(); self.loading = false; } AppEvent::AuthError(e) => { - self.status_msg = format!("✗ Error auth: {e}"); + self.status_msg = self.lang.auth_error(&e); self.loading = false; } AppEvent::StatusMsg(msg) => { @@ -221,10 +278,7 @@ impl App { self.mixes = mixes; self.active_tab = Tab::Library; self.loading = false; - self.status_msg = format!( - "✓ {} playlists, {} mixes", - self.playlists.len(), self.mixes.len() - ); + self.status_msg = self.lang.library_loaded(self.playlists.len(), self.mixes.len()); } AppEvent::PlaylistTracksLoaded(tracks) => { self.queue = tracks; @@ -232,7 +286,7 @@ impl App { self.selected = 0; self.active_tab = Tab::Queue; self.loading = false; - self.status_msg = format!("✓ {} tracks cargados", self.queue.len()); + self.status_msg = self.lang.tracks_loaded(self.queue.len()); } AppEvent::FavTracksLoaded(tracks) => { self.queue = tracks; @@ -240,7 +294,7 @@ impl App { self.selected = 0; self.active_tab = Tab::Queue; self.loading = false; - self.status_msg = format!("✓ {} canciones favoritas en cola", self.queue.len()); + self.status_msg = self.lang.fav_tracks_loaded(self.queue.len()); } AppEvent::FavAlbumsLoaded(albums) => { self.fav_albums = albums; @@ -248,19 +302,62 @@ impl App { self.collection_view = CollectionView::Albums; self.active_tab = Tab::Library; self.loading = false; - self.status_msg = format!("✓ {} álbumes en colección", self.fav_albums.len()); + self.status_msg = self.lang.fav_albums_loaded(self.fav_albums.len()); } + AppEvent::ApiCmd(cmd) => match cmd { + ApiCommand::PlayPause => { self.player.toggle_pause(); } + ApiCommand::Next => { self.play_next_bg(); } + ApiCommand::Prev => { self.play_prev_bg(); } + ApiCommand::VolumeUp => { self.player.volume_up(); } + ApiCommand::VolumeDown => { self.player.volume_down(); } + ApiCommand::VolumeSet(v) => { self.player.set_volume(v); } + ApiCommand::SeekForward => { self.player.seek_forward(); } + ApiCommand::SeekBackward => { self.player.seek_backward(); } + ApiCommand::ToggleShuffle => { + self.shuffle = !self.shuffle; + self.status_msg = if self.shuffle { "Shuffle: on".into() } else { "Shuffle: off".into() }; + } + ApiCommand::CycleRepeat => { + self.repeat = match self.repeat { + RepeatMode::All => RepeatMode::One, + RepeatMode::One => RepeatMode::Off, + RepeatMode::Off => RepeatMode::All, + }; + self.status_msg = format!("Repeat: {}", match self.repeat { + RepeatMode::All => "all", + RepeatMode::One => "one", + RepeatMode::Off => "off", + }); + } + ApiCommand::PlayTrack(api_track) => { + let track = Track { + id: api_track.id, + title: api_track.title.clone(), + duration: api_track.duration, + track_number: None, + artists: vec![Artist { id: 0, name: api_track.artist.clone() }], + album: Album { id: 0, title: api_track.album.clone() }, + audio_quality: None, + explicit: None, + }; + if !self.queue.iter().any(|t| t.id == track.id) { + self.queue.push(track.clone()); + } + let qi = self.queue.iter().position(|t| t.id == track.id).unwrap_or(0); + self.stream_track_bg(track, qi); + } + }, } } pub fn do_search_bg(&mut self) { if !self.authenticated { - self.status_msg = "Primero inicia sesión con 'L'".to_string(); + self.status_msg = self.lang.strings().status_login_required.to_string(); return; } if self.search_input.is_empty() { return; } self.loading = true; - self.status_msg = format!("Buscando \"{}\"...", self.search_input); + self.status_msg = self.lang.searching(&self.search_input.clone()); let tx = self.tx(); let query = self.search_input.clone(); let script = self.tidal.script_path.clone(); @@ -275,7 +372,7 @@ tokio::spawn(async move { pub fn play_selected_bg(&mut self) { if !self.authenticated { - self.status_msg = "Inicia sesión primero (L)".to_string(); + self.status_msg = self.lang.strings().status_login_required_short.to_string(); return; } let track = match self.active_tab { @@ -298,14 +395,39 @@ tokio::spawn(async move { pub fn play_next_bg(&mut self) { if self.queue.is_empty() { return; } - let next = match self.queue_index { - Some(i) if i + 1 < self.queue.len() => i + 1, - _ => 0, + let next = if self.repeat == RepeatMode::One { + self.queue_index.unwrap_or(0) + } else if self.shuffle { + self.random_queue_index() + } else { + match self.queue_index { + Some(i) if i + 1 < self.queue.len() => i + 1, + _ => match self.repeat { + RepeatMode::Off => return, + _ => 0, + } + } }; let track = self.queue[next].clone(); self.stream_track_bg(track, next); } + fn random_queue_index(&self) -> usize { + use std::time::{SystemTime, UNIX_EPOCH}; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as usize; + let seed = nanos ^ (self.player.elapsed.as_nanos() as usize); + if self.queue.len() <= 1 { return 0; } + let candidate = seed % self.queue.len(); + if Some(candidate) == self.queue_index { + (candidate + 1) % self.queue.len() + } else { + candidate + } + } + pub fn play_prev_bg(&mut self) { if self.queue.is_empty() { return; } let prev = match self.queue_index { @@ -321,7 +443,7 @@ tokio::spawn(async move { self.stream_generation += 1; let generation = self.stream_generation; self.loading = true; - self.status_msg = format!("⟳ Obteniendo stream: {}...", track.title); + self.status_msg = self.lang.loading_stream(&track.title.clone()); self.cover_image = None; self.cover_info = None; self.cover_proto = None; @@ -366,7 +488,7 @@ tokio::spawn(async move { pub fn start_login_bg(&mut self) { self.loading = true; - self.status_msg = "Iniciando login...".to_string(); + self.status_msg = self.lang.strings().status_starting_login.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -401,7 +523,7 @@ tokio::spawn(async move { pub fn load_library_bg(&mut self) { if !self.authenticated { return; } self.loading = true; - self.status_msg = "⟳ Cargando biblioteca...".to_string(); + self.status_msg = self.lang.strings().status_loading_lib.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -416,7 +538,7 @@ tokio::spawn(async move { pub fn load_playlist_tracks_bg(&mut self, uuid: String) { self.loading = true; - self.status_msg = "⟳ Cargando playlist...".to_string(); + self.status_msg = self.lang.strings().status_loading_playlist.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -432,7 +554,7 @@ tokio::spawn(async move { pub fn load_mix_tracks_bg(&mut self, mix_id: String) { self.loading = true; - self.status_msg = "⟳ Cargando mix...".to_string(); + self.status_msg = self.lang.strings().status_loading_mix.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -472,7 +594,7 @@ tokio::spawn(async move { pub fn set_quality(&mut self, q: Quality) { self.tidal.quality = q; - self.status_msg = format!("Calidad: {}", q.label()); + self.status_msg = self.lang.quality_changed(q.label()); } pub fn next_tab(&mut self) { @@ -509,27 +631,10 @@ tokio::spawn(async move { } } - pub async fn poll_auth(&mut self) -> bool { - if let Some(code) = self.device_code.clone() { - match self.tidal.poll_device_token(&code).await { - Ok(true) => { - self.authenticated = true; - self.device_code = None; - self.user_code = None; - self.auth_url = None; - self.status_msg = "✓ Autenticado con Tidal".to_string(); - return true; - } - Ok(false) => {} - Err(e) => { self.status_msg = format!("Error poll: {e}"); } - } - } - false - } pub fn load_fav_tracks_bg(&mut self) { if !self.authenticated { return; } self.loading = true; - self.status_msg = "⟳ Cargando canciones favoritas...".to_string(); + self.status_msg = self.lang.strings().status_loading_fav_tracks.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -546,7 +651,7 @@ tokio::spawn(async move { pub fn load_fav_albums_bg(&mut self) { if !self.authenticated { return; } self.loading = true; - self.status_msg = "⟳ Cargando álbumes favoritos...".to_string(); + self.status_msg = self.lang.strings().status_loading_fav_albums.to_string(); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; @@ -560,9 +665,46 @@ tokio::spawn(async move { }); } + pub fn api_status_snapshot(&self) -> ApiStatus { + use crate::player::PlayerState; + let state = match self.player.state { + PlayerState::Playing => "playing", + PlayerState::Paused => "paused", + PlayerState::Stopped => "stopped", + }.to_string(); + let (title, artist, album, duration, bit_depth, sample_rate, codec) = + self.player.current.as_ref().map(|ti| ( + Some(ti.title.clone()), + Some(ti.artist.clone()), + Some(ti.album.clone()), + Some(ti.duration), + Some(ti.bit_depth), + Some(ti.sample_rate), + Some(ti.codec.clone()), + )).unwrap_or_default(); + ApiStatus { + state, + title, + artist, + album, + duration, + elapsed: self.player.elapsed.as_secs(), + volume: self.player.volume, + progress: self.player.progress(), + bit_depth, + sample_rate, + codec, + shuffle: self.shuffle, + repeat: self.repeat.clone(), + authenticated: self.authenticated, + queue: self.queue.clone(), + queue_index: self.queue_index, + } + } + pub fn load_album_tracks_bg(&mut self, album_id: u64, album_title: String) { self.loading = true; - self.status_msg = format!("⟳ Cargando {}...", album_title); + self.status_msg = self.lang.loading_album(&album_title.clone()); let tx = self.tx(); let script = self.tidal.script_path.clone(); let quality = self.tidal.quality; diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..cc9e8a7 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,511 @@ +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Lang { + Es, + En, + De, + Ro, +} + +pub struct Strings { + // Tabs + pub tab_search: &'static str, + pub tab_queue: &'static str, + pub tab_now: &'static str, + pub tab_library: &'static str, + // Search + pub search_placeholder: &'static str, + pub search_results_title: &'static str, + // Queue + pub queue_title: &'static str, + // Now playing + pub now_playing_empty: &'static str, + pub now_playing_title: &'static str, + pub loading_image: &'static str, + // Track list + pub loading: &'static str, + pub not_authenticated: &'static str, + pub no_results_hint: &'static str, + pub col_title: &'static str, + pub col_artist: &'static str, + pub col_album: &'static str, + pub col_duration: &'static str, + // Player bar + pub player_stopped: &'static str, + // Hint bar + pub hint_play: &'static str, + pub hint_pause: &'static str, + pub hint_next_prev: &'static str, + pub hint_seek: &'static str, + pub hint_volume: &'static str, + pub hint_view: &'static str, + pub hint_quality: &'static str, + pub hint_quit: &'static str, + pub hint_library: &'static str, + pub hint_fav_tracks: &'static str, + pub hint_fav_albums: &'static str, + pub hint_lang: &'static str, + // Library + pub library_loading: &'static str, + pub library_hint: &'static str, + pub library_title: &'static str, + pub col_name: &'static str, + pub col_info: &'static str, + pub col_type: &'static str, + // Fav albums + pub fav_albums_empty: &'static str, + pub fav_albums_title: &'static str, + pub col_tracks: &'static str, + // Login overlay + pub login_title_text: &'static str, + pub login_open_url: &'static str, + pub login_code_prefix: &'static str, + pub login_waiting: &'static str, + pub login_overlay_title: &'static str, + // Status messages (static) + pub status_no_results: &'static str, + pub status_auth_done: &'static str, + pub status_login_required: &'static str, + pub status_login_required_short: &'static str, + pub status_starting_login: &'static str, + pub status_loading_lib: &'static str, + pub status_loading_playlist: &'static str, + pub status_loading_mix: &'static str, + pub status_loading_fav_tracks: &'static str, + pub status_loading_fav_albums: &'static str, + pub status_session_loading: &'static str, + pub status_session_active: &'static str, + pub status_press_l: &'static str, +} + +static ES: Strings = Strings { + tab_search: "Buscar", + tab_queue: "Cola", + tab_now: "Ahora", + tab_library: "Biblioteca", + search_placeholder: "Presiona / para buscar...", + search_results_title: "Resultados", + queue_title: "Cola de reproducción", + now_playing_empty: "Sin reproducción — presiona Enter en una canción", + now_playing_title: "◈ Ahora reproduciendo", + loading_image: "⟳ Cargando\n imagen...", + loading: " ⟳ Cargando...", + not_authenticated: " Presiona L para iniciar sesión en Tidal", + no_results_hint: " Sin resultados — busca con /", + col_title: " Título", + col_artist: "Artista", + col_album: "Álbum", + col_duration: "Dur.", + player_stopped: "Sin reproducción", + hint_play: "reproducir", + hint_pause: "pausa", + hint_next_prev: "sig/ant", + hint_seek: "seek", + hint_volume: "volumen", + hint_view: "vista", + hint_quality: "calidad", + hint_quit: "salir", + hint_library: "biblioteca", + hint_fav_tracks: "fav tracks", + hint_fav_albums: "fav álbumes", + hint_lang: "idioma", + library_loading: " ⟳ Cargando biblioteca...", + library_hint: " Presiona 'i' para cargar playlists y mixes", + library_title: "Biblioteca", + col_name: "Nombre", + col_info: "Info", + col_type: "Tipo", + fav_albums_empty: " Sin álbumes favoritos", + fav_albums_title: "Álbumes favoritos", + col_tracks: "Tracks", + login_title_text: " Inicia sesión en Tidal", + login_open_url: " 1. Abre este URL:", + login_code_prefix: " 2. Código: ", + login_waiting: " Esperando autorización...", + login_overlay_title: " ◈ Autenticación ", + status_no_results: "Sin resultados", + status_auth_done: "✓ Autenticado con Tidal", + status_login_required: "Primero inicia sesión con 'L'", + status_login_required_short: "Inicia sesión primero (L)", + status_starting_login: "Iniciando login...", + status_loading_lib: "⟳ Cargando biblioteca...", + status_loading_playlist: "⟳ Cargando playlist...", + status_loading_mix: "⟳ Cargando mix...", + status_loading_fav_tracks: "⟳ Cargando canciones favoritas...", + status_loading_fav_albums: "⟳ Cargando álbumes favoritos...", + status_session_loading: "Cargando sesión...", + status_session_active: "✓ Sesión activa", + status_press_l: "Presiona 'L' para iniciar sesión en Tidal", +}; + +static EN: Strings = Strings { + tab_search: "Search", + tab_queue: "Queue", + tab_now: "Now", + tab_library: "Library", + search_placeholder: "Press / to search...", + search_results_title: "Results", + queue_title: "Playback Queue", + now_playing_empty: "Nothing playing — press Enter on a track", + now_playing_title: "◈ Now Playing", + loading_image: "⟳ Loading\n image...", + loading: " ⟳ Loading...", + not_authenticated: " Press L to log in to Tidal", + no_results_hint: " No results — search with /", + col_title: " Title", + col_artist: "Artist", + col_album: "Album", + col_duration: "Dur.", + player_stopped: "Nothing playing", + hint_play: "play", + hint_pause: "pause", + hint_next_prev: "next/prev", + hint_seek: "seek", + hint_volume: "volume", + hint_view: "view", + hint_quality: "quality", + hint_quit: "quit", + hint_library: "library", + hint_fav_tracks: "fav tracks", + hint_fav_albums: "fav albums", + hint_lang: "language", + library_loading: " ⟳ Loading library...", + library_hint: " Press 'i' to load playlists and mixes", + library_title: "Library", + col_name: "Name", + col_info: "Info", + col_type: "Type", + fav_albums_empty: " No favorite albums", + fav_albums_title: "Favorite Albums", + col_tracks: "Tracks", + login_title_text: " Log in to Tidal", + login_open_url: " 1. Open this URL:", + login_code_prefix: " 2. Code: ", + login_waiting: " Waiting for authorization...", + login_overlay_title: " ◈ Authentication ", + status_no_results: "No results", + status_auth_done: "✓ Authenticated with Tidal", + status_login_required: "Log in first with 'L'", + status_login_required_short: "Log in first (L)", + status_starting_login: "Starting login...", + status_loading_lib: "⟳ Loading library...", + status_loading_playlist: "⟳ Loading playlist...", + status_loading_mix: "⟳ Loading mix...", + status_loading_fav_tracks: "⟳ Loading favorite tracks...", + status_loading_fav_albums: "⟳ Loading favorite albums...", + status_session_loading: "Loading session...", + status_session_active: "✓ Session active", + status_press_l: "Press 'L' to log in to Tidal", +}; + +static DE: Strings = Strings { + tab_search: "Suchen", + tab_queue: "Warteschl.", + tab_now: "Jetzt", + tab_library: "Bibliothek", + search_placeholder: "/ zum Suchen drücken...", + search_results_title: "Ergebnisse", + queue_title: "Wiedergabeliste", + now_playing_empty: "Keine Wiedergabe — Enter auf einem Titel drücken", + now_playing_title: "◈ Jetzt läuft", + loading_image: "⟳ Lädt\n Bild...", + loading: " ⟳ Lädt...", + not_authenticated: " L drücken um sich bei Tidal anzumelden", + no_results_hint: " Keine Ergebnisse — suche mit /", + col_title: " Titel", + col_artist: "Künstler", + col_album: "Album", + col_duration: "Dauer", + player_stopped: "Keine Wiedergabe", + hint_play: "abspielen", + hint_pause: "pause", + hint_next_prev: "vor/zurück", + hint_seek: "spulen", + hint_volume: "Lautstärke", + hint_view: "Ansicht", + hint_quality: "Qualität", + hint_quit: "beenden", + hint_library: "Bibliothek", + hint_fav_tracks: "Fav-Titel", + hint_fav_albums: "Fav-Alben", + hint_lang: "Sprache", + library_loading: " ⟳ Bibliothek wird geladen...", + library_hint: " 'i' drücken um Playlists und Mixes zu laden", + library_title: "Bibliothek", + col_name: "Name", + col_info: "Info", + col_type: "Typ", + fav_albums_empty: " Keine Lieblingsalben", + fav_albums_title: "Lieblingsalben", + col_tracks: "Titel", + login_title_text: " Bei Tidal anmelden", + login_open_url: " 1. URL öffnen:", + login_code_prefix: " 2. Code: ", + login_waiting: " Warte auf Autorisierung...", + login_overlay_title: " ◈ Authentifizierung ", + status_no_results: "Keine Ergebnisse", + status_auth_done: "✓ Bei Tidal authentifiziert", + status_login_required: "Zuerst mit 'L' anmelden", + status_login_required_short: "Zuerst anmelden (L)", + status_starting_login: "Anmeldung wird gestartet...", + status_loading_lib: "⟳ Bibliothek wird geladen...", + status_loading_playlist: "⟳ Playlist wird geladen...", + status_loading_mix: "⟳ Mix wird geladen...", + status_loading_fav_tracks: "⟳ Lieblingstitel werden geladen...", + status_loading_fav_albums: "⟳ Lieblingsalben werden geladen...", + status_session_loading: "Sitzung wird geladen...", + status_session_active: "✓ Sitzung aktiv", + status_press_l: "'L' drücken um sich bei Tidal anzumelden", +}; + +static RO: Strings = Strings { + tab_search: "Caută", + tab_queue: "Coadă", + tab_now: "Acum", + tab_library: "Bibliotecă", + search_placeholder: "Apasă / pentru a căuta...", + search_results_title: "Rezultate", + queue_title: "Coadă de redare", + now_playing_empty: "Nimic nu rulează — apasă Enter pe o piesă", + now_playing_title: "◈ Se redă acum", + loading_image: "⟳ Se încarcă\n imaginea...", + loading: " ⟳ Se încarcă...", + not_authenticated: " Apasă L pentru a te autentifica în Tidal", + no_results_hint: " Niciun rezultat — caută cu /", + col_title: " Titlu", + col_artist: "Artist", + col_album: "Album", + col_duration: "Dur.", + player_stopped: "Nimic nu rulează", + hint_play: "redă", + hint_pause: "pauză", + hint_next_prev: "urm/ant", + hint_seek: "avans", + hint_volume: "volum", + hint_view: "vedere", + hint_quality: "calitate", + hint_quit: "ieșire", + hint_library: "bibliotecă", + hint_fav_tracks: "piese fav", + hint_fav_albums: "albume fav", + hint_lang: "limbă", + library_loading: " ⟳ Se încarcă biblioteca...", + library_hint: " Apasă 'i' pentru a încărca playlisturi și mixuri", + library_title: "Bibliotecă", + col_name: "Nume", + col_info: "Info", + col_type: "Tip", + fav_albums_empty: " Niciun album favorit", + fav_albums_title: "Albume favorite", + col_tracks: "Piese", + login_title_text: " Autentifică-te în Tidal", + login_open_url: " 1. Deschide acest URL:", + login_code_prefix: " 2. Cod: ", + login_waiting: " Se așteaptă autorizarea...", + login_overlay_title: " ◈ Autentificare ", + status_no_results: "Niciun rezultat", + status_auth_done: "✓ Autentificat în Tidal", + status_login_required: "Autentifică-te mai întâi cu 'L'", + status_login_required_short: "Autentifică-te mai întâi (L)", + status_starting_login: "Se inițiază autentificarea...", + status_loading_lib: "⟳ Se încarcă biblioteca...", + status_loading_playlist: "⟳ Se încarcă playlistul...", + status_loading_mix: "⟳ Se încarcă mixul...", + status_loading_fav_tracks: "⟳ Se încarcă piesele favorite...", + status_loading_fav_albums: "⟳ Se încarcă albumele favorite...", + status_session_loading: "Se încarcă sesiunea...", + status_session_active: "✓ Sesiune activă", + status_press_l: "Apasă 'L' pentru a te autentifica în Tidal", +}; + +impl Lang { + pub fn strings(self) -> &'static Strings { + match self { + Lang::Es => &ES, + Lang::En => &EN, + Lang::De => &DE, + Lang::Ro => &RO, + } + } + + pub fn cycle(self) -> Self { + match self { + Lang::Es => Lang::En, + Lang::En => Lang::De, + Lang::De => Lang::Ro, + Lang::Ro => Lang::Es, + } + } + + pub fn label(self) -> &'static str { + match self { + Lang::Es => "ES", + Lang::En => "EN", + Lang::De => "DE", + Lang::Ro => "RO", + } + } + + // ── Dynamic string methods ──────────────────────────────────────────────── + + pub fn results_count(self, n: usize) -> String { + match self { + Lang::Es => format!("{n} resultados"), + Lang::En => format!("{n} results"), + Lang::De => format!("{n} Ergebnisse"), + Lang::Ro => format!("{n} rezultate"), + } + } + + pub fn search_error(self, e: &str) -> String { + match self { + Lang::Es => format!("✗ Error búsqueda: {e}"), + Lang::En => format!("✗ Search error: {e}"), + Lang::De => format!("✗ Suchfehler: {e}"), + Lang::Ro => format!("✗ Eroare căutare: {e}"), + } + } + + pub fn stream_error(self, e: &str) -> String { + match self { + Lang::Es => format!("✗ Error stream: {e}"), + Lang::En => format!("✗ Stream error: {e}"), + Lang::De => format!("✗ Stream-Fehler: {e}"), + Lang::Ro => format!("✗ Eroare stream: {e}"), + } + } + + pub fn searching(self, q: &str) -> String { + match self { + Lang::Es => format!("Buscando \"{q}\"..."), + Lang::En => format!("Searching \"{q}\"..."), + Lang::De => format!("Suche \"{q}\"..."), + Lang::Ro => format!("Se caută \"{q}\"..."), + } + } + + pub fn loading_stream(self, title: &str) -> String { + match self { + Lang::Es => format!("⟳ Obteniendo stream: {title}..."), + Lang::En => format!("⟳ Getting stream: {title}..."), + Lang::De => format!("⟳ Stream wird geladen: {title}..."), + Lang::Ro => format!("⟳ Se obține stream: {title}..."), + } + } + + pub fn browser_opened(self, code: &str) -> String { + match self { + Lang::Es => format!("Browser abierto. Código: {code}"), + Lang::En => format!("Browser opened. Code: {code}"), + Lang::De => format!("Browser geöffnet. Code: {code}"), + Lang::Ro => format!("Browser deschis. Cod: {code}"), + } + } + + pub fn browser_failed(self, e: &str, url: &str) -> String { + match self { + Lang::Es => format!("No se pudo abrir browser ({e}): {url}"), + Lang::En => format!("Could not open browser ({e}): {url}"), + Lang::De => format!("Browser konnte nicht geöffnet werden ({e}): {url}"), + Lang::Ro => format!("Nu s-a putut deschide browser-ul ({e}): {url}"), + } + } + + pub fn auth_error(self, e: &str) -> String { + match self { + Lang::Es => format!("✗ Error auth: {e}"), + Lang::En => format!("✗ Auth error: {e}"), + Lang::De => format!("✗ Auth-Fehler: {e}"), + Lang::Ro => format!("✗ Eroare autentificare: {e}"), + } + } + + pub fn library_loaded(self, playlists: usize, mixes: usize) -> String { + match self { + Lang::Es => format!("✓ {playlists} playlists, {mixes} mixes"), + Lang::En => format!("✓ {playlists} playlists, {mixes} mixes"), + Lang::De => format!("✓ {playlists} Playlists, {mixes} Mixes"), + Lang::Ro => format!("✓ {playlists} playlisturi, {mixes} mixuri"), + } + } + + pub fn tracks_loaded(self, n: usize) -> String { + match self { + Lang::Es => format!("✓ {n} tracks cargados"), + Lang::En => format!("✓ {n} tracks loaded"), + Lang::De => format!("✓ {n} Titel geladen"), + Lang::Ro => format!("✓ {n} piese încărcate"), + } + } + + pub fn fav_tracks_loaded(self, n: usize) -> String { + match self { + Lang::Es => format!("✓ {n} canciones favoritas en cola"), + Lang::En => format!("✓ {n} favorite tracks in queue"), + Lang::De => format!("✓ {n} Lieblingstitel in der Warteschlange"), + Lang::Ro => format!("✓ {n} piese favorite în coadă"), + } + } + + pub fn fav_albums_loaded(self, n: usize) -> String { + match self { + Lang::Es => format!("✓ {n} álbumes en colección"), + Lang::En => format!("✓ {n} albums in collection"), + Lang::De => format!("✓ {n} Alben in der Sammlung"), + Lang::Ro => format!("✓ {n} albume în colecție"), + } + } + + pub fn quality_changed(self, label: &str) -> String { + match self { + Lang::Es => format!("Calidad: {label}"), + Lang::En => format!("Quality: {label}"), + Lang::De => format!("Qualität: {label}"), + Lang::Ro => format!("Calitate: {label}"), + } + } + + pub fn loading_album(self, title: &str) -> String { + match self { + Lang::Es => format!("⟳ Cargando {title}..."), + Lang::En => format!("⟳ Loading {title}..."), + Lang::De => format!("⟳ {title} wird geladen..."), + Lang::Ro => format!("⟳ Se încarcă {title}..."), + } + } + + pub fn library_title_with_counts(self, playlists: usize, mixes: usize) -> String { + match self { + Lang::Es => format!(" Biblioteca ({playlists} playlists, {mixes} mixes) "), + Lang::En => format!(" Library ({playlists} playlists, {mixes} mixes) "), + Lang::De => format!(" Bibliothek ({playlists} Playlists, {mixes} Mixes) "), + Lang::Ro => format!(" Bibliotecă ({playlists} playlisturi, {mixes} mixuri) "), + } + } + + pub fn fav_albums_title_with_count(self, n: usize) -> String { + match self { + Lang::Es => format!(" ◆ Álbumes favoritos ({n}) — Enter para cargar "), + Lang::En => format!(" ◆ Favorite Albums ({n}) — Enter to load "), + Lang::De => format!(" ◆ Lieblingsalben ({n}) — Enter zum Laden "), + Lang::Ro => format!(" ◆ Albume favorite ({n}) — Enter pentru a încărca "), + } + } + + pub fn tracks_count(self, n: u32) -> String { + match self { + Lang::Es => format!("{n} tracks"), + Lang::En => format!("{n} tracks"), + Lang::De => format!("{n} Titel"), + Lang::Ro => format!("{n} piese"), + } + } + + pub fn lang_changed(self) -> String { + match self { + Lang::Es => "Idioma: Español".to_string(), + Lang::En => "Language: English".to_string(), + Lang::De => "Sprache: Deutsch".to_string(), + Lang::Ro => "Limbă: Română".to_string(), + } + } +} diff --git a/src/main.rs b/src/main.rs index 49f4c4f..704c278 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,19 @@ +mod i18n; mod tidal; mod ui; mod player; mod app; +mod api; use anyhow::Result; -use app::{App, AppEvent, InputMode, Tab}; +use app::{App, AppEvent, ApiStatus, InputMode, Tab}; use crossterm::{ event::{self, DisableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; -use std::{io, time::Duration}; +use std::{io, sync::{Arc, RwLock}, time::Duration}; use tokio::sync::mpsc; use tokio::time::interval; @@ -28,15 +30,27 @@ async fn main() -> Result<()> { let mut app = App::new(); app.picker = picker; - app.status_msg = "Cargando sesión...".to_string(); + app.status_msg = app.lang.strings().status_session_loading.to_string(); if app.tidal.load_session().await.is_ok() { - app.status_msg = "✓ Sesión activa".to_string(); + app.status_msg = app.lang.strings().status_session_active.to_string(); app.authenticated = true; } else { - app.status_msg = "Presiona 'L' para iniciar sesión en Tidal".to_string(); + app.status_msg = app.lang.strings().status_press_l.to_string(); } - let result = run_app(&mut terminal, &mut app).await; + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + app.event_tx = Some(event_tx.clone()); + + let api_status = Arc::new(RwLock::new(ApiStatus::default())); + tokio::spawn(api::start_server( + event_tx, + api_status.clone(), + app.tidal.script_path.clone(), + app.tidal.quality, + app.tidal.python_path.clone(), + )); + + let result = run_app(&mut terminal, &mut app, event_rx, api_status).await; disable_raw_mode()?; execute!( @@ -55,15 +69,13 @@ async fn main() -> Result<()> { async fn run_app( terminal: &mut Terminal, app: &mut App, + mut rx: mpsc::UnboundedReceiver, + api_status: Arc>, ) -> Result<()> { let mut ui_tick = interval(Duration::from_millis(50)); let mut auth_tick = interval(Duration::from_secs(5)); auth_tick.reset(); - // Canal para recibir resultados de operaciones async - let (tx, mut rx) = mpsc::unbounded_channel::(); - app.event_tx = Some(tx); - loop { terminal.draw(|f| ui::draw(f, app))?; @@ -75,6 +87,9 @@ async fn run_app( _ = ui_tick.tick() => { app.player.tick(); + if let Ok(mut s) = api_status.write() { + *s = app.api_status_snapshot(); + } // Avance automático al siguiente track if app.player.state == player::PlayerState::Stopped @@ -180,6 +195,7 @@ fn handle_normal(key: KeyCode, app: &mut App) { KeyCode::Char('1') => app.set_quality(tidal::Quality::HiResLossless), KeyCode::Char('2') => app.set_quality(tidal::Quality::Lossless), KeyCode::Char('3') => app.set_quality(tidal::Quality::High), + KeyCode::Char('`') => app.cycle_lang(), _ => {} } } diff --git a/src/player.rs b/src/player.rs index 8ee740e..210ee0a 100644 --- a/src/player.rs +++ b/src/player.rs @@ -54,15 +54,18 @@ impl Player { // Eliminar socket anterior si quedó huérfano let _ = std::fs::remove_file(SOCKET_PATH); + let mut mpv_args = vec![ + "--no-video".to_string(), + "--really-quiet".to_string(), + format!("--input-ipc-server={SOCKET_PATH}"), + format!("--volume={}", self.volume), + ]; + #[cfg(target_os = "linux")] + mpv_args.push("--audio-device=alsa/default".to_string()); + mpv_args.push(url.to_string()); + let child = Command::new("mpv") - .args([ - "--no-video", - "--really-quiet", - &format!("--input-ipc-server={SOCKET_PATH}"), // ← IPC socket - &format!("--volume={}", self.volume), - "--audio-device=alsa/default", - url, - ]) + .args(&mpv_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -120,6 +123,14 @@ impl Player { } } + pub fn set_volume(&mut self, v: u8) { + self.volume = v.min(100); + self.ipc_cmd(&format!( + r#"{{"command":["set_property","volume",{}]}}"#, + self.volume + )); + } + pub fn volume_up(&mut self) { self.volume = (self.volume + 5).min(100); self.ipc_cmd(&format!( @@ -191,4 +202,4 @@ impl Player { let s = self.elapsed.as_secs(); format!("{}:{:02}", s / 60, s % 60) } -} \ No newline at end of file +} diff --git a/src/tidal.rs b/src/tidal.rs index 8b3f8f2..ddb445f 100644 --- a/src/tidal.rs +++ b/src/tidal.rs @@ -1,7 +1,7 @@ /// tidal.rs — Llama a tidal.py como subproceso y parsea el JSON que devuelve. use anyhow::{anyhow, Result}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::io::BufRead; use std::process::{Command, Stdio}; @@ -33,19 +33,22 @@ impl Quality { // ─── Modelos ────────────────────────────────────────────────────────────────── -#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Artist { pub id: u64, pub name: String, } -#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Album { pub id: u64, pub title: String, } -#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Track { pub id: u64, pub title: String, @@ -79,7 +82,7 @@ impl Track { // Modelo para álbumes de la colección #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct FavAlbum { pub id: u64, @@ -99,7 +102,6 @@ impl FavAlbum { #[derive(Debug, Clone)] pub struct StreamInfo { pub url: String, - pub mime_type: String, pub bit_depth: u32, pub sample_rate: u32, pub codec: String, @@ -107,14 +109,11 @@ pub struct StreamInfo { #[derive(Debug, Clone)] pub struct CoverInfo { - pub url: String, - pub title: String, - pub artist: String, - pub album: String, + pub url: String, } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Playlist { pub uuid: String, @@ -129,7 +128,7 @@ pub struct Playlist { } #[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Mix { pub id: String, @@ -137,11 +136,6 @@ pub struct Mix { pub sub_title: Option, } -// Para deserializar tracks dentro de un mix (vienen envueltos en "item") -#[derive(Debug, Deserialize)] -struct MixItem { - item: Track, -} // ─── Respuestas internas ────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -164,17 +158,13 @@ struct StreamResp { codec: Option, bit_depth: Option, sample_rate: Option, - mime_type: Option, error: Option, } #[derive(Deserialize)] struct CoverResp { - url: Option, - title: Option, - artist: Option, - album: Option, - error: Option, + url: Option, + error: Option, } // ─── Cliente ────────────────────────────────────────────────────────────────── @@ -317,7 +307,6 @@ impl TidalClient { codec: resp.codec.unwrap_or_else(|| "flac".into()), bit_depth: resp.bit_depth.unwrap_or(16), sample_rate: resp.sample_rate.unwrap_or(44100), - mime_type: resp.mime_type.unwrap_or_else(|| "audio/flac".into()), }) } @@ -328,10 +317,7 @@ impl TidalClient { .map_err(|e| anyhow!("JSON error: {e}\noutput: {stdout}"))?; if let Some(e) = resp.error { return Err(anyhow!("{e}")); } Ok(CoverInfo { - url: resp.url.unwrap_or_default(), - title: resp.title.unwrap_or_default(), - artist: resp.artist.unwrap_or_default(), - album: resp.album.unwrap_or_default(), + url: resp.url.unwrap_or_default(), }) } pub async fn get_user_playlists(&self) -> Result> { diff --git a/src/ui.rs b/src/ui.rs index 60fdec9..c9a04dc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -55,7 +55,7 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { .constraints([ Constraint::Length(24), Constraint::Min(0), - Constraint::Length(22), + Constraint::Length(30), ]) .split(area); @@ -72,15 +72,16 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { cols[0], ); + let s = app.lang.strings(); f.render_widget( Paragraph::new(Line::from(vec![ - tab_span("Buscar", Tab::Search, &app.active_tab), + tab_span(s.tab_search, Tab::Search, &app.active_tab), Span::styled(" ", Style::default()), - tab_span("Cola", Tab::Queue, &app.active_tab), + tab_span(s.tab_queue, Tab::Queue, &app.active_tab), Span::styled(" ", Style::default()), - tab_span("Ahora", Tab::Now, &app.active_tab), + tab_span(s.tab_now, Tab::Now, &app.active_tab), Span::styled(" ", Style::default()), - tab_span("Biblioteca", Tab::Library, &app.active_tab), + tab_span(s.tab_library, Tab::Library, &app.active_tab), ])) .block(Block::default() .borders(Borders::BOTTOM) @@ -96,6 +97,7 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) { }; f.render_widget( Paragraph::new(Line::from(vec![ + Span::styled(format!("[{}] ", app.lang.label()), Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD)), auth_icon, Span::styled(app.tidal.quality.label(), Style::default().fg(GOLD).add_modifier(Modifier::BOLD)), ])) @@ -142,7 +144,7 @@ fn draw_search_tab(f: &mut Frame, app: &App, area: Rect) { Span::styled("▎", Style::default().fg(ACCENT).add_modifier(Modifier::SLOW_BLINK)) } else { Span::raw("") }, if app.search_input.is_empty() && !is_searching { - Span::styled("Presiona / para buscar...", Style::default().fg(DIM)) + Span::styled(app.lang.strings().search_placeholder, Style::default().fg(DIM)) } else { Span::raw("") }, ])) .block(Block::default() @@ -153,23 +155,24 @@ fn draw_search_tab(f: &mut Frame, app: &App, area: Rect) { chunks[0], ); - draw_track_list(f, app, chunks[1], &app.search_results.clone(), app.selected, "Resultados"); + draw_track_list(f, app, chunks[1], &app.search_results.clone(), app.selected, app.lang.strings().search_results_title); } fn draw_queue_tab(f: &mut Frame, app: &App, area: Rect) { - draw_track_list(f, app, area, &app.queue.clone(), app.selected, "Cola de reproducción"); + draw_track_list(f, app, area, &app.queue.clone(), app.selected, app.lang.strings().queue_title); } fn draw_now_tab(f: &mut Frame, app: &mut App, area: Rect) { + let s = app.lang.strings(); if app.player.current.is_none() { f.render_widget( - Paragraph::new("\n\n Sin reproducción — presiona Enter en una canción") + Paragraph::new(format!("\n\n {}", s.now_playing_empty)) .style(Style::default().fg(MUTED)) .block(Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) - .title(Span::styled(" ◈ Ahora reproduciendo ", Style::default().fg(MUTED)))), + .title(Span::styled(format!(" {} ", s.now_playing_title), Style::default().fg(MUTED)))), area, ); return; @@ -222,7 +225,7 @@ fn draw_now_tab(f: &mut Frame, app: &mut App, area: Rect) { f.render_stateful_widget(widget, img_inner, proto); } else { f.render_widget( - Paragraph::new("\n\n ⟳ Cargando\n imagen...") + Paragraph::new(format!("\n\n {}", app.lang.strings().loading_image)) .alignment(Alignment::Center) .style(Style::default().fg(MUTED)), img_inner, @@ -234,7 +237,7 @@ fn draw_now_tab(f: &mut Frame, app: &mut App, area: Rect) { .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) - .title(Span::styled(" ◈ Ahora reproduciendo ", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))) + .title(Span::styled(format!(" {} ", s.now_playing_title), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))) .style(Style::default().bg(BG2)); let info_inner = info_block.inner(cols[1]); f.render_widget(info_block, cols[1]); @@ -281,10 +284,11 @@ fn draw_track_list( selected: usize, title: &str, ) { + let s = app.lang.strings(); if tracks.is_empty() { - let msg = if app.loading { " ⟳ Cargando..." } - else if !app.authenticated { " Presiona L para iniciar sesión en Tidal" } - else { " Sin resultados — busca con /" }; + let msg = if app.loading { s.loading } + else if !app.authenticated { s.not_authenticated } + else { s.no_results_hint }; f.render_widget( Paragraph::new(msg) .style(Style::default().fg(MUTED)) @@ -337,11 +341,11 @@ fn draw_track_list( }).collect(); let header = Row::new(vec![ - Cell::from(Span::styled(" #", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled(" Título", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Artista", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Álbum", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Dur.", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(" #", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_title, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_artist, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_album, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_duration, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), ]).style(Style::default().bg(BG2)).height(1); let mut state = ratatui::widgets::TableState::default(); @@ -410,7 +414,7 @@ fn draw_player(f: &mut Frame, app: &App, area: Rect) { } else { Line::from(vec![ Span::styled(format!(" {state_icon} "), Style::default().fg(MUTED)), - Span::styled("Sin reproducción", Style::default().fg(DIM)), + Span::styled(app.lang.strings().player_stopped, Style::default().fg(DIM)), ]) }), inner[0], @@ -434,20 +438,21 @@ fn draw_player(f: &mut Frame, app: &App, area: Rect) { inner[1], ); + let s = app.lang.strings(); f.render_widget( Paragraph::new(Line::from(vec![ - hint_key("Enter", "reproducir"), - hint_key("Space", "pausa"), - hint_key("n/p", "sig/ant"), - hint_key("←/→", "seek"), - hint_key("+/-", "volumen"), - hint_key("Tab", "vista"), - hint_key("1/2/3", "calidad"), - hint_key("q", "salir"), - hint_key("i", "biblioteca"), - hint_key("F", "fav tracks"), - hint_key("A", "fav álbumes"), - hint_key("q", "salir"), + hint_key("Enter", s.hint_play), + hint_key("Space", s.hint_pause), + hint_key("n/p", s.hint_next_prev), + hint_key("←/→", s.hint_seek), + hint_key("+/-", s.hint_volume), + hint_key("Tab", s.hint_view), + hint_key("1/2/3", s.hint_quality), + hint_key("q", s.hint_quit), + hint_key("i", s.hint_library), + hint_key("F", s.hint_fav_tracks), + hint_key("A", s.hint_fav_albums), + hint_key("`", s.hint_lang), ])), inner[2], ); @@ -478,27 +483,28 @@ fn draw_login_overlay(f: &mut Frame, app: &App, area: Rect) { let user_code = app.user_code.as_deref().unwrap_or("..."); let auth_url = app.auth_url.as_deref().unwrap_or("..."); + let s = app.lang.strings(); f.render_widget( Paragraph::new(vec![ Line::from(""), - Line::from(Span::styled(" Inicia sesión en Tidal", Style::default().fg(TEXT).add_modifier(Modifier::BOLD))), + Line::from(Span::styled(s.login_title_text, Style::default().fg(TEXT).add_modifier(Modifier::BOLD))), Line::from(""), - Line::from(Span::styled(" 1. Abre este URL:", Style::default().fg(MUTED))), + Line::from(Span::styled(s.login_open_url, Style::default().fg(MUTED))), Line::from(Span::styled(format!(" {auth_url}"), Style::default().fg(ACCENT))), Line::from(""), Line::from(vec![ - Span::styled(" 2. Código: ", Style::default().fg(MUTED)), + Span::styled(s.login_code_prefix, Style::default().fg(MUTED)), Span::styled(user_code, Style::default().fg(GOLD).add_modifier(Modifier::BOLD)), ]), Line::from(""), - Line::from(Span::styled(" Esperando autorización...", Style::default().fg(DIM))), + Line::from(Span::styled(s.login_waiting, Style::default().fg(DIM))), ]) .block(Block::default() .borders(Borders::ALL) .border_type(BorderType::Double) .border_style(Style::default().fg(ACCENT)) - .title(Span::styled(" ◈ Autenticación ", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))) + .title(Span::styled(s.login_overlay_title, Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))) .style(Style::default().bg(BG2))) .wrap(Wrap { trim: false }), popup, @@ -512,20 +518,17 @@ fn draw_library_tab(f: &mut Frame, app: &App, area: Rect) { draw_fav_albums(f, app, area); return; } + let s = app.lang.strings(); let total = app.playlists.len() + app.mixes.len(); if total == 0 { f.render_widget( - Paragraph::new(if app.loading { - " ⟳ Cargando biblioteca..." - } else { - " Presiona 'i' para cargar playlists y mixes" - }) + Paragraph::new(if app.loading { s.library_loading } else { s.library_hint }) .style(Style::default().fg(MUTED)) .block(Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) - .title(Span::styled(" Biblioteca ", Style::default().fg(MUTED)))), + .title(Span::styled(format!(" {} ", s.library_title), Style::default().fg(MUTED)))), area, ); return; @@ -542,7 +545,7 @@ fn draw_library_tab(f: &mut Frame, app: &App, area: Rect) { .add_modifier(if is_sel { Modifier::BOLD } else { Modifier::empty() }), )), Cell::from(Span::styled( - format!("{} tracks", p.number_of_tracks), + app.lang.tracks_count(p.number_of_tracks), Style::default().fg(MUTED), )), Cell::from(Span::styled("Playlist", Style::default().fg(DIM))), @@ -571,9 +574,9 @@ fn draw_library_tab(f: &mut Frame, app: &App, area: Rect) { let header = Row::new(vec![ Cell::from(""), - Cell::from(Span::styled("Nombre", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Info", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Tipo", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_name, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_info, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_type, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), ]).style(Style::default().bg(BG2)); let mut state = ratatui::widgets::TableState::default(); @@ -592,7 +595,7 @@ fn draw_library_tab(f: &mut Frame, app: &App, area: Rect) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) .title(Span::styled( - format!(" Biblioteca ({} playlists, {} mixes) ", app.playlists.len(), app.mixes.len()), + app.lang.library_title_with_counts(app.playlists.len(), app.mixes.len()), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), ))) .row_highlight_style(Style::default().bg(BG3)), @@ -602,15 +605,16 @@ fn draw_library_tab(f: &mut Frame, app: &App, area: Rect) { } fn draw_fav_albums(f: &mut Frame, app: &App, area: Rect) { + let s = app.lang.strings(); if app.fav_albums.is_empty() { f.render_widget( - Paragraph::new(if app.loading { " ⟳ Cargando..." } else { " Sin álbumes favoritos" }) + Paragraph::new(if app.loading { s.loading } else { s.fav_albums_empty }) .style(Style::default().fg(MUTED)) .block(Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) - .title(Span::styled(" Álbumes favoritos ", Style::default().fg(MUTED)))), + .title(Span::styled(format!(" {} ", s.fav_albums_title), Style::default().fg(MUTED)))), area, ); return; @@ -628,7 +632,7 @@ fn draw_fav_albums(f: &mut Frame, app: &App, area: Rect) { )), Cell::from(Span::styled(truncate(&a.artist_names(), 28), Style::default().fg(MUTED))), Cell::from(Span::styled( - format!("{} tracks", a.number_of_tracks), + app.lang.tracks_count(a.number_of_tracks), Style::default().fg(DIM), )), ]) @@ -637,9 +641,9 @@ fn draw_fav_albums(f: &mut Frame, app: &App, area: Rect) { let header = Row::new(vec![ Cell::from(""), - Cell::from(Span::styled("Álbum", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Artista", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), - Cell::from(Span::styled("Tracks", Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_album, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_artist, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), + Cell::from(Span::styled(s.col_tracks, Style::default().fg(ACCENT2).add_modifier(Modifier::BOLD))), ]).style(Style::default().bg(BG2)); let mut state = ratatui::widgets::TableState::default(); @@ -658,7 +662,7 @@ fn draw_fav_albums(f: &mut Frame, app: &App, area: Rect) { .border_type(BorderType::Rounded) .border_style(Style::default().fg(DIM)) .title(Span::styled( - format!(" ◆ Álbumes favoritos ({}) — Enter para cargar ", app.fav_albums.len()), + app.lang.fav_albums_title_with_count(app.fav_albums.len()), Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), ))) .row_highlight_style(Style::default().bg(BG3)),