From 842683444d5788347625e839fd32882918434236 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 14 Oct 2026 15:24:24 -0400 Subject: [PATCH 1/2] feat(playlists): add read-only Spotify shadow playlists --- ARCHITECTURE.md | 4 +- Cargo.lock | 2 + apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/build.rs | 4 + .../discover_shadow_playlists.toml | 11 + .../refresh_shadow_playlists.toml | 11 + .../autogenerated/shadow_playlist.toml | 11 + .../stop_shadowing_playlist.toml | 11 + apps/desktop/src-tauri/permissions/main.toml | 4 + apps/desktop/src-tauri/src/backup.rs | 1 + apps/desktop/src-tauri/src/lib.rs | 10 + .../src-tauri/src/playback_resources.rs | 2 + .../src-tauri/src/playlist_commands.rs | 157 +++++++- apps/desktop/src-tauri/src/playlist_state.rs | 1 + apps/desktop/src-tauri/src/playlists.rs | 124 ++++++- apps/desktop/src-tauri/src/restore.rs | 1 + .../desktop/src-tauri/src/shadow_playlists.rs | 343 ++++++++++++++++++ apps/desktop/src-tauri/src/store.rs | 1 + apps/desktop/src/App.css | 7 + apps/desktop/src/App.tsx | 54 ++- apps/desktop/src/ShadowDiscovery.tsx | 42 +++ apps/desktop/src/spotifyGateway.ts | 4 + apps/desktop/src/types.ts | 3 + apps/desktop/test/interactions.test.tsx | 41 +++ docs/DEVELOPMENT.md | 10 + docs/architecture/persistence.md | 8 + docs/architecture/spotify.md | 33 +- 27 files changed, 884 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/discover_shadow_playlists.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/refresh_shadow_playlists.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/shadow_playlist.toml create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/stop_shadowing_playlist.toml create mode 100644 apps/desktop/src-tauri/src/shadow_playlists.rs create mode 100644 apps/desktop/src/ShadowDiscovery.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f3cb367..d005951 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -111,8 +111,8 @@ policy live in [Last.fm import matching](docs/architecture/lastfm-import-matchin - The desktop shell targets macOS arm64, Windows x64/ARM64, and Ubuntu 22.04 amd64/arm64. - Spotify Premium is required for built-in Spotify playback. -- Spotify restricts third-party access to playlists the current user does not - own; Retune does not request those items. +- Spotify Web API playlist-content access is restricted. Explicit read-only + shadows use separately authorized librespot metadata for saved playlists. - Audiobook chapters can be synchronized but are not currently playable. ## Domain details diff --git a/Cargo.lock b/Cargo.lock index 7cdbc42..d31385a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4181,11 +4181,13 @@ dependencies = [ "keyring", "librespot-audio", "librespot-core", + "librespot-metadata", "librespot-playback", "librespot-protocol", "log", "md-5", "playwire", + "protobuf", "rand 0.9.5", "reqwest 0.12.28", "retune-audio", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5e95f0b..cf176bc 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -31,7 +31,9 @@ log = "0.4" md-5 = "0.10" librespot-audio = { version = "0.8", default-features = false } librespot-core = { version = "0.8", default-features = false, features = ["rustls-tls-native-roots"] } +librespot-metadata = { version = "0.8", default-features = false } librespot-protocol = { version = "0.8", default-features = false } +protobuf = "3.7" librespot-playback = { version = "0.8", default-features = false, features = ["rodio-backend", "rustls-tls-native-roots"] } tauri = { version = "2.11.3", features = [] } tauri-plugin-dialog = "2" diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index cd30e75..e81c40d 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -36,6 +36,10 @@ const APP_COMMANDS: &[&str] = &[ "add_spotify_tracks", "remove_spotify_track", "playlists_list", + "discover_shadow_playlists", + "shadow_playlist", + "stop_shadowing_playlist", + "refresh_shadow_playlists", "open_spotify_playlist", "resolve_spotify_track_destination", "reorder_playlists", diff --git a/apps/desktop/src-tauri/permissions/autogenerated/discover_shadow_playlists.toml b/apps/desktop/src-tauri/permissions/autogenerated/discover_shadow_playlists.toml new file mode 100644 index 0000000..3753516 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/discover_shadow_playlists.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-discover-shadow-playlists" +description = "Enables the discover_shadow_playlists command without any pre-configured scope." +commands.allow = ["discover_shadow_playlists"] + +[[permission]] +identifier = "deny-discover-shadow-playlists" +description = "Denies the discover_shadow_playlists command without any pre-configured scope." +commands.deny = ["discover_shadow_playlists"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/refresh_shadow_playlists.toml b/apps/desktop/src-tauri/permissions/autogenerated/refresh_shadow_playlists.toml new file mode 100644 index 0000000..5bb9a55 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/refresh_shadow_playlists.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-refresh-shadow-playlists" +description = "Enables the refresh_shadow_playlists command without any pre-configured scope." +commands.allow = ["refresh_shadow_playlists"] + +[[permission]] +identifier = "deny-refresh-shadow-playlists" +description = "Denies the refresh_shadow_playlists command without any pre-configured scope." +commands.deny = ["refresh_shadow_playlists"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/shadow_playlist.toml b/apps/desktop/src-tauri/permissions/autogenerated/shadow_playlist.toml new file mode 100644 index 0000000..d8577f4 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/shadow_playlist.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-shadow-playlist" +description = "Enables the shadow_playlist command without any pre-configured scope." +commands.allow = ["shadow_playlist"] + +[[permission]] +identifier = "deny-shadow-playlist" +description = "Denies the shadow_playlist command without any pre-configured scope." +commands.deny = ["shadow_playlist"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/stop_shadowing_playlist.toml b/apps/desktop/src-tauri/permissions/autogenerated/stop_shadowing_playlist.toml new file mode 100644 index 0000000..3c94700 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/stop_shadowing_playlist.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-stop-shadowing-playlist" +description = "Enables the stop_shadowing_playlist command without any pre-configured scope." +commands.allow = ["stop_shadowing_playlist"] + +[[permission]] +identifier = "deny-stop-shadowing-playlist" +description = "Denies the stop_shadowing_playlist command without any pre-configured scope." +commands.deny = ["stop_shadowing_playlist"] diff --git a/apps/desktop/src-tauri/permissions/main.toml b/apps/desktop/src-tauri/permissions/main.toml index 69bab78..a251e72 100644 --- a/apps/desktop/src-tauri/permissions/main.toml +++ b/apps/desktop/src-tauri/permissions/main.toml @@ -39,6 +39,10 @@ permissions = [ "allow-add-spotify-tracks", "allow-remove-spotify-track", "allow-playlists-list", + "allow-discover-shadow-playlists", + "allow-shadow-playlist", + "allow-stop-shadowing-playlist", + "allow-refresh-shadow-playlists", "allow-open-spotify-playlist", "allow-resolve-spotify-track-destination", "allow-reorder-playlists", diff --git a/apps/desktop/src-tauri/src/backup.rs b/apps/desktop/src-tauri/src/backup.rs index 7be00b7..807afd5 100644 --- a/apps/desktop/src-tauri/src/backup.rs +++ b/apps/desktop/src-tauri/src/backup.rs @@ -719,6 +719,7 @@ mod tests { owner: None, track_count: 0, tracks: Vec::new(), + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: Vec::new(), }], diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1fa5ba4..6c1106e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod provider; mod restore; mod restore_latch; mod settings_commands; +mod shadow_playlists; mod spotify_commands; mod spotify_membership; mod spotify_sync_commit; @@ -1445,6 +1446,10 @@ pub fn run() { spotify_commands::add_spotify_tracks, spotify_commands::remove_spotify_track, playlist_commands::playlists_list, + playlist_commands::discover_shadow_playlists, + playlist_commands::shadow_playlist, + playlist_commands::stop_shadowing_playlist, + playlist_commands::refresh_shadow_playlists, playlist_commands::open_spotify_playlist, playlist_commands::resolve_spotify_track_destination, playlist_commands::reorder_playlists, @@ -2220,6 +2225,7 @@ mod tests { owner: None, track_count: 2, tracks: vec!["one".into(), "two".into()], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], @@ -3239,6 +3245,7 @@ mod tests { owner: None, track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }) @@ -3470,6 +3477,7 @@ mod tests { owner: None, track_count: 1, tracks: vec!["spotify:track:restored".into()], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], @@ -3580,6 +3588,7 @@ mod tests { owner: None, track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], @@ -3681,6 +3690,7 @@ mod tests { owner: None, track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], diff --git a/apps/desktop/src-tauri/src/playback_resources.rs b/apps/desktop/src-tauri/src/playback_resources.rs index 5152c79..7b0b601 100644 --- a/apps/desktop/src-tauri/src/playback_resources.rs +++ b/apps/desktop/src-tauri/src/playback_resources.rs @@ -318,6 +318,7 @@ mod tests { owner: None, track_count: 1, tracks: vec!["spotify:track:same".into()], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![CachedTrack { uri: "spotify:track:same".into(), @@ -402,6 +403,7 @@ mod tests { .iter() .map(|track| track.uri.clone()) .collect(), + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: playlist_tracks, }], diff --git a/apps/desktop/src-tauri/src/playlist_commands.rs b/apps/desktop/src-tauri/src/playlist_commands.rs index 626b0cb..144489d 100644 --- a/apps/desktop/src-tauri/src/playlist_commands.rs +++ b/apps/desktop/src-tauri/src/playlist_commands.rs @@ -28,6 +28,7 @@ pub(super) struct PlaylistListView { contains: bool, track_count: usize, items_available: bool, + shadow: Option, } #[derive(Serialize)] @@ -63,6 +64,7 @@ fn playlist_list_views(cache: &playlists::PlaylistCache, uris: &[String]) -> Vec name: playlist.name.clone(), owned: playlist.owned, owner: playlist.owner.clone(), + shadow: playlist.shadow.clone(), contains: !uris.is_empty() && uris.iter().all(|uri| playlist.tracks.contains(uri)), track_count: playlist.track_count, items_available: playlist.owned @@ -73,13 +75,20 @@ fn playlist_list_views(cache: &playlists::PlaylistCache, uris: &[String]) -> Vec } #[tauri::command(rename_all = "camelCase")] -pub(super) fn playlists_list( +pub(super) async fn playlists_list( state: tauri::State<'_, AppState>, uris: Option>, ) -> Result, String> { let uris = uris.unwrap_or_default(); playlists::validate_playlist_uris(&uris)?; - Ok(playlist_list_views(&state.playlists.snapshot()?, &uris)) + let mut cache = state.playlists.snapshot()?; + let account = shadow_credentials(&state).await.ok().map(|c| c.username); + cache.playlists.retain(|p| { + p.shadow + .as_ref() + .is_none_or(|s| Some(&s.account) == account.as_ref()) + }); + Ok(playlist_list_views(&cache, &uris)) } #[tauri::command(rename_all = "camelCase")] @@ -140,6 +149,20 @@ pub(super) async fn playlist_tracks( id: String, ) -> Result, String> { playlists::validate_playlist_id(&id)?; + { + let state = app.state::(); + let cache = state.playlists.snapshot()?; + if let Some(shadow) = cache + .playlists + .iter() + .find(|p| p.id == id) + .and_then(|p| p.shadow.as_ref()) + { + if shadow_credentials(&state).await?.username != shadow.account { + return Err("This shadow belongs to another Spotify account.".into()); + } + } + } tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); playlist_track_views_from_state(&state.playlists, &state.library, &id) @@ -268,6 +291,7 @@ mod tests { owner: None, track_count: 1, tracks: vec!["spotify:track:one".into()], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![playlists::CachedTrack { uri: "spotify:track:one".into(), @@ -300,6 +324,7 @@ mod tests { owner: None, track_count: 2, tracks: vec!["one".into(), "two".into()], + shadow: None, track_metadata_version: playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], @@ -613,3 +638,131 @@ pub(super) async fn playlist_remove( let result = playlists::remove(client.as_ref(), &mut cache, &id, &indices).await; finish_playlist_mutation(&app, client.as_ref(), operation, cache, result).await } + +async fn shadow_credentials( + state: &AppState, +) -> Result { + provider_from(state)?.load_tokens().await.map_err(|e| e.to_string())? + .and_then(|t| t.playback_credentials) + .ok_or_else(|| "Connect to Spotify and authorize built-in playback in File → Account to shadow playlists.".into()) +} + +#[tauri::command] +pub(super) async fn discover_shadow_playlists( + app: tauri::AppHandle, +) -> Result, String> { + let state = app.state::(); + let revision = state.spotify_session.revision(); + let credentials = shadow_credentials(&state).await?; + let reader = crate::shadow_playlists::Reader::connect(&credentials).await?; + let candidates = tokio::time::timeout( + std::time::Duration::from_secs(90), + reader.discover(&credentials.username), + ) + .await + .map_err(|_| "Playlist discovery timed out. Try again.".to_string())??; + let _commit = state.spotify_session.commit_revision(revision).await?; + Ok(candidates) +} + +#[tauri::command(rename_all = "camelCase")] +pub(super) async fn shadow_playlist( + app: tauri::AppHandle, + id: String, + account: Option, +) -> Result<(), String> { + playlists::validate_playlist_id(&id)?; + let state = app.state::(); + let revision = state.spotify_session.revision(); + let (operation, mut cache) = state.playlists.begin_mutation().await?; + let credentials = shadow_credentials(&state).await?; + if account.as_ref().is_some_and(|a| a != &credentials.username) { + return Err("Spotify account changed. Discover playlists again.".into()); + } + let previous = cache.playlists.iter().find(|p| p.id == id); + if previous.is_some_and(|p| p.owned) { + return Err("Your own playlists already sync with Retune.".into()); + } + let reader = crate::shadow_playlists::Reader::connect(&credentials).await?; + let fetched = tokio::time::timeout( + std::time::Duration::from_secs(180), + reader.fetch(&id, &credentials.username, previous), + ) + .await + .map_err(|_| "Shadowing timed out. Existing tracks are unchanged.".to_string())??; + if let Some(previous) = cache.playlists.iter_mut().find(|p| p.id == id) { + *previous = fetched; + } else { + cache.playlists.push(fetched); + } + let _commit = state.spotify_session.commit_revision(revision).await?; + save_playlists(&app, operation, cache, false).await +} + +#[tauri::command] +pub(super) async fn stop_shadowing_playlist( + app: tauri::AppHandle, + id: String, +) -> Result<(), String> { + playlists::validate_playlist_id(&id)?; + let state = app.state::(); + let (operation, mut cache) = state.playlists.begin_mutation().await?; + cache.playlists.retain(|p| p.id != id || p.shadow.is_none()); + save_playlists(&app, operation, cache, false).await +} + +#[tauri::command] +pub(super) async fn refresh_shadow_playlists( + app: tauri::AppHandle, + id: Option, + force: bool, +) -> Result<(), String> { + if let Some(id) = &id { + playlists::validate_playlist_id(id)?; + } + let state = app.state::(); + let revision = state.spotify_session.revision(); + let (operation, mut cache) = state.playlists.begin_mutation().await?; + let credentials = shadow_credentials(&state).await?; + let now = crate::unix_now(); + let indices = cache + .playlists + .iter() + .enumerate() + .filter(|(_, p)| { + id.as_ref().is_none_or(|id| id == &p.id) + && p.shadow + .as_ref() + .is_some_and(|s| s.account == credentials.username && (force || s.due(now))) + }) + .map(|(index, _)| index) + .collect::>(); + if indices.is_empty() { + return Ok(()); + } + let reader = crate::shadow_playlists::Reader::connect(&credentials).await; + let mut failure = None; + for index in indices { + let previous = &cache.playlists[index]; + let fetched = match &reader { + Ok(reader) => tokio::time::timeout( + std::time::Duration::from_secs(180), + reader.fetch(&previous.id, &credentials.username, Some(previous)), + ) + .await + .map_err(|_| "Playlist refresh timed out.".to_string()) + .and_then(|r| r), + Err(error) => Err(error.clone()), + }; + if let Err(error) = crate::shadow_playlists::apply_refresh( + &mut cache.playlists[index], + fetched, + crate::unix_now(), + ) { + failure = Some(error); + } + } + let _commit = state.spotify_session.commit_revision(revision).await?; + save_playlists(&app, operation, cache, false).await?; + failure.map_or(Ok(()), Err) +} diff --git a/apps/desktop/src-tauri/src/playlist_state.rs b/apps/desktop/src-tauri/src/playlist_state.rs index c19d5fa..f322aee 100644 --- a/apps/desktop/src-tauri/src/playlist_state.rs +++ b/apps/desktop/src-tauri/src/playlist_state.rs @@ -251,6 +251,7 @@ mod tests { owner: None, track_count: 0, tracks: Vec::new(), + shadow: None, track_metadata_version: crate::playlists::TRACK_METADATA_VERSION, spotify_tracks: Vec::new(), } diff --git a/apps/desktop/src-tauri/src/playlists.rs b/apps/desktop/src-tauri/src/playlists.rs index b93dbc1..e74ec1c 100644 --- a/apps/desktop/src-tauri/src/playlists.rs +++ b/apps/desktop/src-tauri/src/playlists.rs @@ -77,6 +77,8 @@ pub struct CachedPlaylist { pub tracks: Vec, #[serde(default)] pub track_metadata_version: u8, + #[serde(default)] + pub shadow: Option, #[serde(default, alias = "non_library_tracks")] pub spotify_tracks: Vec, } @@ -115,8 +117,15 @@ pub async fn sync( let cached = current .playlists .iter() - .find(|playlist| playlist.id == summary.id); + .find(|playlist| playlist.id == summary.id) + .filter(|p| p.shadow.as_ref().is_none_or(|s| s.account == user_id)); spotify_order.push(summary.id.clone()); + if let Some(shadow) = + cached.filter(|p| p.shadow.as_ref().is_some_and(|s| s.account == user_id)) + { + refreshed.insert(summary.id, shadow.clone()); + continue; + } if !summary.owned && !summary.collaborative { let mut cached = cached.cloned().unwrap_or_else(|| CachedPlaylist { id: summary.id.clone(), @@ -126,6 +135,7 @@ pub async fn sync( owner: None, track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![], }); @@ -163,9 +173,14 @@ pub async fn sync( .playlists .iter() .filter_map(|playlist| { - refreshed - .remove(&playlist.id) - .or_else(|| incomplete.then(|| playlist.clone())) + refreshed.remove(&playlist.id).or_else(|| { + (playlist + .shadow + .as_ref() + .is_some_and(|s| s.account == user_id) + || (incomplete && playlist.shadow.is_none())) + .then(|| playlist.clone()) + }) }) .collect::>(); playlists.extend( @@ -226,6 +241,7 @@ pub async fn create( owner: None, track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![], }); @@ -547,6 +563,7 @@ async fn fetch( owner: Some(summary.owner.display_name.unwrap_or(summary.owner.id)), track_count, tracks, + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks, }) @@ -650,12 +667,111 @@ mod tests { owner: None, track_count: 2, tracks: vec!["spotify:track:1".into(), "spotify:track:2".into()], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![], }], } } + fn shadowed() -> PlaylistCache { + let mut cache = cached(); + cache.playlists[0].owned = false; + cache.playlists[0].shadow = Some(crate::shadow_playlists::Shadow { + account: "user".into(), + refreshed_at: 10, + attempted_at: 10, + error: None, + }); + cache + } + + #[test] + fn shadow_failure_keeps_tracks_and_survives_disk_round_trip() { + let mut cache = shadowed(); + let before = cache.playlists[0].clone(); + assert!(crate::shadow_playlists::apply_refresh( + &mut cache.playlists[0], + Err("offline".into()), + 30 + ) + .is_err()); + assert_eq!(cache.playlists[0].tracks, before.tracks); + assert_eq!(cache.playlists[0].snapshot_id, before.snapshot_id); + assert_eq!(cache.playlists[0].shadow.as_ref().unwrap().refreshed_at, 10); + assert_eq!( + cache.playlists[0].shadow.as_ref().unwrap().error.as_deref(), + Some("offline") + ); + let dir = tempfile::tempdir().unwrap(); + let store = FsPlaylistStore::new(dir.path()); + store.save(&cache).unwrap(); + assert_eq!(store.load().unwrap(), cache); + let mut legacy = serde_json::to_value(cached()).unwrap(); + legacy["playlists"][0] + .as_object_mut() + .unwrap() + .remove("shadow"); + assert!(serde_json::from_value::(legacy) + .unwrap() + .playlists[0] + .shadow + .is_none()); + } + + #[tokio::test] + async fn web_sync_preserves_shadows_only_for_the_same_account() { + for (user, summaries, expected) in [ + ("user", serde_json::json!([]), true), + ("other", serde_json::json!([]), false), + ( + "user", + serde_json::json!([{ + "id": "playlist", "name": "Web summary", "snapshot_id": "web", + "owner": {"id": "spotify"}, "tracks": {"total": 999} + }]), + true, + ), + ] { + let client = fake_client( + [ + Response::json(200, serde_json::json!({"id": user})), + Response::json(200, serde_json::json!({"items": summaries, "next": null})), + ], + &retune_spotify::auth::SCOPES, + ); + let current = shadowed(); + let synced = sync(&client, ¤t).await.unwrap(); + if expected { + assert_eq!(synced, current); + } else { + assert!(synced.playlists.is_empty()); + } + } + } + + #[tokio::test] + async fn shadow_content_is_read_only() { + let client = fake_client([], &retune_spotify::auth::SCOPES); + let mut cache = shadowed(); + assert!(matches!( + add( + &client, + &mut cache, + &Library::new(), + "playlist", + vec!["spotify:track:new".into()] + ) + .await, + Err(PlaylistAddError::ReadOnly) + )); + assert!(reorder(&client, &mut cache, "playlist", 0, 2, 1) + .await + .is_err()); + assert!(remove(&client, &mut cache, "playlist", &[0]).await.is_err()); + assert!(client.transport().requests().is_empty()); + } + fn ordered() -> PlaylistCache { let mut cache = cached(); for id in ["second", "third"] { diff --git a/apps/desktop/src-tauri/src/restore.rs b/apps/desktop/src-tauri/src/restore.rs index cb0c47b..b1e735b 100644 --- a/apps/desktop/src-tauri/src/restore.rs +++ b/apps/desktop/src-tauri/src/restore.rs @@ -302,6 +302,7 @@ mod tests { owner: None, track_count: 1, tracks: vec!["spotify:track:restored".into()], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![], }], diff --git a/apps/desktop/src-tauri/src/shadow_playlists.rs b/apps/desktop/src-tauri/src/shadow_playlists.rs new file mode 100644 index 0000000..aa65995 --- /dev/null +++ b/apps/desktop/src-tauri/src/shadow_playlists.rs @@ -0,0 +1,343 @@ +use std::{collections::HashMap, time::Duration}; + +use librespot_core::{authentication::Credentials, config::SessionConfig, Session, SpotifyUri}; +use librespot_metadata::{Metadata, Playlist, Track}; +use librespot_protocol::{ + authentication::AuthenticationType, playlist4_external::SelectedListContent, +}; +use protobuf::Message; +use retune_spotify::tokens::PlaybackCredentials; +use serde::{Deserialize, Serialize}; + +use crate::playlists::{CachedPlaylist, CachedTrack, TRACK_METADATA_VERSION}; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Shadow { + pub account: String, + pub refreshed_at: u64, + pub attempted_at: u64, + pub error: Option, +} + +impl Shadow { + pub fn due(&self, now: u64) -> bool { + now.saturating_sub(self.refreshed_at) >= 6 * 60 * 60 + && now.saturating_sub(self.attempted_at) >= 15 * 60 + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Candidate { + pub id: String, + pub name: String, + pub owner: String, + pub account: String, +} + +// A metadata-only session never creates a player or takes over Spotify Connect. +pub(crate) struct Reader(Session); + +impl Drop for Reader { + fn drop(&mut self) { + self.0.shutdown(); + } +} + +impl Reader { + pub async fn connect(saved: &PlaybackCredentials) -> Result { + if saved.auth_data.is_empty() { + return Err( + "Authorize built-in Spotify playback in File → Account to shadow playlists.".into(), + ); + } + let reader = Self(Session::new(SessionConfig::default(), None)); + tokio::time::timeout(Duration::from_secs(30), async { + reader + .0 + .connect( + Credentials { + username: Some(saved.username.clone()), + auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS, + auth_data: saved.auth_data.clone(), + }, + false, + ) + .await + .map_err(|e| e.to_string())?; + reader + .0 + .login5() + .auth_token() + .await + .map_err(|e| e.to_string())?; + if reader.0.username() != saved.username { + return Err( + "Spotify account changed. Reconnect before shadowing playlists.".into(), + ); + } + Ok::<_, String>(()) + }) + .await + .map_err(|_| "Spotify playlist connection timed out.".to_string())??; + Ok(reader) + } + + pub async fn discover(&self, account: &str) -> Result, String> { + let mut result = Vec::new(); + let mut offset = 0; + let mut revision = None; + loop { + let bytes = self + .0 + .spclient() + .get_rootlist(offset, Some(500)) + .await + .map_err(|e| e.to_string())?; + let root = SelectedListContent::parse_from_bytes(&bytes).map_err(|e| e.to_string())?; + if revision + .as_ref() + .is_some_and(|r| r != &root.revision().to_vec()) + { + return Err("Saved playlists changed during discovery. Try again.".into()); + } + revision = Some(root.revision().to_vec()); + let contents = root + .contents + .as_ref() + .ok_or("Spotify returned no saved playlists.")?; + if contents.pos() as usize != offset + || contents.items.len() != contents.meta_items.len() + { + return Err( + "Spotify returned incomplete playlist discovery metadata. Try again.".into(), + ); + } + for (item, meta) in contents.items.iter().zip(&contents.meta_items) { + let Ok(SpotifyUri::Playlist { id, .. }) = SpotifyUri::from_uri(item.uri()) else { + continue; + }; + if meta.owner_username() == account { + continue; + } + result.push(Candidate { + id: id.to_base62().map_err(|e| e.to_string())?, + name: meta + .attributes + .as_ref() + .map_or("Playlist", |a| a.name()) + .to_owned(), + owner: meta.owner_username().to_owned(), + account: account.to_owned(), + }); + } + offset += contents.items.len(); + if offset >= root.length().max(0) as usize { + break; + } + if contents.items.is_empty() || offset > 10_000 { + return Err("Spotify returned an incomplete saved-playlist list.".into()); + } + } + Ok(result) + } + + pub async fn fetch( + &self, + id: &str, + account: &str, + cached: Option<&CachedPlaylist>, + ) -> Result { + let uri = + SpotifyUri::from_uri(&format!("spotify:playlist:{id}")).map_err(|e| e.to_string())?; + // shortcut: reject truncated snapshots; add revision-checked pagination if a needed playlist exceeds the native response limit. + let playlist = Playlist::get(&self.0, &uri) + .await + .map_err(|e| e.to_string())?; + validate_complete( + playlist.length, + playlist.contents.position, + playlist.contents.is_truncated, + playlist.contents.items.len(), + )?; + let owner = match &playlist.id { + SpotifyUri::Playlist { user, .. } => user.clone(), + _ => return Err("Spotify returned a different playlist identity.".into()), + }; + if owner.as_deref() == Some(account) { + return Err("Your own playlists already sync with Retune.".into()); + } + let mut metadata: HashMap = cached + .into_iter() + .flat_map(|p| p.spotify_tracks.iter()) + .map(|t| (t.uri.clone(), t.clone())) + .collect(); + let mut tracks = Vec::new(); + let mut spotify_tracks = Vec::new(); + for uri in playlist.tracks() { + if !matches!(uri, SpotifyUri::Track { .. }) { + return Err( + "This playlist contains unsupported items; its previous shadow has been kept." + .into(), + ); + } + let key = uri.to_uri().map_err(|e| e.to_string())?; + if !metadata.contains_key(&key) { + let track = Track::get(&self.0, uri).await.map_err(|e| e.to_string())?; + if track.name.is_empty() || track.duration <= 0 { + return Err("Spotify returned incomplete track metadata; its previous shadow has been kept.".into()); + } + metadata.insert( + key.clone(), + CachedTrack { + uri: key.clone(), + name: track.name, + art: track + .artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", "), + alb: track.album.name, + duration: track.duration as u64, + disc_no: u32::try_from(track.disc_number).ok().filter(|n| *n > 0), + track_no: u32::try_from(track.number).ok().filter(|n| *n > 0), + release_date: Some(track.album.date.date().to_string()), + }, + ); + } + spotify_tracks.push(metadata[&key].clone()); + tracks.push(key); + } + let now = crate::unix_now(); + Ok(CachedPlaylist { + id: id.to_owned(), + name: playlist.name().to_owned(), + owned: false, + owner, + snapshot_id: playlist + .revision + .iter() + .map(|b| format!("{b:02x}")) + .collect(), + track_count: tracks.len(), + tracks, + spotify_tracks, + track_metadata_version: TRACK_METADATA_VERSION, + shadow: Some(Shadow { + account: account.to_owned(), + refreshed_at: now, + attempted_at: now, + error: None, + }), + }) + } +} + +fn validate_complete( + length: i32, + position: i32, + truncated: bool, + returned: usize, +) -> Result<(), String> { + if length < 0 || position != 0 || truncated || returned != length as usize || returned > 10_000 + { + return Err( + "Spotify returned a partial playlist; its previous shadow has been kept.".into(), + ); + } + Ok(()) +} + +pub(crate) fn apply_refresh( + previous: &mut CachedPlaylist, + fetched: Result, + now: u64, +) -> Result<(), String> { + match fetched { + Ok(fetched) => { + *previous = fetched; + Ok(()) + } + Err(error) => { + let shadow = previous + .shadow + .as_mut() + .ok_or("This playlist is no longer shadowed.")?; + shadow.attempted_at = now; + shadow.error = Some(error.clone()); + Err(error) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn partial_snapshots_cannot_replace_a_shadow() { + assert!(validate_complete(2, 0, false, 2).is_ok()); + assert!(validate_complete(0, 0, false, 0).is_ok()); + for (length, position, truncated, returned) in [ + (2, 0, false, 1), + (2, 1, false, 2), + (2, 0, true, 2), + (-1, 0, false, 0), + ] { + assert!(validate_complete(length, position, truncated, returned).is_err()); + } + } + + #[test] + fn automatic_refresh_waits_six_hours_and_backs_off_after_failure() { + let mut shadow = Shadow { + account: "user".into(), + refreshed_at: 100, + attempted_at: 100, + error: None, + }; + assert!(!shadow.due(100 + 6 * 3600 - 1)); + assert!(shadow.due(100 + 6 * 3600)); + shadow.attempted_at = 100 + 6 * 3600; + assert!(!shadow.due(shadow.attempted_at + 899)); + assert!(shadow.due(shadow.attempted_at + 900)); + } + #[tokio::test] + #[ignore = "Needs explicit Spotify dev token path and playlist IDs; reads Spotify and writes only a temporary cache"] + async fn live_shadow_round_trip() { + let path = std::env::var("RETUNE_SHADOW_PROBE_TOKENS").expect("dev token path"); + let ids = std::env::var("RETUNE_SHADOW_PROBE_IDS").expect("comma-separated playlist IDs"); + let tokens: retune_spotify::tokens::Tokens = + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let credentials = tokens.playback_credentials.unwrap(); + let reader = Reader::connect(&credentials).await.unwrap(); + let candidates = reader.discover(&credentials.username).await.unwrap(); + let mut cache = crate::playlists::PlaylistCache::default(); + for id in ids.split(',') { + assert!(candidates.iter().any(|c| c.id == id)); + let playlist = reader.fetch(id, &credentials.username, None).await.unwrap(); + assert!(!playlist.tracks.is_empty()); + assert_eq!(playlist.tracks.len(), playlist.spotify_tracks.len()); + assert!(playlist + .spotify_tracks + .iter() + .all(|t| !t.name.is_empty() && t.duration > 0)); + let refreshed = reader + .fetch(id, &credentials.username, Some(&playlist)) + .await + .unwrap(); + assert_eq!(playlist.tracks, refreshed.tracks); + println!( + "Shadowed {}: {} complete tracks; refresh preserved canonical order", + playlist.name, playlist.track_count + ); + cache.playlists.push(refreshed); + } + let directory = tempfile::tempdir().unwrap(); + let store = crate::store::FsPlaylistStore::new(directory.path()); + store.save(&cache).unwrap(); + assert_eq!(store.load().unwrap(), cache); + } +} diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 89b1c76..0e85c98 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -2975,6 +2975,7 @@ mod tests { owner: Some("Owner Name".into()), track_count: 0, tracks: vec![], + shadow: None, track_metadata_version: crate::playlists::TRACK_METADATA_VERSION, spotify_tracks: vec![], }], diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 80184ac..983ea95 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -479,3 +479,10 @@ button.spotify-page-art:focus-visible { outline: 2px solid var(--accent); outlin } .context-menu hr { border: 0; border-top: 1px solid var(--border); margin: 4px 0; } + +.shadow-discover-button { margin: 6px 10px; text-align: left; } +.shadow-discovery { width: min(520px, 90vw); } +.shadow-candidates { max-height: 45vh; overflow-y: auto; } +.shadow-candidate { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0; } +.shadow-candidate small { display: block; opacity: .7; } +.shadow-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 12px; font-size: 11px; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7500ee2..75c1346 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,3 +1,4 @@ +import { ShadowDiscovery } from './ShadowDiscovery' import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react' @@ -20,6 +21,7 @@ import { appGateway } from './appGateway.ts' import { lastfmGateway } from './lastfmGateway.ts' import { RemoveTrackDialog, TrackMergeDialog } from './trackDecisionDialogs.tsx' +const SHADOW_PLAYLIST_HINT = 'Shadowing keeps a read-only copy of a Spotify playlist’s track list in Retune, so you can browse and play it here. It refreshes automatically as the original changes. The Spotify playlist is never changed, and music still streams from Spotify.' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." const SPOTIFY_SYNC_ERROR = 'Retune couldn’t sync Spotify; your cached library is unchanged. The network may be blocking Spotify. Try another network or VPN, then sync again.' const SPOTIFY_SEARCH_ERROR = 'Couldn’t search Spotify. Existing results are unchanged. Try another network or VPN, then search again.' @@ -351,7 +353,15 @@ function App() { .then((rows) => active && setPlaylists(rows)) .catch(() => active && dispatch({ type: 'error', error: 'Couldn’t load Spotify playlists. Existing playlists are unchanged. Try again.' })) return () => { active = false } - }, [state.playlistRevision]) + }, [state.playlistRevision, state.connection]) + + useEffect(() => { + if (!state.connection.connected || !state.connection.playback_authorized) return + const refresh = () => { void spotifyGateway.refreshShadows().catch(() => { /* Refresh errors remain on the shadow. */ }) } + refresh() + const timer = window.setInterval(refresh, 15 * 60 * 1000) + return () => window.clearInterval(timer) + }, [state.connection.connected, state.connection.playback_authorized]) useEffect(() => { if (playlists && state.selectedPlaylist && !playlists.some((playlist) => playlist.id === state.selectedPlaylist)) { @@ -1026,7 +1036,7 @@ export function TransportBar({ playing, progress, track, query, queryReset, scop } -function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError, artwork }: { +function Sidebar({ state, playlists: incomingPlaylists, onSource, onPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError, artwork }: { state: State playlists?: PlaylistListView[] onSource: (source: Source) => void @@ -1039,6 +1049,18 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse onError: (error: string) => void artwork?: React.ReactNode }) { + const playlists = useMemo(() => incomingPlaylists ? [...incomingPlaylists.filter(p => !p.shadow), ...incomingPlaylists.filter(p => p.shadow)] : undefined, [incomingPlaylists]) + const [discovering, setDiscovering] = useState(false) + const [shadowBusy, setShadowBusy] = useState(false) + const shadowPending = useRef(false) + const shadowAction = async (action: () => Promise) => { + if (shadowPending.current) return + shadowPending.current = true + setShadowBusy(true) + setMenu(undefined) + try { await action() } catch (error) { onError(String(error)) } + finally { shadowPending.current = false; setShadowBusy(false) } + } const [creating, setCreating] = useState(false) const [name, setName] = useState('') const [dropTarget, setDropTarget] = useState() @@ -1112,7 +1134,7 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse if (event.key === 'Enter') void create() else if (event.key === 'Escape') { setCreating(false); setName('') } }} />} - {!state.settings.plCollapsed && playlists?.map((playlist, index) => {menu?.playlist.id === playlist.id && setMenu(undefined)}>})} + {playlist.shadow ? '◐' : playlist.owned ? '' : '🌐'}{playlist.name}{playlist.trackCount} + {menu?.playlist.id === playlist.id && setMenu(undefined)}>{playlist.shadow ? <> : <>{!playlist.owned && }}})} + {artwork}
- {confirming && setConfirming(undefined)} onSubmit={busy ? undefined : unfollow} closeOnBackdrop>

{confirming.owned ? 'Delete Playlist?' : 'Unfollow Playlist?'}

{confirming.owned ? `Delete “${confirming.name}” from Spotify?` : `Stop following “${confirming.name}”?`}

} + {discovering && setDiscovering(false)} />}{confirming && setConfirming(undefined)} onSubmit={busy ? undefined : unfollow} closeOnBackdrop>

{confirming.owned ? 'Delete Playlist?' : 'Unfollow Playlist?'}

{confirming.owned ? `Delete “${confirming.name}” from Spotify?` : `Stop following “${confirming.name}”?`}

} } function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, columnOrder, columnWidths, hiddenColumns, onBack, onPlay, onRate, onOpen, onPlaylist, onInfo, onReorder, onColumnWidths, onHiddenColumns, onError }: { @@ -1202,6 +1225,22 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, onHiddenColumns: (columns: ColumnKey[]) => void onError: (error: string) => void }) { + const [shadowBusy, setShadowBusy] = useState(false) + const shadowPending = useRef(false) + const shadowAction = async () => { + if (shadowPending.current) return + shadowPending.current = true + setShadowBusy(true) + try { + if (playlist.shadow) await spotifyGateway.refreshShadows(playlist.id, true) + else await spotifyGateway.shadowPlaylist(playlist.id) + } catch (error) { onError(String(error)) } + finally { shadowPending.current = false; setShadowBusy(false) } + } + const isShadow = !!playlist.shadow + useEffect(() => { + if (isShadow) void spotifyGateway.refreshShadows(playlist.id).catch(() => { /* Status is stored with the cached shadow. */ }) + }, [playlist.id, isShadow]) const [trackState, setTrackState] = useState(() => loadingPlaylistRows(playlist.id)) const [selected, setSelected] = useState>(new Set()) const [selectionAnchor, setSelectionAnchor] = useState() @@ -1400,7 +1439,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, return
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{sortColumn ? ` · sorted by ${headings[sortColumn]}` : ''}{playlist.owned && }
- {!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-(
: <> + {playlist.shadow &&
◐ Shadow · read-only · Updated {new Date(playlist.shadow.refreshedAt * 1000).toLocaleString()}{playlist.shadow.error ? ` · Refresh failed: ${playlist.shadow.error} Cached tracks kept.` : ''}
} + {!playlist.itemsAvailable ?
Tracks unavailable in RetuneShadow this playlist to cache and play its tracks in Retune. The original stays read-only.
: <>
{ if (event.target !== event.currentTarget && !(event.target as Element).matches('.playlist-end-drop, .playlist-track-window')) return setSelected(new Set()) diff --git a/apps/desktop/src/ShadowDiscovery.tsx b/apps/desktop/src/ShadowDiscovery.tsx new file mode 100644 index 0000000..eaa1c15 --- /dev/null +++ b/apps/desktop/src/ShadowDiscovery.tsx @@ -0,0 +1,42 @@ +import { useEffect, useRef, useState } from 'react' +import { spotifyGateway } from './spotifyGateway' +import type { PlaylistListView, ShadowCandidate } from './types' +import { ModalDialog } from './viewShared' + +export function ShadowDiscovery({ playlists, onClose }: { playlists: PlaylistListView[]; onClose: () => void }) { + const [candidates, setCandidates] = useState() + const [error, setError] = useState('') + const [busy, setBusy] = useState() + const pending = useRef(false) + const [retry, setRetry] = useState(0) + useEffect(() => { + let active = true + setError('') + setCandidates(undefined) + spotifyGateway.discoverShadows().then(rows => { if (active) setCandidates(rows) }) + .catch(error => { if (active) setError(String(error)) }) + return () => { active = false } + }, [retry]) + const shadow = async (candidate: ShadowCandidate) => { + if (pending.current) return + pending.current = true + setBusy(candidate.id) + setError('') + try { await spotifyGateway.shadowPlaylist(candidate.id, candidate.account) } + catch (error) { setError(String(error)) } + finally { pending.current = false; setBusy(undefined) } + } + return +

Shadow playlists

+

Keep a read-only copy of a saved Spotify playlist in Retune. Shadows refresh automatically and keep their last successful tracks if refresh fails.

+

Discover Weekly and Release Radar appear here when saved in your Spotify library.

+ {error &&

{error}

} + {!candidates && !error &&

Discovering your saved playlists…

} +
{candidates?.map(candidate => { + const shadowed = playlists.some(p => p.id === candidate.id && p.shadow) + return
{candidate.name}by {candidate.owner}
+ })}
+ {candidates?.length === 0 &&

No saved playlists from other creators were found.

} +
+
+} diff --git a/apps/desktop/src/spotifyGateway.ts b/apps/desktop/src/spotifyGateway.ts index d26ebb6..830b2b7 100644 --- a/apps/desktop/src/spotifyGateway.ts +++ b/apps/desktop/src/spotifyGateway.ts @@ -34,6 +34,10 @@ export function createSpotifyGateway(invoke: Invoker) { createPlaylist: (name: string) => invoke('playlist_create', { name }), unfollowPlaylist: (id: string) => invoke('playlist_unfollow', { id }), reorderPlaylists: (ids: string[]) => invoke('reorder_playlists', { ids }), + discoverShadows: () => invoke('discover_shadow_playlists'), + shadowPlaylist: (id: string, account?: string) => invoke('shadow_playlist', { id, account: account ?? null }), + stopShadowing: (id: string) => invoke('stop_shadowing_playlist', { id }), + refreshShadows: (id?: string, force = false) => invoke('refresh_shadow_playlists', { id: id ?? null, force }), playlistTracks: (id: string) => invoke('playlist_tracks', { id }), addToPlaylist: (id: string, uris: string[]) => invoke('playlist_add', { id, uris }), addAlbumToPlaylist: (id: string, albumUri: string, albumLabel: string) => invoke('playlist_add_album', { id, albumUri, albumLabel }), diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 3525c6e..92056c9 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -101,6 +101,7 @@ export type PlaylistListView = { contains: boolean trackCount: number itemsAvailable: boolean + shadow?: { account: string; refreshedAt: number; attemptedAt: number; error: string | null } | null } type RatingView = { stars: number; explicit: boolean } export type SearchAlbum = { @@ -271,3 +272,5 @@ export type MetadataValues = { arts: string[]; albs: string[]; cats: string[] } export type InfoDialog = | { kind: 'single'; track: TrackInfo } | { kind: 'multiple'; tracks: PlaylistTrack[] } + +export type ShadowCandidate = { id: string; name: string; owner: string; account: string } diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index 5266be3..276bb6f 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -1285,6 +1285,47 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('[data-track-id="1"]')?.tabIndex).toBe(0) }) + it('discovers and shadows personalized playlists without a URL, then refreshes without replacing playback', async () => { + const tracks = [track(1, 'Weekly song')] + let shadowed = false + let refreshFailed = false + invokeMock.mockImplementation(async (command, args) => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'discover_shadow_playlists') return [{ id: 'weekly', name: 'Discover Weekly', owner: 'spotify', account: 'me' }] + if (command === 'shadow_playlist') { shadowed = true; return null } + if (command === 'playlists_list') return shadowed ? [{ id: 'weekly', name: 'Discover Weekly', owned: false, owner: 'spotify', itemsAvailable: true, trackCount: 1, shadow: { account: 'me', refreshedAt: 100, attemptedAt: 100, error: refreshFailed ? 'offline' : null } }] : [] + if (command === 'playlist_tracks') return tracks + if (command === 'refresh_shadow_playlists' && args?.force) { refreshFailed = true; throw new Error('offline') } + if (command === 'play_tracks') return 'started' + return null + }) + const view = await render() + await act(async () => [...view.querySelectorAll('button')].find(b => b.textContent === 'Shadow playlists…')!.click()) + expect(invokeMock).toHaveBeenCalledWith('discover_shadow_playlists', undefined) + expect(document.querySelector('input[placeholder*="http"]')).toBeNull() + await act(async () => document.querySelector('[aria-label="Shadow Discover Weekly"]')!.click()) + expect(invokeMock).toHaveBeenCalledWith('shadow_playlist', { id: 'weekly', account: 'me' }) + await emitNativeEvent('playlists-changed', null) + await act(async () => [...document.querySelectorAll('button')].find(b => b.textContent === 'Done')!.click()) + expect(view.textContent).toContain('Shadowed playlists') + const row = view.querySelector('.playlist-row')! + await act(async () => row.click()) + expect(view.textContent).toContain('Shadow · read-only') + expect(view.querySelector('.playlist-track-row.reorderable')).toBeNull() + await act(async () => view.querySelector('.playlist-track-row')!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + const plays = invokeMock.mock.calls.filter(([command]) => command === 'play_tracks').length + await act(async () => [...view.querySelectorAll('button')].find(b => b.textContent === 'Refresh shadow')!.click()) + await emitNativeEvent('playlists-changed', null) + expect(view.textContent).toContain('Cached tracks kept') + expect(view.textContent).toContain('Weekly song') + expect(invokeMock.mock.calls.filter(([command]) => command === 'play_tracks')).toHaveLength(plays) + expect(invokeMock.mock.calls.some(([command]) => ['playlist_add', 'playlist_remove', 'playlist_reorder'].includes(command))).toBe(false) + }) + it('windows playlist rows while retaining duplicate positions, range selection, playback, and drag indices', async () => { const tracks = Array.from({ length: 4000 }, (_, index) => ({ ...track(index, `Track ${index % 100}`), id: null, uri: `spotify:track:${index % 100}` })) invokeMock.mockImplementation(async command => { diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8c5af62..8e3e77c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -203,3 +203,13 @@ dependencies. Release checksums cover both Arch packages; the Linux package repository signs them and updates its pacman index. No second application build or credentials are introduced. Test the resulting package in a clean Arch container before shipping changes to this packaging path. + +### Live shadow playlist check + +The ignored `shadow_playlists::tests::live_shadow_round_trip` native test reads +Spotify using an explicitly supplied development token file. Set +`RETUNE_SHADOW_PROBE_TOKENS` to that file and `RETUNE_SHADOW_PROBE_IDS` to +comma-separated IDs of saved playlists from other creators, then run +`cargo test -p retune-desktop --lib live_shadow_round_trip -- --ignored --nocapture`. +It verifies discovery, full metadata, repeated refresh/order, and a temporary +cache save/reload. It does not mutate Spotify or the application's real cache. diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 3bba928..5d35d65 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -476,3 +476,11 @@ cooldown reads run together on the blocking pool. The shell publishes their results only after all reads succeed; application commands wait at the shell dispatcher until initialization finishes. Existing valid settings are read without a redundant save; missing defaults still use atomic persistence. + +### Shadow playlist cache + +`playlists.json` entries may contain an optional `shadow` record with the Spotify +account, successful/attempted refresh timestamps, and last refresh error. Absent +records retain legacy playlist behavior. Shadow track lists and display metadata +use the existing playlist atomic store; no audio is downloaded to this cache. +Failed refreshes persist their status while retaining the last complete content. diff --git a/docs/architecture/spotify.md b/docs/architecture/spotify.md index 35679f6..34d23b8 100644 --- a/docs/architecture/spotify.md +++ b/docs/architecture/spotify.md @@ -339,9 +339,36 @@ desktop track-add command additionally rejects malformed/non-track URIs before reading managed state or creating provider/event effects. Spotify is canonical for owned-playlist content. Reordering uses snapshot IDs to -detect concurrent changes, then reloads stale state. Retune does not request or -mutate item contents for playlists the current user does not own; it may display -their available metadata and cached counts. +detect concurrent changes, then reloads stale state. Ordinary Web API sync only +requests contents for owned or collaborative playlists. Other playlists can be +explicitly **shadowed** using the separately authorized librespot metadata path; +shadows never write to the source playlist. + +`shadow_playlists::Reader` opens a short-lived metadata-only session using the +shared token store's verified playback credentials. It creates no audio player +and shuts down even on cancellation. Discovery pages the account's saved +rootlist, including saved Discover Weekly and Release Radar playlists omitted +by the public API. It does not discover unsaved recommendations. A discovery +candidate carries its account ID, which is rechecked before shadowing. + +A shadow stores complete ordered track URIs, display metadata, the source +revision, account, last successful/attempted refresh times, and last error in +`playlists.json`. Existing files default to no shadow. PlaylistState's existing +atomic commit gate owns these writes; a Spotify session revision check rejects +results after disconnect or account changes. Web API sync preserves same-account +shadows even when omitted upstream and cannot overwrite their native revision +or counts. List/track commands hide or reject shadows from another account. + +The main window checks every 15 minutes and when opening a shadow. Snapshots +become due after six hours, with 15-minute backoff after failure; a manual +Refresh bypasses that age check. Complete metadata is required before replacement. +Partial/truncated lists, unsupported non-track items, and metadata failures keep +the last successful snapshot and report an error. Metadata for unchanged track +URIs is reused. Refresh never changes the playback controller's current queue. +`Stop shadowing` removes only Retune's cached copy; it does not unfollow Spotify. +Shadows appear under **Shadowed playlists**, with a ◐ hint and a read-only status +showing their last successful refresh. All content mutation guards remain in +force because shadows are never owned playlists. One concrete `PlaylistState` owns the current cache, its filesystem store, and one global async mutation gate. The gate spans the latest cache snapshot, From 5df83619875a7cf89984dba73356bc976dff8eac Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Wed, 14 Oct 2026 15:27:00 -0400 Subject: [PATCH 2/2] fix(desktop): preserve browsing context and recover interrupted playback --- apps/desktop/src-tauri/build.rs | 1 + .../src-tauri/capabilities/preferences.json | 19 + .../autogenerated/open_preferences.toml | 11 + apps/desktop/src-tauri/permissions/main.toml | 1 + .../src-tauri/permissions/preferences.toml | 19 + .../src-tauri/src/lastfm_import/commands.rs | 2 + apps/desktop/src-tauri/src/lib.rs | 13 +- apps/desktop/src-tauri/src/playback/local.rs | 10 + apps/desktop/src-tauri/src/playback/mod.rs | 218 +++++++++- .../desktop/src-tauri/src/playback/reducer.rs | 81 ++++ .../src-tauri/src/playback_commands.rs | 22 +- .../src-tauri/src/playback_resources.rs | 24 +- .../src-tauri/src/settings_commands.rs | 74 +++- apps/desktop/src-tauri/src/store.rs | 33 ++ apps/desktop/src-tauri/tauri.conf.json | 5 +- apps/desktop/src/App.css | 10 + apps/desktop/src/App.tsx | 266 +++++++----- apps/desktop/src/PreferencesWindow.tsx | 57 +++ apps/desktop/src/appGateway.ts | 1 + apps/desktop/src/appState.ts | 27 +- apps/desktop/src/dialogViews.tsx | 13 +- apps/desktop/src/libraryViews.tsx | 4 +- apps/desktop/src/main.tsx | 8 +- apps/desktop/src/playbackGateway.ts | 2 +- apps/desktop/src/spotifyViews.tsx | 30 +- apps/desktop/src/types.ts | 6 +- apps/desktop/src/ui.ts | 36 +- .../src/useTauriInvalidationSnapshot.ts | 29 ++ apps/desktop/test/gateways.test.ts | 2 + apps/desktop/test/interactions.test.tsx | 390 +++++++++++++++++- apps/desktop/test/mainEntry.test.tsx | 6 +- apps/desktop/test/ui.test.ts | 53 ++- docs/DEVELOPMENT.md | 10 + docs/architecture/library.md | 14 +- docs/architecture/persistence.md | 13 +- docs/architecture/playback.md | 25 +- docs/tauri.md | 24 +- docs/ui-validation-2026-10-14.md | 56 +++ scripts/check-tauri-acl.mjs | 23 +- 39 files changed, 1428 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src-tauri/capabilities/preferences.json create mode 100644 apps/desktop/src-tauri/permissions/autogenerated/open_preferences.toml create mode 100644 apps/desktop/src-tauri/permissions/preferences.toml create mode 100644 apps/desktop/src/PreferencesWindow.tsx create mode 100644 apps/desktop/src/useTauriInvalidationSnapshot.ts create mode 100644 docs/ui-validation-2026-10-14.md diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index e81c40d..1919a1d 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -17,6 +17,7 @@ const APP_COMMANDS: &[&str] = &[ "subscribe_main_events", "unsubscribe_main_events", "get_settings", + "open_preferences", "get_appearance", "update_settings", "connection_state", diff --git a/apps/desktop/src-tauri/capabilities/preferences.json b/apps/desktop/src-tauri/capabilities/preferences.json new file mode 100644 index 0000000..d07650e --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/preferences.json @@ -0,0 +1,19 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "preferences", + "description": "Settings, account setup, and diagnostics for the Preferences window", + "windows": [ + "preferences" + ], + "platforms": [ + "linux", + "macOS", + "windows" + ], + "permissions": [ + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:window:allow-close", + "preferences-commands" + ] +} diff --git a/apps/desktop/src-tauri/permissions/autogenerated/open_preferences.toml b/apps/desktop/src-tauri/permissions/autogenerated/open_preferences.toml new file mode 100644 index 0000000..bea4d99 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/open_preferences.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-preferences" +description = "Enables the open_preferences command without any pre-configured scope." +commands.allow = ["open_preferences"] + +[[permission]] +identifier = "deny-open-preferences" +description = "Denies the open_preferences command without any pre-configured scope." +commands.deny = ["open_preferences"] diff --git a/apps/desktop/src-tauri/permissions/main.toml b/apps/desktop/src-tauri/permissions/main.toml index a251e72..291b7b5 100644 --- a/apps/desktop/src-tauri/permissions/main.toml +++ b/apps/desktop/src-tauri/permissions/main.toml @@ -20,6 +20,7 @@ permissions = [ "allow-subscribe-main-events", "allow-unsubscribe-main-events", "allow-get-settings", + "allow-open-preferences", "allow-get-appearance", "allow-update-settings", "allow-connection-state", diff --git a/apps/desktop/src-tauri/permissions/preferences.toml b/apps/desktop/src-tauri/permissions/preferences.toml new file mode 100644 index 0000000..67aa67b --- /dev/null +++ b/apps/desktop/src-tauri/permissions/preferences.toml @@ -0,0 +1,19 @@ +[[set]] +identifier = "preferences-commands" +description = "Settings, account setup, recovery, and diagnostics in Preferences." +permissions = [ + "allow-get-settings", + "allow-update-settings", + "allow-lastfm-state", + "allow-connect-lastfm", + "allow-finish-lastfm", + "allow-disconnect-lastfm", + "allow-lastfm-import-state", + "allow-sync-lastfm-plays", + "allow-open-lastfm-importer", + "allow-removed-retune-tracks", + "allow-restore-retune-track", + "allow-load-diagnostics", + "allow-email-diagnostics", + "allow-open-external-destination", +] diff --git a/apps/desktop/src-tauri/src/lastfm_import/commands.rs b/apps/desktop/src-tauri/src/lastfm_import/commands.rs index a348147..9ed1965 100644 --- a/apps/desktop/src-tauri/src/lastfm_import/commands.rs +++ b/apps/desktop/src-tauri/src/lastfm_import/commands.rs @@ -28,6 +28,8 @@ fn emit_to_import_windows( } fn emit_import_invalidated(app: &tauri::AppHandle) -> Result<(), String> { + app.emit_to("preferences", "lastfm-import-changed", ()) + .map_err(|error| error.to_string())?; emit_to_import_windows(app, "lastfm-import-changed", ()) } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6c1106e..fe64908 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -923,7 +923,12 @@ fn install_file_menu(app: &tauri::AppHandle, settings: &Settings) -> tauri::Resu .show(|_| {}); } "preferences" => { - let _ = emit_main(app, "open-preferences", ()); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = settings_commands::open_preferences(app.clone()).await { + notify_error(&app, error); + } + }); } "zoom_in" | "zoom_out" | "actual_size" | "toggle_zebra" | "toggle_browser" | "theme_system" | "theme_light" | "theme_dark" => { @@ -1098,6 +1103,7 @@ fn finish_startup( ), Arc::new(move |_| { let _ = emit_main(&lastfm_app, "lastfm-changed", ()); + let _ = lastfm_app.emit_to("preferences", "lastfm-changed", ()); }), ); let restore_mutations = Arc::new(restore_latch::RestoreMutationState::default()); @@ -1261,6 +1267,7 @@ fn finish_startup( if import_result.is_ok() { let _ = profile_app.emit_to("main", "lastfm-import-changed", ()); let _ = profile_app.emit_to("lastfm-importer", "lastfm-import-changed", ()); + let _ = profile_app.emit_to("preferences", "lastfm-import-changed", ()); } if let Err(error) = &lastfm_result { notify_error(&profile_app, error.clone()); @@ -1397,7 +1404,7 @@ pub fn run() { #[cfg(all(desktop, not(test)))] let builder = builder.plugin( tauri_plugin_window_state::Builder::default() - .with_denylist(&["lastfm-importer"]) + .with_denylist(&["lastfm-importer", "preferences"]) .with_state_flags( tauri_plugin_window_state::StateFlags::SIZE | tauri_plugin_window_state::StateFlags::POSITION @@ -1427,6 +1434,7 @@ pub fn run() { main_events::subscribe_main_events, main_events::unsubscribe_main_events, settings_commands::get_settings, + settings_commands::open_preferences, settings_commands::get_appearance, settings_commands::update_settings, spotify_commands::connection_state, @@ -3320,6 +3328,7 @@ mod tests { )]), sort_column: Some("plays".into()), sort_desc: true, + ignore_leading_the: true, auto_add_spotify_library: false, auto_connect: false, spotify_client_id: "exported-machine".into(), diff --git a/apps/desktop/src-tauri/src/playback/local.rs b/apps/desktop/src-tauri/src/playback/local.rs index c81e3f3..e70c1a1 100644 --- a/apps/desktop/src-tauri/src/playback/local.rs +++ b/apps/desktop/src-tauri/src/playback/local.rs @@ -37,6 +37,16 @@ pub(super) struct LocalBackend { } impl LocalBackend { + #[cfg(test)] + pub(super) fn interrupt_session_for_test(&mut self) { + let runtime = self.runtime.as_mut().unwrap(); + runtime.session.shutdown(); + let disconnected = Session::new(runtime.session.config().clone(), None); + disconnected.shutdown(); + runtime.player.set_session(disconnected.clone()); + runtime.session = disconnected; + } + #[cfg(test)] pub(super) fn with_snapshot_for_test(snapshot: Snapshot) -> Self { Self { diff --git a/apps/desktop/src-tauri/src/playback/mod.rs b/apps/desktop/src-tauri/src/playback/mod.rs index aaece82..ecd47e9 100644 --- a/apps/desktop/src-tauri/src/playback/mod.rs +++ b/apps/desktop/src-tauri/src/playback/mod.rs @@ -256,12 +256,14 @@ impl Snapshot { fn new_with( tracks: Vec, - index: usize, + index: Option, shuffle: bool, permute: impl FnOnce(&mut [usize]), ) -> Self { - let mut snapshot = Self::new(tracks, index); - if shuffle { + let mut snapshot = Self::new(tracks, index.unwrap_or(0)); + if shuffle && index.is_none() { + permute(&mut snapshot.order); + } else if shuffle { snapshot.set_shuffle_with(true, permute); } snapshot @@ -561,6 +563,7 @@ struct ControllerState { generation: u64, reducer: EventReducer, volume: u8, + load_intent: u64, } pub struct Playback { @@ -622,6 +625,7 @@ impl Playback { generation, reducer, volume: 62, + load_intent: 0, }), backend: tokio::sync::Mutex::new(PlayerBackend::Connect(ConnectBackend::new( events.clone(), @@ -727,19 +731,23 @@ impl Playback { index: usize, ) -> Result { let intent = self.begin_play_intent(); - self.play_for_intent(client, tracks, index, intent).await + self.play_for_intent(client, tracks, Some(index), intent) + .await } pub(crate) async fn play_for_intent( &self, client: Option>, tracks: Vec, - index: usize, + index: Option, intent: u64, ) -> Result { - let target_track_id = tracks.get(index).map(|track| track.id).unwrap_or(0); + let target_track_id = tracks + .get(index.unwrap_or(0)) + .map(|track| track.id) + .unwrap_or(0); let target_track_uri = tracks - .get(index) + .get(index.unwrap_or(0)) .map(|track| track.uri.clone()) .unwrap_or_default(); match self @@ -764,17 +772,20 @@ impl Playback { &self, client: Option>, tracks: Vec, - index: usize, + index: Option, intent: u64, permute: impl FnOnce(&mut [usize]), ) -> Result { - if tracks.is_empty() || index >= tracks.len() { + if tracks.is_empty() || index.unwrap_or(0) >= tracks.len() { return Err("Choose a track to play".into()); } if !self.is_current_play_intent(intent) { return Ok(PlayOutcome::Started); } - if reject_chapter(&tracks[index].uri) { + let mut backend = self.backend.lock().await; + let shuffle = self.state.lock().await.reducer.shuffle(); + let snapshot = Snapshot::new_with(tracks, index, shuffle, permute); + if reject_chapter(&snapshot.current().uri) { let generation = self.state.lock().await.generation; let _ = self.events.send(NeutralEvent::Error { generation, @@ -782,9 +793,6 @@ impl Playback { }); return Ok(PlayOutcome::Started); } - let mut backend = self.backend.lock().await; - let shuffle = self.state.lock().await.reducer.shuffle(); - let snapshot = Snapshot::new_with(tracks, index, shuffle, permute); if self.local_requested() && !is_file_uri(&snapshot.current().uri) { let client = require_spotify(client.as_deref())?; if let Err(error) = self.ensure_local_backend(&mut backend, client).await { @@ -1321,6 +1329,7 @@ impl Playback { return Ok(()); } state.reducer.queue_load(&uri, playing); + state.load_intent = intent; return Ok(state.file.load(&uri, playing, position_ms)?); } @@ -1347,6 +1356,7 @@ impl Playback { return Ok(()); } state.reducer.queue_load(&uri, playing); + state.load_intent = intent; let repeat = state.reducer.repeat(); drop(state); backend @@ -1387,12 +1397,66 @@ impl Playback { ] } + async fn retry_interrupted_load( + &self, + event: &NeutralEvent, + resolve_provider: &Arc, + ) -> bool { + if !matches!(event, NeutralEvent::Unavailable { uri, .. } if uri.starts_with("spotify:")) { + return false; + } + let intent = self.current_play_intent(); + let mut backend = self.backend.lock().await; + let PlayerBackend::Local(local) = &mut *backend else { + return false; + }; + if local.player_is_invalid() || !local.session_is_invalid() { + return false; + } + { + let state = self.state.lock().await; + if state.load_intent != intent { + return true; + } + if state.reducer.session_retry(event).is_none() { + return false; + } + } + let Ok(client) = resolve_provider() else { + return false; + }; + // A lost audio-key connection is recoverable; do not skip its track. + if let Err(error) = local.refresh_session(&client).await { + log::warn!("Unable to reconnect for interrupted Spotify load: {error}"); + return false; + } + if !self.is_current_play_intent(intent) { + return true; + } + let mut state = self.state.lock().await; + let Some((uri, playing, position_ms)) = state.reducer.queue_session_retry(event) else { + return true; + }; + log::info!("Retrying interrupted Spotify load after session refresh: uri={uri}"); + if let Err(error) = local.load(&uri, playing, position_ms) { + log::warn!("Unable to retry interrupted Spotify load: {error}"); + let _ = self.events.send(NeutralEvent::Error { + generation: state.generation, + message: error, + }); + } + true + } + async fn handle_event( self: &Arc, event: NeutralEvent, resolve_provider: &Arc, on_effect: &Arc, ) { + if self.retry_interrupted_load(&event, resolve_provider).await { + return; + } let (actions, facts) = { let mut state = self.state.lock().await; let actions = state.reducer.handle(event); @@ -1884,6 +1948,104 @@ mod tests { )) } + #[tokio::test] + async fn file_failures_do_not_attempt_spotify_session_recovery() { + let playback = Playback::default(); + let resolver: Arc = + Arc::new(|| panic!("file failure must not connect to Spotify")); + let event = NeutralEvent::Unavailable { + generation: 1, + request_id: 1, + uri: "file:///missing.mp3".into(), + }; + assert!(!playback.retry_interrupted_load(&event, &resolver).await); + } + + #[tokio::test] + #[ignore = "requires Spotify playback credentials, network, and an audio device"] + async fn live_interrupted_load_recovers_same_track() { + let path = std::env::var("RETUNE_PLAYBACK_PROBE_TOKENS").expect("dev token path"); + let tokens = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let store: Box = Box::new(InMemoryTokenStore::new(Some(tokens))); + let client = Arc::new(SpotifyClient::new( + "test", + HttpTransport::new(), + Arc::new(CachedTokenStore::new(store)), + )); + let playback = Arc::new(Playback::default()); + playback.set_requested_backend(PlaybackBackend::Local); + let intent = playback.begin_play_intent(); + let cache = tempfile::tempdir().unwrap(); + let audio = *playback.audio.lock().unwrap(); + let mut local = LocalBackend::activate( + &client, + playback.events.clone(), + 1, + 0, + Some(cache.path()), + audio, + ) + .await + .unwrap(); + let uri = "spotify:track:4YAEQfowheSnPLKJKD6alk"; + let snapshot = Snapshot::new( + vec![ + SnapshotTrack { + id: 1, + uri: uri.into(), + name: "Follow You".into(), + art: "First to Eleven".into(), + alb: String::new(), + duration_secs: 235, + }, + SnapshotTrack { + id: 2, + uri: "spotify:track:4SaYK1xomZ5jAIOnqClQsV".into(), + name: "Next track".into(), + art: String::new(), + alb: String::new(), + duration_secs: 295, + }, + ], + 0, + ); + { + let mut state = playback.state.lock().await; + state.reducer.set_snapshot(Some(snapshot.clone())); + state.reducer.queue_load(uri, true); + state.load_intent = intent; + } + local.interrupt_session_for_test(); + local.play(snapshot, true, 0).unwrap(); + *playback.backend.lock().await = PlayerBackend::Local(local); + let mut receiver = playback.receiver.lock().unwrap().take().unwrap(); + let resolver: Arc = Arc::new(move || Ok(client.clone())); + let effects: Arc = Arc::new(|effect| { + assert!( + !matches!(effect, PlaybackEffect::OperationError(_)), + "{effect:?}" + ); + }); + tokio::time::timeout(Duration::from_secs(60), async { + let mut failed = false; + loop { + let event = receiver.recv().await.unwrap(); + if matches!(&event, NeutralEvent::Unavailable { uri: failed_uri, .. } if failed_uri == uri) { + assert!(!failed, "retry also failed"); + failed = true; + } + let recovered = matches!(&event, NeutralEvent::PositionChanged { uri: playing_uri, position_ms, .. } if playing_uri == uri && *position_ms >= 1000); + playback.handle_event(event, &resolver, &effects).await; + assert_eq!(playback.state.lock().await.reducer.snapshot().unwrap().index, 0); + if recovered { + assert!(failed, "test must exercise a failed load before recovery"); + break; + } + } + }).await.expect("same track should recover and decode after reconnection"); + playback.backend.lock().await.stop(None).await.unwrap(); + } + #[test] fn shuffle_only_permutes_future_and_restores_duplicate_occurrence() { let duplicate = SnapshotTrack { @@ -1984,13 +2146,29 @@ mod tests { assert!(state.reducer.state().shuffle); } + #[test] + fn collection_start_shuffles_every_track_and_retains_canonical_order() { + let mut shuffled = Snapshot::new_with(file_tracks(5), None, true, |order| order.reverse()); + assert_eq!(shuffled.order, [4, 3, 2, 1, 0]); + assert_eq!(shuffled.index, 0); + assert_eq!(shuffled.current().id, 5); + shuffled.set_shuffle_with(false, |_| unreachable!()); + assert_eq!(shuffled.order, [0, 1, 2, 3, 4]); + assert_eq!(shuffled.current().id, 5); + let ordered = Snapshot::new_with(file_tracks(5), None, false, |_| unreachable!()); + assert_eq!(ordered.order, [0, 1, 2, 3, 4]); + assert_eq!(ordered.current().id, 1); + } + #[tokio::test] async fn enabled_shuffle_constructs_exact_queue_then_event_advance_and_prev_follow_it() { let playback = Playback::default(); playback.set_shuffle_with(true, |_| unreachable!()).await; let intent = playback.begin_play_intent(); playback - .play_with(None, file_tracks(5), 1, intent, |suffix| suffix.reverse()) + .play_with(None, file_tracks(5), Some(1), intent, |suffix| { + suffix.reverse() + }) .await .unwrap(); let mut state = playback.state.lock().await; @@ -2430,7 +2608,9 @@ mod tests { playback.set_shuffle_with(true, |_| unreachable!()).await; let intent = playback.begin_play_intent(); playback - .play_with(None, file_tracks(4), 1, intent, |suffix| suffix.reverse()) + .play_with(None, file_tracks(4), Some(1), intent, |suffix| { + suffix.reverse() + }) .await .unwrap(); let request = receiver.recv().await.unwrap(); @@ -2640,7 +2820,7 @@ mod tests { let mut stale = file_tracks(1); stale[0].id = 99; playback - .play_for_intent(None, stale, 0, older) + .play_for_intent(None, stale, Some(0), older) .await .unwrap(); } @@ -2648,7 +2828,7 @@ mod tests { let newer = playback.begin_play_intent(); playback - .play_for_intent(None, file_tracks(1), 0, newer) + .play_for_intent(None, file_tracks(1), Some(0), newer) .await .unwrap(); release.notify_one(); @@ -2696,7 +2876,7 @@ mod tests { let stopped = playback.begin_play_intent(); playback.stop(None).await.unwrap(); playback - .play_for_intent(None, file_tracks(1), 0, stopped) + .play_for_intent(None, file_tracks(1), Some(0), stopped) .await .unwrap(); assert!(playback.state.lock().await.reducer.snapshot().is_none()); @@ -2704,7 +2884,7 @@ mod tests { let switched = playback.begin_play_intent(); playback.set_requested_backend(PlaybackBackend::Connect); playback - .play_for_intent(None, file_tracks(1), 0, switched) + .play_for_intent(None, file_tracks(1), Some(0), switched) .await .unwrap(); assert!(playback.state.lock().await.reducer.snapshot().is_none()); diff --git a/apps/desktop/src-tauri/src/playback/reducer.rs b/apps/desktop/src-tauri/src/playback/reducer.rs index 8a1c0af..1d07802 100644 --- a/apps/desktop/src-tauri/src/playback/reducer.rs +++ b/apps/desktop/src-tauri/src/playback/reducer.rs @@ -28,6 +28,7 @@ struct LoadIntent { id: u64, uri: String, playing: bool, + session_retried: bool, } pub(super) struct EventReducer { @@ -153,11 +154,41 @@ impl EventReducer { id: self.next_intent, uri: uri.to_owned(), playing, + session_retried: false, }; self.latest_intent = Some(intent.id); self.pending.push_back(intent); } + pub(super) fn session_retry(&self, event: &NeutralEvent) -> Option<(String, bool, u32)> { + let NeutralEvent::Unavailable { + generation, + request_id, + uri, + } = event + else { + return None; + }; + let intent = self.current_intent(*request_id, uri)?; + if *generation != self.generation + || intent.session_retried + || self.snapshot.as_ref()?.current().uri != *uri + { + return None; + } + Some((uri.clone(), intent.playing, self.previous_position_ms)) + } + + pub(super) fn queue_session_retry( + &mut self, + event: &NeutralEvent, + ) -> Option<(String, bool, u32)> { + let retry = self.session_retry(event)?; + self.queue_load(&retry.0, retry.1); + self.pending.back_mut()?.session_retried = true; + Some(retry) + } + pub(super) fn handle(&mut self, event: NeutralEvent) -> Vec { if event.generation() != self.generation { return vec![]; @@ -808,6 +839,56 @@ mod tests { )); } + #[test] + fn interrupted_load_retries_same_track_once_and_rejects_stale_events() { + let mut reducer = reducer(); + bind(&mut reducer, "spotify:track:1", false, 1); + reducer.handle(NeutralEvent::Loading { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + position_ms: 1250, + }); + let failure = NeutralEvent::Unavailable { + generation: 7, + request_id: 1, + uri: "spotify:track:1".into(), + }; + assert_eq!( + reducer.queue_session_retry(&failure), + Some(("spotify:track:1".into(), false, 1250)) + ); + assert_eq!(reducer.snapshot().unwrap().index, 0); + assert!(reducer.queue_session_retry(&failure).is_none()); + assert!(reducer.handle(failure.clone()).is_empty()); + reducer.handle(NeutralEvent::RequestIdChanged { + generation: 7, + request_id: 2, + }); + let second_failure = NeutralEvent::Unavailable { + generation: 7, + request_id: 2, + uri: "spotify:track:1".into(), + }; + assert!(reducer.queue_session_retry(&second_failure).is_none()); + assert!(matches!( + reducer.handle(second_failure).as_slice(), + [ReducerAction::Error(_), ReducerAction::Advance] + )); + + bind(&mut reducer, "spotify:track:1", true, 3); + let new_failure = NeutralEvent::Unavailable { + generation: 7, + request_id: 3, + uri: "spotify:track:1".into(), + }; + assert!(reducer.session_retry(&new_failure).is_some()); + reducer.queue_load("spotify:track:2", true); + assert!(reducer.queue_session_retry(&new_failure).is_none()); + reducer.activate(8); + assert!(reducer.queue_session_retry(&failure).is_none()); + } + #[test] fn disconnect_while_playing_invalidates_and_reconnects() { let mut reducer = reducer(); diff --git a/apps/desktop/src-tauri/src/playback_commands.rs b/apps/desktop/src-tauri/src/playback_commands.rs index 4aeb731..0bef692 100644 --- a/apps/desktop/src-tauri/src/playback_commands.rs +++ b/apps/desktop/src-tauri/src/playback_commands.rs @@ -89,7 +89,7 @@ pub(crate) async fn handle_media_control( pub(super) async fn play_tracks( app: tauri::AppHandle, resources: Vec, - start_index: usize, + start_index: Option, ) -> Result { let state = app.state::(); let intent = state.playback.begin_play_intent(); @@ -105,7 +105,7 @@ pub(super) async fn play_tracks( .expect("Spotify catalog mutex poisoned") .clone(); let client = provider_from(&state).ok(); - let (snapshot, start_index) = resolve_resources( + let (snapshot, resolved_index) = resolve_resources( resources, start_index, library, @@ -116,7 +116,12 @@ pub(super) async fn play_tracks( .await?; let outcome = state .playback - .play_for_intent(client.clone(), snapshot, start_index, intent) + .play_for_intent( + client.clone(), + snapshot, + start_index.map(|_| resolved_index), + intent, + ) .await?; if let PlayOutcome::PlaybackAuthorizationRequired(prompt) = &outcome { let client = provider_from(&state).ok(); @@ -133,7 +138,7 @@ pub(super) async fn play_tracks( async fn resolve_resources( resources: Vec, - start_index: usize, + start_index: Option, library: Library, playlists: crate::playlists::PlaylistCache, catalog: SpotifyCatalog, @@ -142,7 +147,7 @@ async fn resolve_resources( let (resources, mut resolved, enabled) = tauri::async_runtime::spawn_blocking(move || { let (resolved, enabled) = playback_resources::resolve_cached( &resources, - start_index, + start_index.unwrap_or(0), &library, &playlists, &catalog, @@ -310,6 +315,7 @@ mod tests { owner: None, track_count: 1, tracks: vec![uri.into()], + shadow: None, track_metadata_version: TRACK_METADATA_VERSION, spotify_tracks: vec![CachedTrack { uri: uri.into(), @@ -334,7 +340,7 @@ mod tests { ] { let (tracks, index) = resolve_resources( vec![resource(7, uri)], - 0, + Some(0), Library::new(), playlist_track(uri, name), SpotifyCatalog::default(), @@ -364,7 +370,7 @@ mod tests { ); let (tracks, _) = resolve_resources( vec![resource(8, "spotify:track:track2")], - 0, + Some(0), Library::new(), PlaylistCache::default(), SpotifyCatalog::default(), @@ -393,7 +399,7 @@ mod tests { ]; let (tracks, index) = resolve_resources( resources.to_vec(), - 1, + Some(1), library, playlist_track("spotify:track:track3", "Spotify"), SpotifyCatalog::default(), diff --git a/apps/desktop/src-tauri/src/playback_resources.rs b/apps/desktop/src-tauri/src/playback_resources.rs index 7b0b601..c2e4e7e 100644 --- a/apps/desktop/src-tauri/src/playback_resources.rs +++ b/apps/desktop/src-tauri/src/playback_resources.rs @@ -93,23 +93,26 @@ pub(crate) fn resolve_cached( pub(crate) fn finish( tracks: Vec, - start_index: usize, + start_index: Option, enabled: &[bool], ) -> Result<(Vec, usize), String> { - if tracks.get(start_index).is_none() || enabled.len() != tracks.len() { + if tracks.get(start_index.unwrap_or(0)).is_none() || enabled.len() != tracks.len() { return Err("Playback start index is out of range.".into()); } let mut resolved_index = 0; - let tracks = tracks + let tracks: Vec<_> = tracks .into_iter() .enumerate() .filter_map(|(index, track)| { - if index < start_index && enabled[index] { + if index < start_index.unwrap_or(0) && enabled[index] { resolved_index += 1; } - (index == start_index || enabled[index]).then_some(track) + (Some(index) == start_index || enabled[index]).then_some(track) }) .collect(); + if tracks.is_empty() { + return Err("Choose an enabled track to play.".into()); + } Ok((tracks, resolved_index)) } @@ -360,7 +363,14 @@ mod tests { duration_secs: 1, }) .to_vec(); - let (tracks, index) = finish(tracks, 1, &[false, false, true]).unwrap(); + let (collection, index) = finish(tracks.clone(), None, &[false, false, true]).unwrap(); + assert_eq!( + collection.iter().map(|track| track.id).collect::>(), + [3] + ); + assert_eq!(index, 0); + assert!(finish(tracks.clone(), None, &[false, false, false]).is_err()); + let (tracks, index) = finish(tracks, Some(1), &[false, false, true]).unwrap(); assert_eq!( tracks.iter().map(|track| track.id).collect::>(), [2, 3] @@ -437,7 +447,7 @@ mod tests { let (tracks, enabled) = resolve_cached(&resources, 0, &library, &playlists, &catalog).unwrap(); let tracks = tracks.into_iter().collect::>>().unwrap(); - finish(tracks, 0, &enabled).unwrap().0 + finish(tracks, Some(0), &enabled).unwrap().0 }; let expected = resources .iter() diff --git a/apps/desktop/src-tauri/src/settings_commands.rs b/apps/desktop/src-tauri/src/settings_commands.rs index ae6451c..7dd366f 100644 --- a/apps/desktop/src-tauri/src/settings_commands.rs +++ b/apps/desktop/src-tauri/src/settings_commands.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use serde::Serialize; -use tauri::{Emitter, Manager}; +use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::{ emit_main, @@ -16,6 +16,23 @@ pub(super) fn get_settings(state: tauri::State<'_, AppState>) -> SettingsView { SettingsView::from(&state.settings.snapshot()) } +#[tauri::command] +pub(super) async fn open_preferences(app: tauri::AppHandle) -> Result<(), String> { + if let Some(window) = app.get_webview_window("preferences") { + window.show().map_err(|error| error.to_string())?; + window.unminimize().map_err(|error| error.to_string())?; + return window.set_focus().map_err(|error| error.to_string()); + } + WebviewWindowBuilder::new(&app, "preferences", WebviewUrl::App("index.html".into())) + .title("Preferences") + .inner_size(680.0, 640.0) + .min_inner_size(480.0, 360.0) + .resizable(true) + .build() + .map(|_| ()) + .map_err(|error| error.to_string()) +} + #[derive(Clone, Copy, Serialize)] #[serde(rename_all = "camelCase")] pub(super) struct Appearance { @@ -35,6 +52,8 @@ pub(crate) fn emit_settings_changed( current: &Settings, ) -> Result<(), String> { emit_main(app, "settings-changed", ()).map_err(|error| error.to_string())?; + app.emit_to("preferences", "settings-changed", ()) + .map_err(|error| error.to_string())?; if previous.theme != current.theme { app.emit_to( "lastfm-importer", @@ -51,6 +70,7 @@ pub(crate) fn emit_settings_changed( struct SettingsCommit { client_id_changed: bool, audio_changed: bool, + backend_changed: bool, settings: Settings, } @@ -61,10 +81,11 @@ async fn persist_settings_patch( committed: impl FnOnce(&Settings, &Settings) -> Result<(), String>, ) -> Result { patch.validate()?; - let ((client_id_changed, audio_changed), settings) = settings + let ((client_id_changed, audio_changed, backend_changed), settings) = settings .mutate( |settings| { let previous_client_id = settings.spotify_client_id.clone(); + let previous_backend = settings.playback_backend; let previous_audio = ( settings.streaming_bitrate, settings.normalize_volume, @@ -84,6 +105,7 @@ async fn persist_settings_patch( settings.normalize_volume, settings.gapless, ), + previous_backend != settings.playback_backend, )) }, committed, @@ -92,6 +114,7 @@ async fn persist_settings_patch( Ok(SettingsCommit { client_id_changed, audio_changed, + backend_changed, settings, }) } @@ -111,6 +134,7 @@ pub(super) async fn update_settings( let SettingsCommit { client_id_changed, audio_changed, + backend_changed, settings, } = persist_settings_patch( &state.settings, @@ -119,9 +143,11 @@ pub(super) async fn update_settings( |previous, current| emit_settings_changed(&app, previous, current), ) .await?; - state - .playback - .set_requested_backend(settings.playback_backend); + if backend_changed { + state + .playback + .set_requested_backend(settings.playback_backend); + } // Local activation is intentionally lazy: playback owns authorization // prompts, and unrelated preference saves must remain offline-safe. match settings.playback_backend { @@ -287,6 +313,44 @@ mod tests { ); } + #[tokio::test] + async fn unrelated_settings_do_not_request_a_backend_switch() { + let temp = tempfile::tempdir().unwrap(); + let state = SettingsState::new(Settings::default(), FsSettingsStore::new(temp.path())); + let original = state.snapshot().playback_backend; + for patch in [ + SettingsPatch { + ignore_leading_the: Some(true), + ..SettingsPatch::default() + }, + SettingsPatch { + playback_backend: Some(original), + ..SettingsPatch::default() + }, + ] { + let result = persist_settings_patch(&state, patch, None, |_, _| Ok(())) + .await + .unwrap(); + assert!(!result.backend_changed); + } + let other = match original { + PlaybackBackend::Local => PlaybackBackend::Connect, + PlaybackBackend::Connect => PlaybackBackend::Local, + }; + let result = persist_settings_patch( + &state, + SettingsPatch { + playback_backend: Some(other), + ..SettingsPatch::default() + }, + None, + |_, _| Ok(()), + ) + .await + .unwrap(); + assert!(result.backend_changed); + } + #[test] fn oversized_settings_patch_is_rejected_before_store_or_memory_changes() { fn assert_rejected_without_store(patch: SettingsPatch) { diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 0e85c98..13b4a4c 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -145,6 +145,8 @@ pub struct Settings { pub sort_column: Option, #[serde(default)] pub sort_desc: bool, + #[serde(default)] + pub ignore_leading_the: bool, pub auto_add_spotify_library: bool, #[serde(default = "default_true")] pub auto_connect: bool, @@ -195,6 +197,7 @@ pub struct SettingsView { pub playlist_column_widths: BTreeMap>, pub sort_column: Option, pub sort_desc: bool, + pub ignore_leading_the: bool, pub auto_add_spotify_library: bool, pub auto_connect: bool, pub spotify_client_id: String, @@ -226,6 +229,7 @@ impl From<&Settings> for SettingsView { playlist_column_widths: settings.playlist_column_widths.clone(), sort_column: settings.sort_column.clone(), sort_desc: settings.sort_desc, + ignore_leading_the: settings.ignore_leading_the, auto_add_spotify_library: settings.auto_add_spotify_library, auto_connect: settings.auto_connect, spotify_client_id: settings.spotify_client_id.clone(), @@ -260,6 +264,7 @@ pub struct SettingsPatch { #[serde(deserialize_with = "present_nullable")] pub sort_column: Option>, pub sort_desc: Option, + pub ignore_leading_the: Option, pub auto_add_spotify_library: Option, pub auto_connect: Option, pub spotify_client_id: Option, @@ -356,6 +361,7 @@ impl SettingsPatch { playlist_column_widths, sort_column, sort_desc, + ignore_leading_the, auto_add_spotify_library, auto_connect, spotify_client_id, @@ -427,6 +433,7 @@ impl Default for Settings { playlist_column_widths: BTreeMap::new(), sort_column: None, sort_desc: false, + ignore_leading_the: false, auto_add_spotify_library: true, auto_connect: true, spotify_client_id: String::new(), @@ -2370,6 +2377,7 @@ mod tests { ]), sort_column: Some("artist".into()), sort_desc: true, + ignore_leading_the: true, auto_add_spotify_library: true, auto_connect: false, spotify_client_id: "client-id".into(), @@ -3264,11 +3272,36 @@ mod tests { assert_eq!(settings.sort_column, None); assert!(!settings.sort_desc); + assert!(!settings.ignore_leading_the); assert!(settings.playlist_hidden_columns.is_empty()); assert!(settings.playlist_column_orders.is_empty()); assert!(settings.playlist_column_widths.is_empty()); } + #[test] + fn alphabetizing_preference_round_trips_and_can_be_disabled() { + let mut settings = Settings::default(); + let patch: SettingsPatch = serde_json::from_value(serde_json::json!({ + "ignoreLeadingThe": true + })) + .unwrap(); + patch.validate().unwrap(); + patch.apply(&mut settings); + let restored: Settings = + serde_json::from_value(serde_json::to_value(&settings).unwrap()).unwrap(); + assert!(restored.ignore_leading_the); + assert_eq!( + serde_json::to_value(SettingsView::from(&restored)).unwrap()["ignoreLeadingThe"], + true + ); + SettingsPatch { + ignore_leading_the: Some(false), + ..SettingsPatch::default() + } + .apply(&mut settings); + assert!(!settings.ignore_leading_the); + } + #[test] fn missing_playback_backend_defaults_to_local() { let settings: Settings = serde_json::from_value(serde_json::json!({ diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 4f0994c..0f26ebc 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -13,7 +13,7 @@ "windows": [ { "label": "main", - "title": "Retune — Library", + "title": "Retune \u2014 Library", "width": 1120, "height": 720, "resizable": true, @@ -23,7 +23,8 @@ "security": { "capabilities": [ "default", - "lastfm-importer" + "lastfm-importer", + "preferences" ], "csp": "default-src 'self'; base-uri 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; img-src 'self' https://i.scdn.co data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost" } diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 983ea95..94e2ddd 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -93,6 +93,8 @@ button { color: inherit; } .playlist-row { width: 100%; height: 22px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 28px; align-items: center; padding: 0 10px; border: 0; overflow: hidden; color: var(--text); background: transparent; text-align: left; } .playlist-row > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .playlist-row.active { color: #fff; background: var(--accent); font-weight: 600; } +.sidebar:focus-visible, .source-row:focus-visible, .playlist-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.playlist-row.active:focus-visible { outline-color: var(--list); } .playlist-row.dragging { opacity: .55; } .playlist-row.insert-before { box-shadow: inset 0 2px var(--accent); } .playlist-row.insert-after { box-shadow: inset 0 -2px var(--accent); } @@ -486,3 +488,11 @@ button.spotify-page-art:focus-visible { outline: 2px solid var(--accent); outlin .shadow-candidate { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 0; } .shadow-candidate small { display: block; opacity: .7; } .shadow-status { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 12px; font-size: 11px; } + +.preferences { user-select: none; -webkit-user-select: none; } +.preferences input, .preferences textarea, .preferences .diagnostic-log { user-select: text; -webkit-user-select: text; } +.preferences-window { height: 100%; display: flex; flex-direction: column; color: var(--text); background: var(--control); font-size: 11px; } +.preferences-window > .modal-backdrop { position: static; flex: 1; min-height: 0; display: flex; padding: 0; background: none; } +.preferences-window > .modal-backdrop > .preferences { width: 100%; height: 100%; max-height: none; border: 0; border-radius: 0; box-shadow: none; } +.preferences-window .preferences > h2 { display: none; } +.preferences-window .preference-toolbar, .preferences-window .modal-actions { flex-shrink: 0; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 75c1346..b385da5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,11 +1,11 @@ import { ShadowDiscovery } from './ShadowDiscovery' import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' -import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react' +import { Fragment, useCallback, useEffect, useEffectEvent, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react' import './App.css' -import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseTypeaheadContextKey, browseViewForRequest, cancelTrackInfoLoad, COLUMN_SPECS, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, DRAG_LOCAL_TYPE, DRAG_TYPE, entityRequestGeneration, facetLabel, failedPlaylistRows, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentRequestGeneration, isCurrentTrack, labels, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, moveBefore, moveToIndex, normalizeZoom, pendingEntities, pendingPlaybackTarget, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resolvedPlaylistRows, resizedColumnWidth, routeGlobalShortcut, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' -import { defaultSettings, initialState, reducer, type Action, type State } from './appState.ts' -import { GetInfo, MultipleItemInformation, PlaybackAuthorization, Preferences, SetupLibrary } from './dialogViews.tsx' +import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseTypeaheadContextKey, browseViewForRequest, cancelTrackInfoLoad, COLUMN_SPECS, compareBrowseTracks, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, DRAG_LOCAL_TYPE, DRAG_TYPE, entityRequestGeneration, facetLabel, failedPlaylistRows, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentRequestGeneration, isCurrentTrack, labels, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, moveBefore, moveToIndex, normalizeZoom, pendingEntities, pendingPlaybackTarget, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_COLUMNS, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, resolvedPlaylistRows, resizedColumnWidth, routeGlobalShortcut, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns, visibleColumnOrder } from './ui.ts' +import { initialState, reducer, type Action, type State } from './appState.ts' +import { GetInfo, MultipleItemInformation, PlaybackAuthorization, SetupLibrary } from './dialogViews.tsx' import { AlbumRatingStrip, BrowserPane, TrackCell, TrackContextMenu, TrackList } from './libraryViews.tsx' import { SpotifyPageBack, SpotifySearch } from './spotifyViews.tsx' import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, LastFmImportState, PlaybackOrigin, PlaybackTrack, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, SettingsPatch, Source, SpotifySyncStatus, Theme, Track } from './types.ts' @@ -14,9 +14,10 @@ import { importDownloadPercent, importDownloadProgressLabel, importStatusText } import { libraryEvents, libraryGateway } from './libraryGateway.ts' import { playbackEvents, playbackGateway } from './playbackGateway.ts' import { createPlaybackProgress, documentVisible, onlyPlaybackProgressChanged, subscribeDocumentVisibility } from './playbackProgress.ts' +import { useTauriInvalidationSnapshot } from './useTauriInvalidationSnapshot.ts' import { useTrackWindow } from './useTrackWindow.ts' import { spotifyEvents, spotifyGateway } from './spotifyGateway.ts' -import { dispatchMainEvent, subscribeInvalidationThenSnapshot, subscribeMainEvents, type MainEvent, type SpotifyPlayRequest } from './ipc.ts' +import { dispatchMainEvent, subscribeMainEvents, type MainEvent, type SpotifyPlayRequest } from './ipc.ts' import { appGateway } from './appGateway.ts' import { lastfmGateway } from './lastfmGateway.ts' import { RemoveTrackDialog, TrackMergeDialog } from './trackDecisionDialogs.tsx' @@ -45,37 +46,13 @@ function useTauriEvent(event: string, handler: (payload: T) => void }, [event]) } -function useTauriInvalidationSnapshot( - event: string, - snapshot: () => Promise, - handler: (payload: T) => void, - onError: (error: unknown) => void, -) { - const read = useEffectEvent(snapshot) - const handle = useEffectEvent(handler) - const reportError = useEffectEvent(onError) - useEffect(() => { - let active = true - const subscription = subscribeInvalidationThenSnapshot( - (invalidate) => listen(event, invalidate), - () => read(), - (value) => handle(value), - () => active, - (error) => { if (active) reportError(error) }, - ) - void subscription.catch((error) => { if (active) reportError(error) }) - return () => { - active = false - void subscription.then((stop) => stop()).catch(() => {}) - } - }, [event]) -} function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Playing | null, dispatch: React.Dispatch) { const [progress] = useState(createPlaybackProgress) const queue = useRef(emptyTracks) const origin = useRef(undefined) - const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin; awaitingPlaybackAuthorization: boolean } | null>(null) + const collectionStart = useRef(false) + const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin; collection?: boolean; awaitingPlaybackAuthorization: boolean } | null>(null) const playGeneration = useRef(0) const playingRef = useRef(playing) const volumeTimer = useRef(undefined) @@ -95,7 +72,7 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const id = pendingPlaybackTarget(prompt, queue.current) if (id === null) return beginRequestGeneration(playGeneration) - pendingPlay.current = { id, tracks: queue.current, origin: origin.current, awaitingPlaybackAuthorization: true } + pendingPlay.current = { id, tracks: queue.current, origin: origin.current, collection: collectionStart.current, awaitingPlaybackAuthorization: true } dispatch({ type: 'playbackAuthorization', prompt }) }, [dispatch]) @@ -103,7 +80,7 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla request().catch(() => dispatch({ type: 'error', error: message })) }, [dispatch]) - const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin) => { + const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin, collection = false) => { const target = tracks.find((track) => track.id === id) if (!target || (!target.uri.startsWith('file:') && !target.uri.startsWith('spotify:'))) { dispatch({ type: 'error', error: 'This track can’t be played because its source isn’t supported.' }) @@ -118,20 +95,24 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla const request = beginRequestGeneration(playGeneration) queue.current = playable origin.current = launchOrigin + collectionStart.current = collection pendingPlay.current = null dispatch({ type: 'playbackAuthorization', prompt: null }) if (playbackStartAction(target.uri, connected) === 'connect') { // Kick off the OAuth flow instead of erroring; the pending play fires // once connection-changed reports connected. - pendingPlay.current = { id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: false } + pendingPlay.current = { id, tracks: playable, origin: launchOrigin, collection, awaitingPlaybackAuthorization: false } run(spotifyGateway.connect, 'Couldn’t connect to Spotify. Try again.') return } - playbackGateway.play(playable, targetIndex) + playbackGateway.play(playable, collection ? null : targetIndex) .then((outcome) => { const authorization = currentPlaybackAuthorization(request, playGeneration, outcome, playable) - if (!authorization) return - pendingPlay.current = { id: authorization.id, tracks: playable, origin: launchOrigin, awaitingPlaybackAuthorization: true } + if (!authorization) { + if (isCurrentRequestGeneration(request, playGeneration)) collectionStart.current = false + return + } + pendingPlay.current = { id: authorization.id, tracks: playable, origin: launchOrigin, collection, awaitingPlaybackAuthorization: true } dispatch({ type: 'playbackAuthorization', prompt: authorization.prompt }) }) .catch(() => { @@ -139,12 +120,33 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla }) }, [connected, dispatch, run]) + const startPlaylist = useCallback(async (playlist: PlaylistListView) => { + if (!playlist.itemsAvailable) { + dispatch({ type: 'error', error: 'Spotify does not make this playlist’s tracks available to Retune.' }) + return + } + const request = beginRequestGeneration(playGeneration) + pendingPlay.current = null + try { + const rows = await spotifyGateway.playlistTracks(playlist.id) + if (!isCurrentRequestGeneration(request, playGeneration)) return + const tracks = playbackQueue(rows.map((track, index) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + index }))) + if (!tracks.length) { + dispatch({ type: 'error', error: 'This playlist has no enabled, playable tracks.' }) + return + } + start(tracks[0].id, tracks, { kind: 'playlist', id: playlist.id }, true) + } catch { + if (isCurrentRequestGeneration(request, playGeneration)) dispatch({ type: 'error', error: 'Couldn’t load this playlist for playback. Try again.' }) + } + }, [dispatch, start]) + useEffect(() => { if (!connected || !pendingPlay.current) return if (!playbackRetryReady(connected, playbackAuthorized, pendingPlay.current.awaitingPlaybackAuthorization)) return - const { id, tracks, origin: launchOrigin } = pendingPlay.current + const { id, tracks, origin: launchOrigin, collection } = pendingPlay.current pendingPlay.current = null - start(id, tracks, launchOrigin) + start(id, tracks, launchOrigin, collection) }, [connected, playbackAuthorized, start]) const cancelPending = useCallback(() => { @@ -185,8 +187,8 @@ function usePlayer(connected: boolean, playbackAuthorized: boolean, playing: Pla useEffect(() => () => window.clearTimeout(volumeTimer.current), []) return useMemo( - () => ({ start, toggle, playRequestedTrack, step, setVolume, seek, cancelPending, onState, onAuthorizationRequired, progress }), - [cancelPending, onAuthorizationRequired, onState, playRequestedTrack, progress, seek, setVolume, start, step, toggle], + () => ({ start, startPlaylist, toggle, playRequestedTrack, step, setVolume, seek, cancelPending, onState, onAuthorizationRequired, progress }), + [cancelPending, onAuthorizationRequired, onState, playRequestedTrack, progress, seek, setVolume, start, startPlaylist, step, toggle], ) } @@ -199,13 +201,15 @@ function App() { const [activePane, setActivePane] = useState('track') const [playlists, setPlaylists] = useState() const [playlistSubject, setPlaylistSubject] = useState() + const [playlistTotals, setPlaylistTotals] = useState<{ playlistId: string; counts: Pick | null }>() const [artworkOpen, setArtworkOpen] = useState(false) const [trackDecision, setTrackDecision] = useState<{ kind: 'merge'; ids: number[] } | { kind: 'remove'; tracks: Pick[]; spotify: boolean }>() const [spotifySearchRetry, setSpotifySearchRetry] = useState(0) const [playingMenu, setPlayingMenu] = useState<{ x: number; y: number; uri: string; name: string; trackId?: number }>() const [browserPlayKey, setBrowserPlayKey] = useState() + const [originReveal, setOriginReveal] = useState<{ uri: string; origin: PlaybackOrigin; queryReset: number }>() + const libraryRevealHandled = useRef(undefined) const search = useRef(null) - const preferenceZoom = useRef(defaultSettings.zoom) const facetAnchors = useRef>>({}) const typeahead = useRef<{ buffer: string; timer: number; context: string; candidates: { value: string; id?: number }[] }>({ buffer: '', timer: 0, context: '', candidates: [] }) const browseDesiredToken = useRef('') @@ -218,8 +222,8 @@ function App() { const view = browseViewForRequest(state.view, state.viewKey, browseKey) const tracks = view?.tracks ?? emptyTracks const displayedTracks = useMemo(() => state.settings.sortColumn - ? [...tracks].sort((left, right) => compareTracks(left, right, state.settings.sortColumn!, state.settings.sortDesc)) - : tracks, [state.settings.sortColumn, state.settings.sortDesc, tracks]) + ? [...tracks].sort((left, right) => compareTracks(left, right, state.settings.sortColumn!, state.settings.sortDesc, state.settings.ignoreLeadingThe)) + : state.settings.ignoreLeadingThe ? [...tracks].sort((left, right) => compareBrowseTracks(left, right, state.sel)) : tracks, [state.settings.sortColumn, state.settings.sortDesc, state.settings.ignoreLeadingThe, state.sel, tracks]) const selectedTracks = displayedTracks.filter((track) => state.selectedTrackIds.has(track.id)) const spotifySearchActive = state.scope === 'spotify' && Boolean(state.query.trim() || state.spotifyNavigation) const tracklistVisible = !spotifySearchActive && !state.selectedPlaylist @@ -270,8 +274,8 @@ function App() { if (state.viewKey !== browserPlayKey) return setBrowserPlayKey(undefined) const first = displayedTracks.find((track) => track.enabled) - if (first && tracklistVisible) player.start(first.id, displayedTracks, { kind: 'library', source: state.source }) - }, [browserPlayKey, browseKey, displayedTracks, player, state.source, state.viewKey, tracklistVisible]) + if (first && tracklistVisible) player.start(first.id, displayedTracks, { kind: 'library', source: state.source, selection: state.sel, query: state.scope === 'library' ? state.query : '' }, true) + }, [browserPlayKey, browseKey, displayedTracks, player, state.source, state.sel, state.query, state.scope, state.viewKey, tracklistVisible]) const setBrowserPanes = useCallback((browserPanes: BrowserPanes) => { for (const facet of ['cat', 'art', 'alb'] as const) { if (!browserPanes[facet]) delete facetAnchors.current[facet] @@ -482,25 +486,15 @@ function App() { updateSettings({ zoom: normalizeZoom(zoom, ZOOM_MIN, ZOOM_MAX) }) }, [updateSettings]) const openPreferences = () => { - preferenceZoom.current = state.settings.zoom - dispatch({ type: 'preferences', open: true }) + appGateway.openPreferences().catch(() => dispatch({ type: 'error', error: 'Couldn’t open Preferences. Try again.' })) } const openLastfmImporter = () => { appGateway.openLastFmImporter().catch(() => dispatch({ type: 'error', error: 'Couldn’t open the Last.fm importer. Try again.' })) } - const syncLastfm = () => { - lastfmGateway.syncPlays() - .then((lastfmImport) => dispatch({ type: 'lastfmImport', lastfmImport })) - .catch(() => dispatch({ type: 'error', error: 'Couldn’t sync Last.fm. Existing import state is unchanged. Try again.' })) - } const syncSpotify = useCallback(() => { dispatch({ type: 'clear-error' }) spotifyGateway.sync().catch(() => dispatch({ type: 'error', error: SPOTIFY_SYNC_ERROR })) }, []) - const cancelPreferences = () => { - dispatch({ type: 'settings', settings: { zoom: preferenceZoom.current } }) - dispatch({ type: 'preferences', open: false }) - } const saveSetupClientId = async (spotifyClientId: string) => { if (spotifyClientId === state.settings.spotifyClientId) return dispatch({ type: 'settings', settings: { spotifyClientId } }) @@ -510,7 +504,7 @@ function App() { const selectedAlbum = state.sel.alb?.length === 1 ? state.sel.alb[0] : undefined const handlePrefix = (event: Pick, pane: ActivePane): boolean => { if (event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.key.length !== 1) return false - const context = browseTypeaheadContextKey(pane, state.source, state.sel, browseKey) + const context = browseTypeaheadContextKey(pane, state.source, state.sel, browseKey) + state.settings.ignoreLeadingThe if (typeahead.current.context !== context) { window.clearTimeout(typeahead.current.timer) typeahead.current.buffer = '' @@ -520,7 +514,7 @@ function App() { if (event.key === ' ' && !typeahead.current.buffer) return false if (!typeahead.current.buffer) { const facet = pane === 'track' ? undefined : pane - const values = facet ? browseFacetValues(state.view, state.viewKey, browseKey, facet) : [] + const values = facet ? browseFacetValues(state.view, state.viewKey, browseKey, facet, state.settings.ignoreLeadingThe) : [] typeahead.current = { ...typeahead.current, buffer: '', @@ -578,11 +572,10 @@ function App() { useTauriEvent('open-setup', () => dispatch({ type: 'setup', open: true }), reportInfrastructureError) const onKeyDown = useEffectEvent((event: KeyboardEvent) => { - const modalOpen = Boolean(state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) + const modalOpen = Boolean(state.info || state.setup || state.playbackAuthorization || playlistSubject) if (event.key === 'Escape' && modalOpen) { event.preventDefault() if (state.info) closeInfo() - else if (state.preferences) cancelPreferences() else if (state.setup) dispatch({ type: 'setup', open: false }) else if (state.playbackAuthorization) player.cancelPending() else setPlaylistSubject(undefined) @@ -609,7 +602,7 @@ function App() { } else if (command && event.key === ',') { event.preventDefault() openPreferences() - } else if (!command && event.key === ' ' && (!typeahead.current.buffer || typeahead.current.context !== browseTypeaheadContextKey(activePane, state.source, state.sel, browseKey))) { + } else if (!command && event.key === ' ' && (!typeahead.current.buffer || typeahead.current.context !== browseTypeaheadContextKey(activePane, state.source, state.sel, browseKey) + state.settings.ignoreLeadingThe)) { event.preventDefault() typeahead.current.buffer = '' player.toggle() @@ -632,7 +625,7 @@ function App() { dispatch({ type: 'selectTrack', id: displayedTracks[index].id }) window.requestAnimationFrame(() => document.querySelector(`[data-track-id="${displayedTracks[index].id}"]`)?.scrollIntoView({ block: 'nearest' })) } else { - const facetValues = view?.facets[activePane === 'cat' ? 'cats' : activePane === 'art' ? 'arts' : 'albs'] ?? [] + const facetValues = browseFacetValues(state.view, state.viewKey, browseKey, activePane, state.settings.ignoreLeadingThe) const values: (string | undefined)[] = [undefined, ...facetValues] const current = values.indexOf(facetAnchors.current[activePane] ?? state.sel[activePane]?.[0]) const index = Math.max(0, Math.min(values.length - 1, current + direction)) @@ -651,13 +644,20 @@ function App() { useEffect(() => { const onWheel = (event: WheelEvent) => { - if (!(event.metaKey || event.ctrlKey) || state.info || state.preferences || state.setup || state.playbackAuthorization || playlistSubject) return + if (!(event.metaKey || event.ctrlKey) || state.info || state.setup || state.playbackAuthorization || playlistSubject) return event.preventDefault() setZoom(state.settings.zoom + (event.deltaY < 0 ? 0.1 : -0.1)) } window.addEventListener('wheel', onWheel, { passive: false }) return () => window.removeEventListener('wheel', onWheel) - }, [playlistSubject, setZoom, state.info, state.playbackAuthorization, state.preferences, state.setup, state.settings.zoom]) + }, [playlistSubject, setZoom, state.info, state.playbackAuthorization, state.setup, state.settings.zoom]) + + useEffect(() => { + if (!originReveal || originReveal.origin.kind !== 'library' || libraryRevealHandled.current === originReveal || !view || !tracklistVisible) return + libraryRevealHandled.current = originReveal + const track = displayedTracks.find(track => track.uri === originReveal.uri) + if (track) dispatch({ type: 'selectTrack', id: track.id }) + }, [originReveal, view, tracklistVisible, displayedTracks]) const selectedPlaylist = playlists?.find((playlist) => playlist.id === state.selectedPlaylist) const playlistLayout = playlistLayoutFor(selectedPlaylist?.id, state.settings) @@ -665,7 +665,7 @@ function App() { const origin = state.playing?.origin if (!origin) return facetAnchors.current = {} - dispatch({ type: 'scope', scope: 'library' }) + setOriginReveal({ uri: state.playing!.uri!, origin, queryReset: state.queryReset + 1 }) dispatch(playbackOriginAction(origin)) } return ( @@ -713,6 +713,7 @@ function App() { playlists={playlists} onSource={(source) => { facetAnchors.current = {}; dispatch({ type: 'source', source }) }} onPlaylist={(id) => dispatch({ type: 'playlist', id })} + onPlayPlaylist={player.startPlaylist} onReorder={setPlaylists} onCollapse={() => updateSettings({ plCollapsed: !state.settings.plCollapsed })} onShuffle={(shuffle) => playbackGateway.setShuffle(shuffle).then(() => dispatch({ type: 'settings', settings: { shuffle } })).catch(() => dispatch({ type: 'error', error: 'Couldn’t change shuffle. Playback is unchanged. Try again.' }))} @@ -729,7 +730,8 @@ function App() { {state.connection.needs_reauth &&
Spotify needs to be reconnected to enable playlists.
} {spotifySearchActive ? ( !state.connectionHydrated ?
Checking Spotify connection…
: state.connection.connected ? dispatch({ type: 'playlist' })} - onPlay={(id, tracks) => player.start(id, tracks, { kind: 'playlist', id: selectedPlaylist.id })} + onPlay={(id, tracks, sortColumn, sortDesc) => player.start(id, tracks, { kind: 'playlist', id: selectedPlaylist.id, sortColumn, sortDesc })} onRate={(id, stars) => rate(`track:${id}`, () => libraryGateway.clickTrackStar(id, stars))} onOpen={(target) => spotifyGateway.openPlaylist(selectedPlaylist.id, target).catch(() => dispatch({ type: 'error', error: 'Couldn’t open this playlist in Spotify. Try again.' }))} onPlaylist={setPlaylistSubject} @@ -829,7 +834,7 @@ function App() { dispatch({ type: 'selectTrack', id }) } }} - onPlay={(id) => player.start(id, displayedTracks, { kind: 'library', source: state.source })} + onPlay={(id) => player.start(id, displayedTracks, { kind: 'library', source: state.source, selection: state.sel, query: state.scope === 'library' ? state.query : '' })} onEnabled={(id, enabled) => mutate(() => libraryGateway.setTrackEnabled(id, enabled))} onRate={(id, stars) => rate(`track:${id}`, () => libraryGateway.clickTrackStar(id, stars))} onInfo={openInfo} @@ -848,7 +853,7 @@ function App() { )} {state.error && dispatch({ type: 'clear-error' })}>{state.error}} - +
{state.info?.kind === 'single' && { @@ -869,13 +874,6 @@ function App() { return spotifyGateway.sync() }) .catch(() => dispatch({ type: 'error', error: SPOTIFY_SYNC_ERROR }))} />} - {state.preferences && dispatch({ type: 'settings', settings: { zoom } })} onCancel={cancelPreferences} onLastfm={(lastfm) => dispatch({ type: 'lastfm', lastfm })} onImport={openLastfmImporter} onSyncLastfm={syncLastfm} onLibraryChanged={() => dispatch({ type: 'refresh' })} onSave={({ browserPanes, ...settings }) => { - const patch = { ...settings, browserPanes, zoom: state.settings.zoom } - dispatch({ type: 'settings', settings }) - dispatch({ type: 'browserPanes', browserPanes }) - persistSettings(patch) - dispatch({ type: 'preferences', open: false }) - }} />} {state.playbackAuthorization && } {playlistSubject && setPlaylistSubject(undefined)} onError={(error) => dispatch({ type: 'error', error })} />} {nativeDragActive &&
Drop to add to LibraryAudio files and folders
} @@ -998,7 +996,7 @@ export function TransportBar({ playing, progress, track, query, queryReset, scop onOrigin() }} onKeyDown={(event) => { - if (!playing?.origin || (event.key !== 'Enter' && event.key !== ' ')) return + if (event.target !== event.currentTarget || !playing?.origin || (event.key !== 'Enter' && event.key !== ' ')) return event.preventDefault() onOrigin() }} @@ -1036,11 +1034,12 @@ export function TransportBar({ playing, progress, track, query, queryReset, scop } -function Sidebar({ state, playlists: incomingPlaylists, onSource, onPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError, artwork }: { +function Sidebar({ state, playlists: incomingPlaylists, onSource, onPlaylist, onPlayPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError, artwork }: { state: State playlists?: PlaylistListView[] onSource: (source: Source) => void onPlaylist: (id: string) => void + onPlayPlaylist: (playlist: PlaylistListView) => void onReorder: (playlists: PlaylistListView[]) => void onCollapse: () => void onShuffle: (shuffle: boolean) => void @@ -1074,6 +1073,7 @@ function Sidebar({ state, playlists: incomingPlaylists, onSource, onPlaylist, on const playlistDrag = useRef<{ id: string; pointerId: number; startY: number; moved: boolean } | undefined>(undefined) const dragInsertBefore = useRef(undefined) const suppressPlaylistClick = useRef(false) + const playlistPrefix = useRef({ text: '', at: 0 }) const create = async () => { if (!name.trim() || !beginPendingEntity(createPending.current, 'create')) return setCreateBusy(true) @@ -1115,13 +1115,34 @@ function Sidebar({ state, playlists: incomingPlaylists, onSource, onPlaylist, on setBusy(false) } } - return <>
} {!state.settings.plCollapsed && playlists?.map((playlist, index) => {playlist.shadow && !playlists[index - 1]?.shadow &&
Shadowed playlists
}} } -function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, columnOrder, columnWidths, hiddenColumns, onBack, onPlay, onRate, onOpen, onPlaylist, onInfo, onReorder, onColumnWidths, onHiddenColumns, onError }: { +function PlaylistView({ playlist, ignoreLeadingThe, onTotals, backLabel, revision, libraryRevision, playing, revealRequest, columnOrder, columnWidths, hiddenColumns, onBack, onPlay, onRate, onOpen, onPlaylist, onInfo, onReorder, onColumnWidths, onHiddenColumns, onError }: { playlist: PlaylistListView + ignoreLeadingThe: boolean + onTotals: (totals: { playlistId: string; counts: Pick | null }) => void backLabel: string revision: number libraryRevision: number playing: State['playing'] + revealRequest?: { uri: string; origin: PlaybackOrigin } columnOrder: ColumnKey[] columnWidths: Partial> hiddenColumns: ColumnKey[] onBack: () => void - onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void + onPlay: (id: number, tracks: readonly PlaybackTrack[], sortColumn: ColumnKey | null, sortDesc: boolean) => void onRate: (id: number, stars: number) => void onOpen: (target: 'app' | 'web') => void onPlaylist: (subject: PlaylistSubject) => void @@ -1245,6 +1271,8 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, const [selected, setSelected] = useState>(new Set()) const [selectionAnchor, setSelectionAnchor] = useState() const [focusedUpstreamIndex, setFocusedUpstreamIndex] = useState() + const trackPrefix = useRef({ text: '', at: 0 }) + const pendingRowFocus = useRef(undefined) const [insertBefore, setInsertBefore] = useState() const [mutating, setMutating] = useState(false) const [sortColumn, setSortColumn] = useState(null) @@ -1260,6 +1288,11 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, const resize = useRef<{ column: ColumnKey; pointerId: number; startX: number; startWidth: number; scale: number } | undefined>(undefined) onErrorRef.current = onError const tracks = currentPlaylistRows(trackState, playlist.id) + useEffect(() => { + onTotals({ playlistId: playlist.id, counts: playlistRowsReady(trackState, playlist.id) ? { + tracks: trackState.rows.length, totalSecs: trackState.rows.reduce((total, track) => total + track.durationSecs, 0), + } : null }) + }, [onTotals, playlist.id, trackState]) const canChangePlaylist = playlist.owned && playlistRowsReady(trackState, playlist.id) && tracks.length === playlist.trackCount const canReorder = canChangePlaylist && sortColumn === null useEffect(() => setLiveWidths(columnWidths), [columnWidths]) @@ -1268,6 +1301,9 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, setSelected(new Set()) setSelectionAnchor(undefined) setFocusedUpstreamIndex(undefined) + trackPrefix.current = { text: '', at: 0 } + }, [playlist.id, playlist.itemsAvailable, revision]) + useEffect(() => { if (!playlist.itemsAvailable) { return } @@ -1276,7 +1312,7 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, .then((rows) => active && setTrackState((current) => resolvedPlaylistRows(current, playlist.id, rows))) .catch(() => { if (!active) return - setTrackState((current) => failedPlaylistRows(current, playlist.id)) + setTrackState((current) => playlistRowsReady(current, playlist.id) ? current : failedPlaylistRows(current, playlist.id)) onErrorRef.current('Couldn’t load this playlist. Existing tracks are unchanged. Try again.') }) return () => { active = false } @@ -1295,13 +1331,38 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, window.addEventListener('keydown', selectAll) return () => window.removeEventListener('keydown', selectAll) }, [tracks]) - const rows = useMemo(() => playlistRows(tracks, sortColumn, sortDesc), [tracks, sortColumn, sortDesc]) + const rows = useMemo(() => playlistRows(tracks, sortColumn, sortDesc, ignoreLeadingThe), [tracks, sortColumn, sortDesc, ignoreLeadingThe]) const rovingUpstreamIndex = rows.some((row) => row.upstreamIndex === focusedUpstreamIndex) ? focusedUpstreamIndex : rows.find((row) => selected.has(row.upstreamIndex))?.upstreamIndex ?? rows[0]?.upstreamIndex const queue = useMemo(() => rows.map(({ track, upstreamIndex }) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + upstreamIndex })), [rows]) const rowHeight = 20 const { scroll, readViewport, reveal, indices: rowIndices } = useTrackWindow(rows.length, rowHeight, rows.findIndex(row => row.upstreamIndex === rovingUpstreamIndex)) + useLayoutEffect(() => { + if (pendingRowFocus.current === undefined) return + // Focus before the next key arrives; virtualization may have removed its old target. + scroll.current?.querySelector(`[data-upstream-index="${pendingRowFocus.current}"]`)?.focus({ preventScroll: true }) + pendingRowFocus.current = undefined + }) + const handledReveal = useRef(undefined) + useEffect(() => { + if (!revealRequest || handledReveal.current === revealRequest || !playlistRowsReady(trackState, playlist.id)) return + const origin = revealRequest.origin + if (origin.kind !== 'playlist') return + if (sortColumn !== (origin.sortColumn ?? null) || sortDesc !== (origin.sortDesc ?? false)) { + setSortColumn(origin.sortColumn ?? null) + setSortDesc(origin.sortDesc ?? false) + return + } + handledReveal.current = revealRequest + const index = rows.findIndex(({ track }) => track.uri === revealRequest.uri) + if (index < 0) return + const upstreamIndex = rows[index].upstreamIndex + setSelected(new Set([upstreamIndex])) + setSelectionAnchor(upstreamIndex) + setFocusedUpstreamIndex(upstreamIndex) + reveal(index) + }, [revealRequest, trackState, playlist.id, rows, sortColumn, sortDesc, reveal]) const headings = { ...trackColumnHeadings(labels.music), track: 'Track' } const customizableColumns = columnOrder.filter((column) => PLAYLIST_COLUMNS.includes(column)) const visibleColumns = visibleColumnOrder(customizableColumns, hiddenColumns) @@ -1484,14 +1545,34 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, onFocus={() => setFocusedUpstreamIndex(upstreamIndex)} onClick={(event) => { if (suppressTrackClick.current) { suppressTrackClick.current = false; event.preventDefault(); return } + trackPrefix.current = { text: '', at: 0 } select(upstreamIndex, event) }} - onDoubleClick={() => onPlay(queue[rowIndex].id, queue)} + onDoubleClick={() => onPlay(queue[rowIndex].id, queue, sortColumn, sortDesc)} onKeyDown={(event) => { if (event.target !== event.currentTarget) return + if (!event.nativeEvent.isComposing && !event.altKey && !event.ctrlKey && !event.metaKey && event.key.length === 1) { + const now = performance.now() + if (now - trackPrefix.current.at > 1000) trackPrefix.current.text = '' + if (event.key !== ' ' || trackPrefix.current.text) { + event.preventDefault() + event.stopPropagation() + trackPrefix.current = { text: trackPrefix.current.text + event.key, at: now } + const nextIndex = rows.findIndex(({ track }) => track.name.toLocaleLowerCase().startsWith(trackPrefix.current.text.toLocaleLowerCase())) + const next = rows[nextIndex] + if (next) { + setSelected(new Set([next.upstreamIndex])) + setSelectionAnchor(next.upstreamIndex) + setFocusedUpstreamIndex(next.upstreamIndex) + reveal(nextIndex) + pendingRowFocus.current = next.upstreamIndex + } + return + } + } if (event.key === 'Enter') { event.preventDefault() - onPlay(queue[rowIndex].id, queue) + onPlay(queue[rowIndex].id, queue, sortColumn, sortDesc) return } if (event.key === ' ') { @@ -1507,11 +1588,10 @@ function PlaylistView({ playlist, backLabel, revision, libraryRevision, playing, select(next.upstreamIndex, event) setFocusedUpstreamIndex(next.upstreamIndex) reveal(nextIndex) - const rowContainer = event.currentTarget.parentElement - window.requestAnimationFrame(() => rowContainer?.querySelector(`[data-upstream-index="${next.upstreamIndex}"]`)?.focus()) + pendingRowFocus.current = next.upstreamIndex }} onPointerDown={canReorder && !mutating ? (event) => { - if (event.button !== 0) return + if (event.button !== 0 || (event.target as Element).closest('button, input, select, textarea, a')) return setFocusedUpstreamIndex(upstreamIndex) suppressTrackClick.current = false trackDrag.current = { indices: selected.has(upstreamIndex) ? [...selected] : [upstreamIndex], pointerId: event.pointerId, startY: event.clientY, moved: false } @@ -1649,11 +1729,11 @@ function statusDuration(totalSeconds: number) { return parts.slice(0, 3).join(' ') || '<1m' } -export function StatusBar({ view, unit, browsePending, syncPhase, syncProgress, importStatus, spotifySyncStatus, lastfmImport, onSpotifySync, onLastfmImport, empty }: { view: BrowseView | null; unit: string; browsePending?: boolean; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; spotifySyncStatus: SpotifySyncStatus; lastfmImport: LastFmImportState; onSpotifySync: () => void; onLastfmImport: () => void; empty: boolean }) { - const total = view?.counts.totalSecs ?? 0 +export function StatusBar({ view, counts = view?.counts, unit, browsePending, syncPhase, syncProgress, importStatus, spotifySyncStatus, lastfmImport, onSpotifySync, onLastfmImport, empty }: { view: BrowseView | null; counts?: Pick | null; unit: string; browsePending?: boolean; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; spotifySyncStatus: SpotifySyncStatus; lastfmImport: LastFmImportState; onSpotifySync: () => void; onLastfmImport: () => void; empty: boolean }) { + const total = counts?.totalSecs ?? 0 const hours = Math.floor(total / 3600) const minutes = Math.floor((total % 3600) / 60) - const count = view?.counts.tracks ?? 0 + const count = counts?.tracks ?? 0 const sourceWork = lastfmImport.phase === 'downloading' || lastfmImport.phase === 'aggregating' const lastfmLoading = lastfmImport.syncProblem === 'Retune is still loading Last.fm import state.' const progress = importDownloadProgressLabel(lastfmImport.processedScrobbles, lastfmImport.totalScrobbles, importDownloadPercent(lastfmImport.downloadedPages, lastfmImport.totalPages)) @@ -1664,7 +1744,7 @@ export function StatusBar({ view, unit, browsePending, syncPhase, syncProgress, return () => window.clearInterval(timer) }, [spotifySyncStatus.cooldown, spotifySyncStatus.lastFullSync, spotifySyncStatus.nextSync]) - const librarySummary = importStatus ?? (browsePending ? 'Filtering library…' : empty ? 'No library — set up to begin' : `${count.toLocaleString()} ${count === 1 ? unit : `${unit}s`}, ${hours}:${String(minutes).padStart(2, '0')} hours`) + const librarySummary = importStatus ?? (browsePending ? 'Filtering library…' : empty ? 'No library — set up to begin' : counts === null ? 'Track totals unavailable' : `${count.toLocaleString()} ${count === 1 ? unit : `${unit}s`}, ${hours}:${String(minutes).padStart(2, '0')} hours`) const spotifySegment = spotifySyncStatus.running ? ⟳ {syncPhase ?? 'Syncing from Spotify…'}{syncProgress && <>{syncProgress.tracks} tracks synced} : spotifySyncStatus.cooldown diff --git a/apps/desktop/src/PreferencesWindow.tsx b/apps/desktop/src/PreferencesWindow.tsx new file mode 100644 index 0000000..1399130 --- /dev/null +++ b/apps/desktop/src/PreferencesWindow.tsx @@ -0,0 +1,57 @@ +import { getCurrentWindow } from '@tauri-apps/api/window' +import { useEffect, useRef, useState } from 'react' +import { appGateway } from './appGateway.ts' +import { initialState } from './appState.ts' +import { Preferences } from './dialogViews.tsx' +import { lastfmGateway } from './lastfmGateway.ts' +import type { Settings, SettingsPatch } from './types.ts' +import { useTauriInvalidationSnapshot } from './useTauriInvalidationSnapshot.ts' +import { ErrorNotice } from './viewShared.tsx' + +export default function PreferencesWindow() { + const [settings, setSettings] = useState() + const [lastfm, setLastfm] = useState(initialState.lastfm) + const [lastfmImport, setLastfmImport] = useState(initialState.lastfmImport) + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + const savePending = useRef(false) + const loadError = () => setError('Couldn’t load preferences. Close this window and try again.') + useTauriInvalidationSnapshot('settings-changed', appGateway.settings, setSettings, loadError) + useTauriInvalidationSnapshot('lastfm-changed', lastfmGateway.accountState, setLastfm, loadError) + useTauriInvalidationSnapshot('lastfm-import-changed', lastfmGateway.state, setLastfmImport, loadError) + const theme = settings?.theme ?? 'system' + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const apply = () => { document.documentElement.dataset.theme = theme === 'system' ? media.matches ? 'dark' : 'light' : theme } + apply() + media.addEventListener('change', apply) + return () => media.removeEventListener('change', apply) + }, [theme]) + const close = () => { + if (savePending.current) return + void getCurrentWindow().close().catch(() => setError('Couldn’t close Preferences. Try again.')) + } + const save = async (patch: SettingsPatch) => { + if (savePending.current) return + savePending.current = true + setSaving(true) + setError(undefined) + try { + await appGateway.updateSettings({ ...patch, zoom: settings!.zoom }) + await getCurrentWindow().close() + } catch { + setError('Couldn’t finish saving preferences. Check your settings and try again.') + } finally { + savePending.current = false + setSaving(false) + } + } + return
+ {error && setError(undefined)}>{error}} + {settings ? setSettings({ ...settings, zoom })} onCancel={close} onLastfm={setLastfm} + onImport={() => { void appGateway.openLastFmImporter().catch(() => setError('Couldn’t open the Last.fm importer. Try again.')) }} + onSyncLastfm={() => { void lastfmGateway.syncPlays().then(setLastfmImport).catch(() => setError('Couldn’t sync Last.fm. Try again.')) }} + onSave={save} /> :

Loading preferences…

} +
+} diff --git a/apps/desktop/src/appGateway.ts b/apps/desktop/src/appGateway.ts index edc29d6..28984d7 100644 --- a/apps/desktop/src/appGateway.ts +++ b/apps/desktop/src/appGateway.ts @@ -7,6 +7,7 @@ export function createAppGateway(invoke: Invoker) { settings: () => invoke('get_settings'), updateSettings: (patch: SettingsPatch) => invoke('update_settings', { patch }), appearance: () => invoke('get_appearance'), + openPreferences: () => invoke('open_preferences'), openLastFmImporter: () => invoke('open_lastfm_importer'), diagnostics: () => invoke('load_diagnostics'), emailDiagnostics: (body: string) => invoke('email_diagnostics', { body }), diff --git a/apps/desktop/src/appState.ts b/apps/desktop/src/appState.ts index b6f642b..8252875 100644 --- a/apps/desktop/src/appState.ts +++ b/apps/desktop/src/appState.ts @@ -23,7 +23,6 @@ export type State = { error?: string notice?: string info?: InfoDialog - preferences: boolean setup: boolean playbackAuthorization: PlaybackAuthorizationPrompt | null connection: ConnectionState @@ -43,6 +42,7 @@ export type State = { } export type Action = + | { type: 'playingOrigin'; origin: PlaybackOrigin } | { type: 'view'; view: BrowseView; key: string } | { type: 'browsePending'; pending: boolean } | { type: 'error'; error: string } @@ -62,7 +62,6 @@ export type Action = | { type: 'refresh' } | { type: 'notice'; notice?: string } | { type: 'info'; info?: InfoDialog } - | { type: 'preferences'; open: boolean } | { type: 'setup'; open: boolean } | { type: 'playbackAuthorization'; prompt: PlaybackAuthorizationPrompt | null } | { type: 'connection'; connection: ConnectionState } @@ -97,6 +96,7 @@ export const defaultSettings: Settings = { playlistColumnWidths: {}, sortColumn: null, sortDesc: false, + ignoreLeadingThe: false, autoAddSpotifyLibrary: true, autoConnect: true, spotifyClientId: '', @@ -127,7 +127,6 @@ export const initialState: State = { viewKey: undefined, browsePending: false, revision: 0, - preferences: false, setup: false, playbackAuthorization: null, connection: { connected: false, needs_reauth: false, playback_authorized: false }, @@ -142,6 +141,14 @@ export const initialState: State = { export function reducer(state: State, action: Action): State { switch (action.type) { + case 'playingOrigin': { + const origin = action.origin + const base = { ...state, query: '', queryReset: state.queryReset + 1, scope: 'library' as const, spotifyNavigation: undefined, selectedPlaylist: undefined, selectedTrackIds: new Set(), selectionAnchor: undefined } + if (origin.kind === 'playlist') return { ...base, selectedPlaylist: origin.id } + if (origin.kind === 'spotify') return { ...base, scope: 'spotify', query: origin.query, spotifyNavigation: origin.query.trim() ? undefined : origin.navigation[0] } + const sel = origin.selection ?? {} + return { ...base, source: origin.source, sel, query: origin.query ?? '', savedSelections: rememberSelection(state.savedSelections, origin.source, sel) } + } case 'view': return { ...state, view: action.view, viewKey: action.key, browsePending: false } case 'browsePending': @@ -173,8 +180,10 @@ export function reducer(state: State, action: Action): State { ...state, playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue, origin: action.origin }, } - case 'hydrateSettings': - return { ...state, settings: action.settings, settingsHydrated: true } + case 'hydrateSettings': { + const next = reducer(state, { type: 'browserPanes', browserPanes: action.settings.browserPanes }) + return { ...next, settings: action.settings, settingsHydrated: true } + } case 'settings': return { ...state, settings: { ...state.settings, ...action.settings } } case 'browserPanes': { @@ -189,14 +198,12 @@ export function reducer(state: State, action: Action): State { case 'notice': return { ...state, notice: action.notice } case 'info': - return { ...state, info: action.info, preferences: false, setup: false } - case 'preferences': - return { ...state, preferences: action.open, setup: false, info: undefined } + return { ...state, info: action.info, setup: false } case 'setup': - return { ...state, setup: action.open, preferences: false, info: undefined } + return { ...state, setup: action.open, info: undefined } case 'playbackAuthorization': return action.prompt - ? { ...state, playbackAuthorization: action.prompt, info: undefined, preferences: false, setup: false } + ? { ...state, playbackAuthorization: action.prompt, info: undefined, setup: false } : { ...state, playbackAuthorization: null } case 'connection': return { diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 6be3130..c983363 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -274,8 +274,9 @@ function BugPreferences() { } -export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, onLastfm, onImport, onSyncLastfm, onSave, onLibraryChanged = () => {} }: { +export function Preferences({ settings, saving = false, lastfm, lastfmImport, onZoom, onCancel, onLastfm, onImport, onSyncLastfm, onSave, onLibraryChanged = () => {} }: { settings: Settings + saving?: boolean lastfm: LastFmState lastfmImport: LastFmImportState onZoom: (zoom: number) => void @@ -284,13 +285,14 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, onImport: () => void onSyncLastfm: () => void onLibraryChanged?: () => void - onSave: (settings: Pick) => void + onSave: (settings: Pick) => void }) { type PreferenceTab = 'appearance' | 'library' | 'audio' | 'bug' const [tab, setTab] = useState('appearance') const [theme, setTheme] = useState(settings.theme) const [browserVisible, setBrowserVisible] = useState(settings.browserVisible) const [browserPanes, setBrowserPanes] = useState(settings.browserPanes) + const [ignoreLeadingThe, setIgnoreLeadingThe] = useState(settings.ignoreLeadingThe) const [autoAdd, setAutoAdd] = useState(settings.autoAddSpotifyLibrary) const [autoConnect, setAutoConnect] = useState(settings.autoConnect) const [clientId, setClientId] = useState(settings.spotifyClientId) @@ -314,7 +316,7 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, ['light', 'Light', 'Always use the light theme.'], ['dark', 'Dark', 'Always use the dark theme.'], ] - const save = () => onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent, lastfmScrobbling }) + const save = () => onSave({ theme, browserVisible, browserPanes, ignoreLeadingThe, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent, lastfmScrobbling }) const lastfmAction = async (action: 'connect' | 'finish' | 'disconnect') => { setLastfmBusy(true) setLastfmError(undefined) @@ -356,6 +358,9 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, } {tab === 'library' && (removedTracksOpen ? { setRemovedTracksOpen(false); setTab('library') }} onChanged={onLibraryChanged} /> : <> +

Alphabetizing

+ +

Spotify account

From your Spotify Developer dashboard. Stored on this Mac only. @@ -412,6 +417,6 @@ export function Preferences({ settings, lastfm, lastfmImport, onZoom, onCancel, } {tab === 'bug' && }
-
+
} diff --git a/apps/desktop/src/libraryViews.tsx b/apps/desktop/src/libraryViews.tsx index 169523b..ad0b58f 100644 --- a/apps/desktop/src/libraryViews.tsx +++ b/apps/desktop/src/libraryViews.tsx @@ -5,7 +5,7 @@ import { CheckboxMenu, ContextMenu, RatingStars } from './viewShared.tsx' import { useTrackWindow } from './useTrackWindow.ts' export function BrowserPane({ state, anchors, onActivate, onSelect, onPlay, onToggle, onPrefix, onNavigate }: { - state: { source: Source; view: BrowseView | null; viewKey?: string; browseKey: string; settings: Pick; sel: Selection } + state: { source: Source; view: BrowseView | null; viewKey?: string; browseKey: string; settings: Pick; sel: Selection } anchors: { current: Partial> } onActivate: (facet: keyof Selection) => void onSelect: (facet: keyof Selection, values: string[], anchor?: string) => void @@ -19,7 +19,7 @@ export function BrowserPane({ state, anchors, onActivate, onSelect, onPlay, onTo const pane = useRef(null) const resize = useRef<{ pointerId: number; startY: number; startHeight: number; maxHeight: number; zoom: number } | undefined>(undefined) const sourceLabels = labels[state.source].facets - const values = (['cat', 'art', 'alb'] as const).map((facet) => browseFacetValues(state.view, state.viewKey, state.browseKey, facet)) + const values = (['cat', 'art', 'alb'] as const).map((facet) => browseFacetValues(state.view, state.viewKey, state.browseKey, facet, state.settings.ignoreLeadingThe)) const facets: (keyof Selection)[] = ['cat', 'art', 'alb'] const visible = facets.filter((facet) => state.settings.browserPanes[facet]) if (!state.settings.browserVisible || !visible.length) return null diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index aeea30e..9cb0be7 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -2,16 +2,16 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { getCurrentWindow } from '@tauri-apps/api/window' import './index.css' -// Theme and shared dialogs are used by both windows. +// Theme and shared dialogs are used by every window. import './App.css' -const isImporter = getCurrentWindow().label === 'lastfm-importer' +const label = getCurrentWindow().label const root = createRoot(document.getElementById('root')!) root.render(
Loading Retune…
) // Keep separate loader functions so Vite associates each chunk with its own CSS. -const entries = { main: () => import('./App.tsx'), importer: () => import('./LastFmImporter.tsx') } -entries[isImporter ? 'importer' : 'main']().then(({ default: WindowView }) => root.render()) +const entries = { main: () => import('./App.tsx'), importer: () => import('./LastFmImporter.tsx'), preferences: () => import('./PreferencesWindow.tsx') } +entries[label === 'lastfm-importer' ? 'importer' : label === 'preferences' ? 'preferences' : 'main']().then(({ default: WindowView }) => root.render()) .catch(error => { console.error('Could not load Retune window', error) root.render(
Couldn’t load Retune.
) diff --git a/apps/desktop/src/playbackGateway.ts b/apps/desktop/src/playbackGateway.ts index 840398a..b164ddc 100644 --- a/apps/desktop/src/playbackGateway.ts +++ b/apps/desktop/src/playbackGateway.ts @@ -5,7 +5,7 @@ export const playbackEvents = { action: 'player-action' } as const export function createPlaybackGateway(invoke: Invoker) { return { - play: (snapshot: readonly PlaybackTrack[], startIndex: number) => invoke('play_tracks', { + play: (snapshot: readonly PlaybackTrack[], startIndex: number | null) => invoke('play_tracks', { resources: snapshot.map(({ id, uri }) => ({ id, uri })), startIndex, }), diff --git a/apps/desktop/src/spotifyViews.tsx b/apps/desktop/src/spotifyViews.tsx index 76b0162..d6583a7 100644 --- a/apps/desktop/src/spotifyViews.tsx +++ b/apps/desktop/src/spotifyViews.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' -import type { AlbumPageView, ArtistAlbumsPage, ArtistPageView, PlaybackTrack, PlaylistSubject, SearchAlbum, SearchArtist, SearchTrack, SpotifyNavEntry, SpotifyResults } from './types.ts' +import type { AlbumPageView, ArtistAlbumsPage, ArtistPageView, PlaybackOrigin, PlaybackTrack, PlaylistSubject, SearchAlbum, SearchArtist, SearchTrack, SpotifyNavEntry, SpotifyResults } from './types.ts' import { createSpotifySearchState, dismissSpotifySearchGroup, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifyMembership, spotifySearchGroupHeader, spotifySearchPendingPageKey, type SpotifyMembershipOverrides, type SpotifySearchState } from './spotifySearch.ts' import { beginPendingEntity, DRAG_TYPE, entityRequestGeneration, formatTime, loadCurrentGeneration, mergeByUri, pendingEntities, SYNTHETIC_BASE } from './ui.ts' import { ArtworkLightbox, ContextMenu, ErrorNotice, RatingStars } from './viewShared.tsx' @@ -375,30 +375,31 @@ function SpotifyArtistPage({ id, backLabel, adding, membership, onBack, onAlbum, } -export function SpotifySearch({ query, searching, results, searchError, navigation, playingUri, onAdd, onAddTrack, onRemoveTrack, onPlay, onPlaylist, onClose, onRetrySearch, onDismissSearch, onError }: { +export function SpotifySearch({ query, searching, results, searchError, navigation, restore, playingUri, onAdd, onAddTrack, onRemoveTrack, onPlay, onPlaylist, onClose, onRetrySearch, onDismissSearch, onError }: { query: string searching: boolean results: SpotifyResults | null searchError?: string + restore?: { uri: string; origin: PlaybackOrigin } navigation?: SpotifyNavEntry playingUri: string | null onAdd: (album: { uri: string; name: string; artist: string }) => Promise onAddTrack: (uri: string) => Promise onRemoveTrack: (uri: string) => Promise - onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void + onPlay: (id: number, tracks: readonly PlaybackTrack[], origin?: PlaybackOrigin) => void onPlaylist: (subject: PlaylistSubject) => void onClose: () => void onRetrySearch?: () => void onDismissSearch?: () => void onError: (error: string) => void }) { - const [searchState, setSearchState] = useState(() => createSpotifySearchState(query)) + const [searchState, setSearchState] = useState(() => restore?.origin.kind === 'spotify' ? restore.origin.search : createSpotifySearchState(query)) const searchStateRef = useRef(searchState) const [adding, setAdding] = useState>(new Set()) const pendingMembership = useRef(new Set()) const [playingAlbum, setPlayingAlbum] = useState() const [membership, setMembership] = useState>({}) - const [nav, setNav] = useState(navigation ? [navigation] : []) + const [nav, setNav] = useState(restore?.origin.kind === 'spotify' ? restore.origin.navigation : navigation ? [navigation] : []) const [menu, setMenu] = useState<{ x: number; y: number; track: SpotifyResults['tracks']['items'][number] }>() const pendingPages = useRef(new Map>()) searchStateRef.current = searchState @@ -409,12 +410,12 @@ export function SpotifySearch({ query, searching, results, searchError, navigati return next }) setMembership({}) - setNav(navigation ? [navigation] : []) - }, [query, navigation]) + setNav(restore?.origin.kind === 'spotify' ? restore.origin.navigation : navigation ? [navigation] : []) + }, [query, navigation, restore]) useEffect(() => { - if (!results) return + if (!results || restore) return setSearchState((current) => replaceSpotifySearchResults(current, results)) - }, [results]) + }, [results, restore]) const requestGroup = (group: keyof SpotifyResults, retry = false) => { const current = searchStateRef.current const outcome = retry ? retrySpotifySearchGroup(current, group) : expandSpotifySearchGroup(current, group) @@ -466,12 +467,15 @@ export function SpotifySearch({ query, searching, results, searchError, navigati spotifyGateway.removeAlbum(uri).catch((error) => { onError('Couldn’t confirm removing this album from Retune. Refresh your library and try again.'); throw error })) const addTrack = (uri: string) => mutateMembership(uri, true, () => onAddTrack(uri)) const removeTrack = (uri: string) => mutateMembership(uri, false, () => onRemoveTrack(uri)) + const play = (id: number, tracks: readonly PlaybackTrack[]) => onPlay(id, tracks, { kind: 'spotify', query, navigation: nav, search: { ...searchState, loading: new Set() } }) + const restoredRow = useRef(null) + useEffect(() => { restoredRow.current?.scrollIntoView({ block: 'nearest' }) }, [restore, searchState]) const playAlbum = async (album: SearchAlbum) => { setPlayingAlbum(album.uri) try { const page = await spotifyGateway.albumPage(album.uri) const tracks = albumPlaybackTracks(page) - if (tracks.length) onPlay(tracks[0].id, tracks) + if (tracks.length) play(tracks[0].id, tracks) } catch { onError('Couldn’t play this Spotify album. Playback is unchanged. Try again.') } finally { @@ -484,7 +488,7 @@ export function SpotifySearch({ query, searching, results, searchError, navigati const backLabel = below?.kind ?? (navigation ? 'library' : 'results') const back = () => nav.length === 1 && navigation ? onClose() : setNav((current) => current.slice(0, -1)) if (searching) return
Searching Spotify…
- if (top?.kind === 'album') return setNav((current) => [...current, { kind: 'artist', id }])} onAdd={add} onRemove={remove} onAddTrack={addTrack} onRemoveTrack={removeTrack} onPlay={onPlay} onPlaylist={onPlaylist} onError={onError} /> + if (top?.kind === 'album') return setNav((current) => [...current, { kind: 'artist', id }])} onAdd={add} onRemove={remove} onAddTrack={addTrack} onRemoveTrack={removeTrack} onPlay={play} onPlaylist={onPlaylist} onError={onError} /> if (top?.kind === 'artist') return const tab = searchState.tab const counts = { @@ -515,10 +519,10 @@ export function SpotifySearch({ query, searching, results, searchError, navigati {searchState.groups.tracks.items.slice(0, searchState.visible.tracks).map((track, index) => { const open = () => { if (track.albumUri) pushAlbum(track.albumUri, track.uri) } const playback = searchTrackPlayback(track, index) - return
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, track }) }}> + return
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY, track }) }}> {track.name}{track.artist} · {track.alb} - onPlay(playback.id, [playback])} playing={playingUri === track.uri} adding={adding.has(track.uri)} added={spotifyMembership(track.inLibrary, track.uri, membership)} onAdd={() => { void addTrack(track.uri) }} onRemove={() => { void removeTrack(track.uri) }} onOpen={track.albumUri ? open : undefined} /> + play(playback.id, [playback])} playing={playingUri === track.uri} adding={adding.has(track.uri)} added={spotifyMembership(track.inLibrary, track.uri, membership)} onAdd={() => { void addTrack(track.uri) }} onRemove={() => { void removeTrack(track.uri) }} onOpen={track.albumUri ? open : undefined} />
})} } diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 92056c9..172c7d4 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -23,6 +23,7 @@ export type Settings = { playlistColumnWidths: Record>> sortColumn: ColumnKey | null sortDesc: boolean + ignoreLeadingThe: boolean autoAddSpotifyLibrary: boolean autoConnect: boolean spotifyClientId: string @@ -183,7 +184,10 @@ export type Track = { } export type PlaybackTrack = Pick -export type PlaybackOrigin = { kind: 'library'; source: Source } | { kind: 'playlist'; id: string } +export type PlaybackOrigin = + | { kind: 'library'; source: Source; selection?: Selection; query?: string } + | { kind: 'playlist'; id: string; sortColumn?: ColumnKey | null; sortDesc?: boolean } + | { kind: 'spotify'; query: string; navigation: SpotifyNavEntry[]; search: import('./spotifySearch.ts').SpotifySearchState } export type PlaylistTrack = Omit & { id: number | null } export type PlaylistSubject = | { kind: 'tracks'; label: string; uris: string[] } diff --git a/apps/desktop/src/ui.ts b/apps/desktop/src/ui.ts index 35f2d94..9ad7d7e 100644 --- a/apps/desktop/src/ui.ts +++ b/apps/desktop/src/ui.ts @@ -49,15 +49,15 @@ export const browseViewForRequest = (view: T | null, resolvedKey: string | un && sameValues(resolved[3], requested[3]) ? view : null } -export function browseFacetValues(view: BrowseView | null, resolvedKey: string | undefined, requestKey: string, facet: keyof Selection): string[] { +export function browseFacetValues(view: BrowseView | null, resolvedKey: string | undefined, requestKey: string, facet: keyof Selection, ignoreLeadingThe = false): string[] { if (!view) return [] const resolved = browseKeyParts(resolvedKey) const requested = browseKeyParts(requestKey) if (!resolved || !requested || resolved[0] !== requested[0]) return [] if (facet === 'cat') return view.facets.cats if (!sameValues(resolved[1], requested[1])) return [] - if (facet === 'art') return view.facets.arts - return sameValues(resolved[2], requested[2]) ? view.facets.albs : [] + const values = facet === 'art' ? view.facets.arts : sameValues(resolved[2], requested[2]) ? view.facets.albs : [] + return ignoreLeadingThe ? [...values].sort((a, b) => compareMusicText(a, b, true)) : values } export const browseTypeaheadContextKey = (pane: 'track' | keyof Selection, source: Source, selection: Selection, requestKey: string) => pane === 'cat' @@ -123,14 +123,12 @@ export const pendingPlaybackTarget = (prompt: PlaybackAuthorizationPrompt, track export const playbackRetryReady = (connected: boolean, playbackAuthorized: boolean, awaitingAuthorization: boolean) => connected && (!awaitingAuthorization || playbackAuthorized) -export const playbackOriginAction = (origin: PlaybackOrigin) => origin.kind === 'playlist' - ? { type: 'playlist' as const, id: origin.id } - : { type: 'source' as const, source: origin.source } +export const playbackOriginAction = (origin: PlaybackOrigin) => ({ type: 'playingOrigin' as const, origin }) export const isCurrentTrack = ( playing: { trackId: number | null; uri: string | null } | null, track: { id: number; uri: string }, -) => playing?.trackId === track.id && playing.uri === track.uri +) => playing?.uri === track.uri export const facetLabel = (title: string, value: string) => title === 'Genre' && value === 'Uncategorized' ? 'No Genre' : value @@ -154,7 +152,23 @@ const sortValue = (track: SortableTrack, column: ColumnKey): string | number | n return track.releaseDate } -export const compareTracks = (left: SortableTrack, right: SortableTrack, column: ColumnKey, desc: boolean) => { +export const compareMusicText = (left: string, right: string, ignoreLeadingThe: boolean) => { + const key = (value: string) => ignoreLeadingThe ? value.replace(/^the\s+(?=\S)/i, '') : value + return key(left).localeCompare(key(right), undefined, { sensitivity: 'base' }) + || left.localeCompare(right, undefined, { sensitivity: 'base' }) +} + +export const compareBrowseTracks = (left: SortableTrack, right: SortableTrack, selection: Selection) => { + const field = selection.alb?.length ? 'alb' : selection.art?.length ? 'art' : 'cat' + const rank = (track: SortableTrack) => selection[field]?.indexOf(track[field]) ?? 0 + return rank(left) - rank(right) + || compareMusicText(left.art, right.art, true) + || compareMusicText(left.alb, right.alb, true) + || (left.discNo ?? 1) - (right.discNo ?? 1) + || (left.trackNo ?? Number.MAX_SAFE_INTEGER) - (right.trackNo ?? Number.MAX_SAFE_INTEGER) +} + +export const compareTracks = (left: SortableTrack, right: SortableTrack, column: ColumnKey, desc: boolean, ignoreLeadingThe = false) => { const primary: ColumnKey[] = column === 'track' ? ['disc', 'track'] : [column] const columns = [...primary, ...(['disc', 'track', 'artist', 'album', 'genre'] as ColumnKey[]).filter((key) => !primary.includes(key))] for (const key of columns) { @@ -165,15 +179,15 @@ export const compareTracks = (left: SortableTrack, right: SortableTrack, column: if (b === null) return -1 const compared = typeof a === 'number' && typeof b === 'number' ? a - b - : String(a).localeCompare(String(b), undefined, { sensitivity: 'base' }) + : compareMusicText(String(a), String(b), ignoreLeadingThe && ['name', 'artist', 'album'].includes(key)) if (compared) return desc ? -compared : compared } return 0 } -export const playlistRows = (tracks: readonly T[], column: ColumnKey | null, desc: boolean) => { +export const playlistRows = (tracks: readonly T[], column: ColumnKey | null, desc: boolean, ignoreLeadingThe = false) => { const rows = tracks.map((track, upstreamIndex) => ({ track, upstreamIndex })) - return column ? rows.sort((left, right) => compareTracks(left.track, right.track, column, desc)) : rows + return column ? rows.sort((left, right) => compareTracks(left.track, right.track, column, desc, ignoreLeadingThe)) : rows } export type KeyedPlaylistRows = { diff --git a/apps/desktop/src/useTauriInvalidationSnapshot.ts b/apps/desktop/src/useTauriInvalidationSnapshot.ts new file mode 100644 index 0000000..4c47186 --- /dev/null +++ b/apps/desktop/src/useTauriInvalidationSnapshot.ts @@ -0,0 +1,29 @@ +import { useEffect, useEffectEvent } from 'react' +import { listen } from '@tauri-apps/api/event' +import { subscribeInvalidationThenSnapshot } from './ipc.ts' + +export function useTauriInvalidationSnapshot( + event: string, + snapshot: () => Promise, + handler: (payload: T) => void, + onError: (error: unknown) => void, +) { + const read = useEffectEvent(snapshot) + const handle = useEffectEvent(handler) + const reportError = useEffectEvent(onError) + useEffect(() => { + let active = true + const subscription = subscribeInvalidationThenSnapshot( + (invalidate) => listen(event, invalidate), + () => read(), + (value) => handle(value), + () => active, + (error) => { if (active) reportError(error) }, + ) + void subscription.catch((error) => { if (active) reportError(error) }) + return () => { + active = false + void subscription.then((stop) => stop()).catch(() => {}) + } + }, [event]) +} diff --git a/apps/desktop/test/gateways.test.ts b/apps/desktop/test/gateways.test.ts index 36a6eea..a346d6f 100644 --- a/apps/desktop/test/gateways.test.ts +++ b/apps/desktop/test/gateways.test.ts @@ -347,6 +347,7 @@ test('app gateway preserves shell command names and argument shapes', async () = await app.settings() await app.updateSettings(fixture.settingsPatch) await app.appearance() + await app.openPreferences() await app.openLastFmImporter() await app.diagnostics() await app.emailDiagnostics('redacted report') @@ -355,6 +356,7 @@ test('app gateway preserves shell command names and argument shapes', async () = ['get_settings', undefined], ['update_settings', { patch: fixture.settingsPatch }], ['get_appearance', undefined], + ['open_preferences', undefined], ['open_lastfm_importer', undefined], ['load_diagnostics', undefined], ['email_diagnostics', { body: 'redacted report' }], diff --git a/apps/desktop/test/interactions.test.tsx b/apps/desktop/test/interactions.test.tsx index 276bb6f..1db034f 100644 --- a/apps/desktop/test/interactions.test.tsx +++ b/apps/desktop/test/interactions.test.tsx @@ -3,10 +3,11 @@ import { act, createRef, Profiler, StrictMode, useState } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AlbumPageView, ArtistPageView, BrowseView, DecisionTrack, LastFmImportState, LastFmState, LibrarySources, PlayerState, Settings, SpotifyNavEntry, SpotifyResults, SpotifySyncStatus, Track, TrackInfo, TrackMergePreview } from '../src/types.ts' +import type { AlbumPageView, ArtistPageView, BrowseView, DecisionTrack, LastFmImportState, LastFmState, LibrarySources, PlayerState, PlaybackOrigin, Settings, SpotifyNavEntry, SpotifyResults, SpotifySyncStatus, Track, TrackInfo, TrackMergePreview } from '../src/types.ts' import type { MainEvent } from '../src/ipc.ts' const invokeMock = vi.hoisted(() => vi.fn<(command: string, args?: Record) => Promise>(async () => null)) +const closeWindowMock = vi.hoisted(() => vi.fn(async () => {})) const nativeEventHandlers = vi.hoisted(() => new Map void>>()) vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock, @@ -24,12 +25,13 @@ vi.mock('@tauri-apps/api/event', () => ({ } }), })) -vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDropEvent: vi.fn(async () => () => {}), setTitle: vi.fn(async () => {}) }) })) +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ onDragDropEvent: vi.fn(async () => () => {}), setTitle: vi.fn(async () => {}), close: closeWindowMock }) })) import App, { StatusBar, TransportBar } from '../src/App.tsx' import { defaultSettings } from '../src/appState.ts' import LastFmImporter, { ImportQueueFilter } from '../src/LastFmImporter.tsx' import { GetInfo, Preferences } from '../src/dialogViews.tsx' +import PreferencesWindow from '../src/PreferencesWindow.tsx' import { filterImportQueue, type ImportQueueItem } from '../src/lastfmImportState.ts' import { TrackList } from '../src/libraryViews.tsx' import { SpotifySearch } from '../src/spotifyViews.tsx' @@ -52,6 +54,7 @@ const sources = (overrides: Partial = {}): LibrarySources => ({ const info = (track: Track, overrides: Partial = {}): TrackInfo => ({ ...track, source: 'music', localPath: null, origCat: null, inheritedRating: null, genres: [], sources: sources(), mergedSources: [], latestMergeAt: null, ...overrides }) beforeEach(() => { + closeWindowMock.mockClear() nativeEventHandlers.clear() Object.defineProperty(window, 'matchMedia', { configurable: true, value: () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} }) }) Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, value: class { observe() {} unobserve() {} disconnect() {} } }) @@ -400,6 +403,35 @@ describe('mounted native interaction boundaries', () => { expect(nativeEventHandlers.has('lastfm-import-playback')).toBe(false) }) + it.each(['cat', 'art', 'alb'].flatMap(facet => [false, true].map(shuffle => ({ facet, shuffle }))))('double-clicking $facet starts its scope as a collection with shuffle=$shuffle', async ({ facet, shuffle }) => { + const tracks = [track(1, 'First'), { ...track(2, 'Disabled'), enabled: false }, track(3, 'Last')] + const browse: BrowseView = { + facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: tracks.length, totalSecs: 360, perSource: { music: tracks.length, podcasts: 0, audiobooks: 0 } }, + } + const projection = deferred() + invokeMock.mockImplementation(async (command, args) => { + if (command === 'browse') return Object.values(args?.sel as Record).some(values => values.length) ? projection.promise : browse + if (command === 'get_settings') return { ...defaultSettings, shuffle } + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [] + if (command === 'play_tracks') return 'started' + return null + }) + const view = await render() + await act(async () => view.querySelector(`[data-facet="${facet}"] [data-row-index="1"]`)!.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) + await act(async () => projection.resolve({ ...browse, tracks: tracks.slice(1), counts: { ...browse.counts, tracks: 2, totalSecs: 360 } })) + expect(view.querySelector('.status-library')?.textContent).toBe('2 songs, 0:06 hours') + expect(invokeMock).toHaveBeenCalledWith('play_tracks', { + resources: [{ id: tracks[2].id, uri: tracks[2].uri }], startIndex: null, + }) + }) + it('reports unsupported fixture playback instead of simulating it', async () => { const tracks = [ { ...track(1, 'Excluded'), uri: 'fixture:track:excluded', enabled: false }, @@ -641,6 +673,26 @@ describe('mounted native interaction boundaries', () => { expect(onSeek).toHaveBeenCalledWith(42) }) + it('captures and restores the Spotify album launch view with its playing row', async () => { + const page: AlbumPageView = { + uri: 'spotify:album:origin', name: 'Origin Album', artist: 'Artist', artistId: 'artist', albumType: 'Album', year: '2026', + imageUrl: null, totalDurationSecs: 180, savedAlbum: false, contentComplete: false, addedAt: null, albumRating: null, + tracks: [{ uri: 'spotify:track:origin', name: 'Origin Track', trackNo: 1, durationSecs: 180, enabled: true, trackId: null, savedIndividually: false, rating: null }], + } + invokeMock.mockImplementation(async command => command === 'spotify_album_page' ? page : null) + const onPlay = vi.fn<(id: number, tracks: unknown, origin?: PlaybackOrigin) => void>() + const props = { query: '', searching: false, results: null, playingUri: null, onAdd: vi.fn(async () => {}), onAddTrack: vi.fn(async () => {}), onRemoveTrack: vi.fn(async () => {}), onPlay, onPlaylist: vi.fn(), onClose: vi.fn(), onError: vi.fn() } + const view = await render() + await act(async () => view.querySelector('.spotify-track-action')!.click()) + const origin = onPlay.mock.calls[0][2]! + expect(origin.kind).toBe('spotify') + expect(origin).toMatchObject({ navigation: [{ kind: 'album', uri: page.uri }] }) + await act(async () => root?.render()) + expect(view.textContent).toContain('Origin Track') + expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalled() + expect(onPlay).toHaveBeenCalledTimes(1) + }) + it('opens album artwork in the shared lightbox without refetching and restores focus when dismissed', async () => { const page: AlbumPageView = { uri: 'spotify:album:one', name: 'One', artist: 'Artist', artistId: 'artist', albumType: 'Album', year: '2026', @@ -1285,6 +1337,225 @@ describe('mounted native interaction boundaries', () => { expect(view.querySelector('[data-track-id="1"]')?.tabIndex).toBe(0) }) + it.each(['playlist', 'shadow', 'library', 'cat', 'art', 'alb'].flatMap(scope => [0.8, 1].map(zoom => ({ scope, zoom }))))('jumps to an offscreen title in $scope at zoom $zoom', async ({ scope, zoom }) => { + const tracks = Array.from({ length: 200 }, (_, index) => track(index + 1, index === 179 ? 'Man of Stone' : index === 99 ? 'May It Be' : `Song ${index + 1}`)) + const browse: BrowseView = { + facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: 200, totalSecs: 36000, perSource: { music: 200, podcasts: 0, audiobooks: 0 } }, + } + invokeMock.mockImplementation(async command => { + if (command === 'browse') return browse + if (command === 'get_settings') return { ...defaultSettings, zoom } + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: scope !== 'shadow', itemsAvailable: true, trackCount: tracks.length, ...(scope === 'shadow' ? { shadow: { account: 'me', refreshedAt: 100, attemptedAt: 100, error: null } } : {}) }] + if (command === 'playlist_tracks') return tracks + return null + }) + const view = await render() + const playlist = scope === 'playlist' || scope === 'shadow' + if (playlist) await act(async () => view.querySelector('.playlist-row')!.click()) + else if (scope !== 'library') await act(async () => view.querySelector(`[data-facet="${scope}"] [data-row-index="1"]`)!.click()) + const targetSelector = playlist ? '[data-upstream-index="179"]' : '[data-track-id="180"]' + expect(view.querySelector(targetSelector)).toBeNull() + await act(async () => view.querySelector('.track-row[data-keyboard-row]')!.focus()) + for (const character of 'man of') { + await act(async () => { key(document.activeElement!, character) }) + } + const target = view.querySelector(targetSelector)! + expect(target.classList.contains('selected')).toBe(true) + expect(document.activeElement === target).toBe(true) + const scroll = view.querySelector(playlist ? '.playlist-track-scroll' : '.track-scroll') + expect(scroll!.scrollTop).toBeGreaterThan(0) + expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) + }) + + it.each([false, true])('jumps to playlist names from sidebar focus, row focus=%s', async rowFocus => { + const playlists = [ + { id: 'metal', name: 'Metal', owned: true, itemsAvailable: true, trackCount: 1 }, + { id: 'metalcore', name: 'Metalcore', owned: true, itemsAvailable: true, trackCount: 1 }, + { id: 'shadow', name: 'Shadow Metal', owned: false, itemsAvailable: true, trackCount: 1, shadow: { account: 'me', refreshedAt: 100, attemptedAt: 100, error: null } }, + ] + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return playlists + if (command === 'playlist_tracks') return [track(1, 'Song')] + return null + }) + const view = await render() + const sidebar = view.querySelector('.sidebar')! + const rows = [...view.querySelectorAll('.playlist-row')] + await act(async () => (rowFocus ? rows[0] : sidebar).focus()) + for (const char of 'metalco') await act(async () => { key(document.activeElement!, char) }) + expect(rows[1].classList.contains('active')).toBe(true) + expect(document.activeElement).toBe(rows[1]) + expect(rows[1].scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + await act(async () => { key(rows[1], 'z') }) + expect(rows[1].classList.contains('active')).toBe(true) + await act(async () => { key(rows[1], 'ArrowDown') }) + expect(document.activeElement).toBe(rows[2]) + for (const char of 'shadow m') await act(async () => { key(document.activeElement!, char) }) + expect(rows[2].classList.contains('active')).toBe(true) + expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) + await act(async () => view.querySelector('[aria-label="New playlist"]')!.click()) + const input = view.querySelector('[aria-label="Playlist name"]')! + await act(async () => { input.focus() }) + expect(key(input, 'm').defaultPrevented).toBe(false) + }) + + it.each(['library', 'playlist', 'shadow'])('returns to the %s launch view and reveals the current track without replaying', async scope => { + const tracks = Array.from({ length: 200 }, (_, index) => ({ ...track(index + 1, index === 179 ? 'Target song' : `Song ${index + 1}`), uri: `spotify:track:${index + 1}` })) + const browse: BrowseView = { + facets: { cats: ['Rock'], arts: ['Artist'], albs: ['Album'] }, tracks, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: 200, totalSecs: 36000, perSource: { music: 200, podcasts: 0, audiobooks: 0 } }, + } + let channel: { onmessage: (event: MainEvent) => void } + let delayBrowse = false + const delayed = deferred() + invokeMock.mockImplementation(async (command, args) => { + if (command === 'browse') return delayBrowse ? delayed.promise : browse + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: scope !== 'shadow', itemsAvailable: true, trackCount: tracks.length, ...(scope === 'shadow' ? { shadow: { account: 'me', refreshedAt: 100, attemptedAt: 100, error: null } } : {}) }] + if (command === 'playlist_tracks') return tracks + if (command === 'subscribe_main_events') { channel = args?.channel as typeof channel; return 1 } + if (command === 'play_tracks') { + const resource = (args!.resources as { id: number; uri: string }[])[Number(args!.startIndex)] + channel.onmessage({ type: 'playerState', payload: { trackId: resource.id, uri: resource.uri, elapsed: 17, isPlaying: true, external: false, name: null, art: null, alb: null, durationSecs: 180, shuffle: false } }) + return 'started' + } + return null + }) + const view = await render() + if (scope !== 'library') await act(async () => view.querySelector('.playlist-row')!.click()) + else for (const facet of ['cat', 'art', 'alb']) await act(async () => view.querySelector(`[data-facet="${facet}"] [data-row-index="1"]`)!.click()) + const row = view.querySelector('.track-row[data-keyboard-row]')! + await act(async () => row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + // Queue advancement must reveal the current URI, not the track originally clicked. + await act(async () => channel.onmessage({ type: 'playerState', payload: { trackId: 180, uri: tracks[179].uri, elapsed: 25, isPlaying: true, external: false, name: null, art: null, alb: null, durationSecs: 180, shuffle: false } })) + const playCount = invokeMock.mock.calls.filter(([command]) => command === 'play_tracks').length + await act(async () => view.querySelector('.source-row')!.click()) + if (scope === 'library') { + for (const facet of ['cat', 'art', 'alb']) await act(async () => view.querySelector(`[data-facet="${facet}"] [data-row-index="0"]`)!.click()) + delayBrowse = true + } + await act(async () => view.querySelector('[aria-label="Show playing source"]')!.click()) + if (scope === 'library') await act(async () => delayed.resolve(browse)) + const selector = scope === 'library' ? '[data-track-id="180"]' : '[data-upstream-index="179"]' + await waitFor(() => expect(view.querySelector(selector)?.classList.contains('selected')).toBe(true)) + if (scope === 'library') { + for (const facet of ['cat', 'art', 'alb']) expect(view.querySelector(`[data-facet="${facet}"] [data-row-index="1"]`)?.classList.contains('active')).toBe(true) + } + const scroll = view.querySelector(scope === 'library' ? '.track-scroll' : '.playlist-track-scroll')! + expect(scroll.scrollTop).toBeGreaterThan(0) + await act(async () => { scroll.scrollTop = 0; scroll.dispatchEvent(new Event('scroll')) }) + await act(async () => view.querySelector('[aria-label="Show playing source"]')!.click()) + expect(scroll.scrollTop).toBeGreaterThan(0) + expect(invokeMock.mock.calls.filter(([command]) => command === 'play_tracks')).toHaveLength(playCount) + }) + + it('rates a playlist track without capturing the star click for reordering', async () => { + let song = track(1, 'Man of Stone') + invokeMock.mockImplementation(async (command, args) => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: true, itemsAvailable: true, trackCount: 1 }] + if (command === 'playlist_tracks') return [song] + if (command === 'click_track_star') song = { ...song, rating: { stars: args?.stars as number, explicit: true } } + return null + }) + const view = await render() + await act(async () => view.querySelector('.playlist-row')!.click()) + const row = view.querySelector('.playlist-track-row')! + const capture = vi.fn() + row.setPointerCapture = capture + const star = row.querySelector('[aria-label="4 stars"]')! + await act(async () => { + star.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + star.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, button: 0 })) + star.click() + }) + expect(capture).not.toHaveBeenCalled() + expect(invokeMock).toHaveBeenCalledWith('click_track_star', { id: 1, stars: 4 }) + expect(row.querySelector('.rating-stars')?.getAttribute('aria-label')).toBe('4 out of 5 stars') + expect(invokeMock.mock.calls.some(([command]) => command === 'playlist_reorder' || command === 'play_tracks')).toBe(false) + }) + + it('keeps playlist rows and selection mounted while metadata refreshes', async () => { + const tracks = Array.from({ length: 115 }, (_, index) => track(index + 1, `Song ${index + 1}`)) + const refreshed = deferred() + let refreshing = false + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: true, itemsAvailable: true, trackCount: tracks.length }] + if (command === 'playlist_tracks') return refreshing ? refreshed.promise : tracks + return null + }) + const view = await render() + await act(async () => view.querySelector('.playlist-row')!.click()) + expect(view.querySelector('.status-library')?.textContent).toBe('115 tracks, 5:45 hours') + const scroll = view.querySelector('.playlist-track-scroll')! + await act(async () => { + scroll.scrollTop = 1700 + scroll.dispatchEvent(new Event('scroll', { bubbles: true })) + }) + const row = [...view.querySelectorAll('.playlist-track-row')].find(row => row.textContent?.includes('Song 112'))! + await act(async () => row.click()) + refreshing = true + await emitNativeEvent('library-changed', null) + // Keeping the spacer height prevents the browser from clamping scrollTop to zero. + expect(view.querySelector('.playlist-track-window')!.style.height).toBe('2300px') + expect(row.isConnected).toBe(true) + expect(row.classList.contains('selected')).toBe(true) + await act(async () => refreshed.resolve(tracks.map(track => track.id === 112 ? { ...track, cat: 'Covers', durationSecs: 240 } : track))) + expect(view.querySelector('.status-library')?.textContent).toBe('115 tracks, 5:46 hours') + expect(row.textContent).toContain('Covers') + expect(row.classList.contains('selected')).toBe(true) + expect(scroll.scrollTop).toBe(1700) + }) + + it.each([false, true])('double-clicking a playlist starts the complete collection with shuffle=%s', async shuffle => { + const tracks = [track(1, 'First'), { ...track(2, 'Disabled'), enabled: false }, track(3, 'Last')] + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return { ...defaultSettings, shuffle } + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return [{ id: 'fixture', name: 'Fixture', owned: true, itemsAvailable: true, trackCount: tracks.length }] + if (command === 'playlist_tracks') return tracks + if (command === 'play_tracks') return 'started' + return null + }) + const view = await render() + const playlist = view.querySelector('.playlist-row')! + await act(async () => playlist.click()) + expect(invokeMock.mock.calls.some(([command]) => command === 'play_tracks')).toBe(false) + await act(async () => playlist.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + expect(invokeMock).toHaveBeenCalledWith('play_tracks', { + resources: [tracks[0], tracks[2]].map(({ uri }) => ({ id: expect.any(Number), uri })), + startIndex: null, + }) + }) + it('discovers and shadows personalized playlists without a URL, then refreshes without replacing playback', async () => { const tracks = [track(1, 'Weekly song')] let shadowed = false @@ -1326,6 +1597,29 @@ describe('mounted native interaction boundaries', () => { expect(invokeMock.mock.calls.some(([command]) => ['playlist_add', 'playlist_remove', 'playlist_reorder'].includes(command))).toBe(false) }) + it('ignores a late playlist start after a newer double-click', async () => { + const first = deferred() + invokeMock.mockImplementation(async (command, args) => { + if (command === 'get_settings') return defaultSettings + if (command === 'connection_state') return { connected: true, needs_reauth: false, playback_authorized: true } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + if (command === 'playlists_list') return ['first', 'second'].map(id => ({ id, name: id, owned: true, itemsAvailable: true, trackCount: 1 })) + if (command === 'playlist_tracks') return args?.id === 'first' ? first.promise : [track(2, 'Second')] + if (command === 'play_tracks') return 'started' + return null + }) + const view = await render() + const playlists = view.querySelectorAll('.playlist-row') + await act(async () => playlists[0].dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + await act(async () => playlists[1].dispatchEvent(new MouseEvent('dblclick', { bubbles: true }))) + await act(async () => first.resolve([track(1, 'First')])) + const plays = invokeMock.mock.calls.filter(([command]) => command === 'play_tracks') + expect(plays).toHaveLength(1) + expect(plays[0][1]).toEqual({ resources: [{ id: 2, uri: track(2, 'Second').uri }], startIndex: null }) + }) + it('windows playlist rows while retaining duplicate positions, range selection, playback, and drag indices', async () => { const tracks = Array.from({ length: 4000 }, (_, index) => ({ ...track(index, `Track ${index % 100}`), id: null, uri: `spotify:track:${index % 100}` })) invokeMock.mockImplementation(async command => { @@ -2444,7 +2738,7 @@ describe('type-ahead behavior', () => { let row = view.querySelector('[data-track-id="1"]')! row.focus() for (const character of 'alpha tw') { - await act(async () => { key(document.activeElement!, character); await new Promise(requestAnimationFrame) }) + await act(async () => { key(document.activeElement!, character) }) } expect(view.querySelector('[data-track-id="2"]')?.classList.contains('selected')).toBe(true) @@ -2453,7 +2747,7 @@ describe('type-ahead behavior', () => { facet.focus() expect(key(facet, ' ').defaultPrevented).toBe(false) for (const character of 'classic r') { - await act(async () => { key(document.activeElement!, character); await new Promise(requestAnimationFrame) }) + await act(async () => { key(document.activeElement!, character) }) } expect(view.querySelector('[data-facet="cat"] [data-row-index="2"]')?.classList.contains('active')).toBe(true) expect(held).toHaveLength(1) @@ -2781,3 +3075,91 @@ function albumCandidate(uri: string, name: string) { albumType: 'album', totalTracks: 1, trackNumbers: [1], trackDurations: [180], } } + + +describe('native Preferences window', () => { + const preferencesFixture = () => { + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return defaultSettings + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + return null + }) + } + + it('uses the saved preference for browser ordering and keeps selection identities', async () => { + let settings = { ...defaultSettings } + const tracks = [ + { ...track(1, 'Queen song'), art: 'Queen', alb: 'Queen' }, + { ...track(2, 'Beatles song'), art: 'The Beatles', alb: 'The Abbey Road' }, + ] + const browse: BrowseView = { + facets: { cats: ['Rock'], arts: ['Queen', 'The Beatles'], albs: ['Queen', 'The Abbey Road'] }, tracks, + albumRating: null, albumRatingArtist: null, albumRatingAmbiguous: false, + counts: { tracks: 2, totalSecs: 360, perSource: { music: 2, podcasts: 0, audiobooks: 0 } }, + } + invokeMock.mockImplementation(async command => { + if (command === 'get_settings') return settings + if (command === 'browse') return browse + if (command === 'playlists_list') return [] + if (command === 'connection_state') return { connected: false, needs_reauth: false, playback_authorized: false } + if (command === 'spotify_sync_status') return spotifyStatus() + if (command === 'lastfm_state') return { available: false, connected: false } + if (command === 'lastfm_import_state') return idleLastFmImport() + return null + }) + const view = await render() + const artists = () => [...view.querySelectorAll('[data-facet="art"] [data-row-index]')].slice(1).map(row => row.textContent) + expect(artists()).toEqual(['Queen', 'The Beatles']) + settings = { ...settings, ignoreLeadingThe: true } + await emitNativeEvent('settings-changed', null) + expect(artists()).toEqual(['The Beatles', 'Queen']) + expect(view.querySelector('.track-window [data-track-id]')?.getAttribute('data-track-id')).toBe('2') + const artist = view.querySelector('[data-facet="art"] [data-row-index="1"]')! + await act(async () => artist.click()) + expect(invokeMock).toHaveBeenCalledWith('browse', expect.objectContaining({ sel: { cat: [], art: ['The Beatles'], alb: [] } })) + await act(async () => { key(document.body, ',', { metaKey: true }) }) + expect(invokeMock).toHaveBeenCalledWith('open_preferences', undefined) + expect(view.querySelector('.preferences')).toBeNull() + }) + + it('discards local settings and text size changes on Cancel', async () => { + preferencesFixture() + const view = await render() + const range = view.querySelector('[aria-label="Text size"]')! + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(range, '1.3') + await act(async () => range.dispatchEvent(new Event('input', { bubbles: true }))) + await act(async () => view.querySelector('#preferences-library-tab')!.click()) + await act(async () => view.querySelector('.preference-content input[type="checkbox"]')!.click()) + await clickText(view, 'Cancel') + expect(closeWindowMock).toHaveBeenCalledOnce() + expect(invokeMock.mock.calls.some(([command]) => command === 'update_settings')).toBe(false) + }) + + it('saves the alphabetizing preference and only closes after a successful save', async () => { + preferencesFixture() + const view = await render() + await act(async () => view.querySelector('#preferences-library-tab')!.click()) + const checkbox = view.querySelector('.preference-content input[type="checkbox"]')! + expect(checkbox.checked).toBe(false) + await act(async () => checkbox.click()) + invokeMock.mockImplementation(async command => { + if (command === 'update_settings') throw new Error('disk full') + return null + }) + const submit = () => view.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + await act(async () => { submit() }) + expect(closeWindowMock).not.toHaveBeenCalled() + expect(view.querySelector('[role="alert"]')?.textContent).toContain('Couldn’t finish saving') + expect(checkbox.checked).toBe(true) + const pending = deferred() + invokeMock.mockImplementation(async () => pending.promise) + await act(async () => { submit(); submit() }) + expect(view.querySelector('button[type="submit"]')?.disabled).toBe(true) + expect(invokeMock.mock.calls.filter(([command]) => command === 'update_settings')).toHaveLength(2) + expect(invokeMock).toHaveBeenLastCalledWith('update_settings', { patch: expect.objectContaining({ ignoreLeadingThe: true, zoom: 1 }) }) + expect(closeWindowMock).not.toHaveBeenCalled() + await act(async () => pending.resolve(null)) + expect(closeWindowMock).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/test/mainEntry.test.tsx b/apps/desktop/test/mainEntry.test.tsx index 9843acb..bb800d3 100644 --- a/apps/desktop/test/mainEntry.test.tsx +++ b/apps/desktop/test/mainEntry.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { beforeEach, expect, it, vi } from 'vitest' -const entry = vi.hoisted(() => ({ label: 'main', fail: false, render: vi.fn(), main: vi.fn(), importer: vi.fn() })) +const entry = vi.hoisted(() => ({ label: 'main', fail: false, render: vi.fn(), main: vi.fn(), importer: vi.fn(), preferences: vi.fn() })) vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => ({ label: entry.label }) })) vi.mock('react-dom/client', () => ({ createRoot: () => ({ render: entry.render }) })) beforeEach(() => { @@ -11,15 +11,17 @@ beforeEach(() => { if (entry.fail) throw new Error('missing main chunk') return { default: () => null } }) + vi.doMock('../src/PreferencesWindow.tsx', () => { entry.preferences(); return { default: () => null } }) vi.doMock('../src/LastFmImporter.tsx', () => { entry.importer(); return { default: () => null } }) }) -it.each(['main', 'lastfm-importer'])('loads only the selected %s window entry', async label => { +it.each(['main', 'lastfm-importer', 'preferences'])('loads only the selected %s window entry', async label => { entry.label = label await import('../src/main.tsx') await vi.dynamicImportSettled() expect(entry.main).toHaveBeenCalledTimes(label === 'main' ? 1 : 0) expect(entry.importer).toHaveBeenCalledTimes(label === 'lastfm-importer' ? 1 : 0) + expect(entry.preferences).toHaveBeenCalledTimes(label === 'preferences' ? 1 : 0) expect(entry.render.mock.calls[0][0].props.role).toBe('status') expect(entry.render.mock.calls.at(-1)![0].props.children.type).toBeTypeOf('function') }) diff --git a/apps/desktop/test/ui.test.ts b/apps/desktop/test/ui.test.ts index b873b60..614e9f0 100644 --- a/apps/desktop/test/ui.test.ts +++ b/apps/desktop/test/ui.test.ts @@ -6,7 +6,7 @@ import { initialState, reducer, type Action } from '../src/appState.ts' import type { BrowseView, PlaybackTrack, PlayOutcome, Selection, Settings, SpotifyResults } from '../src/types.ts' import { createSpotifySearchState, expandSpotifySearchGroup, failSpotifySearchGroup, moreSpotifySearchLabel, receiveSpotifySearchPage, replaceSpotifySearchResults, resetSpotifySearchQuery, retrySpotifySearchGroup, setSpotifySearchTab, spotifyMembership, spotifySearchGroupHeader, spotifySearchPendingPageKey } from '../src/spotifySearch.ts' import { activeImportQueue, applyCurrentImportRefresh, beginImportRefresh, canHandleImportShortcut, collectionAlbumActionLabel, collectionAlbumTrackStatuses, collectionAmbiguousChoices, collectionCoverageStatus, collectionDialogInitialState, collectionDialogScreen, collectionDialogTransition, collectionImportBranch, collectionPreviewCoverageCopy, collectionSuggestion, downloadAction, filterImportQueue, handleImportQueueTab, importAlbumActionAdvances, importApplyErrorCode, importCountMergePresentation, importDownloadCopy, importDownloadPercent, importDownloadProgressLabel, importEmptyPageMessage, importHistoryBreadcrumb, importQueueHighlightIndex, importQueueTabTarget, importQueueVisibleRange, importStatusText, isCurrentImportPageResponse, loadSelectedImportPage, mergeReviewBatchDraft, moveImportNavigationRow, moveImportQueueIndex, nextRemainingImportQueue, parseImportApplyResult, pickerCandidates, pickerSelectedUri, projectAcknowledgedImportApply, resolveImportCount, runCheckedImportMutation, selectImportRows, selectedCollectionAlbumUris, selectedImportTrackConfidence, shouldRefreshImportEvent, showsImportRemaining, sortImportQueue, sortImportRows, spotifyLimitCountdown, stablePartitionImportRows, strongImportAlbumMatch, trackPickerQuery, validImportIntent, type ImportQueueItem, type ImportSourceRow, type ReviewState } from '../src/lastfmImportState.ts' -import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseViewForRequest, cancelTrackInfoLoad, clearedTrackRating, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, dialogTabTarget, entityRequestGeneration, facetLabel, failedPlaylistRows, insertionIndexAtY, isCurrentTrack, LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, menuPosition, mergeByUri, moveBefore, moveToIndex, normalizeZoom, overlayEditTargets, pendingEntities, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, rememberSelection, resolvedPlaylistRows, restoreSelection, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, visibleColumnOrder } from '../src/ui.ts' +import { appliedZoom, beginPendingEntity, beginRequestGeneration, browseFacetValues, browseRequestKey, browseViewForRequest, cancelTrackInfoLoad, clearedTrackRating, compareBrowseTracks, compareMusicText, compareTracks, contiguousRange, currentPlaybackAuthorization, currentPlaylistRows, dialogTabTarget, entityRequestGeneration, facetLabel, failedPlaylistRows, insertionIndexAtY, isCurrentTrack, LIBRARY_DEFAULT_COLUMN_ORDER, LIBRARY_DEFAULT_HIDDEN_COLUMNS, loadArtwork, loadCurrentGeneration, loadingPlaylistRows, menuPosition, mergeByUri, moveBefore, moveToIndex, normalizeZoom, overlayEditTargets, pendingEntities, pendingPlaybackTarget, playbackAuthorizationPrompt, playbackOriginAction, playbackQueue, playbackRetryReady, playbackStartAction, playlistLayoutFor, playlistOverride, playlistRows, playlistRowsReady, PLAYLIST_DEFAULT_COLUMN_ORDER, PLAYLIST_DEFAULT_HIDDEN_COLUMNS, rememberSelection, resolvedPlaylistRows, restoreSelection, resizedColumnWidth, resizedPaneHeight, selectionAfterFacet, staleSelectionFacet, SYNTHETIC_BASE, visibleColumnOrder } from '../src/ui.ts' const searchPage = (overrides: Partial = {}): SpotifyResults => ({ artists: { items: Array.from({ length: 10 }, (_, index) => ({ id: `artist-${index}`, name: `Artist ${index}`, descriptor: '', imageUrl: null })), total: 21, nextOffset: 10 }, @@ -797,7 +797,7 @@ test('source, pane, and playlist transitions restore and retain the intended sel let state = reducer(initialState, { type: 'select', facet: 'cat', values: ['Rock'] }) state = reducer(state, { type: 'select', facet: 'art', values: ['Artist'] }) state = reducer(state, { type: 'select', facet: 'alb', values: ['Album'] }) - state = reducer(state, { type: 'source', source: 'podcasts' }) + state = reducer(state, { type: 'playingOrigin', origin: { kind: 'library', source: 'podcasts' } }) state = reducer(state, { type: 'select', facet: 'cat', values: ['The Hobbit'] }) state = reducer(state, { type: 'source', source: 'music' }) @@ -1066,15 +1066,41 @@ test('ordinary queues skip exclusions but an explicit start still plays one', () assert.deepEqual(playbackQueue(tracks, 3).map((track) => track.id), [1, 3]) }) -test('playlist highlights require both the synthetic id and Spotify URI', () => { +test('playlist highlights follow the URI when adding a playing track to the library', () => { const playing = { trackId: SYNTHETIC_BASE + 15, uri: 'spotify:track:better-days' } assert.equal(isCurrentTrack(playing, { id: SYNTHETIC_BASE + 15, uri: 'spotify:track:silent-thanks' }), false) assert.equal(isCurrentTrack(playing, { id: SYNTHETIC_BASE + 15, uri: 'spotify:track:better-days' }), true) + assert.equal(isCurrentTrack(playing, { id: 42, uri: 'spotify:track:better-days' }), true) + assert.equal(isCurrentTrack(null, { id: 42, uri: 'spotify:track:better-days' }), false) + assert.equal(isCurrentTrack({ trackId: null, uri: null }, { id: 42, uri: 'spotify:track:better-days' }), false) }) test('playback origins return to the launching library or playlist', () => { - assert.deepEqual(playbackOriginAction({ kind: 'library', source: 'podcasts' }), { type: 'source', source: 'podcasts' }) - assert.deepEqual(playbackOriginAction({ kind: 'playlist', id: 'road-trip' }), { type: 'playlist', id: 'road-trip' }) + assert.deepEqual(playbackOriginAction({ kind: 'library', source: 'podcasts' }), { type: 'playingOrigin', origin: { kind: 'library', source: 'podcasts' } }) + assert.deepEqual(playbackOriginAction({ kind: 'playlist', id: 'road-trip' }), { type: 'playingOrigin', origin: { kind: 'playlist', id: 'road-trip' } }) +}) + +test('returning to a library origin restores launch filters and query without changing playback', () => { + const origin = { kind: 'library' as const, source: 'music' as const, selection: { cat: ['Rock'], art: ['Artist'], alb: ['Album'] }, query: 'song' } + const away = { ...initialState, scope: 'spotify' as const, query: 'other', sel: { cat: ['Jazz'] }, selectedPlaylist: 'other' } + const restored = reducer(away, playbackOriginAction(origin)) + assert.deepEqual(restored.sel, origin.selection) + assert.deepEqual(restored.savedSelections.music, origin.selection) + assert.equal(restored.query, 'song') + assert.equal(restored.queryReset, away.queryReset + 1) + assert.equal(restored.scope, 'library') + assert.equal(restored.selectedPlaylist, undefined) + assert.equal(restored.playing, away.playing) +}) + +test('Spotify origin keeps search navigation rooted in results', () => { + const origin = { kind: 'spotify' as const, query: 'Nightwish Nemo', navigation: [{ kind: 'album' as const, uri: 'spotify:album:once' }], search: createSpotifySearchState('Nightwish Nemo') } + const restored = reducer(initialState, playbackOriginAction(origin)) + assert.equal(restored.scope, 'spotify') + assert.equal(restored.query, origin.query) + assert.equal(restored.spotifyNavigation, undefined) + const direct = reducer(initialState, playbackOriginAction({ ...origin, query: '' })) + assert.deepEqual(direct.spotifyNavigation, origin.navigation[0]) }) test('playback start and result decisions keep OAuth outside the controller', () => { @@ -1205,3 +1231,20 @@ test('release dates sort chronologically with the standard tie-breakers and miss assert.deepEqual([...tracks].sort((a, b) => compareTracks(a, b, 'releaseDate', false)), [tracks[3], tracks[2], tracks[1], tracks[0]]) assert.deepEqual([...tracks].sort((a, b) => compareTracks(a, b, 'releaseDate', true)), [tracks[1], tracks[2], tracks[3], tracks[0]]) }) + + +test('ignoring leading The sorts music labels without changing names or playlist order', () => { + const names = ['The Beatles', 'ABBA', 'Thelonious Monk', 'the Cure', 'Queen'] + assert.deepEqual([...names].sort((a, b) => compareMusicText(a, b, true)), ['ABBA', 'The Beatles', 'the Cure', 'Queen', 'Thelonious Monk']) + assert.deepEqual([...names].sort((a, b) => compareMusicText(a, b, false)), ['ABBA', 'Queen', 'The Beatles', 'the Cure', 'Thelonious Monk']) + assert.ok(compareMusicText('The', 'Them', true) < 0) + assert.ok(compareMusicText('Breathe the Air', 'Cure', true) < 0) + const tracks = names.map((name, index) => ({ name, art: name, alb: name, cat: 'Rock', discNo: 1, trackNo: 1, durationSecs: 180, rating: null, playCount: 0, kind: null, bitrateKbps: null, lastPlayedAt: null, addedAt: null, releaseDate: null, index })) + for (const column of ['name', 'artist', 'album'] as const) { + assert.deepEqual([...tracks].sort((a, b) => compareTracks(a, b, column, false, true)).map(track => track.index), [1, 0, 3, 4, 2]) + assert.deepEqual([...tracks].sort((a, b) => compareTracks(a, b, column, true, true)).map(track => track.index), [2, 4, 3, 0, 1]) + } + assert.deepEqual(playlistRows(tracks, null, false, true).map(row => row.upstreamIndex), [0, 1, 2, 3, 4]) + assert.deepEqual([...tracks].sort((a, b) => compareBrowseTracks(a, b, {})).map(track => track.index), [1, 0, 3, 4, 2]) + assert.deepEqual([...tracks].sort((a, b) => compareBrowseTracks(a, b, { art: ['Queen', 'The Beatles'] })).filter(track => ['Queen', 'The Beatles'].includes(track.art)).map(track => track.index), [4, 0]) +}) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8e3e77c..5a39a76 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -213,3 +213,13 @@ comma-separated IDs of saved playlists from other creators, then run `cargo test -p retune-desktop --lib live_shadow_round_trip -- --ignored --nocapture`. It verifies discovery, full metadata, repeated refresh/order, and a temporary cache save/reload. It does not mutate Spotify or the application's real cache. + +### Live interrupted-playback check + +Set `RETUNE_PLAYBACK_PROBE_TOKENS` to an existing development token file and run +`cargo test -p retune-desktop --lib live_interrupted_load_recovers_same_track -- --ignored --nocapture`. +This opt-in probe installs a disconnected AP session before loading Follow You, +requires an unavailable event, and verifies that a fresh session decodes the same +track past one second without advancing the queue. It uses muted native output, +a temporary audio cache, and an in-memory token store; no library or listening +history callbacks are installed. Network and an audio device are required. diff --git a/docs/architecture/library.md b/docs/architecture/library.md index 098bf50..ea65c35 100644 --- a/docs/architecture/library.md +++ b/docs/architecture/library.md @@ -380,8 +380,9 @@ still refresh the local projection used when Spotify search is closed. Category prefix candidates depend on source, artist candidates on source and category, and album candidates on source, category, and artist, so a focused pane can finish a prefix while its own selection request is pending. Double-clicking a -facet row waits for that exact projection, then starts its first enabled visible -track with the enabled projection as the new queue. +facet row waits for that exact projection, then starts the enabled projection as +a collection queue. Shuffle off starts at its first track; shuffle on shuffles +the whole scope before selecting the first track. Right-clicking an artist or album value offers the corresponding Spotify navigation before a separator and the column visibility controls. The action @@ -418,3 +419,12 @@ envelope; see [Persistence](persistence.md). Keep provider translation out of this crate. New behavior belongs here only when it can be expressed and tested as a deterministic transformation of library state. + + +The Library preference “Ignore leading ‘The’ when sorting” defaults to off. +When enabled, the frontend sorts artist/album browser values, the default +library artist/album groups, and explicit song/artist/album column sorts using +the name after a case-insensitive leading `The` plus whitespace. Display names, +selection identities, the narrowest selected facet's group order, disc/track +order, and canonical playlist order remain intact. Keyboard navigation and +prefix search use the same sorted browser values as the rendered columns. diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 5d35d65..bff7f14 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -336,16 +336,16 @@ save, and export restore all validate this boundary. One native settings owner serializes every runtime mutation from the latest in-memory value through normalization, validation, atomic replacement, and the -memory swap. User-visible changes then emit one `settings-changed` event in the -same serialized operation; private bookkeeping changes remain eventless. +memory swap. User-visible changes then emit targeted `settings-changed` +invalidations to the main and Preferences windows in the same serialized operation; private bookkeeping changes remain eventless. Once a settings save starts, an owned completion retains the mutation gate through the durable write and memory swap even if the invoking command is cancelled. Frontend commands send field patches or playback intents instead of persisted snapshots. The public settings view omits `spotifySyncCompleted`, `lastFullSync`, and `lastfmScrobblingProfile`; those -remain native bookkeeping in the unchanged `settings.json` format. Secondary -windows read the narrow appearance payload and subscribe to +remain native bookkeeping in the unchanged `settings.json` format. The Preferences window reads the public settings view. The importer +reads the narrow appearance payload and subscribe to `appearance-changed` rather than loading the settings record. The existing lowercase `playbackBackend` and `repeat` bytes are closed enums; missing values default to `local` and `off`, while unknown or case-changed @@ -484,3 +484,8 @@ account, successful/attempted refresh timestamps, and last refresh error. Absent records retain legacy playlist behavior. Shadow track lists and display metadata use the existing playlist atomic store; no audio is downloaded to this cache. Failed refreshes persist their status while retaining the last complete content. + + +The Library preference `ignoreLeadingThe` is persisted as a boolean, defaulting +to false for new and legacy settings. It changes presentation sorting only; +track names, library identities, and stored playlist order are unchanged. diff --git a/docs/architecture/playback.md b/docs/architecture/playback.md index af321f5..3be2fde 100644 --- a/docs/architecture/playback.md +++ b/docs/architecture/playback.md @@ -48,7 +48,14 @@ work. The callback runs on the serialized playback event path and must not synchronously re-enter `Playback`; artwork publication remains spawned by the shell. -Shuffle permutes only the future suffix and retains the canonical list so turning +Playlist sidebar double-click loads the playlist in canonical order and starts a +collection queue. The playback IPC uses a null start index for collection starts: +shuffle off starts at the first enabled track; shuffle on permutes the entire +queue before selecting its first track. Explicit track starts retain a numeric +index and keep the selected track first. Late playlist loads cannot replace a +newer frontend play request. + +During playback, shuffle permutes only the future suffix and retains the canonical list so turning shuffle off restores normal order without losing the current track. Repeat off, all, and one are controller policies shared by every backend. The persisted and IPC boundaries use closed lowercase `RepeatMode` @@ -61,6 +68,14 @@ Ordinary queues omit overlay tracks disabled by the user. Explicitly starting a disabled track includes that track for the run while later advancement still skips the other disabled tracks. +The main-window playing title returns to the view captured by the explicit play +request: library source, Genre/Artist/Album filters and query; playlist and its +sort; or Spotify query, result tab/pages and album navigation. This snapshot is +session-local React state, retained as the queue advances. Return navigation +reveals and selects the current URI after rows load without sending a playback +command. Missing or removed rows leave the restored view usable. External +Spotify playback has no Retune launch view. + Navigation and resolved Library projections are view-only; they never mutate or replace the active queue. An explicit Library, playlist, or other play/start action establishes the canonical queue and current position. Repeat off stops @@ -173,6 +188,14 @@ and playback position. Unexpected player-thread failure rebuilds the local runtime and resumes from the millisecond position reported by the player. Explicit backend and audio-setting changes may also replace the runtime. +If the AP session drops during a load (including its audio-key request), an +unavailable event gets one same-track retry after session refresh, before the +normal unavailable/advance policy. The reducer binds the retry to the current +generation, request, URI, and load intent; newer playback commands supersede it. +A second failure follows the normal error policy. Healthy-session failures and +local-file failures are not retried this way. Recovery does not clear the audio +cache: missing decryption keys do not establish corrupt cached audio. + Near the end of a track, librespot asks Retune to preload the next item. The controller rejects stale generation, request, or URI signals, then selects the next built-in Spotify track from the active queue order. Shuffle order is already diff --git a/docs/tauri.md b/docs/tauri.md index a651c5d..a4af263 100644 --- a/docs/tauri.md +++ b/docs/tauri.md @@ -31,14 +31,15 @@ time-bounded audit, not in a permanent completed-findings ledger. ## Windows and authority -Retune has two webview labels: +Retune has three webview labels: | Window | Purpose | Authority | | --- | --- | --- | -| `main` | Library, settings, Spotify, playback, diagnostics, and import launch | `main-commands`, event listen/unlisten, and title mutation | +| `main` | Library, Spotify, playback, and auxiliary-window launch | `main-commands`, event listen/unlisten, and title mutation | | `lastfm-importer` | Last.fm import review and application | `importer-commands` and event listen/unlisten | +| `preferences` | Preferences, account setup, removed-track recovery, and diagnostics | `preferences-commands`, event listen/unlisten, and closing its own window | -`tauri.conf.json` activates exactly `default` and `lastfm-importer`; adding a +`tauri.conf.json` activates exactly `default`, `lastfm-importer`, and `preferences`; adding a capability file does not silently enable it. The capability files name one window apiece and avoid aggregate `core:default`, opener defaults, frontend event emission, image-from-path, and reveal-in-directory permissions. Plugins @@ -46,11 +47,20 @@ may be registered for Rust use without granting their JavaScript permissions. `build.rs` declares every application command in a Tauri `AppManifest`. `generate_handler!`, generated command permissions, `main.toml`, and -`importer.toml` must remain in parity. `scripts/check-tauri-acl.mjs` enforces +`importer.toml` and `preferences.toml` must remain in parity. `scripts/check-tauri-acl.mjs` enforces that parity, the exact window capability sets, the importer allow-list, the absence of raw frontend path import, and the absence of sensitive core-event payloads. +Preferences is a singleton native window with a standard title bar, resize +handles, and a minimum size. It can move outside the main window. Cancel or +native close discards draft preferences; OK persists a settings patch before +closing. Text-size changes preview inside Preferences until OK. Its controls +remain usable at the minimum size via scrolling. Labels do not select text; +editable fields and diagnostics remain selectable. It receives only the commands +used by its tabs, without playback or private main-channel authority. Account, +settings, and import status changes arrive as targeted invalidations. + The importer receives the narrow appearance and genre queries it needs. It does not receive full settings, Spotify connection or mutation, direct playback control, diagnostics, local import, or main-event subscription authority. Shared @@ -89,7 +99,7 @@ owns queue advancement and visible playback state. - The single-instance plugin is registered first on desktop. A second launch shows, unminimizes, and focuses the existing main window. - Window-state persistence records size, position, and maximized state for the - main window only. The transient importer is denied. + main window only. The transient importer and Preferences windows are denied. - Native dialogs perform backup selection, restore selection, local-file selection, and user-facing confirmations. Rust receives their results. - Native file-drop events are accepted only for the intended window and are @@ -140,8 +150,8 @@ form destinations; permits scripts from self; permits artwork from self, `https://i.scdn.co`, and data URLs; and limits connections to Tauri IPC. Adding a remote origin requires an explicit architecture review. -The frontend entry loads only the component for its native window label. Main -and importer view modules are separate chunks; shared theme/dialog CSS remains +The frontend entry loads only the component for its native window label. Main, +importer, and Preferences view modules are separate chunks; shared theme/dialog CSS remains eager. A loading status is replaced by the chosen view, and a chunk-load error offers a full-window retry without changing the webview's authority. diff --git a/docs/ui-validation-2026-10-14.md b/docs/ui-validation-2026-10-14.md new file mode 100644 index 0000000..8230055 --- /dev/null +++ b/docs/ui-validation-2026-10-14.md @@ -0,0 +1,56 @@ +# Desktop UI validation — 2026-10-14 + +Tested the release app with the existing local library and Spotify account. The final build was installed at `/Applications/Retune.app` and launched successfully. Tests below used native computer interaction unless explicitly marked automated. + +## Playback origin and track identity + +| Procedure | Expected result | Observed result | +| --- | --- | --- | +| Play from Cinematic Rock → Nathan Wagner → Introspection, visit Chill and Recently Added, then click the playing title. | Restore all three facets and reveal the current track without restarting it. | Passed: all facets restored, Die selected, playback continued. | +| Sort Metalcore by Song, play I Can't Breathe, leave the playlist, then click the playing title. | Restore the playlist's captured sort and select the playing row. | Passed; playback continued. | +| Play I'm Gone in Metalcore, search Spotify for Nightwish, then click the playing title. | Return to Metalcore and reveal the offscreen playing row. | Passed: row 91 selected and bold; elapsed time continued. | +| Search Spotify for Nightwish Nemo, choose Tracks, play Nemo, visit Library, then click the playing title. | Restore the query and Tracks tab. | Passed: query and Tracks results restored; playback continued. | +| Open Once from Spotify search, play Higher Than Hope, visit Music, then click the playing title and use Back. | Restore the album and playing row; Back returns to search results. | Passed after correcting the restored navigation root: Back returned to Nightwish Nemo results. | +| Play Follow You from a temporary Release Radar shadow playlist, leave and return through the playing title, then add the track to the library. | Restore the shadow playlist and retain the playing highlight when the library ID changes. | Passed: correct row highlighted before and after adding it. | + +Automated interaction tests additionally cover queue advancement to another URI, asynchronous library loading, repeated title clicks without replay, and Spotify album restoration. Playback origins are session-local; externally started playback has no captured origin. + +## Browsing, selection, and metadata + +| Procedure | Expected result | Observed result | +| --- | --- | --- | +| Type a track prefix in Metalcore, External Covers, a shadow playlist, and the filtered library. | Select and reveal the matching row without playing it. | Passed for `i'm`, `heyya`, `follow`, and `manof`. | +| Rapidly type a prefix whose first letter moves focus offscreen. | Keep subsequent letters in the same prefix. | Found a focus race; fixed focus timing and verified `i'm` selects I'm Gone in the rebuilt native app. Regression tests omit inter-letter animation-frame waits. | +| Exercise track prefix navigation at 80% and 100% zoom. | Selection and focus remain usable. | Passed. Automated coverage includes playlist, shadow playlist, library, genre, artist, and album contexts at both zoom levels. | +| Double-click genre, artist, and album with shuffle on, then repeat with shuffle off. | Shuffle applies to the chosen collection; disabling shuffle starts its first track. | Passed: shuffled starts included Die and Homesick; ordered starts were Dreams of my Downfall for the album and Angels vs Demons for artist/genre. | +| Inspect collection footers. | Counts and duration describe the displayed collection. | Passed: Metalcore 110 / 6:39, External Covers 93 / 6:11, Release Radar 30 / 1:47; filtered library counts also matched the chosen scope. | +| Rate a playing playlist track, then edit its genre through Get Info. | Preserve scroll, selection, and playing highlight. | Passed for a four-star rating and blank genre → Metalcore. Both edits were restored afterward. | +| Inspect keyboard focus in light and dark themes. | Visible solid focus outline. | Passed visually. Sidebar name-prefix behavior also has automated coverage for ordinary/shadow playlists, arrow navigation, and avoiding text-input interception. | + +## Preferences + +| Procedure | Expected result | Observed result | +| --- | --- | --- | +| Open the separate Preferences window and use native zoom/maximize, then restore it. | Independent native window can enlarge and restore. | Passed; it expanded beyond the main window's dimensions. | +| Change settings and Cancel. | Discard draft changes. | Passed. | +| Enable Ignore leading “The” and Save. | Artist and album sorting use the setting. | Passed: The Barden Bellas and The Beginning Of The End appeared among B entries. | +| Change zoom from 100% to 80% and back. | Preferences and application remain usable. | Passed. | +| Click a label and select all. | Labels do not become selectable text. | No label text selection observed. | + +Native title-bar dragging and manual edge resizing/minimum-size enforcement could not be conclusively verified through the automation driver. Earlier browser layout checks covered 680×640 and 480×360, with scrolling and the footer accessible; these do not establish native window behavior. + +## Automated checks and installation + +- Final frontend suite: 98 Node tests and 122 Vitest tests passed; six existing tests skipped. +- Lint, TypeScript/Vite build, native release app build, and whitespace checks passed. +- Full Rust workspace tests passed with the required local socket access. The opt-in live session-recovery probe also passed; other live/audio tests remain opt-in. +- Strict workspace Clippy passed; the existing atomic API deprecations are corrected by the latest main-branch change incorporated into this PR. +- The release bundle was ad-hoc signed, passed strict deep signature verification, copied to Applications, and launched successfully. + +A follow-up review also found that unrelated settings saves cancelled playback intent. Settings now request a backend switch only when that setting changes; a focused regression test covers ordinary preference changes and an explicit backend switch. + +## Test side effects and limitations + +One initial Follow You playback attempt hit a native decoder/cache failure and skipped to MADARA. The application log recorded an invalid MPEG/end-of-stream failure followed by session refresh/cache eviction. A retry played Follow You successfully. Investigation identified an AP connection closure and audio-key timeout immediately before this error: librespot attempted to decode encrypted bytes without the key. The controller now reconnects and retries that same load once. An opt-in native fault-injection test forced an unavailable load on a disconnected session and verified Follow You decoded past one second without advancing the queue; it passed using muted output and a temporary cache. + +The temporary Release Radar shadow playlist was removed. Follow You was unsaved from Spotify and moved to Retune's Removed tracks, restoring the library count to 7,966; its soft-removed metadata remains. Temporary genre/rating edits and preferences were restored. The installed app was left open with playback stopped, light theme, 100% zoom, Ignore leading “The” disabled, shuffle enabled, and repeat-all enabled. Playback testing may have generated normal listening history/scrobbles. diff --git a/scripts/check-tauri-acl.mjs b/scripts/check-tauri-acl.mjs index 7d5a4d9..c04fe31 100644 --- a/scripts/check-tauri-acl.mjs +++ b/scripts/check-tauri-acl.mjs @@ -95,10 +95,27 @@ for (const command of importerCommands) { if (!registered.includes(command)) errors.push(`importer command is not registered: ${command}`); } +const preferencesCommands = [ + "get_settings", "update_settings", "lastfm_state", "connect_lastfm", "finish_lastfm", + "disconnect_lastfm", "lastfm_import_state", "sync_lastfm_plays", "open_lastfm_importer", + "removed_retune_tracks", "restore_retune_track", "load_diagnostics", "email_diagnostics", + "open_external_destination", +]; +checkSet("preferences command permissions", permissionList("apps/desktop/src-tauri/permissions/preferences.toml"), preferencesCommands.map(allow)); +for (const command of preferencesCommands) { + if (!registered.includes(command)) errors.push(`preferences command is not registered: ${command}`); +} + const capabilityFiles = fs.readdirSync(path.join(tauri, "capabilities")).sort(); -checkSet("capability files", capabilityFiles, ["default.json", "lastfm-importer.json"]); +checkSet("capability files", capabilityFiles, ["default.json", "lastfm-importer.json", "preferences.json"]); const main = JSON.parse(read("apps/desktop/src-tauri/capabilities/default.json")); const importer = JSON.parse(read("apps/desktop/src-tauri/capabilities/lastfm-importer.json")); +const preferences = JSON.parse(read("apps/desktop/src-tauri/capabilities/preferences.json")); +checkSet("preferences capability windows", preferences.windows ?? [], ["preferences"]); +checkSet("preferences capability platforms", preferences.platforms ?? [], ["linux", "macOS", "windows"]); +checkSet("preferences capability permissions", preferences.permissions ?? [], [ + "core:event:allow-listen", "core:event:allow-unlisten", "core:window:allow-close", "preferences-commands", +]); checkSet("main capability windows", main.windows ?? [], ["main"]); checkSet("importer capability windows", importer.windows ?? [], ["lastfm-importer"]); checkSet("main capability platforms", main.platforms ?? [], ["linux", "macOS", "windows"]); @@ -116,13 +133,13 @@ checkSet("importer capability permissions", importer.permissions ?? [], [ ]); const config = JSON.parse(read("apps/desktop/src-tauri/tauri.conf.json")); -checkSet("configured capabilities", config.app?.security?.capabilities ?? [], ["default", "lastfm-importer"]); +checkSet("configured capabilities", config.app?.security?.capabilities ?? [], ["default", "lastfm-importer", "preferences"]); checkSet("configured window labels", (config.app?.windows ?? []).map((window) => window.label), ["main"]); const csp = new Set((config.app?.security?.csp ?? "").split(";").map((directive) => directive.trim())); for (const directive of ["base-uri 'none'", "object-src 'none'", "frame-src 'none'", "form-action 'none'"]) { if (!csp.has(directive)) errors.push(`missing CSP directive: ${directive}`); } -const allCapabilityPermissions = [...(main.permissions ?? []), ...(importer.permissions ?? [])]; +const allCapabilityPermissions = [...(main.permissions ?? []), ...(importer.permissions ?? []), ...(preferences.permissions ?? [])]; for (const forbidden of [":default", "allow-emit", "allow-image-from-path", "allow-reveal-item-in-dir"]) { if (allCapabilityPermissions.some((permission) => permission.includes(forbidden))) { errors.push(`forbidden capability permission: ${forbidden}`);