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