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:
5 files changed
+192
-4
No files matched your search
@@ -317,6 +317,8 @@ pub fn reset_album(window: &AppWindow) {
|
||||
let state = window.global::<AlbumState>();
|
||||
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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.
|
||||
/// Shared by the `open-artist` callback and by history back/forward.
|
||||
fn navigate_artist(
|
||||
@@ -2175,6 +2222,32 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let id = id.to_string();
|
||||
let action = action.to_string();
|
||||
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()) {
|
||||
("album", "play") => playback::play_album(
|
||||
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
|
||||
.global::<LocalLibraryActions>()
|
||||
.on_open_album(move |id| {
|
||||
// TODO(locallibrary): open the local album-detail view.
|
||||
log::debug!("[qbz-slint] local album open (album-detail slice pending): {id}");
|
||||
navigate_local_album(
|
||||
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(),
|
||||
handle.clone(),
|
||||
id.to_string(),
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
// play-next / queue land with a later slice.
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn play_local_album(
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
album_id: String,
|
||||
start_track_id: Option<i64>,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let tracks = tokio::task::spawn_blocking(move || {
|
||||
@@ -232,7 +233,13 @@ pub fn play_local_album(
|
||||
})
|
||||
.await
|
||||
.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
|
||||
/// now-playing bar + queue, not just the album grid. Runs off-thread (fs),
|
||||
/// 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;
|
||||
let mut memo: HashMap<String, Option<String>> = HashMap::new();
|
||||
for t in tracks.iter_mut() {
|
||||
|
||||
@@ -288,6 +288,9 @@ export global AlbumState {
|
||||
in property <[string]> awards: [];
|
||||
in property <[TrackItem]> tracks: [];
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user