feat(slint): playlist detail view

First piece of the playlist module: a detail view at the AlbumView /
MixView level, reusing the shared TrackRow (show-artwork + show-album +
context menu, so Add to playlist / Go to album / etc. work in it).

  - PlaylistState + crate::playlist controller (get_playlist -> header
    metadata + TrackItem rows; cached track list backs play-all /
    per-track play; artwork jobs resolve row covers + the header cover).
  - PlaylistView.slint: cover + "Playlist" label + name + description +
    "owner - N tracks - duration" info line + circular actions (Play
    all, Shuffle, Favorite) over a column-headed track list.
  - ContentView.playlist + NavEntry::Playlist (back/forward history) +
    navigate_playlist; the Favorites > Playlists card now opens it.
  - media-action: playlist play-all / shuffle / favorite (add/remove
    favorite) + per-track play-from-here.

cc
This commit is contained in:
vicrodh committed 2026-05-22 03:02:09 -06:00
1 parent 75d8571be3
commit 1be78193e2
8 files changed
+607 -5

No files matched your search

+14
View File
@@ -88,6 +88,10 @@ pub enum ArtworkTarget {
ForYouSpotlightAlbum { index: usize }, ForYouSpotlightAlbum { index: usize },
/// A row in MixState.tracks[index]. /// A row in MixState.tracks[index].
MixTrack { index: usize }, MixTrack { index: usize },
/// A row in PlaylistState.tracks[index].
PlaylistTrack { index: usize },
/// The PlaylistState header cover.
PlaylistCover,
} }
/// An artwork download job: which card, and the image URL. /// An artwork download job: which card, and the image URL.
@@ -500,5 +504,15 @@ fn apply_artwork(
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::PlaylistTrack { index } => {
let model = window.global::<crate::PlaylistState>().get_tracks();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::PlaylistCover => {
window.global::<crate::PlaylistState>().set_cover(image);
}
} }
} }
+93 -3
View File
@@ -35,6 +35,7 @@ mod play_history;
mod strip_html; mod strip_html;
mod playback; mod playback;
mod queue; mod queue;
mod playlist;
mod playlist_picker; mod playlist_picker;
mod recently; mod recently;
mod search; mod search;
@@ -483,6 +484,9 @@ fn apply_entry(
nav::NavEntry::Mix { kind } => { nav::NavEntry::Mix { kind } => {
navigate_mix(runtime.clone(), weak.clone(), handle, image_cache.clone(), kind); navigate_mix(runtime.clone(), weak.clone(), handle, image_cache.clone(), kind);
} }
nav::NavEntry::Playlist(id) => {
navigate_playlist(runtime.clone(), weak.clone(), handle, image_cache.clone(), id);
}
nav::NavEntry::Location { nav::NavEntry::Location {
mbid, mbid,
area_id, area_id,
@@ -642,6 +646,32 @@ fn navigate_mix(
}); });
} }
fn navigate_playlist(
runtime: Arc<AppRuntime<SlintAdapter>>,
weak: slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: artwork::ImageCache,
playlist_id: String,
) {
let Ok(id) = playlist_id.parse::<u64>() else {
log::warn!("[qbz-slint] navigate_playlist: bad id {playlist_id}");
return;
};
handle.spawn(async move {
let _ = weak.upgrade_in_event_loop(move |w| {
playlist::reset(&w);
w.global::<NavState>().set_view(ContentView::Playlist);
});
if let Some(data) = playlist::load(&runtime, id).await {
let jobs = playlist::artwork_jobs(&data);
let _ = weak.upgrade_in_event_loop(move |w| {
playlist::apply(&w, data);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
});
}
/// Open a MusicianPageView for `name + role`. Routes to the artist /// Open a MusicianPageView for `name + role`. Routes to the artist
/// page instead when the resolved musician has a Confirmed Qobuz /// page instead when the resolved musician has a Confirmed Qobuz
/// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches /// match (Tauri's `confidence === 'confirmed'` shortcut). Fetches
@@ -1460,6 +1490,53 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
playback::play_tracks(runtime, weak, handle, tracks, idx); playback::play_tracks(runtime, weak, handle, tracks, idx);
}); });
} }
("playlist", "play-all") => {
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let tracks = playlist::current_tracks();
playback::play_tracks(runtime, weak, handle, tracks, 0);
});
}
("playlist", "shuffle") => {
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let tracks = playlist::shuffled_tracks();
playback::play_tracks(runtime, weak, handle, tracks, 0);
});
}
("playlist", "favorite") => {
// Favorite/unfavorite the open playlist.
if let Some(w) = weak.upgrade() {
let pid = w.global::<PlaylistState>().get_id().to_string();
let was_fav = w.global::<PlaylistState>().get_is_favorite();
w.global::<PlaylistState>().set_is_favorite(!was_fav);
let runtime = runtime.clone();
handle.spawn(async move {
let res = if was_fav {
runtime.core().remove_favorite("playlist", &pid).await
} else {
runtime.core().add_favorite("playlist", &pid).await
};
if let Err(e) = res {
log::error!("[qbz-slint] toggle playlist favorite failed: {e}");
}
});
}
}
("playlist-track", track_id) => {
let idx = playlist::index_of(track_id);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
let tracks = playlist::current_tracks();
playback::play_tracks(runtime, weak, handle, tracks, idx);
});
}
_ => {} _ => {}
} }
}); });
@@ -2329,12 +2406,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}); });
} }
{ {
// Favorite playlist click — no playlist detail view in the // Favorite playlist click — open the playlist detail view.
// Slint MVP yet; logged until that lands. 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::<FavoritesActions>() .global::<FavoritesActions>()
.on_open_playlist(move |id| { .on_open_playlist(move |id| {
log::info!("[qbz-slint] favorites: playlist clicked id={id} (no view yet)"); nav::record(nav::NavEntry::Playlist(id.to_string()));
navigate_playlist(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
id.to_string(),
);
if let Some(w) = weak.upgrade() {
update_nav_flags(&w);
}
}); });
} }
{ {
+2
View File
@@ -23,6 +23,8 @@ pub enum NavEntry {
Mix { Mix {
kind: String, kind: String,
}, },
/// A playlist detail page; the string is the playlist id.
Playlist(String),
Album(String), Album(String),
Artist(String), Artist(String),
Settings, Settings,
+206
View File
@@ -0,0 +1,206 @@
//! Playlist detail view controller.
//!
//! Fetches a playlist through `QbzCore`, maps it to the shared
//! TrackItem rows + header metadata, and applies it to `PlaylistState`.
//! Mirrors `mix.rs`: a cached track list backs play-all / per-track
//! play, and an artwork-jobs pass resolves the row covers + header
//! cover off-thread.
use std::sync::{LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::Track;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{AppWindow, PlaylistState, TrackItem};
/// The currently-loaded playlist tracks, for play-all / per-track play.
static CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Plain, `Send` playlist data produced on the worker thread.
pub struct PlaylistData {
pub id: String,
pub name: String,
pub owner: String,
pub description: String,
pub cover_url: String,
pub tracks: Vec<Track>,
}
pub async fn load<A>(runtime: &AppRuntime<A>, playlist_id: u64) -> Option<PlaylistData>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let pl = match runtime.core().get_playlist(playlist_id).await {
Ok(pl) => pl,
Err(e) => {
log::error!("[qbz-slint] load playlist {playlist_id} failed: {e}");
return None;
}
};
let tracks = pl.tracks.map(|c| c.items).unwrap_or_default();
// Header cover: the server-composed playlist image, else the first
// track's album cover.
let cover_url = pl
.images
.as_ref()
.and_then(|imgs| imgs.first().cloned())
.or_else(|| {
tracks
.first()
.and_then(|t| t.album.as_ref())
.and_then(|a| a.image.best().cloned())
})
.unwrap_or_default();
Some(PlaylistData {
id: pl.id.to_string(),
name: pl.name,
owner: pl.owner.name,
description: pl
.description
.map(|d| crate::strip_html::strip_html(&d))
.unwrap_or_default(),
cover_url,
tracks,
})
}
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn total_duration(tracks: &[Track]) -> String {
let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum();
let mins = secs / 60;
if mins >= 60 {
format!("{} h {} min", mins / 60, mins % 60)
} else {
format!("{} min", mins)
}
}
fn to_item(track: &Track) -> TrackItem {
let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({v})");
}
TrackItem {
id: track.id.to_string().into(),
number: "".into(),
title: title.into(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default()
.into(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default()
.into(),
duration: mmss(track.duration).into(),
quality_tier: match track.maximum_bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None => "",
}
.into(),
explicit: track.parental_warning,
selected: false,
artwork_url: track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.unwrap_or_default()
.into(),
artwork: slint::Image::default(),
}
}
pub fn reset(window: &AppWindow) {
let state = window.global::<PlaylistState>();
state.set_tracks(ModelRc::new(VecModel::from(Vec::<TrackItem>::new())));
state.set_track_count(0);
state.set_total_duration("".into());
state.set_cover(slint::Image::default());
state.set_loading(true);
}
pub fn apply(window: &AppWindow, data: PlaylistData) {
let items: Vec<TrackItem> = data.tracks.iter().map(to_item).collect();
let count = data.tracks.len() as i32;
let duration = total_duration(&data.tracks);
if let Ok(mut cur) = CURRENT.lock() {
*cur = data.tracks;
}
let state = window.global::<PlaylistState>();
state.set_id(data.id.into());
state.set_name(data.name.into());
state.set_owner(data.owner.into());
state.set_description(data.description.into());
state.set_cover_url(data.cover_url.into());
state.set_tracks(ModelRc::new(VecModel::from(items)));
state.set_track_count(count);
state.set_total_duration(duration.into());
state.set_loading(false);
}
/// Artwork jobs for the loaded playlist — one per row plus the header
/// cover (resolved into PlaylistState.cover).
pub fn artwork_jobs(data: &PlaylistData) -> Vec<ArtworkJob> {
let mut jobs: Vec<ArtworkJob> = data
.tracks
.iter()
.enumerate()
.filter_map(|(i, t)| {
t.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.map(|url| ArtworkJob {
url,
target: ArtworkTarget::PlaylistTrack { index: i },
})
})
.collect();
if !data.cover_url.is_empty() {
jobs.push(ArtworkJob {
url: data.cover_url.clone(),
target: ArtworkTarget::PlaylistCover,
});
}
jobs
}
pub fn current_tracks() -> Vec<Track> {
CURRENT.lock().map(|c| c.clone()).unwrap_or_default()
}
/// The current playlist tracks in a fresh random order (Shuffle).
pub fn shuffled_tracks() -> Vec<Track> {
let mut tracks = current_tracks();
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
for i in (1..tracks.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
tracks
}
pub fn index_of(track_id: &str) -> usize {
CURRENT
.lock()
.ok()
.and_then(|c| c.iter().position(|t| t.id.to_string() == track_id))
.unwrap_or(0)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint"; import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them. // Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions } from "state.slint"; export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it // Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`. // restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,256 @@
// Playlist detail view. Same anatomy as AlbumView / MixView: a header
// (cover + "Playlist" label + name + description + "owner • N tracks •
// duration" info line + circular actions) over a column-headed track
// list of shared TrackRows (show-artwork + show-album).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { PlaylistState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
component CircleAction inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <bool> active: false;
in property <length> diameter: 38px;
in property <bool> enabled: true;
callback clicked();
width: root.diameter;
height: root.diameter;
border-radius: root.diameter / 2;
opacity: root.enabled ? 1.0 : 0.4;
background: root.primary
? (ta.has-hover ? #ffffffd6 : Theme.accent)
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
QbzIcon {
source: root.icon;
width: root.primary ? 18px : 16px;
height: root.primary ? 18px : 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.primary
? #ffffff
: (root.active ? Theme.accent : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
export component PlaylistView inherits Rectangle {
callback play-track(string);
callback play-all();
callback shuffle();
callback toggle-favorite();
callback media-action(string, string, string);
background: Theme.surface-main;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
// --- Header ------------------------------------------------
HorizontalLayout {
spacing: 32px;
alignment: start;
// Cover.
Rectangle {
width: 224px;
height: 224px;
border-radius: Radius.md;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: PlaylistState.cover;
image-fit: cover;
}
if PlaylistState.cover-url == "": QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 56px;
height: 56px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
// Metadata.
VerticalLayout {
alignment: start;
padding-top: 4px;
horizontal-stretch: 1;
Text {
text: @tr("Playlist");
color: Theme.text-muted;
font-size: 11px;
font-weight: Typography.semibold;
letter-spacing: 1.5px;
}
Rectangle { height: 6px; }
Text {
text: PlaylistState.name;
color: Theme.text-primary;
font-size: Typography.title;
font-weight: Typography.bold;
wrap: word-wrap;
}
if PlaylistState.description != "": Rectangle { height: 8px; }
if PlaylistState.description != "": Text {
width: root.width - 320px;
text: PlaylistState.description;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
overflow: elide;
}
Rectangle { height: 10px; }
Text {
text: PlaylistState.owner
+ " • " + PlaylistState.track-count + " " + @tr("tracks")
+ " • " + PlaylistState.total-duration;
color: Theme.text-secondary;
font-size: Typography.legal;
}
Rectangle { height: 20px; }
// Actions.
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.play-all();
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.shuffle();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
active: PlaylistState.is-favorite;
clicked => {
root.toggle-favorite();
}
}
}
}
}
}
Rectangle { height: 28px; }
if PlaylistState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
if !PlaylistState.loading && PlaylistState.tracks.length == 0: Text {
text: @tr("This playlist is empty.");
color: Theme.text-muted;
font-size: Typography.body;
}
// --- Track list column header ------------------------------
if PlaylistState.tracks.length > 0: HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 14px;
Text {
text: "#";
width: 20px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
Rectangle { width: 40px; }
Text {
text: @tr("Title");
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-stretch: 1;
}
Text {
text: @tr("Album");
width: 220px;
color: Theme.text-muted;
font-size: Typography.legal;
}
Text {
text: @tr("Duration");
width: 70px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
Text {
text: @tr("Quality");
width: 70px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
Rectangle { width: 28px; }
Rectangle { width: 32px; }
}
if PlaylistState.tracks.length > 0: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 6px; }
for track[i] in PlaylistState.tracks: TrackRow {
track: track;
index: i;
show-artwork: true;
show-album: true;
show-favorite: true;
media-action(kind, id, action) => {
if (action == "play") {
root.play-track(id);
} else {
root.media-action(kind, id, action);
}
}
}
}
}
}
+18
View File
@@ -22,6 +22,7 @@ import { LabelReleasesView } from "../label/LabelReleasesView.slint";
import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint"; import { ArtistsByLocationView } from "../location/ArtistsByLocationView.slint";
import { FavoritesView } from "../favorites/FavoritesView.slint"; import { FavoritesView } from "../favorites/FavoritesView.slint";
import { MixView } from "../mix/MixView.slint"; import { MixView } from "../mix/MixView.slint";
import { PlaylistView } from "../playlist/PlaylistView.slint";
import { SettingsView } from "../settings/SettingsView.slint"; import { SettingsView } from "../settings/SettingsView.slint";
import { SearchResultsView } from "../search/SearchResultsView.slint"; import { SearchResultsView } from "../search/SearchResultsView.slint";
@@ -147,6 +148,23 @@ export component AppShell inherits Rectangle {
root.media-action(kind, id, action); root.media-action(kind, id, action);
} }
} }
if NavState.view == ContentView.playlist: PlaylistView {
play-track(id) => {
root.media-action("playlist-track", id, "play");
}
play-all => {
root.media-action("playlist", "", "play-all");
}
shuffle => {
root.media-action("playlist", "", "shuffle");
}
toggle-favorite => {
root.media-action("playlist", "", "favorite");
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.settings: SettingsView { if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => { settings-bool(k, v) => {
root.settings-bool(k, v); root.settings-bool(k, v);
+17 -1
View File
@@ -196,7 +196,23 @@ export global AlbumActions {
} }
// Which content view the shell shows. // Which content view the shell shows.
export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix } export enum ContentView { home, album, artist, settings, search, musician, label, location, favorites, mix, playlist }
// Playlist detail screen state. Rows reuse the shared TrackRow
// (show-artwork + show-album); populated by crate::playlist.
export global PlaylistState {
in property <string> id;
in property <string> name;
in property <string> owner;
in property <string> description;
in property <int> track-count: 0;
in property <string> total-duration;
in property <string> cover-url;
in property <image> cover;
in property <bool> is-favorite: false;
in property <[TrackItem]> tracks: [];
in property <bool> loading: false;
}
// Settings screen state. Audio and Playback preferences are loaded from // Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here; // the persistence stores by the Rust settings controller and pushed here;