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
1470 lines
57 KiB
Plaintext
1470 lines
57 KiB
Plaintext
// Playlist Manager — the full playlist + folder organization surface
|
|
// (Tauri's PlaylistManagerView). A fixed header/toolbar (Back/NavButtons +
|
|
// title + search + filter/sort dropdowns + folder/flat toggle + view-mode
|
|
// cycle + New folder + counter) over a scrolling FOLDERS section and a
|
|
// PLAYLISTS section with three view modes (grid / list / tree).
|
|
//
|
|
// Rows are precomputed in Rust (crate::playlist_manager) and pushed as
|
|
// ready-to-render models, so this view does no per-row map lookups.
|
|
// Backend is reused via the PlaylistManagerActions callbacks.
|
|
|
|
import { ScrollView, LineEdit } from "std-widgets.slint";
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import {
|
|
PlaylistManagerState,
|
|
PlaylistManagerActions,
|
|
PmFolderItem,
|
|
PmPlaylistItem,
|
|
PmTreeRow,
|
|
NavState,
|
|
} from "../state.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { PmCollage } from "../primitives/PmCollage.slint";
|
|
import { PmFolderIcon } from "../primitives/PmFolderIcon.slint";
|
|
|
|
// --- Toolbar bits -------------------------------------------------------
|
|
|
|
// Search field — leading magnifier, input, trailing X to clear.
|
|
component PmSearch inherits Rectangle {
|
|
height: 34px;
|
|
// 70% of the former 240px width (task: shrink the PM search input).
|
|
width: 168px;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
border-width: 1px;
|
|
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
|
|
|
|
HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 6px;
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
clip: true;
|
|
input := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
text <=> PlaylistManagerState.search-query;
|
|
edited => {
|
|
PlaylistManagerActions.search-changed(self.text);
|
|
}
|
|
}
|
|
Text {
|
|
visible: input.text == "";
|
|
width: 100%;
|
|
height: 100%;
|
|
text: @tr("Search playlists…");
|
|
color: Theme.text-muted;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
visible: PlaylistManagerState.search-query != "";
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: clear-ta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/x.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: clear-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
clear-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
PlaylistManagerState.search-query = "";
|
|
PlaylistManagerActions.search-changed("");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// One dropdown-menu row.
|
|
component PmMenuItem inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> selected: false;
|
|
// Collapse property — `for x: if c: El` is disallowed in Slint 1.16, so
|
|
// a hidden item collapses to zero height instead (the sidebar pattern).
|
|
in property <bool> shown: true;
|
|
callback activated();
|
|
height: root.shown ? 30px : 0px;
|
|
visible: root.shown;
|
|
border-radius: 5px;
|
|
background: mi-ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
spacing: 8px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.selected ? Theme.accent : Theme.text-secondary;
|
|
font-size: 13px;
|
|
font-weight: root.selected ? 600 : 400;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
overflow: elide;
|
|
}
|
|
if root.selected: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/check.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
}
|
|
mi-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.activated();
|
|
}
|
|
}
|
|
}
|
|
|
|
// A toolbar icon button with a small text label (the dropdown triggers
|
|
// and the New-folder button).
|
|
component PmToolButton inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
callback clicked();
|
|
height: 34px;
|
|
width: row.preferred-width + 22px;
|
|
border-radius: 6px;
|
|
background: root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
|
row := HorizontalLayout {
|
|
x: 11px;
|
|
y: 0;
|
|
height: parent.height;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 15px;
|
|
height: 15px;
|
|
tint: root.active ? #ffffff : Theme.text-secondary;
|
|
}
|
|
}
|
|
if root.label != "": VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.label;
|
|
color: root.active ? #ffffff : Theme.text-secondary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
// A square icon-only toggle (folder/flat + view-mode cycle).
|
|
component PmIconButton inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <bool> active: false;
|
|
callback clicked();
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
background: root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: 16px;
|
|
height: 16px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: root.active ? #ffffff : Theme.text-secondary;
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
// A small hover action button on cards / rows (favorite / visibility /
|
|
// edit / mixtape / reorder arrows).
|
|
component PmActionButton inherits Rectangle {
|
|
in property <image> icon;
|
|
in property <bool> filled: false;
|
|
in property <length> btn-size: 26px;
|
|
in property <length> glyph: 15px;
|
|
callback clicked();
|
|
width: root.btn-size;
|
|
height: root.btn-size;
|
|
border-radius: 5px;
|
|
background: ta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: root.icon;
|
|
width: root.glyph;
|
|
height: root.glyph;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: root.filled ? Theme.accent : (ta.has-hover ? Theme.text-primary : Theme.text-muted);
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Local-availability badge (all-local green Wifi / partial amber Cloud).
|
|
component PmLocalBadge inherits Rectangle {
|
|
in property <string> status;
|
|
// First-class LOCAL playlist (library.db entity, D7) — hard-drive
|
|
// marker; offline-only flavors tint accent (same badge, distinct shade).
|
|
in property <bool> is-local-pl: false;
|
|
in property <bool> offline-only: false;
|
|
visible: root.is-local-pl
|
|
|| root.status == "all_local" || root.status == "some_local";
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 11px;
|
|
background: #00000099;
|
|
QbzIcon {
|
|
source: root.is-local-pl
|
|
? @image-url("../assets/icons/hard-drive.svg")
|
|
: (root.status == "all_local"
|
|
? @image-url("../assets/icons/wifi.svg")
|
|
: @image-url("../assets/icons/cloud-download.svg"));
|
|
width: 13px;
|
|
height: 13px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: root.is-local-pl
|
|
? (root.offline-only ? Theme.accent : Theme.text-secondary)
|
|
: (root.status == "all_local" ? #22c55e : #f59e0b);
|
|
}
|
|
}
|
|
|
|
// --- Folder card / chip -------------------------------------------------
|
|
|
|
component PmFolderCard inherits Rectangle {
|
|
in property <PmFolderItem> folder;
|
|
callback open();
|
|
callback edit();
|
|
// Matches Tauri's `.folder-card`: 160 wide, 16px padding, centered
|
|
// icon/name/count column. Hover reveals the edit pencil (opacity 0->1)
|
|
// and lifts the surface — same affordance as Tauri.
|
|
width: 160px;
|
|
// Tall enough for the 64px icon + name + count + 16px padding without
|
|
// overflowing (128px squeezed the count to the bottom edge); matches
|
|
// Tauri's content-driven `.folder-card`. Keep in sync with `folder-h`.
|
|
height: 150px;
|
|
border-radius: 10px;
|
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
opacity: root.folder.is-hidden ? 0.6 : 1.0;
|
|
|
|
VerticalLayout {
|
|
padding: 16px;
|
|
spacing: 8px;
|
|
alignment: center;
|
|
// Centered icon.
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
PmFolderIcon {
|
|
folder: root.folder;
|
|
size: 64px;
|
|
}
|
|
}
|
|
// Centered single-line name (13px, elided — matches `.folder-name`).
|
|
Text {
|
|
text: root.folder.name;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
font-weight: Typography.medium;
|
|
horizontal-alignment: center;
|
|
overflow: elide;
|
|
height: 18px;
|
|
vertical-alignment: center;
|
|
}
|
|
// Centered count (12px — matches `.folder-count`).
|
|
Text {
|
|
text: root.folder.count == 1
|
|
? @tr("1 playlist")
|
|
: @tr("{} playlists", root.folder.count);
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
horizontal-alignment: center;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
// Hover edit pencil (top-right). Hidden until the card (or the button)
|
|
// is hovered, exactly like Tauri's `.folder-card:hover .folder-edit-btn`.
|
|
edit-btn := Rectangle {
|
|
x: parent.width - self.width - 8px;
|
|
y: 8px;
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
opacity: (ta.has-hover || edit-ta.has-hover) ? 1.0 : 0.0;
|
|
animate opacity { duration: 150ms; }
|
|
background: edit-ta.has-hover ? Theme.surface-card : Theme.surface-elevated;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/pen-line.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
edit-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.edit();
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compact folder CHIP — the LIST-mode folder representation. Mirrors
|
|
// Tauri's `.folder-list-item`: a rounded surface row with a small tinted
|
|
// icon square (36px / 20px glyph) + folder name (13px) + playlist count
|
|
// (11px, hover-revealed) + a hover-revealed edit pencil (12px). Used in a
|
|
// wrapping row, so it sizes to its content.
|
|
component PmFolderChip inherits Rectangle {
|
|
in property <PmFolderItem> folder;
|
|
callback open();
|
|
callback edit();
|
|
height: 52px;
|
|
width: chip-row.preferred-width;
|
|
border-radius: 8px;
|
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
opacity: root.folder.is-hidden ? 0.6 : 1.0;
|
|
|
|
chip-row := HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 8px;
|
|
// Tinted icon square.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmFolderIcon {
|
|
folder: root.folder;
|
|
size: 36px;
|
|
glyph-size: 20px;
|
|
radius: 8px;
|
|
}
|
|
}
|
|
// Folder name (capped width so very long names elide instead of
|
|
// stretching the chip — matches `.folder-list-name` max-width 120px).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.folder.name;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
font-weight: Typography.medium;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
max-width: 120px;
|
|
}
|
|
}
|
|
// Playlist count — revealed on hover (matches `.folder-list-count`).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.folder.count;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
vertical-alignment: center;
|
|
opacity: ta.has-hover ? 1.0 : 0.0;
|
|
animate opacity { duration: 150ms; }
|
|
}
|
|
}
|
|
// Edit pencil — revealed on hover (matches `.folder-list-edit`).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
edit-btn := Rectangle {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
opacity: (ta.has-hover || edit-ta.has-hover) ? 1.0 : 0.0;
|
|
animate opacity { duration: 150ms; }
|
|
background: edit-ta.has-hover ? Theme.surface-card : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/pen-line.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: edit-ta.has-hover ? Theme.text-primary : Theme.text-muted;
|
|
}
|
|
edit-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.edit();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
// Covers the icon + name region; the edit pencil keeps its own click.
|
|
width: parent.width - 32px;
|
|
height: parent.height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Playlist grid card -------------------------------------------------
|
|
|
|
component PmGridCard inherits Rectangle {
|
|
in property <PmPlaylistItem> item;
|
|
in property <int> index;
|
|
in property <int> total;
|
|
in property <bool> can-reorder: false;
|
|
callback open();
|
|
callback favorite();
|
|
callback visibility();
|
|
callback edit();
|
|
callback mixtape();
|
|
callback move-up();
|
|
callback move-down();
|
|
|
|
width: 160px;
|
|
// pad(10) + [reorder 22 + gap 8] + collage 140 + gap 8 + name 18 +
|
|
// gap 8 + footer 22 + pad(10). Tauri's name is a single-line 13px
|
|
// title (the .grid-item .name); we elide instead of 2-line clamp.
|
|
height: root.can-reorder ? 250px : 220px;
|
|
border-radius: 8px;
|
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
|
opacity: root.item.is-hidden ? 0.6 : 1.0;
|
|
|
|
VerticalLayout {
|
|
padding: 10px;
|
|
spacing: 8px;
|
|
// Pack children at the top so the footer sits right under the name
|
|
// (matching Tauri's `.grid-item` flex column — no justify-content,
|
|
// 8px gaps). Without this the layout stretches and pushes the footer
|
|
// to the bottom edge, leaving a large gap after the name.
|
|
alignment: start;
|
|
|
|
// Optional reorder header (custom sort only).
|
|
if root.can-reorder: HorizontalLayout {
|
|
height: 22px;
|
|
alignment: space-between;
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/chevron-up.svg");
|
|
btn-size: 22px;
|
|
glyph: 14px;
|
|
clicked => { root.move-up(); }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/grip-vertical.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/chevron-down.svg");
|
|
btn-size: 22px;
|
|
glyph: 14px;
|
|
clicked => { root.move-down(); }
|
|
}
|
|
}
|
|
|
|
// Artwork + availability badge.
|
|
Rectangle {
|
|
width: 140px;
|
|
height: 140px;
|
|
PmCollage {
|
|
item: root.item;
|
|
size: 140px;
|
|
}
|
|
PmLocalBadge {
|
|
x: parent.width - self.width - 6px;
|
|
y: 6px;
|
|
status: root.item.local-status;
|
|
is-local-pl: root.item.is-local-playlist;
|
|
offline-only: root.item.offline-only;
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: root.item.name;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
width: 140px;
|
|
height: 18px;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
// Footer: meta + actions.
|
|
HorizontalLayout {
|
|
width: 140px;
|
|
height: 22px;
|
|
alignment: space-between;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.item.tracks-line;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
spacing: 0px;
|
|
PmActionButton {
|
|
icon: root.item.is-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg");
|
|
filled: root.item.is-favorite;
|
|
btn-size: 24px;
|
|
glyph: 14px;
|
|
clicked => { root.favorite(); }
|
|
}
|
|
PmActionButton {
|
|
icon: root.item.is-hidden
|
|
? @image-url("../assets/icons/eye-off.svg")
|
|
: @image-url("../assets/icons/eye.svg");
|
|
btn-size: 24px;
|
|
glyph: 14px;
|
|
clicked => { root.visibility(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/pen-line.svg");
|
|
btn-size: 24px;
|
|
glyph: 14px;
|
|
clicked => { root.edit(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
|
btn-size: 24px;
|
|
glyph: 14px;
|
|
clicked => { root.mixtape(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
// Covers the collage + name region (above the footer actions) so a
|
|
// card-body click opens the playlist while the footer buttons keep
|
|
// their own clicks. Sits below the reorder header when present.
|
|
width: parent.width;
|
|
// pad(10) + collage(140) + gap(8) + name(18) = 176.
|
|
height: 176px;
|
|
y: root.can-reorder ? 30px : 0px;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Playlist list row --------------------------------------------------
|
|
|
|
component PmListRow inherits Rectangle {
|
|
in property <PmPlaylistItem> item;
|
|
in property <int> index;
|
|
in property <bool> can-reorder: false;
|
|
// Folder list for the move-to-folder menu ([] = no folders).
|
|
in property <[PmFolderItem]> folders;
|
|
// Local text binding for the menu's folder-search box.
|
|
property <string> folder-query;
|
|
callback open();
|
|
callback favorite();
|
|
callback visibility();
|
|
callback edit();
|
|
callback mixtape();
|
|
callback move-up();
|
|
callback move-down();
|
|
callback move-to-folder(string /* folder id, "" = root */);
|
|
|
|
// Matches Tauri's `.list-item`: a solid surface row (bg-secondary),
|
|
// hover lifts to bg-tertiary. Compact: 8px/12px padding, 48px collage.
|
|
height: 64px;
|
|
border-radius: 6px;
|
|
background: ta.has-hover ? Theme.surface-hover : Theme.surface-card;
|
|
opacity: root.item.is-hidden ? 0.6 : 1.0;
|
|
|
|
HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 12px;
|
|
spacing: 12px;
|
|
|
|
// Reorder controls (custom sort only).
|
|
if root.can-reorder: VerticalLayout {
|
|
alignment: center;
|
|
spacing: 0px;
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/chevron-up.svg");
|
|
btn-size: 20px;
|
|
glyph: 13px;
|
|
clicked => { root.move-up(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/chevron-down.svg");
|
|
btn-size: 20px;
|
|
glyph: 13px;
|
|
clicked => { root.move-down(); }
|
|
}
|
|
}
|
|
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmCollage {
|
|
item: root.item;
|
|
size: 48px;
|
|
radius: 6px;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
spacing: 2px;
|
|
// Name — single-line 14px elided (matches `.list-item .name`),
|
|
// with the LOCAL-playlist marker leading when applicable.
|
|
HorizontalLayout {
|
|
spacing: 6px;
|
|
alignment: start;
|
|
if root.item.is-local-playlist: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/hard-drive.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: root.item.offline-only ? Theme.accent : Theme.text-muted;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.item.name;
|
|
color: Theme.text-primary;
|
|
font-size: 14px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
// Meta line — 12px muted (matches `.list-item .meta`):
|
|
// "N tracks · 1h 43m (3 local)".
|
|
HorizontalLayout {
|
|
spacing: 6px;
|
|
alignment: start;
|
|
Text {
|
|
text: root.item.tracks-line;
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
// Dot + total playtime (Tauri shows this only in the list row).
|
|
if root.item.duration-line != "": Text {
|
|
text: "·";
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
if root.item.duration-line != "": Text {
|
|
text: root.item.duration-line;
|
|
color: Theme.text-muted;
|
|
font-size: 12px;
|
|
}
|
|
if root.item.local-line != "": Text {
|
|
text: root.item.local-line;
|
|
color: #22c55e;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
}
|
|
// Play-count badge — rounded chip (matches `.play-count-badge`).
|
|
if root.item.play-count > 0: VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: pc-row.preferred-width + 16px;
|
|
height: 22px;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
pc-row := HorizontalLayout {
|
|
x: 8px;
|
|
width: self.preferred-width;
|
|
height: parent.height;
|
|
spacing: 4px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/chart-no-axes-column.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.item.play-count;
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Actions — always visible (matches Tauri's `.list-item` buttons).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
spacing: 0px;
|
|
PmActionButton {
|
|
icon: root.item.is-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg");
|
|
filled: root.item.is-favorite;
|
|
clicked => { root.favorite(); }
|
|
}
|
|
PmActionButton {
|
|
icon: root.item.is-hidden
|
|
? @image-url("../assets/icons/eye-off.svg")
|
|
: @image-url("../assets/icons/eye.svg");
|
|
clicked => { root.visibility(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/pen-line.svg");
|
|
clicked => { root.edit(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
|
clicked => { root.mixtape(); }
|
|
}
|
|
// Move-to-folder "..." (only when folders exist).
|
|
if root.folders.length > 0: more-btn := PmActionButton {
|
|
icon: @image-url("../assets/icons/ellipsis.svg");
|
|
clicked => {
|
|
// Reset the filter to the full list each open.
|
|
root.folder-query = "";
|
|
PlaylistManagerActions.search-folders("");
|
|
folder-menu.show();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
folder-menu := PopupWindow {
|
|
x: root.width - 226px;
|
|
y: 30px;
|
|
width: 220px;
|
|
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;
|
|
Text {
|
|
text: @tr("Move to folder");
|
|
color: Theme.text-muted;
|
|
font-size: 11px;
|
|
horizontal-alignment: left;
|
|
}
|
|
// Folder search — only with enough folders to warrant it
|
|
// (matches the sidebar's >=8 threshold). The substring filter
|
|
// runs in Rust into `menu-folders` (Slint has no `contains`).
|
|
if root.folders.length >= 8: Rectangle {
|
|
height: 32px;
|
|
LineEdit {
|
|
height: 28px;
|
|
width: parent.width;
|
|
placeholder-text: @tr("Search folders");
|
|
text <=> root.folder-query;
|
|
edited => {
|
|
PlaylistManagerActions.search-folders(self.text);
|
|
}
|
|
}
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("No folder");
|
|
selected: root.item.folder-id == "";
|
|
activated => { root.move-to-folder(""); }
|
|
}
|
|
// Cap the visible folders at 4 rows (30px each), then scroll.
|
|
ScrollView {
|
|
height: min(PlaylistManagerState.menu-folders.length * 30px, 120px);
|
|
vertical-scrollbar-policy: ScrollBarPolicy.as-needed;
|
|
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
|
VerticalLayout {
|
|
for f in PlaylistManagerState.menu-folders: PmMenuItem {
|
|
// Hide the playlist's current folder (the sidebar's
|
|
// current-folder exclusion).
|
|
shown: f.id != root.item.folder-id;
|
|
label: f.name;
|
|
selected: root.item.folder-id == f.id;
|
|
activated => { root.move-to-folder(f.id); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
width: parent.width - 184px;
|
|
height: parent.height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tree rows ----------------------------------------------------------
|
|
|
|
component PmTreeFolderRow inherits Rectangle {
|
|
in property <PmTreeRow> entry;
|
|
callback toggle();
|
|
callback edit();
|
|
height: 44px;
|
|
border-radius: 8px;
|
|
background: ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 12px;
|
|
spacing: 10px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: root.entry.expanded
|
|
? @image-url("../assets/icons/chevron-down.svg")
|
|
: @image-url("../assets/icons/chevron-right.svg");
|
|
width: 15px;
|
|
height: 15px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmFolderIcon {
|
|
folder: root.entry.folder;
|
|
size: 28px;
|
|
glyph-size: 15px;
|
|
radius: 6px;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.entry.folder.name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.entry.folder.count;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/pen-line.svg");
|
|
clicked => { root.edit(); }
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
width: parent.width - 40px;
|
|
height: parent.height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.toggle();
|
|
}
|
|
}
|
|
}
|
|
|
|
component PmTreePlaylistRow inherits Rectangle {
|
|
in property <PmTreeRow> entry;
|
|
callback open();
|
|
callback favorite();
|
|
callback edit();
|
|
height: 40px;
|
|
border-radius: 6px;
|
|
background: ta.has-hover ? Theme.surface-hover : transparent;
|
|
opacity: root.entry.playlist.is-hidden ? 0.6 : 1.0;
|
|
HorizontalLayout {
|
|
padding-left: root.entry.indent ? 36px : 10px;
|
|
padding-right: 12px;
|
|
spacing: 10px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmCollage {
|
|
item: root.entry.playlist;
|
|
size: 28px;
|
|
radius: 5px;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.entry.playlist.name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.entry.playlist.tracks-line;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
spacing: 0px;
|
|
opacity: ta.has-hover ? 1.0 : 0.0;
|
|
PmActionButton {
|
|
icon: root.entry.playlist.is-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg");
|
|
filled: root.entry.playlist.is-favorite;
|
|
clicked => { root.favorite(); }
|
|
}
|
|
PmActionButton {
|
|
icon: @image-url("../assets/icons/pen-line.svg");
|
|
clicked => { root.edit(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
width: parent.width - 90px;
|
|
height: parent.height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Section header (collapsible) --------------------------------------
|
|
|
|
component PmSectionHeader inherits Rectangle {
|
|
in property <string> title;
|
|
in property <bool> collapsible: false;
|
|
in property <bool> collapsed: false;
|
|
callback toggle();
|
|
height: 28px;
|
|
HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.title;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
}
|
|
if root.collapsible: VerticalLayout {
|
|
alignment: center;
|
|
chev := Rectangle {
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 4px;
|
|
background: chev-ta.has-hover ? Theme.surface-hover : transparent;
|
|
QbzIcon {
|
|
source: root.collapsed
|
|
? @image-url("../assets/icons/chevron-right.svg")
|
|
: @image-url("../assets/icons/chevron-down.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
chev-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.toggle();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
}
|
|
}
|
|
|
|
// === Main view ==========================================================
|
|
|
|
export component PlaylistManagerView inherits Rectangle {
|
|
property <length> grid-gap: 16px;
|
|
property <length> folder-w: 160px;
|
|
property <length> folder-h: 150px;
|
|
// Compact folder-chip cell (LIST mode). Wrapping row of uniform-width
|
|
// chips (Slint has no flex-wrap); 8px gap matches Tauri's `.folders-list`.
|
|
property <length> chip-w: 184px;
|
|
property <length> chip-h: 52px;
|
|
property <length> chip-gap: 8px;
|
|
property <length> card-w: 160px;
|
|
// Card height depends on whether reorder controls show (matches
|
|
// PmGridCard's height).
|
|
property <length> card-h: PlaylistManagerState.can-reorder ? 250px : 220px;
|
|
|
|
// The view-mode cycle button shows the NEXT mode's icon (Tauri).
|
|
property <image> next-view-icon:
|
|
PlaylistManagerState.view-mode == "grid"
|
|
? @image-url("../assets/icons/list.svg")
|
|
: PlaylistManagerState.view-mode == "list"
|
|
? (PlaylistManagerState.folder-mode
|
|
? @image-url("../assets/icons/network.svg")
|
|
: @image-url("../assets/icons/layout-grid.svg"))
|
|
: @image-url("../assets/icons/layout-grid.svg");
|
|
|
|
background: Theme.surface-main;
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// --- Fixed header / toolbar ------------------------------------
|
|
Rectangle {
|
|
height: 56px;
|
|
background: Theme.surface-main;
|
|
|
|
// Left: nav buttons + title.
|
|
HorizontalLayout {
|
|
x: 32px;
|
|
y: 25px - self.height / 2;
|
|
width: self.preferred-width;
|
|
height: self.preferred-height;
|
|
spacing: 16px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
NavButtons { }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Playlist manager");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right: toolbar tools.
|
|
HorizontalLayout {
|
|
x: parent.width - self.preferred-width - 32px;
|
|
y: 25px - self.height / 2;
|
|
width: self.preferred-width;
|
|
height: self.preferred-height;
|
|
spacing: 8px;
|
|
|
|
VerticalLayout { alignment: center; PmSearch { } }
|
|
|
|
// Filter dropdown.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
filter-btn := PmToolButton {
|
|
icon: @image-url("../assets/icons/list-filter.svg");
|
|
label: PlaylistManagerState.filter == "visible"
|
|
? @tr("Visible")
|
|
: (PlaylistManagerState.filter == "hidden" ? @tr("Hidden") : @tr("All"));
|
|
clicked => { filter-menu.show(); }
|
|
}
|
|
filter-menu := PopupWindow {
|
|
x: 0; y: 38px; width: 160px;
|
|
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;
|
|
PmMenuItem {
|
|
label: @tr("All");
|
|
selected: PlaylistManagerState.filter == "all";
|
|
activated => { PlaylistManagerActions.set-filter("all"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Visible");
|
|
selected: PlaylistManagerState.filter == "visible";
|
|
activated => { PlaylistManagerActions.set-filter("visible"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Hidden");
|
|
selected: PlaylistManagerState.filter == "hidden";
|
|
activated => { PlaylistManagerActions.set-filter("hidden"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort dropdown.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
sort-btn := PmToolButton {
|
|
icon: @image-url("../assets/icons/arrow-up-down.svg");
|
|
label: PlaylistManagerState.sort == "recent" ? @tr("Recent")
|
|
: PlaylistManagerState.sort == "playcount" ? @tr("Play Count")
|
|
: PlaylistManagerState.sort == "tracks" ? @tr("Track Count")
|
|
: PlaylistManagerState.sort == "custom" ? @tr("Custom")
|
|
: @tr("Name A-Z");
|
|
clicked => { sort-menu.show(); }
|
|
}
|
|
sort-menu := PopupWindow {
|
|
x: 0; y: 38px; width: 180px;
|
|
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;
|
|
PmMenuItem {
|
|
label: @tr("Name A-Z");
|
|
selected: PlaylistManagerState.sort == "name";
|
|
activated => { PlaylistManagerActions.set-sort("name"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Recent");
|
|
selected: PlaylistManagerState.sort == "recent";
|
|
activated => { PlaylistManagerActions.set-sort("recent"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Play Count");
|
|
selected: PlaylistManagerState.sort == "playcount";
|
|
activated => { PlaylistManagerActions.set-sort("playcount"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Track Count");
|
|
selected: PlaylistManagerState.sort == "tracks";
|
|
activated => { PlaylistManagerActions.set-sort("tracks"); }
|
|
}
|
|
PmMenuItem {
|
|
label: @tr("Custom");
|
|
selected: PlaylistManagerState.sort == "custom";
|
|
activated => { PlaylistManagerActions.set-sort("custom"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Folder / flat toggle.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmIconButton {
|
|
icon: PlaylistManagerState.folder-mode
|
|
? @image-url("../assets/icons/folder-open.svg")
|
|
: @image-url("../assets/icons/rows-3.svg");
|
|
active: PlaylistManagerState.folder-mode;
|
|
clicked => { PlaylistManagerActions.toggle-folder-mode(); }
|
|
}
|
|
}
|
|
|
|
// View-mode cycle (shows next mode's icon).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
PmIconButton {
|
|
icon: root.next-view-icon;
|
|
clicked => {
|
|
if (PlaylistManagerState.folder-mode) {
|
|
PlaylistManagerActions.set-view-mode(
|
|
PlaylistManagerState.view-mode == "grid" ? "list"
|
|
: PlaylistManagerState.view-mode == "list" ? "tree" : "grid");
|
|
} else {
|
|
PlaylistManagerActions.set-view-mode(
|
|
PlaylistManagerState.view-mode == "list" ? "grid" : "list");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// New folder (folder mode only).
|
|
if PlaylistManagerState.folder-mode: VerticalLayout {
|
|
alignment: center;
|
|
PmToolButton {
|
|
icon: @image-url("../assets/icons/folder-plus.svg");
|
|
label: @tr("New folder");
|
|
clicked => { PlaylistManagerActions.new-folder(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Scrolling content -----------------------------------------
|
|
pm-flick := Flickable {
|
|
vertical-stretch: 1;
|
|
viewport-width: self.width;
|
|
viewport-height: page.preferred-height;
|
|
// 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-manager" && 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); }
|
|
|
|
page := VerticalLayout {
|
|
width: parent.width;
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 6px;
|
|
padding-bottom: 100px;
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// Counter + drag hint.
|
|
HorizontalLayout {
|
|
height: 24px;
|
|
spacing: 12px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("{} folders, {} playlists",
|
|
PlaylistManagerState.folder-count,
|
|
PlaylistManagerState.playlist-count);
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
if PlaylistManagerState.can-reorder: VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Use the arrows to reorder playlists");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { height: 14px; }
|
|
|
|
if PlaylistManagerState.loading: HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
|
|
// --- FOLDERS section (folder mode, non-tree) -----------
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.folder-mode
|
|
&& PlaylistManagerState.view-mode != "tree"
|
|
&& PlaylistManagerState.folders.length > 0: VerticalLayout {
|
|
spacing: 12px;
|
|
PmSectionHeader {
|
|
title: @tr("Folders ({})", PlaylistManagerState.folders.length);
|
|
collapsible: true;
|
|
collapsed: PlaylistManagerState.folders-collapsed;
|
|
toggle => { PlaylistManagerActions.toggle-folders-collapsed(); }
|
|
}
|
|
// GRID mode: the big folder cards.
|
|
if !PlaylistManagerState.folders-collapsed
|
|
&& PlaylistManagerState.view-mode == "grid": folders-grid := Rectangle {
|
|
property <int> columns: Math.max(1,
|
|
Math.floor((self.width + root.grid-gap) / (root.folder-w + root.grid-gap)));
|
|
property <int> rows: Math.ceil(PlaylistManagerState.folders.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.folder-h + Math.max(0, self.rows - 1) * root.grid-gap
|
|
: 0px;
|
|
for folder[i] in PlaylistManagerState.folders: PmFolderCard {
|
|
x: Math.mod(i, folders-grid.columns) * (root.folder-w + root.grid-gap);
|
|
y: Math.floor(i / folders-grid.columns) * (root.folder-h + root.grid-gap);
|
|
folder: folder;
|
|
open => { PlaylistManagerActions.edit-folder(folder.id); }
|
|
edit => { PlaylistManagerActions.edit-folder(folder.id); }
|
|
}
|
|
}
|
|
// LIST mode: a wrapping row of compact folder chips
|
|
// (Tauri's `.folders-list`). Uniform-width cells since
|
|
// Slint has no flex-wrap.
|
|
if !PlaylistManagerState.folders-collapsed
|
|
&& PlaylistManagerState.view-mode == "list": folders-chips := Rectangle {
|
|
property <int> columns: Math.max(1,
|
|
Math.floor((self.width + root.chip-gap) / (root.chip-w + root.chip-gap)));
|
|
property <int> rows: Math.ceil(PlaylistManagerState.folders.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.chip-h + Math.max(0, self.rows - 1) * root.chip-gap
|
|
: 0px;
|
|
for folder[i] in PlaylistManagerState.folders: PmFolderChip {
|
|
x: Math.mod(i, folders-chips.columns) * (root.chip-w + root.chip-gap);
|
|
y: Math.floor(i / folders-chips.columns) * (root.chip-h + root.chip-gap);
|
|
width: root.chip-w;
|
|
folder: folder;
|
|
open => { PlaylistManagerActions.edit-folder(folder.id); }
|
|
edit => { PlaylistManagerActions.edit-folder(folder.id); }
|
|
}
|
|
}
|
|
Rectangle { height: 24px; }
|
|
}
|
|
|
|
// --- PLAYLISTS section header (non-tree) ---------------
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.view-mode != "tree": PmSectionHeader {
|
|
title: @tr("Playlists ({})", PlaylistManagerState.playlist-count);
|
|
}
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.view-mode != "tree": Rectangle { height: 14px; }
|
|
|
|
// Empty state.
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.playlists.length == 0
|
|
&& PlaylistManagerState.view-mode != "tree": Text {
|
|
text: @tr("No playlists found.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
|
|
// --- PLAYLISTS grid ------------------------------------
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.view-mode == "grid": pl-grid := Rectangle {
|
|
property <int> columns: Math.max(1,
|
|
Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap)));
|
|
property <int> rows: Math.ceil(PlaylistManagerState.playlists.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.card-h + Math.max(0, self.rows - 1) * root.grid-gap
|
|
: 0px;
|
|
for item[i] in PlaylistManagerState.playlists: PmGridCard {
|
|
x: Math.mod(i, pl-grid.columns) * (root.card-w + root.grid-gap);
|
|
y: Math.floor(i / pl-grid.columns) * (root.card-h + root.grid-gap);
|
|
item: item;
|
|
index: i;
|
|
total: PlaylistManagerState.playlists.length;
|
|
can-reorder: PlaylistManagerState.can-reorder;
|
|
open => { PlaylistManagerActions.open-playlist(item.id); }
|
|
favorite => { PlaylistManagerActions.toggle-favorite(item.id); }
|
|
visibility => { PlaylistManagerActions.toggle-hidden(item.id); }
|
|
edit => { PlaylistManagerActions.edit-playlist(item.id); }
|
|
mixtape => { PlaylistManagerActions.add-to-mixtape(item.id); }
|
|
move-up => { PlaylistManagerActions.move-up(item.id); }
|
|
move-down => { PlaylistManagerActions.move-down(item.id); }
|
|
}
|
|
}
|
|
|
|
// --- PLAYLISTS list ------------------------------------
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.view-mode == "list": VerticalLayout {
|
|
spacing: 4px;
|
|
for item[i] in PlaylistManagerState.playlists: PmListRow {
|
|
item: item;
|
|
index: i;
|
|
can-reorder: PlaylistManagerState.can-reorder;
|
|
folders: PlaylistManagerState.folders;
|
|
open => { PlaylistManagerActions.open-playlist(item.id); }
|
|
favorite => { PlaylistManagerActions.toggle-favorite(item.id); }
|
|
visibility => { PlaylistManagerActions.toggle-hidden(item.id); }
|
|
edit => { PlaylistManagerActions.edit-playlist(item.id); }
|
|
mixtape => { PlaylistManagerActions.add-to-mixtape(item.id); }
|
|
move-up => { PlaylistManagerActions.move-up(item.id); }
|
|
move-down => { PlaylistManagerActions.move-down(item.id); }
|
|
move-to-folder(fid) => { PlaylistManagerActions.move-to-folder(item.id, fid); }
|
|
}
|
|
}
|
|
|
|
// --- TREE view -----------------------------------------
|
|
if !PlaylistManagerState.loading
|
|
&& PlaylistManagerState.view-mode == "tree": VerticalLayout {
|
|
spacing: 2px;
|
|
for tree-row[i] in PlaylistManagerState.tree: VerticalLayout {
|
|
if tree-row.kind == "folder": PmTreeFolderRow {
|
|
entry: tree-row;
|
|
toggle => { PlaylistManagerActions.toggle-tree-folder(tree-row.folder.id); }
|
|
edit => { PlaylistManagerActions.edit-folder(tree-row.folder.id); }
|
|
}
|
|
if tree-row.kind == "playlist": PmTreePlaylistRow {
|
|
entry: tree-row;
|
|
open => { PlaylistManagerActions.open-playlist(tree-row.playlist.id); }
|
|
favorite => { PlaylistManagerActions.toggle-favorite(tree-row.playlist.id); }
|
|
edit => { PlaylistManagerActions.edit-playlist(tree-row.playlist.id); }
|
|
}
|
|
}
|
|
if PlaylistManagerState.tree.length == 0: Text {
|
|
text: @tr("No playlists found.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shared scrollbar in the right gutter (homologated cross-view). Sits
|
|
// below the fixed 56px toolbar so it tracks only the scrolling area.
|
|
ListScrollbar {
|
|
x: parent.width - self.width - 4px;
|
|
y: 56px;
|
|
width: 14px;
|
|
height: parent.height - 56px;
|
|
viewport-height: pm-flick.viewport-height;
|
|
visible-height: pm-flick.height;
|
|
viewport-y <=> pm-flick.viewport-y;
|
|
}
|
|
}
|