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
1432 lines
55 KiB
Plaintext
1432 lines
55 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,
|
|
} 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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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;
|
|
visible: root.status == "all_local" || root.status == "some_local";
|
|
width: 22px;
|
|
height: 22px;
|
|
border-radius: 11px;
|
|
background: #00000099;
|
|
QbzIcon {
|
|
source: root.status == "all_local"
|
|
? @image-url("../assets/icons/wifi.svg")
|
|
: @image-url("../assets/icons/cloud-download.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: 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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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;
|
|
}
|
|
}
|
|
|
|
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`).
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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;
|
|
|
|
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;
|
|
}
|
|
}
|