// Settings > Local Library — the folder-management surface relocated here // from the Tauri browse view's inline gear panel (per the existing decision // in local_library.rs: management lives in Settings, the browse-view gear // routes here). Folder list (add/remove/edit/enable) + filter + maintenance // (cleanup missing) + two-step danger-zone clear. The scan engine + progress // bar land with Slice B (LibraryScanState). ADR-008: no pills — plain // bordered icon buttons + square badges. import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; import { Radius } from "../foundation/radius.slint"; import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions } from "../state.slint"; import { SettingRow } from "SettingRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzCheckbox } from "../primitives/QbzCheckbox.slint"; import { QbzToggle } from "../primitives/QbzToggle.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint"; import { ExpandableSearch } from "../primitives/ExpandableSearch.slint"; import { ScrollView, LineEdit } from "std-widgets.slint"; component GroupHeader inherits Text { color: Theme.text-muted; font-size: 11px; letter-spacing: 1.5px; font-weight: Typography.semibold; } // Matches OfflineSettings/AudioSettings' SecondaryButton. component SecondaryButton inherits Rectangle { in property label; in property danger: false; in property enabled: true; callback clicked(); // 170px floor keeps the settings right-edge grid; longer translations grow. width: Math.max(label-txt.preferred-width + 32px, 170px); height: 34px; border-radius: Radius.sm; border-width: 1px; border-color: root.danger ? #e0564f : Theme.border-subtle; background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated; opacity: root.enabled ? 1.0 : 0.5; label-txt := Text { width: 100%; height: 100%; text: root.label; color: root.danger ? #e0564f : Theme.text-secondary; font-size: Typography.body; font-weight: Typography.medium; horizontal-alignment: center; vertical-alignment: center; } ta := TouchArea { mouse-cursor: root.enabled ? pointer : default; clicked => { if (root.enabled) { root.clicked(); } } } } // Plain bordered icon button for the folder toolbar (ADR-008: NOT a pill). component IconBtn inherits Rectangle { in property icon; in property enabled: true; callback clicked(); width: 34px; height: 34px; border-radius: Radius.sm; border-width: 1px; border-color: Theme.border-subtle; background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated; opacity: root.enabled ? 1.0 : 0.4; QbzIcon { source: root.icon; width: 16px; height: 16px; x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; tint: Theme.text-secondary; } ta := TouchArea { mouse-cursor: root.enabled ? pointer : default; clicked => { if (root.enabled) { root.clicked(); } } } } // Shared column geometry for the folder table (header + rows MUST match). // checkbox 20 · folder (stretch) · last-scan 120 · status 84 · actions 76. component FolderTableHeader inherits Rectangle { height: 26px; HorizontalLayout { padding-left: 10px; padding-right: 12px; spacing: 12px; Rectangle { width: 20px; } Text { text: @tr("FOLDER"); horizontal-stretch: 1; color: Theme.text-muted; font-size: Typography.legal; letter-spacing: 0.5px; vertical-alignment: center; } Text { text: @tr("LAST SCAN"); width: 120px; color: Theme.text-muted; font-size: Typography.legal; letter-spacing: 0.5px; vertical-alignment: center; } Text { text: @tr("STATUS"); width: 84px; color: Theme.text-muted; font-size: Typography.legal; letter-spacing: 0.5px; vertical-alignment: center; } Rectangle { width: 76px; } } } // One folder row — a compact table row aligned to FolderTableHeader's columns. component FolderRow inherits Rectangle { in property display-name; in property last-scan-label; in property enabled-folder; in property is-network; in property accessible; in property selected; in property scanning; callback toggle(); callback sync(); callback remove(); height: 40px; border-radius: Radius.sm; background: root.selected ? Theme.surface-elevated : (ta.has-hover ? Theme.surface-hover : transparent); // Declared FIRST so it sits BENEATH the per-row IconBtns: their own // TouchAreas (on top) take their clicks (sync/remove), while clicks on the // empty row area fall through to here to toggle selection. (Was last — // i.e. on top — which swallowed the button clicks into a select-toggle.) ta := TouchArea { mouse-cursor: pointer; clicked => { root.toggle(); } } HorizontalLayout { padding-left: 10px; padding-right: 12px; spacing: 12px; // Select. VerticalLayout { width: 20px; alignment: center; QbzCheckbox { checked: root.selected; toggled => { root.toggle(); } } } // FOLDER: type glyph + name. HorizontalLayout { horizontal-stretch: 1; spacing: 8px; VerticalLayout { alignment: center; QbzIcon { source: root.is-network ? @image-url("../assets/icons/network.svg") : @image-url("../assets/icons/hard-drive.svg"); width: 16px; height: 16px; tint: root.is-network ? (root.accessible ? #3fae6a : #e0564f) : Theme.text-secondary; } } VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: root.display-name; color: root.enabled-folder ? Theme.text-primary : Theme.text-muted; font-size: Typography.body; overflow: elide; } } } // LAST SCAN. VerticalLayout { width: 120px; alignment: center; Text { text: root.last-scan-label; color: Theme.text-muted; font-size: Typography.legal; overflow: elide; } } // STATUS. VerticalLayout { width: 84px; alignment: center; Text { text: !root.enabled-folder ? @tr("Disabled") : ((root.is-network && !root.accessible) ? @tr("Unavailable") : @tr("Active")); color: (root.is-network && !root.accessible && root.enabled-folder) ? #e0564f : Theme.text-muted; font-size: Typography.legal; font-weight: Typography.medium; overflow: elide; } } // ACTIONS: per-folder sync + delete (recycled IconBtns). HorizontalLayout { width: 76px; spacing: 4px; alignment: end; VerticalLayout { alignment: center; IconBtn { icon: @image-url("../assets/icons/refresh-cw.svg"); enabled: root.enabled-folder && !root.scanning; clicked => { root.sync(); } } } VerticalLayout { alignment: center; IconBtn { icon: @image-url("../assets/icons/trash-2.svg"); clicked => { root.remove(); } } } } } } export component LocalLibrarySettings inherits VerticalLayout { spacing: 4px; // Lazy-load the folder list each time the panel is shown (the `if // section==4` mount in SettingsView creates this fresh on entry). The // Plex panel below self-loads here too (seed from store + warm cache). init => { LibraryManageActions.load(); PlexAuthActions.load(); } // --- Library folders ------------------------------------------------- HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; horizontal-stretch: 1; GroupHeader { text: @tr("LIBRARY FOLDERS"); } } // Toolbar: scan-all / add / edit (one selected) / remove (any selected). IconBtn { icon: @image-url("../assets/icons/refresh-cw.svg"); enabled: !LibraryScanState.scanning; clicked => { LibraryManageActions.scan-all(); } } IconBtn { icon: @image-url("../assets/icons/folder-plus.svg"); clicked => { LibraryManageActions.add-folder(); } } IconBtn { icon: @image-url("../assets/icons/pencil.svg"); enabled: LibraryFoldersState.selected-count == 1; clicked => { LibraryManageActions.edit-folder(0); } } IconBtn { icon: @image-url("../assets/icons/trash-2.svg"); enabled: LibraryFoldersState.selected-count > 0; clicked => { LibraryManageActions.remove-folders(); } } } Rectangle { height: 6px; } // Filter. HorizontalLayout { ExpandableSearch { placeholder: @tr("Filter folders..."); sm: true; query <=> LibraryFoldersState.filter; edited(q) => { LibraryManageActions.set-filter(q); } } Rectangle { horizontal-stretch: 1; } } Rectangle { height: 8px; } // Folder list. if LibraryFoldersState.folders.length == 0 && !LibraryFoldersState.loading: Text { text: LibraryFoldersState.filter == "" ? @tr("No folders yet. Add a folder to build your local library.") : @tr("No folders match your filter."); color: Theme.text-muted; font-size: Typography.body; } // Compact table: sticky header + a scrolling body (internal scroll, capped // height) aligned to the same columns. Rows recycle the sync/delete buttons. if LibraryFoldersState.folders.length > 0: VerticalLayout { spacing: 0px; FolderTableHeader { } Rectangle { height: 1px; background: Theme.border-subtle; } Rectangle { height: Math.min(LibraryFoldersState.folders.length * 41px, 360px); ScrollView { horizontal-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { spacing: 1px; for f in LibraryFoldersState.folders: FolderRow { display-name: f.display-name; last-scan-label: f.last-scan-label; enabled-folder: f.enabled; is-network: f.is-network; accessible: f.accessible; selected: f.selected; scanning: LibraryScanState.scanning; toggle => { LibraryManageActions.toggle-folder-select(f.id); } sync => { LibraryManageActions.scan-folder(f.id); } remove => { LibraryManageActions.remove-folder(f.id); } } } } } } // --- Scan progress (determinate; only while scanning) ---------------- if LibraryScanState.scanning: Rectangle { height: scan-box.preferred-height + 24px; VerticalLayout { scan-box := VerticalLayout { padding-top: 14px; spacing: 8px; HorizontalLayout { Text { text: @tr("Scanning") + ": \{LibraryScanState.processed-files} / \{LibraryScanState.total-files}"; color: Theme.text-secondary; font-size: Typography.body; font-weight: Typography.medium; horizontal-stretch: 1; vertical-alignment: center; } // Stop scan. Rectangle { // Size to content. The inner HorizontalLayout fills this // Rectangle, so the old `x: (parent-self)/2` centering // collapsed to 0 and the glyph touched the border — use // explicit padding on the row instead (same fix as the // album VersionPicker). width: stop-row.preferred-width; height: 30px; border-radius: Radius.sm; border-width: 1px; border-color: #e0564f; background: stop-ta.has-hover ? #e0564f22 : transparent; stop-row := HorizontalLayout { padding-left: 12px; padding-right: 12px; spacing: 6px; VerticalLayout { alignment: center; QbzIcon { source: @image-url("../assets/icons/x.svg"); width: 13px; height: 13px; tint: #e0564f; } } VerticalLayout { alignment: center; Text { text: @tr("Stop"); color: #e0564f; font-size: Typography.legal; font-weight: Typography.semibold; } } } stop-ta := TouchArea { mouse-cursor: pointer; clicked => { LibraryManageActions.stop-scan(); } } } } // Determinate bar (2 rectangles; ADR-008-clean). Rectangle { height: 6px; border-radius: 3px; background: Theme.surface-elevated; clip: true; Rectangle { x: 0; width: parent.width * LibraryScanState.progress; height: parent.height; border-radius: 3px; background: Theme.accent; } } if LibraryScanState.current-file != "": Text { text: LibraryScanState.current-file; color: Theme.text-muted; font-size: Typography.legal; overflow: elide; } } } } Rectangle { height: 22px; } // --- Albums view ------------------------------------------------------- // Same pref as the Albums-tab header dropdown — this is the discoverable // home, the header is the quick switch (both write albums-id-mode). GroupHeader { text: @tr("ALBUMS VIEW"); } SettingRow { label: @tr("Album grouping"); description: @tr("Folders: one card per album folder — best for compilations and box sets. Metadata: split by album + artist tags."); VerticalLayout { alignment: center; QbzSelect { menu-width: 190px; sm: true; options: [ @tr("Albums by folder"), @tr("Albums by metadata"), ]; current-index: LocalLibraryState.albums-id-mode == "metadata" ? 1 : 0; selected(i) => { LocalLibraryActions.albums-set-id-mode(i == 1 ? "metadata" : "folder"); } } } } Rectangle { height: 22px; } // --- Maintenance ----------------------------------------------------- GroupHeader { text: @tr("MAINTENANCE"); } SettingRow { label: @tr("Cleanup missing files"); description: @tr("Remove tracks whose files no longer exist on disk."); VerticalLayout { alignment: center; spacing: 4px; SecondaryButton { label: LibraryFoldersState.cleaning-missing ? @tr("Cleaning up...") : @tr("Cleanup"); enabled: !LibraryFoldersState.cleaning-missing; clicked => { LibraryManageActions.cleanup-missing(); } } if LibraryFoldersState.cleanup-status != "": Text { text: LibraryFoldersState.cleanup-status; color: Theme.text-muted; font-size: Typography.legal; horizontal-alignment: right; } } } Rectangle { height: 22px; } // --- Danger zone ----------------------------------------------------- GroupHeader { text: @tr("DANGER ZONE"); } SettingRow { label: @tr("Clear library database"); description: @tr("Remove all indexed tracks. Your audio files are not deleted."); SecondaryButton { label: LibraryFoldersState.clearing-library ? @tr("Clearing...") : @tr("Clear all"); danger: true; enabled: !LibraryFoldersState.clearing-library; clicked => { LibraryManageActions.clear-library(); } } } Rectangle { height: 28px; } // =================================================================== // PLEX — LAN-only Plex Media Server connection (Settings > Plex). // Body renders only when `enabled && !ui-collapsed`. ADR-008: no pills. // =================================================================== // Header row: title + master toggle + collapse chevron. HorizontalLayout { spacing: 10px; VerticalLayout { alignment: center; horizontal-stretch: 1; spacing: 2px; GroupHeader { text: @tr("PLEX"); vertical-alignment: center; } Text { text: @tr("Connect a Plex Media Server on your local network."); color: Theme.text-muted; font-size: 12px; wrap: word-wrap; } } // Collapse chevron — moved to row level so it is vertically centered // with the master toggle (it used to be inline with the title and sat // too high), and enlarged (28px hit-box / 18px glyph). if PlexSettingsState.enabled: VerticalLayout { alignment: center; Rectangle { width: 28px; height: 28px; border-radius: Radius.sm; background: chev-ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: PlexSettingsState.ui-collapsed ? @image-url("../assets/icons/chevron-right.svg") : @image-url("../assets/icons/chevron-down.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: Theme.text-secondary; } chev-ta := TouchArea { mouse-cursor: pointer; clicked => { PlexSettingsState.ui-collapsed = !PlexSettingsState.ui-collapsed; PlexAuthActions.collapse-toggle(PlexSettingsState.ui-collapsed); } } } } VerticalLayout { alignment: center; QbzToggle { checked: PlexSettingsState.enabled; toggled(v) => { PlexSettingsState.enabled = v; PlexAuthActions.enable-toggle(v); } } } } // Body — only when enabled and not collapsed. if PlexSettingsState.enabled && !PlexSettingsState.ui-collapsed: VerticalLayout { spacing: 4px; Rectangle { height: 10px; } // --- Server address ------------------------------------------------ SettingRow { label: @tr("Server address"); description: @tr("Only local network servers are supported."); HorizontalLayout { width: 240px; VerticalLayout { alignment: center; horizontal-stretch: 1; LineEdit { text: PlexSettingsState.server-url; placeholder-text: @tr("http://127.0.0.1:32400"); // Hotkey-guard probe: a use-site `changed has-focus` on a std-widgets // input never fires (has-focus is an alias into the inner TextInput), // so observe it through a local property instead (#619). property guard-focused: self.has-focus; changed guard-focused => { UiFocusState.text-input-focused = self.guard-focused; } edited(s) => { PlexSettingsState.server-url = s; } accepted(s) => { PlexAuthActions.set-server-url(s); } } } } } // LAN-only inline warning (read-only indicator). if !PlexSettingsState.is-local-address && PlexSettingsState.server-url != "": Text { text: @tr("Only local network servers are supported."); color: #e0564f; font-size: Typography.legal; wrap: word-wrap; } Rectangle { height: 8px; } // --- Authorize (PIN) ---------------------------------------------- // Hidden in manual-token mode. if !PlexSettingsState.manual-token-mode: SettingRow { label: @tr("Authorize"); description: @tr("Generate a code and sign in to Plex in your browser."); VerticalLayout { alignment: center; SecondaryButton { label: PlexSettingsState.pin-busy ? @tr("Working...") : @tr("Generate code"); enabled: PlexSettingsState.enabled && PlexSettingsState.is-local-address && !PlexSettingsState.pin-busy; clicked => { PlexAuthActions.generate-code(); } } } } // --- Code block (only while a code exists) ------------------------ if PlexSettingsState.pin-code != "": SettingRow { label: @tr("Link code"); description: @tr("Enter this code at the Plex sign-in page."); HorizontalLayout { spacing: 8px; VerticalLayout { alignment: center; Rectangle { width: code-text.preferred-width + 20px; height: 34px; border-radius: Radius.sm; border-width: 1px; border-color: Theme.border-subtle; background: Theme.surface-elevated; code-text := Text { x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; text: PlexSettingsState.pin-code; color: Theme.text-primary; font-size: Typography.body; font-weight: Typography.semibold; letter-spacing: 1.5px; } } } SecondaryButton { label: @tr("Copy code"); clicked => { PlexAuthActions.copy-code(); } } SecondaryButton { label: @tr("Open Plex sign-in"); clicked => { PlexAuthActions.open-auth-url(); } } } } Rectangle { height: 8px; } // --- Manual token toggle + field ---------------------------------- SettingRow { label: @tr("Enter token manually"); description: @tr("Use an existing X-Plex-Token instead of the PIN flow."); VerticalLayout { alignment: center; QbzToggle { checked: PlexSettingsState.manual-token-mode; toggled(v) => { PlexSettingsState.manual-token-mode = v; PlexAuthActions.manual-token-toggle(v); } } } } if PlexSettingsState.manual-token-mode: SettingRow { label: @tr("Token"); HorizontalLayout { width: 240px; VerticalLayout { alignment: center; horizontal-stretch: 1; LineEdit { text: PlexSettingsState.token-input; placeholder-text: @tr("X-Plex-Token"); // Hotkey-guard probe: a use-site `changed has-focus` on a std-widgets // input never fires (has-focus is an alias into the inner TextInput), // so observe it through a local property instead (#619). property guard-focused: self.has-focus; changed guard-focused => { UiFocusState.text-input-focused = self.guard-focused; } input-type: password; edited(s) => { PlexSettingsState.token-input = s; } accepted(s) => { PlexAuthActions.set-token(s); } } } } } Rectangle { height: 14px; } // --- Connection actions ------------------------------------------- SettingRow { label: @tr("Check connection"); description: @tr("Verify the server is reachable."); VerticalLayout { alignment: center; SecondaryButton { label: @tr("Check connection"); enabled: PlexSettingsState.can-use && !PlexSettingsState.busy; clicked => { PlexAuthActions.ping(); } } } } SettingRow { label: @tr("Libraries"); description: @tr("Fetch your Plex music libraries."); VerticalLayout { alignment: center; SecondaryButton { label: @tr("Get libraries"); enabled: PlexSettingsState.can-use && !PlexSettingsState.busy; clicked => { PlexAuthActions.load-sections(); } } } } // Status line (resolved (key,args) -> text; highlighted when ok). if PlexSettingsState.status-text != "": Text { text: PlexSettingsState.status-text; color: PlexSettingsState.status-kind == 3 ? #e0564f : (PlexSettingsState.status-kind == 2 ? #3fae6a : Theme.text-muted); font-size: Typography.legal; wrap: word-wrap; } Rectangle { height: 6px; } // --- Section multi-select grid ------------------------------------ if PlexSettingsState.sections.length > 0: VerticalLayout { spacing: 2px; GroupHeader { text: @tr("MUSIC LIBRARIES"); } Rectangle { height: 4px; } Rectangle { height: Math.min(PlexSettingsState.sections.length * 44px, 264px); ScrollView { horizontal-scrollbar-policy: ScrollBarPolicy.always-off; VerticalLayout { spacing: 2px; for sec in PlexSettingsState.sections: Rectangle { height: 42px; border-radius: Radius.sm; background: sec-ta.has-hover ? Theme.surface-hover : transparent; HorizontalLayout { padding-left: 10px; padding-right: 12px; spacing: 12px; VerticalLayout { alignment: center; QbzCheckbox { checked: sec.selected; toggled => { PlexAuthActions.toggle-section(sec.key); } } } VerticalLayout { alignment: center; horizontal-stretch: 1; Text { text: sec.title; color: Theme.text-primary; font-size: Typography.body; overflow: elide; } } VerticalLayout { alignment: center; Text { text: @tr("({} tracks)", sec.count); color: Theme.text-muted; font-size: Typography.legal; } } } sec-ta := TouchArea { mouse-cursor: pointer; clicked => { PlexAuthActions.toggle-section(sec.key); } } } } } } Rectangle { height: 14px; } } // --- Metadata write toggle ---------------------------------------- SettingRow { label: @tr("Write metadata to Plex (experimental)"); description: @tr("Allow QBZ to write track metadata back to your Plex server."); VerticalLayout { alignment: center; QbzToggle { checked: PlexSettingsState.metadata-write-enabled; toggled(v) => { PlexSettingsState.metadata-write-enabled = v; PlexAuthActions.metadata-write-toggle(v); } } } } Rectangle { height: 18px; } // --- Disconnect / Clear cache (danger) ---------------------------- GroupHeader { text: @tr("PLEX DANGER ZONE"); } SettingRow { label: @tr("Disconnect"); description: @tr("Sign out of Plex and clear the local cache."); SecondaryButton { label: @tr("Disconnect"); danger: true; enabled: PlexSettingsState.token != ""; clicked => { PlexAuthActions.disconnect(); } } } SettingRow { label: @tr("Clear cache"); description: @tr("Remove cached Plex libraries and tracks. Your sign-in is kept."); SecondaryButton { label: @tr("Clear cache"); danger: true; enabled: PlexSettingsState.token != ""; clicked => { PlexAuthActions.clear-cache(); } } } } }