// 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 } from "../state.slint"; import { NavButtons } from "../shell/NavButtons.slint"; import { QbzIcon } from "../primitives/QbzIcon.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 { ListView } from "std-widgets.slint"; // One tab in the Local Library tab menu — underline style (ADR-008: NO // pills). 1:1 with Favorites' FavTab so the chrome reads identically. component LibTab inherits Rectangle { in property label; in property id; in property count; callback selected(string); property active: LocalLibraryState.active-tab == root.id; height: 34px; width: tab-row.preferred-width; tab-row := HorizontalLayout { padding-left: 4px; padding-right: 4px; spacing: 6px; Text { text: root.label; color: root.active ? Theme.text-primary : Theme.text-muted; font-size: Typography.body; font-weight: root.active ? Typography.semibold : Typography.regular; vertical-alignment: center; } if root.count > 0: Text { text: root.count; color: Theme.text-muted; font-size: Typography.legal; vertical-alignment: center; } } // Active underline (NOT a pill background). Rectangle { y: parent.height - 2px; height: 2px; width: parent.width; background: root.active ? Theme.accent : transparent; border-radius: 1px; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.selected(root.id); } } } // The Local Library tabs as one floating group (row 1, right side). Order // mirrors the existing HeaderBar "Local Library" menu (Albums / Artists / // Folders / Tracks). Counts come from LocalLibraryState (0 until seeded). component LibTabMenu inherits HorizontalLayout { spacing: 18px; VerticalLayout { alignment: center; LibTab { label: @tr("Albums"); id: "albums"; count: LocalLibraryState.album-count; selected(id) => { LocalLibraryActions.select-tab(id); } } } VerticalLayout { alignment: center; LibTab { label: @tr("Artists"); id: "artists"; count: LocalLibraryState.artist-count; selected(id) => { LocalLibraryActions.select-tab(id); } } } VerticalLayout { alignment: center; LibTab { label: @tr("Folders"); id: "folders"; count: LocalLibraryState.folder-count; selected(id) => { LocalLibraryActions.select-tab(id); } } } VerticalLayout { alignment: center; LibTab { label: @tr("Tracks"); id: "tracks"; count: LocalLibraryState.track-count; selected(id) => { LocalLibraryActions.select-tab(id); } } } } // 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: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; tint: Theme.text-primary; } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } } } 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; NavButtons { } } VerticalLayout { alignment: center; Text { text: @tr("Local Library"); color: Theme.text-primary; font-size: Typography.section; font-weight: Typography.bold; } } Rectangle { horizontal-stretch: 1; } VerticalLayout { alignment: center; LibTabMenu { } } VerticalLayout { alignment: center; CircleIconBtn { icon: @image-url("../assets/icons/settings.svg"); clicked => { LocalLibraryActions.open-settings(); } } } } 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; 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"); } } } } // Tracks toolbar — search. if LocalLibraryState.active-tab == "tracks" && (LocalLibraryState.tracks.length > 0 || LocalLibraryState.tracks-search != ""): HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; ExpandableSearch { placeholder: @tr("Search"); sm: true; query <=> LocalLibraryState.tracks-search; edited(q) => { LocalLibraryActions.tracks-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: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } 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; } } // Empty — search matched nothing. if !LocalLibraryState.albums-loading && !LocalLibraryState.albums-load-failed && LocalLibraryState.albums.length == 0 && LocalLibraryState.albums-search != "": VerticalLayout { alignment: center; Text { text: @tr("No albums match your search."); color: Theme.text-muted; font-size: Typography.body; horizontal-alignment: center; } } // The grid — self-scrolling, paged on scroll-near-bottom. if !LocalLibraryState.albums-loading && !LocalLibraryState.albums-load-failed && LocalLibraryState.albums.length > 0: Rectangle { albums-flick := Flickable { viewport-width: self.width; viewport-height: albums-page.preferred-height; changed viewport-y => { if (-self.viewport-y + self.height >= self.viewport-height - 600px && LocalLibraryState.albums-has-more && !LocalLibraryState.albums-loading-more && !LocalLibraryState.albums-loading) { LocalLibraryActions.albums-load-more(); } } 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; is-grouped: false; view-mode: "grid"; show-source: false; // 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); } } if LocalLibraryState.albums-loading-more: HorizontalLayout { alignment: center; padding-top: 16px; LoadingSpinner { size: 24px; } } } } ListScrollbar { x: 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: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; } 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; } } if !LocalLibraryState.tracks-loading && !LocalLibraryState.tracks-load-failed && LocalLibraryState.tracks.length > 0: Rectangle { // Padded content, but the scrollbar floats at the view's // TRUE right edge — matching the Albums grid, which is the // chrome-bezel standard. (Previously it was inset by the // 32px content padding, sitting too far into the content.) VerticalLayout { padding-left: 32px; padding-right: 32px; padding-top: 16px; tracks-list := ListView { width: 100%; height: 100%; vertical-scrollbar-policy: ScrollBarPolicy.always-off; changed 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: TrackRow { track: track; index: i; show-artwork: false; show-album: true; show-favorite: false; show-download: false; 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); } } } } } ListScrollbar { x: 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; } } } // -------- Other tabs — placeholder until their slices land ---- if LocalLibraryState.active-tab != "albums" && LocalLibraryState.active-tab != "tracks": 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; } } } } }