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>();
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user