// Local Library — the user's on-disk + Plex music, browsed inside QBZ. // // Greenfield Slint port. This view uses the app's MANDATORY fixed-chrome // header — the SAME one FavoritesView / Discover use: NavButtons (back/ // forward) + title on row 1 with the tab menu floated right, per-tab action // buttons + toolbar on row 2, a 1px divider, then a single scrolling content // area below. No invented header, no pills (ADR-008): the tabs are underline // tabs (mirroring FavTab), and the settings affordance is a standalone // icon-only circle (the one circular shape ADR-008 permits). // // Folder management, scan, maintenance, and the danger zone do NOT live in // this view — they belong under Settings > Local Library. The gear routes // there. // // The per-tab bodies are filled slice by slice (Albums first). Until a slice // lands the content area shows an empty placeholder so the chrome wiring is // visible end to end. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { LocalLibraryState, LocalLibraryActions, FolderNode, FolderSubcardItem, LocalArtistItem, AlphaJump, LibAlbumFilterState, AppearanceState, NavState } from "../state.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { CircleAction } from "../primitives/CircleAction.slint"; import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { TrackRow } from "../primitives/TrackRow.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint"; import { MultiSelectBar } from "../primitives/MultiSelectBar.slint"; import { TooltipState } from "../state.slint"; import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint"; import { ListView } from "std-widgets.slint"; // The Local Library tabs as one floating group (row 1, right side) — now // the shared Discovery-style SegmentedTabBar, counts rendered as badges. // Order mirrors the existing HeaderBar "Local Library" menu (Albums / // Artists / Folders / Tracks). Counts come from LocalLibraryState (seeded // up front so the badges show without visiting each tab). component LibTabMenu inherits SegmentedTabBar { active-id: LocalLibraryState.active-tab; tabs: [ { id: "albums", label: @tr("Albums"), count: LocalLibraryState.album-count }, { id: "artists", label: @tr("Artists"), count: LocalLibraryState.artist-count }, { id: "folders", label: @tr("Folders"), count: LocalLibraryState.folder-count }, { id: "tracks", label: @tr("Tracks"), count: LocalLibraryState.track-count }, ]; selected(id) => { LocalLibraryActions.select-tab(id); } } // Subfolder cover card for the tree-mode folder-detail pane — 1:1 with Tauri's // `.subfolder-card`: bordered card, square cover (folder placeholder when none), // name (13px/medium) + recursive track count (11px/muted). Clicking drills in. component FolderSubcard inherits Rectangle { in property item; callback clicked(string /* path */); border-radius: 6px; border-width: 1px; border-color: card-ta.has-hover ? Theme.accent : Theme.border-subtle; background: card-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; VerticalLayout { padding: 8px; spacing: 6px; // Square cover (width = card width minus h-padding). Rectangle { height: root.width - 16px; border-radius: 4px; background: Theme.surface-main; clip: true; QbzIcon { source: @image-url("../assets/icons/folder.svg"); width: 32px; height: 32px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: Theme.text-muted; } Image { width: 100%; height: 100%; source: root.item.artwork; image-fit: cover; } } Text { text: root.item.name; color: Theme.text-primary; font-size: 13px; font-weight: Typography.medium; overflow: elide; } Text { text: root.item.track-count + " " + @tr("tracks"); color: Theme.text-muted; font-size: 11px; } } card-ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(root.item.path); } } } // Ephemeral-folder pane — shown in the Folders tab while an ad-hoc folder is // open. Album-grouped (a folder can hold several albums), each block with its // cover + tracks; per-album play appears only for multi-album sessions. The // whole pane replaces the normal flat/tree browse. Metadata-bound actions // (favorite/playlist) are off — ephemeral tracks have no DB row. component EphemeralPane inherits Rectangle { eph-flick := Flickable { viewport-width: self.width; viewport-height: eph-page.preferred-height; eph-page := VerticalLayout { width: parent.width; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 100px; spacing: 14px; alignment: start; // Header: folder name + subtitle (count · path) + Play all + Clear. HorizontalLayout { spacing: 12px; VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 2px; Text { text: LocalLibraryState.ephemeral-name; color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.bold; overflow: elide; } Text { text: LocalLibraryState.ephemeral-track-count + " " + @tr("tracks") + " · " + LocalLibraryState.ephemeral-path; color: Theme.text-muted; font-size: Typography.legal; overflow: elide; } } // Play all (primary) + Clear session — app-standard CircleActions. VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 44px; tooltip: @tr("Play all"); clicked => { LocalLibraryActions.ephemeral-play-all(); } } } VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/x.svg"); diameter: 40px; tooltip: @tr("Clear session"); clicked => { LocalLibraryActions.ephemeral-clear(); } } } } // Scanning. if LocalLibraryState.ephemeral-loading: HorizontalLayout { alignment: center; padding-top: 30px; LoadingSpinner { size: 28px; } } // Empty (no playable tracks). if !LocalLibraryState.ephemeral-loading && LocalLibraryState.ephemeral-albums.length == 0: VerticalLayout { padding-top: 24px; Text { text: @tr("No playable tracks in this folder."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // Album blocks. if !LocalLibraryState.ephemeral-loading: VerticalLayout { spacing: 22px; for album[ai] in LocalLibraryState.ephemeral-albums: VerticalLayout { spacing: 6px; // Album header: cover + title/artist/meta + per-album play. HorizontalLayout { spacing: 12px; Rectangle { width: 56px; height: 56px; border-radius: 6px; background: Theme.surface-hover; clip: true; QbzIcon { source: @image-url("../assets/icons/disc-3.svg"); width: 24px; height: 24px; tint: Theme.text-muted; } Image { width: 100%; height: 100%; source: album.artwork; image-fit: cover; } } VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 2px; HorizontalLayout { spacing: 6px; Text { text: album.title; color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; overflow: elide; horizontal-stretch: 1; } // CUE badge — single-file rip (not a pill, ADR-008). if album.is-cue: VerticalLayout { alignment: center; Rectangle { height: 16px; border-radius: 3px; background: Theme.surface-elevated; HorizontalLayout { padding-left: 6px; padding-right: 6px; Text { text: "CUE"; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; vertical-alignment: center; } } } } } Text { text: album.artist; color: Theme.text-secondary; font-size: Typography.legal; overflow: elide; } if album.meta != "": Text { text: album.meta; color: Theme.text-muted; font-size: Typography.legal; } } if LocalLibraryState.ephemeral-multi-album: VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); diameter: 34px; tooltip: @tr("Play this album"); clicked => { LocalLibraryActions.ephemeral-play-album(album.group-key); } } } } // Album tracks. for track[ti] in album.tracks: TrackRow { track: track; index: ti; show-artwork: false; show-album: false; show-favorite: false; show-download: false; show-source: false; show-menu: false; media-action(kind, id, action) => { LocalLibraryActions.ephemeral-play-track(id); } } } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: eph-flick.viewport-height; visible-height: eph-flick.height; viewport-y <=> eph-flick.viewport-y; } } // Standalone icon-only circle (ADR-008 permits a full circle for an // icon-only control that stands alone) — matches NavButtons' history pair. // Used for the "Local Library settings" gear (management lives in Settings). component CircleIconBtn inherits Rectangle { in property icon; callback clicked(); width: 28px; height: 28px; border-radius: 14px; background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; QbzIcon { source: root.icon; width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: Theme.text-primary; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } } // Grid/list segmented toggle for the Folders flat mode (mirrors Favorites' // ViewToggle). ADR-008: small-radius rectangle, NOT a pill. component ViewToggle inherits Rectangle { in property view-mode; // "grid" | "list" callback set-view(string); width: 60px; height: 30px; border-radius: 6px; background: Theme.surface-elevated; clip: true; HorizontalLayout { ToggleButton { icon: @image-url("../assets/icons/layout-grid.svg"); active: root.view-mode == "grid"; sm: true; clicked => { root.set-view("grid"); } } ToggleButton { icon: @image-url("../assets/icons/list.svg"); active: root.view-mode == "list"; sm: true; clicked => { root.set-view("list"); } } } } // Flat/tree mode segmented toggle for the Folders tab. flat = the album grid // grouped by directory; tree = the two-pane filesystem browser. component ModeToggle inherits Rectangle { in property mode; // "flat" | "tree" callback set-mode(string); width: 60px; height: 30px; border-radius: 6px; background: Theme.surface-elevated; clip: true; HorizontalLayout { ToggleButton { icon: @image-url("../assets/icons/disc-album.svg"); active: root.mode == "flat"; sm: true; clicked => { root.set-mode("flat"); } } ToggleButton { icon: @image-url("../assets/icons/folder-tree.svg"); active: root.mode == "tree"; sm: true; clicked => { root.set-mode("tree"); } } } } // Flat/Tree toggle with TEXT labels (1:1 with Tauri's `.folders-mode-btn`): // ghost buttons, the active one highlighted. // Inherits Rectangle (content-sized) so the buttons render identically in flat // and tree mode regardless of the surrounding toolbar's fill; `alignment: end` // keeps the pair floated to the right. component ModeTextToggle inherits Rectangle { in property mode; // "flat" | "tree" callback set-mode(string); HorizontalLayout { alignment: end; spacing: 2px; Rectangle { height: 30px; width: flat-lbl.preferred-width + 24px; border-radius: 6px; background: root.mode == "flat" ? Theme.surface-elevated : (flat-ta.has-hover ? Theme.surface-hover : transparent); flat-lbl := Text { text: @tr("Flat"); color: root.mode == "flat" ? Theme.text-primary : Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.medium; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } flat-ta := TouchArea { mouse-cursor: pointer; clicked => { root.set-mode("flat"); } } } Rectangle { height: 30px; width: tree-lbl.preferred-width + 24px; border-radius: 6px; background: root.mode == "tree" ? Theme.surface-elevated : (tree-ta.has-hover ? Theme.surface-hover : transparent); tree-lbl := Text { text: @tr("Tree"); color: root.mode == "tree" ? Theme.text-primary : Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.medium; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } tree-ta := TouchArea { mouse-cursor: pointer; clicked => { root.set-mode("tree"); } } } } } // Compact icon button for the tree select bar — icon-only with a hover tooltip. component RailBulkBtn inherits Rectangle { in property icon; in property tooltip; callback clicked(); width: 28px; height: 28px; border-radius: 6px; background: rb-ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: root.icon; width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: rb-ta.has-hover ? Theme.text-primary : Theme.text-secondary; } rb-ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } changed has-hover => { if (self.has-hover && root.tooltip != "") { TooltipState.text = root.tooltip; TooltipState.anchor-x = root.absolute-position.x + root.width / 2; TooltipState.anchor-y = root.absolute-position.y; TooltipState.show = true; } else if (!self.has-hover) { TooltipState.show = false; } } } } // One row of the flattened folder tree (tree mode, left rail). Carries its // `depth` (indent), an expand chevron for folders with content, a folder / // music icon and the segment name. component TreeRow inherits Rectangle { in property node; in property selected; in property select-mode: false; callback toggle(); // chevron — expand/collapse callback activate(); // row body — select folder (load detail) callback toggle-select(); // select-mode checkbox height: 26px; border-radius: 6px; background: root.selected ? Theme.surface-elevated : (row-ta.has-hover ? Theme.surface-hover : transparent); row-ta := TouchArea { mouse-cursor: root.node.is-folder ? pointer : default; clicked => { if (root.node.is-folder) { root.activate(); } } } // Accent bar marking the selected folder (mirrors the underline-tab // accent language; ADR-008: a thin rectangle, not a pill). if root.selected: Rectangle { x: 0; width: 3px; height: parent.height - 8px; y: 4px; background: Theme.accent; border-radius: 1.5px; } HorizontalLayout { padding-left: 8px + root.node.depth * 16px; padding-right: 10px; spacing: 6px; // Select-mode checkbox (track = checked; folder tri-state: all=check, // partial=dash, none=empty). Driven purely by the node state — the row // emits `toggle-select` and the controller rebuilds the model. if root.select-mode: VerticalLayout { alignment: center; Rectangle { property on: root.node.is-folder ? root.node.select-state == 2 : root.node.selected; property partial: root.node.is-folder && root.node.select-state == 1; // Circular checkbox, homologated with the app-wide selection // checkbox (TrackRow): 13px, fully rounded, 8px glyph. width: 13px; height: 13px; border-radius: 6.5px; background: (self.on || self.partial) ? Theme.accent : transparent; border-width: (self.on || self.partial) ? 0px : 1.5px; border-color: cb-ta.has-hover ? Theme.text-primary : Theme.text-muted; QbzIcon { source: parent.partial ? @image-url("../assets/icons/minus.svg") : @image-url("../assets/icons/check.svg"); visible: parent.on || parent.partial; width: 8px; height: 8px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: #ffffff; } cb-ta := TouchArea { mouse-cursor: pointer; clicked => { root.toggle-select(); } } } } // Expand chevron — its own hit target so toggling doesn't select. Rectangle { width: 18px; VerticalLayout { alignment: center; HorizontalLayout { alignment: center; if root.node.can-expand: QbzIcon { source: root.node.expanded ? @image-url("../assets/icons/chevron-down.svg") : @image-url("../assets/icons/chevron-right.svg"); width: 11px; height: 11px; tint: Theme.text-muted; } } } chev-ta := TouchArea { mouse-cursor: root.node.can-expand ? pointer : default; clicked => { if (root.node.can-expand) { root.toggle(); } } } } // Folder / track icon. VerticalLayout { alignment: center; QbzIcon { source: root.node.is-folder ? (root.node.expanded ? @image-url("../assets/icons/folder-open.svg") : @image-url("../assets/icons/folder.svg")) : @image-url("../assets/icons/music.svg"); width: 14px; height: 14px; tint: root.node.is-folder ? Theme.accent : Theme.text-muted; } } // Name. (No trailing count — the tree mixes folders + tracks, so a // bare number would be ambiguous.) VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: root.node.segment; color: root.selected ? Theme.text-primary : Theme.text-secondary; font-size: 13px; font-weight: root.selected ? Typography.semibold : Typography.regular; overflow: elide; } } } } // Vertical A-Z jump strip on the right edge of the artist rail. Emits // jump(ordinal, index); the host scrolls its flickable proportionally. // Ported 1:1 from FavoritesView's AlphaStrip. component AlphaStrip inherits Rectangle { in property <[AlphaJump]> jumps; callback jump(int /* ordinal */, int /* index */); width: 18px; VerticalLayout { alignment: center; spacing: 0px; for j[k] in root.jumps: Rectangle { height: 15px; width: 18px; Text { text: j.letter; color: la.has-hover ? Theme.accent : Theme.text-muted; font-size: 9px; font-weight: Typography.semibold; horizontal-alignment: center; vertical-alignment: center; } la := TouchArea { mouse-cursor: pointer; clicked => { root.jump(k, j.index); } } } } } // One compact row in the Artists left rail — a 48px round avatar (custom/ // cached photo or a mic-style placeholder) + display name + "N albums · // M tracks". 1:1 with Tauri's .artist-card-compact. ADR-008: round avatar is // the permitted icon-only circle; the row itself is a small-radius rectangle. component LocalArtistRow inherits Rectangle { in property item; in property selected; callback clicked(string /* artist name */); height: 60px; border-radius: 8px; background: root.selected ? Theme.accent : (ta.has-hover ? Theme.surface-hover : transparent); HorizontalLayout { padding-left: 8px; padding-right: 8px; spacing: 12px; VerticalLayout { alignment: center; Rectangle { width: 48px; height: 48px; border-radius: 24px; background: Theme.surface-elevated; clip: true; if root.item.image-path == "": QbzIcon { source: @image-url("../assets/icons/user.svg"); width: 22px; height: 22px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: Theme.text-muted; } if root.item.image-path != "": Image { source: root.item.image; image-fit: cover; width: 48px; height: 48px; } } } VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 3px; Text { text: root.item.display-name; color: root.selected ? Theme.accent-text : Theme.text-primary; font-size: Typography.body; font-weight: root.selected ? Typography.semibold : Typography.medium; overflow: elide; } Text { text: "\{root.item.album-count} " + @tr("albums") + " · \{root.item.track-count} " + @tr("tracks"); color: root.selected ? Theme.accent-text : Theme.text-muted; font-size: Typography.legal; overflow: elide; } } } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(root.item.name); } } } // One filter chip in the Albums quality/format/source popup. 6px radius (not // a pill — ADR-008; matches the GenreFilterPopup chip). component FilterChip inherits Rectangle { in property label; in property active; callback toggled(); height: 30px; width: lbl.preferred-width + 22px; border-radius: 6px; border-width: 1px; border-color: root.active ? Theme.accent : Theme.border-subtle; background: root.active ? Theme.accent : (chip-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); lbl := Text { text: root.label; color: root.active ? Theme.accent-text : Theme.text-secondary; font-size: Typography.legal; font-weight: root.active ? Typography.semibold : Typography.regular; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } chip-ta := TouchArea { mouse-cursor: pointer; clicked => { root.toggled(); } } } export component LocalLibraryView inherits Rectangle { background: Theme.surface-main; VerticalLayout { spacing: 0px; // ===================== FIXED CHROME ===================== // Row 1 — back/forward + title (left), tab menu + settings gear // (right). Same structure as FavoritesView row 1. HorizontalLayout { padding-left: 32px; padding-right: 32px; padding-top: 11px; spacing: 16px; VerticalLayout { alignment: center; Text { text: @tr("Local Library"); color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.bold; } } // Settings gear — inline, to the right of the title. VerticalLayout { alignment: center; CircleIconBtn { icon: @image-url("../assets/icons/settings.svg"); clicked => { LocalLibraryActions.open-settings(); } } } Rectangle { horizontal-stretch: 1; } } Rectangle { height: 12px; } // Row 2 — per-tab action buttons (left) + toolbar (right). Filled as // each tab slice lands. vertical-stretch: 0 is load-bearing (see // FavoritesView): without it an empty row grabs leftover height and // inflates the sub-header. HorizontalLayout { vertical-stretch: 0; padding-left: 32px; padding-right: 32px; spacing: 8px; // The section tab menu sits under the title (left of row 2). VerticalLayout { alignment: center; LibTabMenu { } } Rectangle { horizontal-stretch: 1; } // Albums toolbar — search + sort (both server-side). The // grid/list toggle, group-by and filters land with the next slice. if LocalLibraryState.active-tab == "albums" && (LocalLibraryState.albums.length > 0 || LocalLibraryState.albums-search != ""): HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> LocalLibraryState.albums-search; edited(q) => { LocalLibraryActions.albums-search(q); } } } VerticalLayout { alignment: center; QbzSelect { menu-width: 180px; options: [ @tr("Artist A-Z"), @tr("Artist Z-A"), @tr("Title A-Z"), @tr("Title Z-A"), @tr("Year (newest)"), @tr("Year (oldest)"), ]; sm: true; current-index: LocalLibraryState.albums-sort == "artist-asc" ? 0 : LocalLibraryState.albums-sort == "artist-desc" ? 1 : LocalLibraryState.albums-sort == "title-asc" ? 2 : LocalLibraryState.albums-sort == "title-desc" ? 3 : LocalLibraryState.albums-sort == "year-desc" ? 4 : 5; selected(i) => { LocalLibraryActions.albums-set-sort( i == 0 ? "artist-asc" : i == 1 ? "artist-desc" : i == 2 ? "title-asc" : i == 3 ? "title-desc" : i == 4 ? "year-desc" : "year-asc"); } } } // Group-by. VerticalLayout { alignment: center; QbzSelect { menu-width: 160px; sm: true; options: [ @tr("No grouping"), @tr("Group by letter"), @tr("Group by artist"), ]; current-index: LocalLibraryState.albums-group == "alpha" ? 1 : LocalLibraryState.albums-group == "artist" ? 2 : 0; selected(i) => { LocalLibraryActions.albums-set-group( i == 1 ? "alpha" : i == 2 ? "artist" : "off"); } } } // Quality/format/source filter (popup) + active-count badge. VerticalLayout { alignment: center; Rectangle { width: 34px; height: 30px; border-radius: 6px; border-width: 1px; border-color: LibAlbumFilterState.count > 0 ? Theme.accent : Theme.border-subtle; background: filt-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; QbzIcon { source: @image-url("../assets/icons/list-filter.svg"); width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: LibAlbumFilterState.count > 0 ? Theme.accent : Theme.text-secondary; } if LibAlbumFilterState.count > 0: Rectangle { x: parent.width - self.width + 3px; y: -4px; width: 15px; height: 15px; border-radius: 7.5px; background: Theme.accent; Text { text: LibAlbumFilterState.count; color: Theme.accent-text; font-size: 9px; font-weight: Typography.bold; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } } filt-ta := TouchArea { mouse-cursor: pointer; clicked => { LibAlbumFilterState.open = !LibAlbumFilterState.open; } } } } // Grid/list view toggle. VerticalLayout { alignment: center; ViewToggle { view-mode: LocalLibraryState.albums-view-mode; set-view(v) => { LocalLibraryActions.albums-set-view(v); } } } } // Tracks toolbar — multi-select toggle + search + group-by. if LocalLibraryState.active-tab == "tracks" && (LocalLibraryState.tracks.length > 0 || LocalLibraryState.tracks-search != ""): HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; CircleIconBtn { icon: @image-url("../assets/icons/square-check-big.svg"); clicked => { LocalLibraryActions.tracks-toggle-multi-select(); } } } VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> LocalLibraryState.tracks-search; edited(q) => { LocalLibraryActions.tracks-search(q); } } } VerticalLayout { alignment: center; QbzSelect { menu-width: 160px; sm: true; options: [ @tr("No grouping"), @tr("By album"), @tr("By artist"), @tr("By name"), ]; current-index: LocalLibraryState.tracks-group-mode == "album" ? 1 : LocalLibraryState.tracks-group-mode == "artist" ? 2 : LocalLibraryState.tracks-group-mode == "name" ? 3 : 0; selected(i) => { LocalLibraryActions.tracks-set-group( i == 1 ? "album" : i == 2 ? "artist" : i == 3 ? "name" : "off"); } } } } // Folders toolbar — flat mode adds search + sort + group + grid/list; // the Flat/Tree text toggle is always visible. Tree-mode search lives // in the tree rail header (not here), 1:1 with Tauri. if LocalLibraryState.active-tab == "folders" && !LocalLibraryState.ephemeral-active: HorizontalLayout { spacing: 8px; // Flat-only controls: search, sort, group-by, grid/list. if LocalLibraryState.folders-view-mode == "flat" && (LocalLibraryState.folders.length > 0 || LocalLibraryState.folders-search != ""): HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> LocalLibraryState.folders-search; edited(q) => { LocalLibraryActions.folders-search(q); } } } VerticalLayout { alignment: center; QbzSelect { menu-width: 150px; options: [ @tr("Artist A-Z"), @tr("Artist Z-A"), @tr("Title A-Z"), @tr("Title Z-A"), @tr("Year (newest)"), @tr("Year (oldest)"), ]; sm: true; current-index: LocalLibraryState.folders-sort == "artist-asc" ? 0 : LocalLibraryState.folders-sort == "artist-desc" ? 1 : LocalLibraryState.folders-sort == "title-asc" ? 2 : LocalLibraryState.folders-sort == "title-desc" ? 3 : LocalLibraryState.folders-sort == "year-desc" ? 4 : 5; selected(i) => { LocalLibraryActions.folders-set-sort( i == 0 ? "artist-asc" : i == 1 ? "artist-desc" : i == 2 ? "title-asc" : i == 3 ? "title-desc" : i == 4 ? "year-desc" : "year-asc"); } } } VerticalLayout { alignment: center; QbzSelect { menu-width: 150px; options: [ @tr("No grouping"), @tr("Group by letter"), @tr("Group by artist"), ]; sm: true; current-index: LocalLibraryState.folders-group == "alpha" ? 1 : LocalLibraryState.folders-group == "artist" ? 2 : 0; selected(i) => { LocalLibraryActions.folders-set-group( i == 1 ? "alpha" : i == 2 ? "artist" : "off"); } } } VerticalLayout { alignment: center; ViewToggle { view-mode: LocalLibraryState.folders-grid-view; set-view(v) => { LocalLibraryState.folders-grid-view = v; } } } } // Flat/Tree icon toggle (segmented, like Grid/List). Always // visible on the Folders tab, floated right. VerticalLayout { alignment: center; ModeToggle { mode: LocalLibraryState.folders-view-mode; set-mode(m) => { LocalLibraryActions.folders-set-mode(m); } } } } // Artists toolbar — search (filters the master rail in place). if LocalLibraryState.active-tab == "artists" && LocalLibraryState.artists.length > 0: HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search artists"); sm: true; query <=> LocalLibraryState.artists-search; edited(q) => { LocalLibraryActions.artists-search(q); } } } } } Rectangle { height: 14px; } Rectangle { height: 1px; background: Theme.border-subtle; } // ===================== SCROLLING CONTENT ===================== Rectangle { vertical-stretch: 1; clip: true; // -------- Albums tab — chunked grid -------------------------- if LocalLibraryState.active-tab == "albums": Rectangle { // Initial page load. if LocalLibraryState.albums-loading: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } } // Load error + retry. if !LocalLibraryState.albums-loading && LocalLibraryState.albums-load-failed: VerticalLayout { alignment: center; spacing: 10px; Text { text: @tr("Couldn't load your local albums."); color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; horizontal-alignment: center; } HorizontalLayout { alignment: center; Rectangle { width: retry-label.preferred-width + 28px; height: 32px; border-radius: 6px; background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; border-width: 1px; border-color: Theme.border-subtle; retry-label := Text { text: @tr("Retry"); color: Theme.text-primary; font-size: Typography.legal; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } retry-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.albums-retry(); } } } } } // Empty — no albums indexed yet. if !LocalLibraryState.albums-loading && !LocalLibraryState.albums-load-failed && LocalLibraryState.albums.length == 0 && LocalLibraryState.albums-search == "": VerticalLayout { alignment: center; Text { text: @tr("No albums in your local library yet."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // No-match — search or the quality/format/source filter emptied // the visible set (the full set still has albums). if !LocalLibraryState.albums-loading && !LocalLibraryState.albums-load-failed && LocalLibraryState.albums.length > 0 && LocalLibraryState.albums-visible.length == 0 && LocalLibraryState.albums-grouped.length == 0: VerticalLayout { alignment: center; Text { text: @tr("No albums match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // Grid / list / grouped — self-scrolling (full-load, derived). if !LocalLibraryState.albums-loading && !LocalLibraryState.albums-load-failed && (LocalLibraryState.albums-visible.length > 0 || LocalLibraryState.albums-grouped.length > 0): Rectangle { albums-flick := Flickable { viewport-width: self.width; viewport-height: albums-page.preferred-height; // Scroll-position restore on back/forward (nav::scope_for → "ll:albums"). property restored: false; function restore-scroll() { if (!self.restored && NavState.restore-scope == "ll:albums" && self.viewport-height > self.height) { self.viewport-y = NavState.scroll-restore; self.restored = true; // Consume the arm so a later fresh re-mount of this // tab does not replay a stale position. NavState.restore-scope = ""; } } init => { self.restore-scroll(); } changed viewport-height => { self.restore-scroll(); } changed viewport-y => { NavState.report-scroll(self.viewport-y); } albums-page := VerticalLayout { width: parent.width; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 100px; spacing: 0px; alignment: start; AlbumCollectionView { albums: LocalLibraryState.albums-visible; grouped: LocalLibraryState.albums-grouped; is-grouped: LocalLibraryState.albums-group != "off"; view-mode: LocalLibraryState.albums-view-mode; // List view shows the SOURCE column (local/offline/plex). show-source: true; // Local albums aren't Qobuz-favoritable — hide the heart. show-favorite: false; // Always-visible source badge (local | qobuz-offline | plex). show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); } open-artist(id) => { LocalLibraryActions.open-artist(id); } media-action(kind, id, action) => { LocalLibraryActions.album-action(id, action); } } } } // A-Z jump strip (alpha grouping; proportional like the rail). if LocalLibraryState.albums-group == "alpha" && LocalLibraryState.albums-alpha.length > 0: AlphaStrip { x: parent.width - self.width - 2px; height: 100%; jumps: LocalLibraryState.albums-alpha; jump(ord, idx) => { albums-flick.viewport-y = -(ord / Math.max(1, LocalLibraryState.albums-alpha.length - 1)) * Math.max(0, (albums-flick.viewport-height - albums-flick.height) / 1px) * 1px; } } ListScrollbar { x: LocalLibraryState.albums-group == "alpha" ? parent.width - self.width - 22px : parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: albums-flick.viewport-height; visible-height: albums-flick.height; viewport-y <=> albums-flick.viewport-y; } } } // -------- Tracks tab — server-paginated flat list ------------ if LocalLibraryState.active-tab == "tracks": Rectangle { if LocalLibraryState.tracks-loading: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } } if !LocalLibraryState.tracks-loading && LocalLibraryState.tracks-load-failed: VerticalLayout { alignment: center; spacing: 10px; Text { text: @tr("Couldn't load your local tracks."); color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; horizontal-alignment: center; } HorizontalLayout { alignment: center; Rectangle { width: tretry-label.preferred-width + 28px; height: 32px; border-radius: 6px; background: tretry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; border-width: 1px; border-color: Theme.border-subtle; tretry-label := Text { text: @tr("Retry"); color: Theme.text-primary; font-size: Typography.legal; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } tretry-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.tracks-retry(); } } } } } if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed && LocalLibraryState.tracks.length == 0 && LocalLibraryState.tracks-search == "": VerticalLayout { alignment: center; Text { text: @tr("No tracks in your local library yet."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed && LocalLibraryState.tracks.length == 0 && LocalLibraryState.tracks-search != "": VerticalLayout { alignment: center; Text { text: @tr("No tracks match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // No-match (search/filter emptied the visible set). if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed && LocalLibraryState.tracks.length > 0 && LocalLibraryState.tracks-visible.length == 0: VerticalLayout { alignment: center; Text { text: @tr("No tracks match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed && LocalLibraryState.tracks-visible.length > 0: Rectangle { VerticalLayout { padding-left: 32px; padding-right: 32px; padding-top: 16px; // Bulk action bar (Tauri Tracks subset — no make-offline/remove). if LocalLibraryState.tracks-multi-select: MultiSelectBar { selected-count: LocalLibraryState.tracks-selected-count; actions: [ { id: "select-all", label: @tr("Select all"), danger: false, needs-selection: false }, { id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true }, { id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true }, { id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true }, { id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), danger: false, needs-selection: true }, { id: "clear", label: @tr("Clear"), danger: false, needs-selection: true }, ]; action(id) => { LocalLibraryActions.tracks-bulk-action(id); } } if LocalLibraryState.tracks-multi-select: Rectangle { height: 8px; } tracks-list := ListView { vertical-stretch: 1; vertical-scrollbar-policy: ScrollBarPolicy.always-off; // Scroll-position restore on back/forward (nav::scope_for → "ll:tracks"). // Best-effort on this paginated list: restores when the // saved position falls within already-loaded pages. property restored: false; function restore-scroll() { if (!self.restored && NavState.restore-scope == "ll:tracks" && self.viewport-height > self.height) { self.viewport-y = NavState.scroll-restore; self.restored = true; NavState.restore-scope = ""; } } init => { self.restore-scroll(); } changed viewport-height => { self.restore-scroll(); } changed viewport-y => { NavState.report-scroll(self.viewport-y); if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px && LocalLibraryState.tracks-has-more && !LocalLibraryState.tracks-loading-more && !LocalLibraryState.tracks-loading) { LocalLibraryActions.tracks-load-more(); } } for track[i] in LocalLibraryState.tracks-visible: VerticalLayout { spacing: 0px; // Group header rows for album / artist grouping. if LocalLibraryState.tracks-group-mode == "album" && track.album != "" && (i == 0 || LocalLibraryState.tracks-visible[i - 1].album != track.album): Rectangle { height: 32px; Text { x: 2px; width: parent.width - 4px; height: 100%; text: track.album; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; vertical-alignment: center; overflow: elide; } } if LocalLibraryState.tracks-group-mode == "artist" && track.artist != "" && (i == 0 || LocalLibraryState.tracks-visible[i - 1].artist != track.artist): Rectangle { height: 32px; Text { x: 2px; width: parent.width - 4px; height: 100%; text: track.artist; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; vertical-alignment: center; overflow: elide; } } TrackRow { track: track; index: i; show-artwork: AppearanceState.local-library-track-artwork; show-album: LocalLibraryState.tracks-group-mode != "album"; show-favorite: false; show-download: false; show-source: true; // Library surface: local menu even for // qobuz_download rows (spec §3.4). force-local-menu: true; multi-select-mode: LocalLibraryState.tracks-multi-select; media-action(kind, id, action) => { if (kind == "album" && action == "open") { LocalLibraryActions.open-album(id); } else if (kind == "artist" && action == "open") { LocalLibraryActions.open-artist(id); } else { LocalLibraryActions.track-action(id, action); } } } } } } // A-Z jump strip for name grouping (far right; scrollbar insets). if LocalLibraryState.tracks-group-mode == "name" && LocalLibraryState.tracks-alpha.length > 0: AlphaStrip { x: parent.width - self.width - 2px; height: 100%; jumps: LocalLibraryState.tracks-alpha; jump(ord, idx) => { tracks-list.viewport-y = -idx * 50px; } } ListScrollbar { x: LocalLibraryState.tracks-group-mode == "name" ? parent.width - self.width - 22px : parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: tracks-list.viewport-height; visible-height: tracks-list.visible-height; viewport-y <=> tracks-list.viewport-y; } } } // -------- Folders tab — flat grid/list OR tree two-pane ------ if LocalLibraryState.active-tab == "folders": Rectangle { // Ephemeral pane — replaces the normal browse while a folder is // open ad-hoc (see EphemeralPane below). if LocalLibraryState.ephemeral-active: EphemeralPane { } // Initial flat load. if LocalLibraryState.folders-loading && !LocalLibraryState.ephemeral-active: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } } // Empty — nothing indexed. if !LocalLibraryState.folders-loading && !LocalLibraryState.ephemeral-active && LocalLibraryState.folders.length == 0 && LocalLibraryState.folders-search == "": VerticalLayout { alignment: center; Text { text: @tr("No folders in your local library yet."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // ===================== FLAT MODE ===================== if !LocalLibraryState.folders-loading && !LocalLibraryState.ephemeral-active && LocalLibraryState.folders.length > 0 && LocalLibraryState.folders-view-mode == "flat": Rectangle { // Search matched nothing. if LocalLibraryState.folders-visible.length == 0 && LocalLibraryState.folders-grouped.length == 0 && LocalLibraryState.folders-search != "": VerticalLayout { alignment: center; Text { text: @tr("No folders match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } if LocalLibraryState.folders-visible.length > 0 || LocalLibraryState.folders-grouped.length > 0: Rectangle { folders-flick := Flickable { viewport-width: self.width; viewport-height: folders-page.preferred-height; // Scroll-position restore on back/forward (nav::scope_for → "ll:folders"). property restored: false; function restore-scroll() { if (!self.restored && NavState.restore-scope == "ll:folders" && self.viewport-height > self.height) { self.viewport-y = NavState.scroll-restore; self.restored = true; NavState.restore-scope = ""; } } init => { self.restore-scroll(); } changed viewport-height => { self.restore-scroll(); } changed viewport-y => { NavState.report-scroll(self.viewport-y); } folders-page := VerticalLayout { width: parent.width; padding-left: 32px; padding-right: 32px; padding-top: 16px; padding-bottom: 100px; alignment: start; AlbumCollectionView { albums: LocalLibraryState.folders-visible; grouped: LocalLibraryState.folders-grouped; is-grouped: LocalLibraryState.folders-group != "off"; view-mode: LocalLibraryState.folders-grid-view; show-source: false; show-favorite: false; show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); } open-artist(id) => { LocalLibraryActions.open-artist(id); } media-action(kind, id, action) => { LocalLibraryActions.album-action(id, action); } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: folders-flick.viewport-height; visible-height: folders-flick.height; viewport-y <=> folders-flick.viewport-y; } } } // ===================== TREE MODE ===================== if !LocalLibraryState.folders-loading && !LocalLibraryState.ephemeral-active && LocalLibraryState.folders.length > 0 && LocalLibraryState.folders-view-mode == "tree": tree-cols := HorizontalLayout { spacing: 0px; // ---- Left rail: header (select / collapse-all / open // ephemeral / search) + the flattened folder tree ---- Rectangle { width: LocalLibraryState.tree-sidebar-width; VerticalLayout { padding-left: 12px; padding-right: 8px; padding-top: 12px; padding-bottom: 8px; spacing: 8px; // Header toolbar. HorizontalLayout { spacing: 4px; // Select mode. VerticalLayout { alignment: center; Rectangle { width: 30px; height: 30px; border-radius: 6px; background: LocalLibraryState.tree-select-mode ? Theme.surface-elevated : (selm-ta.has-hover ? Theme.surface-hover : transparent); QbzIcon { source: @image-url("../assets/icons/square-check-big.svg"); width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: LocalLibraryState.tree-select-mode ? Theme.accent : (selm-ta.has-hover ? Theme.text-primary : Theme.text-secondary); } selm-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.folders-toggle-select-mode(); } } } } // Collapse all. VerticalLayout { alignment: center; Rectangle { width: 30px; height: 30px; border-radius: 6px; background: colap-ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: @image-url("../assets/icons/chevron-up.svg"); width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: colap-ta.has-hover ? Theme.text-primary : Theme.text-secondary; } colap-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.folders-collapse-all(); } } } } // Open ephemeral folder. VerticalLayout { alignment: center; Rectangle { width: 30px; height: 30px; border-radius: 6px; background: eopen-ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: @image-url("../assets/icons/folder-open.svg"); width: 15px; height: 15px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: eopen-ta.has-hover ? Theme.text-primary : Theme.text-secondary; } eopen-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.ephemeral-open(); } } } } // Search box (stretches). Rectangle { horizontal-stretch: 1; height: 30px; border-radius: 6px; background: Theme.surface-card; border-width: 1px; border-color: tree-search-input.has-focus ? Theme.accent : Theme.border-subtle; clip: true; HorizontalLayout { padding-left: 8px; padding-right: 6px; spacing: 6px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/search.svg"); width: 13px; height: 13px; tint: Theme.text-muted; } } Rectangle { horizontal-stretch: 1; clip: true; tree-search-input := TextInput { width: 100%; height: 100%; color: Theme.text-primary; font-size: 12px; vertical-alignment: center; single-line: true; text <=> LocalLibraryState.folders-tree-search; edited => { LocalLibraryActions.folders-tree-search(self.text); } } Text { visible: tree-search-input.text == ""; width: 100%; height: 100%; text: @tr("Search folders"); color: Theme.text-muted; font-size: 12px; vertical-alignment: center; overflow: elide; } } } } } // Compact bulk bar (select mode + selection) — icon // buttons with tooltips; the rail is narrow. if LocalLibraryState.tree-select-mode && LocalLibraryState.tree-selected-count > 0: Rectangle { height: 36px; border-radius: 8px; background: Theme.surface-elevated; HorizontalLayout { padding-left: 10px; padding-right: 6px; spacing: 2px; VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: LocalLibraryState.tree-selected-count + " " + @tr("sel"); color: Theme.text-secondary; font-size: Typography.legal; } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/folder-check.svg"); tooltip: @tr("Check all"); clicked => { LocalLibraryActions.folders-bulk-action("select-all"); } } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/list-plus.svg"); tooltip: @tr("Play next"); clicked => { LocalLibraryActions.folders-bulk-action("play-next"); } } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/list-end.svg"); tooltip: @tr("Add to queue"); clicked => { LocalLibraryActions.folders-bulk-action("queue"); } } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/list-music.svg"); tooltip: @tr("Add to playlist"); clicked => { LocalLibraryActions.folders-bulk-action("add-to-playlist"); } } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/cassette-tape.svg"); tooltip: @tr("Add to Mixtape/Collection"); clicked => { LocalLibraryActions.folders-bulk-action("add-to-mixtape"); } } } VerticalLayout { alignment: center; RailBulkBtn { icon: @image-url("../assets/icons/x.svg"); tooltip: @tr("Clear"); clicked => { LocalLibraryActions.folders-bulk-action("clear"); } } } } } // The tree. if LocalLibraryState.folder-tree-loading: HorizontalLayout { alignment: center; padding-top: 20px; LoadingSpinner { size: 28px; } } if !LocalLibraryState.folder-tree-loading: tree-list := ListView { vertical-stretch: 1; for node[i] in LocalLibraryState.folder-tree-visible: TreeRow { node: node; selected: node.path == LocalLibraryState.folders-selected-path; select-mode: LocalLibraryState.tree-select-mode; toggle => { LocalLibraryActions.folders-toggle-node(node.path, !node.expanded); } activate => { LocalLibraryActions.folders-select(node.path, node.segment); } toggle-select => { if (node.is-folder) { LocalLibraryActions.folders-toggle-folder-select(node.path); } else { LocalLibraryActions.folders-toggle-track-select(node.path); } } } } } } // Divider — also the drag handle to resize the rail. Min = // 50% of the default width; max = 50% of the content area. Rectangle { width: 7px; // The visible 1px line, centred in the grab area. Rectangle { width: 1px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; background: drag-ta.has-hover || drag-ta.pressed ? Theme.accent : Theme.border-subtle; } drag-ta := TouchArea { mouse-cursor: ew-resize; property start-w: 272px; pointer-event(e) => { if (e.kind == PointerEventKind.down) { self.start-w = LocalLibraryState.tree-sidebar-width; } } moved => { if (self.pressed) { LocalLibraryState.tree-sidebar-width = Math.max( 136px, Math.min( tree-cols.width / 2, self.start-w + (self.mouse-x - self.pressed-x))); } } } } // ---- Right pane: selected folder detail ---- Rectangle { horizontal-stretch: 1; // Nothing selected yet. if LocalLibraryState.folders-selected-path == "": VerticalLayout { alignment: center; Text { text: @tr("Select a folder to see its contents."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } if LocalLibraryState.folders-selected-path != "": Rectangle { detail-flick := Flickable { viewport-width: self.width; viewport-height: detail-page.preferred-height; detail-page := VerticalLayout { width: parent.width; padding-left: 28px; padding-right: 28px; padding-top: 16px; padding-bottom: 100px; spacing: 14px; alignment: start; // Header: circular Play + folder name + // recursive count + search + grid/list toggle. HorizontalLayout { spacing: 12px; VerticalLayout { alignment: center; CircleAction { icon: @image-url("../assets/icons/play-fill.svg"); primary: true; diameter: 40px; tooltip: @tr("Play all"); clicked => { LocalLibraryActions.folders-play-node( LocalLibraryState.folders-selected-path); } } } VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 2px; Text { text: LocalLibraryState.folders-selected-name; color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.semibold; overflow: elide; } Text { text: LocalLibraryState.folder-detail-track-count + " " + @tr("tracks"); color: Theme.text-muted; font-size: Typography.legal; } } VerticalLayout { alignment: center; HorizontalLayout { spacing: 8px; // Persistent subfolder name search (1:1 Tauri). Rectangle { width: 200px; height: 30px; border-radius: 6px; background: Theme.surface-card; border-width: 1px; border-color: detail-search-input.has-focus ? Theme.accent : Theme.border-subtle; clip: true; HorizontalLayout { padding-left: 10px; padding-right: 8px; spacing: 6px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/search.svg"); width: 14px; height: 14px; tint: Theme.text-muted; } } Rectangle { horizontal-stretch: 1; clip: true; detail-search-input := TextInput { width: 100%; height: 100%; color: Theme.text-primary; font-size: 12px; vertical-alignment: center; single-line: true; text <=> LocalLibraryState.folder-detail-search; edited => { LocalLibraryActions.folder-detail-search(self.text); } } Text { visible: detail-search-input.text == ""; width: 100%; height: 100%; text: @tr("Search albums..."); color: Theme.text-muted; font-size: 12px; vertical-alignment: center; overflow: elide; } } } } VerticalLayout { alignment: center; ViewToggle { view-mode: LocalLibraryState.folder-detail-view; set-view(v) => { LocalLibraryState.folder-detail-view = v; } } } } } } if LocalLibraryState.folder-detail-loading: HorizontalLayout { alignment: center; padding-top: 30px; LoadingSpinner { size: 28px; } } // Subfolders — cover grid (or list), 1:1 with Tauri. if !LocalLibraryState.folder-detail-loading && LocalLibraryState.folder-detail-subfolders.length > 0: VerticalLayout { spacing: 12px; Text { text: @tr("Folders"); color: Theme.text-secondary; font-size: 12px; font-weight: Typography.semibold; letter-spacing: 0.5px; } if LocalLibraryState.folder-detail-subfolders-visible.length == 0: VerticalLayout { padding-top: 8px; Text { text: @tr("No folders match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // Grid view — wrapping cover cards. if LocalLibraryState.folder-detail-view == "grid" && LocalLibraryState.folder-detail-subfolders-visible.length > 0: subgrid := Rectangle { property gap: 12px; property min-cw: 140px; property cols: Math.max(1, Math.floor((self.width + self.gap) / (self.min-cw + self.gap))); property cw: (self.width - (self.cols - 1) * self.gap) / self.cols; property ch: self.cw + 42px; property rws: Math.ceil(LocalLibraryState.folder-detail-subfolders-visible.length / self.cols); height: self.rws > 0 ? self.rws * self.ch + (self.rws - 1) * self.gap : 0px; for sub[i] in LocalLibraryState.folder-detail-subfolders-visible: FolderSubcard { x: Math.mod(i, subgrid.cols) * (subgrid.cw + subgrid.gap); y: Math.floor(i / subgrid.cols) * (subgrid.ch + subgrid.gap); width: subgrid.cw; height: subgrid.ch; item: sub; clicked(path) => { LocalLibraryActions.folders-select(path, ""); } } } // List view — compact rows. if LocalLibraryState.folder-detail-view == "list" && LocalLibraryState.folder-detail-subfolders-visible.length > 0: VerticalLayout { spacing: 2px; for sub[si] in LocalLibraryState.folder-detail-subfolders-visible: Rectangle { height: 34px; border-radius: 6px; background: subrow-ta.has-hover ? Theme.surface-hover : transparent; HorizontalLayout { padding-left: 8px; padding-right: 10px; spacing: 8px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/folder.svg"); width: 14px; height: 14px; tint: Theme.text-muted; } } VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: sub.name; color: Theme.text-primary; font-size: 13px; overflow: elide; } } VerticalLayout { alignment: center; Text { text: sub.track-count; color: Theme.text-muted; font-size: 11px; } } } subrow-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.folders-select(sub.path, ""); } } } } } // Direct tracks. if !LocalLibraryState.folder-detail-loading && LocalLibraryState.folder-detail-tracks.length > 0: VerticalLayout { spacing: 6px; Text { text: @tr("Tracks"); color: Theme.text-secondary; font-size: 12px; font-weight: Typography.semibold; letter-spacing: 0.5px; } for track[ti] in LocalLibraryState.folder-detail-tracks: TrackRow { track: track; index: ti; show-artwork: AppearanceState.local-library-track-artwork; show-album: true; show-favorite: false; show-download: false; show-source: true; // Library surface: local menu even // for qobuz_download rows (§3.4). force-local-menu: true; media-action(kind, id, action) => { if (kind == "album" && action == "open") { LocalLibraryActions.open-album(id); } else if (kind == "artist" && action == "open") { LocalLibraryActions.open-artist(id); } else if (action == "play") { LocalLibraryActions.folders-play-track(id); } else { // Context-menu actions (play-next / // queue / add-to-playlist) — the rows // are real library rows, so the shared // track-action arm resolves them. LocalLibraryActions.track-action(id, action); } } } } // Folder has no direct tracks (only subfolders). if !LocalLibraryState.folder-detail-loading && LocalLibraryState.folder-detail-tracks.length == 0 && LocalLibraryState.folder-detail-subfolders.length == 0: VerticalLayout { padding-top: 24px; Text { text: @tr("This folder has no tracks."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: detail-flick.viewport-height; visible-height: detail-flick.height; viewport-y <=> detail-flick.viewport-y; } } } } } // -------- Artists tab — two-column master/detail ------------- if LocalLibraryState.active-tab == "artists": Rectangle { if LocalLibraryState.artists-loading: VerticalLayout { alignment: center; HorizontalLayout { alignment: center; LoadingSpinner { size: 36px; } } } // Empty library. if !LocalLibraryState.artists-loading && LocalLibraryState.artists.length == 0: VerticalLayout { alignment: center; spacing: 10px; HorizontalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/user.svg"); width: 44px; height: 44px; tint: Theme.text-muted; } } Text { text: @tr("No artists in your local library yet."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // Two-column layout. if !LocalLibraryState.artists-loading && LocalLibraryState.artists.length > 0: HorizontalLayout { spacing: 0px; // ---- Left rail: merged, A-Z grouped master list ---- Rectangle { width: 280px; clip: true; VerticalLayout { padding-left: 24px; padding-top: 12px; spacing: 8px; // Rail header — count after filter. Text { text: "\{LocalLibraryState.artists-shown} " + @tr("artists"); color: Theme.text-muted; font-size: Typography.legal; font-weight: Typography.semibold; } // No-match state. if LocalLibraryState.artists-shown == 0: Text { text: @tr("No artists match your search."); color: Theme.text-muted; font-size: Typography.legal; } if LocalLibraryState.artists-shown > 0: Rectangle { vertical-stretch: 1; rail-flick := Flickable { viewport-width: self.width; viewport-height: rail-col.preferred-height; // Scroll-position restore on back/forward (nav::scope_for → "ll:artists"). property restored: false; function restore-scroll() { if (!self.restored && NavState.restore-scope == "ll:artists" && self.viewport-height > self.height) { self.viewport-y = NavState.scroll-restore; self.restored = true; NavState.restore-scope = ""; } } init => { self.restore-scroll(); } changed viewport-height => { self.restore-scroll(); } changed viewport-y => { NavState.report-scroll(self.viewport-y); } rail-col := VerticalLayout { width: parent.width; padding-right: 22px; padding-bottom: 40px; spacing: 4px; alignment: start; for section[s] in LocalLibraryState.artists-grouped: VerticalLayout { spacing: 2px; Text { text: section.letter; color: Theme.text-secondary; font-size: Typography.legal; font-weight: Typography.semibold; } for artist[i] in section.artists: LocalArtistRow { item: artist; selected: LocalLibraryState.artists-selected-name == artist.name; clicked(name) => { LocalLibraryActions.artists-select(name); } } } } } if LocalLibraryState.artists-alpha.length > 0: AlphaStrip { x: parent.width - self.width - 2px; height: 100%; jumps: LocalLibraryState.artists-alpha; jump(ord, idx) => { rail-flick.viewport-y = -(ord / Math.max(1, LocalLibraryState.artists-alpha.length - 1)) * Math.max(0, (rail-flick.viewport-height - rail-flick.height) / 1px) * 1px; } } } } } // Divider. Rectangle { width: 1px; background: Theme.border-subtle; } // ---- Right pane: selected artist's albums ---- Rectangle { horizontal-stretch: 1; clip: true; // Nothing selected. if LocalLibraryState.artists-selected-name == "": VerticalLayout { alignment: center; spacing: 10px; HorizontalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/user.svg"); width: 44px; height: 44px; tint: Theme.text-muted; } } Text { text: @tr("Select an artist to view their albums"); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } if LocalLibraryState.artists-selected-name != "": Rectangle { VerticalLayout { padding-left: 28px; padding-right: 8px; padding-top: 14px; spacing: 12px; // Header — artist name + album count. VerticalLayout { spacing: 2px; Text { text: LocalLibraryState.artists-selected-display; color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.bold; overflow: elide; } Text { text: "\{LocalLibraryState.artists-selected-albums.length} " + @tr("albums"); color: Theme.text-muted; font-size: Typography.legal; } } if LocalLibraryState.artists-selected-loading: HorizontalLayout { alignment: center; padding-top: 30px; LoadingSpinner { size: 28px; } } // No albums for this artist. if !LocalLibraryState.artists-selected-loading && LocalLibraryState.artists-selected-albums.length == 0: VerticalLayout { padding-top: 24px; Text { text: @tr("No albums found"); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // Album grid — self-scrolling. if !LocalLibraryState.artists-selected-loading && LocalLibraryState.artists-selected-albums.length > 0: Rectangle { vertical-stretch: 1; artist-albums-flick := Flickable { viewport-width: self.width; viewport-height: artist-albums-page.preferred-height; artist-albums-page := VerticalLayout { width: parent.width; padding-right: 20px; padding-bottom: 100px; alignment: start; AlbumCollectionView { albums: LocalLibraryState.artists-selected-albums; is-grouped: false; view-mode: "grid"; show-source: false; show-favorite: false; show-source-badge: true; open-album(id) => { LocalLibraryActions.open-album(id); } open-artist(id) => { LocalLibraryActions.open-artist(id); } media-action(kind, id, action) => { LocalLibraryActions.album-action(id, action); } } } } ListScrollbar { x: parent.width - self.width - 4px; width: 14px; height: 100%; viewport-height: artist-albums-flick.viewport-height; visible-height: artist-albums-flick.height; viewport-y <=> artist-albums-flick.viewport-y; } } } } } } } // -------- Other tabs — placeholder (all four now have bodies) - if LocalLibraryState.active-tab != "albums" && LocalLibraryState.active-tab != "tracks" && LocalLibraryState.active-tab != "folders" && LocalLibraryState.active-tab != "artists": VerticalLayout { alignment: center; spacing: 6px; Text { text: @tr("Local Library port in progress"); horizontal-alignment: center; color: Theme.text-secondary; font-size: Typography.body; font-weight: Typography.semibold; } Text { text: @tr("This tab is coming next."); horizontal-alignment: center; color: Theme.text-muted; font-size: Typography.legal; } } } } // ===== Albums quality/format/source filter popup (overlay) ===== // Sibling of the content VerticalLayout (child of the view Rectangle), so // it FLOATS over the content instead of taking a layout slot. if LibAlbumFilterState.open: Rectangle { // Click-out backdrop (covers the whole view). TouchArea { mouse-cursor: default; clicked => { LibAlbumFilterState.open = false; } } Rectangle { width: 372px; height: filt-col.preferred-height; x: parent.width - self.width - 36px; y: 92px; background: Theme.surface-card; border-radius: 10px; border-width: 1px; border-color: Theme.border-subtle; drop-shadow-blur: 28px; drop-shadow-color: #00000080; TouchArea { } filt-col := VerticalLayout { padding: 16px; spacing: 10px; HorizontalLayout { Text { text: @tr("Filter"); color: Theme.text-primary; font-size: Typography.heading; font-weight: Typography.semibold; horizontal-stretch: 1; vertical-alignment: center; } if LibAlbumFilterState.count > 0: Rectangle { width: clr.preferred-width + 18px; height: 28px; border-radius: 6px; background: clr-ta.has-hover ? Theme.surface-hover : transparent; clr := Text { text: @tr("Clear"); color: Theme.accent; font-size: Typography.legal; font-weight: Typography.medium; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; } clr-ta := TouchArea { mouse-cursor: pointer; clicked => { LocalLibraryActions.albums-clear-filter(); } } } } // Quality. Text { text: @tr("Quality"); color: Theme.text-muted; font-size: Typography.legal; font-weight: Typography.semibold; } HorizontalLayout { spacing: 8px; FilterChip { label: @tr("Hi-Res"); active: LibAlbumFilterState.hires; toggled => { LibAlbumFilterState.hires = !LibAlbumFilterState.hires; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: @tr("CD"); active: LibAlbumFilterState.cd; toggled => { LibAlbumFilterState.cd = !LibAlbumFilterState.cd; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: @tr("Lossy"); active: LibAlbumFilterState.lossy; toggled => { LibAlbumFilterState.lossy = !LibAlbumFilterState.lossy; LocalLibraryActions.albums-filter-changed(); } } Rectangle { horizontal-stretch: 1; } } // Format. Text { text: @tr("Format"); color: Theme.text-muted; font-size: Typography.legal; font-weight: Typography.semibold; } HorizontalLayout { spacing: 8px; FilterChip { label: "FLAC"; active: LibAlbumFilterState.flac; toggled => { LibAlbumFilterState.flac = !LibAlbumFilterState.flac; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: "ALAC"; active: LibAlbumFilterState.alac; toggled => { LibAlbumFilterState.alac = !LibAlbumFilterState.alac; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: "APE"; active: LibAlbumFilterState.ape; toggled => { LibAlbumFilterState.ape = !LibAlbumFilterState.ape; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: "WAV"; active: LibAlbumFilterState.wav; toggled => { LibAlbumFilterState.wav = !LibAlbumFilterState.wav; LocalLibraryActions.albums-filter-changed(); } } Rectangle { horizontal-stretch: 1; } } HorizontalLayout { spacing: 8px; FilterChip { label: "MP3"; active: LibAlbumFilterState.mp3; toggled => { LibAlbumFilterState.mp3 = !LibAlbumFilterState.mp3; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: "AAC"; active: LibAlbumFilterState.aac; toggled => { LibAlbumFilterState.aac = !LibAlbumFilterState.aac; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: @tr("Other"); active: LibAlbumFilterState.other; toggled => { LibAlbumFilterState.other = !LibAlbumFilterState.other; LocalLibraryActions.albums-filter-changed(); } } Rectangle { horizontal-stretch: 1; } } // Source. Text { text: @tr("Source"); color: Theme.text-muted; font-size: Typography.legal; font-weight: Typography.semibold; } HorizontalLayout { spacing: 8px; FilterChip { label: @tr("Local"); active: LibAlbumFilterState.local; toggled => { LibAlbumFilterState.local = !LibAlbumFilterState.local; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: @tr("Offline cache"); active: LibAlbumFilterState.offline; toggled => { LibAlbumFilterState.offline = !LibAlbumFilterState.offline; LocalLibraryActions.albums-filter-changed(); } } FilterChip { label: "Plex"; active: LibAlbumFilterState.plex; toggled => { LibAlbumFilterState.plex = !LibAlbumFilterState.plex; LocalLibraryActions.albums-filter-changed(); } } Rectangle { horizontal-stretch: 1; } } } } } }