Spec 2026-07-19-local-album-grouping-mode (A2-A5 + i18n). The Albums tab
gains a third header dropdown ('Albums by folder' / 'Albums by metadata')
and Settings > Local Library a matching 'Album grouping' row — both write
the same pref: LocalLibraryState.albums-id-mode, persisted in
locallibrary_ui.json (default 'folder': compilations and box sets stay
whole; the metadata split stays opt-in for #411's use case).
The group key changes with the mode, so switching reloads the Albums set
(reload_albums) and invalidates the Artists tab (its album cache groups
the same way — a folder-mode compilation cross-lists under every artist
in all_artists, the owner's 'appears in all artists' ask). Threaded
through spawn_albums_load, seed_counts, the Plex quality-hydration
aggregate lookup, and the artists feed; myqbz_builder pins Metadata
(artist-scoped candidates, not the Albums view).
5 msgids x 8 locales (msgfmt clean); slintcheck green.
km
830 lines
33 KiB
Plaintext
830 lines
33 KiB
Plaintext
// 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 <string> label;
|
|
in property <bool> danger: false;
|
|
in property <bool> 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 <image> icon;
|
|
in property <bool> 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 <string> display-name;
|
|
in property <string> last-scan-label;
|
|
in property <bool> enabled-folder;
|
|
in property <bool> is-network;
|
|
in property <bool> accessible;
|
|
in property <bool> selected;
|
|
in property <bool> 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 <bool> 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 <bool> 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(); }
|
|
}
|
|
}
|
|
}
|
|
}
|