diff --git a/crates/qbz-app/src/settings/local_favorites.rs b/crates/qbz-app/src/settings/local_favorites.rs new file mode 100644 index 000000000..e26e40bdb --- /dev/null +++ b/crates/qbz-app/src/settings/local_favorites.rs @@ -0,0 +1,307 @@ +//! Headless local-favorites service. +//! +//! Frontend-agnostic store for favoriting LOCAL library items (genuine local +//! files + Plex — never the Qobuz offline cache). Mirrors `pinned_items.rs` +//! (same pragmas, error style, in-memory `(kind, id)` set) per ADR-006; the +//! per-user lifecycle lives in the `qbz` crate wrapper (`crate::local_favorites`). +//! +//! Rows carry a display snapshot (title/subtitle/artwork) taken at favorite +//! time plus a denormalized `artist` (for per-artist counts) and `source` +//! (`local` | `plex`). The `CHECK` on `source` refuses `qobuz_download` at +//! write time, so the mixed-library feed built from this store is inherently +//! free of Qobuz-offline duplicates. + +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::Path; +use std::sync::RwLock; + +/// Database file name, joined onto the per-user data dir by the lifecycle layer. +pub const DB_FILE_NAME: &str = "local_favorites.db"; + +/// A favorited local item with its display snapshot. +/// +/// Ids are Strings: album = the local group key (`plex:…` / contains `|`/`/`), +/// artist = the artist NAME (local artists have no numeric id), track = +/// `file_path` (local) or `plex:` (Plex) — the stable key. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalFavItem { + /// "album" | "artist" | "track". + pub kind: String, + pub id: String, + pub title: String, + pub subtitle: String, + pub artwork_url: String, + /// Denormalized artist name (for per-artist counts); empty for kind="artist". + pub artist: String, + /// "local" | "plex" — never "qobuz_download". + pub source: String, + /// Unix seconds; the ordering key (newest first). + pub favorited_at: i64, +} + +/// Local-favorites service with O(1) lookup performance. +pub struct LocalFavoritesService { + conn: Connection, + /// In-memory `(kind, id)` set for O(1) heart lookups. + keys: RwLock>, +} + +impl LocalFavoritesService { + /// Create a new service, opening or creating the database. + pub fn new(db_path: &Path) -> Result { + log::info!("[LocalFav] Opening database at: {}", db_path.display()); + + let conn = Connection::open(db_path) + .map_err(|e| format!("Failed to open local favorites database: {}", e))?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;") + .map_err(|e| format!("Failed to set WAL mode: {}", e))?; + + let service = Self { + conn, + keys: RwLock::new(HashSet::new()), + }; + + service.init_schema()?; + service.load_from_db()?; + + Ok(service) + } + + /// Create an in-memory service (test/ephemeral helper). + pub fn new_in_memory() -> Result { + let conn = Connection::open_in_memory() + .map_err(|e| format!("Failed to open in-memory local favorites database: {}", e))?; + + let service = Self { + conn, + keys: RwLock::new(HashSet::new()), + }; + + service.init_schema()?; + service.load_from_db()?; + + Ok(service) + } + + fn init_schema(&self) -> Result<(), String> { + self.conn + .execute_batch( + r#" + CREATE TABLE IF NOT EXISTS local_favorites ( + kind TEXT NOT NULL CHECK (kind IN ('album','artist','track')), + id TEXT NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + artwork_url TEXT, + artist TEXT, + source TEXT NOT NULL CHECK (source IN ('local','plex')), + favorited_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + PRIMARY KEY (kind, id) + ); + CREATE INDEX IF NOT EXISTS idx_local_favorites_at + ON local_favorites(favorited_at); + CREATE INDEX IF NOT EXISTS idx_local_favorites_artist + ON local_favorites(kind, artist); + "#, + ) + .map_err(|e| format!("Failed to initialize local favorites schema: {}", e))?; + + Ok(()) + } + + fn load_from_db(&self) -> Result<(), String> { + let mut stmt = self + .conn + .prepare("SELECT kind, id FROM local_favorites") + .map_err(|e| format!("Failed to prepare local favorites query: {}", e))?; + + let keys: Vec<(String, String)> = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("Failed to query local favorites: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + let count = keys.len(); + let mut set = self + .keys + .write() + .map_err(|_| "Failed to acquire write lock")?; + *set = keys.into_iter().collect(); + + log::info!("[LocalFav] Loaded {} local favorites into memory", count); + Ok(()) + } + + /// Check if a local item is favorited — O(1). + #[inline] + pub fn is_favorite(&self, kind: &str, id: &str) -> bool { + self.keys + .read() + .map(|set| set.contains(&(kind.to_string(), id.to_string()))) + .unwrap_or(false) + } + + /// Favorite an item (upsert). `favorited_at` is stamped now. + pub fn favorite(&self, item: &LocalFavItem) -> Result<(), String> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + self.conn + .execute( + "INSERT OR REPLACE INTO local_favorites + (kind, id, title, subtitle, artwork_url, artist, source, favorited_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + item.kind, + item.id, + item.title, + item.subtitle, + item.artwork_url, + item.artist, + item.source, + now + ], + ) + .map_err(|e| format!("Failed to favorite item: {}", e))?; + + if let Ok(mut set) = self.keys.write() { + set.insert((item.kind.clone(), item.id.clone())); + } + Ok(()) + } + + /// Unfavorite an item. Absent rows are Ok, not an error. + pub fn unfavorite(&self, kind: &str, id: &str) -> Result<(), String> { + self.conn + .execute( + "DELETE FROM local_favorites WHERE kind = ?1 AND id = ?2", + params![kind, id], + ) + .map_err(|e| format!("Failed to unfavorite item: {}", e))?; + + if let Ok(mut set) = self.keys.write() { + set.remove(&(kind.to_string(), id.to_string())); + } + Ok(()) + } + + /// All favorites, newest first. + pub fn list(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT kind, id, title, subtitle, artwork_url, artist, source, favorited_at + FROM local_favorites + ORDER BY favorited_at DESC", + ) + .map_err(|e| format!("Failed to prepare local favorites query: {}", e))?; + + let items = stmt + .query_map([], |row| { + Ok(LocalFavItem { + kind: row.get(0)?, + id: row.get(1)?, + title: row.get(2)?, + subtitle: row.get::<_, Option>(3)?.unwrap_or_default(), + artwork_url: row.get::<_, Option>(4)?.unwrap_or_default(), + artist: row.get::<_, Option>(5)?.unwrap_or_default(), + source: row.get(6)?, + favorited_at: row.get(7)?, + }) + }) + .map_err(|e| format!("Failed to query local favorites: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(items) + } + + /// Per-artist favorite counts (album + track kinds carry an artist). + pub fn count_by_artist(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare( + "SELECT artist, COUNT(*) FROM local_favorites + WHERE artist IS NOT NULL AND artist != '' + GROUP BY artist ORDER BY COUNT(*) DESC", + ) + .map_err(|e| format!("Failed to prepare count-by-artist query: {}", e))?; + + let rows = stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("Failed to query count-by-artist: {}", e))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(rows) + } + + /// Count of favorites. + pub fn count(&self) -> usize { + self.keys.read().map(|set| set.len()).unwrap_or(0) + } + + /// Snapshot of the in-memory `(kind, id)` set, for bulk card stamping. + pub fn keys_snapshot(&self) -> HashSet<(String, String)> { + self.keys.read().map(|set| set.clone()).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(kind: &str, id: &str, title: &str, artist: &str, source: &str) -> LocalFavItem { + LocalFavItem { + kind: kind.to_string(), + id: id.to_string(), + title: title.to_string(), + subtitle: String::new(), + artwork_url: String::new(), + artist: artist.to_string(), + source: source.to_string(), + favorited_at: 0, + } + } + + #[test] + fn lifecycle() { + let s = LocalFavoritesService::new_in_memory().expect("svc"); + assert!(!s.is_favorite("album", "plex:abc")); + assert_eq!(s.count(), 0); + + s.favorite(&item("album", "plex:abc", "A", "Artist X", "plex")) + .unwrap(); + assert!(s.is_favorite("album", "plex:abc")); + assert!(!s.is_favorite("track", "plex:abc")); + + s.favorite(&item("track", "/music/x.flac", "T", "Artist X", "local")) + .unwrap(); + assert_eq!(s.count(), 2); + let by_artist = s.count_by_artist().unwrap(); + assert_eq!(by_artist[0], ("Artist X".to_string(), 2)); + + let all = s.list().unwrap(); + assert_eq!(all.len(), 2); + + s.unfavorite("album", "plex:abc").unwrap(); + assert!(!s.is_favorite("album", "plex:abc")); + assert_eq!(s.count(), 1); + s.unfavorite("album", "nope").unwrap(); + } + + #[test] + fn source_check_rejects_offline() { + let s = LocalFavoritesService::new_in_memory().expect("svc"); + assert!( + s.favorite(&item("album", "x", "X", "A", "qobuz_download")) + .is_err(), + "the source CHECK refuses qobuz-offline rows" + ); + } +} diff --git a/crates/qbz-app/src/settings/mod.rs b/crates/qbz-app/src/settings/mod.rs index d86e1953d..f090de096 100644 --- a/crates/qbz-app/src/settings/mod.rs +++ b/crates/qbz-app/src/settings/mod.rs @@ -6,6 +6,7 @@ pub mod discover_prefs; pub mod favorites; pub mod favorites_cache; pub mod graphics; +pub mod local_favorites; pub mod pinned_items; pub mod playback; pub mod plex; diff --git a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po index 8a852bc31..b79644138 100644 --- a/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/de/LC_MESSAGES/qbz-ui.po @@ -4610,3 +4610,12 @@ msgstr "integriert" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "Bevorzugte GPU geändert — QBZ neu starten, um die Änderung zu übernehmen" + +msgid "Search your library" +msgstr "Deine Bibliothek durchsuchen" + +msgid "From catalog" +msgstr "Aus dem Katalog" + +msgid "In library" +msgstr "In der Bibliothek" diff --git a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po index 79e280c07..ca8ff9dd9 100644 --- a/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/en/LC_MESSAGES/qbz-ui.po @@ -388,3 +388,12 @@ msgstr "integrated" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "Preferred GPU changed — restart QBZ to apply" + +msgid "Search your library" +msgstr "Search your library" + +msgid "From catalog" +msgstr "From catalog" + +msgid "In library" +msgstr "In library" diff --git a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po index efcb2a3c6..21ccdf9da 100644 --- a/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/es/LC_MESSAGES/qbz-ui.po @@ -4640,3 +4640,12 @@ msgstr "integrada" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "GPU preferida cambiada — reinicia QBZ para aplicar" + +msgid "Search your library" +msgstr "Buscar en tu biblioteca" + +msgid "From catalog" +msgstr "Del catálogo" + +msgid "In library" +msgstr "En la biblioteca" diff --git a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po index a56805dc4..74e6e1431 100644 --- a/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/fr/LC_MESSAGES/qbz-ui.po @@ -4610,3 +4610,12 @@ msgstr "intégré" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "GPU préféré modifié — redémarrez QBZ pour appliquer" + +msgid "Search your library" +msgstr "Rechercher dans votre bibliothèque" + +msgid "From catalog" +msgstr "Du catalogue" + +msgid "In library" +msgstr "Dans la bibliothèque" diff --git a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po index 5e2361452..29284beb5 100644 --- a/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ja/LC_MESSAGES/qbz-ui.po @@ -4598,3 +4598,12 @@ msgstr "内蔵" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "優先GPUを変更しました — 適用するには QBZ を再起動してください" + +msgid "Search your library" +msgstr "ライブラリを検索" + +msgid "From catalog" +msgstr "カタログから" + +msgid "In library" +msgstr "ライブラリ内" diff --git a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po index 59b7803a8..299ff966f 100644 --- a/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/nl/LC_MESSAGES/qbz-ui.po @@ -4622,3 +4622,12 @@ msgstr "geïntegreerd" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "Voorkeurs-GPU gewijzigd — herstart QBZ om toe te passen" + +msgid "Search your library" +msgstr "Zoek in je bibliotheek" + +msgid "From catalog" +msgstr "Uit catalogus" + +msgid "In library" +msgstr "In bibliotheek" diff --git a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po index 8b457db12..02f84f7f1 100644 --- a/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/pt/LC_MESSAGES/qbz-ui.po @@ -4610,3 +4610,12 @@ msgstr "integrada" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "GPU preferida alterada — reinicie o QBZ para aplicar" + +msgid "Search your library" +msgstr "Pesquisar na sua biblioteca" + +msgid "From catalog" +msgstr "Do catálogo" + +msgid "In library" +msgstr "Na biblioteca" diff --git a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po index fc807b1f4..f558ab5f3 100644 --- a/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-ui/translations/ru/LC_MESSAGES/qbz-ui.po @@ -4624,3 +4624,12 @@ msgstr "встроенный" msgid "Preferred GPU changed — restart QBZ to apply" msgstr "Предпочитаемый GPU изменён — перезапустите QBZ, чтобы применить" + +msgid "Search your library" +msgstr "Поиск в библиотеке" + +msgid "From catalog" +msgstr "Из каталога" + +msgid "In library" +msgstr "В библиотеке" diff --git a/crates/qbz-ui/ui/app.slint b/crates/qbz-ui/ui/app.slint index 2e5e10e7e..66f037ee7 100644 --- a/crates/qbz-ui/ui/app.slint +++ b/crates/qbz-ui/ui/app.slint @@ -31,7 +31,7 @@ export { ThemeState } from "foundation/theme-state.slint"; export { Typography } from "foundation/typography.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint"; +export { HomeState, HomeActions, RecentAlbumsState, DiscoverState, DiscoverActions, SectionDescriptor, ConfigRow, DiscoverBrowseState, DiscoverBrowseActions, PlaylistBrowseState, PlaylistBrowseActions, ForYouState, PinnedItem, PinnedState, PinnedActions, ExternalRecoState, ExternalRecoActions, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, ShellState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, LyricsState, LyricsLineItem, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, AwardState, AwardActions, AwardEntry, ArtistReleasesState, ArtistReleasesActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, LibraryFeedItem, LibraryAllState, LibraryAllActions, PlaylistPickerState, PlaylistPickerActions, DuplicateConfirmState, DuplicateConfirmActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, SidebarFolderPopupState, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, SettingsExportState, SettingsExportActions, MyQbzCreateState, MyQbzCreateActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, BlacklistState, BlacklistActions, BlacklistedArtistItem, MyQbzState, MyQbzActions, MixtapeCardItem, MyQbzAddState, MyQbzAddActions, MyQbzAddRow, MyQbzDetailState, MyQbzDetailActions, MixtapeDetailItem, MyQbzEditState, MyQbzEditActions, MyQbzMixState, MyQbzMixActions, DiscoBuilderState, DiscoBuilderActions, DiscoGroup, DiscoCandidate, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, LibraryScanState, LibAlbumFilterState, LocalAlbumState, LocalAlbumActions, TagEditorState, TagEditorActions, FolderEditState, FolderEditActions, ToastState, TextUtil, QconnectDevState, QconnectDevice, CastState, CastDevice, CastActions, AppearanceState, MyQbzBrandingState, EphemeralPlayChoiceState, EphemeralPlayChoiceActions, PlexSettingsState, PlexAuthActions, PlexSectionItem, ScrobbleState, ScrobbleActions, DiscordState, OfflineState, LoginState, OfflineModeActions, OfflineFavoritesState, OfflineFavoritesActions, ImportLogEntry, PlaylistImportState, PlaylistImportActions, DacWizardState, DacWizardActions, DacCandidateRow, RemediationRow, DacConfigRow, InfoCreditRow, InfoCreditPair, AlbumCreditPerformer, AlbumCreditTrack, TrackInfoState, TrackInfoActions, AlbumInfoState, AlbumInfoActions, BookletState, BookletActions, SuggestionsState, SuggestionsActions, SuggestionCard, PlaylistSuggestionsState, PlaylistSuggestionsActions, PlaylistSuggestionRow, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, MiniPlayerState, WindowControlActions, PurchasesState, PurchasesActions, PurchaseAlbumItem, PurchaseTrackItem, PurchaseAlbumGroup, PurchaseTrackGroup, PurchaseFormatItem, PurchaseDetailState, PurchaseDetailActions, PurchaseDetailTrack, KeybindingRow, KeybindingCategoryGroup, KeybindingsState, KeybindingsActions, KeyboardShortcutsState, LinkResolverState, LinkResolverActions, UiFocusState, UiScale, SleepTimerState, SleepTimerActions, LogRow, LogViewerState, DiagRow, DiagnosticsState, ReportIssueState, ReportIssueActions, AboutState, AboutActions, AboutContributorRow, AboutContributorGroup, WhatsNewState, WhatsNewActions, WhatsNewBlock, WhatsNewTocEntry } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-ui/ui/artist/ArtistPageView.slint b/crates/qbz-ui/ui/artist/ArtistPageView.slint index 342707951..78259970d 100644 --- a/crates/qbz-ui/ui/artist/ArtistPageView.slint +++ b/crates/qbz-ui/ui/artist/ArtistPageView.slint @@ -34,6 +34,9 @@ import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { ReleaseGrid } from "ReleaseGrid.slint"; import { AlbumCard } from "../discover/AlbumCard.slint"; +import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; +import { PlaylistCarousel } from "../discover/PlaylistCarousel.slint"; +import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { JumpNavBar, JumpNavTab } from "../primitives/JumpNavBar.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; @@ -662,6 +665,26 @@ export component ArtistPageView inherits Rectangle { // below it rather than behind it. Rectangle { height: 52px; } + // Catalog / Library toggle (webplayer "Del catálogo" / "En la + // biblioteca"). Shown ONLY when the user has library items for this + // artist; right-aligned at the top of the content column. Lives + // OUTSIDE the catalog wrapper so it stays visible in both modes. + if ArtistState.library-count > 0: HorizontalLayout { + Rectangle { horizontal-stretch: 1; } + VerticalLayout { + alignment: center; + SegmentedTabBar { + active-id: ArtistState.artist-tab; + tabs: [ + { id: "catalog", label: @tr("From catalog"), count: 0 }, + { id: "library", label: @tr("In library"), count: ArtistState.library-count }, + ]; + selected(id) => { ArtistState.artist-tab = id; } + } + } + } + if ArtistState.library-count > 0: Rectangle { height: 12px; } + if ArtistState.loading && ArtistState.top-tracks.length == 0: Rectangle { height: 280px; VerticalLayout { @@ -680,6 +703,11 @@ export component ArtistPageView inherits Rectangle { } } + // Catalog content — the whole discography stack renders only in + // "catalog" mode; "library" swaps to the user's added items below. + if ArtistState.artist-tab == "catalog": VerticalLayout { + spacing: 0px; + // --- Popular tracks ---------------------------------------------- // Section header: title on the left, action cluster on the right // (Play all / Select-mode toggle / More menu). Mirrors Tauri's @@ -953,6 +981,20 @@ export component ArtistPageView inherits Rectangle { } } + // --- Playlists (curated playlists featuring this artist) --------- + // The /artist/page `playlists` section, rendered as a main-column + // carousel directly above the "Other" block. Body click opens the + // playlist; the overlay play button plays it. + if ArtistState.playlists.length > 0: Rectangle { height: 32px; } + if ArtistState.playlists.length > 0: PlaylistCarousel { + title: @tr("Playlists"); + playlists: ArtistState.playlists; + body-opens: true; + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + // --- Other (rendered LAST, collapsed by default) ----------------- // Usually low-quality / AI cover junk, so it sits below Appears On // and stays hidden behind a Show/Hide toggle. @@ -1010,6 +1052,39 @@ export component ArtistPageView inherits Rectangle { } } } + } // close the "catalog" wrapper VerticalLayout + + // --- In library — the user's added items for this artist ------ + if ArtistState.artist-tab == "library": VerticalLayout { + spacing: 0px; + if ArtistState.library-tracks.length > 0: SectionTitle { + text: @tr("Tracks"); + } + if ArtistState.library-tracks.length > 0: Rectangle { height: 10px; } + for track[index] in ArtistState.library-tracks: TrackRow { + track: track; + index: index; + show-artwork: true; + multi-select-mode: false; + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + if ArtistState.library-albums.length > 0: Rectangle { height: 24px; } + if ArtistState.library-albums.length > 0: SectionTitle { + text: @tr("Albums"); + } + if ArtistState.library-albums.length > 0: Rectangle { height: 10px; } + if ArtistState.library-albums.length > 0: AlbumCollectionView { + albums: ArtistState.library-albums; + view-mode: "grid"; + show-favorite: true; + open-album(id) => { root.open-album(id); } + media-action(kind, id, action) => { + root.media-action(kind, id, action); + } + } + } } // close main-col VerticalLayout // Sidebar slot — empty placeholder that reserves the diff --git a/crates/qbz-ui/ui/discover/TrackCard.slint b/crates/qbz-ui/ui/discover/TrackCard.slint new file mode 100644 index 000000000..7f8fd32cc --- /dev/null +++ b/crates/qbz-ui/ui/discover/TrackCard.slint @@ -0,0 +1,316 @@ +// Track card for grid surfaces (the Library "All" mixed feed). +// +// A reduced AlbumCard: a single track rendered as a 200px card instead of a +// list row. Mirrors AlbumCard.slint 1:1 for design coherence — same 200x200 +// clipped cover, hover scrim, centered play/favorite/more overlay row, then a +// 6px spacer and a 40px meta row (title + "Track • Artist" subtitle beside an +// icon-only quality badge). Deterministic 246px height. +// +// Unlike AlbumCard (whose body click OPENS the album), a track card's body +// click PLAYS the track (like PlaylistCard's body-opens=false). Actions bubble +// up as media-action("track", id, action). + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { Spacing } from "../foundation/spacing.slint"; +import { TrackItem, NavState } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QualityBadge } from "../primitives/QualityBadge.slint"; +import { ContextMenu } from "../primitives/ContextMenu.slint"; +import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; + +// Circular hover-overlay action button. Solid (semi-transparent) fill so the +// whole circle is a stable hit/hover surface. File-local by design — every card +// (AlbumCard/PlaylistCard/ArtistGridCard) carries its own copy. +component OverlayButton inherits Rectangle { + in property icon; + in property primary: false; + in property diameter: 36px; + in property icon-size: 16px; + in property active: false; + out property hovered: ta.has-hover; + callback clicked(); + + width: root.diameter; + height: root.diameter; + border-radius: root.diameter / 2; + background: root.primary + ? (ta.has-hover ? #ffffffd6 : #ffffff) + : (ta.has-hover ? #ffffff3d : #ffffff24); + border-width: root.primary ? 0px : 1.5px; + border-color: #ffffffcc; + + QbzIcon { + source: root.icon; + width: root.icon-size; + height: root.icon-size; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + tint: root.active + ? Theme.accent + : (root.primary ? #000000 : #ffffff); + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(); + } + pointer-event(e) => { + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.back) { + NavState.request-back(); + } + if (e.kind == PointerEventKind.down && e.button == PointerEventButton.forward) { + NavState.request-forward(); + } + } + } +} + +export component TrackCard inherits Rectangle { + in property track; + in property show-favorite: true; + // Always-visible source badge on the artwork (Library All: local | plex | + // qobuz). Off elsewhere (single-source surfaces). + in property show-source-badge: false; + // Fade out when the card is being removed (favorites un-favorite). + opacity: root.track.removing ? 0.0 : 1.0; + animate opacity { duration: 250ms; } + callback clicked(string); + // kind ("track"), track id, action ("play" | "queue" | "play-next" | + // "favorite" | "open"). + callback media-action(string, string, string); + + in property card-height: 246px; + width: 200px; + height: root.card-height; + + property overlay-on: hover.has-hover + || fav-btn.hovered || play-btn.hovered || more-btn.hovered; + + VerticalLayout { + alignment: start; + spacing: 0px; + + // --- Artwork + hover overlay ------------------------------------- + Rectangle { + width: 200px; + height: 200px; + border-radius: Radius.sm; + background: Theme.surface-elevated; + clip: true; + + Image { + width: 100%; + height: 100%; + source: root.track.artwork; + image-fit: cover; + } + + // Hover scrim. + Rectangle { + width: 100%; + height: 100%; + background: #000000; + opacity: root.overlay-on ? 0.6 : 0.0; + animate opacity { duration: 150ms; } + } + + // Card body — click PLAYS the track. Declared before the action + // buttons so those, when interactive, win the pointer. + hover := TouchArea { + width: 100%; + height: 100%; + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "play"); + } + } + + // Hover action buttons — favorite / play / more. + HorizontalLayout { + x: 0px; + y: 120px; + width: 200px; + height: 44px; + alignment: center; + spacing: 12px; + opacity: root.overlay-on ? 1.0 : 0.0; + animate opacity { duration: 150ms; } + + VerticalLayout { + alignment: center; + fav-btn := OverlayButton { + visible: root.show-favorite; + icon: root.track.is-favorite + ? @image-url("../assets/icons/heart-filled.svg") + : @image-url("../assets/icons/heart.svg"); + active: root.track.is-favorite; + clicked => { + root.media-action("track", root.track.id, "favorite"); + } + } + } + play-btn := OverlayButton { + icon: @image-url("../assets/icons/play-fill.svg"); + primary: true; + diameter: 44px; + icon-size: 18px; + clicked => { + root.media-action("track", root.track.id, "play"); + } + } + VerticalLayout { + alignment: center; + more-btn := OverlayButton { + icon: @image-url("../assets/icons/ellipsis.svg"); + clicked => { + track-menu.show(); + } + } + } + } + + // Always-visible source badge — bottom-right of the art. Small + // rounded square (ADR-008: not a pill). + if root.show-source-badge && root.track.source != "": Rectangle { + x: parent.width - self.width - 6px; + y: parent.height - self.height - 6px; + width: 24px; + height: 24px; + border-radius: 4px; + background: #000000b3; + Image { + source: root.track.source == "plex" + ? @image-url("../assets/icons/plex-logo.svg") + : (root.track.source == "qobuz" + ? @image-url("../assets/icons/qobuz-logo-filled.svg") + : @image-url("../assets/icons/hard-drive.svg")); + colorize: root.track.source == "local" ? #ffffff : transparent; + width: root.track.source == "plex" ? 16px + : (root.track.source == "qobuz" ? 18px : 14px); + height: self.width; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + image-fit: contain; + } + } + + // Context menu for the "more" button. + track-menu := PopupWindow { + x: 26px; + y: 56px; + width: 196px; + // Open-artist? Play / Play-next / Queue + favorite = 4-5 items. + height: 5 * 33px + 10px; + ContextMenu { + ContextMenuItem { + icon: @image-url("../assets/icons/play.svg"); + label: @tr("Play"); + clicked => { + root.media-action("track", root.track.id, "play"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/list-plus.svg"); + label: @tr("Play next"); + clicked => { + root.media-action("track", root.track.id, "play-next"); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/list-end.svg"); + label: @tr("Add to queue"); + clicked => { + root.media-action("track", root.track.id, "queue"); + } + } + if root.track.artist-id != "": ContextMenuItem { + icon: @image-url("../assets/icons/user.svg"); + label: @tr("Go to artist"); + clicked => { + root.media-action("artist", root.track.artist-id, "open"); + } + } + if root.show-favorite: ContextMenuItem { + icon: root.track.is-favorite + ? @image-url("../assets/icons/heart-filled.svg") + : @image-url("../assets/icons/heart.svg"); + label: root.track.is-favorite + ? @tr("Remove from Library") + : @tr("Add to Library"); + clicked => { + root.media-action("track", root.track.id, "favorite"); + } + } + } + } + } + + Rectangle { height: 6px; } + + // --- Title / "Track • Artist" + quality badge -------------------- + HorizontalLayout { + height: 40px; + spacing: Spacing.sm; + + VerticalLayout { + alignment: center; + spacing: 2px; + horizontal-stretch: 1; + + // Track title — clickable, plays the track (same as the body). + title-area := TouchArea { + height: 20px; + mouse-cursor: pointer; + clicked => { + root.media-action("track", root.track.id, "play"); + } + Text { + width: 100%; + text: root.track.title; + color: title-area.has-hover ? Theme.accent : Theme.text-primary; + font-size: Typography.body - 2px; + font-weight: Typography.medium; + vertical-alignment: center; + overflow: elide; + } + } + // "Track • Artist" subtitle — the artist portion links to the + // artist page when an id is known. + artist-area := TouchArea { + height: 18px; + mouse-cursor: root.track.artist-id != "" ? pointer : default; + clicked => { + if (root.track.artist-id != "") { + root.media-action("artist", root.track.artist-id, "open"); + } + } + Text { + width: 100%; + text: @tr("Track") + " • " + root.track.artist; + color: root.track.artist-id != "" && artist-area.has-hover + ? Theme.text-primary + : Theme.text-muted; + font-size: Typography.link - 1px; + vertical-alignment: center; + overflow: elide; + } + } + } + + // Icon-only quality badge. TrackItem has no pre-composed label, so + // the hover tooltip carries the bare detail line (e.g. "24-bit / + // 96 kHz") rather than AlbumCard's full "Hi-Res: …" string. + VerticalLayout { + alignment: center; + QualityBadge { + tier: root.track.quality-tier; + label: root.track.quality-detail; + detail: root.track.quality-detail; + } + } + } + } +} diff --git a/crates/qbz-ui/ui/favorites/FavoritesView.slint b/crates/qbz-ui/ui/favorites/FavoritesView.slint index 3a209d59c..0bc8f76da 100644 --- a/crates/qbz-ui/ui/favorites/FavoritesView.slint +++ b/crates/qbz-ui/ui/favorites/FavoritesView.slint @@ -11,6 +11,9 @@ import { Radius } from "../foundation/radius.slint"; import { FavoritesState, FavoritesActions, + LibraryAllState, + LibraryAllActions, + LibraryFeedItem, AlbumCardItem, TrackItem, FavoriteArtistItem, @@ -23,6 +26,9 @@ import { ShellState, } from "../state.slint"; import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; +import { AlbumCard } from "../discover/AlbumCard.slint"; +import { TrackCard } from "../discover/TrackCard.slint"; +import { ArtistGridCard } from "../discover/ArtistGridCard.slint"; import { PlaylistCard } from "../discover/PlaylistCard.slint"; import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; @@ -46,6 +52,7 @@ import { ListView } from "std-widgets.slint"; component FavTabMenu inherits SegmentedTabBar { active-id: FavoritesState.active-tab; tabs: [ + { id: "all", label: @tr("All"), count: LibraryAllState.total }, { id: "tracks", label: @tr("Tracks"), count: FavoritesState.tracks-total }, { id: "albums", label: @tr("Albums"), count: FavoritesState.albums-total }, { id: "artists", label: @tr("Artists"), count: FavoritesState.artists-total }, @@ -727,6 +734,69 @@ export component FavoritesView inherits Rectangle { } } } + // --- Library "All" mixed feed toolbar (search + source switches + + // genre + sort). Mirrors the webplayer /user-library/all header. --- + if FavoritesState.active-tab == "all": HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + ExpandableSearch { + placeholder: @tr("Search your library"); + sm: true; + query <=> LibraryAllState.search; + edited(q) => { + LibraryAllActions.search(q); + } + } + } + // Source switches — Purchases / Favorites / Following. + VerticalLayout { + alignment: center; + ToggleButton { + icon: @image-url("../assets/icons/shopping-bag.svg"); + active: LibraryAllState.show-purchases; + sm: true; + clicked => { LibraryAllActions.toggle-source("purchases"); } + } + } + VerticalLayout { + alignment: center; + ToggleButton { + icon: @image-url("../assets/icons/heart.svg"); + active: LibraryAllState.show-favorites; + sm: true; + clicked => { LibraryAllActions.toggle-source("favorites"); } + } + } + VerticalLayout { + alignment: center; + ToggleButton { + icon: @image-url("../assets/icons/user-plus.svg"); + active: LibraryAllState.show-following; + sm: true; + clicked => { LibraryAllActions.toggle-source("following"); } + } + } + VerticalLayout { + alignment: center; + QbzSelect { + menu-width: 150px; + options: [ + @tr("Date added"), + @tr("Title"), + @tr("Artist"), + ]; + sm: true; + current-index: LibraryAllState.sort-by == "date" ? 0 + : LibraryAllState.sort-by == "title" ? 1 + : 2; + selected(i) => { + LibraryAllActions.set-sort( + i == 0 ? "date" : i == 1 ? "title" : "artist"); + } + } + } + } } Rectangle { height: 14px; } @@ -792,6 +862,156 @@ export component FavoritesView inherits Rectangle { } } + // --- Library "All" — the mixed feed grid (webplayer parity) ---- + if FavoritesState.active-tab == "all": Rectangle { + vertical-stretch: 1; + clip: true; + // Loading. + if LibraryAllState.loading: VerticalLayout { + alignment: center; + HorizontalLayout { + alignment: center; + LoadingSpinner { size: 36px; } + } + } + // Error + retry. + if !LibraryAllState.loading && LibraryAllState.load-error != "": VerticalLayout { + alignment: center; + spacing: 10px; + Text { + text: @tr("Couldn't load your Library."); + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.semibold; + horizontal-alignment: center; + } + HorizontalLayout { + alignment: center; + Rectangle { + width: all-retry-label.preferred-width + 28px; + height: 32px; + border-radius: 6px; + background: all-retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + border-width: 1px; + border-color: Theme.border-subtle; + all-retry-label := Text { + text: @tr("Retry"); + color: Theme.text-primary; + font-size: Typography.legal; + x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; + y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; + } + all-retry-ta := TouchArea { + mouse-cursor: pointer; + clicked => { LibraryAllActions.retry(); } + } + } + } + } + // Grid — non-windowed flow (columns from width), one card per + // item keyed off `kind`, reusing the existing card components. + if !LibraryAllState.loading && LibraryAllState.load-error == "": Flickable { + viewport-height: all-grid.height + 32px; + all-grid := Rectangle { + property cw: 200px; + property ch: 246px; + property gap: 20px; + property columns: Math.max(1, + Math.floor((self.width + self.gap) / (self.cw + self.gap))); + property rows: Math.ceil( + LibraryAllState.items-visible.length / self.columns); + x: 32px; + y: 16px; + width: parent.width - 64px; + height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px; + + for item[i] in LibraryAllState.items-visible: Rectangle { + x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap); + y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap); + width: all-grid.cw; + height: all-grid.ch; + + if item.kind == "track": TrackCard { + track: { + id: item.id, + title: item.title, + artist: item.artist, + artist-id: item.artist-id, + album: item.album, + artwork: item.image, + quality-tier: item.quality-tier, + quality-detail: item.quality-detail, + is-favorite: item.is-favorite, + source: item.source, + }; + media-action(kind, id, action) => { + if (kind == "artist") { + FavoritesActions.open-artist(id); + } else { + FavoritesActions.track-action(id, action); + } + } + } + if item.kind == "album": AlbumCard { + album: { + id: item.id, + title: item.title, + artist: item.artist, + artist-id: item.artist-id, + artwork: item.image, + quality-tier: item.quality-tier, + quality-label: item.quality-detail, + }; + clicked(id) => { FavoritesActions.open-album(id); } + media-action(kind, id, action) => { + if (kind == "artist") { + FavoritesActions.open-artist(id); + } else { + FavoritesActions.album-action(id, action); + } + } + } + if item.kind == "artist": ArtistGridCard { + artist: { + id: item.id, + title: item.title, + artwork: item.image, + }; + follow-mode: "none"; + clicked(id) => { FavoritesActions.open-artist(id); } + } + if item.kind == "playlist": PlaylistCard { + playlist: { + id: item.id, + title: item.title, + subtitle: item.subtitle, + cover-count: 1, + url1: item.image-url, + cover1: item.image, + }; + body-opens: true; + media-action(kind, id, action) => { + if (action == "open") { + FavoritesActions.open-playlist(id); + } else { + FavoritesActions.playlist-action(id, action); + } + } + } + if item.kind == "label": FavLabelCard { + item: { + id: item.id, + name: item.title, + image: item.image, + image-url: item.image-url, + }; + clicked(id, name) => { FavoritesActions.open-label(id, name); } + } + } + } + } + } + // --- Tracks tab — virtualized self-scrolling list -------------- if !FavoritesState.loading && FavoritesState.load-error == "" && FavoritesState.active-tab == "tracks": Rectangle { diff --git a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint index 8616032ae..c79a79bde 100644 --- a/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint +++ b/crates/qbz-ui/ui/locallibrary/LocalLibraryView.slint @@ -1240,8 +1240,9 @@ export component LocalLibraryView inherits Rectangle { select-mode: LocalLibraryState.albums-multi-select; // List view shows the SOURCE column (local/offline/plex). show-source: true; - // Local albums aren't Qobuz-favoritable — hide the heart. - show-favorite: false; + // Local files + Plex are locally favoritable (Phase 2); + // offline-cache albums show the heart but it no-ops. + show-favorite: true; // Always-visible source badge (local | qobuz-offline | plex). show-source-badge: true; // Viewport windowing (Phase 1): only cards near the @@ -1581,7 +1582,7 @@ export component LocalLibraryView inherits Rectangle { is-grouped: LocalLibraryState.folders-group != "off"; view-mode: LocalLibraryState.folders-grid-view; show-source: false; - show-favorite: false; + show-favorite: true; show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); @@ -2380,7 +2381,7 @@ export component LocalLibraryView inherits Rectangle { is-grouped: false; view-mode: "grid"; show-source: false; - show-favorite: false; + show-favorite: true; show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); diff --git a/crates/qbz-ui/ui/shell/HeaderBar.slint b/crates/qbz-ui/ui/shell/HeaderBar.slint index f80227918..317861870 100644 --- a/crates/qbz-ui/ui/shell/HeaderBar.slint +++ b/crates/qbz-ui/ui/shell/HeaderBar.slint @@ -897,6 +897,7 @@ export component HeaderBar inherits Rectangle { label: @tr("Library"); show-icon: root.show-tab-icons; items: [ + { label: @tr("All"), icon: @image-url("../assets/icons/layout-grid.svg"), has-glyph: true, route: "favorites-all" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" }, @@ -1008,6 +1009,7 @@ export component HeaderBar inherits Rectangle { title: @tr("Library"); icon: @image-url("../assets/icons/music-library-2.svg"); items: [ + { label: @tr("All"), icon: @image-url("../assets/icons/layout-grid.svg"), has-glyph: true, route: "favorites-all" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" }, diff --git a/crates/qbz-ui/ui/shell/Sidebar.slint b/crates/qbz-ui/ui/shell/Sidebar.slint index 443efcd1e..b7ce75f73 100644 --- a/crates/qbz-ui/ui/shell/Sidebar.slint +++ b/crates/qbz-ui/ui/shell/Sidebar.slint @@ -742,6 +742,7 @@ export component Sidebar inherits Rectangle { icon: @image-url("../assets/icons/music-library-2.svg"); label: @tr("Library"); items: [ + { label: @tr("All"), icon: @image-url("../assets/icons/layout-grid.svg"), has-glyph: true, route: "favorites-all" }, { label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" }, { label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" }, { label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" }, diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index f6bc54fb6..043fe2c6b 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -3452,6 +3452,17 @@ export global ArtistState { // Similar artists from the /artist/page response — feed the Similar // Artists section of the network sidebar. in property <[SimilarEntry]> similar-artists: []; + // Curated playlists featuring this artist (the /artist/page `playlists` + // section). Rendered as a main-column PlaylistCarousel directly above the + // "Other" releases block. Covers filled by ArtworkTarget::ArtistPlaylistCover. + in property <[SearchPlaylistItem]> playlists: []; + // Catalog-vs-library toggle (webplayer "Del catálogo" / "En la biblioteca"). + // The toggle only shows when library-count > 0; "library" swaps the main + // column to the user's added items for this artist. + in-out property artist-tab: "catalog"; // "catalog" | "library" + in property library-count: 0; + in property <[TrackItem]> library-tracks: []; + in property <[AlbumCardItem]> library-albums: []; // Filtered JUMP TO tabs for this artist — only the sections // that actually have content. Built in artist::apply_artist // from the page response. anchor-y is the page-local y the @@ -4947,6 +4958,61 @@ export global FavoritesActions { callback retry-load(); } +// ── Library "All" — the mixed feed (webplayer /user-library/all) ── +// +// One heterogeneous item that renders as a track / album / artist / playlist / +// label card depending on `kind`. The mixed feed interleaves favorites, +// purchases and followed items sorted by date added. Sort/filter/source-switch +// are applied ENTIRELY in Rust (crate::library_all::derive) — Slint renders the +// pre-computed `items-visible`. +export struct LibraryFeedItem { + kind: string, // "track" | "album" | "artist" | "playlist" | "label" + group: string, // "favorites" | "following" | "purchases" + source: string, // "qobuz" | "local" | "plex" + id: string, + title: string, + subtitle: string, // "Track • Artist" / "year • artist" / owner·count / … + artist: string, + artist-id: string, + album: string, + image-url: string, + image: image, // decoded cover (empty until the pipeline resolves it) + quality-tier: string, // "hires" | "cd" | "" + quality-detail: string,// "24-bit / 96 kHz" + is-favorite: bool, + removing: bool, + sort-title: string, // lowercased title for the A-Z sort + sort-artist: string, // lowercased artist for the artist sort +} + +export global LibraryAllState { + in property <[LibraryFeedItem]> items: []; // full merged feed + in property <[LibraryFeedItem]> items-visible: []; // after search + source + sort + in-out property search: ""; + in-out property sort-by: "date"; // "date" | "title" | "artist" + in-out property view-mode: "grid"; // "grid" | "list" + // Source-switch filters — the three webplayer toggles. All on = show all. + in-out property show-purchases: true; + in-out property show-favorites: true; + in-out property show-following: true; + // "show local" — includes local files + Plex (Phase 2). Off until the + // local-favorites layer lands. + in-out property show-local: false; + in property total: 0; // badge count (deduped union) + in property loading: false; + in property load-error: ""; +} + +export global LibraryAllActions { + callback search(string /* query */); + callback set-sort(string /* date | title | artist */); + callback set-view(string /* grid | list */); + callback toggle-source(string /* purchases | favorites | following | local */); + callback open-item(string /* kind */, string /* id */); + callback media-action(string /* kind */, string /* id */, string /* action */); + callback retry(); +} + // ── Purchases (My-Purchases library surface, source-of-truth §2.1) ── // // A purchased album row/card. `quality-label` is the grid card's kHz form diff --git a/crates/qbz/src/album_map.rs b/crates/qbz/src/album_map.rs index 6e3c20ef8..fada7a99a 100644 --- a/crates/qbz/src/album_map.rs +++ b/crates/qbz/src/album_map.rs @@ -195,12 +195,15 @@ pub fn classify_release_type(track_count: Option) -> &'static str { /// (single-source Qobuz context — hide the column with `show-source: false`). pub fn to_item(card: AlbumCard) -> AlbumCardItem { AlbumCardItem { - // Favorite heart state from the login-seeded cache, so every card - // surface fed by this funnel (album suggestions, label, awards, …) + // Favorite heart state. Local/Plex albums read the local-favorites + // store (their composite keys never match a Qobuz favorite id); + // Qobuz albums read the login-seeded fav cache so every card surface // renders the filled heart in sync with the album-detail header. - // Local Library ids never match a Qobuz favorite id (and LL hides - // the heart), so the lookup is harmless there. - is_favorite: crate::fav_cache::is_album_favorite(&card.id), + is_favorite: if card.source == "local" || card.source == "plex" { + crate::local_favorites::is_favorite("album", &card.id) + } else { + crate::fav_cache::is_album_favorite(&card.id) + }, // Pin badge state from the per-user pinned store (kept live by // main::set_album_row_pinned when a pin toggles anywhere). is_pinned: crate::pinned::is_pinned("album", &card.id), diff --git a/crates/qbz/src/artist.rs b/crates/qbz/src/artist.rs index f3aaf85ee..194273f64 100644 --- a/crates/qbz/src/artist.rs +++ b/crates/qbz/src/artist.rs @@ -20,7 +20,7 @@ use crate::home::CardData; use crate::{ AlbumCardItem, TrackItem, AppWindow, ArtistReleaseSection, ArtistState, DiscoveryArtist, JumpNavTab, LabelEntry, MbOriginData, MbRelationship, MbRelationshipsData, - NetworkSidebarState, SettingsState, ShellState, SimilarEntry, StoryItem, + NetworkSidebarState, SearchPlaylistItem, SettingsState, ShellState, SimilarEntry, StoryItem, }; /// Plain, `Send` artist data produced on the worker thread. @@ -48,6 +48,18 @@ pub struct ArtistData { pub labels: Vec, /// Similar artists from /artist/page — sidebar Similar Artists. pub similar_artists: Vec, + /// Curated playlists featuring this artist (the /artist/page `playlists` + /// section) — main-column Playlists carousel, above the "Other" block. + pub playlists: Vec, +} + +/// One curated playlist card for the artist Playlists carousel. +#[derive(Clone)] +pub struct PlaylistSlim { + pub id: String, + pub title: String, + pub subtitle: String, + pub image_url: String, } #[derive(Clone)] @@ -362,6 +374,36 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { }) .unwrap_or_default(); + // Curated playlists featuring this artist (the /artist/page `playlists` + // section) — rendered as a main-column carousel above the "Other" block. + let playlists: Vec = page + .playlists + .map(|p| { + p.items + .into_iter() + .map(|pl| { + let owner = pl + .owner + .and_then(|o| o.name) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Qobuz".to_string()); + let track_count = pl.tracks_count.unwrap_or(0); + let image_url = pl + .images + .and_then(|imgs| imgs.rectangle) + .and_then(|rects| rects.into_iter().find(|s| !s.is_empty())) + .unwrap_or_default(); + PlaylistSlim { + id: pl.id.to_string(), + title: pl.title.unwrap_or_default(), + subtitle: format!("{owner} · {track_count}"), + image_url, + } + }) + .collect() + }) + .unwrap_or_default(); + // "Novedad más reciente" — the single latest-release highlight. // Drop a blocked "Latest release" at the SOURCE so has_last_release is // false (section hidden) and no stale cover job is queued. @@ -435,6 +477,7 @@ fn map_artist(page: PageArtistResponse) -> ArtistData { release_sections, labels, similar_artists, + playlists, } } @@ -499,6 +542,15 @@ pub fn artwork_jobs(data: &ArtistData) -> Vec { }); } } + // Curated playlist covers (single rectangle cover per card). + for (i, playlist) in data.playlists.iter().enumerate() { + if !playlist.image_url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::ArtistPlaylistCover { index: i }, + url: playlist.image_url.clone(), + }); + } + } jobs } @@ -790,6 +842,8 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { name: sa.name.into(), }) .collect(); + let playlists: Vec = + data.playlists.iter().map(playlist_to_item).collect(); // Cache the full models on the UI thread so the in-page search // can rebuild filtered views without re-fetching the artist. @@ -821,9 +875,33 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { state.set_release_sections(ModelRc::new(VecModel::from(release_sections))); state.set_labels(ModelRc::new(VecModel::from(labels))); state.set_similar_artists(ModelRc::new(VecModel::from(similar_artists))); + state.set_playlists(ModelRc::new(VecModel::from(playlists))); state.set_jump_tabs(ModelRc::new(VecModel::from(jump_tabs))); } +/// Map an artist-page curated playlist to the shared collage card model +/// (single cover, slot 0 — filled by ArtworkTarget::ArtistPlaylistCover). +/// Mirrors `label::playlist_to_item`. +fn playlist_to_item(p: &PlaylistSlim) -> SearchPlaylistItem { + SearchPlaylistItem { + id: p.id.clone().into(), + title: p.title.clone().into(), + subtitle: p.subtitle.clone().into(), + is_pinned: crate::pinned::is_pinned("playlist", &p.id), + cover_count: if p.image_url.is_empty() { 0 } else { 1 }, + url1: p.image_url.clone().into(), + url2: "".into(), + url3: "".into(), + url4: "".into(), + cover1: slint::Image::default(), + cover2: slint::Image::default(), + cover3: slint::Image::default(), + cover4: slint::Image::default(), + category: "".into(), + dominant_color: slint::Color::from_argb_u8(0, 0, 0, 0), + } +} + /// Build the JUMP TO tab list for this artist. Tabs are emitted only /// for sections that actually have content (no empty Compilations /// row when the artist has none); each tab carries a page-local @@ -1077,6 +1155,14 @@ pub fn reset_artist(window: &AppWindow) { state.set_top_tracks_multi_select(false); state.set_top_tracks_selected_count(0); state.set_is_blacklisted(false); + state.set_playlists(ModelRc::new(VecModel::from(Vec::::new()))); + // Catalog/library toggle — reset so an artist WITHOUT library items (apply + // only seeds these when the index has the artist) never shows the previous + // artist's count/subset. + state.set_artist_tab("catalog".into()); + state.set_library_count(0); + state.set_library_tracks(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_library_albums(ModelRc::new(VecModel::from(Vec::::new()))); state.set_loading(true); } diff --git a/crates/qbz/src/artwork.rs b/crates/qbz/src/artwork.rs index f8fcffef5..c73397b8c 100644 --- a/crates/qbz/src/artwork.rs +++ b/crates/qbz/src/artwork.rs @@ -127,6 +127,13 @@ pub enum ArtworkTarget { /// the row's album-cover thumbnail only paints once this job decodes the /// `artwork_url` bytes into the row's `artwork` field (#631). ArtistTopTrack { index: usize }, + /// A curated playlist card in `ArtistState.playlists[index]` (the + /// main-column Playlists carousel). Single cover (slot 0), like + /// `LabelPlaylistCover`. + ArtistPlaylistCover { index: usize }, + /// A card in the Library "All" mixed feed (`LibraryAllState.items-visible[index]`). + /// Dispatched against the VISIBLE model, re-dispatched on each derive. + LibraryAllCover { index: usize }, /// A card in MusicianState.appearances[index]. MusicianAppearance { index: usize }, /// A card in LabelState.albums[index] (releases sub-view grid). @@ -1214,6 +1221,20 @@ fn apply_artwork( model.set_row_data(index, item); } } + ArtworkTarget::ArtistPlaylistCover { index } => { + let model = window.global::().get_playlists(); + if let Some(mut item) = model.row_data(index) { + item.cover1 = image; + model.set_row_data(index, item); + } + } + ArtworkTarget::LibraryAllCover { index } => { + let model = window.global::().get_items_visible(); + if let Some(mut item) = model.row_data(index) { + item.image = image; + model.set_row_data(index, item); + } + } ArtworkTarget::ArtistStory { index } => { let model = window.global::().get_stories(); if let Some(mut item) = model.row_data(index) { diff --git a/crates/qbz/src/library_all.rs b/crates/qbz/src/library_all.rs new file mode 100644 index 000000000..48c90cf27 --- /dev/null +++ b/crates/qbz/src/library_all.rs @@ -0,0 +1,402 @@ +//! Library "All" — the mixed feed controller (webplayer /user-library/all). +//! +//! There is NO single Qobuz endpoint for the aggregated library; the webplayer +//! merges favorites + purchases + playlists client-side. We do the same: fan out +//! to the existing per-type loaders, normalize each into a `Feed` item, merge and +//! order by "date added" (approximated from each source's server order), then push +//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust +//! (`derive`) — Slint renders the pre-computed `items-visible`. + +use std::sync::Arc; + +use qbz_app::shell::AppRuntime; +use slint::{ComponentHandle, Model, ModelRc, VecModel}; + +use crate::adapter::SlintAdapter; +use crate::artwork::{ArtworkJob, ArtworkTarget}; +use crate::favorites::{self, FavData, FavTab}; +use crate::{AppWindow, LibraryAllState, LibraryFeedItem}; + +type Runtime = Arc>; + +/// Plain, `Send` feed item produced on the worker thread. +#[derive(Clone)] +pub struct Feed { + pub kind: String, // track | album | artist | playlist | label + pub group: String, // favorites | following | purchases + pub source: String, // qobuz | local | plex + pub id: String, + pub title: String, + pub subtitle: String, + pub artist: String, + pub artist_id: String, + pub album: String, + pub image_url: String, + pub quality_tier: String, + pub quality_detail: String, + pub is_favorite: bool, + /// Recency proxy in [0.0, 1.0]; 0.0 = most-recently added. Each source list + /// comes back date-desc, so `index / len` interleaves the sources by recency + /// without needing exact per-item timestamps. + pub added_rank: f32, +} + +fn rank(i: usize, n: usize) -> f32 { + if n <= 1 { + 0.0 + } else { + i as f32 / n as f32 + } +} + +/// Fan out to every source, normalize + merge into one date-ordered feed. +/// Qobuz-only for now (favorites + following + purchases); local/Plex arrive +/// with the Phase 2 local-favorites layer behind the `show-local` switch. +pub async fn load_library_all(runtime: &Runtime) -> Result, String> { + let mut feed: Vec = Vec::new(); + + // --- Favorites: tracks + albums (group "favorites") ------------------- + if let Ok(FavData::Tracks { items, .. }) = + favorites::load_favorites(runtime, FavTab::Tracks).await + { + let n = items.len(); + for (i, t) in items.into_iter().enumerate() { + feed.push(Feed { + kind: "track".into(), + group: "favorites".into(), + source: "qobuz".into(), + subtitle: t.artist.clone(), + artist: t.artist, + artist_id: t.artist_id, + album: t.album, + image_url: t.artwork_url, + quality_tier: t.quality_tier, + quality_detail: t.quality_detail, + is_favorite: true, + added_rank: rank(i, n), + id: t.id, + title: t.title, + }); + } + } + if let Ok(FavData::Albums { items, .. }) = + favorites::load_favorites(runtime, FavTab::Albums).await + { + let n = items.len(); + for (i, a) in items.into_iter().enumerate() { + feed.push(Feed { + kind: "album".into(), + group: "favorites".into(), + source: "qobuz".into(), + subtitle: a.artist.clone(), + artist: a.artist, + artist_id: a.artist_id, + album: String::new(), + image_url: a.artwork_url, + quality_tier: a.quality_tier, + quality_detail: a.quality_detail, + is_favorite: true, + added_rank: rank(i, n), + id: a.id, + title: a.title, + }); + } + } + + // --- Following: artists + labels (group "following") ------------------ + if let Ok(FavData::Artists { items, .. }) = + favorites::load_favorites(runtime, FavTab::Artists).await + { + let n = items.len(); + for (i, ar) in items.into_iter().enumerate() { + feed.push(Feed { + kind: "artist".into(), + group: "following".into(), + source: "qobuz".into(), + subtitle: String::new(), + artist: String::new(), + artist_id: ar.id.clone(), + album: String::new(), + image_url: ar.image_url, + quality_tier: String::new(), + quality_detail: String::new(), + is_favorite: true, + added_rank: rank(i, n), + id: ar.id, + title: ar.name, + }); + } + } + if let Ok(FavData::Labels { items, .. }) = + favorites::load_favorites(runtime, FavTab::Labels).await + { + let n = items.len(); + for (i, l) in items.into_iter().enumerate() { + feed.push(Feed { + kind: "label".into(), + group: "following".into(), + source: "qobuz".into(), + subtitle: l.albums_line, + artist: String::new(), + artist_id: String::new(), + album: String::new(), + image_url: l.image_url, + quality_tier: String::new(), + quality_detail: String::new(), + is_favorite: true, + added_rank: rank(i, n), + id: l.id, + title: l.name, + }); + } + } + + // --- Playlists: owned/hearted = favorites, followed = following ------- + if let Ok(FavData::Playlists { + favorites: fav_pl, + following: fol_pl, + }) = favorites::load_favorites(runtime, FavTab::Playlists).await + { + let n = fav_pl.len(); + for (i, p) in fav_pl.into_iter().enumerate() { + let image_url = p.cover_urls.into_iter().next().unwrap_or_default(); + feed.push(Feed { + kind: "playlist".into(), + group: "favorites".into(), + source: "qobuz".into(), + subtitle: p.subtitle, + artist: String::new(), + artist_id: String::new(), + album: String::new(), + image_url, + quality_tier: String::new(), + quality_detail: String::new(), + is_favorite: true, + added_rank: rank(i, n), + id: p.id, + title: p.title, + }); + } + let n = fol_pl.len(); + for (i, p) in fol_pl.into_iter().enumerate() { + let image_url = p.cover_urls.into_iter().next().unwrap_or_default(); + feed.push(Feed { + kind: "playlist".into(), + group: "following".into(), + source: "qobuz".into(), + subtitle: p.subtitle, + artist: String::new(), + artist_id: String::new(), + album: String::new(), + image_url, + quality_tier: String::new(), + quality_detail: String::new(), + is_favorite: false, + added_rank: rank(i, n), + id: p.id, + title: p.title, + }); + } + } + + // --- Purchases: albums + tracks (group "purchases") ------------------- + // `search_purchases("")` returns the full owned set (both types). + if let Ok((albums, tracks)) = crate::purchases::search_purchases(runtime, "").await { + let n = albums.len(); + for (i, a) in albums.into_iter().enumerate() { + let image_url = a.image.best().cloned().unwrap_or_default(); + let tier = if a.hires { "hires" } else { "cd" }; + feed.push(Feed { + kind: "album".into(), + group: "purchases".into(), + source: "qobuz".into(), + subtitle: a.artist.name.clone(), + artist: a.artist.name, + artist_id: a.artist.id.to_string(), + album: String::new(), + image_url, + quality_tier: tier.into(), + quality_detail: String::new(), + is_favorite: false, + added_rank: rank(i, n), + id: a.id, + title: a.title, + }); + } + let n = tracks.len(); + for (i, t) in tracks.into_iter().enumerate() { + let (artist, image_url, album) = { + let artist = t.performer.name.clone(); + let (img, alb) = t + .album + .as_ref() + .map(|a| (a.image.best().cloned().unwrap_or_default(), a.title.clone())) + .unwrap_or_default(); + (artist, img, alb) + }; + let tier = if t.hires { "hires" } else { "cd" }; + feed.push(Feed { + kind: "track".into(), + group: "purchases".into(), + source: "qobuz".into(), + subtitle: artist.clone(), + artist, + artist_id: t.performer.id.to_string(), + album, + image_url, + quality_tier: tier.into(), + quality_detail: String::new(), + is_favorite: false, + added_rank: rank(i, n), + id: t.id.to_string(), + title: t.title, + }); + } + } + + // --- Local + Plex favorites (source "local"/"plex"; gated by show-local + // in derive). group "local" — bypasses the Qobuz source switches. --- + { + let locals = crate::local_favorites::list(); + let n = locals.len(); + for (i, lf) in locals.into_iter().enumerate() { + feed.push(Feed { + kind: lf.kind, + group: "local".into(), + source: lf.source, + subtitle: lf.subtitle, + artist: lf.artist.clone(), + artist_id: String::new(), + album: String::new(), + image_url: lf.artwork_url, + quality_tier: String::new(), + quality_detail: String::new(), + is_favorite: true, + added_rank: rank(i, n), + id: lf.id, + title: lf.title, + }); + } + } + + // Merge by recency proxy (stable so equal ranks keep source order). + feed.sort_by(|a, b| { + a.added_rank + .partial_cmp(&b.added_rank) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(feed) +} + +fn to_item(f: &Feed) -> LibraryFeedItem { + LibraryFeedItem { + kind: f.kind.clone().into(), + group: f.group.clone().into(), + source: f.source.clone().into(), + id: f.id.clone().into(), + title: f.title.clone().into(), + subtitle: f.subtitle.clone().into(), + artist: f.artist.clone().into(), + artist_id: f.artist_id.clone().into(), + album: f.album.clone().into(), + image_url: f.image_url.clone().into(), + image: slint::Image::default(), + quality_tier: f.quality_tier.clone().into(), + quality_detail: f.quality_detail.clone().into(), + is_favorite: f.is_favorite, + removing: false, + sort_title: f.title.to_lowercase().into(), + sort_artist: f.artist.to_lowercase().into(), + } +} + +/// Push the full merged feed into `LibraryAllState` and derive the first view. +pub fn apply_library_all(window: &AppWindow, feed: Vec) { + let items: Vec = feed.iter().map(to_item).collect(); + let total = items.len() as i32; + let st = window.global::(); + st.set_items(ModelRc::new(VecModel::from(items))); + st.set_total(total); + st.set_loading(false); + st.set_load_error("".into()); + derive(window); +} + +/// Apply search + source-switch + sort over the full model into `items-visible`. +/// Runs on the Slint event loop; Slint never sorts/filters. +pub fn derive(window: &AppWindow) { + let st = window.global::(); + let needle = st.get_search().to_lowercase(); + let show_purchases = st.get_show_purchases(); + let show_favorites = st.get_show_favorites(); + let show_following = st.get_show_following(); + let show_local = st.get_show_local(); + let sort_by = st.get_sort_by(); + + let full = st.get_items(); + let mut out: Vec = Vec::new(); + for i in 0..full.row_count() { + let Some(item) = full.row_data(i) else { + continue; + }; + let src = item.source.as_str(); + let is_local = src == "local" || src == "plex"; + if is_local { + // Local files + Plex are gated ONLY by the show-local switch; they + // bypass the Qobuz purchases/favorites/following switches. + if !show_local { + continue; + } + } else { + // Qobuz source switches: an item shows when its group's switch is on. + // If ALL three are off, treat as "no filter" (show everything) to + // avoid an empty grid from an accidental all-off state. + let any_group = show_purchases || show_favorites || show_following; + let group = item.group.as_str(); + let group_ok = !any_group + || (group == "purchases" && show_purchases) + || (group == "favorites" && show_favorites) + || (group == "following" && show_following); + if !group_ok { + continue; + } + } + if !needle.is_empty() { + let hit = item.sort_title.as_str().contains(&needle) + || item.sort_artist.as_str().contains(&needle); + if !hit { + continue; + } + } + out.push(item); + } + + match sort_by.as_str() { + "title" => out.sort_by(|a, b| a.sort_title.as_str().cmp(b.sort_title.as_str())), + "artist" => out.sort_by(|a, b| a.sort_artist.as_str().cmp(b.sort_artist.as_str())), + // "date" keeps the merged recency order from load. + _ => {} + } + + st.set_items_visible(ModelRc::new(VecModel::from(out))); +} + +/// Build cover-download jobs for the CURRENT visible feed. Call after apply and +/// after every derive (the ImageCache dedups already-decoded covers, so +/// re-dispatching on filter/sort is cheap). Indices target `items-visible`. +pub fn artwork_jobs(window: &AppWindow) -> Vec { + let visible = window.global::().get_items_visible(); + let mut jobs = Vec::new(); + for i in 0..visible.row_count() { + if let Some(item) = visible.row_data(i) { + let url = item.image_url.to_string(); + if !url.is_empty() { + jobs.push(ArtworkJob { + target: ArtworkTarget::LibraryAllCover { index: i }, + url, + }); + } + } + } + jobs +} diff --git a/crates/qbz/src/library_by_artist.rs b/crates/qbz/src/library_by_artist.rs new file mode 100644 index 000000000..e454281c8 --- /dev/null +++ b/crates/qbz/src/library_by_artist.rs @@ -0,0 +1,124 @@ +//! In-memory per-artist library index for the ArtistPage catalog/library toggle. +//! +//! Qobuz has NO server-side "library items by artist" endpoint, so we build a +//! process-wide index once per session (seeded at login, off the UI thread, like +//! `fav_cache`) from the user's favorite tracks + albums, grouped by artist id. +//! `navigate_artist` then does an O(1) lookup to decide whether the toggle shows +//! and to fill the "En la biblioteca" subset. Favorites-only for now (matches the +//! webplayer's "Pistas añadidas" / "Álbumes añadidos"); purchases + local fold in +//! as a follow-up. +//! +//! NOTE: the index is a session snapshot — favoriting/unfavoriting during the +//! session is not reflected until the next login (acceptable v1). + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use qbz_app::shell::AppRuntime; +use qbz_core::FrontendAdapter; +use slint::{ModelRc, VecModel}; + +use crate::album_map::{self, AlbumCard}; +use crate::favorites::{self, FavData, FavTab, TrackCard}; +use crate::{AlbumCardItem, TrackItem}; + +/// One artist's library items (favorites, this session). +#[derive(Default, Clone)] +pub struct ArtistLibrary { + pub tracks: Vec, + pub albums: Vec, +} + +impl ArtistLibrary { + pub fn count(&self) -> usize { + self.tracks.len() + self.albums.len() + } +} + +static INDEX: RwLock>> = RwLock::new(None); + +/// Seed the index from favorite tracks + albums, grouped by artist id. Idempotent +/// (replaces any prior snapshot). Best-effort: a failed fetch leaves the artist +/// out (its toggle simply won't appear). +pub async fn seed(runtime: &Arc>) +where + A: FrontendAdapter + Send + Sync + 'static, +{ + let mut map: HashMap = HashMap::new(); + + if let Ok(FavData::Tracks { items, .. }) = + favorites::load_favorites(runtime, FavTab::Tracks).await + { + for t in items { + if !t.artist_id.is_empty() { + map.entry(t.artist_id.clone()).or_default().tracks.push(t); + } + } + } + if let Ok(FavData::Albums { items, .. }) = + favorites::load_favorites(runtime, FavTab::Albums).await + { + for a in items { + if !a.artist_id.is_empty() { + map.entry(a.artist_id.clone()).or_default().albums.push(a); + } + } + } + + if let Ok(mut guard) = INDEX.write() { + *guard = Some(map); + } +} + +/// O(1) lookup of one artist's library items. `None` (or empty) when the artist +/// has nothing in the user's library / the index isn't seeded yet. +pub fn get(artist_id: &str) -> Option { + INDEX + .read() + .ok() + .and_then(|g| g.as_ref().and_then(|m| m.get(artist_id).cloned())) +} + +/// Convert stored album cards to the Slint model (reuses `album_map::to_item`, +/// which stamps favorite/pin state). +pub fn album_items(albums: &[AlbumCard]) -> ModelRc { + let rows: Vec = albums.iter().cloned().map(album_map::to_item).collect(); + ModelRc::new(VecModel::from(rows)) +} + +/// Convert stored track cards to the Slint model (mirrors the favorites +/// apply_favorites mapping; everything here is, by definition, a favorite). +pub fn track_items(tracks: &[TrackCard]) -> ModelRc { + let rows: Vec = tracks + .iter() + .cloned() + .map(|t| TrackItem { + is_blacklisted: false, + id: t.id.clone().into(), + number: "".into(), + title: t.title.into(), + artist: t.artist.into(), + album: t.album.into(), + duration: t.duration.into(), + quality_tier: t.quality_tier.into(), + quality_detail: t.quality_detail.into(), + explicit: t.explicit, + selected: false, + artwork_url: t.artwork_url.into(), + artwork: slint::Image::default(), + is_favorite: true, + artist_id: t.artist_id.into(), + album_id: t.album_id.into(), + removing: false, + cache_status: 0, + cache_progress: 0.0, + source: "qobuz".into(), + unlocking: false, + disc_header_number: 0, + work_header: "".into(), + work_composer_name: "".into(), + work_composer_id: "".into(), + }) + .collect(); + ModelRc::new(VecModel::from(rows)) +} diff --git a/crates/qbz/src/local_favorites.rs b/crates/qbz/src/local_favorites.rs new file mode 100644 index 000000000..8587a8210 --- /dev/null +++ b/crates/qbz/src/local_favorites.rs @@ -0,0 +1,103 @@ +//! Per-user local-favorites lifecycle + access wrapper. +//! +//! Process-global singleton over the headless +//! `qbz_app::settings::local_favorites::LocalFavoritesService` (ADR-006). Lets +//! the user favorite LOCAL library items (genuine local files + Plex — never the +//! Qobuz offline cache) so they appear in the mixed-library "All" feed behind the +//! `show-local` switch. Mirrors `crate::pinned`: a `Mutex>` bound +//! per session, in-memory `(kind, id)` set for O(1) heart stamping, fail-open +//! when no session is bound. + +use std::collections::HashSet; +use std::path::Path; +use std::sync::Mutex; + +use qbz_app::settings::local_favorites::{LocalFavoritesService, DB_FILE_NAME}; + +pub use qbz_app::settings::local_favorites::LocalFavItem; + +static SERVICE: Mutex> = Mutex::new(None); + +const NO_SESSION_ERR: &str = "No active session - please log in"; + +/// Bind the per-user store from `/local_favorites.db`. Called on every +/// session activation (login, restore, offline entry), next to +/// `crate::pinned::init_for_user`. Fail-open on error (nothing favorited). +pub fn init_for_user(base_dir: &Path) { + let db_path = base_dir.join(DB_FILE_NAME); + match LocalFavoritesService::new(&db_path) { + Ok(service) => { + if let Ok(mut guard) = SERVICE.lock() { + *guard = Some(service); + } + } + Err(e) => log::error!("[qbz-slint] local favorites store open failed: {e}"), + } +} + +/// Drop the per-user store on logout. +pub fn teardown() { + if let Ok(mut guard) = SERVICE.lock() { + *guard = None; + } +} + +fn with_service(default: T, f: impl FnOnce(&LocalFavoritesService) -> T) -> T { + SERVICE + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(f)) + .unwrap_or(default) +} + +/// True when the local `(kind, id)` item is favorited. Fail-open `false`. +pub fn is_favorite(kind: &str, id: &str) -> bool { + with_service(false, |s| s.is_favorite(kind, id)) +} + +/// All local favorites, newest first (for the mixed feed loader). +pub fn list() -> Vec { + with_service(Vec::new(), |s| s.list().unwrap_or_default()) +} + +/// Per-artist favorite counts, for the ArtistPage library toggle. +pub fn count_by_artist() -> Vec<(String, i64)> { + with_service(Vec::new(), |s| s.count_by_artist().unwrap_or_default()) +} + +/// Snapshot of the full `(kind, id)` key set, for bulk card stamping. +#[allow(dead_code)] +pub fn keys_snapshot() -> HashSet<(String, String)> { + with_service(HashSet::new(), |s| s.keys_snapshot()) +} + +fn mutate(f: impl FnOnce(&LocalFavoritesService) -> Result<(), String>) -> Result<(), String> { + match SERVICE.lock() { + Ok(guard) => match guard.as_ref() { + Some(service) => f(service), + None => Err(NO_SESSION_ERR.into()), + }, + Err(_) => Err(NO_SESSION_ERR.into()), + } +} + +/// Favorite a local item (upsert). +pub fn favorite(item: &LocalFavItem) -> Result<(), String> { + mutate(|s| s.favorite(item)) +} + +/// Unfavorite a local item. Absent rows are Ok. +pub fn unfavorite(kind: &str, id: &str) -> Result<(), String> { + mutate(|s| s.unfavorite(kind, id)) +} + +/// Toggle: returns the NEW favorite state (`true` = now favorited). +pub fn toggle(item: &LocalFavItem) -> Result { + if is_favorite(&item.kind, &item.id) { + unfavorite(&item.kind, &item.id)?; + Ok(false) + } else { + favorite(item)?; + Ok(true) + } +} diff --git a/crates/qbz/src/local_library.rs b/crates/qbz/src/local_library.rs index f3367d6b9..bbe5334d0 100644 --- a/crates/qbz/src/local_library.rs +++ b/crates/qbz/src/local_library.rs @@ -992,7 +992,16 @@ fn map_local_track(t: qbz_library::LocalTrack) -> TrackItem { selected: false, artwork_url: t.artwork_path.unwrap_or_default().into(), artwork: slint::Image::default(), - is_favorite: false, + // Local/Plex favorite state from the local-favorites store, keyed by the + // stable track key (file_path, or plex: for Plex). Offline / + // ephemeral rows are not locally favoritable. + is_favorite: match t.source.as_deref() { + Some("plex") => { + crate::local_favorites::is_favorite("track", &format!("plex:{}", t.file_path)) + } + Some("qobuz_download") | Some("ephemeral") => false, + _ => crate::local_favorites::is_favorite("track", &t.file_path), + }, artist_id: "".into(), album_id: "".into(), removing: false, @@ -1428,6 +1437,61 @@ fn album_is_selected(window: &AppWindow, id: &str) -> bool { found } +/// Toggle the local-favorite state of a local/Plex album by its composite key. +/// Reads the album's display snapshot from the rendered model, writes to the +/// local-favorites store (genuine local/Plex only — offline-cache albums are +/// skipped), and optimistically flips the heart on every rendered model. +pub fn toggle_album_favorite(window: &AppWindow, id: &str) { + let mut snap: Option<(String, String, String, String)> = None; // title, artist, artwork, source + for_each_album_model(window, |m| { + if snap.is_some() { + return; + } + for i in 0..m.row_count() { + if let Some(a) = m.row_data(i) { + if a.id.as_str() == id { + snap = Some(( + a.title.to_string(), + a.artist.to_string(), + a.artwork_url.to_string(), + a.source.to_string(), + )); + return; + } + } + } + }); + let Some((title, artist, artwork_url, source)) = snap else { + return; + }; + // Only genuine local files + Plex are locally favoritable (never the Qobuz + // offline cache). + if source != "local" && source != "plex" { + return; + } + let item = crate::local_favorites::LocalFavItem { + kind: "album".into(), + id: id.to_string(), + title, + subtitle: String::new(), + artwork_url, + artist, + source, + favorited_at: 0, + }; + let new_state = crate::local_favorites::toggle(&item).unwrap_or(false); + for_each_album_model(window, |m| { + for i in 0..m.row_count() { + if let Some(mut a) = m.row_data(i) { + if a.id.as_str() == id && a.is_favorite != new_state { + a.is_favorite = new_state; + m.set_row_data(i, a); + } + } + } + }); +} + /// Recount distinct selected albums into `albums-selected-count`. pub fn recount_albums_selected(window: &AppWindow) { let mut seen = std::collections::HashSet::new(); diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index e30dbd23a..7303cf885 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -55,6 +55,8 @@ mod immersive; mod info_modals; mod keybindings; mod label; +mod library_all; +mod library_by_artist; mod link_resolver; mod miniplayer; mod location_view; @@ -86,6 +88,7 @@ mod drag; mod ephemeral; mod folders; mod library_db; +mod local_favorites; mod local_library; mod local_playlist; mod local_library_settings; @@ -422,6 +425,19 @@ async fn enter_shell( }); } + // Seed the per-artist library index so the ArtistPage catalog/library toggle + // can decide (O(1)) whether the user has items for that artist. Favorites- + // only (tracks + albums), once per session, off the UI thread. + { + let runtime = runtime.clone(); + tokio::spawn(async move { + if crate::offline_mode::engine().is_offline() { + return; + } + crate::library_by_artist::seed(&runtime).await; + }); + } + // Same for followed AWARDS — seeds fav_cache so the AwardView follow heart // is correct from first open (Tauri's loadAwardFavorites, plural read). { @@ -632,6 +648,7 @@ async fn enter_shell_offline( crate::artist_blacklist::init_for_user(&dir); // Pinned items are local-only and must render offline too. crate::pinned::init_for_user(&dir); + crate::local_favorites::init_for_user(&dir); // Intelligent Search (cache + ranking), seeded from the persisted pref. // Cached results stay searchable offline; live revalidation no-ops. crate::search_service::init(&dir, crate::ui_prefs::load().intelligent_search); @@ -2260,8 +2277,17 @@ fn navigate_artist( .iter() .map(|s| s.name.clone()) .collect(); + let aid_lib = artist_id.clone(); let _ = weak.upgrade_in_event_loop(move |w| { artist::apply_artist(&w, data); + // Seed the catalog/library toggle from the per-artist index + // (favorites this session). The toggle only shows if count>0. + if let Some(lib) = crate::library_by_artist::get(&aid_lib) { + let ast = w.global::(); + ast.set_library_count(lib.count() as i32); + ast.set_library_tracks(crate::library_by_artist::track_items(&lib.tracks)); + ast.set_library_albums(crate::library_by_artist::album_items(&lib.albums)); + } w.global::().set_loading(false); }); artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); @@ -3615,6 +3641,50 @@ fn navigate_favorites( }); } +/// Navigate to the Library "All" mixed feed (webplayer /user-library/all). +/// Fans out to every source, merges + orders by date added, then applies + +/// dispatches cover artwork. Rendered by the FavoritesView `active-tab == "all"` +/// branch reading `LibraryAllState`. +fn navigate_library_all( + runtime: Arc>, + weak: slint::Weak, + handle: &tokio::runtime::Handle, + image_cache: artwork::ImageCache, +) { + handle.spawn(async move { + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_active_tab("all".into()); + w.global::().set_view(ContentView::Favorites); + let st = w.global::(); + st.set_loading(true); + st.set_load_error("".into()); + // The genre popup edits the favorites context on this surface too. + genre_filter::set_context("favorites"); + genre_filter::apply_state(&w); + }); + match library_all::load_library_all(&runtime).await { + Ok(feed) => { + let weak_j = weak.clone(); + let ic = image_cache.clone(); + let _ = weak.upgrade_in_event_loop(move |w| { + library_all::apply_library_all(&w, feed); + let jobs = library_all::artwork_jobs(&w); + artwork::spawn_loads(jobs, weak_j.clone(), ic.clone()); + }); + } + Err(e) => { + log::error!("[qbz-slint] library-all load failed: {e}"); + let msg = e.to_string(); + let _ = weak.upgrade_in_event_loop(move |w| { + let st = w.global::(); + st.set_loading(false); + st.set_load_error(msg.into()); + }); + } + } + }); +} + /// Navigate to the LocalLibrary Artists tab and auto-select `name`. Local/Plex /// artists have no id — they're keyed by NAME. The selection is latched and /// consumed by `ensure_artists_loaded` once the tab's data is ready (handles @@ -16469,6 +16539,21 @@ fn main() -> Result<(), Box> { } return; } + if route.as_str() == "favorites-all" { + nav::record(nav::NavEntry::Favorites { + tab: "all".to_string(), + }); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + navigate_library_all( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + ); + return; + } if let Some(tab) = favorites::FavTab::from_route(route.as_str()) { let tab_id = route.strip_prefix("favorites-").unwrap_or("tracks"); nav::record(nav::NavEntry::Favorites { @@ -17358,6 +17443,11 @@ fn main() -> Result<(), Box> { local_library::toggle_album_select(&w, id.as_str()); } } + "favorite" => { + if let Some(w) = weak.upgrade() { + local_library::toggle_album_favorite(&w, id.as_str()); + } + } _ => { // Single-album play-next / queue land with a later slice. log::debug!("[qbz-slint] local album action (queue slice pending): {id} {action}"); @@ -20249,6 +20339,21 @@ fn main() -> Result<(), Box> { window .global::() .on_select_tab(move |id| { + if id.as_str() == "all" { + nav::record(nav::NavEntry::Favorites { + tab: "all".to_string(), + }); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + navigate_library_all( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + ); + return; + } let Some(tab) = favorites::FavTab::from_tab_id(id.as_str()) else { // Playlists / Labels: just switch the visible tab, // their content is not implemented yet. @@ -20611,6 +20716,74 @@ fn main() -> Result<(), Box> { } }); } + // ── Library "All" mixed feed — toolbar handlers ── + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_search(move |q| { + if let Some(w) = weak.upgrade() { + w.global::().set_search(q); + library_all::derive(&w); + let jobs = library_all::artwork_jobs(&w); + artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window.global::().on_set_sort(move |key| { + if let Some(w) = weak.upgrade() { + w.global::().set_sort_by(key); + library_all::derive(&w); + let jobs = library_all::artwork_jobs(&w); + artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); + } + }); + } + { + let weak = window.as_weak(); + window.global::().on_set_view(move |mode| { + if let Some(w) = weak.upgrade() { + w.global::().set_view_mode(mode); + } + }); + } + { + let weak = window.as_weak(); + let image_cache = image_cache.clone(); + window + .global::() + .on_toggle_source(move |which| { + if let Some(w) = weak.upgrade() { + let st = w.global::(); + match which.as_str() { + "purchases" => st.set_show_purchases(!st.get_show_purchases()), + "favorites" => st.set_show_favorites(!st.get_show_favorites()), + "following" => st.set_show_following(!st.get_show_following()), + "local" => st.set_show_local(!st.get_show_local()), + _ => {} + } + library_all::derive(&w); + let jobs = library_all::artwork_jobs(&w); + artwork::spawn_loads(jobs, weak.clone(), image_cache.clone()); + } + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window.global::().on_retry(move || { + navigate_library_all( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + ); + }); + } { // Local search over the loaded favorite albums (title / artist). let weak = window.as_weak();