New local_playlists + local_playlist_tracks schema in the shared per-user library.db (ids 'local:<uuid>', sources qobuz/local/plex, position-compacted, 8 repo tests in qbz-library). PlaylistRef routing at the navigation seam makes local ids unrepresentable in Qobuz playlist calls by construction (all take u64). Local playlists appear in sidebar, manager and picker (picker hides the Qobuz set while offline); detail view reuses the existing playlist row machinery and renders offline; creation modal gains the offline-only toggle (forced on while offline - creating offline always produces a local playlist, D8) and Edit can flip it later; explicit Upload-to-Qobuz conversion for non-offline-only locals (confirm dialog, local rows attach via the existing sidecar, entity kept on failure). Never-to-Qobuz guard: queues built from an offline-only playlist carry a core-level stamp cleared by every queue replacement; the QConnect sync skips the cloud push and play-on-peer falls back to local playback while the stamp is set. Deferred (logged in the slice report): plex-row attach on upload, custom reorder for local playlists, folder/favorite/hide on local items, manager sort interleaving, offline cover thumbs for cached qobuz rows. cc
872 lines
41 KiB
Plaintext
872 lines
41 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, NavState, OfflineState } 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 { MultiSelectBar } from "../primitives/MultiSelectBar.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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
// "Upload to Qobuz" confirm modal (local playlists, D8).
|
|
property <bool> show-upload-confirm: 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
// Favorite — a Qobuz concept; hidden for LOCAL
|
|
// playlists (no server entity to favorite).
|
|
if !PlaylistState.is-local: VerticalLayout {
|
|
horizontal-stretch: 0;
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/heart.svg");
|
|
active: PlaylistState.is-favorite;
|
|
clicked => {
|
|
root.toggle-favorite();
|
|
}
|
|
}
|
|
}
|
|
// Make the whole playlist available offline —
|
|
// resolves Qobuz membership, hidden for LOCAL ones.
|
|
if !PlaylistState.is-local: VerticalLayout {
|
|
horizontal-stretch: 0;
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/cloud-download.svg");
|
|
enabled: PlaylistState.tracks.length > 0;
|
|
clicked => {
|
|
root.media-action("playlist", PlaylistState.id, "cache");
|
|
}
|
|
}
|
|
}
|
|
// Upload to Qobuz (D8) — LOCAL playlists that are
|
|
// NOT offline-only, while online. Asks first; the
|
|
// local entity is deleted after a full upload.
|
|
if PlaylistState.is-local && !PlaylistState.offline-only
|
|
&& !OfflineState.offline: VerticalLayout {
|
|
horizontal-stretch: 0;
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/cloud-upload.svg");
|
|
enabled: PlaylistState.tracks.length > 0;
|
|
clicked => {
|
|
root.show-upload-confirm = true;
|
|
}
|
|
}
|
|
}
|
|
// 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;
|
|
}
|
|
// Play/cover cell — matches the row's 36px artwork cell.
|
|
Rectangle { width: 36px; }
|
|
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: 92px;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
horizontal-alignment: center;
|
|
}
|
|
// Reserve the row's three trailing columns: favorite (28),
|
|
// offline/download (28), context-menu (32).
|
|
Rectangle { width: 28px; }
|
|
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: MultiSelectBar {
|
|
selected-count: PlaylistState.selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false },
|
|
{ id: "remove-selected", label: @tr("Remove"), danger: true, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
root.media-action("playlist", "", id);
|
|
}
|
|
}
|
|
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;
|
|
// The list sits in a right-padded layout so the custom
|
|
// scrollbar lands in the gutter instead of overlapping the
|
|
// rows, and the rows' right edge lines up with the column
|
|
// header (mirrors LocalLibrary > Tracks).
|
|
VerticalLayout {
|
|
padding-right: 14px;
|
|
track-list := ListView {
|
|
vertical-stretch: 1;
|
|
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "playlist" && self.viewport-height > self.height) {
|
|
self.viewport-y = NavState.scroll-restore;
|
|
NavState.restore-scope = "";
|
|
}
|
|
}
|
|
init => { self.sr-restore(); }
|
|
changed sr-armed => { self.sr-restore(); }
|
|
changed viewport-height => { self.sr-restore(); }
|
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
|
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 {
|
|
// Anchor to the VIEW's right edge like LocalLibrary > Tracks:
|
|
// the +16px cancels `page`'s right padding so the scrollbar
|
|
// sits 4px from the edge (not 4px from the padded content).
|
|
x: parent.width - self.width - 4px + 16px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: btt-ta.has-hover ? #ffffff : Theme.text-secondary;
|
|
}
|
|
btt-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
track-list.viewport-y = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// "Upload to Qobuz" confirm (D8) — destructive-ish conversion: the
|
|
// local entity is deleted once the upload completes.
|
|
if root.show-upload-confirm: Rectangle {
|
|
background: #000000bf;
|
|
TouchArea {
|
|
mouse-cursor: default;
|
|
clicked => {
|
|
root.show-upload-confirm = false;
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: Math.min(root.width - 80px, 440px);
|
|
height: confirm-panel.preferred-height;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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 { }
|
|
confirm-panel := VerticalLayout {
|
|
padding: 20px;
|
|
spacing: 14px;
|
|
Text {
|
|
text: @tr("Upload to Qobuz?");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.heading;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
Text {
|
|
text: @tr("This creates the playlist on Qobuz with its Qobuz tracks, keeps local files attached, and removes the local-only copy.");
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
wrap: word-wrap;
|
|
}
|
|
HorizontalLayout {
|
|
alignment: end;
|
|
spacing: 10px;
|
|
Rectangle {
|
|
width: upload-cancel-label.preferred-width + 28px;
|
|
height: 36px;
|
|
border-radius: Radius.sm;
|
|
background: upload-cancel-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
upload-cancel-label := Text {
|
|
text: @tr("Cancel");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
upload-cancel-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.show-upload-confirm = false;
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: upload-go-label.preferred-width + 36px;
|
|
height: 36px;
|
|
border-radius: Radius.sm;
|
|
background: upload-go-ta.has-hover ? #ffffffd6 : Theme.accent;
|
|
upload-go-label := Text {
|
|
text: @tr("Upload");
|
|
color: #ffffff;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
upload-go-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.show-upload-confirm = false;
|
|
root.media-action("playlist", PlaylistState.id, "upload-to-qobuz");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|