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 },
|
||||
/// A row in MixState.tracks[index].
|
||||
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.
|
||||
@@ -500,5 +504,15 @@ fn apply_artwork(
|
||||
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 playback;
|
||||
mod queue;
|
||||
mod playlist;
|
||||
mod playlist_picker;
|
||||
mod recently;
|
||||
mod search;
|
||||
@@ -483,6 +484,9 @@ fn apply_entry(
|
||||
nav::NavEntry::Mix { 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 {
|
||||
mbid,
|
||||
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
|
||||
/// page instead when the resolved musician has a Confirmed Qobuz
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
("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
|
||||
// Slint MVP yet; logged until that lands.
|
||||
// Favorite playlist click — open the playlist detail view.
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
let image_cache = image_cache.clone();
|
||||
window
|
||||
.global::<FavoritesActions>()
|
||||
.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 {
|
||||
kind: String,
|
||||
},
|
||||
/// A playlist detail page; the string is the playlist id.
|
||||
Playlist(String),
|
||||
Album(String),
|
||||
Artist(String),
|
||||
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";
|
||||
|
||||
// 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
|
||||
// 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 { FavoritesView } from "../favorites/FavoritesView.slint";
|
||||
import { MixView } from "../mix/MixView.slint";
|
||||
import { PlaylistView } from "../playlist/PlaylistView.slint";
|
||||
import { SettingsView } from "../settings/SettingsView.slint";
|
||||
import { SearchResultsView } from "../search/SearchResultsView.slint";
|
||||
|
||||
@@ -147,6 +148,23 @@ export component AppShell inherits Rectangle {
|
||||
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 {
|
||||
settings-bool(k, v) => {
|
||||
root.settings-bool(k, v);
|
||||
|
||||
@@ -196,7 +196,23 @@ export global AlbumActions {
|
||||
}
|
||||
|
||||
// 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
|
||||
// the persistence stores by the Rust settings controller and pushed here;
|
||||
|
||||
Reference in new issue
Block a user