diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2ade8a5..4e48e1c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -13,7 +13,7 @@ mod sync; mod sync_orchestrator; use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fs, io::Read, path::PathBuf, @@ -29,8 +29,8 @@ use flate2::read::GzDecoder; use playback::{AudioSettings, Playback, PlayerStateEvent, SnapshotTrack}; use provider::{ - artist_albums_page, artist_descriptor, image_url, spotify_id, title_case, ArtistAlbumsPage, - SearchResults, SpotifySyncProvider, SyncBatch, + artist_albums_page, artist_descriptor, image_url, image_url_at_least, spotify_id, title_case, + ArtistAlbumsPage, SearchResults, SpotifySyncProvider, SyncBatch, }; use retune_core::{ browse::{self, Selection}, @@ -76,7 +76,7 @@ struct AppState { recovery_notice: Mutex>, token_store: SharedTokenStore, spotify: Mutex>>, - artwork_cache: Mutex>>, + artwork_cache: Mutex>>, playback: Arc, media_keys: media_keys::MediaKeys, sync_orchestrator: SyncOrchestrator, @@ -158,6 +158,8 @@ struct ExportSettings { column_widths: BTreeMap, hidden_columns: Vec, #[serde(default)] + playlist_hidden_columns: BTreeMap>, + #[serde(default)] sort_column: Option, #[serde(default)] sort_desc: bool, @@ -177,6 +179,7 @@ impl ExportSettings { column_order: settings.column_order.clone(), column_widths: settings.column_widths.clone(), hidden_columns: settings.hidden_columns.clone(), + playlist_hidden_columns: settings.playlist_hidden_columns.clone(), sort_column: settings.sort_column.clone(), sort_desc: settings.sort_desc, shuffle: settings.shuffle, @@ -193,6 +196,7 @@ impl ExportSettings { settings.column_order = self.column_order; settings.column_widths = self.column_widths; settings.hidden_columns = self.hidden_columns; + settings.playlist_hidden_columns = self.playlist_hidden_columns; settings.sort_column = self.sort_column; settings.sort_desc = self.sort_desc; settings.shuffle = self.shuffle; @@ -437,8 +441,19 @@ struct PlaylistTrackView { name: String, art: String, alb: String, + cat: String, + disc_no: Option, + track_no: Option, duration_secs: u64, enabled: bool, + play_count: u32, + last_played_at: Option, + added_at: Option, + release_date: Option, + kind: Option, + bitrate_kbps: Option, + overridden: bool, + is_local: bool, rating: Option, } @@ -805,18 +820,20 @@ fn track_id(uri: &str) -> Option<&str> { async fn resolve_track_artwork( client: Option<&SpotifyClient>, - cache: &Mutex>>, + cache: &Mutex>>, uri: &str, + min_width: u32, ) -> Option { let local = uri.starts_with("file:"); let id = (!local).then(|| track_id(uri)).flatten(); if !local && id.is_none() { return None; } + let cache_key = (uri.into(), min_width); if let Some(cached) = cache .lock() .expect("artwork cache mutex poisoned") - .get(uri) + .get(&cache_key) .cloned() { return cached; @@ -842,13 +859,13 @@ async fn resolve_track_artwork( .await .ok() .and_then(|track| track.album) - .and_then(|album| image_url(&album.images)) + .and_then(|album| image_url_at_least(&album.images, min_width)) }; let mut cache = cache.lock().expect("artwork cache mutex poisoned"); if cache.len() >= 512 { cache.clear(); } - cache.insert(uri.into(), artwork.clone()); + cache.insert(cache_key, artwork.clone()); artwork } @@ -859,6 +876,7 @@ pub(crate) async fn publish_media_artwork(app: tauri::AppHandle, event: PlayerSt provider.as_deref(), &state.artwork_cache, event.uri.as_deref().unwrap_or_default(), + 300, ) .await else { @@ -1143,12 +1161,7 @@ async fn sync_playlists(app: &tauri::AppHandle, client: &SpotifyProvider) -> Res .lock() .expect("playlist mutex poisoned") .clone(); - let library = state - .library - .lock() - .expect("library mutex poisoned") - .clone(); - let synced = match playlists::sync(client, ¤t, &library).await { + let synced = match playlists::sync(client, ¤t).await { Ok(synced) => synced, Err(error) => { let tokens = state.token_store.load().ok().flatten(); @@ -2057,6 +2070,7 @@ pub fn run() { spotify_commands::add_spotify_album, spotify_commands::remove_spotify_album, spotify_commands::add_spotify_track, + spotify_commands::add_spotify_tracks, spotify_commands::remove_spotify_track, playlist_commands::playlists_list, playlist_commands::open_spotify_playlist, @@ -2472,7 +2486,8 @@ mod tests { owner: None, track_count: 2, tracks: vec!["one".into(), "two".into()], - non_library_tracks: vec![], + track_metadata_version: playlists::TRACK_METADATA_VERSION, + spotify_tracks: vec![], }], } } @@ -2589,25 +2604,24 @@ mod tests { } #[tokio::test] - async fn track_artwork_resolves_smallest_usable_image_and_caches() { + async fn track_artwork_resolves_requested_sizes_and_caches_each() { + let track = serde_json::json!({ + "uri": "spotify:track:track", + "name": "Track", + "album": { + "id": "album", + "uri": "spotify:album:album", + "name": "Album", + "images": [ + {"url": "large", "width": 300}, + {"url": "small", "width": 64}, + {"url": "tiny", "width": 63} + ] + } + }); let client = playlist_client([ - Response::json( - 200, - serde_json::json!({ - "uri": "spotify:track:track", - "name": "Track", - "album": { - "id": "album", - "uri": "spotify:album:album", - "name": "Album", - "images": [ - {"url": "large", "width": 300}, - {"url": "small", "width": 64}, - {"url": "tiny", "width": 63} - ] - } - }), - ), + Response::json(200, track.clone()), + Response::json(200, track), Response::json( 200, serde_json::json!({"uri": "spotify:track:missing", "name": "Missing"}), @@ -2619,28 +2633,32 @@ mod tests { assert_eq!(track_id("spotify:album:album"), None); assert_eq!(track_id("spotify:track:"), None); assert_eq!( - resolve_track_artwork(Some(&client), &cache, "spotify:track:track") + resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 64) .await .as_deref(), Some("small") ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, "spotify:track:track").await, + resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 64).await, Some("small".into()) ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, "spotify:album:album").await, + resolve_track_artwork(Some(&client), &cache, "spotify:track:track", 300).await, + Some("large".into()) + ); + assert_eq!( + resolve_track_artwork(Some(&client), &cache, "spotify:album:album", 64).await, None ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, "spotify:track:missing").await, + resolve_track_artwork(Some(&client), &cache, "spotify:track:missing", 64).await, None ); assert_eq!( - resolve_track_artwork(Some(&client), &cache, "spotify:track:missing").await, + resolve_track_artwork(Some(&client), &cache, "spotify:track:missing", 64).await, None ); - assert_eq!(client.transport().requests().len(), 2); + assert_eq!(client.transport().requests().len(), 3); } #[tokio::test] @@ -2657,6 +2675,7 @@ mod tests { None::<&SpotifyClient>, &cache, &uri, + 64, ) .await .unwrap(); @@ -2675,7 +2694,8 @@ mod tests { resolve_track_artwork( None::<&SpotifyClient>, &cache, - &uri + &uri, + 64 ) .await, Some(artwork) @@ -2687,7 +2707,8 @@ mod tests { resolve_track_artwork( None::<&SpotifyClient>, &cache, - &localfiles::file_uri(&wav) + &localfiles::file_uri(&wav), + 64 ) .await, None @@ -2820,7 +2841,7 @@ mod tests { ); assert_eq!( cache.playlists[0] - .non_library_tracks + .spotify_tracks .iter() .map(|track| track.uri.as_str()) .collect::>(), @@ -3150,7 +3171,8 @@ mod tests { owner: None, track_count: 0, tracks: vec![], - non_library_tracks: vec![], + track_metadata_version: playlists::TRACK_METADATA_VERSION, + spotify_tracks: vec![], }) .to_vec(), }; @@ -3192,6 +3214,10 @@ mod tests { "added".into(), "releaseDate".into(), ], + playlist_hidden_columns: BTreeMap::from([( + "first".into(), + vec!["genre".into(), "plays".into()], + )]), sort_column: Some("plays".into()), sort_desc: true, auto_add_spotify_library: false, @@ -3234,6 +3260,10 @@ mod tests { assert_eq!(restored.column_order, exported.column_order); assert_eq!(restored.column_widths, exported.column_widths); assert_eq!(restored.hidden_columns, exported.hidden_columns); + assert_eq!( + restored.playlist_hidden_columns, + exported.playlist_hidden_columns + ); assert_eq!(restored.sort_column.as_deref(), Some("plays")); assert!(restored.sort_desc); assert!(restored.shuffle); diff --git a/apps/desktop/src-tauri/src/library_commands.rs b/apps/desktop/src-tauri/src/library_commands.rs index 0a7eba3..a0078cc 100644 --- a/apps/desktop/src-tauri/src/library_commands.rs +++ b/apps/desktop/src-tauri/src/library_commands.rs @@ -66,16 +66,21 @@ pub(super) fn click_track_star( } #[tauri::command] -pub(super) fn set_track_enabled( - state: tauri::State<'_, AppState>, +pub(super) async fn set_track_enabled( + app: tauri::AppHandle, id: u64, enabled: bool, ) -> Result<(), String> { + let state = app.state::(); mutate_library(&state, |library| { library .set_track_enabled(TrackId(id), enabled) .map_err(|error| error.to_string()) - }) + })?; + if !enabled { + state.playback.exclude_track(id).await; + } + Ok(()) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/playback/mod.rs b/apps/desktop/src-tauri/src/playback/mod.rs index b25eedd..1787cf3 100644 --- a/apps/desktop/src-tauri/src/playback/mod.rs +++ b/apps/desktop/src-tauri/src/playback/mod.rs @@ -118,6 +118,19 @@ impl Snapshot { fn has_next(&self) -> bool { self.index + 1 < self.len() } + + fn exclude(&mut self, id: u64) -> bool { + let current = self.order[self.index]; + let previous_len = self.order.len(); + self.order + .retain(|&index| index == current || self.tracks[index].id != id); + self.index = self + .order + .iter() + .position(|&index| index == current) + .expect("current track is retained"); + self.order.len() != previous_len + } } #[derive(Clone, Debug, PartialEq, Eq, Serialize)] @@ -421,6 +434,20 @@ impl Playback { .set_play_threshold_percent(percent); } + pub async fn exclude_track(&self, id: u64) { + let mut state = self.state.lock().await; + if !state + .reducer + .snapshot_mut() + .is_some_and(|snapshot| snapshot.exclude(id)) + { + return; + } + let snapshot = state.reducer.snapshot().cloned(); + let repeat = state.reducer.repeat().to_owned(); + state.backend.set_shuffle_snapshot(snapshot, &repeat).await; + } + pub fn listen( self: &Arc, app: tauri::AppHandle, @@ -1110,6 +1137,27 @@ mod tests { assert_eq!(ids, [1, 2, 3, 4, 5]); } + #[test] + fn excluding_a_track_keeps_the_current_instance_and_removes_the_rest() { + let mut tracks = file_tracks(3); + tracks.push(tracks[1].clone()); + let mut snapshot = Snapshot::new(tracks, 1); + + assert!(snapshot.exclude(2)); + assert_eq!(snapshot.current().id, 2); + assert_eq!( + snapshot + .active_tracks() + .into_iter() + .map(|track| track.id) + .collect::>(), + [1, 2, 3] + ); + assert!(snapshot.exclude(1)); + assert_eq!(snapshot.current().id, 2); + assert_eq!(snapshot.index, 0); + } + #[tokio::test] async fn toggling_shuffle_mid_song_does_not_load() { let playback = Playback::default(); diff --git a/apps/desktop/src-tauri/src/playlist_commands.rs b/apps/desktop/src-tauri/src/playlist_commands.rs index 2efda30..b8468d7 100644 --- a/apps/desktop/src-tauri/src/playlist_commands.rs +++ b/apps/desktop/src-tauri/src/playlist_commands.rs @@ -68,16 +68,28 @@ pub(super) fn playlist_tracks( id: String, ) -> Result, String> { let playlists = state.playlists.lock().expect("playlist mutex poisoned"); + let library = state.library.lock().expect("library mutex poisoned"); + playlist_track_views(&playlists, &library, &id) +} + +fn playlist_track_views( + playlists: &playlists::PlaylistCache, + library: &Library, + id: &str, +) -> Result, String> { let playlist = playlists .playlists .iter() .find(|playlist| playlist.id == id) .ok_or_else(|| format!("Unknown playlist {id}"))?; - let library = state.library.lock().expect("library mutex poisoned"); Ok(playlist .tracks .iter() .map(|uri| { + let cached = playlist + .spotify_tracks + .iter() + .find(|track| &track.uri == uri); if let Some(track) = library.tracks().iter().find(|track| &track.uri == uri) { PlaylistTrackView { id: Some(track.id.0), @@ -85,23 +97,51 @@ pub(super) fn playlist_tracks( name: track.name.clone(), art: track.art.clone(), alb: track.alb.clone(), + cat: track.cat.clone(), + disc_no: track + .disc_no + .or_else(|| cached.and_then(|track| track.disc_no)), + track_no: track + .track_no + .or_else(|| cached.and_then(|track| track.track_no)), duration_secs: track.duration.as_secs(), enabled: track.enabled, + play_count: track.play_count, + last_played_at: track.last_played_at, + added_at: track.added_at, + release_date: track + .release_date + .clone() + .or_else(|| cached.and_then(|track| track.release_date.clone())), + kind: track.kind.clone(), + bitrate_kbps: track.bitrate_kbps, + overridden: track + .orig_cat + .as_ref() + .is_some_and(|original| original != &track.cat), + is_local: false, rating: library.effective_rating(track.id).map(rating_view), } } else { - let cached = playlist - .non_library_tracks - .iter() - .find(|track| &track.uri == uri); PlaylistTrackView { id: None, uri: uri.clone(), name: cached.map(|track| track.name.clone()).unwrap_or_default(), art: cached.map(|track| track.art.clone()).unwrap_or_default(), alb: cached.map(|track| track.alb.clone()).unwrap_or_default(), + cat: String::new(), + disc_no: cached.and_then(|track| track.disc_no), + track_no: cached.and_then(|track| track.track_no), duration_secs: cached.map_or(0, |track| track.duration / 1000), enabled: true, + play_count: 0, + last_played_at: None, + added_at: None, + release_date: cached.and_then(|track| track.release_date.clone()), + kind: None, + bitrate_kbps: None, + overridden: false, + is_local: false, rating: None, } } @@ -109,6 +149,54 @@ pub(super) fn playlist_tracks( .collect()) } +#[cfg(test)] +mod tests { + use std::time::Duration; + + use retune_core::model::NewTrack; + + use super::*; + + #[test] + fn playlist_view_fills_missing_library_fields_from_spotify() { + let mut library = Library::new(); + let id = library.add(NewTrack { + uri: "spotify:track:one".into(), + name: "Song".into(), + duration: Duration::from_secs(180), + ..NewTrack::default() + }); + let playlists = playlists::PlaylistCache { + playlists: vec![playlists::CachedPlaylist { + id: "playlist".into(), + name: "Playlist".into(), + snapshot_id: "snapshot".into(), + owned: true, + owner: None, + track_count: 1, + tracks: vec!["spotify:track:one".into()], + track_metadata_version: playlists::TRACK_METADATA_VERSION, + spotify_tracks: vec![playlists::CachedTrack { + uri: "spotify:track:one".into(), + name: "Song".into(), + art: "Artist".into(), + alb: "Album".into(), + duration: 180_000, + disc_no: Some(2), + track_no: Some(7), + release_date: Some("1999".into()), + }], + }], + }; + + let tracks = playlist_track_views(&playlists, &library, "playlist").unwrap(); + + assert_eq!(tracks[0].id, Some(id.0)); + assert_eq!((tracks[0].disc_no, tracks[0].track_no), (Some(2), Some(7))); + assert_eq!(tracks[0].release_date.as_deref(), Some("1999")); + } +} + #[tauri::command] pub(super) async fn playlist_create( app: tauri::AppHandle, @@ -173,15 +261,9 @@ pub(super) async fn playlist_reorder( .lock() .expect("playlist mutex poisoned") .clone(); - let library = state - .library - .lock() - .expect("library mutex poisoned") - .clone(); let result = playlists::reorder( client.as_ref(), &mut cache, - &library, &id, range_start, insert_before, @@ -221,11 +303,6 @@ pub(super) async fn playlist_remove( .lock() .expect("playlist mutex poisoned") .clone(); - let library = state - .library - .lock() - .expect("library mutex poisoned") - .clone(); - let result = playlists::remove(client.as_ref(), &mut cache, &library, &id, &indices).await; + let result = playlists::remove(client.as_ref(), &mut cache, &id, &indices).await; finish_playlist_mutation(&app, &state, cache, result) } diff --git a/apps/desktop/src-tauri/src/playlists.rs b/apps/desktop/src-tauri/src/playlists.rs index c44d41b..a40acc3 100644 --- a/apps/desktop/src-tauri/src/playlists.rs +++ b/apps/desktop/src-tauri/src/playlists.rs @@ -11,6 +11,7 @@ use url::Url; const PLAYLIST_PAGE_SIZE: u32 = 50; const TRACK_PAGE_SIZE: u32 = 50; +pub(crate) const TRACK_METADATA_VERSION: u8 = 1; pub const RECONNECT_HINT: &str = "Reconnect to Spotify to enable playlists (File → Account)."; pub const STALE_PLAYLIST: &str = "Playlist changed elsewhere — reloaded."; @@ -31,7 +32,9 @@ pub struct CachedPlaylist { pub track_count: usize, pub tracks: Vec, #[serde(default)] - pub non_library_tracks: Vec, + pub track_metadata_version: u8, + #[serde(default, alias = "non_library_tracks")] + pub spotify_tracks: Vec, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -41,12 +44,17 @@ pub struct CachedTrack { pub art: String, pub alb: String, pub duration: u64, + #[serde(default)] + pub disc_no: Option, + #[serde(default)] + pub track_no: Option, + #[serde(default)] + pub release_date: Option, } pub async fn sync( client: &SpotifyClient, current: &PlaylistCache, - library: &Library, ) -> retune_spotify::Result { let user_id = client.me().await?.id; let mut refreshed = HashMap::new(); @@ -72,7 +80,8 @@ pub async fn sync( owner: None, track_count: 0, tracks: vec![], - non_library_tracks: vec![], + track_metadata_version: TRACK_METADATA_VERSION, + spotify_tracks: vec![], }); cached.name = summary.name; cached.snapshot_id = summary.snapshot_id; @@ -85,6 +94,7 @@ pub async fn sync( summary.id.clone(), if cached.is_some_and(|playlist| { playlist.snapshot_id == summary.snapshot_id + && playlist.track_metadata_version == TRACK_METADATA_VERSION && (playlist.track_count == 0 || !playlist.tracks.is_empty()) }) { let mut cached = cached.expect("checked above").clone(); @@ -92,15 +102,9 @@ pub async fn sync( cached.owned = summary.owned; cached.owner = Some(summary.owner.display_name.unwrap_or(summary.owner.id)); cached.track_count = summary.tracks.total as usize; - cached.non_library_tracks.retain(|track| { - !library - .tracks() - .iter() - .any(|library_track| library_track.uri == track.uri) - }); cached } else { - fetch(client, summary, library).await? + fetch(client, summary).await? }, ); } @@ -169,7 +173,8 @@ pub async fn create( owner: None, track_count: 0, tracks: vec![], - non_library_tracks: vec![], + track_metadata_version: TRACK_METADATA_VERSION, + spotify_tracks: vec![], }); Ok(()) } @@ -198,12 +203,13 @@ pub async fn add( return Err(PlaylistAddError::ReadOnly); } let snapshot_id = client.add_playlist_tracks(id, &uris, None).await?; - let (track_count, tracks, non_library_tracks) = fetch_tracks(client, id, library).await?; + let (track_count, tracks, spotify_tracks) = fetch_tracks(client, id).await?; let playlist = &mut cache.playlists[index]; playlist.snapshot_id = snapshot_id; playlist.track_count = track_count; playlist.tracks = tracks; - playlist.non_library_tracks = non_library_tracks; + playlist.track_metadata_version = TRACK_METADATA_VERSION; + playlist.spotify_tracks = spotify_tracks; Ok(()) } @@ -261,7 +267,6 @@ impl From for PlaylistAddError { pub async fn reorder( client: &SpotifyClient, cache: &mut PlaylistCache, - library: &Library, id: &str, range_start: u32, insert_before: u32, @@ -293,7 +298,7 @@ pub async fn reorder( let result = client .reorder_playlist_tracks(id, range_start, insert_before, range_length, &snapshot_id) .await; - let new_snapshot = recover_stale_snapshot(client, cache, library, id, result).await?; + let new_snapshot = recover_stale_snapshot(client, cache, id, result).await?; let playlist = cache .playlists .iter_mut() @@ -325,7 +330,6 @@ impl From for PlaylistMutationError { async fn recover_stale_snapshot( client: &SpotifyClient, cache: &mut PlaylistCache, - library: &Library, id: &str, result: retune_spotify::Result, ) -> Result { @@ -334,7 +338,7 @@ async fn recover_stale_snapshot( Err(Error::Http { status: 400 | 409, .. }) => { - refresh_one(client, cache, library, id) + refresh_one(client, cache, id) .await .map_err(PlaylistMutationError::Spotify)?; Err(PlaylistMutationError::Reloaded) @@ -346,7 +350,6 @@ async fn recover_stale_snapshot( pub async fn remove( client: &SpotifyClient, cache: &mut PlaylistCache, - library: &Library, id: &str, indices: &[u32], ) -> Result<(), PlaylistMutationError> { @@ -403,8 +406,8 @@ pub async fn remove( .collect::>(); let snapshot_id = playlist.snapshot_id.clone(); let result = client.remove_playlist_tracks(id, &uris, &snapshot_id).await; - let new_snapshot = recover_stale_snapshot(client, cache, library, id, result).await?; - let (track_count, tracks, non_library_tracks) = fetch_tracks(client, id, library).await?; + let new_snapshot = recover_stale_snapshot(client, cache, id, result).await?; + let (track_count, tracks, spotify_tracks) = fetch_tracks(client, id).await?; let playlist = cache .playlists .iter_mut() @@ -413,7 +416,8 @@ pub async fn remove( playlist.snapshot_id = new_snapshot; playlist.track_count = track_count; playlist.tracks = tracks; - playlist.non_library_tracks = non_library_tracks; + playlist.track_metadata_version = TRACK_METADATA_VERSION; + playlist.spotify_tracks = spotify_tracks; Ok(()) } @@ -430,7 +434,6 @@ pub fn map_error(error: Error, tokens: Option<&Tokens>) -> String { async fn refresh_one( client: &SpotifyClient, cache: &mut PlaylistCache, - library: &Library, id: &str, ) -> retune_spotify::Result<()> { let user_id = client.me().await?.id; @@ -441,7 +444,7 @@ async fn refresh_one( .await?; let count = (page.items.len() + page.skipped) as u32; if let Some(summary) = page.items.into_iter().find(|playlist| playlist.id == id) { - let refreshed = fetch(client, summary, library).await?; + let refreshed = fetch(client, summary).await?; if let Some(existing) = cache .playlists .iter_mut() @@ -463,10 +466,8 @@ async fn refresh_one( async fn fetch( client: &SpotifyClient, summary: Playlist, - library: &Library, ) -> retune_spotify::Result { - let (track_count, tracks, non_library_tracks) = - fetch_tracks(client, &summary.id, library).await?; + let (track_count, tracks, spotify_tracks) = fetch_tracks(client, &summary.id).await?; Ok(CachedPlaylist { id: summary.id, name: summary.name, @@ -475,22 +476,17 @@ async fn fetch( owner: Some(summary.owner.display_name.unwrap_or(summary.owner.id)), track_count, tracks, - non_library_tracks, + track_metadata_version: TRACK_METADATA_VERSION, + spotify_tracks, }) } async fn fetch_tracks( client: &SpotifyClient, id: &str, - library: &Library, ) -> retune_spotify::Result<(usize, Vec, Vec)> { - let library_uris = library - .tracks() - .iter() - .map(|track| track.uri.as_str()) - .collect::>(); let mut tracks = vec![]; - let mut non_library_tracks = vec![]; + let mut spotify_tracks = vec![]; let mut track_count = None; let mut offset = 0; loop { @@ -498,9 +494,7 @@ async fn fetch_tracks( track_count.get_or_insert(page.total as usize); let count = (page.items.len() + page.skipped) as u32; for track in page.items { - if !library_uris.contains(track.uri.as_str()) { - non_library_tracks.push(cached_track(&track)); - } + spotify_tracks.push(cached_track(&track)); tracks.push(track.uri); } offset += count; @@ -508,7 +502,7 @@ async fn fetch_tracks( break; } } - Ok((track_count.unwrap_or_default(), tracks, non_library_tracks)) + Ok((track_count.unwrap_or_default(), tracks, spotify_tracks)) } fn cached_track(track: &Track) -> CachedTrack { @@ -526,6 +520,12 @@ fn cached_track(track: &Track) -> CachedTrack { .map(|album| album.name.clone()) .unwrap_or_default(), duration: track.duration_ms.unwrap_or_default(), + disc_no: track.disc_number, + track_no: track.track_number, + release_date: track + .album + .as_ref() + .and_then(|album| album.release_date.clone()), } } @@ -579,7 +579,8 @@ mod tests { owner: None, track_count: 2, tracks: vec!["spotify:track:1".into(), "spotify:track:2".into()], - non_library_tracks: vec![], + track_metadata_version: TRACK_METADATA_VERSION, + spotify_tracks: vec![], }], } } @@ -595,6 +596,28 @@ mod tests { cache } + #[test] + fn legacy_track_cache_deserializes_for_refresh() { + let playlist: CachedPlaylist = serde_json::from_value(serde_json::json!({ + "id": "playlist", + "name": "Playlist", + "snapshot_id": "snapshot", + "owned": true, + "tracks": ["spotify:track:one"], + "non_library_tracks": [{ + "uri": "spotify:track:one", + "name": "Song", + "art": "Artist", + "alb": "Album", + "duration": 180000 + }] + })) + .unwrap(); + + assert_eq!(playlist.track_metadata_version, 0); + assert_eq!(playlist.spotify_tracks[0].track_no, None); + } + #[test] fn playlist_reorder_validates_atomically_and_persists() { let mut cache = ordered(); @@ -715,7 +738,7 @@ mod tests { } #[tokio::test] - async fn changed_collaborative_playlist_fetches_tracks() { + async fn legacy_playlist_cache_refreshes_spotify_metadata_for_all_tracks() { let client = fake_client( [ Response::json(200, serde_json::json!({"id": "user"})), @@ -723,7 +746,7 @@ mod tests { 200, serde_json::json!({ "items": [{ - "id": "playlist", "name": "Collaborative", "snapshot_id": "changed", "collaborative": true, + "id": "playlist", "name": "Collaborative", "snapshot_id": "same", "collaborative": true, "owner": {"id": "other", "display_name": "Other"}, "items": {"total": 2} }], @@ -736,13 +759,15 @@ mod tests { "items": [ {"is_local": false, "item": { "uri": "spotify:track:1", "name": "Library track", - "artists": [], "album": null, "duration_ms": 1000 + "artists": [], + "album": {"id": "one", "uri": "spotify:album:one", "name": "Library Album", "release_date": "1999", "images": []}, + "duration_ms": 1000, "disc_number": 2, "track_number": 7 }}, {"is_local": false, "item": { "uri": "spotify:track:2", "name": "Followed track", "artists": [{"id": "artist", "name": "Artist"}], - "album": {"id": "album", "uri": "spotify:album:album", "name": "Album", "images": []}, - "duration_ms": 2345 + "album": {"id": "album", "uri": "spotify:album:album", "name": "Album", "release_date": "2024-03-01", "images": []}, + "duration_ms": 2345, "disc_number": 1, "track_number": 3 }} ], "next": null, "total": 2 @@ -753,24 +778,38 @@ mod tests { ); let mut current = cached(); current.playlists[0].owned = false; - let mut library = Library::new(); - library.add(local_track("spotify:track:1", "Library track")); + current.playlists[0].track_metadata_version = 0; - let synced = sync(&client, ¤t, &library).await.unwrap(); + let synced = sync(&client, ¤t).await.unwrap(); let playlist = &synced.playlists[0]; assert_eq!(playlist.track_count, 2); assert!(!playlist.owned); assert_eq!(playlist.tracks, ["spotify:track:1", "spotify:track:2"]); assert_eq!( - playlist.non_library_tracks, - [CachedTrack { - uri: "spotify:track:2".into(), - name: "Followed track".into(), - art: "Artist".into(), - alb: "Album".into(), - duration: 2345, - }] + playlist.spotify_tracks, + [ + CachedTrack { + uri: "spotify:track:1".into(), + name: "Library track".into(), + art: String::new(), + alb: "Library Album".into(), + duration: 1000, + disc_no: Some(2), + track_no: Some(7), + release_date: Some("1999".into()), + }, + CachedTrack { + uri: "spotify:track:2".into(), + name: "Followed track".into(), + art: "Artist".into(), + alb: "Album".into(), + duration: 2345, + disc_no: Some(1), + track_no: Some(3), + release_date: Some("2024-03-01".into()), + } + ] ); let requests = client.transport().requests(); assert_eq!(requests.len(), 3); @@ -802,7 +841,7 @@ mod tests { let mut current = cached(); current.playlists[0].owned = false; - let synced = sync(&client, ¤t, &Library::new()).await.unwrap(); + let synced = sync(&client, ¤t).await.unwrap(); assert_eq!(synced.playlists[0].name, "New name"); assert_eq!(synced.playlists[0].owner.as_deref(), Some("Owner Name")); @@ -837,7 +876,7 @@ mod tests { current.playlists[0].track_count = 0; current.playlists[0].tracks.clear(); - let synced = sync(&client, ¤t, &Library::new()).await.unwrap(); + let synced = sync(&client, ¤t).await.unwrap(); assert!(synced.playlists[0].tracks.is_empty()); let requests = client.transport().requests(); @@ -872,9 +911,9 @@ mod tests { ); let current = PlaylistCache::default(); - let skipped = sync(&client, ¤t, &Library::new()).await.unwrap(); + let skipped = sync(&client, ¤t).await.unwrap(); assert_eq!(skipped.playlists[0].track_count, 3); - let synced_again = sync(&client, &skipped, &Library::new()).await.unwrap(); + let synced_again = sync(&client, &skipped).await.unwrap(); assert!(synced_again.playlists[0].tracks.is_empty()); assert_eq!( client @@ -929,7 +968,7 @@ mod tests { &retune_spotify::auth::SCOPES, ); - let synced = sync(&client, ¤t, &Library::new()).await.unwrap(); + let synced = sync(&client, ¤t).await.unwrap(); assert_eq!( synced @@ -1025,7 +1064,7 @@ mod tests { let client = fake_client(stale_refresh_responses(), &retune_spotify::auth::SCOPES); let mut cache = cached(); - let error = reorder(&client, &mut cache, &Library::new(), "playlist", 0, 2, 1) + let error = reorder(&client, &mut cache, "playlist", 0, 2, 1) .await .unwrap_err(); @@ -1046,7 +1085,7 @@ mod tests { ); let mut cache = cached(); - reorder(&client, &mut cache, &Library::new(), "playlist", 0, 2, 1) + reorder(&client, &mut cache, "playlist", 0, 2, 1) .await .unwrap(); @@ -1077,9 +1116,7 @@ mod tests { ); let mut cache = cached(); - remove(&client, &mut cache, &Library::new(), "playlist", &[0]) - .await - .unwrap(); + remove(&client, &mut cache, "playlist", &[0]).await.unwrap(); assert_eq!(cache.playlists[0].snapshot_id, "removed"); assert_eq!(cache.playlists[0].track_count, 1); @@ -1097,22 +1134,22 @@ mod tests { let mut cache = cached(); assert!(matches!( - remove(&client, &mut cache, &Library::new(), "playlist", &[]).await, + remove(&client, &mut cache, "playlist", &[]).await, Err(PlaylistMutationError::Other(_)) )); assert!(matches!( - remove(&client, &mut cache, &Library::new(), "playlist", &[2]).await, + remove(&client, &mut cache, "playlist", &[2]).await, Err(PlaylistMutationError::Other(_)) )); cache.playlists[0].owned = false; assert!(matches!( - remove(&client, &mut cache, &Library::new(), "playlist", &[0]).await, + remove(&client, &mut cache, "playlist", &[0]).await, Err(PlaylistMutationError::Other(_)) )); cache.playlists[0].owned = true; cache.playlists[0].track_count = 3; assert!(matches!( - remove(&client, &mut cache, &Library::new(), "playlist", &[0]).await, + remove(&client, &mut cache, "playlist", &[0]).await, Err(PlaylistMutationError::Other(_)) )); assert!(client.transport().requests().is_empty()); @@ -1124,7 +1161,7 @@ mod tests { let mut cache = cached(); cache.playlists[0].tracks[1] = cache.playlists[0].tracks[0].clone(); - let error = remove(&client, &mut cache, &Library::new(), "playlist", &[0]) + let error = remove(&client, &mut cache, "playlist", &[0]) .await .unwrap_err(); @@ -1137,7 +1174,7 @@ mod tests { let client = fake_client(stale_refresh_responses(), &retune_spotify::auth::SCOPES); let mut cache = cached(); - let error = remove(&client, &mut cache, &Library::new(), "playlist", &[0]) + let error = remove(&client, &mut cache, "playlist", &[0]) .await .unwrap_err(); @@ -1159,7 +1196,7 @@ mod tests { cache.playlists[0].track_count = 4; cache.playlists[0].tracks = ["1", "2", "3", "4"].map(String::from).to_vec(); - reorder(&client, &mut cache, &Library::new(), "playlist", 1, 4, 2) + reorder(&client, &mut cache, "playlist", 1, 4, 2) .await .unwrap(); diff --git a/apps/desktop/src-tauri/src/provider.rs b/apps/desktop/src-tauri/src/provider.rs index 01fd75d..c8eff32 100644 --- a/apps/desktop/src-tauri/src/provider.rs +++ b/apps/desktop/src-tauri/src/provider.rs @@ -324,11 +324,15 @@ pub(crate) fn artist_descriptor(artist: &Artist) -> String { } pub(crate) fn image_url(images: &[Image]) -> Option { + image_url_at_least(images, 64) +} + +pub(crate) fn image_url_at_least(images: &[Image], min_width: u32) -> Option { images .iter() - .filter(|image| image.width.is_some_and(|width| width >= 64)) + .filter(|image| image.width.is_some_and(|width| width >= min_width)) .min_by_key(|image| image.width) - .or_else(|| images.last()) + .or_else(|| images.iter().max_by_key(|image| image.width)) .map(|image| image.url.clone()) } @@ -1822,6 +1826,7 @@ mod tests { }, ]; assert_eq!(image_url(&images).as_deref(), Some("small")); + assert_eq!(image_url_at_least(&images, 300).as_deref(), Some("large")); assert_eq!( image_url(&[Image { url: "fallback".into(), diff --git a/apps/desktop/src-tauri/src/spotify_commands.rs b/apps/desktop/src-tauri/src/spotify_commands.rs index c0bb3d6..8d60f1a 100644 --- a/apps/desktop/src-tauri/src/spotify_commands.rs +++ b/apps/desktop/src-tauri/src/spotify_commands.rs @@ -94,13 +94,20 @@ pub(super) async fn disconnect_spotify(app: tauri::AppHandle) -> Result<(), Stri .map_err(|error| error.to_string()) } -#[tauri::command] +#[tauri::command(rename_all = "camelCase")] pub(super) async fn track_artwork( state: tauri::State<'_, AppState>, uri: String, + min_width: Option, ) -> Result, String> { let provider = provider_from(&state).ok(); - Ok(resolve_track_artwork(provider.as_deref(), &state.artwork_cache, &uri).await) + Ok(resolve_track_artwork( + provider.as_deref(), + &state.artwork_cache, + &uri, + min_width.unwrap_or(64).clamp(1, 2048), + ) + .await) } #[tauri::command] @@ -276,28 +283,69 @@ pub(super) async fn remove_spotify_album(app: tauri::AppHandle, uri: String) -> #[tauri::command] pub(super) async fn add_spotify_track(app: tauri::AppHandle, uri: String) -> Result<(), String> { - let id = track_id(&uri).ok_or_else(|| "Expected a Spotify track URI".to_string())?; + add_spotify_tracks(app, vec![uri]).await.map(|_| ()) +} + +#[tauri::command] +pub(super) async fn add_spotify_tracks( + app: tauri::AppHandle, + uris: Vec, +) -> Result, String> { let state = app.state::(); + let mut seen = HashSet::new(); + let mut uris = uris + .into_iter() + .filter(|uri| seen.insert(uri.clone())) + .collect::>(); + if uris.iter().any(|uri| track_id(uri).is_none()) { + return Err("Expected Spotify track URIs".into()); + } + let mut ids = vec![]; + { + let library = state.library.lock().expect("library mutex poisoned"); + uris.retain( + |uri| match library.tracks().iter().find(|track| &track.uri == uri) { + Some(track) => { + ids.push(track.id.0); + false + } + None => true, + }, + ); + } + if uris.is_empty() { + return Ok(ids); + } let provider = provider_from(&state)?; - let track = provider - .track(id) - .await - .map_err(|error| error.to_string())?; - let artist = match track.artists.first() { - Some(artist) => provider.artist(&artist.id).await.ok(), - None => None, - }; - let normalized = retune_spotify::normalize::track(&track, artist.as_ref(), None); + let mut tracks = Vec::with_capacity(uris.len()); + for uri in &uris { + let track = provider + .track(track_id(uri).expect("validated above")) + .await + .map_err(|error| error.to_string())?; + let artist = match track.artists.first() { + Some(artist) => provider.artist(&artist.id).await.ok(), + None => None, + }; + tracks.push(retune_spotify::normalize::track( + &track, + artist.as_ref(), + None, + )); + } provider - .save_to_library(std::slice::from_ref(&uri)) + .save_to_library(&uris) .await .map_err(|error| error.to_string())?; - mutate_library(&state, |library| { - library.add(normalized); - Ok(()) - })?; + ids.extend(mutate_library(&state, |library| { + Ok(tracks + .into_iter() + .map(|track| library.add(track).0) + .collect::>()) + })?); app.emit("library-changed", ()) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + Ok(ids) } #[tauri::command] diff --git a/apps/desktop/src-tauri/src/store.rs b/apps/desktop/src-tauri/src/store.rs index 311260c..edb9949 100644 --- a/apps/desktop/src-tauri/src/store.rs +++ b/apps/desktop/src-tauri/src/store.rs @@ -90,6 +90,8 @@ pub struct Settings { #[serde(default)] pub hidden_columns: Vec, #[serde(default)] + pub playlist_hidden_columns: BTreeMap>, + #[serde(default)] pub sort_column: Option, #[serde(default)] pub sort_desc: bool, @@ -181,6 +183,7 @@ impl Default for Settings { column_order: Self::COLUMNS.map(String::from).to_vec(), column_widths: BTreeMap::new(), hidden_columns: Self::OPTIONAL_COLUMNS.map(String::from).to_vec(), + playlist_hidden_columns: BTreeMap::new(), sort_column: None, sort_desc: false, auto_add_spotify_library: true, @@ -228,8 +231,6 @@ impl Settings { pub(crate) fn normalize(&mut self) { self.column_order .retain(|column| Self::COLUMNS.contains(&column.as_str())); - self.hidden_columns - .retain(|column| Self::COLUMNS.contains(&column.as_str())); self.column_widths .retain(|column, _| Self::COLUMNS.contains(&column.as_str())); if self @@ -247,17 +248,16 @@ impl Settings { self.column_order.push(column.into()); if Self::OPTIONAL_COLUMNS.contains(&column) { self.hidden_columns.push(column.into()); + for hidden_columns in self.playlist_hidden_columns.values_mut() { + hidden_columns.push(column.into()); + } } } } - self.hidden_columns.retain(|column| column != "name"); - self.hidden_columns.sort_by_key(|column| { - Self::COLUMNS - .iter() - .position(|candidate| candidate == column) - .unwrap_or(usize::MAX) - }); - self.hidden_columns.dedup(); + Self::normalize_hidden_columns(&mut self.hidden_columns); + for hidden_columns in self.playlist_hidden_columns.values_mut() { + Self::normalize_hidden_columns(hidden_columns); + } if !matches!(self.play_threshold_percent, 50 | 75 | 90 | 100) { self.play_threshold_percent = default_play_threshold_percent(); } @@ -282,16 +282,12 @@ impl Settings { "settings columnOrder must contain each track column exactly once", )); } - if self.hidden_columns.iter().any(|column| { - column == "name" - || !Self::COLUMNS.contains(&column.as_str()) - || self - .hidden_columns - .iter() - .filter(|item| *item == column) - .count() - != 1 - }) { + if !Self::hidden_columns_valid(&self.hidden_columns) + || self + .playlist_hidden_columns + .values() + .any(|columns| !Self::hidden_columns_valid(columns)) + { return Err(StoreError::InvalidSettings( "settings hiddenColumns must be unique track columns other than name", )); @@ -336,6 +332,25 @@ impl Settings { } Ok(()) } + + fn normalize_hidden_columns(columns: &mut Vec) { + columns.retain(|column| column != "name" && Self::COLUMNS.contains(&column.as_str())); + columns.sort_by_key(|column| { + Self::COLUMNS + .iter() + .position(|candidate| candidate == column) + .unwrap_or(usize::MAX) + }); + columns.dedup(); + } + + fn hidden_columns_valid(columns: &[String]) -> bool { + columns.iter().all(|column| { + column != "name" + && Self::COLUMNS.contains(&column.as_str()) + && columns.iter().filter(|item| *item == column).count() == 1 + }) + } } pub struct FsSettingsStore { @@ -625,6 +640,10 @@ mod tests { "added".into(), "releaseDate".into(), ], + playlist_hidden_columns: BTreeMap::from([( + "road-trip".into(), + vec!["genre".into(), "plays".into()], + )]), sort_column: Some("artist".into()), sort_desc: true, auto_add_spotify_library: true, @@ -811,7 +830,8 @@ mod tests { owner: Some("Owner Name".into()), track_count: 0, tracks: vec![], - non_library_tracks: vec![], + track_metadata_version: crate::playlists::TRACK_METADATA_VERSION, + spotify_tracks: vec![], }], }; @@ -996,6 +1016,7 @@ mod tests { assert_eq!(settings.sort_column, None); assert!(!settings.sort_desc); + assert!(settings.playlist_hidden_columns.is_empty()); } #[test] @@ -1085,6 +1106,16 @@ mod tests { assert!(settings.validate().is_err()); settings.hidden_columns = vec!["unknown".into()]; assert!(settings.validate().is_err()); + + settings.hidden_columns = vec![]; + settings + .playlist_hidden_columns + .insert("playlist".into(), vec!["genre".into()]); + assert!(settings.validate().is_ok()); + settings + .playlist_hidden_columns + .insert("playlist".into(), vec!["name".into()]); + assert!(settings.validate().is_err()); } #[test] diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 5818c8b..30eb50a 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -27,7 +27,7 @@ button { color: inherit; } .native-drop-overlay { position: fixed; z-index: 100; inset: 8px; pointer-events: none; display: grid; place-content: center; gap: 5px; border: 3px solid var(--accent); border-radius: 8px; color: var(--text); background: color-mix(in srgb, var(--accent) 16%, var(--list)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--list) 75%, transparent); text-align: center; } .native-drop-overlay strong { font-size: 16px; } .native-drop-overlay span { color: var(--dim); } -.track-list, .browser-pane, .sidebar, .album-rating-strip, .lcd { cursor: default; user-select: none; -webkit-user-select: none; } +.track-list, .playlist-view, .browser-pane, .sidebar, .album-rating-strip, .lcd { cursor: default; user-select: none; -webkit-user-select: none; } .transport { height: 52px; display: grid; grid-template-columns: 200px minmax(260px, 1fr) 268px; align-items: center; gap: 16px; padding: 0 16px; border-bottom: 1px solid var(--border); background: linear-gradient(var(--head1), var(--head2)); } .transport button { border: 0; background: transparent; cursor: default; padding: 2px 4px; } .transport-controls { display: flex; flex-direction: column; align-items: center; gap: 5px; padding-right: 8px; } @@ -43,7 +43,10 @@ button { color: inherit; } .volume-control input::-moz-range-progress { height: 5px; border-radius: 3px; background: var(--dim); } .volume-control input::-moz-range-thumb { width: 13px; height: 13px; border: 1px solid var(--volume-knob-border); border-radius: 50%; background: linear-gradient(#fff, #e4e4e4); box-shadow: 0 1px 2px rgb(0 0 0 / .28); } .lcd { box-sizing: border-box; height: 40px; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: linear-gradient(var(--lcd1), var(--lcd2)); box-shadow: inset 0 1px 2px rgb(0 0 0 / .12); } -.lcd-artwork { box-sizing: border-box; width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--lcd-art-border); border-radius: 4px; color: var(--lcd-art-glyph); background: repeating-linear-gradient(45deg, var(--lcd-art1), var(--lcd-art1) 6px, var(--lcd-art2) 6px, var(--lcd-art2) 12px); font-size: 16px; } +.lcd.returnable { cursor: pointer; } +.lcd.returnable:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.lcd-artwork { box-sizing: border-box; width: 32px; height: 32px; flex: 0 0 auto; display: grid; place-items: center; padding: 0; overflow: hidden; border: 1px solid var(--lcd-art-border); border-radius: 4px; color: var(--lcd-art-glyph); background: repeating-linear-gradient(45deg, var(--lcd-art1), var(--lcd-art1) 6px, var(--lcd-art2) 6px, var(--lcd-art2) 12px); font-size: 16px; cursor: pointer; } +.lcd-artwork:disabled { cursor: default; } .lcd-artwork img { width: 100%; height: 100%; object-fit: cover; } .lcd-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 1px; } .lcd-line { height: 13px; overflow: hidden; text-align: left; white-space: nowrap; line-height: 13px; } @@ -87,6 +90,11 @@ button { color: inherit; } .playlist-row.insert-after { box-shadow: inset 0 -2px var(--accent); } .playlist-new-row { padding: 1px 10px 3px; } .playlist-new-row input { width: 100%; height: 20px; box-sizing: border-box; border: 1px solid var(--accent); border-radius: 2px; color: var(--text); background: var(--list); font: inherit; outline: 0; } +.sidebar-artwork { position: relative; width: 100%; flex: 0 0 auto; aspect-ratio: 1; overflow: hidden; border-top: 1px solid var(--border); background: var(--control); } +.sidebar-artwork-open { width: 100%; height: 100%; display: grid; place-items: center; padding: 0; border: 0; color: var(--lcd-art-glyph); background: transparent; font-size: 38px; cursor: pointer; } +.sidebar-artwork-open:disabled { cursor: default; } +.sidebar-artwork-open img { width: 100%; height: 100%; object-fit: contain; } +.sidebar-artwork-close, .artwork-lightbox-close { position: absolute; z-index: 1; top: 6px; right: 6px; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; color: #fff; background: rgb(0 0 0 / .58); font: inherit; font-size: 16px; line-height: 22px; cursor: pointer; } .sidebar-actions { position: relative; z-index: 5; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 10px; border-top: 1px solid var(--border); } .sidebar-actions button { position: relative; width: 27px; height: 22px; display: flex; align-items: center; justify-content: center; padding: 0; border: 1px solid var(--border); border-radius: 5px; color: var(--dim); background: linear-gradient(var(--list), var(--control)); box-shadow: 0 1px 0 rgb(255 255 255 / .5); font: inherit; font-size: 13px; cursor: default; } .sidebar-actions button.active { border-color: var(--gel-border); color: #fff; background: linear-gradient(var(--gel-top), var(--gel-bottom)); } @@ -223,6 +231,10 @@ button { color: inherit; } .startup-notice button { border: 0; color: inherit; background: transparent; font-size: 16px; } .reauth-notice button { padding: 3px 8px; border: 1px solid currentColor; border-radius: 3px; font-size: inherit; } .modal-backdrop { position: fixed; inset: 0; z-index: 10; display: grid; place-items: center; background: rgb(0 0 0 / .38); } +.artwork-lightbox { position: relative; box-sizing: border-box; width: min(72vw, 72vh); aspect-ratio: 1; display: grid; place-items: center; padding: 0; overflow: hidden; border: 0; background: #111; box-shadow: 0 18px 60px rgb(0 0 0 / .55); } +.artwork-lightbox img { width: 100%; height: 100%; object-fit: contain; } +.artwork-placeholder { color: var(--lcd-art-glyph); font-size: 72px; } +.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } .playlist-popover { width: min(360px, calc(100vw - 40px)); overflow: hidden; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--list); box-shadow: 0 12px 40px rgb(0 0 0 / .35); } .playlist-popover > header { padding: 12px 14px 9px; border-bottom: 1px solid var(--border); } .playlist-popover h2 { margin: 0; font-size: 15px; } @@ -246,6 +258,9 @@ button { color: inherit; } .playlist-header button:disabled { opacity: .45; } .playlist-track-header, .playlist-track-row { min-width: 620px; display: grid; grid-template-columns: 35px minmax(160px, 1.5fr) 55px minmax(110px, 1fr) minmax(110px, 1fr); align-items: center; padding: 0 9px; } .playlist-track-header { height: 20px; flex: 0 0 20px; border-bottom: 1px solid var(--border); color: var(--dim); background: linear-gradient(var(--head1), var(--head2)); font-size: 10px; } +.playlist-track-header > button { min-width: 0; padding: 0 3px; overflow: hidden; border: 0; color: inherit; background: transparent; font: inherit; text-align: left; text-overflow: ellipsis; white-space: nowrap; } +.playlist-track-header > button:first-child, .playlist-track-header > button.track-number { text-align: right; } +.playlist-track-header > button.active { color: var(--text); font-weight: 700; } .playlist-track-scroll { min-height: 0; flex: 1; overflow: auto; } .playlist-unavailable { min-height: 0; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; color: var(--dim); text-align: center; } .playlist-unavailable strong { color: var(--text); font-size: 13px; } @@ -260,7 +275,7 @@ button { color: inherit; } .playlist-track-row > :first-child, .playlist-track-row time { color: var(--dim); font-weight: 400; } .playlist-track-row.playing { font-weight: 700; } .playlist-track-row.insert-before, .playlist-end-drop.insert-before { border-top-color: var(--accent); } -.playlist-track-row[draggable="true"], .spotify-row[draggable="true"], .spotify-track-row[draggable="true"] { cursor: grab; } +.spotify-row[draggable="true"], .spotify-track-row[draggable="true"] { cursor: grab; } .playlist-end-drop { height: 12px; border-top: 2px solid transparent; } .get-info { width: min(440px, calc(100vw - 40px)); padding: 16px; border: 1px solid var(--border); border-radius: 7px; color: var(--text); background: var(--list); box-shadow: 0 12px 40px rgb(0 0 0 / .35); outline: 0; } .get-info h2 { margin: 0 0 14px; font-size: 16px; } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0dad9c9..ee0c59a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,14 +3,13 @@ import { listen } from '@tauri-apps/api/event' import { getCurrentWindow } from '@tauri-apps/api/window' import { Fragment, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import './App.css' -import { compareTracks, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, labels, moveToIndex, nextNativeDragActive, normalizeZoom, parseDragRange, playbackQueue, SYNTHETIC_BASE } from './ui.ts' +import { COLUMN_SPECS, compareTracks, contiguousRange, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, insertionIndexAtY, isCurrentTrack, labels, moveToIndex, nextNativeDragActive, normalizeZoom, playbackOriginAction, playbackQueue, playlistRows, SYNTHETIC_BASE, trackColumnHeadings, trackGridColumns } from './ui.ts' import { GetInfo, MultipleItemInformation, Preferences, SetupLibrary } from './dialogViews.tsx' -import { AlbumRatingStrip, BrowserPane, TrackList } from './libraryViews.tsx' +import { AlbumRatingStrip, BrowserPane, TrackCell, TrackList } from './libraryViews.tsx' import { SpotifySearch } from './spotifyViews.tsx' -import type { ActivePane, BrowseView, BrowserPanes, ConnectionState, ImportSummary, InfoDialog, PlaybackTrack, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' -import { ContextMenu } from './viewShared.tsx' +import type { ActivePane, BrowseView, BrowserPanes, ColumnKey, ConnectionState, ImportSummary, InfoDialog, PlaybackOrigin, PlaybackTrack, PlayerState, Playing, PlaylistListView, PlaylistSubject, PlaylistTrack, RepeatMode, Selection, Settings, Source, SpotifyNavEntry, SpotifyResults, Theme, Track, TrackInfo } from './types.ts' +import { CheckboxMenu, ContextMenu, ModalDialog } from './viewShared.tsx' -const PLAYLIST_TRACK_DRAG_TYPE = 'application/x-retune-playlist-track' const LOCAL_PLAYLIST_HINT = "Selection includes local files — Spotify playlists can't contain them." const emptyTracks: Track[] = [] @@ -60,12 +59,12 @@ type Action = | { type: 'scope'; scope: State['scope'] } | { type: 'selectTrack'; id: number } | { type: 'selection'; ids: Set; anchor?: number } - | { type: 'play'; id: number; queue: readonly PlaybackTrack[] } + | { type: 'play'; id: number; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } | { type: 'togglePlay' } | { type: 'step'; id: number } | { type: 'tick'; duration: number; nextId: number } | { type: 'seek'; elapsed: number } - | { type: 'playerState'; player: PlayerState; queue: readonly PlaybackTrack[] } + | { type: 'playerState'; player: PlayerState; queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin } | { type: 'hydrateSettings'; settings: Settings } | { type: 'settings'; settings: Partial } | { type: 'browserPanes'; browserPanes: BrowserPanes } @@ -98,6 +97,7 @@ const defaultSettings: Settings = { columnOrder: ['name', 'artist', 'album', 'disc', 'track', 'time', 'rating', 'genre', 'plays', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'], columnWidths: {}, hiddenColumns: ['disc', 'kind', 'bitrate', 'lastPlayed', 'added', 'releaseDate'], + playlistHiddenColumns: {}, sortColumn: null, sortDesc: false, autoAddSpotifyLibrary: true, @@ -171,7 +171,7 @@ function reducer(state: State, action: Action): State { trackId: action.id, elapsed: 0, isPlaying: true, queue: action.queue, uri: action.queue.find((track) => track.id === action.id)?.uri ?? null, external: false, name: null, art: null, alb: null, durationSecs: null, - shuffle: state.settings.shuffle, simulated: true, + shuffle: state.settings.shuffle, origin: action.origin, simulated: true, }, } case 'togglePlay': @@ -192,7 +192,7 @@ function reducer(state: State, action: Action): State { ? { ...state, playing: null } : { ...state, - playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue }, + playing: { ...action.player, queue: action.player.external ? emptyTracks : action.queue, origin: action.origin }, } case 'seek': return state.playing @@ -256,13 +256,15 @@ function useTauriEvent(event: string, handler: (payload: T) => void function usePlayer(connected: boolean, playing: Playing | null, dispatch: React.Dispatch) { const queue = useRef(emptyTracks) - const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[] } | null>(null) + const origin = useRef(undefined) + const pendingPlay = useRef<{ id: number; tracks: readonly PlaybackTrack[]; origin?: PlaybackOrigin } | null>(null) const playingRef = useRef(playing) const volumeTimer = useRef(undefined) playingRef.current = playing useTauriEvent('player-state', (player) => { - dispatch({ type: 'playerState', player, queue: queue.current }) + if (player.external) origin.current = undefined + dispatch({ type: 'playerState', player, queue: queue.current, origin: origin.current }) }) const run = useCallback((command: string, args?: Record) => { @@ -274,29 +276,31 @@ function usePlayer(connected: boolean, playing: Playing | null, dispatch: React. return !current?.simulated && (connected || current?.uri?.startsWith('file:')) }, [connected]) - const start = useCallback((id: number, tracks: readonly PlaybackTrack[]) => { + const start = useCallback((id: number, tracks: readonly PlaybackTrack[], launchOrigin?: PlaybackOrigin) => { const playable = playbackQueue(tracks, id) const target = playable.find((track) => track.id === id) if (target?.uri.startsWith('spotify:') && !connected) { // Kick off the OAuth flow instead of erroring; the pending play fires // once connection-changed reports connected. - pendingPlay.current = { id, tracks: playable } + pendingPlay.current = { id, tracks: playable, origin: launchOrigin } run('connect_spotify') return } if (target?.uri.startsWith('file:') || target?.uri.startsWith('spotify:')) { queue.current = playable + origin.current = launchOrigin run('play_tracks', { snapshot: playable, startIndex: playable.findIndex((track) => track.id === id) }) return } - dispatch({ type: 'play', id, queue: playable }) + dispatch({ type: 'play', id, queue: playable, origin: launchOrigin }) }, [connected, dispatch, run]) useEffect(() => { if (!connected || !pendingPlay.current) return - const { id, tracks } = pendingPlay.current + const { id, tracks, origin: launchOrigin } = pendingPlay.current pendingPlay.current = null queue.current = tracks + origin.current = launchOrigin run('play_tracks', { snapshot: tracks, startIndex: tracks.findIndex((track) => track.id === id) }) }, [connected, run]) @@ -345,6 +349,7 @@ function App() { const [activePane, setActivePane] = useState('track') const [playlists, setPlaylists] = useState() const [playlistSubject, setPlaylistSubject] = useState() + const [artworkOpen, setArtworkOpen] = useState(false) const search = useRef(null) const preferenceZoom = useRef(defaultSettings.zoom) const skipSettingsSave = useRef(false) @@ -678,6 +683,16 @@ function App() { }, [playlistSubject, state.info, state.preferences, state.setup, state.settings.zoom]) const selectedPlaylist = playlists?.find((playlist) => playlist.id === state.selectedPlaylist) + const playlistHiddenColumns = selectedPlaylist + ? state.settings.playlistHiddenColumns[selectedPlaylist.id] ?? defaultSettings.hiddenColumns + : defaultSettings.hiddenColumns + const showPlayingOrigin = () => { + const origin = state.playing?.origin + if (!origin) return + facetAnchors.current = {} + dispatch({ type: 'scope', scope: 'library' }) + dispatch(playbackOriginAction(origin)) + } return (
@@ -695,6 +710,8 @@ function App() { onNext={() => player.step(1)} onVolume={(volume) => { dispatch({ type: 'settings', settings: { volume } }); player.setVolume(volume) }} onSeek={player.seek} + onOrigin={showPlayingOrigin} + onArtwork={() => setArtworkOpen(true)} />
invoke('set_repeat', { mode: repeat }).then(() => dispatch({ type: 'settings', settings: { repeat } })).catch(fail)} onDrop={(id, subject) => addToPlaylist(id, subject).catch(fail)} onError={(error) => dispatch({ type: 'error', error })} + artwork={artworkOpen && state.playing?.uri ? setArtworkOpen(false)} + /> : undefined} />
{state.connection.needs_reauth &&
Spotify needs to be reconnected to enable playlists.
} @@ -725,7 +747,33 @@ function App() { onClose={() => dispatch({ type: 'scope', scope: 'library' })} onError={(error) => dispatch({ type: 'error', error })} /> :
Connect to Spotify to search artists and albums.
- ) : selectedPlaylist ? invoke('open_spotify_playlist', { id: selectedPlaylist.id, target }).catch(fail)} onError={(error) => dispatch({ type: 'error', error })} /> + ) : selectedPlaylist ? player.start(id, tracks, { kind: 'playlist', id: selectedPlaylist.id })} + onRate={(id, stars) => mutate('click_track_star', { id, stars })} + onOpen={(target) => invoke('open_spotify_playlist', { id: selectedPlaylist.id, target }).catch(fail)} + onPlaylist={setPlaylistSubject} + onInfo={(tracks) => { + if (tracks.length > 1 || tracks[0]?.id === null) dispatch({ type: 'info', info: { kind: 'multiple', tracks } }) + else openInfo(tracks[0]?.id) + }} + onHiddenColumns={(hiddenColumns) => dispatch({ + type: 'settings', + settings: { + playlistHiddenColumns: { + ...state.settings.playlistHiddenColumns, + [selectedPlaylist.id]: hiddenColumns, + }, + }, + })} + onError={(error) => dispatch({ type: 'error', error })} + /> : ( <> @@ -755,6 +803,7 @@ function App() { empty={libraryEmpty} onActivate={() => setActivePane('track')} onSetup={() => dispatch({ type: 'setup', open: true })} + onClearSelection={() => dispatch({ type: 'selection', ids: new Set() })} onSelect={(id, event) => { if (event.shiftKey && state.selectionAnchor !== undefined) { const anchor = displayedTracks.findIndex((track) => track.id === state.selectionAnchor) @@ -776,7 +825,7 @@ function App() { dispatch({ type: 'selectTrack', id }) } }} - onPlay={(id) => player.start(id, displayedTracks)} + onPlay={(id) => player.start(id, displayedTracks, { kind: 'library', source: state.source })} onEnabled={(id, enabled) => mutate('set_track_enabled', { id, enabled })} onRate={(id, stars) => mutate('click_track_star', { id, stars })} onInfo={openInfo} @@ -844,22 +893,7 @@ function Marquee({ text, strong }: { text: string; strong?: boolean }) { const artworkCache = new Map() -function TransportBar({ playing, track, query, scope, volume, searchRef, onQuery, onScope, onPlay, onPrev, onNext, onVolume, onSeek }: { - playing: State['playing']; track?: PlaybackTrack; query: string; scope: State['scope'] - volume: number - searchRef: React.RefObject - onQuery: (query: string) => void; onScope: (scope: State['scope']) => void; onSeek: (seconds: number) => void - onPlay: () => void; onPrev: () => void; onNext: () => void; onVolume: (volume: number) => void -}) { - const elapsed = playing?.elapsed ?? 0 - const shown = playing?.external ? { - name: `${playing.name ?? 'Unknown Track'} (Spotify)`, - art: playing.art ?? '', - alb: playing.alb ?? '', - durationSecs: playing.durationSecs ?? 0, - } : track - const duration = shown?.durationSecs ?? 0 - const uri = playing?.external ? playing.uri : track?.uri +function useArtwork(uri: string | null | undefined, minWidth: number) { const [artwork, setArtwork] = useState(null) useEffect(() => { let current = true @@ -867,22 +901,66 @@ function TransportBar({ playing, track, query, scope, volume, searchRef, onQuery setArtwork(null) return () => { current = false } } - if (artworkCache.has(uri)) { - setArtwork(artworkCache.get(uri) ?? null) + const key = `${uri}@${minWidth}` + if (artworkCache.has(key)) { + setArtwork(artworkCache.get(key) ?? null) return () => { current = false } } setArtwork(null) - invoke('track_artwork', { uri }) + invoke('track_artwork', { uri, minWidth }) .then((url) => { - artworkCache.set(uri, url) + artworkCache.set(key, url) if (current) setArtwork(url) }) .catch(() => { - artworkCache.set(uri, null) + artworkCache.set(key, null) if (current) setArtwork(null) }) return () => { current = false } - }, [uri]) + }, [minWidth, uri]) + return artwork +} + +function ArtworkPanel({ uri, name, onClose }: { uri: string; name: string; onClose: () => void }) { + const [expanded, setExpanded] = useState(false) + const artwork = useArtwork(uri, 300) + return <> +
+ + +
+ {expanded && setExpanded(false)} />} + +} + +function ArtworkLightbox({ uri, name, onClose }: { uri: string; name: string; onClose: () => void }) { + const artwork = useArtwork(uri, 640) + return +

Artwork for {name}

+ + {artwork ? {`${name} : } +
+} + +function TransportBar({ playing, track, query, scope, volume, searchRef, onQuery, onScope, onPlay, onPrev, onNext, onVolume, onSeek, onOrigin, onArtwork }: { + playing: State['playing']; track?: PlaybackTrack; query: string; scope: State['scope'] + volume: number + searchRef: React.RefObject + onQuery: (query: string) => void; onScope: (scope: State['scope']) => void; onSeek: (seconds: number) => void + onPlay: () => void; onPrev: () => void; onNext: () => void; onVolume: (volume: number) => void; onOrigin: () => void; onArtwork: () => void +}) { + const elapsed = playing?.elapsed ?? 0 + const shown = playing?.external ? { + name: `${playing.name ?? 'Unknown Track'} (Spotify)`, + art: playing.art ?? '', + alb: playing.alb ?? '', + durationSecs: playing.durationSecs ?? 0, + } : track + const duration = shown?.durationSecs ?? 0 + const uri = playing?.external ? playing.uri : track?.uri + const artwork = useArtwork(uri, 64) return
@@ -892,8 +970,23 @@ function TransportBar({ playing, track, query, scope, volume, searchRef, onQuery
-
-
{artwork ? : }
+
{ + if (!playing?.origin || (event.target as Element).closest('progress')) return + onOrigin() + }} + onKeyDown={(event) => { + if (!playing?.origin || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + onOrigin() + }} + > +
{shown ? <>{shown.art} · {shown.alb} : 'Not Playing'}
@@ -921,7 +1014,7 @@ function TransportBar({ playing, track, query, scope, volume, searchRef, onQuery } -function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError }: { +function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse, onShuffle, onRepeat, onDrop, onError, artwork }: { state: State playlists?: PlaylistListView[] onSource: (source: Source) => void @@ -932,6 +1025,7 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse onRepeat: (repeat: RepeatMode) => void onDrop: (id: string, subject: PlaylistSubject) => void onError: (error: string) => void + artwork?: React.ReactNode }) { const [creating, setCreating] = useState(false) const [name, setName] = useState('') @@ -944,12 +1038,6 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse const playlistDrag = useRef<{ id: string; pointerId: number; startY: number; moved: boolean } | undefined>(undefined) const dragInsertBefore = useRef(undefined) const suppressPlaylistClick = useRef(false) - useEffect(() => { - if (!confirming) return - const close = (event: KeyboardEvent) => { if (event.key === 'Escape' && !busy) setConfirming(undefined) } - document.addEventListener('keydown', close) - return () => document.removeEventListener('keydown', close) - }, [busy, confirming]) const create = async () => { if (!name.trim()) return try { @@ -1067,20 +1155,29 @@ function Sidebar({ state, playlists, onSource, onPlaylist, onReorder, onCollapse {playlist.owned ? '' : '🌐'}{playlist.name}{playlist.trackCount} {menu?.playlist.id === playlist.id && setMenu(undefined)}>})} + {artwork}
- {confirming &&
{ if (event.target === event.currentTarget && !busy) setConfirming(undefined) }}>

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

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

} + {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, revision, playing, onPlay, onOpen, onError }: { +function PlaylistView({ playlist, revision, libraryRevision, playing, columnOrder, columnWidths, hiddenColumns, onPlay, onRate, onOpen, onPlaylist, onInfo, onHiddenColumns, onError }: { playlist: PlaylistListView revision: number + libraryRevision: number playing: State['playing'] + columnOrder: ColumnKey[] + columnWidths: Partial> + hiddenColumns: ColumnKey[] onPlay: (id: number, tracks: readonly PlaybackTrack[]) => void + onRate: (id: number, stars: number) => void onOpen: (target: 'app' | 'web') => void + onPlaylist: (subject: PlaylistSubject) => void + onInfo: (tracks: PlaylistTrack[]) => void + onHiddenColumns: (columns: ColumnKey[]) => void onError: (error: string) => void }) { const [tracks, setTracks] = useState([]) @@ -1088,7 +1185,16 @@ function PlaylistView({ playlist, revision, playing, onPlay, onOpen, onError }: const [selectionAnchor, setSelectionAnchor] = useState() const [insertBefore, setInsertBefore] = useState() const [mutating, setMutating] = useState(false) - const canReorder = playlist.owned && tracks.length === playlist.trackCount + const [sortColumn, setSortColumn] = useState(null) + const [sortDesc, setSortDesc] = useState(false) + const [menu, setMenu] = useState<{ x: number; y: number; upstreamIndex?: number }>() + const onErrorRef = useRef(onError) + const trackDrag = useRef<{ indices: number[]; pointerId: number; startY: number; moved: boolean } | undefined>(undefined) + const dragInsertBefore = useRef(undefined) + const suppressTrackClick = useRef(false) + onErrorRef.current = onError + const canChangePlaylist = playlist.owned && tracks.length === playlist.trackCount + const canReorder = canChangePlaylist && sortColumn === null useEffect(() => { if (!playlist.itemsAvailable) { setTracks([]) @@ -1099,13 +1205,52 @@ function PlaylistView({ playlist, revision, playing, onPlay, onOpen, onError }: setSelectionAnchor(undefined) invoke('playlist_tracks', { id: playlist.id }) .then((rows) => active && setTracks(rows)) - .catch((error) => active && onError(String(error))) + .catch((error) => active && onErrorRef.current(String(error))) return () => { active = false } - }, [playlist.id, playlist.itemsAvailable, revision]) - const queue: PlaybackTrack[] = tracks.map((track, index) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + index })) - const drop = async (event: React.DragEvent, index: number) => { - const range = parseDragRange(event.dataTransfer.getData(PLAYLIST_TRACK_DRAG_TYPE)) - if (!range) return + }, [playlist.id, playlist.itemsAvailable, revision, libraryRevision]) + useEffect(() => { + setSortColumn(null) + setSortDesc(false) + }, [playlist.id]) + useEffect(() => { + const selectAll = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'a' || (event.target as Element | null)?.closest('input, textarea, select')) return + event.preventDefault() + setSelected(new Set(tracks.map((_, index) => index))) + setSelectionAnchor(tracks.length ? 0 : undefined) + } + window.addEventListener('keydown', selectAll) + return () => window.removeEventListener('keydown', selectAll) + }, [tracks]) + const rows = playlistRows(tracks, sortColumn, sortDesc) + const queue: PlaybackTrack[] = rows.map(({ track, upstreamIndex }) => ({ ...track, id: track.id ?? SYNTHETIC_BASE + upstreamIndex })) + const headings = { ...trackColumnHeadings(labels.music), track: 'Track' } + const visibleColumns = columnOrder.filter((column) => !hiddenColumns.includes(column)) + const columns = trackGridColumns(visibleColumns, columnWidths, '35px') + const select = (upstreamIndex: number, event: React.MouseEvent) => { + if (event.shiftKey && selectionAnchor !== undefined) { + const anchor = rows.findIndex((row) => row.upstreamIndex === selectionAnchor) + const current = rows.findIndex((row) => row.upstreamIndex === upstreamIndex) + if (anchor >= 0 && current >= 0) { + setSelected(new Set(rows.slice(Math.min(anchor, current), Math.max(anchor, current) + 1).map((row) => row.upstreamIndex))) + return + } + } + if (event.metaKey || event.ctrlKey) { + const next = new Set(selected) + if (!next.delete(upstreamIndex)) next.add(upstreamIndex) + setSelected(next) + setSelectionAnchor(upstreamIndex) + } else { + setSelected(new Set([upstreamIndex])) + setSelectionAnchor(upstreamIndex) + } + } + const selectedIndices = (target?: number) => { + const indices = target !== undefined && !selected.has(target) ? [target] : [...selected] + return indices.sort((left, right) => left - right) + } + const reorder = async (range: { start: number; length: number }, index: number) => { if (index >= range.start && index <= range.start + range.length) { setInsertBefore(undefined) return @@ -1122,8 +1267,13 @@ function PlaylistView({ playlist, revision, playing, onPlay, onOpen, onError }: setMutating(false) } } - const remove = async () => { - const indices = [...selected].sort((left, right) => left - right) + const cancelTrackDrag = () => { + trackDrag.current = undefined + dragInsertBefore.current = undefined + setInsertBefore(undefined) + } + const remove = async (target?: number) => { + const indices = selectedIndices(target) if (!indices.length || !window.confirm(`Remove ${indices.length} selected ${indices.length === 1 ? 'track' : 'tracks'} from “${playlist.name}”?`)) return setMutating(true) try { @@ -1136,46 +1286,108 @@ function PlaylistView({ playlist, revision, playing, onPlay, onOpen, onError }: setMutating(false) } } + const addToLibrary = async (target?: number) => { + const uris = selectedIndices(target).map((index) => tracks[index]).filter((track) => track.id === null).map((track) => track.uri) + if (!uris.length) return + setMenu(undefined) + setMutating(true) + try { + await invoke('add_spotify_tracks', { uris }) + } catch (error) { + onError(String(error)) + } finally { + setMutating(false) + } + } + const addToAnotherPlaylist = (target?: number) => { + const chosen = selectedIndices(target).map((index) => tracks[index]) + if (!chosen.length) return + setMenu(undefined) + onPlaylist({ kind: 'tracks', label: chosen.length === 1 ? `Track · ${chosen[0].name}` : `${chosen.length} tracks`, uris: chosen.map((track) => track.uri) }) + } + const getInfo = (target?: number) => { + const chosen = selectedIndices(target).map((index) => tracks[index]) + if (!chosen.length) return + setMenu(undefined) + onInfo(chosen) + } return
-
{playlist.name}{playlist.trackCount} {playlist.trackCount === 1 ? 'track' : 'tracks'}{playlist.owner ? ` · by ${playlist.owner}` : ''}{playlist.owned && }
- {!playlist.itemsAvailable ?
Tracks unavailable in RetuneSpotify does not allow third-party apps to interact with playlists not owned by you. :-(
: <>
#NameTimeArtistAlbum
-
- {tracks.map((track, index) =>
{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. :-(
: <> +
{ + if (event.target !== event.currentTarget && !(event.target as Element).closest('.playlist-end-drop')) return + setSelected(new Set()) + setSelectionAnchor(undefined) + }}> +
{ event.preventDefault(); setMenu({ x: event.clientX, y: event.clientY }) }}> + + {visibleColumns.map((column) => )} +
+ {rows.map(({ track, upstreamIndex }, rowIndex) =>
{ - if (event.shiftKey && selectionAnchor !== undefined) { - const next = new Set() - for (let row = Math.min(selectionAnchor, index); row <= Math.max(selectionAnchor, index); row += 1) next.add(row) - setSelected(next) - } else if (event.metaKey || event.ctrlKey) { - const next = new Set(selected) - if (!next.delete(index)) next.add(index) - setSelected(next) - setSelectionAnchor(index) - } else { - setSelected(new Set([index])) - setSelectionAnchor(index) - } + if (suppressTrackClick.current) { suppressTrackClick.current = false; event.preventDefault(); return } + select(upstreamIndex, event) }} - onDoubleClick={() => onPlay(queue[index].id, queue)} - onDragStart={canReorder ? (event) => { - const rows = selected.has(index) ? [...selected].sort((left, right) => left - right) : [index] - if (rows.some((row, offset) => row !== rows[0] + offset)) { - event.preventDefault() + onDoubleClick={() => onPlay(queue[rowIndex].id, queue)} + onPointerDown={canReorder && !mutating ? (event) => { + if (event.button !== 0) return + suppressTrackClick.current = false + trackDrag.current = { indices: selected.has(upstreamIndex) ? [...selected] : [upstreamIndex], pointerId: event.pointerId, startY: event.clientY, moved: false } + event.currentTarget.setPointerCapture(event.pointerId) + } : undefined} + onPointerMove={canReorder && !mutating ? (event) => { + const drag = trackDrag.current + if (!drag || drag.pointerId !== event.pointerId || (!drag.moved && Math.abs(event.clientY - drag.startY) < 4)) return + if (!contiguousRange(drag.indices)) { + cancelTrackDrag() onError('Select a contiguous block of tracks to reorder.') return } - event.dataTransfer.effectAllowed = 'move' - event.dataTransfer.setData(PLAYLIST_TRACK_DRAG_TYPE, JSON.stringify({ start: rows[0], length: rows.length })) + drag.moved = true + event.preventDefault() + const trackRows = [...event.currentTarget.parentElement!.querySelectorAll('.playlist-track-row')] + const target = insertionIndexAtY(trackRows.map((row) => { const bounds = row.getBoundingClientRect(); return bounds.top + bounds.height / 2 }), event.clientY) + dragInsertBefore.current = target + setInsertBefore(target) + } : undefined} + onPointerUp={canReorder && !mutating ? (event) => { + const drag = trackDrag.current + if (!drag || drag.pointerId !== event.pointerId) return + const range = contiguousRange(drag.indices) + const target = dragInsertBefore.current + const moved = drag.moved + cancelTrackDrag() + if (!moved || !range || target === undefined) return + event.preventDefault() + suppressTrackClick.current = true + window.setTimeout(() => { suppressTrackClick.current = false }, 0) + void reorder(range, target) } : undefined} - onDragOver={canReorder ? (event) => { event.preventDefault(); setInsertBefore(index) } : undefined} - onDrop={canReorder ? (event) => { event.preventDefault(); void drop(event, index) } : undefined} - onDragEnd={() => setInsertBefore(undefined)} - >{index + 1}{track.name}{track.art}{track.alb}
)} - {canReorder &&
{ event.preventDefault(); setInsertBefore(tracks.length) }} onDrop={(event) => { event.preventDefault(); void drop(event, tracks.length) }} />} -
} + onPointerCancel={cancelTrackDrag} + onContextMenu={(event) => { + event.preventDefault() + if (!selected.has(upstreamIndex)) select(upstreamIndex, event) + setMenu({ x: event.clientX, y: event.clientY, upstreamIndex }) + }} + >{upstreamIndex + 1}{visibleColumns.map((column) => )}
)} + {canReorder &&
} +
+ {menu && (menu.upstreamIndex === undefined + ? setMenu(undefined)} items={columnOrder.map((column) => ({ key: column, label: headings[column], checked: !hiddenColumns.includes(column), disabled: column === 'name', onChange: (checked) => onHiddenColumns(checked ? hiddenColumns.filter((hidden) => hidden !== column) : [...hiddenColumns, column]) }))} /> + : setMenu(undefined)}> + + + + + {playlist.owned && } + )} + }
} @@ -1218,21 +1430,19 @@ function AddToPlaylist({ subject, revision, onAdd, onClose, onError }: { setBusy(undefined) } } - return
{ if (event.target === event.currentTarget) onClose() }}> -
+ return

Add to Playlist

{subject.label}
{local &&

{LOCAL_PLAYLIST_HINT}

} -
{playlists.map((playlist) => )}
{creating ? setName(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') void create() }} /> - : } -
-
-
+ : } + + } function StatusBar({ view, unit, syncPhase, syncProgress, importStatus, empty }: { view: BrowseView | null; unit: string; syncPhase?: string; syncProgress?: { tracks: number; fraction: number }; importStatus?: string; empty: boolean }) { diff --git a/apps/desktop/src/dialogViews.tsx b/apps/desktop/src/dialogViews.tsx index 9443a49..117521e 100644 --- a/apps/desktop/src/dialogViews.tsx +++ b/apps/desktop/src/dialogViews.tsx @@ -1,8 +1,8 @@ import { invoke } from '@tauri-apps/api/core' -import { useEffect, useMemo, useRef, useState } from 'react' -import type { MetadataValues, PlayThresholdPercent, Settings, Theme, Track, TrackInfo } from './types.ts' -import { clearedTrackRating } from './ui.ts' -import { RatingStars } from './viewShared.tsx' +import { useEffect, useMemo, useState } from 'react' +import type { MetadataValues, PlayThresholdPercent, PlaylistTrack, Settings, Theme, TrackInfo } from './types.ts' +import { clearedTrackRating, overlayEditTargets } from './ui.ts' +import { ModalDialog, RatingStars } from './viewShared.tsx' const streamingQualities = [ ['Normal', 96], @@ -34,9 +34,7 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf const [draft, setDraft] = useState({ name: track.name, art: track.art, alb: track.alb, cat: track.cat === 'Uncategorized' ? '' : track.cat }) const [suggestions, setSuggestions] = useState({ arts: [], albs: [], cats: [] }) const [rating, setRating] = useState(track.rating) - const dialog = useRef(null) useEffect(() => { - dialog.current?.focus() invoke('metadata_values').then(setSuggestions).catch((error) => onError(String(error))) }, []) const genres = useMemo(() => [...new Map([...suggestions.cats, ...track.genres].filter((genre) => genre && genre !== 'Uncategorized').map((genre) => [genre.toLowerCase(), genre] as const)).values()] @@ -58,8 +56,7 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf value: draft[key], onChange: (event: React.ChangeEvent) => setDraft({ ...draft, [key]: event.target.value }), }) - return
-
+ return

Get Info

{track.localPath ? : } @@ -67,33 +64,35 @@ export function GetInfo({ track, onCancel, onSaved, onError }: { track: TrackInf
normalize freely, e.g. “Operatic Rock” → “Rock”
-
Track Rating
+
Track Rating
{track.origCat && draft.cat !== track.origCat &&
Spotify reports this as “{track.origCat}”. Your overlay wins in Retune.
} -
-
-
+
+ } -export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: { tracks: Track[]; onCancel: () => void; onSaved: () => void; onError: (error: string) => void }) { +export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: { tracks: PlaylistTrack[]; onCancel: () => void; onSaved: () => void; onError: (error: string) => void }) { type Field = 'art' | 'alb' | 'cat' const [draft, setDraft] = useState>>({}) const [suggestions, setSuggestions] = useState({ arts: [], albs: [], cats: [] }) const [rating, setRating] = useState(undefined) - const dialog = useRef(null) + const [confirming, setConfirming] = useState(false) + const [saving, setSaving] = useState(false) useEffect(() => { - dialog.current?.focus() invoke('metadata_values').then(setSuggestions).catch((error) => onError(String(error))) }, []) const placeholder = (key: Field) => tracks.every((track) => track[key] === tracks[0][key]) ? tracks[0][key] : 'Mixed' - const save = async () => { + const { ids, missingUris } = overlayEditTargets(tracks) + const save = async (addMissing: boolean) => { + setSaving(true) try { - await invoke('set_track_infos', { - ids: tracks.map((track) => track.id), - edit: { ...draft, ...(rating === undefined ? {} : { ratingChange: { stars: rating } }) }, - }) + const addedIds = addMissing ? await invoke('add_spotify_tracks', { uris: missingUris }) : [] + const targetIds = [...new Set([...ids, ...addedIds])] + if (targetIds.length) await invoke('set_track_infos', { ids: targetIds, edit: { ...draft, ...(rating === undefined ? {} : { ratingChange: { stars: rating } }) } }) onSaved() } catch (error) { onError(String(error)) + } finally { + setSaving(false) } } const field = (key: Field, values: string[]) => ({ @@ -107,16 +106,19 @@ export function MultipleItemInformation({ tracks, onCancel, onSaved, onError }: return next }), }) - return
-
+ if (confirming) return setConfirming(false)} onSubmit={saving ? undefined : () => save(true)}> +

Add missing tracks?

+

{missingUris.length} of the {tracks.length} selected {tracks.length === 1 ? 'track is' : 'tracks are'} not in your Library. Add {missingUris.length === 1 ? 'it' : 'them'} before applying this overlay?

+
+
+ return missingUris.length ? setConfirming(true) : save(false)}>

Editing {tracks.length} items

-
Rating
-
-
-
+
Rating
+
+ } export function SetupLibrary({ settings, connected, onCancel, onConnect, onSync }: { @@ -128,21 +130,18 @@ export function SetupLibrary({ settings, connected, onCancel, onConnect, onSync }) { const [clientId, setClientId] = useState(settings.spotifyClientId) const [webApi, setWebApi] = useState(true) - const dialog = useRef(null) - useEffect(() => { dialog.current?.focus() }, []) const trimmedClientId = clientId.trim() - return
-
+ const canSync = Boolean(trimmedClientId && webApi && connected) + return onSync(trimmedClientId) : undefined}>

Set Up Your Library

Retune reads your Spotify library through the Web API and builds a local overlay. Confirm three things, then sync.

1
2
The app must have the Web API scope turned on in its dashboard settings.
-
3
Spotify connection
{connected ? 'Connected' : 'Not connected'}{connected ? ✓ auto-detected : }
+
3
Spotify connection
{connected ? 'Connected' : 'Not connected'}{connected ? ✓ auto-detected : }
-
-
-
+
+ } export function Preferences({ settings, onZoom, onCancel, onSave }: { @@ -164,8 +163,6 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: { const [normalizeVolume, setNormalizeVolume] = useState(settings.normalizeVolume) const [gapless, setGapless] = useState(settings.gapless) const [playThresholdPercent, setPlayThresholdPercent] = useState(settings.playThresholdPercent) - const dialog = useRef(null) - useEffect(() => { dialog.current?.focus() }, []) const tabs: [PreferenceTab, string, string][] = [ ['appearance', '◑', 'Appearance'], ['library', '♫', 'Library'], @@ -176,11 +173,11 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: { ['light', 'Light', 'Always use the light theme.'], ['dark', 'Dark', 'Always use the dark theme.'], ] - return
-
+ const save = () => onSave({ theme, browserVisible, browserPanes, autoAddSpotifyLibrary: autoAdd, autoConnect, spotifyClientId: clientId.trim(), playbackBackend, streamingBitrate, normalizeVolume, gapless, playThresholdPercent }) + return

Preferences

- {tabs.map(([value, glyph, label]) => )} + {tabs.map(([value, glyph, label]) => )}
{tab === 'appearance' && <> @@ -223,7 +220,6 @@ export function Preferences({ settings, onZoom, onCancel, onSave }: {
}
-
-
-
+
+ } diff --git a/apps/desktop/src/libraryViews.tsx b/apps/desktop/src/libraryViews.tsx index 1aef07e..f6be598 100644 --- a/apps/desktop/src/libraryViews.tsx +++ b/apps/desktop/src/libraryViews.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { BrowseView, ColumnKey, Playing, PlaylistSubject, Selection, Settings, Source, Track } from './types.ts' -import { DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, labels, moveBefore, resizedColumnWidth, resizedPaneHeight } from './ui.ts' +import { COLUMN_SPECS, DRAG_LOCAL_TYPE, DRAG_TYPE, facetLabel, formatTime, hasLocalTracks, isCurrentTrack, labels, moveBefore, resizedColumnWidth, resizedPaneHeight, trackColumnHeadings, trackGridColumns } from './ui.ts' import { CheckboxMenu, ContextMenu, RatingStars } from './viewShared.tsx' export function BrowserPane({ state, anchors, onActivate, onSelect, onToggle }: { @@ -91,20 +91,40 @@ export function AlbumRatingStrip({ album, rating, onRate }: { album: string; rat return
{album} onRate(stars === rating ? null : stars)} />
} -const COLUMN_SPECS: Record = { - disc: { width: '34px', numeric: true }, track: { width: '34px', numeric: true }, name: { width: 'minmax(160px, 1.6fr)' }, time: { width: '52px', numeric: true }, artist: { width: '1.1fr' }, - album: { width: '1.1fr' }, genre: { width: '.9fr' }, rating: { width: '84px' }, plays: { width: '48px', numeric: true }, kind: { width: '140px' }, - bitrate: { width: '64px', numeric: true }, lastPlayed: { width: '88px', numeric: true }, added: { width: '88px', numeric: true }, releaseDate: { width: '88px', numeric: true }, +type TrackCellData = Omit & { id: number | null } + +export function TrackCell({ track, column, playing, selected, onInfo, onRate }: { + track: TrackCellData + column: ColumnKey + playing: false | 'playing' | 'paused' + selected: boolean + onInfo?: () => void + onRate?: (stars: number) => void +}) { + if (column === 'disc') return {track.discNo ?? 1} + if (column === 'track') return {track.trackNo ?? ''} + if (column === 'name') return {playing && {playing === 'playing' ? '▶' : '❚❚'}}{track.isLocal && }{track.name}{selected && onInfo && } + if (column === 'time') return {formatTime(track.durationSecs)} + if (column === 'artist') return {track.art} + if (column === 'album') return {track.alb} + if (column === 'genre') return {track.overridden ? '● ' : ''}{track.cat} + if (column === 'plays') return {track.playCount || ''} + if (column === 'kind') return {track.kind ?? ''} + if (column === 'bitrate') return {track.bitrateKbps === null ? '' : `${track.bitrateKbps} kbps`} + if (column === 'lastPlayed') return {track.lastPlayedAt === null ? '' : new Date(track.lastPlayedAt * 1000).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })} + if (column === 'added') return {track.addedAt === null ? '' : new Date(track.addedAt * 1000).toLocaleDateString()} + if (column === 'releaseDate') return {track.releaseDate ?? ''} + return } -export function TrackList({ tracks, label, selectedIds, playing, columnOrder, columnWidths, hiddenColumns, sortColumn, sortDesc, empty, onActivate, onSetup, onSelect, onPlay, onEnabled, onRate, onInfo, onPlaylist, onGoToAlbum, onGoToArtist, onReorder, onColumnWidths, onHiddenColumns, onSort }: { +export function TrackList({ tracks, label, selectedIds, playing, columnOrder, columnWidths, hiddenColumns, sortColumn, sortDesc, empty, onActivate, onSetup, onSelect, onClearSelection, onPlay, onEnabled, onRate, onInfo, onPlaylist, onGoToAlbum, onGoToArtist, onReorder, onColumnWidths, onHiddenColumns, onSort }: { tracks: Track[]; label: (typeof labels)[Source]; selectedIds: Set; playing: Playing | null columnOrder: ColumnKey[]; columnWidths: Partial>; hiddenColumns: ColumnKey[]; sortColumn: ColumnKey | null; sortDesc: boolean; empty: boolean; onSelect: (id: number, event: React.MouseEvent) => void; onPlay: (id: number) => void; onEnabled: (id: number, enabled: boolean) => void onRate: (id: number, stars: number) => void; onInfo: (id: number) => void; onReorder: (order: ColumnKey[]) => void onColumnWidths: (widths: Partial>) => void onPlaylist: (subject: PlaylistSubject) => void onGoToAlbum: (track: Track) => void; onGoToArtist: (track: Track) => void - onActivate: () => void; onSetup: () => void; onHiddenColumns: (columns: ColumnKey[]) => void; onSort: (column: ColumnKey, desc: boolean) => void + onActivate: () => void; onSetup: () => void; onClearSelection: () => void; onHiddenColumns: (columns: ColumnKey[]) => void; onSort: (column: ColumnKey, desc: boolean) => void }) { const [liveWidths, setLiveWidths] = useState(columnWidths) const [menu, setMenu] = useState<{ x: number; y: number; trackId?: number }>() @@ -112,24 +132,9 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co const columnDrag = useRef<{ column: ColumnKey; pointerId: number; startX: number; element: HTMLSpanElement } | undefined>(undefined) const resize = useRef<{ column: ColumnKey; pointerId: number; startX: number; startWidth: number } | undefined>(undefined) useEffect(() => setLiveWidths(columnWidths), [columnWidths]) - const headings: Record = { - disc: 'Disc', - track: '#', - name: label.item[0].toUpperCase() + label.item.slice(1), - time: 'Time', - artist: label.facets[1], - album: label.facets[2], - genre: label.facets[0], - rating: 'Rating', - plays: 'Plays', - kind: 'Kind', - bitrate: 'Bit Rate', - lastPlayed: 'Last Played', - added: 'Date Added', - releaseDate: 'Release Date', - } + const headings = trackColumnHeadings(label) const visibleColumns = columnOrder.filter((column) => !hiddenColumns.includes(column)) - const columns = `16px ${visibleColumns.map((column) => liveWidths[column] === undefined ? COLUMN_SPECS[column].width : `${liveWidths[column]}px`).join(' ')}` + const columns = trackGridColumns(visibleColumns, liveWidths) const moveColumn = (event: React.PointerEvent) => { const active = columnDrag.current if (!active || active.pointerId !== event.pointerId) return @@ -174,25 +179,9 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co resize.current = undefined setLiveWidths(columnWidths) } - const cell = (track: Track, column: ColumnKey) => { - if (column === 'disc') return {track.discNo ?? 1} - if (column === 'track') return {track.trackNo ?? ''} - if (column === 'name') return {playing?.trackId === track.id && {playing.isPlaying ? '▶' : '❚❚'}}{track.isLocal && }{track.name}{selectedIds.has(track.id) && } - if (column === 'time') return {formatTime(track.durationSecs)} - if (column === 'artist') return {track.art} - if (column === 'album') return {track.alb} - if (column === 'genre') return {track.overridden ? '● ' : ''}{track.cat} - if (column === 'plays') return {track.playCount || ''} - if (column === 'kind') return {track.kind ?? ''} - if (column === 'bitrate') return {track.bitrateKbps === null ? '' : `${track.bitrateKbps} kbps`} - if (column === 'lastPlayed') return {track.lastPlayedAt === null ? '' : new Date(track.lastPlayedAt * 1000).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })} - if (column === 'added') return {track.addedAt === null ? '' : new Date(track.addedAt * 1000).toLocaleDateString()} - if (column === 'releaseDate') return {track.releaseDate ?? ''} - return onRate(track.id, stars)} /> - } const menuTrack = menu?.trackId === undefined ? undefined : tracks.find((track) => track.id === menu.trackId) return
-
{ +
{ if (event.target === event.currentTarget) onClearSelection() }}>
{ event.preventDefault() setMenu({ x: event.clientX, y: event.clientY }) }}>{visibleColumns.map((column) => { @@ -214,7 +203,7 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co event.stopPropagation() }} />)}
{empty ?
Your library is emptyConnect Spotify and sync to pull your saved music into a local overlay.
: tracks.map((track) => { - const isPlaying = playing?.trackId === track.id + const isPlaying = isCurrentTrack(playing, track) return
onSelect(track.id, event)} onDoubleClick={() => onPlay(track.id)} onDragStart={(event) => { const dragged = selectedIds.has(track.id) ? tracks.filter((candidate) => selectedIds.has(candidate.id)) : [track] event.dataTransfer.effectAllowed = 'copy' @@ -227,7 +216,7 @@ export function TrackList({ tracks, label, selectedIds, playing, columnOrder, co setMenu({ x: event.clientX, y: event.clientY, trackId: track.id }) }}> event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onChange={(event) => onEnabled(track.id, event.target.checked)} /> - {visibleColumns.map((column) => cell(track, column))} + {visibleColumns.map((column) => onInfo(track.id)} onRate={(stars) => onRate(track.id, stars)} />)}
})}
diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index 663eaef..49c54f2 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -18,6 +18,7 @@ export type Settings = { columnOrder: ColumnKey[] columnWidths: Partial> hiddenColumns: ColumnKey[] + playlistHiddenColumns: Record sortColumn: ColumnKey | null sortDesc: boolean autoAddSpotifyLibrary: boolean @@ -118,7 +119,8 @@ export type Track = { } export type PlaybackTrack = Pick -export type PlaylistTrack = Omit & { id: number | null; rating: RatingView | null } +export type PlaybackOrigin = { kind: 'library'; source: Source } | { kind: 'playlist'; id: string } +export type PlaylistTrack = Omit & { id: number | null } export type PlaylistSubject = | { kind: 'tracks'; label: string; uris: string[] } | { kind: 'album'; label: string; albumUri: string } @@ -137,7 +139,7 @@ export type PlayerState = { } // `simulated` marks fixture tracks whose URIs must never reach a real backend. -export type Playing = PlayerState & { queue: readonly PlaybackTrack[]; simulated?: boolean } +export type Playing = PlayerState & { queue: readonly PlaybackTrack[]; origin?: PlaybackOrigin; simulated?: boolean } export type BrowseView = { facets: { cats: string[]; arts: string[]; albs: string[] } @@ -171,4 +173,4 @@ export type MetadataValues = { arts: string[]; albs: string[]; cats: string[] } export type InfoDialog = | { kind: 'single'; track: TrackInfo } - | { kind: 'multiple'; tracks: Track[] } + | { kind: 'multiple'; tracks: PlaylistTrack[] } diff --git a/apps/desktop/src/ui.ts b/apps/desktop/src/ui.ts index 8f6282a..d1ac65b 100644 --- a/apps/desktop/src/ui.ts +++ b/apps/desktop/src/ui.ts @@ -1,4 +1,4 @@ -import type { ColumnKey, PlaybackTrack, PlaylistSubject, Track } from './types.ts' +import type { ColumnKey, PlaybackOrigin, PlaybackTrack, PlaylistSubject, Source, Track } from './types.ts' export type NativeDragEvent = { type: 'enter'; paths: string[] } | { type: 'over' } | { type: 'drop' } | { type: 'leave' } @@ -22,10 +22,21 @@ export const clearedTrackRating = (inherited: number | null) => export const playbackQueue = (tracks: readonly PlaybackTrack[], requestedId: number) => tracks.filter((track) => track.enabled || track.id === requestedId) +export const playbackOriginAction = (origin: PlaybackOrigin) => origin.kind === 'playlist' + ? { type: 'playlist' as const, id: origin.id } + : { type: 'source' as const, source: origin.source } + +export const isCurrentTrack = ( + playing: { trackId: number | null; uri: string | null } | null, + track: { id: number; uri: string }, +) => playing?.trackId === track.id && playing.uri === track.uri + export const facetLabel = (title: string, value: string) => title === 'Genre' && value === 'Uncategorized' ? 'No Genre' : value -const sortValue = (track: Track, column: ColumnKey): string | number | null => { +type SortableTrack = Pick + +const sortValue = (track: SortableTrack, column: ColumnKey): string | number | null => { if (column === 'disc') return track.discNo ?? 1 if (column === 'track') return track.trackNo if (column === 'name') return track.name @@ -42,7 +53,7 @@ const sortValue = (track: Track, column: ColumnKey): string | number | null => { return track.releaseDate } -export const compareTracks = (left: Track, right: Track, column: ColumnKey, desc: boolean) => { +export const compareTracks = (left: SortableTrack, right: SortableTrack, column: ColumnKey, desc: boolean) => { 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) { @@ -59,6 +70,37 @@ export const compareTracks = (left: Track, right: Track, column: ColumnKey, desc return 0 } +export const playlistRows = (tracks: readonly T[], column: ColumnKey | null, desc: boolean) => { + const rows = tracks.map((track, upstreamIndex) => ({ track, upstreamIndex })) + return column ? rows.sort((left, right) => compareTracks(left.track, right.track, column, desc)) : rows +} + +export const dialogTabTarget = (current: number, count: number, backward: boolean) => { + if (!count) return null + if (current < 0) return backward ? count - 1 : 0 + if (backward && current === 0) return count - 1 + if (!backward && current === count - 1) return 0 + return null +} + +export const overlayEditTargets = (tracks: readonly { id: number | null; uri: string }[]) => ({ + ids: [...new Set(tracks.flatMap((track) => track.id === null ? [] : [track.id]))], + missingUris: [...new Set(tracks.filter((track) => track.id === null).map((track) => track.uri))], +}) + +export const COLUMN_SPECS: Record = { + disc: { width: '34px', numeric: true }, track: { width: '34px', numeric: true }, name: { width: 'minmax(160px, 1.6fr)' }, time: { width: '52px', numeric: true }, artist: { width: '1.1fr' }, + album: { width: '1.1fr' }, genre: { width: '.9fr' }, rating: { width: '84px' }, plays: { width: '48px', numeric: true }, kind: { width: '140px' }, + bitrate: { width: '64px', numeric: true }, lastPlayed: { width: '88px', numeric: true }, added: { width: '88px', numeric: true }, releaseDate: { width: '88px', numeric: true }, +} + +export const trackColumnHeadings = (label: (typeof labels)[Source]): Record => ({ + disc: 'Disc', track: '#', name: label.item[0].toUpperCase() + label.item.slice(1), time: 'Time', artist: label.facets[1], album: label.facets[2], genre: label.facets[0], rating: 'Rating', plays: 'Plays', kind: 'Kind', bitrate: 'Bit Rate', lastPlayed: 'Last Played', added: 'Date Added', releaseDate: 'Release Date', +}) + +export const trackGridColumns = (columns: ColumnKey[], widths: Partial>, leading = '16px') => + `${leading} ${columns.map((column) => widths[column] === undefined ? COLUMN_SPECS[column].width : `${widths[column]}px`).join(' ')}` + export const moveBefore = (items: T[], item: T, target?: T) => { if (!items.includes(item) || item === target) return items const next = items.filter((candidate) => candidate !== item) @@ -97,13 +139,11 @@ export const menuPosition = (x: number, y: number, width: number, height: number return { left: left / zoom, top: top / zoom } } -export type DragRange = { start: number; length: number } - -export const parseDragRange = (value: string): DragRange | undefined => { - try { - const range = JSON.parse(value) as Partial - if (Number.isInteger(range.start) && Number.isInteger(range.length) && range.start! >= 0 && range.length! > 0) return range as DragRange - } catch {} +export const contiguousRange = (indices: number[]) => { + const sorted = [...indices].sort((left, right) => left - right) + return sorted.length && sorted.every((row, offset) => row === sorted[0] + offset) + ? { start: sorted[0], length: sorted.length } + : undefined } export const DRAG_TYPE = 'application/x-retune' diff --git a/apps/desktop/src/viewShared.tsx b/apps/desktop/src/viewShared.tsx index faa7c24..f88fa7c 100644 --- a/apps/desktop/src/viewShared.tsx +++ b/apps/desktop/src/viewShared.tsx @@ -1,6 +1,44 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { ReactNode } from 'react' -import { menuPosition } from './ui.ts' +import { dialogTabTarget, menuPosition } from './ui.ts' + +const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])' + +export function ModalDialog({ className, labelledBy, onCancel, onSubmit, closeOnBackdrop = false, children }: { + className: string + labelledBy: string + onCancel?: () => void + onSubmit?: () => void | Promise + closeOnBackdrop?: boolean + children: ReactNode +}) { + const dialog = useRef(null) + useEffect(() => { + const previous = document.activeElement as HTMLElement | null + const element = dialog.current + if (element && !element.contains(document.activeElement)) (element.querySelector('[autofocus]') ?? element.querySelector(FOCUSABLE) ?? element).focus() + return () => previous?.focus() + }, []) + return
{ + if (closeOnBackdrop && event.target === event.currentTarget) onCancel?.() + }}>
{ + event.preventDefault() + void onSubmit?.() + }} onKeyDown={(event) => { + if (event.key === 'Escape' && onCancel) { + event.preventDefault() + event.stopPropagation() + onCancel() + return + } + if (event.key !== 'Tab') return + const focusable = [...event.currentTarget.querySelectorAll(FOCUSABLE)] + const target = dialogTabTarget(focusable.indexOf(document.activeElement as HTMLElement), focusable.length, event.shiftKey) + if (target === null) return + event.preventDefault() + focusable[target].focus() + }}>{children}
+} export function ContextMenu({ x, y, onClose, children }: { x: number; y: number; onClose: () => void; children: ReactNode }) { const menu = useRef(null) @@ -41,6 +79,6 @@ export function CheckboxMenu({ x, y, onClose, items }: { } export function RatingStars({ rating, explicit = false, onRate }: { rating: number | null; explicit?: boolean; onRate?: (stars: number) => void }) { return - {[1, 2, 3, 4, 5].map((star) => )} + {[1, 2, 3, 4, 5].map((star) => )} } diff --git a/apps/desktop/test/ui.test.ts b/apps/desktop/test/ui.test.ts index 77a878e..aa397c1 100644 --- a/apps/desktop/test/ui.test.ts +++ b/apps/desktop/test/ui.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { clearedTrackRating, compareTracks, facetLabel, insertionIndexAtY, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, parseDragRange, playbackQueue, resizedColumnWidth, resizedPaneHeight } from '../src/ui.ts' +import { clearedTrackRating, compareTracks, contiguousRange, dialogTabTarget, facetLabel, insertionIndexAtY, isCurrentTrack, menuPosition, mergeByUri, moveBefore, moveToIndex, nextNativeDragActive, normalizeZoom, overlayEditTargets, playbackOriginAction, playbackQueue, playlistRows, resizedColumnWidth, resizedPaneHeight, SYNTHETIC_BASE } from '../src/ui.ts' test('the music catch-all has a user-facing genre label', () => { assert.equal(facetLabel('Genre', 'Uncategorized'), 'No Genre') @@ -25,10 +25,10 @@ test('menu coordinates stay under the pointer and inside a zoomed viewport', () assert.deepEqual(menuPosition(1085, 700, 150, 200, 1120, 720, 1.2), { left: 964 / 1.2, top: 500 / 1.2 }) }) -test('playlist drag ranges reject malformed payloads', () => { - assert.deepEqual(parseDragRange('{"start":2,"length":3}'), { start: 2, length: 3 }) - assert.equal(parseDragRange('{"start":-1,"length":3}'), undefined) - assert.equal(parseDragRange('nope'), undefined) +test('playlist drags accept only contiguous selections', () => { + assert.deepEqual(contiguousRange([4, 2, 3]), { start: 2, length: 3 }) + assert.equal(contiguousRange([2, 4]), undefined) + assert.equal(contiguousRange([]), undefined) }) test('columns move before the header under the pointer', () => { @@ -73,6 +73,17 @@ test('sequential playback skips exclusions but an explicit start still plays one assert.deepEqual(playbackQueue(tracks, 2).map((track) => track.id), [1, 2, 3]) }) +test('playlist highlights require both the synthetic id and Spotify URI', () => { + 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) +}) + +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' }) +}) + test('track and disc sorts keep multi-disc albums in playback order', () => { const track = (discNo: number | null, trackNo: number) => ({ discNo, trackNo } as never) const tracks = [track(2, 1), track(1, 2), track(null, 1), track(1, 3)] @@ -82,6 +93,26 @@ test('track and disc sorts keep multi-disc albums in playback order', () => { assert.deepEqual([...tracks].sort((a, b) => compareTracks(a, b, 'track', true)), [...expected].reverse()) }) +test('playlist sorting is view-only and clearing it restores Spotify order', () => { + const tracks = [{ name: 'Zulu' }, { name: 'Alpha' }, { name: 'Mike' }] as never + assert.deepEqual(playlistRows(tracks, 'name', false).map((row) => row.upstreamIndex), [1, 2, 0]) + assert.deepEqual(playlistRows(tracks, null, false).map((row) => row.upstreamIndex), [0, 1, 2]) +}) + +test('dialog tabs wrap in both directions', () => { + assert.equal(dialogTabTarget(2, 3, false), 0) + assert.equal(dialogTabTarget(0, 3, true), 2) + assert.equal(dialogTabTarget(1, 3, false), null) +}) + +test('overlay edits separate Library tracks from unique missing playlist tracks', () => { + assert.deepEqual(overlayEditTargets([ + { id: 7, uri: 'spotify:track:in' }, + { id: null, uri: 'spotify:track:out' }, + { id: null, uri: 'spotify:track:out' }, + ]), { ids: [7], missingUris: ['spotify:track:out'] }) +}) + test('release dates sort chronologically with the standard tie-breakers and missing dates last', () => { const track = (releaseDate: string | null, trackNo: number) => ({ releaseDate, trackNo } as never) const tracks = [track(null, 1), track('2024-01-01', 2), track('2024-01-01', 1), track('2020', 1)] diff --git a/docs/architecture/persistence.md b/docs/architecture/persistence.md index 838843f..9b83121 100644 --- a/docs/architecture/persistence.md +++ b/docs/architecture/persistence.md @@ -18,6 +18,13 @@ All JSON state writes use a temporary file followed by atomic rename. Built-in Spotify playback also maintains an `audio-cache` directory. Cache data is disposable; library and settings files are not. +The playlist cache retains Spotify display metadata for every fetched track, +including disc/track numbers and album release date. Older caches deserialize +with defaults and are refreshed once before snapshot-based fetch skipping resumes. + +Column visibility is UI state in `settings.json`: the Library has one hidden-column +list, and playlists have independent lists keyed by Spotify playlist ID. + ## Spotify audio cache The audio cache contains complete encrypted Spotify audio files keyed by