feat(locallibrary): local album detail (reuses AlbumPageView)

Clicking a local album (grid card or a track row's album link) opens its
detail in the shared AlbumPageView. navigate_local_album loads the album's
metadata-grouped tracks (get_album_tracks_metadata) + fills folder covers,
populates AlbumState (title / album-artist or "Various Artists" / cover /
quality / "N tracks · duration" / disc-ordered TrackRows) with a new is-local
flag, and resolves the cover from disk. Play routes to local: the media-action
dispatcher gains a guard — when the album view shows a local album, album play
-> play_local_album, a track row -> play the album from that track; Qobuz
album/track play is untouched (guarded by view==Album && is-local).

cc
This commit is contained in:
vicrodh committed 2026-05-29 07:26:21 -06:00
1 parent b9b6d141f8
commit 79551cebb7
5 files changed
+192 -4

No files matched your search

+2
View File
@@ -317,6 +317,8 @@ pub fn reset_album(window: &AppWindow) {
let state = window.global::<AlbumState>(); let state = window.global::<AlbumState>();
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new()))); state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_artwork(slint::Image::default()); state.set_artwork(slint::Image::default());
// Default to a Qobuz album; the local-album loader opts in.
state.set_is_local(false);
state.set_loading(true); state.set_loading(true);
} }
+93
View File
@@ -577,6 +577,99 @@ pub fn load_more_tracks(weak: slint::Weak<AppWindow>, handle: tokio::runtime::Ha
}); });
} }
// ============================ Album detail ================================
//
// Local albums reuse the shared `AlbumPageView` + `AlbumState`: we just
// populate the state from the album's local tracks and flag `is-local` so
// the media-action dispatcher routes play to local playback.
fn fmt_album_duration(secs: u64) -> String {
let mins = secs / 60;
if mins >= 60 {
format!("{} h {} min", mins / 60, mins % 60)
} else {
format!("{mins} min")
}
}
/// Populate `AlbumState` from a local album's tracks (UI thread). The cover
/// is loaded separately by the caller; `is-local` is set so playback routes
/// to local. `group_key` is the metadata group key.
pub fn apply_local_album(
window: &AppWindow,
group_key: &str,
tracks: Vec<qbz_library::LocalTrack>,
) {
let title = tracks
.first()
.map(|t| t.album_group_title.clone())
.unwrap_or_default();
// Album artist: the common album-artist, else "Various Artists".
let artist_of = |t: &qbz_library::LocalTrack| {
t.album_artist.clone().unwrap_or_else(|| t.artist.clone())
};
let artist = match tracks.first() {
Some(first) => {
let name = artist_of(first);
if tracks.iter().all(|t| artist_of(t) == name) {
name
} else {
"Various Artists".to_string()
}
}
None => String::new(),
};
let cover = tracks
.iter()
.find_map(|t| t.artwork_path.clone())
.unwrap_or_default();
let cover_url = if cover.is_empty() {
String::new()
} else {
format!("file://{cover}")
};
// Quality from the highest-resolution track in the album.
let (tier, detail) = match tracks.iter().max_by_key(|t| t.bit_depth.unwrap_or(0)) {
Some(t) => {
let tier = match t.bit_depth {
Some(b) if b >= 24 => "hires",
Some(_) => "cd",
None => "",
};
(tier.to_string(), local_quality(t.bit_depth, t.sample_rate).0)
}
None => (String::new(), String::new()),
};
let total_secs: u64 = tracks.iter().map(|t| t.duration_secs).sum();
let info_line = format!("{} tracks · {}", tracks.len(), fmt_album_duration(total_secs));
let items: Vec<TrackItem> = tracks
.into_iter()
.map(|t| {
let mut it = map_local_track(t);
it.album_id = group_key.into();
it
})
.collect();
let s = window.global::<crate::AlbumState>();
s.set_id(group_key.into());
s.set_is_local(true);
s.set_title(title.into());
s.set_artist(artist.into());
s.set_artist_id("".into());
s.set_artwork_url(cover_url.into());
s.set_has_custom_cover(false);
s.set_quality_tier(tier.into());
s.set_quality_detail(detail.into());
s.set_info_line(info_line.into());
s.set_label("".into());
s.set_description("".into());
s.set_description_short("".into());
s.set_awards(ModelRc::new(VecModel::from(Vec::<slint::SharedString>::new())));
s.set_tracks(ModelRc::new(VecModel::from(items)));
s.set_loading(false);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+85 -2
View File
@@ -414,6 +414,53 @@ fn navigate_album(
}); });
} }
/// Open a LOCAL album's detail in the shared AlbumPageView: load its tracks
/// (metadata-grouped), populate AlbumState with `is-local` set, then resolve
/// the folder/embedded cover from disk. `group_key` is the album's metadata
/// group key.
fn navigate_local_album(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
group_key: String,
) {
let _ = runtime;
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(|w| {
album::reset_album(&w);
w.global::<NavState>().set_view(ContentView::Album);
});
let gk = group_key.clone();
let tracks = tokio::task::spawn_blocking(move || {
let mut t = crate::library_db::with_db(|db| db.get_album_tracks_metadata(&gk))
.unwrap_or_default();
playback::fill_missing_covers(&mut t);
t
})
.await
.unwrap_or_default();
let cover = tracks
.iter()
.find_map(|t| t.artwork_path.clone())
.unwrap_or_default();
let gk2 = group_key.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
local_library::apply_local_album(&w, &gk2, tracks);
});
if !cover.is_empty() {
let art = qbz_models::ArtworkRef::LocalFile(cover);
if let Some((px, ww, hh)) =
artwork::fetch_and_decode_ref(&art, &image_cache, 448).await
{
let _ = weak.upgrade_in_event_loop(move |w| {
album::apply_artwork(&w, &px, ww, hh);
});
}
}
});
}
/// Load an artist page and show the artist view, then fetch the portrait. /// Load an artist page and show the artist view, then fetch the portrait.
/// Shared by the `open-artist` callback and by history back/forward. /// Shared by the `open-artist` callback and by history back/forward.
fn navigate_artist( fn navigate_artist(
@@ -2175,6 +2222,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let id = id.to_string(); let id = id.to_string();
let action = action.to_string(); let action = action.to_string();
log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}"); log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}");
// Local Library album detail reuses AlbumPageView. Route its play
// actions to local playback — guarded to the album view + is-local
// so Qobuz album/track play is untouched.
if action == "play" && (kind == "album" || kind == "track") {
if let Some(w) = weak.upgrade() {
let album_state = w.global::<AlbumState>();
if matches!(w.global::<NavState>().get_view(), ContentView::Album)
&& album_state.get_is_local()
{
let album_id = album_state.get_id().to_string();
let start = if kind == "track" {
id.parse::<i64>().ok()
} else {
None
};
playback::play_local_album(
runtime.clone(),
weak.clone(),
handle.clone(),
album_id,
start,
);
return;
}
}
}
match (kind.as_str(), action.as_str()) { match (kind.as_str(), action.as_str()) {
("album", "play") => playback::play_album( ("album", "play") => playback::play_album(
runtime.clone(), runtime.clone(),
@@ -4002,11 +4075,20 @@ 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_album(move |id| { .on_open_album(move |id| {
// TODO(locallibrary): open the local album-detail view. navigate_local_album(
log::debug!("[qbz-slint] local album open (album-detail slice pending): {id}"); runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
id.to_string(),
);
}); });
} }
{ {
@@ -4031,6 +4113,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
weak.clone(), weak.clone(),
handle.clone(), handle.clone(),
id.to_string(), id.to_string(),
None,
); );
} else { } else {
// play-next / queue land with a later slice. // play-next / queue land with a later slice.
+9 -2
View File
@@ -222,6 +222,7 @@ pub fn play_local_album(
weak: slint::Weak<AppWindow>, weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle, handle: tokio::runtime::Handle,
album_id: String, album_id: String,
start_track_id: Option<i64>,
) { ) {
handle.spawn(async move { handle.spawn(async move {
let tracks = tokio::task::spawn_blocking(move || { let tracks = tokio::task::spawn_blocking(move || {
@@ -232,7 +233,13 @@ pub fn play_local_album(
}) })
.await .await
.unwrap_or_default(); .unwrap_or_default();
play_local_tracks_now(&runtime, &weak, tracks, 0).await; // Start at the requested track (a row click in the album detail) or
// the top (play-all).
let start = match start_track_id {
Some(tid) => tracks.iter().position(|t| t.id == tid).unwrap_or(0),
None => 0,
};
play_local_tracks_now(&runtime, &weak, tracks, start).await;
}); });
} }
@@ -316,7 +323,7 @@ fn local_queue_track(track: &qbz_library::LocalTrack) -> QueueTrack {
/// always backfill the index) — so the cover that exists on disk reaches the /// always backfill the index) — so the cover that exists on disk reaches the
/// now-playing bar + queue, not just the album grid. Runs off-thread (fs), /// now-playing bar + queue, not just the album grid. Runs off-thread (fs),
/// memoized per folder so a whole album costs one stat. /// memoized per folder so a whole album costs one stat.
fn fill_missing_covers(tracks: &mut [qbz_library::LocalTrack]) { pub fn fill_missing_covers(tracks: &mut [qbz_library::LocalTrack]) {
use std::collections::HashMap; use std::collections::HashMap;
let mut memo: HashMap<String, Option<String>> = HashMap::new(); let mut memo: HashMap<String, Option<String>> = HashMap::new();
for t in tracks.iter_mut() { for t in tracks.iter_mut() {
+3
View File
@@ -288,6 +288,9 @@ export global AlbumState {
in property <[string]> awards: []; in property <[string]> awards: [];
in property <[TrackItem]> tracks: []; in property <[TrackItem]> tracks: [];
in property <bool> loading: false; in property <bool> loading: false;
// True when this is a Local Library album (local files / offline copies).
// Routes the play actions to local playback + hides Qobuz-only chrome.
in property <bool> is-local: false;
} }
// Album-view actions wired to Rust callbacks. // Album-view actions wired to Rust callbacks.