Flyouts & idle (main effort): - Header dropdowns now glue under the tab: open-menu resets from-sidebar + title (a stale sidebar flyout state was flinging the panel far away). - Idle-close now pauses while the cursor rests on the TRIGGER too (full header tabs gained the trigger-hovered logic the others already had). - Dropped the dropdown caret (saves width on small screens). Header nav layout: - Section nav moved INTO the left-controls cluster, after the three sacred buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off and sidebar not closed; compact icons when closed — mutually exclusive (no more duplicate/cluttered header). - Search field gives up 60px (animated) when the header hosts the nav. Settings made functional: - Toast component mounted in AppShell (it was never rendered) + the in-app toasts toggle drives ToastState.enabled. - "Show album artwork in Local Library track list" now drives the Tracks/ Folders rows. Both rows un-flagged as unsupported. Collapsible search (ExpandableSearch, app-wide): - Smooth open AND close (width animates both ways; magnifier fades). - Reliable auto-focus on open (deferred via a one-shot timer). cc
1803 lines
86 KiB
Plaintext
1803 lines
86 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, LocalArtistItem, AlphaJump, LibAlbumFilterState, AppearanceState } from "../state.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
|
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
|
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { TrackRow } from "../primitives/TrackRow.slint";
|
|
import { ToggleButton } from "../primitives/ToggleButton.slint";
|
|
import { MultiSelectBar } from "../primitives/MultiSelectBar.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); }
|
|
}
|
|
|
|
// 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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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.svg");
|
|
active: root.mode == "flat";
|
|
sm: true;
|
|
clicked => { root.set-mode("flat"); }
|
|
}
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/folder.svg");
|
|
active: root.mode == "tree";
|
|
sm: true;
|
|
clicked => { root.set-mode("tree"); }
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, the segment name and (for folders) the recursive track count.
|
|
component TreeRow inherits Rectangle {
|
|
in property <FolderNode> node;
|
|
in property <bool> selected;
|
|
callback toggle(); // chevron — expand/collapse
|
|
callback activate(); // row body — select folder (load detail)
|
|
height: 32px;
|
|
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;
|
|
|
|
// 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: 13px;
|
|
height: 13px;
|
|
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: 16px;
|
|
height: 16px;
|
|
tint: root.node.is-folder ? Theme.accent : Theme.text-muted;
|
|
}
|
|
}
|
|
|
|
// Name.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.node.segment;
|
|
color: root.selected ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
font-weight: root.selected ? Typography.semibold : Typography.regular;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
|
|
// Recursive track count (folders only).
|
|
if root.node.is-folder && root.node.track-count > 0: VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.node.track-count;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
}
|
|
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 — search (both modes); flat adds sort + group +
|
|
// grid/list; a flat/tree mode toggle sits at the far right.
|
|
if LocalLibraryState.active-tab == "folders"
|
|
&& (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);
|
|
}
|
|
}
|
|
}
|
|
// Flat-only controls: sort, group-by, grid/list.
|
|
if LocalLibraryState.folders-view-mode == "flat": HorizontalLayout {
|
|
spacing: 8px;
|
|
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.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: 160px;
|
|
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 mode toggle (always visible).
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
retry-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
LocalLibraryActions.albums-retry();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Empty — no albums indexed yet.
|
|
if !LocalLibraryState.albums-loading
|
|
&& !LocalLibraryState.albums-load-failed
|
|
&& LocalLibraryState.albums.length == 0
|
|
&& LocalLibraryState.albums-search == "": VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("No albums in your local library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
// 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;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
tretry-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
LocalLibraryActions.tracks-retry();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !LocalLibraryState.tracks-loading
|
|
&& !LocalLibraryState.tracks-load-failed
|
|
&& LocalLibraryState.tracks.length == 0
|
|
&& LocalLibraryState.tracks-search == "": VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("No tracks in your local library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
if !LocalLibraryState.tracks-loading
|
|
&& !LocalLibraryState.tracks-load-failed
|
|
&& LocalLibraryState.tracks.length == 0
|
|
&& LocalLibraryState.tracks-search != "": VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("No tracks match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
// 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;
|
|
changed viewport-y => {
|
|
if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px
|
|
&& LocalLibraryState.tracks-has-more
|
|
&& !LocalLibraryState.tracks-loading-more
|
|
&& !LocalLibraryState.tracks-loading) {
|
|
LocalLibraryActions.tracks-load-more();
|
|
}
|
|
}
|
|
for track[i] in LocalLibraryState.tracks-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 {
|
|
// Initial flat load.
|
|
if LocalLibraryState.folders-loading: VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
}
|
|
// Empty — nothing indexed.
|
|
if !LocalLibraryState.folders-loading
|
|
&& 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.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;
|
|
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.folders.length > 0
|
|
&& LocalLibraryState.folders-view-mode == "tree": HorizontalLayout {
|
|
spacing: 0px;
|
|
|
|
// ---- Left rail: the flattened folder tree ----
|
|
Rectangle {
|
|
width: 340px;
|
|
if LocalLibraryState.folder-tree-loading: VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 28px; }
|
|
}
|
|
}
|
|
if !LocalLibraryState.folder-tree-loading: VerticalLayout {
|
|
padding-left: 24px;
|
|
padding-right: 8px;
|
|
padding-top: 12px;
|
|
tree-list := ListView {
|
|
for node[i] in LocalLibraryState.folder-tree: TreeRow {
|
|
node: node;
|
|
selected: node.path == LocalLibraryState.folders-selected-path;
|
|
toggle => {
|
|
LocalLibraryActions.folders-toggle-node(node.path, !node.expanded);
|
|
}
|
|
activate => {
|
|
LocalLibraryActions.folders-select(node.path, node.segment);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Divider.
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// ---- 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: folder name + Play all.
|
|
HorizontalLayout {
|
|
spacing: 12px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: LocalLibraryState.folders-selected-name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: playall-row.preferred-width + 28px;
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
background: playall-ta.has-hover
|
|
? Theme.accent-hover : Theme.accent;
|
|
playall-row := HorizontalLayout {
|
|
x: (parent.width - self.width) / 2;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/play-fill.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.accent-text;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Play all");
|
|
color: Theme.accent-text;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
}
|
|
}
|
|
playall-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
LocalLibraryActions.folders-play-node(
|
|
LocalLibraryState.folders-selected-path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if LocalLibraryState.folder-detail-loading: HorizontalLayout {
|
|
alignment: center;
|
|
padding-top: 30px;
|
|
LoadingSpinner { size: 28px; }
|
|
}
|
|
|
|
// Subfolders (drill-down), if any.
|
|
if !LocalLibraryState.folder-detail-loading
|
|
&& LocalLibraryState.folder-detail-subfolders.length > 0: VerticalLayout {
|
|
spacing: 6px;
|
|
Text {
|
|
text: @tr("Subfolders");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
for sub[si] in LocalLibraryState.folder-detail-subfolders: Rectangle {
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
background: sub-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: 16px;
|
|
height: 16px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: sub.segment;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
if sub.track-count > 0: VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: sub.track-count;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
}
|
|
sub-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
LocalLibraryActions.folders-select(sub.path, sub.segment);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Direct tracks.
|
|
if !LocalLibraryState.folder-detail-loading
|
|
&& LocalLibraryState.folder-detail-tracks.length > 0: VerticalLayout {
|
|
spacing: 0px;
|
|
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;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
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; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|