feat(slint): local album homologation, Recently Played fixes, local artist nav
Album view (local/Plex): - Rebuild LocalAlbumView as a near copy of the Qobuz AlbumPageView (whole-page scroll, 224px cover, action row, toolbar with quality badge + track search, column header, source-aware rows) minus the label/awards sidebar and Qobuz context menus. Add a per-album client-side track search. Recently Played (Home) — D: - Carry the track source (qobuz|plex|local) through the recently-played store so the cards resolve source-aware artwork (PlexThumb / local file), the album-card play routes to local/Plex playback instead of the empty Qobuz path, and the card click opens the right album view. - Fix a regression from the Plex version split: local_queue_track now derives the content-hash plex_album_key for album_id (navigation) while album_group_key keeps the per-edition split key, so "go to album" and recent Plex albums open correctly. Local artist navigation — B: - Implement open_artist (was a TODO): latch the requested artist and select it once the Artists tab loads. The shared open-artist is now source-aware (numeric id -> Qobuz artist; name -> LocalLibrary Artists tab). Wire the LocalAlbum header artist link and the now-playing "Go to artist" for Plex/local. Folder delete -> Recently Played cascade: - Removing a Local Library folder now also prunes its albums/tracks from the recently-played store (album_keys_in_folder + recently::prune_albums). Covers — C: - Robust on-disk folder cover lookup (cover/folder/front/art/<album>.jpg, any image as a last resort), shared by the Folders subcards and now-playing/album rows. cc
This commit is contained in:
12 files changed
+645
-162
No files matched your search
@@ -1215,6 +1215,29 @@ impl LibraryDatabase {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinct `album_group_key`s of the indexed tracks under `folder` — the
|
||||||
|
/// same keys used as the playback/Recently-Played album id. Call BEFORE
|
||||||
|
/// deleting the folder so the frontend can prune those albums from the
|
||||||
|
/// recently-played store.
|
||||||
|
pub fn album_keys_in_folder(&self, folder: &str) -> Result<Vec<String>, LibraryError> {
|
||||||
|
let pattern = format!("{}/%", folder.trim_end_matches('/'));
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT DISTINCT album_group_key FROM local_tracks
|
||||||
|
WHERE file_path LIKE ? AND album_group_key IS NOT NULL AND album_group_key != ''",
|
||||||
|
)
|
||||||
|
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![pattern], |row| row.get::<_, String>(0))
|
||||||
|
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for k in rows {
|
||||||
|
keys.push(k.map_err(|e| LibraryError::Database(e.to_string()))?);
|
||||||
|
}
|
||||||
|
Ok(keys)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a folder and its indexed tracks (separator-safe cascade). Mirrors
|
/// Remove a folder and its indexed tracks (separator-safe cascade). Mirrors
|
||||||
/// the Tauri remove-folder command order: drop the folder row, then the
|
/// the Tauri remove-folder command order: drop the folder row, then the
|
||||||
/// tracks under it. Returns the number of tracks removed.
|
/// tracks under it. Returns the number of tracks removed.
|
||||||
|
|||||||
@@ -740,7 +740,12 @@ fn playback_track_id(rating_key: &str) -> u64 {
|
|||||||
.unwrap_or_else(|_| synthetic_track_id(rating_key))
|
.unwrap_or_else(|_| synthetic_track_id(rating_key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn plex_album_key(artist: &str, album: &str) -> String {
|
/// Content-hash album key for a Plex album (`plex:<hash(artist::album)>`). This
|
||||||
|
/// is the stable per-album identity the grid card carries and that
|
||||||
|
/// `plex_cache_get_album_tracks` queries by — distinct from the per-edition
|
||||||
|
/// `parent_rating_key`. Public so the frontend can recover it for a played
|
||||||
|
/// track (whose `album_group_key` is the per-edition split key, not this).
|
||||||
|
pub fn plex_album_key(artist: &str, album: &str) -> String {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = DefaultHasher::new();
|
||||||
artist.hash(&mut hasher);
|
artist.hash(&mut hasher);
|
||||||
"::".hash(&mut hasher);
|
"::".hash(&mut hasher);
|
||||||
|
|||||||
@@ -193,6 +193,9 @@ where
|
|||||||
ribbon: String::new(),
|
ribbon: String::new(),
|
||||||
ribbon_kind: String::new(),
|
ribbon_kind: String::new(),
|
||||||
artwork_url: album.artwork_url,
|
artwork_url: album.artwork_url,
|
||||||
|
// Carry the origin so the card resolves source-aware artwork
|
||||||
|
// (PlexThumb / local file) and the play/open route correctly.
|
||||||
|
source: album.source,
|
||||||
// Recently-played cards render in the grid only — list-row
|
// Recently-played cards render in the grid only — list-row
|
||||||
// extras stay default.
|
// extras stay default.
|
||||||
..CardData::default()
|
..CardData::default()
|
||||||
|
|||||||
@@ -1081,6 +1081,23 @@ fn album_versions() -> std::sync::MutexGuard<'static, Vec<(String, Vec<qbz_libra
|
|||||||
ALBUM_VERSIONS.lock().unwrap_or_else(|e| e.into_inner())
|
ALBUM_VERSIONS.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Client-side track filter for the open local album (mirrors the Qobuz album
|
||||||
|
/// view's track search). Applied over the current version's tracks at render.
|
||||||
|
static ALBUM_QUERY: LazyLock<Mutex<String>> = LazyLock::new(|| Mutex::new(String::new()));
|
||||||
|
|
||||||
|
fn album_query() -> String {
|
||||||
|
ALBUM_QUERY.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the album track filter and re-render the current version in place.
|
||||||
|
pub fn search_album(weak: slint::Weak<AppWindow>, query: String) {
|
||||||
|
*ALBUM_QUERY.lock().unwrap_or_else(|e| e.into_inner()) = query;
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
let index = w.global::<crate::LocalAlbumState>().get_version_index();
|
||||||
|
apply_album_version(&w, index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Quality rank for ordering versions (hi-res first).
|
/// Quality rank for ordering versions (hi-res first).
|
||||||
fn version_rank(t: &qbz_library::LocalTrack) -> (u32, u64) {
|
fn version_rank(t: &qbz_library::LocalTrack) -> (u32, u64) {
|
||||||
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
|
(t.bit_depth.unwrap_or(0), t.sample_rate as u64)
|
||||||
@@ -1121,6 +1138,8 @@ pub fn open_local_album(
|
|||||||
image_cache: ImageCache,
|
image_cache: ImageCache,
|
||||||
group_key: String,
|
group_key: String,
|
||||||
) {
|
) {
|
||||||
|
// Fresh album → clear any leftover track filter from the previous one.
|
||||||
|
*ALBUM_QUERY.lock().unwrap_or_else(|e| e.into_inner()) = String::new();
|
||||||
let _ = weak.upgrade_in_event_loop(|w| {
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
let s = w.global::<crate::LocalAlbumState>();
|
let s = w.global::<crate::LocalAlbumState>();
|
||||||
s.set_loading(true);
|
s.set_loading(true);
|
||||||
@@ -1266,8 +1285,16 @@ pub fn apply_album_version(window: &AppWindow, index: i32) {
|
|||||||
}
|
}
|
||||||
None => (String::new(), String::new()),
|
None => (String::new(), String::new()),
|
||||||
};
|
};
|
||||||
|
// Client-side filter (Qobuz album view parity): match title/artist; the
|
||||||
|
// header badge/info stay album-level (computed from the full version above).
|
||||||
|
let q = album_query().to_lowercase();
|
||||||
let items: Vec<TrackItem> = tracks
|
let items: Vec<TrackItem> = tracks
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|t| {
|
||||||
|
q.is_empty()
|
||||||
|
|| t.title.to_lowercase().contains(&q)
|
||||||
|
|| t.artist.to_lowercase().contains(&q)
|
||||||
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
let mut it = map_local_track(t);
|
let mut it = map_local_track(t);
|
||||||
@@ -2177,10 +2204,35 @@ pub fn select_folder(
|
|||||||
db.list_folder_tracks(&path_for_fetch, false)
|
db.list_folder_tracks(&path_for_fetch, false)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// Resolve a real on-disk cover for each subfolder whose indexed
|
||||||
|
// artwork_path is empty (no embedded art / never backfilled) — the
|
||||||
|
// image can sit under any of a dozen names (cover/folder/front/art/
|
||||||
|
// <album>.jpg, …). Off-thread, so the fs scan is fine here.
|
||||||
let children = crate::library_db::with_db(|db| {
|
let children = crate::library_db::with_db(|db| {
|
||||||
db.list_folder_children(&path_for_fetch, false)
|
db.list_folder_children(&path_for_fetch, false)
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| match e {
|
||||||
|
qbz_library::FolderTreeEntry::Folder {
|
||||||
|
path,
|
||||||
|
segment,
|
||||||
|
track_count_under,
|
||||||
|
artwork,
|
||||||
|
} => {
|
||||||
|
let artwork = artwork.filter(|a| !a.is_empty()).or_else(|| {
|
||||||
|
find_folder_cover(std::path::Path::new(&path))
|
||||||
|
});
|
||||||
|
qbz_library::FolderTreeEntry::Folder {
|
||||||
|
path,
|
||||||
|
segment,
|
||||||
|
track_count_under,
|
||||||
|
artwork,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
(tracks, children)
|
(tracks, children)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -2295,6 +2347,82 @@ pub fn set_folder_detail_subfolder_artwork(window: &AppWindow, path: &str, image
|
|||||||
static ARTIST_ALBUMS: std::sync::Mutex<Vec<qbz_library::LocalAlbum>> =
|
static ARTIST_ALBUMS: std::sync::Mutex<Vec<qbz_library::LocalAlbum>> =
|
||||||
std::sync::Mutex::new(Vec::new());
|
std::sync::Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// An artist name to auto-select once the Artists tab finishes loading — set
|
||||||
|
/// when navigating to a local artist from outside the tab (LocalAlbum header
|
||||||
|
/// link, now-playing "Go to artist", a track's context menu). Consumed once.
|
||||||
|
static PENDING_ARTIST: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// Queue an artist to be selected as soon as the Artists tab is ready.
|
||||||
|
pub fn set_pending_artist(name: String) {
|
||||||
|
*PENDING_ARTIST.lock().unwrap_or_else(|e| e.into_inner()) = Some(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_pending_artist() -> Option<String> {
|
||||||
|
PENDING_ARTIST.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort on-disk cover for a folder. The index often has no
|
||||||
|
/// `artwork_path` (no embedded art + no backfill), yet a cover image sits in
|
||||||
|
/// the folder under any of a dozen names. Priority: a known cover stem
|
||||||
|
/// (cover/folder/front/art/album/…), then `<foldername>.<ext>` (a file named
|
||||||
|
/// after the album), then the first image file as a last resort. Case- and
|
||||||
|
/// extension-insensitive. Returns an absolute path; must run off the UI thread.
|
||||||
|
pub fn find_folder_cover(folder: &std::path::Path) -> Option<String> {
|
||||||
|
const STEMS: &[&str] = &[
|
||||||
|
"cover",
|
||||||
|
"folder",
|
||||||
|
"front",
|
||||||
|
"art",
|
||||||
|
"album",
|
||||||
|
"albumart",
|
||||||
|
"albumartsmall",
|
||||||
|
"thumb",
|
||||||
|
"artwork",
|
||||||
|
"scan",
|
||||||
|
"booklet",
|
||||||
|
"title",
|
||||||
|
];
|
||||||
|
const EXTS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "gif", "tif", "tiff"];
|
||||||
|
let is_img = |p: &std::path::Path| {
|
||||||
|
p.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| EXTS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(folder)
|
||||||
|
.ok()?
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.is_file() && is_img(p))
|
||||||
|
.collect();
|
||||||
|
if entries.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entries.sort();
|
||||||
|
let stem_lower = |p: &std::path::Path| {
|
||||||
|
p.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_lowercase())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let folder_name = folder
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let by_stem = entries
|
||||||
|
.iter()
|
||||||
|
.find(|p| STEMS.contains(&stem_lower(p).as_str()));
|
||||||
|
let by_name = entries
|
||||||
|
.iter()
|
||||||
|
.find(|p| !folder_name.is_empty() && stem_lower(p) == folder_name);
|
||||||
|
by_stem
|
||||||
|
.or(by_name)
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| entries.into_iter().next())
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
/// Fold a common Latin accented char to its ASCII base (best-effort, no
|
/// Fold a common Latin accented char to its ASCII base (best-effort, no
|
||||||
/// `unicode-normalization` dep). Covers Spanish/European music metadata; the
|
/// `unicode-normalization` dep). Covers Spanish/European music metadata; the
|
||||||
/// uncovered tail just won't merge across diacritics. Mirrors the intent of
|
/// uncovered tail just won't merge across diacritics. Mirrors the intent of
|
||||||
@@ -2642,6 +2770,12 @@ pub fn ensure_artists_loaded(
|
|||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
let s = w.global::<LocalLibraryState>();
|
let s = w.global::<LocalLibraryState>();
|
||||||
if s.get_artists().row_count() != 0 || s.get_artists_loading() {
|
if s.get_artists().row_count() != 0 || s.get_artists_loading() {
|
||||||
|
// Already loaded → satisfy a pending open-artist immediately.
|
||||||
|
if s.get_artists().row_count() != 0 {
|
||||||
|
if let Some(name) = take_pending_artist() {
|
||||||
|
select_local_artist(w.as_weak(), handle.clone(), image_cache.clone(), name);
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s.set_artists_loading(true);
|
s.set_artists_loading(true);
|
||||||
@@ -2727,6 +2861,16 @@ pub fn ensure_artists_loaded(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let _ = weak2.upgrade_in_event_loop(move |w| {
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
||||||
apply_artists(&w, items);
|
apply_artists(&w, items);
|
||||||
|
// Satisfy a pending open-artist now that the set + ARTIST_ALBUMS
|
||||||
|
// cache are loaded (navigated here from a "Go to artist" link).
|
||||||
|
if let Some(name) = take_pending_artist() {
|
||||||
|
select_local_artist(
|
||||||
|
w.as_weak(),
|
||||||
|
handle_inner.clone(),
|
||||||
|
image_cache.clone(),
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Seed decode jobs for rows that already carry an image-path.
|
// Seed decode jobs for rows that already carry an image-path.
|
||||||
// Non-http paths now split into local files vs Plex `/library/`
|
// Non-http paths now split into local files vs Plex `/library/`
|
||||||
// thumbs: both go through the source-aware dispatcher (which
|
// thumbs: both go through the source-aware dispatcher (which
|
||||||
|
|||||||
@@ -278,41 +278,74 @@ pub fn add_folder(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
|||||||
|
|
||||||
/// Bulk-remove the selected folders (with confirm + cascade track delete).
|
/// Bulk-remove the selected folders (with confirm + cascade track delete).
|
||||||
pub fn remove_folders(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
pub fn remove_folders(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
let ids: Vec<i64> = folders_lock()
|
// Removes only the LocalLibrary DB entries + their indexed tracks — never
|
||||||
|
// the files on disk. Reversible (re-add + re-scan reindexes), so we skip a
|
||||||
|
// confirm dialog: rfd's message dialog needs `zenity`, which isn't present
|
||||||
|
// on every Linux box, and silently fails-closed there (the original "delete
|
||||||
|
// does nothing" bug). A Toast gives feedback instead.
|
||||||
|
let paths: Vec<String> = folders_lock()
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.selected)
|
.filter(|f| f.selected)
|
||||||
.map(|f| f.id)
|
.map(|f| f.path.clone())
|
||||||
.collect();
|
.collect();
|
||||||
if ids.is_empty() {
|
if paths.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let count = ids.len();
|
let count = paths.len();
|
||||||
let h = handle.clone();
|
let h = handle.clone();
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
let ok = rfd::AsyncMessageDialog::new()
|
let paths2 = paths.clone();
|
||||||
.set_title("Remove folders")
|
let keys = tokio::task::spawn_blocking(move || {
|
||||||
.set_description(format!(
|
|
||||||
"Remove {count} selected folder(s)? Their indexed tracks will be removed. Your audio files are not deleted."
|
|
||||||
))
|
|
||||||
.set_buttons(rfd::MessageButtons::YesNo)
|
|
||||||
.show()
|
|
||||||
.await;
|
|
||||||
if ok != rfd::MessageDialogResult::Yes {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let ids2 = ids.clone();
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
crate::library_db::with_db(|db| {
|
crate::library_db::with_db(|db| {
|
||||||
for id in &ids2 {
|
let mut keys: Vec<String> = Vec::new();
|
||||||
if let Some(f) = db.get_folder_by_id(*id)? {
|
for p in &paths2 {
|
||||||
db.remove_folder_with_tracks(&f.path)?;
|
keys.extend(db.album_keys_in_folder(p).unwrap_or_default());
|
||||||
}
|
db.remove_folder_with_tracks(p)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(keys)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default();
|
||||||
|
crate::recently::prune_albums(&keys);
|
||||||
|
crate::toast::success_weak(&weak, format!("Removed {count} folder(s)"));
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove ONE folder by id (confirm + cascade track delete). The per-row
|
||||||
|
/// delete button; independent of the multi-select state, so a previously
|
||||||
|
/// added folder can be removed without first selecting it + the toolbar trash.
|
||||||
|
pub fn remove_folder(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, id: i64) {
|
||||||
|
// DB-only removal (entry + indexed tracks), never the files. No confirm
|
||||||
|
// dialog — see remove_folders for why (zenity-less boxes fail-closed).
|
||||||
|
let (path, name) = {
|
||||||
|
let g = folders_lock();
|
||||||
|
match g.iter().find(|f| f.id == id) {
|
||||||
|
Some(f) => (f.path.clone(), display_name(f)),
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let p = path.clone();
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::library_db::with_db(|db| {
|
||||||
|
// Capture album keys BEFORE the delete so we can prune them out
|
||||||
|
// of Recently Played too (not just the DB rows).
|
||||||
|
let keys = db.album_keys_in_folder(&p).unwrap_or_default();
|
||||||
|
let n = db.remove_folder_with_tracks(&p)?;
|
||||||
|
Ok((n, keys))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let (n, keys) = result.unwrap_or((0, Vec::new()));
|
||||||
|
crate::recently::prune_albums(&keys);
|
||||||
|
crate::toast::success_weak(&weak, format!("Removed \"{name}\" ({n} tracks)"));
|
||||||
load_folders(weak, h);
|
load_folders(weak, h);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-26
@@ -260,14 +260,28 @@ async fn reload_home(
|
|||||||
url: slim.artwork_url.clone(),
|
url: slim.artwork_url.clone(),
|
||||||
})
|
})
|
||||||
}));
|
}));
|
||||||
jobs.extend(data.recent_albums.iter().enumerate().filter_map(|(idx, card)| {
|
// Recently-played album covers: Qobuz covers use the plain loader;
|
||||||
(!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob {
|
// Plex/local covers need the source-aware funnel (PlexThumb
|
||||||
|
// tokenization / local file read), else they never resolve.
|
||||||
|
let mut plex_album_jobs: Vec<artwork::ArtworkJob> = Vec::new();
|
||||||
|
for (idx, card) in data.recent_albums.iter().enumerate() {
|
||||||
|
if card.artwork_url.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let job = artwork::ArtworkJob {
|
||||||
target: artwork::ArtworkTarget::RecentAlbum { idx },
|
target: artwork::ArtworkTarget::RecentAlbum { idx },
|
||||||
url: card.artwork_url.clone(),
|
url: card.artwork_url.clone(),
|
||||||
})
|
};
|
||||||
}));
|
if card.source == "plex" || card.source == "local" {
|
||||||
|
plex_album_jobs.push(job);
|
||||||
|
} else {
|
||||||
|
jobs.push(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let weak_for_artwork = weak.clone();
|
let weak_for_artwork = weak.clone();
|
||||||
|
let weak_for_local = weak.clone();
|
||||||
|
let image_cache_local = image_cache.clone();
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
home::apply_home(&w, data);
|
home::apply_home(&w, data);
|
||||||
// apply_home shows the home set; swap to the requested
|
// apply_home shows the home set; swap to the requested
|
||||||
@@ -276,6 +290,16 @@ async fn reload_home(
|
|||||||
w.global::<HomeState>().set_loading(false);
|
w.global::<HomeState>().set_loading(false);
|
||||||
});
|
});
|
||||||
artwork::spawn_loads(jobs, weak_for_artwork, image_cache.clone());
|
artwork::spawn_loads(jobs, weak_for_artwork, image_cache.clone());
|
||||||
|
if !plex_album_jobs.is_empty() {
|
||||||
|
let plex = crate::plex_settings::get();
|
||||||
|
artwork::spawn_local_or_plex_loads(
|
||||||
|
plex_album_jobs,
|
||||||
|
plex.base_url,
|
||||||
|
plex.token,
|
||||||
|
weak_for_local,
|
||||||
|
image_cache_local,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[qbz-slint] discover load failed: {e}");
|
log::error!("[qbz-slint] discover load failed: {e}");
|
||||||
@@ -1028,6 +1052,37 @@ fn navigate_favorites(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// both the already-loaded and still-loading cases). Used by the LocalAlbum
|
||||||
|
/// header artist link, the now-playing "Go to artist", and local track menus.
|
||||||
|
fn open_local_artist(
|
||||||
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||||
|
weak: &slint::Weak<AppWindow>,
|
||||||
|
handle: &tokio::runtime::Handle,
|
||||||
|
image_cache: &artwork::ImageCache,
|
||||||
|
name: String,
|
||||||
|
) {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
local_library::set_pending_artist(name);
|
||||||
|
nav::record(nav::NavEntry::LocalLibrary {
|
||||||
|
tab: "artists".to_string(),
|
||||||
|
});
|
||||||
|
navigate_local_library(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
local_library::LibTab::Artists,
|
||||||
|
);
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks).
|
/// Open a Local Library browse tab (Albums / Artists / Folders / Tracks).
|
||||||
///
|
///
|
||||||
/// Sets the active tab + switches the view, then lazily loads the tab's data
|
/// Sets the active tab + switches the view, then lazily loads the tab's data
|
||||||
@@ -2029,18 +2084,26 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let handle = tokio_rt.handle().clone();
|
let handle = tokio_rt.handle().clone();
|
||||||
let image_cache = image_cache.clone();
|
let image_cache = image_cache.clone();
|
||||||
window.on_open_artist(move |artist_id| {
|
window.on_open_artist(move |artist_ref| {
|
||||||
let artist_id = artist_id.to_string();
|
let artist_ref = artist_ref.to_string();
|
||||||
nav::record(nav::NavEntry::Artist(artist_id.clone()));
|
// Qobuz artists are numeric ids → the Qobuz artist page. Local/Plex
|
||||||
navigate_artist(
|
// artists have no id, so their surfaces (LocalAlbum link, now-playing
|
||||||
runtime.clone(),
|
// "Go to artist") pass the NAME instead → the LocalLibrary Artists
|
||||||
weak.clone(),
|
// tab, focused on that artist.
|
||||||
&handle,
|
if artist_ref.parse::<u64>().is_ok() {
|
||||||
image_cache.clone(),
|
nav::record(nav::NavEntry::Artist(artist_ref.clone()));
|
||||||
artist_id,
|
navigate_artist(
|
||||||
);
|
runtime.clone(),
|
||||||
if let Some(w) = weak.upgrade() {
|
weak.clone(),
|
||||||
update_nav_flags(&w);
|
&handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
artist_ref,
|
||||||
|
);
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
|
} else if !artist_ref.trim().is_empty() {
|
||||||
|
open_local_artist(&runtime, &weak, &handle, &image_cache, artist_ref);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2440,13 +2503,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
crate::ui_prefs::save(&prefs);
|
crate::ui_prefs::save(&prefs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
("album", "play") => playback::play_album(
|
("album", "play") => {
|
||||||
runtime.clone(),
|
// A Plex/local id is a metadata group key, not a Qobuz id —
|
||||||
weak.clone(),
|
// play it from the local/Plex cache (Home "Recently played",
|
||||||
handle.clone(),
|
// etc.) instead of trying to fetch a Qobuz album.
|
||||||
id,
|
if is_local_album_key(&id) {
|
||||||
0,
|
playback::play_local_album(
|
||||||
),
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
handle.clone(),
|
||||||
|
id,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
playback::play_album(runtime.clone(), weak.clone(), handle.clone(), id, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
("track", "play") => {
|
("track", "play") => {
|
||||||
// Universal per-row play: queue the current view's VISIBLE
|
// Universal per-row play: queue the current view's VISIBLE
|
||||||
// tracklist starting at the clicked track (see
|
// tracklist starting at the clicked track (see
|
||||||
@@ -4497,6 +4569,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
local_library_settings::remove_folders(weak.clone(), handle.clone())
|
local_library_settings::remove_folders(weak.clone(), handle.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_remove_folder(move |id| {
|
||||||
|
local_library_settings::remove_folder(weak.clone(), handle.clone(), id as i64)
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
window
|
window
|
||||||
@@ -4844,6 +4925,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window.global::<LocalAlbumActions>().on_search(move |q| {
|
||||||
|
local_library::search_album(weak.clone(), q.to_string());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Local Library — Albums tab controls (search / sort re-query page 1;
|
// Local Library — Albums tab controls (search / sort re-query page 1;
|
||||||
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
||||||
@@ -4940,11 +5027,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
let runtime = app_runtime.clone();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
window
|
window
|
||||||
.global::<LocalLibraryActions>()
|
.global::<LocalLibraryActions>()
|
||||||
.on_open_artist(move |id| {
|
.on_open_artist(move |name| {
|
||||||
// TODO(locallibrary): open the local Artists tab / artist.
|
// `name` is the artist NAME (local/Plex artists have no id).
|
||||||
log::debug!("[qbz-slint] local artist open (Artists slice pending): {id}");
|
open_local_artist(&runtime, &weak, &handle, &image_cache, name.to_string());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -844,7 +844,17 @@ fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
|
|||||||
bit_depth: track.bit_depth,
|
bit_depth: track.bit_depth,
|
||||||
sample_rate: Some(sample_rate_khz),
|
sample_rate: Some(sample_rate_khz),
|
||||||
is_local: true,
|
is_local: true,
|
||||||
album_id: Some(track.album_group_key.clone()),
|
// album_id is the navigation key (now-playing "go to album", Recently
|
||||||
|
// Played, record_recent). For Plex the track's `album_group_key` is the
|
||||||
|
// per-edition SPLIT key (plex:album:<parentRatingKey>) which the album
|
||||||
|
// cache is NOT keyed by — recover the content-hash album key instead so
|
||||||
|
// open-album finds it. Local files: the group key is already the right
|
||||||
|
// navigation key.
|
||||||
|
album_id: Some(if is_plex {
|
||||||
|
qbz_plex::plex_album_key(&track.artist, &track.album)
|
||||||
|
} else {
|
||||||
|
track.album_group_key.clone()
|
||||||
|
}),
|
||||||
artist_id: None,
|
artist_id: None,
|
||||||
streamable: true,
|
streamable: true,
|
||||||
source: Some(src.to_string()),
|
source: Some(src.to_string()),
|
||||||
@@ -884,13 +894,9 @@ pub fn fill_missing_covers(tracks: &mut [qbz_library::LocalTrack]) {
|
|||||||
let key = folder.to_string_lossy().into_owned();
|
let key = folder.to_string_lossy().into_owned();
|
||||||
let cover = memo
|
let cover = memo
|
||||||
.entry(key)
|
.entry(key)
|
||||||
.or_insert_with(|| {
|
// Robust on-disk cover lookup (cover/folder/front/art/<album>.jpg,
|
||||||
["cover.jpg", "cover.png", "folder.jpg", "front.jpg"]
|
// any image as a last resort) — shared with the Folders subcards.
|
||||||
.iter()
|
.or_insert_with(|| crate::local_library::find_folder_cover(&folder))
|
||||||
.map(|n| folder.join(n))
|
|
||||||
.find(|c| c.is_file())
|
|
||||||
.map(|c| c.to_string_lossy().into_owned())
|
|
||||||
})
|
|
||||||
.clone();
|
.clone();
|
||||||
if cover.is_some() {
|
if cover.is_some() {
|
||||||
t.artwork_path = cover;
|
t.artwork_path = cover;
|
||||||
@@ -1224,6 +1230,7 @@ async fn record_recent(runtime: &Runtime) {
|
|||||||
genre: meta.genre,
|
genre: meta.genre,
|
||||||
release_date: meta.release_date,
|
release_date: meta.release_date,
|
||||||
artist_id: track.artist_id,
|
artist_id: track.artist_id,
|
||||||
|
source: track.source.clone().unwrap_or_else(|| "qobuz".to_string()),
|
||||||
});
|
});
|
||||||
// Per-artist play count — feeds the discovery filter "skip
|
// Per-artist play count — feeds the discovery filter "skip
|
||||||
// artists I already know" (HavingCount > threshold). artist_id
|
// artists I already know" (HavingCount > threshold). artist_id
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ pub struct RecentTrack {
|
|||||||
/// Artist id for navigation / scrobble context.
|
/// Artist id for navigation / scrobble context.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub artist_id: Option<u64>,
|
pub artist_id: Option<u64>,
|
||||||
|
/// Origin: "qobuz" | "plex" | "local". Drives source-aware artwork
|
||||||
|
/// (PlexThumb / local file) and routing for the Recently Played cards.
|
||||||
|
/// Empty for pre-source entries (serde default) → treated as "qobuz".
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One recently-played album, derived from the track history.
|
/// One recently-played album, derived from the track history.
|
||||||
@@ -104,6 +109,8 @@ pub struct RecentAlbum {
|
|||||||
pub genre: String,
|
pub genre: String,
|
||||||
/// Raw ISO release date; localized at render time.
|
/// Raw ISO release date; localized at render time.
|
||||||
pub release_date: String,
|
pub release_date: String,
|
||||||
|
/// "qobuz" | "plex" | "local" — see RecentTrack::source.
|
||||||
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn store_path() -> Option<PathBuf> {
|
fn store_path() -> Option<PathBuf> {
|
||||||
@@ -139,11 +146,34 @@ pub fn load_albums() -> Vec<RecentAlbum> {
|
|||||||
quality_label: track.quality_label,
|
quality_label: track.quality_label,
|
||||||
genre: track.genre,
|
genre: track.genre,
|
||||||
release_date: track.release_date,
|
release_date: track.release_date,
|
||||||
|
source: track.source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
albums
|
albums
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove every recently-played entry whose `album_id` is in `album_ids`.
|
||||||
|
/// Used when a Local Library folder is deleted so its albums/tracks no longer
|
||||||
|
/// linger in Recently Played. Returns how many track entries were removed.
|
||||||
|
pub fn prune_albums(album_ids: &[String]) -> usize {
|
||||||
|
if album_ids.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let Some(path) = store_path() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let mut list = load();
|
||||||
|
let before = list.len();
|
||||||
|
list.retain(|t| !album_ids.iter().any(|k| k == &t.album_id));
|
||||||
|
let removed = before - list.len();
|
||||||
|
if removed > 0 {
|
||||||
|
if let Ok(json) = serde_json::to_vec_pretty(&list) {
|
||||||
|
let _ = std::fs::write(&path, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a played track at the front of the list. Deduplicates by id and
|
/// Record a played track at the front of the list. Deduplicates by id and
|
||||||
/// caps the list at [`MAX_RECENT`]. Called by the playback session when a
|
/// caps the list at [`MAX_RECENT`]. Called by the playback session when a
|
||||||
/// track starts.
|
/// track starts.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState, NavState } from "../state.slint";
|
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState, NavState, DragState } from "../state.slint";
|
||||||
import { NavButtons } from "../shell/NavButtons.slint";
|
import { NavButtons } from "../shell/NavButtons.slint";
|
||||||
import { CircleAction } from "../primitives/CircleAction.slint";
|
import { CircleAction } from "../primitives/CircleAction.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
@@ -39,15 +39,16 @@ component VersionPicker inherits Rectangle {
|
|||||||
in property <[LocalAlbumVersion]> versions;
|
in property <[LocalAlbumVersion]> versions;
|
||||||
in property <int> current;
|
in property <int> current;
|
||||||
callback select(int);
|
callback select(int);
|
||||||
height: 32px;
|
// Matches QbzSelect `sm` (height 30, radius 6, no outline, 10px left pad)
|
||||||
width: pick-row.preferred-width + 22px;
|
// so it reads like every other small select instead of towering over them.
|
||||||
|
height: 30px;
|
||||||
|
width: pick-row.preferred-width;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-width: 1px;
|
|
||||||
border-color: Theme.border-subtle;
|
|
||||||
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
|
||||||
pick-row := HorizontalLayout {
|
pick-row := HorizontalLayout {
|
||||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
padding-left: 10px;
|
||||||
|
padding-right: 8px;
|
||||||
spacing: 7px;
|
spacing: 7px;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
@@ -94,7 +95,7 @@ component VersionPicker inherits Rectangle {
|
|||||||
padding: 6px;
|
padding: 6px;
|
||||||
spacing: 1px;
|
spacing: 1px;
|
||||||
for v[i] in root.versions: Rectangle {
|
for v[i] in root.versions: Rectangle {
|
||||||
height: 34px;
|
height: 30px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: i == root.current
|
background: i == root.current
|
||||||
? Theme.surface-elevated
|
? Theme.surface-elevated
|
||||||
@@ -141,23 +142,55 @@ component VersionPicker inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export component LocalAlbumView inherits Rectangle {
|
export component LocalAlbumView inherits Rectangle {
|
||||||
|
// Navigate to the artist (the NAME — local/Plex artists have no id; the
|
||||||
|
// source-aware open-artist routes a name to the LocalLibrary Artists tab).
|
||||||
|
callback open-artist(string);
|
||||||
background: Theme.surface-main;
|
background: Theme.surface-main;
|
||||||
|
|
||||||
VerticalLayout {
|
// Homologated 1:1 with the Qobuz AlbumPageView: whole-page Flickable, the
|
||||||
spacing: 0px;
|
// same header proportions + action row, the toolbar (quality badge + track
|
||||||
|
// search), the column header, and source-aware TrackRows. The only
|
||||||
|
// intentional differences vs AlbumPageView: no label/awards right sidebar,
|
||||||
|
// no Qobuz context menus, and the local-only version picker.
|
||||||
|
flick := Flickable {
|
||||||
|
interactive: !DragState.active;
|
||||||
|
viewport-width: self.width;
|
||||||
|
viewport-height: page.preferred-height;
|
||||||
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
||||||
|
property <string> sr-armed: NavState.restore-scope;
|
||||||
|
function sr-restore() {
|
||||||
|
if (NavState.restore-scope == "local-album" && self.viewport-height > self.height) {
|
||||||
|
self.viewport-y = NavState.scroll-restore;
|
||||||
|
NavState.restore-scope = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init => { self.sr-restore(); }
|
||||||
|
changed sr-armed => { self.sr-restore(); }
|
||||||
|
changed viewport-height => { self.sr-restore(); }
|
||||||
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
||||||
|
|
||||||
// --- History nav + header ---------------------------------------
|
// Header band (neutral — local albums have no artwork-derived tint yet).
|
||||||
// padding-top 11px matches Discover/Album so the [<] [>] pair sits at
|
Rectangle {
|
||||||
// the same top margin across every view. vertical-stretch: 0 is
|
x: 0;
|
||||||
// load-bearing (FavoritesView lesson): without it this nested layout
|
y: 0;
|
||||||
// grabs the leftover height and the header balloons.
|
width: parent.width;
|
||||||
VerticalLayout {
|
height: 340px;
|
||||||
vertical-stretch: 0;
|
background: @linear-gradient(180deg,
|
||||||
|
#181820 0%,
|
||||||
|
#181820 16%,
|
||||||
|
transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
page := VerticalLayout {
|
||||||
|
width: parent.width;
|
||||||
padding-left: 32px;
|
padding-left: 32px;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
padding-top: 11px;
|
padding-top: 11px;
|
||||||
|
padding-bottom: 100px;
|
||||||
spacing: 0px;
|
spacing: 0px;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
// --- History navigation -------------------------------------
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
NavButtons { }
|
NavButtons { }
|
||||||
@@ -165,13 +198,13 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
|
|
||||||
Rectangle { height: 22px; }
|
Rectangle { height: 22px; }
|
||||||
|
|
||||||
|
// --- Album header -------------------------------------------
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
spacing: 28px;
|
spacing: 32px;
|
||||||
|
|
||||||
// Cover.
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 200px;
|
width: 224px;
|
||||||
height: 200px;
|
height: 224px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: Theme.surface-elevated;
|
background: Theme.surface-elevated;
|
||||||
drop-shadow-blur: 32px;
|
drop-shadow-blur: 32px;
|
||||||
@@ -188,8 +221,8 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
padding-top: 6px;
|
padding-top: 4px;
|
||||||
spacing: 6px;
|
spacing: 0px;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
@@ -199,29 +232,38 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
font-weight: Typography.bold;
|
font-weight: Typography.bold;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
Text {
|
Rectangle { height: 4px; }
|
||||||
text: LocalAlbumState.artist;
|
HorizontalLayout {
|
||||||
color: Theme.text-secondary;
|
|
||||||
font-size: Typography.heading;
|
|
||||||
font-weight: Typography.bold;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: LocalAlbumState.info-line;
|
|
||||||
color: Theme.text-muted;
|
|
||||||
font-size: Typography.body;
|
|
||||||
}
|
|
||||||
if LocalAlbumState.quality-tier != "": HorizontalLayout {
|
|
||||||
alignment: start;
|
alignment: start;
|
||||||
QualityBadgeFull {
|
la-artist := TouchArea {
|
||||||
tier: LocalAlbumState.quality-tier;
|
width: la-artist-text.preferred-width;
|
||||||
detail: LocalAlbumState.quality-detail;
|
height: la-artist-text.preferred-height;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.open-artist(LocalAlbumState.artist);
|
||||||
|
}
|
||||||
|
la-artist-text := Text {
|
||||||
|
text: LocalAlbumState.artist;
|
||||||
|
color: la-artist.has-hover
|
||||||
|
? Theme.text-primary
|
||||||
|
: Theme.text-secondary;
|
||||||
|
font-size: Typography.heading;
|
||||||
|
font-weight: Typography.bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Rectangle { height: 10px; }
|
||||||
|
Text {
|
||||||
|
text: LocalAlbumState.info-line;
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle { height: 6px; }
|
Rectangle { height: 20px; }
|
||||||
|
|
||||||
// Local-only action row.
|
// Local action row — same chrome as AlbumPageView, only the
|
||||||
|
// actions that apply to a local/Plex album.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
spacing: 12px;
|
spacing: 12px;
|
||||||
@@ -229,8 +271,6 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
icon: @image-url("../assets/icons/play-fill.svg");
|
icon: @image-url("../assets/icons/play-fill.svg");
|
||||||
primary: true;
|
primary: true;
|
||||||
diameter: 44px;
|
diameter: 44px;
|
||||||
// Spin only while THIS album's first track is fetching
|
|
||||||
// (local album-id == the now-playing track's album_id).
|
|
||||||
loading: NowPlayingState.loading
|
loading: NowPlayingState.loading
|
||||||
&& NowPlayingState.album-id == LocalAlbumState.id;
|
&& NowPlayingState.album-id == LocalAlbumState.id;
|
||||||
clicked => {
|
clicked => {
|
||||||
@@ -266,7 +306,8 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version picker (only when there are multiple copies).
|
// Version picker (only with multiple physical copies).
|
||||||
|
if LocalAlbumState.versions.length > 1: Rectangle { height: 16px; }
|
||||||
if LocalAlbumState.versions.length > 1: HorizontalLayout {
|
if LocalAlbumState.versions.length > 1: HorizontalLayout {
|
||||||
alignment: start;
|
alignment: start;
|
||||||
spacing: 8px;
|
spacing: 8px;
|
||||||
@@ -292,73 +333,165 @@ export component LocalAlbumView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle { height: 16px; }
|
Rectangle { height: 20px; }
|
||||||
}
|
Rectangle {
|
||||||
|
height: 1px;
|
||||||
Rectangle {
|
background: Theme.border-subtle;
|
||||||
height: 1px;
|
|
||||||
background: Theme.border-subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Track list -------------------------------------------------
|
|
||||||
Rectangle {
|
|
||||||
vertical-stretch: 1;
|
|
||||||
clip: true;
|
|
||||||
|
|
||||||
if LocalAlbumState.loading: VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
LoadingSpinner { size: 32px; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
if !LocalAlbumState.loading && LocalAlbumState.tracks.length > 0: Rectangle {
|
// --- Track list (no sidebar) --------------------------------
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding-left: 32px;
|
spacing: 0px;
|
||||||
padding-right: 32px;
|
alignment: start;
|
||||||
padding-top: 12px;
|
|
||||||
tracks-list := ListView {
|
if LocalAlbumState.loading && LocalAlbumState.tracks.length == 0: Rectangle {
|
||||||
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
height: 280px;
|
||||||
// Scroll-position restore on back/forward (see nav::scope_for).
|
VerticalLayout {
|
||||||
property <string> sr-armed: NavState.restore-scope;
|
alignment: center;
|
||||||
function sr-restore() {
|
HorizontalLayout {
|
||||||
if (NavState.restore-scope == "local-album" && self.viewport-height > self.height) {
|
alignment: center;
|
||||||
self.viewport-y = NavState.scroll-restore;
|
LoadingSpinner { size: 36px; }
|
||||||
NavState.restore-scope = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
init => { self.sr-restore(); }
|
}
|
||||||
changed sr-armed => { self.sr-restore(); }
|
}
|
||||||
changed viewport-height => { self.sr-restore(); }
|
|
||||||
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
// Toolbar — album quality badge + track search.
|
||||||
for track[i] in LocalAlbumState.tracks: TrackRow {
|
HorizontalLayout {
|
||||||
track: track;
|
height: 52px;
|
||||||
index: i;
|
spacing: 16px;
|
||||||
show-artwork: false;
|
VerticalLayout {
|
||||||
show-album: false;
|
alignment: center;
|
||||||
show-favorite: false;
|
QualityBadgeFull {
|
||||||
show-download: false;
|
tier: LocalAlbumState.quality-tier;
|
||||||
show-source: false;
|
detail: LocalAlbumState.quality-detail;
|
||||||
media-action(kind, id, action) => {
|
}
|
||||||
// Local-only: ignore album/artist link clicks;
|
}
|
||||||
// any other row action plays the track.
|
Rectangle { horizontal-stretch: 1; }
|
||||||
if (kind != "album" && kind != "artist") {
|
VerticalLayout {
|
||||||
LocalAlbumActions.play-track(id);
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: 280px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
spacing: 7px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/search.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: Theme.text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
track-search := TextInput {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
single-line: true;
|
||||||
|
edited => {
|
||||||
|
LocalAlbumActions.search(self.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
visible: track-search.text == "";
|
||||||
|
text: @tr("Search tracks...");
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ListScrollbar {
|
|
||||||
x: parent.width - self.width - 4px;
|
// Column header.
|
||||||
width: 14px;
|
HorizontalLayout {
|
||||||
height: 100%;
|
height: 40px;
|
||||||
viewport-height: tracks-list.viewport-height;
|
padding-left: 12px;
|
||||||
visible-height: tracks-list.visible-height;
|
padding-right: 12px;
|
||||||
viewport-y <=> tracks-list.viewport-y;
|
spacing: 16px;
|
||||||
|
Text {
|
||||||
|
text: "#";
|
||||||
|
width: 32px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("TITLE");
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("DURATION");
|
||||||
|
width: 80px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("QUALITY");
|
||||||
|
width: 80px;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
// Source column (local/Plex/qobuz indicator on each row).
|
||||||
|
Rectangle { width: 28px; }
|
||||||
|
// Aligns with the per-row more-button slot.
|
||||||
|
Rectangle { width: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
for track[index] in LocalAlbumState.tracks: TrackRow {
|
||||||
|
track: track;
|
||||||
|
index: index;
|
||||||
|
show-artwork: false;
|
||||||
|
show-album: false;
|
||||||
|
show-favorite: false;
|
||||||
|
show-download: false;
|
||||||
|
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
||||||
|
show-source: true;
|
||||||
|
media-action(kind, id, action) => {
|
||||||
|
// Local-only: ignore album/artist link clicks; any other
|
||||||
|
// row action plays the track.
|
||||||
|
if (kind != "album" && kind != "artist") {
|
||||||
|
LocalAlbumActions.play-track(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared scrollbar in the right gutter (homologated cross-view).
|
||||||
|
ListScrollbar {
|
||||||
|
x: parent.width - self.width - 4px;
|
||||||
|
width: 14px;
|
||||||
|
height: 100%;
|
||||||
|
viewport-height: flick.viewport-height;
|
||||||
|
visible-height: flick.height;
|
||||||
|
viewport-y <=> flick.viewport-y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +175,11 @@ export component AppShell inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dedicated Local Library album detail (separate from AlbumView).
|
// Dedicated Local Library album detail (separate from AlbumView).
|
||||||
if NavState.view == ContentView.local-album: LocalAlbumView { }
|
if NavState.view == ContentView.local-album: LocalAlbumView {
|
||||||
|
open-artist(name) => {
|
||||||
|
root.open-artist(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
if NavState.view == ContentView.artist: ArtistPageView {
|
if NavState.view == ContentView.artist: ArtistPageView {
|
||||||
open-album(id) => {
|
open-album(id) => {
|
||||||
root.open-album(id);
|
root.open-album(id);
|
||||||
|
|||||||
@@ -328,8 +328,14 @@ export component SongCard inherits Rectangle {
|
|||||||
label: NowPlayingState.artist;
|
label: NowPlayingState.artist;
|
||||||
stretch: 2.0;
|
stretch: 2.0;
|
||||||
activated => {
|
activated => {
|
||||||
if (NowPlayingState.artist-id != "" && !NowPlayingState.is-ephemeral) {
|
if (!NowPlayingState.is-ephemeral) {
|
||||||
root.open-artist(NowPlayingState.artist-id);
|
// Qobuz: numeric artist-id → Qobuz artist page. Plex/local:
|
||||||
|
// no id, so pass the NAME → LocalLibrary Artists tab.
|
||||||
|
if (NowPlayingState.artist-id != "") {
|
||||||
|
root.open-artist(NowPlayingState.artist-id);
|
||||||
|
} else if (NowPlayingState.artist != "") {
|
||||||
|
root.open-artist(NowPlayingState.artist);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -699,6 +699,9 @@ export global LocalAlbumActions {
|
|||||||
callback add-to-playlist();
|
callback add-to-playlist();
|
||||||
callback play-track(string /* track id */);
|
callback play-track(string /* track id */);
|
||||||
callback select-version(int);
|
callback select-version(int);
|
||||||
|
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||||
|
// current version's tracks.
|
||||||
|
callback search(string /* query */);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================ Local Library management ====================
|
// ============================ Local Library management ====================
|
||||||
@@ -767,6 +770,7 @@ export global LibraryManageActions {
|
|||||||
callback load(); // (re)load the folder list (panel init)
|
callback load(); // (re)load the folder list (panel init)
|
||||||
callback add-folder(); // native dir picker (Rust)
|
callback add-folder(); // native dir picker (Rust)
|
||||||
callback remove-folders(); // bulk-remove selected (confirm)
|
callback remove-folders(); // bulk-remove selected (confirm)
|
||||||
|
callback remove-folder(int /* id */); // remove ONE folder directly (confirm)
|
||||||
callback toggle-folder-select(int /* id */);
|
callback toggle-folder-select(int /* id */);
|
||||||
callback edit-folder(int /* id */); // open the settings modal
|
callback edit-folder(int /* id */); // open the settings modal
|
||||||
callback save-folder-settings(int /* id */, string /* alias */, bool /* enabled */,
|
callback save-folder-settings(int /* id */, string /* alias */, bool /* enabled */,
|
||||||
|
|||||||
Reference in new issue
Block a user