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

@@ -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);
}
}
}
}
}
}