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
2559 lines
127 KiB
Plaintext
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; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|