Files
qbz/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint
T
vicrodh 641aa22c17 feat(slint): scroll-position restore on back/forward (Local Library)
Scroll-position restore was never implemented (only a TODO in nav.rs);
back/forward tracked which page was visited, not where it was scrolled.

Add per-history-entry scroll memory: the mounted scroll container reports
its live viewport-y via NavState.report-scroll, so every navigation (fresh
record + back/forward) stamps the outgoing entry without touching the ~30
record call sites. go_back/go_forward hand the saved scroll back to the
shell, which arms NavState.restore-scope + scroll-restore before switching
the view. A container restores only when its scope matches, once per mount,
and only after content has laid out (viewport overflows) to dodge the
clamp-to-0 on async data load; it clears the scope once consumed.

Wired the four Local Library lists (albums, tracks, folders, artists rail).
Tracks is paginated, so its restore is best-effort within loaded pages.

cc
2026-06-08 15:38:35 -06:00

2559 lines
127 KiB
Plaintext

// 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 <FolderSubcardItem> 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 <image> 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 <string> 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 <string> 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 <string> 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 <image> icon;
in property <string> 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 <FolderNode> node;
in property <bool> selected;
in property <bool> 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 <bool> on: root.node.is-folder
? root.node.select-state == 2
: root.node.selected;
property <bool> 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 <LocalArtistItem> item;
in property <bool> 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 <string> label;
in property <bool> 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 <bool> 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: "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 <bool> 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;
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 <bool> 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/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 <length> 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 <length> gap: 12px;
property <length> min-cw: 140px;
property <int> cols: Math.max(1, Math.floor((self.width + self.gap) / (self.min-cw + self.gap)));
property <length> cw: (self.width - (self.cols - 1) * self.gap) / self.cols;
property <length> ch: self.cw + 42px;
property <int> 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;
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.folders-play-track(id);
}
}
}
}
// 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 <bool> 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; }
}
}
}
}
}