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:
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 "В библиотеке"
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user