// 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 label; in property 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 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 icon; in property label; in property 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 icon; in property 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 icon; in property filled: false; in property btn-size: 26px; in property 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 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 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 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 item; in property index; in property total; in property 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 item; in property index; in property 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 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 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 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 title; in property collapsible: false; in property 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 grid-gap: 16px; property folder-w: 160px; property 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 chip-w: 184px; property chip-h: 52px; property chip-gap: 8px; property card-w: 160px; // Card height depends on whether reorder controls show (matches // PmGridCard's height). property card-h: PlaylistManagerState.can-reorder ? 250px : 220px; // The view-mode cycle button shows the NEXT mode's icon (Tauri). property 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 columns: Math.max(1, Math.floor((self.width + root.grid-gap) / (root.folder-w + root.grid-gap))); property 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 columns: Math.max(1, Math.floor((self.width + root.chip-gap) / (root.chip-w + root.chip-gap))); property 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 columns: Math.max(1, Math.floor((self.width + root.grid-gap) / (root.card-w + root.grid-gap))); property 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; } }