Files
qbz/crates/qbz-slint/ui/playlist/PlaylistView.slint
T
vicrodh b2004e6867 feat(slint): TrackRow favorites + artist/album links, PlaylistView, LabelView, scrollbar
CHECKPOINT — this batch is NOT yet acceptable; substantial feedback is pending. Committed so the work is preserved for rework next session, after a max-effort Tauri code review. Areas: TrackRow favorite toggle/state + clickable artist/album (TrackItem gains is-favorite/artist-id/album-id + fav_cache, populated across all builders); PlaylistView header buttons + cover-art hover overlay; LabelView full AlbumCard + grid/list toggle; vertical scrollbar moved to the right gutter across ~12 views.

cc
2026-05-26 16:36:02 -06:00

780 lines
35 KiB
Plaintext

// 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, PlaylistActions, DragState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { ListView } from "std-widgets.slint";
// Header action button — homologated 1:1 with AlbumPageView's
// CircleAction: a ghost circle (translucent fill + faint ring, never a
// solid accent fill) so the playlist transport row matches the album
// one. Adds `active` (accent-tinted glyph for toggled state, e.g.
// favorite / select mode) and `enabled` (dimmed + non-interactive),
// which the album header doesn't need but the playlist one does.
component CircleAction inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <bool> active: false;
in property <length> diameter: 40px;
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;
// Ghost circle — translucent fill with a faint ring (matches the
// Tauri transport controls + AlbumPageView).
background: ta.has-hover ? Theme.surface-hover : transparent;
border-width: 1px;
border-color: ta.has-hover ? Theme.text-disabled : Theme.border-subtle;
QbzIcon {
source: root.icon;
width: root.primary ? 19px : 17px;
height: root.primary ? 19px : 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active
? Theme.accent
: (ta.has-hover ? Theme.text-primary : Theme.text-secondary);
}
ta := TouchArea {
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// One row in the Sort menu. The active field shows its direction arrow
// (asc/desc); re-selecting it toggles direction.
component SortOption inherits Rectangle {
in property <string> label;
in property <bool> active: false;
in property <bool> asc: true;
callback chosen();
height: 30px;
border-radius: 5px;
background: so-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 10px;
padding-right: 8px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-secondary;
font-size: Typography.legal;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
horizontal-stretch: 1;
}
VerticalLayout {
alignment: center;
if root.active: QbzIcon {
source: root.asc
? @image-url("../assets/icons/chevron-up.svg")
: @image-url("../assets/icons/chevron-down.svg");
width: 13px;
height: 13px;
tint: Theme.accent;
}
}
}
so-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.chosen();
}
}
}
export component PlaylistView inherits Rectangle {
callback play-track(string);
callback play-all();
callback shuffle();
callback toggle-favorite();
callback edit();
callback media-action(string, string, string);
// Read-more description modal.
property <bool> show-description: false;
// Label for the active sort field.
property <string> sort-label:
PlaylistState.sort-field == "title" ? @tr("Title")
: PlaylistState.sort-field == "artist" ? @tr("Artist")
: PlaylistState.sort-field == "album" ? @tr("Album")
: PlaylistState.sort-field == "duration" ? @tr("Duration")
: PlaylistState.sort-field == "custom" ? @tr("Custom")
: @tr("Default");
background: Theme.surface-main;
page := VerticalLayout {
// Fill PlaylistView so the ListView's vertical-stretch has
// real height (otherwise it collapses to a single row).
width: 100%;
height: 100%;
padding-left: 32px;
// Smaller right padding so the track list + scrollbar use
// the space instead of leaving a dead band on the right.
padding-right: 16px;
padding-top: 11px;
padding-bottom: 16px;
spacing: 0px;
HorizontalLayout {
vertical-stretch: 0;
alignment: start;
NavButtons { }
}
// Same nav -> header separation as AlbumView (homologated).
Rectangle { height: 22px; }
// --- Header ------------------------------------------------
HorizontalLayout {
vertical-stretch: 0;
spacing: 24px;
alignment: start;
// Cover — custom artwork if set, else a 4-up collage of
// the first tracks' covers (Tauri parity), else the
// server playlist image, else the first track, else a
// placeholder.
cover-box := Rectangle {
width: 150px;
height: 150px;
border-radius: Radius.md;
background: Theme.surface-elevated;
clip: true;
// Hover state — drives the artwork overlay (only
// shown on mouse-over). OR'd with the overlay button so
// moving onto it keeps the overlay visible.
property <bool> hovered:
cover-hover.has-hover || cover-btn-ta.has-hover;
// Custom artwork / single server image.
property <bool> show-single:
PlaylistState.has-custom
|| (PlaylistState.track-count < 4 && PlaylistState.cover-url != "");
property <bool> show-collage:
!PlaylistState.has-custom && PlaylistState.track-count >= 4;
if cover-box.show-single: Image {
width: 100%;
height: 100%;
source: PlaylistState.cover;
image-fit: cover;
}
// 2x2 collage from the first four track covers.
if cover-box.show-collage: VerticalLayout {
spacing: 2px;
HorizontalLayout {
spacing: 2px;
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[0].artwork;
image-fit: cover;
}
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[1].artwork;
image-fit: cover;
}
}
HorizontalLayout {
spacing: 2px;
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[2].artwork;
image-fit: cover;
}
Image {
width: (cover-box.width - 2px) / 2; height: (cover-box.width - 2px) / 2;
source: PlaylistState.tracks[3].artwork;
image-fit: cover;
}
}
}
// Single first-track cover when 1-3 tracks and no
// server image.
if !cover-box.show-single && !cover-box.show-collage
&& PlaylistState.track-count >= 1: Image {
width: 100%;
height: 100%;
source: PlaylistState.tracks[0].artwork;
image-fit: cover;
}
// Empty placeholder.
if PlaylistState.track-count == 0 && PlaylistState.cover-url == ""
&& !PlaylistState.has-custom: 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;
}
// Hover detector (below the overlay buttons so they
// stay clickable). Owner only.
cover-hover := TouchArea {
mouse-cursor: PlaylistState.is-owner ? pointer : default;
enabled: PlaylistState.is-owner;
}
// Hover overlay — a centered "change cover" affordance
// over the whole artwork, mirroring Tauri's
// `.artwork-overlay`: a dim scrim that fades in on
// hover with a single centered button. ImagePlus when
// no custom art is set (sets one); X when one is
// (clears it). Owner only. The button stays in the tree
// (its TouchArea name must be in scope for `hovered`),
// its glyph + action just switch on `has-custom`.
cover-overlay := Rectangle {
opacity: (PlaylistState.is-owner && cover-box.hovered) ? 1.0 : 0.0;
background: #00000066;
animate opacity { duration: 150ms; }
cover-btn := Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: cover-btn-ta.has-hover
? (PlaylistState.has-custom ? #ef4444 : Theme.accent)
: #000000aa;
QbzIcon {
source: PlaylistState.has-custom
? @image-url("../assets/icons/x.svg")
: @image-url("../assets/icons/image-plus.svg");
width: PlaylistState.has-custom ? 20px : 22px;
height: PlaylistState.has-custom ? 20px : 22px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: #ffffff;
}
cover-btn-ta := TouchArea {
mouse-cursor: pointer;
// Only interactive while the overlay is up
// (and the user owns the playlist).
enabled: PlaylistState.is-owner && cover-box.hovered;
clicked => {
if (PlaylistState.has-custom) {
root.media-action("playlist", "", "clear-artwork");
} else {
root.media-action("playlist", "", "set-artwork");
}
}
}
}
}
}
// 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: 4px; }
Text {
text: PlaylistState.name;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
wrap: word-wrap;
}
if PlaylistState.description != "": Rectangle { height: 6px; }
if PlaylistState.description != "": Text {
width: root.width - 250px;
text: PlaylistState.description-short;
color: Theme.text-secondary;
font-size: Typography.legal;
wrap: word-wrap;
}
if PlaylistState.description != PlaylistState.description-short: Rectangle { height: 3px; }
if PlaylistState.description != PlaylistState.description-short: read-more := TouchArea {
width: rm-row.preferred-width;
height: 18px;
mouse-cursor: pointer;
clicked => {
root.show-description = true;
}
rm-row := HorizontalLayout {
Text {
text: @tr("Read more");
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
font-size: Typography.legal;
vertical-alignment: center;
}
}
}
Rectangle { height: 8px; }
Text {
text: PlaylistState.owner
+ " • " + PlaylistState.track-count + " " + @tr("tracks")
+ " • " + PlaylistState.total-duration;
color: Theme.text-secondary;
font-size: Typography.legal;
}
Rectangle { height: 14px; }
// Actions — buttons left, search + sort floating right.
HorizontalLayout {
vertical-stretch: 0;
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 {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
enabled: PlaylistState.tracks.length > 0;
clicked => {
root.shuffle();
}
}
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/heart.svg");
active: PlaylistState.is-favorite;
clicked => {
root.toggle-favorite();
}
}
}
// Edit (rename / delete) — only the user's own.
if PlaylistState.is-owner: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pen-line.svg");
clicked => {
root.edit();
}
}
}
// Select tracks (multi-select edit mode).
if PlaylistState.is-owner && PlaylistState.tracks.length > 0: VerticalLayout {
horizontal-stretch: 0;
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/square-check-big.svg");
active: PlaylistState.multi-select-mode;
clicked => {
root.media-action("playlist", "", "select-toggle");
}
}
}
// Spacer floats the search + sort to the right.
Rectangle { horizontal-stretch: 1; }
// In-playlist search (shared ExpandableSearch).
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
ExpandableSearch {
placeholder: @tr("Search tracks");
max-open-width: 220px;
edited(q) => {
PlaylistActions.search(q);
}
}
}
// Sort dropdown — fixed width so it never grows
// when the active option changes.
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
sort-anchor := Rectangle {
height: 34px;
width: 132px;
border-radius: 6px;
background: sort-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
sort-row := HorizontalLayout {
padding-left: 11px;
padding-right: 8px;
spacing: 5px;
Text {
text: @tr("Sort") + ": " + root.sort-label;
color: Theme.text-secondary;
font-size: Typography.legal;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
VerticalLayout {
horizontal-stretch: 0;
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/chevron-down.svg");
width: 13px;
height: 13px;
tint: Theme.text-muted;
}
}
}
sort-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
sort-menu.show();
}
}
sort-menu := PopupWindow {
x: sort-anchor.width - 172px;
y: 38px;
width: 172px;
Rectangle {
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
border-radius: 8px;
drop-shadow-blur: 20px;
drop-shadow-color: #00000080;
VerticalLayout {
padding: 6px;
spacing: 1px;
SortOption {
label: @tr("Default");
active: PlaylistState.sort-field == "default";
chosen => { PlaylistActions.set-sort("default"); }
}
SortOption {
label: @tr("Title");
active: PlaylistState.sort-field == "title";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("title"); }
}
SortOption {
label: @tr("Artist");
active: PlaylistState.sort-field == "artist";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("artist"); }
}
SortOption {
label: @tr("Album");
active: PlaylistState.sort-field == "album";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("album"); }
}
SortOption {
label: @tr("Duration");
active: PlaylistState.sort-field == "duration";
asc: PlaylistState.sort-asc;
chosen => { PlaylistActions.set-sort("duration"); }
}
// Manual order — enables the per-row
// up/down reorder controls.
SortOption {
label: @tr("Custom");
active: PlaylistState.sort-field == "custom";
chosen => { PlaylistActions.set-sort("custom"); }
}
}
}
}
}
}
}
}
}
Rectangle { height: 18px; }
if PlaylistState.loading: HorizontalLayout {
vertical-stretch: 0;
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 ------------------------------
// Extra right padding (12 + 14px gutter) aligns the column
// labels with the rows, which are inset by the scrollbar.
if PlaylistState.tracks.length > 0: HorizontalLayout {
vertical-stretch: 0;
padding-left: 12px;
padding-right: 26px;
spacing: 14px;
Text {
text: "#";
width: 32px;
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; }
}
// Breathing room between the column labels and the divider.
if PlaylistState.tracks.length > 0: Rectangle { height: 3px; }
if PlaylistState.tracks.length > 0: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 6px; }
// Bulk action bar — visible in edit mode.
if PlaylistState.multi-select-mode: Rectangle {
height: 44px;
border-radius: 8px;
background: Theme.surface-elevated;
HorizontalLayout {
padding-left: 14px;
padding-right: 10px;
spacing: 12px;
Text {
text: PlaylistState.selected-count + " " + @tr("selected");
color: Theme.text-secondary;
font-size: Typography.body;
vertical-alignment: center;
horizontal-stretch: 1;
}
pill-all := Rectangle {
width: all-label.preferred-width + 24px;
height: 30px;
border-radius: Radius.sm;
background: all-ta.has-hover ? Theme.surface-hover : transparent;
all-label := Text {
text: @tr("Select all");
color: Theme.text-secondary;
font-size: Typography.legal;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
all-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", "", "select-all");
}
}
}
pill-remove := Rectangle {
width: rm-label.preferred-width + 24px;
height: 30px;
border-radius: Radius.sm;
opacity: PlaylistState.selected-count > 0 ? 1.0 : 0.5;
background: rm-ta.has-hover ? #ef444433 : transparent;
border-width: 1px;
border-color: #ef4444;
rm-label := Text {
text: @tr("Remove");
color: #ef4444;
font-size: Typography.legal;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
rm-ta := TouchArea {
mouse-cursor: PlaylistState.selected-count > 0 ? pointer : default;
clicked => {
if (PlaylistState.selected-count > 0) {
root.media-action("playlist", "", "remove-selected");
}
}
}
}
}
}
if PlaylistState.multi-select-mode: Rectangle { height: 8px; }
// Virtualized track list (built-in scrollbar hidden — a
// custom one OVERLAYS its own gutter to the right — homologated
// with every other view (the scrollbar sits in the right gutter
// without shrinking the list).
Rectangle {
vertical-stretch: 1;
track-list := ListView {
width: 100%;
height: 100%;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
for track[i] in PlaylistState.tracks: TrackRow {
track: track;
index: i;
show-artwork: true;
show-album: true;
show-favorite: true;
show-reorder: PlaylistState.sort-field == "custom";
multi-select-mode: PlaylistState.multi-select-mode;
media-action(kind, id, action) => {
if (action == "play") {
root.play-track(id);
} else {
root.media-action(kind, id, action);
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: track-list.viewport-height;
visible-height: track-list.visible-height;
viewport-y <=> track-list.viewport-y;
}
}
}
// Back to top — appears once the list is scrolled past one
// viewport. A shared rule for big lists (playlists, library, ...).
if track-list.viewport-y < -track-list.visible-height: Rectangle {
width: 44px;
height: 44px;
border-radius: 22px;
x: parent.width - self.width - 24px;
y: parent.height - self.height - 24px;
background: btt-ta.has-hover ? Theme.accent : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 16px;
drop-shadow-color: #00000066;
QbzIcon {
source: @image-url("../assets/icons/chevron-up.svg");
width: 20px;
height: 20px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: btt-ta.has-hover ? #ffffff : Theme.text-secondary;
}
btt-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
track-list.viewport-y = 0;
}
}
}
// Read-more description modal (same treatment as Album / Artist).
if root.show-description: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
root.show-description = false;
}
}
Rectangle {
width: Math.min(root.width - 80px, 560px);
height: Math.min(root.height - 120px, 460px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
VerticalLayout {
padding: 24px;
spacing: 14px;
HorizontalLayout {
Text {
text: @tr("About this playlist");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
desc-close := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
root.show-description = false;
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: desc-close.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: desc-full.preferred-height;
desc-full := Text {
width: parent.width;
text: PlaylistState.description;
color: Theme.text-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
}
}
}
}
}