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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user