From 531c2b5d96ced35c57e55a9156bb5ec2ec3fa12c Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 001/183] cipher: stop rebuilding the extraction stack on every track Three interlocking causes made each track start refetch the 2.9 MB player.js and spawn a fresh hidden WebKit process: - the cipher and PoToken webviews were torn down 15 s after use, which any song outlives; keep them while media is loaded and release after 5 min idle - the player hash was re-read from iframe_api on every resolve because current_hash.txt was written but never read; pin it for the cache TTL - a WEB_REMIX HEAD 403, routine on capped videos, ran the full self-heal (delete player.js, drop the bridge and the PoToken session) each time; allow one heal per ten minutes Measured on a Ryoku desktop: six consecutive track changes with no fetch and no webview build, where before each one paid ~1.2 s and a WebProcess. --- src-tauri/src/cipher/fetcher.rs | 14 ++++++++++++-- src-tauri/src/lib.rs | 26 +++++++++++++++++--------- src-tauri/src/orchestrator.rs | 22 ++++++++++++++++++++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/cipher/fetcher.rs b/src-tauri/src/cipher/fetcher.rs index 7390bec..cce0d3d 100644 --- a/src-tauri/src/cipher/fetcher.rs +++ b/src-tauri/src/cipher/fetcher.rs @@ -53,7 +53,6 @@ impl PlayerJsFetcher { format!("https://www.youtube.com/s/player/{hash}/player_ias.vflset/en_GB/base.js"); let js = get(&url).await?.error_for_status()?.text().await?; std::fs::write(&cached, &js)?; - std::fs::write(self.cache_dir.join("current_hash.txt"), format!("{hash}\n{}", now_secs()))?; tracing::info!(hash, bytes = js.len(), "fetched fresh player.js"); Ok(PlayerJs { js, hash }) } @@ -74,9 +73,20 @@ impl PlayerJsFetcher { } } + /// The player hash, pinned on disk for the cache TTL so a listening session keeps one + /// player: YouTube hands different builds to consecutive `iframe_api` requests, and + /// following every answer meant a fresh 3 MB `base.js` and a webview rebuild per track. async fn current_hash(&self) -> Result { + let pin = self.cache_dir.join("current_hash.txt"); + if let Some(hash) = read_if_fresh(&pin).and_then(|s| s.lines().next().map(str::to_owned)) { + if !hash.is_empty() { + return Ok(hash); + } + } let body = get(IFRAME_API).await?.error_for_status()?.text().await?; - extract_hash(&body).ok_or(Error::NoHash) + let hash = extract_hash(&body).ok_or(Error::NoHash)?; + std::fs::write(&pin, format!("{hash}\n{}", now_secs()))?; + Ok(hash) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7286a44..d2b99f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -392,16 +392,22 @@ pub fn run() { } }); } - // Hidden authentication JS is burst work, not resident UI. Tear helpers down quickly - // after real work and keep only their Rust-side analysis/session state. + // The hidden cipher/PoToken webviews are burst workers, but every track start needs + // them: tearing them down 15 s after use meant a fresh WebKit process, a 3 MB JS + // injection and a full analysis per track. Keep them resident while media is + // loaded and only release them after a long idle with nothing playing. { let cipher = cipher.clone(); let potoken = potoken.clone(); + let st = app_state.clone(); tauri::async_runtime::spawn(async move { loop { - tokio::time::sleep(Duration::from_secs(10)).await; - cipher.teardown_if_idle(Duration::from_secs(15)).await; - potoken.teardown_if_idle(Duration::from_secs(15)).await; + tokio::time::sleep(Duration::from_secs(30)).await; + if st.player.has_loaded_media() { + continue; + } + cipher.teardown_if_idle(Duration::from_secs(300)).await; + potoken.teardown_if_idle(Duration::from_secs(300)).await; } }); } @@ -414,10 +420,12 @@ pub fn run() { main_window::enforce_floating_geometry(app.handle()); main_window::request_hyprland_float(app.handle()); // The main WebView starts hidden to avoid exposing an unpainted WebKit frame. - // Svelte normally reveals it immediately from `onMount`; this bounded native - // fallback guarantees that a lost readiness message can never leave a normal - // cold launch permanently tray-only. - main_window::arm_reveal_failsafe(app.handle(), Duration::from_millis(1500)); + // Svelte normally reveals it from `onMount`; this bounded native fallback only + // guarantees that a lost readiness message can never leave a cold launch + // permanently tray-only. A cold WebKitGTK + SvelteKit mount takes 1-3 s on a + // laptop, so the fallback sits well past that or it fires before the handshake + // and shows an unstyled frame. + main_window::arm_reveal_failsafe(app.handle(), Duration::from_millis(4000)); spawn_heap_trimmer(); } Ok(()) diff --git a/src-tauri/src/orchestrator.rs b/src-tauri/src/orchestrator.rs index fac1113..3c67f30 100644 --- a/src-tauri/src/orchestrator.rs +++ b/src-tauri/src/orchestrator.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use innertube::{ find_format, rustypipe_fallback, AudioQuality, Clients, Format, InnerTube, PlayerResponse, @@ -65,6 +65,8 @@ pub enum ResolveError { /// Client keys that need the `n`-transform applied to their stream URLs. stream selection. const NEEDS_N_TRANSFORM: [&str; 4] = ["WEB", "WEB_REMIX", "WEB_CREATOR", "TVHTML5"]; +/// Minimum spacing between two off-hot-path self-heals (`take_heal_slot`). +const HEAL_WINDOW: Duration = Duration::from_secs(10 * 60); // WEB_REMIX is validated with a HEAD like every other client — see `validate_head`. @@ -86,6 +88,11 @@ pub struct Orchestrator { /// (stream selection §2). Cleared when the cipher self-heals. `Arc` so the off-hot-path self-heal /// task can clear it. web_remix_failed: Arc>>, + /// When the last self-heal ran. A WEB_REMIX HEAD 403 is routine on capped videos (see the + /// validation comment in `resolve`), so healing on every one of them threw away player.js, + /// the cipher webview and the PoToken session per track. One heal per window is plenty to + /// catch a config that really went stale. + last_heal: Arc>>, } impl Orchestrator { @@ -101,6 +108,7 @@ impl Orchestrator { cipher, potoken, web_remix_failed: Arc::new(Mutex::new(HashSet::new())), + last_heal: Arc::new(Mutex::new(None)), } } @@ -110,6 +118,16 @@ impl Orchestrator { self.web_remix_failed.lock().await.insert(video_id.to_owned()); } + /// Claim the one self-heal allowed per `HEAL_WINDOW`; false while a recent heal is cooling. + async fn take_heal_slot(&self) -> bool { + let mut last = self.last_heal.lock().await; + if last.is_some_and(|t| t.elapsed() < HEAL_WINDOW) { + return false; + } + *last = Some(Instant::now()); + true + } + /// Resolve a videoId to a playable stream. stream selection full algorithm. pub async fn resolve( &self, @@ -284,7 +302,7 @@ impl Orchestrator { main_ping.clone().or_else(|| playback_ping(&resp, &key)), is_upload, )); - } else if needs_n { + } else if needs_n && self.take_heal_slot().await { // A cipher client that fails validation may have a stale config → self-heal off // the hot path so it never blocks falling through (stream selection §7). If the heal // changes the config table, clear the WEB_REMIX failure memory (stream selection §2). From a75d030472867f4df363a1b4be90f5edbfbdc31a Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 002/183] player: name the mpv stream after the track mpv titled the stream by its URL, so the PipeWire node carried the full signed googlevideo link. Pass a per-file force-media-title (Artist - Title) through loadfile's options, quoted for the command parser and length-prefixed for the option parser so any title round-trips verbatim. Includes a probe example that asserts the round trip. --- crates/player/examples/play.rs | 4 ++-- crates/player/examples/title.rs | 27 +++++++++++++++++++++++++++ crates/player/src/lib.rs | 24 ++++++++++++++++++++---- src-tauri/src/state.rs | 31 ++++++++++++++++++++++++------- 4 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 crates/player/examples/title.rs diff --git a/crates/player/examples/play.rs b/crates/player/examples/play.rs index 02b802d..0b2f0a9 100644 --- a/crates/player/examples/play.rs +++ b/crates/player/examples/play.rs @@ -17,8 +17,8 @@ async fn main() { let mut p = Player::new(cache.to_str().unwrap()).expect("player"); let mut events = p.take_events().unwrap(); - p.load(&a, &HashMap::new(), None).expect("load A"); - p.enqueue(&b).expect("enqueue B"); + p.load(&a, &HashMap::new(), None, "A").expect("load A"); + p.enqueue(&b, "B").expect("enqueue B"); p.play().expect("play"); let mut ended = 0; diff --git a/crates/player/examples/title.rs b/crates/player/examples/title.rs new file mode 100644 index 0000000..991f24f --- /dev/null +++ b/crates/player/examples/title.rs @@ -0,0 +1,27 @@ +//! Media-title probe: `cargo run -p player --example title -- [title]` +//! Loads a file with an awkward per-file title (spaces, commas, quotes, non-ASCII) and asserts +//! that mpv reports it back verbatim. + +use std::collections::HashMap; + +use player::Player; + +fn main() { + let a = std::env::args().nth(1).expect("usage: cargo run -p player --example title -- "); + let cache = std::env::temp_dir().join("ryotunes-player-example"); + std::fs::create_dir_all(&cache).ok(); + let p = Player::new(cache.to_str().unwrap()).expect("player"); + let title = std::env::args() + .nth(2) + .unwrap_or_else(|| "Ünïcode Artist, \"quoted\" – A Title".to_owned()); + p.load(&a, &HashMap::new(), None, &title).expect("load"); + let mut got = String::new(); + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(100)); + got = p.media_title().unwrap_or_default(); + if !got.is_empty() { break; } + } + println!("want: {title}"); + println!("got: {got}"); + assert_eq!(got, title); +} diff --git a/crates/player/src/lib.rs b/crates/player/src/lib.rs index 52dde9a..bb88bf3 100644 --- a/crates/player/src/lib.rs +++ b/crates/player/src/lib.rs @@ -122,16 +122,19 @@ impl Player { self.events.take() } - /// Load and play a fresh URL, replacing the playlist. + /// Load and play a fresh URL, replacing the playlist. `title` names the stream for mpv + /// (and so for the PipeWire node); without it mpv uses the URL, which for a signed + /// googlevideo link puts the whole token into the audio graph. pub fn load( &self, url: &str, headers: &HashMap, gain_db: Option, + title: &str, ) -> Result<(), Error> { self.apply_headers(headers)?; self.set_gain(gain_db)?; - self.mpv.command("loadfile", &["ed(url), "replace"])?; + self.mpv.command("loadfile", &["ed(url), "replace", "-1", &title_option(title)])?; // Close→pause can happen before mpv's property notification reaches the event thread. // Mark the session loaded immediately so background lifecycle can never mistake that tiny // transition window for an empty player. The observed property corrects this on failure/EOF. @@ -144,8 +147,8 @@ impl Player { /// Note: mpv's `http-header-fields`/`user-agent` are global properties, so appended tracks /// inherit the currently-set headers. Ordinary direct-URL streams do not require per-track /// cookies; upload-specific headers are handled before a track is loaded. - pub fn enqueue(&self, url: &str) -> Result<(), Error> { - self.mpv.command("loadfile", &["ed(url), "append"])?; + pub fn enqueue(&self, url: &str, title: &str) -> Result<(), Error> { + self.mpv.command("loadfile", &["ed(url), "append", "-1", &title_option(title)])?; Ok(()) } @@ -162,6 +165,11 @@ impl Player { self.mpv.get_property::("idle-active").unwrap_or(true) } + /// The title mpv reports for the current file (the per-file `force-media-title` once set). + pub fn media_title(&self) -> Option { + self.mpv.get_property::("media-title").ok() + } + /// Cheap event-driven loaded-media state for background lifecycle decisions. Unlike /// [`Self::is_idle`], this never takes mpv's core lock and therefore cannot turn a transient /// property-query failure during Pause into a false "nothing loaded" result. @@ -414,6 +422,14 @@ fn quoted(arg: &str) -> String { format!("\"{}\"", arg.replace('\\', "\\\\").replace('"', "\\\"")) } +/// Per-file `force-media-title` for `loadfile`'s options argument. Two parsers see it: the +/// command-string parser (libmpv2 joins the args into one line) needs the token quoted, and the +/// comma-separated option list inside needs the value length-prefixed (`%len%...`), which takes +/// any bytes verbatim. A per-file option also leaves the gapless lookahead's title untouched. +fn title_option(title: &str) -> String { + quoted(&format!("force-media-title=%{}%{}", title.len(), title)) +} + /// Slider percent → mpv `volume` value, over a 60 dB range. mpv applies gain = (v/100)³, /// i.e. 60·log10(v/100) dB, so v = 100·10^(−(1−s/100)^1.5) yields −60·(1−s/100)^1.5 dB: /// 50% is −21 dB, 25% is −39 dB, 1% is −59 dB. 0 stays a hard mute. diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f921a47..e89d67b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1504,9 +1504,12 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return false; // user moved on } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&item.title, &item.artists), + ) { self.emit_error(&item.video_id, &e.to_string()); return false; } @@ -1664,7 +1667,8 @@ impl AppState { } // Headers are global in mpv; the direct-URL clients need none beyond UA, which the // current track already set. Just append the URL. - if let Err(e) = self.player.enqueue(&data.stream_url) { + let title = q.items.get(next_idx).map(|i| media_title(&i.title, &i.artists)).unwrap_or_default(); + if let Err(e) = self.player.enqueue(&data.stream_url, &title) { tracing::warn!(error = %e, "enqueue lookahead failed"); return; } @@ -2434,9 +2438,12 @@ impl AppState { if self.generation.load(Ordering::SeqCst) != gen { return; // superseded by a newer sync } - if let Err(e) = - self.player.load(&data.stream_url, &data.headers, loudness_gain(data.loudness_db)) - { + if let Err(e) = self.player.load( + &data.stream_url, + &data.headers, + loudness_gain(data.loudness_db), + &media_title(&track.title, &track.artist), + ) { self.emit_error(&track.id, &e.to_string()); return; } @@ -3381,6 +3388,16 @@ fn loudness_gain(loudness_db: Option) -> Option { (gain < -0.05).then(|| gain.max(-24.0)) } +/// "Artist – Title" for mpv's per-file media title (what PipeWire and mpv's own metadata show). +fn media_title(title: &str, artists: &str) -> String { + match (title.trim(), artists.trim()) { + ("", "") => "Ryotunes".to_owned(), + (t, "") => t.to_owned(), + ("", a) => a.to_owned(), + (t, a) => format!("{a} – {t}"), + } +} + /// Loudness target, matching YouTube Music's own player rather than the video site's -14. const TARGET_LUFS: f64 = -7.0; From 079755d3cafe76db61a2f80b6d3115c81d99c1d7 Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 003/183] startup: bound the palette wait before the reveal handshake The reveal was gated on the Ryoku token IPC and retried for ~1.85 s against a 1.5 s native failsafe, so a cold WebKit mount was routinely revealed natively and unstyled. Race the palette against 250 ms; the native failsafe moved to 4 s in the previous commit's lib.rs change. --- ui/src/routes/+layout.svelte | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 22e9254..2c6a273 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -111,9 +111,14 @@ // `visible:false`, which deadlocks the only callback capable of revealing that window. // Start the bridge handshake immediately; retries use ordinary timers, which continue // to run for a hidden WebView. Native code also owns a bounded reveal failsafe. + // Palette-before-reveal is worth a short wait, not the whole IPC round-trip: gating on + // the token invoke alone routinely outran the native 1.5 s failsafe on a cold WebKit + // process, so the window was revealed natively, unhandshaken. Bound the wait. const begin = () => { if (!cancelled) void attempt(0); }; - if (beforeReveal) void beforeReveal.then(begin, begin); - else begin(); + if (beforeReveal) { + const bounded = Promise.race([beforeReveal, new Promise((r) => setTimeout(r, 250))]); + void bounded.then(begin, begin); + } else begin(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; } From 7f9feedfa406516a4b4c712448af124592557f9c Mon Sep 17 00:00:00 2001 From: carlos Date: Wed, 2 Sep 2026 23:09:17 -0400 Subject: [PATCH 004/183] build: default the custom-protocol feature Tauri sets cfg(dev) whenever custom-protocol is absent, so the source PKGBUILD's plain cargo build produced a binary that opened build.devUrl. Default the feature on; cargo tauri dev still passes --no-default-features. --- src-tauri/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6aeb4bf..75c7a81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,13 @@ rust-version = "1.80" name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] +# Tauri's `dev` cfg is the absence of `custom-protocol`: without it a release binary still +# points its window at `build.devUrl`. Default it on so `cargo build --release` (the source +# PKGBUILD) ships the bundled frontend; `cargo tauri dev` passes --no-default-features. +[features] +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] + [build-dependencies] tauri-build = { version = "2", features = [] } From 31bcfda593a74be6e7f0288642ac414f0d9605ab Mon Sep 17 00:00:00 2001 From: 77Darius77 <98291816+77Darius77@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:28:55 +0200 Subject: [PATCH 005/183] fix: derive InnerTube locale from the environment Locale::default() is hard-coded to US/en, so every InnerTube request announces English even when the system language and the signed-in account are French; the API's hl/gl context wins over account preferences and the UI never switches. Add Locale::from_env(), which reads LC_ALL/LANG, parses lang_TERRITORY and maps the compound tags YouTube localises (zh-CN/TW/HK, pt-BR/PT, es-419/es-US, en-GB, fr-CA), falling back to US/en when nothing parseable is set. The session bootstrap now uses it instead of Locale::default(). --- crates/innertube/src/models/context.rs | 36 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/innertube/src/models/context.rs b/crates/innertube/src/models/context.rs index 620a193..f54ea84 100644 --- a/crates/innertube/src/models/context.rs +++ b/crates/innertube/src/models/context.rs @@ -17,6 +17,42 @@ impl Default for Locale { } } +impl Locale { + /// Derive a `Locale` from the process environment (`LC_ALL`/`LANG`), so the + /// YouTube API answers in the user's language instead of always US/en. + /// Falls back to [`Locale::default`] when nothing parseable is set. + pub fn from_env() -> Self { + let raw = std::env::var("LC_ALL").or_else(|_| std::env::var("LANG")).unwrap_or_default(); + Self::from_lang(&raw) + } + + fn from_lang(raw: &str) -> Self { + // "fr_FR.UTF-8" -> ("fr", "FR"); "", "C" or "POSIX" -> default. + let base = raw.split('.').next().unwrap_or(""); + let mut parts = base.split('_'); + let lang = parts.next().unwrap_or("").to_ascii_lowercase(); + if lang.is_empty() || lang == "c" || lang == "posix" { + return Locale::default(); + } + let terr = parts.next().unwrap_or("").to_ascii_uppercase(); + let hl = match (lang.as_str(), terr.as_str()) { + // Language+territory pairs YouTube localises as a compound tag. + ("zh", "CN") => "zh-CN".into(), + ("zh", "TW") => "zh-TW".into(), + ("zh", "HK") => "zh-HK".into(), + ("pt", "BR") => "pt-BR".into(), + ("pt", "PT") => "pt-PT".into(), + ("es", "US") => "es-US".into(), + ("es", "ES") => "es".into(), + ("es", _) => "es-419".into(), + ("en", "GB") => "en-GB".into(), + ("fr", "CA") => "fr-CA".into(), + _ => lang, + }; + Locale { gl: if terr.is_empty() { "US".into() } else { terr }, hl } + } +} + // The three load-bearing JSON flags (write API) are realized structurally here: // - ignoreUnknownKeys → serde ignores unknown fields on Deserialize by default. // - explicitNulls = false → `skip_serializing_if = "Option::is_none"` on every Option. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d2b99f8..19a7f61 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -247,7 +247,7 @@ pub fn run() { tracing::info!("loaded persisted login session"); } - let session = Session { locale: Locale::default(), visitor_data, data_sync_id, cookie }; + let session = Session { locale: Locale::from_env(), visitor_data, data_sync_id, cookie }; let it = match InnerTube::new(session.clone(), proxy.as_deref()) { Ok(it) => it, Err(error) => { From 74c77d678daf98ad49809da407b9d9f2597563c5 Mon Sep 17 00:00:00 2001 From: 77Darius77 <98291816+77Darius77@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:50:48 +0200 Subject: [PATCH 006/183] i18n: translate the hard-coded UI chrome into French InnerTube content is now localised by the backend locale (hl/gl), but the app-owned chrome (context menus, settings, dialogs, status text) is hard-coded English in the Svelte UI. Add a small reactive i18n module (t() with a fr dictionary keyed by the exact English source string, locale detected from navigator.language) and wrap every static UI string in it. Unknown keys fall back to English, so missing translations degrade gracefully. Covers 36 components/routes (~490 strings), including ternary display branches and aria labels; dynamic API-driven text is untouched. --- ui/src/lib/components/AccountMenu.svelte | 19 +- ui/src/lib/components/AddToPlaylist.svelte | 15 +- ui/src/lib/components/ChannelPicker.svelte | 19 +- ui/src/lib/components/CommandPalette.svelte | 15 +- .../lib/components/EditPlaylistDialog.svelte | 40 +- ui/src/lib/components/ErrorState.svelte | 3 +- ui/src/lib/components/HomeHero.svelte | 13 +- ui/src/lib/components/LibrarySongs.svelte | 21 +- ui/src/lib/components/LinkDialog.svelte | 17 +- ui/src/lib/components/ListenTogether.svelte | 87 ++-- .../lib/components/ListeningInsights.svelte | 33 +- ui/src/lib/components/LocalMusic.svelte | 32 +- ui/src/lib/components/LyricsView.svelte | 23 +- ui/src/lib/components/MiniPlayer.svelte | 49 +- ui/src/lib/components/NowPlaying.svelte | 13 +- ui/src/lib/components/PlayerBar.svelte | 35 +- ui/src/lib/components/PlaylistMenu.svelte | 21 +- ui/src/lib/components/QueuePanel.svelte | 5 +- ui/src/lib/components/SavedInPlaylists.svelte | 9 +- ui/src/lib/components/SearchSuggest.svelte | 21 +- ui/src/lib/components/SettingsDialog.svelte | 168 +++--- ui/src/lib/components/ShareDialog.svelte | 21 +- ui/src/lib/components/Sidebar.svelte | 39 +- ui/src/lib/components/TrackFilter.svelte | 5 +- ui/src/lib/components/TrackMenu.svelte | 29 +- ui/src/lib/i18n.svelte.ts | 24 + ui/src/lib/i18n/fr.json | 491 ++++++++++++++++++ ui/src/routes/+error.svelte | 9 +- ui/src/routes/+layout.svelte | 3 +- ui/src/routes/+page.svelte | 15 +- ui/src/routes/album/[id]/+page.svelte | 41 +- ui/src/routes/artist/[id]/+page.svelte | 31 +- ui/src/routes/library/+page.svelte | 80 ++- ui/src/routes/list/+page.svelte | 5 +- ui/src/routes/playlist/[id]/+page.svelte | 63 +-- ui/src/routes/radio/+page.svelte | 62 ++- ui/src/routes/search-more/+page.svelte | 9 +- ui/src/routes/search/+page.svelte | 49 +- 38 files changed, 1084 insertions(+), 550 deletions(-) create mode 100644 ui/src/lib/i18n.svelte.ts create mode 100644 ui/src/lib/i18n/fr.json diff --git a/ui/src/lib/components/AccountMenu.svelte b/ui/src/lib/components/AccountMenu.svelte index f4afc89..5435110 100644 --- a/ui/src/lib/components/AccountMenu.svelte +++ b/ui/src/lib/components/AccountMenu.svelte @@ -8,6 +8,7 @@ import * as api from '$lib/api'; import { auth, openChannelPicker } from '$lib/player.svelte'; import { thumb } from '$lib/thumb'; + import { t } from '$lib/i18n.svelte'; import { anchorMenu, fitMenu, NO_ANCHOR, toBody } from '$lib/menu'; let menuOpen = $state(false); @@ -39,7 +40,7 @@
{#if auth.account?.signedIn}
-
{auth.account.name ?? 'Account'}
+
{auth.account.name ?? t('Account')}
{#if auth.account.handle || auth.account.email}
{auth.account.handle ?? auth.account.email} @@ -90,18 +91,18 @@ {:else} -

Sign in

+

{t('Sign in')}

- Sign in with your Google account to reach your YouTube Music library and playlists. + {t('Sign in with your Google account to reach your YouTube Music library and playlists.')}

- + {/if}
{/if} diff --git a/ui/src/lib/components/AddToPlaylist.svelte b/ui/src/lib/components/AddToPlaylist.svelte index af7be5b..56cab9c 100644 --- a/ui/src/lib/components/AddToPlaylist.svelte +++ b/ui/src/lib/components/AddToPlaylist.svelte @@ -13,6 +13,7 @@ notePlaylistAdd, noteSavedIn } from '$lib/player.svelte'; + import { t } from '$lib/i18n.svelte'; let playlists = $state([]); let loading = $state(false); @@ -117,11 +118,11 @@ class="ryo-overlay-sheet w-full max-w-sm p-4" >
-

Add to playlist

+

{t('Add to playlist')}

@@ -139,14 +140,14 @@ class="min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/40" bind:value={newTitle} maxlength="150" - placeholder="Playlist name" + placeholder={t('Playlist name')} /> {:else} @@ -154,12 +155,12 @@ class="w-full rounded-md border border-border px-3 py-2 text-left text-sm font-medium hover:bg-accent/10" onclick={() => (showCreate = true)} > - + New playlist + {t('+ New playlist')} {/if}
{#if loading} -

Loading…

+

{t('Loading…')}

{:else if playlists.length}
{#each playlists as pl (pl.id)} @@ -183,7 +184,7 @@
{:else}

- No playlists yet. Create one above and these songs will be added immediately. + {t('No playlists yet. Create one above and these songs will be added immediately.')}

{/if}
diff --git a/ui/src/lib/components/ChannelPicker.svelte b/ui/src/lib/components/ChannelPicker.svelte index 368d89f..462924e 100644 --- a/ui/src/lib/components/ChannelPicker.svelte +++ b/ui/src/lib/components/ChannelPicker.svelte @@ -6,6 +6,7 @@ import * as api from '$lib/api'; import type { AccountIdentity } from '$lib/api'; import { toast, ui } from '$lib/player.svelte'; + import { t } from '$lib/i18n.svelte'; import { thumb } from '$lib/thumb'; let loading = $state(false); @@ -47,7 +48,7 @@ ui.channelPickerRequired = false; ui.channelPickerOpen = false; ui.channelIdentities = []; - toast.success(wasRequired ? `Signed in as ${identity.name}` : `Switched to ${identity.name}`); + toast.success(wasRequired ? `${t('Signed in as')} ${identity.name}` : `${t('Switched to')} ${identity.name}`); } catch (e) { error = String(e); toast.error(error); @@ -83,23 +84,23 @@ interactOutsideBehavior={dismissable} >
- Choose a YouTube channel + {t('Choose a YouTube channel')} - Library, likes and playlists will use this channel. You can switch again later. + {t('Library, likes and playlists will use this channel. You can switch again later.')}
{#if loading} -

Loading channels…

+

{t('Loading channels…')}

{:else if error}

{error}

- +
{:else if ui.channelIdentities.length === 0}

- YouTube did not return any selectable channels. + {t('YouTube did not return any selectable channels.')}

{:else} {#each ui.channelIdentities as identity (identity.selectionKey)} @@ -131,7 +132,7 @@ {#if identity.selected} - Selected + {t('Selected')} {/if} @@ -142,11 +143,11 @@
{#if ui.channelPickerRequired} {:else} {/if}
diff --git a/ui/src/lib/components/CommandPalette.svelte b/ui/src/lib/components/CommandPalette.svelte index f4a1a33..ef879e3 100644 --- a/ui/src/lib/components/CommandPalette.svelte +++ b/ui/src/lib/components/CommandPalette.svelte @@ -9,6 +9,7 @@ import { goto } from '$app/navigation'; import { HugeiconsIcon } from '@hugeicons/svelte'; import { Search01Icon, MusicNote01Icon, UserIcon } from '@hugeicons/core-free-icons'; + import { t } from '$lib/i18n.svelte'; import * as Command from '$lib/components/ui/command/index.js'; import { Skeleton } from '$lib/components/ui/skeleton'; import ExplicitIcon from './ExplicitIcon.svelte'; @@ -18,7 +19,7 @@ import { thumb } from '$lib/thumb'; import { warmStream } from '$lib/warm-stream'; - const KIND = { song: 'Song', album: 'Album', artist: 'Artist', playlist: 'Playlist' }; + const KIND = { song: t('Song'), album: t('Album'), artist: t('Artist'), playlist: t('Playlist') }; let query = $state(''); let items = $state([]); @@ -81,11 +82,11 @@ shouldFilter={false} vimBindings={false} loop - title="Search" - description="Search songs, albums, artists and playlists" + title={t('Search')} + description={t('Search songs, albums, artists and playlists')} class="ryo-command-palette sm:max-w-xl" > - + {#if loading} {#each Array(4) as _, i (i)} @@ -99,10 +100,10 @@ {/each} {:else if !items.length}
- {query.trim().length < 2 ? 'Type to search.' : 'Nothing quick for that.'} + {t(query.trim().length < 2 ? 'Type to search.' : 'Nothing quick for that.')}
{:else} - + {#each items as item (item.id)} choose(item)} onmouseenter={() => item.kind === 'song' && warmStream(item.id, !!item.isUpload)} class="gap-3 px-2 py-1.5"> {#if item.thumbnail} @@ -147,7 +148,7 @@ - All results for “{query.trim()}” + {t('All results for')} “{query.trim()}” {/if} diff --git a/ui/src/lib/components/EditPlaylistDialog.svelte b/ui/src/lib/components/EditPlaylistDialog.svelte index e0262ed..af5429a 100644 --- a/ui/src/lib/components/EditPlaylistDialog.svelte +++ b/ui/src/lib/components/EditPlaylistDialog.svelte @@ -13,6 +13,7 @@ import * as api from '$lib/api'; import { thumb } from '$lib/thumb'; import { toast } from '$lib/player.svelte'; + import { t } from '$lib/i18n.svelte'; /** What the page shows while YouTube catches up, and what it puts back if the write fails. */ type Edit = { title?: string; description?: string; privacy?: string; cover?: string }; @@ -109,7 +110,7 @@ open = false; try { await api.editPlaylistDetails(id, changes); - toast.success('Playlist updated'); + toast.success(t('Playlist updated')); } catch (e) { onchange(before); toast.error(String(e)); @@ -122,11 +123,11 @@ - Edit playlist + {t('Edit playlist')} - {local + {t(local ? 'Change the name or artwork stored for this playlist on this device.' - : 'Change how this playlist looks and who can see it.'} + : 'Change how this playlist looks and who can see it.')}
{#if preview} @@ -155,7 +156,7 @@ : 'opacity-100'}" > - Choose image + {t('Choose image')} {#if cover} @@ -168,24 +169,23 @@ disabled={removing} > - {removing ? 'Removing…' : 'Remove'} + {t(removing ? 'Removing…' : 'Remove')} {/if}
- + {#if !local} {:else}

- Device playlists stay available without a Google account. Their songs, name and artwork - are kept locally and are never uploaded automatically. + {t('Device playlists stay available without a Google account. Their songs, name and artwork are kept locally and are never uploaded automatically.')}

{/if}
@@ -193,25 +193,25 @@ {#if !local}
-
Public
+
{t('Public')}

- {isPublic + {t(isPublic ? 'Anyone can find this playlist on YouTube Music.' - : 'Only you can see this playlist.'} + : 'Only you can see this playlist.')}

- +
{/if}

- {local + {t(local ? 'Artwork is copied into Ryotunes storage on this device. Square JPEG or PNG works best.' - : 'Artwork applies here at once and uploads to YouTube Music in the background. Square JPEG or PNG works best.'} + : 'Artwork applies here at once and uploads to YouTube Music in the background. Square JPEG or PNG works best.')}

- + diff --git a/ui/src/lib/components/ErrorState.svelte b/ui/src/lib/components/ErrorState.svelte index 594f4c2..fca0316 100644 --- a/ui/src/lib/components/ErrorState.svelte +++ b/ui/src/lib/components/ErrorState.svelte @@ -2,6 +2,7 @@ import { HugeiconsIcon } from '@hugeicons/svelte'; import { RefreshIcon } from '@hugeicons/core-free-icons'; import { Button } from '$lib/components/ui/button'; + import { t } from '$lib/i18n.svelte'; let { message, onRetry }: { message: string; onRetry: () => void } = $props(); @@ -10,6 +11,6 @@

{message}

diff --git a/ui/src/lib/components/HomeHero.svelte b/ui/src/lib/components/HomeHero.svelte index acff6ce..94c9764 100644 --- a/ui/src/lib/components/HomeHero.svelte +++ b/ui/src/lib/components/HomeHero.svelte @@ -6,9 +6,10 @@ import SearchSuggest from '$lib/components/SearchSuggest.svelte'; import RyokuMusicDeck from '$lib/components/RyokuMusicDeck.svelte'; import { auth } from '$lib/player.svelte'; + import { t } from '$lib/i18n.svelte'; const hour = new Date().getHours(); - const daypart = hour < 5 ? 'Good night' : hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'; + const daypart = hour < 5 ? t('Good night') : hour < 12 ? t('Good morning') : hour < 18 ? t('Good afternoon') : t('Good evening'); let searchQuery = $state(''); function accountFirstName(name?: string | null) { const raw = name?.trim().split(/\s+/)[0] ?? ''; @@ -32,13 +33,13 @@
力 - HOME / LISTEN + {t('HOME / LISTEN')} 01

{daypart}{firstName ? `, ${firstName}` : ''}

-

Pick up where you left off, or find the next thing worth hearing.

+

{t('Pick up where you left off, or find the next thing worth hearing.')}