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:
8 files changed
+607
-5
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user