feat(library): All mixed feed, TrackCard, artist playlists + library toggle, local favorites

- Library "All": mixed feed (tracks/albums/artists/playlists/labels) sorted by
  date added, reusing the existing cards; new TrackCard; toolbar (search + source
  switches + sort); "All" added to the Library nav surfaces.
- ArtistPage: restored the curated Playlists carousel (parsed but dropped in the
  Slint port); added the catalog/library toggle backed by a per-session
  per-artist index (favorites).
- Local-library favorites layer: new SQLite store (mirrors the pinned service),
  album-level; excludes the Qobuz offline cache at write time. Feeds "All" behind
  the show-local switch.
- i18n: 3 new strings across all 8 locales.

cc
This commit is contained in:
vicrodh committed 2026-07-18 01:33:23 -06:00
1 parent e84d1035f8
commit 83df91439c
26 files changed
+2049 -12

No files matched your search

@@ -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:<file_path>` (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<HashSet<(String, String)>>,
}
impl LocalFavoritesService {
/// Create a new service, opening or creating the database.
pub fn new(db_path: &Path) -> Result<Self, String> {
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<Self, String> {
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<Vec<LocalFavItem>, 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<String>>(3)?.unwrap_or_default(),
artwork_url: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
artist: row.get::<_, Option<String>>(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<Vec<(String, i64)>, 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"
);
}
}
+1
View File
@@ -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;
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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 "ライブラリ内"
@@ -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"
@@ -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"
@@ -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 "В библиотеке"
+1 -1
View File
@@ -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`.
@@ -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
+316
View File
@@ -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 <image> icon;
in property <bool> primary: false;
in property <length> diameter: 36px;
in property <length> icon-size: 16px;
in property <bool> active: false;
out property <bool> 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 <TrackItem> track;
in property <bool> show-favorite: true;
// Always-visible source badge on the artwork (Library All: local | plex |
// qobuz). Off elsewhere (single-source surfaces).
in property <bool> 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 <length> card-height: 246px;
width: 200px;
height: root.card-height;
property <bool> 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;
}
}
}
}
}
@@ -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 <length> cw: 200px;
property <length> ch: 246px;
property <length> gap: 20px;
property <int> columns: Math.max(1,
Math.floor((self.width + self.gap) / (self.cw + self.gap)));
property <int> 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 {
@@ -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);
+2
View File
@@ -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" },
+1
View File
@@ -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" },
+66
View File
@@ -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 <string> artist-tab: "catalog"; // "catalog" | "library"
in property <int> 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 <string> search: "";
in-out property <string> sort-by: "date"; // "date" | "title" | "artist"
in-out property <string> view-mode: "grid"; // "grid" | "list"
// Source-switch filters — the three webplayer toggles. All on = show all.
in-out property <bool> show-purchases: true;
in-out property <bool> show-favorites: true;
in-out property <bool> show-following: true;
// "show local" — includes local files + Plex (Phase 2). Off until the
// local-favorites layer lands.
in-out property <bool> show-local: false;
in property <int> total: 0; // badge count (deduped union)
in property <bool> loading: false;
in property <string> 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
+8 -5
View File
@@ -195,12 +195,15 @@ pub fn classify_release_type(track_count: Option<u32>) -> &'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),
+87 -1
View File
@@ -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<LabelData>,
/// Similar artists from /artist/page — sidebar Similar Artists.
pub similar_artists: Vec<SimilarArtistData>,
/// Curated playlists featuring this artist (the /artist/page `playlists`
/// section) — main-column Playlists carousel, above the "Other" block.
pub playlists: Vec<PlaylistSlim>,
}
/// 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<PlaylistSlim> = 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<ArtworkJob> {
});
}
}
// 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<SearchPlaylistItem> =
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::<SearchPlaylistItem>::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::<TrackItem>::new())));
state.set_library_albums(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
state.set_loading(true);
}
+21
View File
@@ -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::<ArtistState>().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::<crate::LibraryAllState>().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::<ArtistState>().get_stories();
if let Some(mut item) = model.row_data(index) {
+402
View File
@@ -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<AppRuntime<SlintAdapter>>;
/// 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<Vec<Feed>, String> {
let mut feed: Vec<Feed> = 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<Feed>) {
let items: Vec<LibraryFeedItem> = feed.iter().map(to_item).collect();
let total = items.len() as i32;
let st = window.global::<LibraryAllState>();
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::<LibraryAllState>();
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<LibraryFeedItem> = 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<ArtworkJob> {
let visible = window.global::<LibraryAllState>().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
}
+124
View File
@@ -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<TrackCard>,
pub albums: Vec<AlbumCard>,
}
impl ArtistLibrary {
pub fn count(&self) -> usize {
self.tracks.len() + self.albums.len()
}
}
static INDEX: RwLock<Option<HashMap<String, ArtistLibrary>>> = 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<A>(runtime: &Arc<AppRuntime<A>>)
where
A: FrontendAdapter + Send + Sync + 'static,
{
let mut map: HashMap<String, ArtistLibrary> = 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<ArtistLibrary> {
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<AlbumCardItem> {
let rows: Vec<AlbumCardItem> = 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<TrackItem> {
let rows: Vec<TrackItem> = 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))
}
+103
View File
@@ -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<Option<Service>>` 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<Option<LocalFavoritesService>> = Mutex::new(None);
const NO_SESSION_ERR: &str = "No active session - please log in";
/// Bind the per-user store from `<dir>/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<T>(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<LocalFavItem> {
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<bool, String> {
if is_favorite(&item.kind, &item.id) {
unfavorite(&item.kind, &item.id)?;
Ok(false)
} else {
favorite(item)?;
Ok(true)
}
}
+65 -1
View File
@@ -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:<file_path> 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();
+173
View File
@@ -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::<ArtistState>();
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::<ArtistState>().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<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
) {
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<FavoritesState>().set_active_tab("all".into());
w.global::<NavState>().set_view(ContentView::Favorites);
let st = w.global::<LibraryAllState>();
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::<LibraryAllState>();
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<dyn std::error::Error>> {
}
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
window
.global::<FavoritesActions>()
.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<dyn std::error::Error>> {
}
});
}
// ── Library "All" mixed feed — toolbar handlers ──
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window.global::<LibraryAllActions>().on_search(move |q| {
if let Some(w) = weak.upgrade() {
w.global::<LibraryAllState>().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::<LibraryAllActions>().on_set_sort(move |key| {
if let Some(w) = weak.upgrade() {
w.global::<LibraryAllState>().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::<LibraryAllActions>().on_set_view(move |mode| {
if let Some(w) = weak.upgrade() {
w.global::<LibraryAllState>().set_view_mode(mode);
}
});
}
{
let weak = window.as_weak();
let image_cache = image_cache.clone();
window
.global::<LibraryAllActions>()
.on_toggle_source(move |which| {
if let Some(w) = weak.upgrade() {
let st = w.global::<LibraryAllState>();
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::<LibraryAllActions>().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();