feat(library-all): windowed feed, show-local default + scope, local hearts
- Viewport windowing for the All grid + list (AlbumGrid-class sampler); thousands of items no longer mount eagerly (owner's 2200-item library was unscrollable, even typing in search lagged). - Artwork is id-keyed (kind:id) + generation-guarded + band eviction, ending wrong-cover races and per-keystroke image churn; covers persist on the full model across derives. - show-local defaults ON (persisted) with the filter toggle kept in the view toolbar; Settings gains the scope selector (hearted items vs the whole local library, loaded with Local-Library query parity). - Cards respect favorite/followed state; hearts of local/Plex items route to the local-favorites store instead of the Qobuz API. km
This commit is contained in:
8 files changed
+1182
-337
No files matched your search
@@ -68,6 +68,104 @@ component FavTabMenu inherits SegmentedTabBar {
|
||||
selected(id) => { FavoritesActions.select-tab(id); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Viewport windowing engine for the Library "All" grid + list — POST-LAYOUT
|
||||
// SNAPSHOT SAMPLING, the SAME class-kill as AlbumGrid's (see the long comment
|
||||
// in AlbumCollectionView.slint; the "Recursion detected" panic class).
|
||||
//
|
||||
// Rules copied from there, do not break them:
|
||||
// - `visible-top` / `visible-height` / `content-offset` are POLLED, never
|
||||
// observed: read ONLY from the sampler Timer below. Wiring them to live
|
||||
// layout expressions (-flick.viewport-y / flick.height) is safe BECAUSE
|
||||
// nothing here evaluates them from a binding or `changed` handler.
|
||||
// - The band state is PLAIN properties written ONLY by the Timer. No
|
||||
// `changed` handlers, no init-time reads of the inputs.
|
||||
// Consumers gate each slot on `first-item`/`last-item` (plain, Timer-written)
|
||||
// and report through `window-changed` so Rust dispatches covers for the band.
|
||||
// ---------------------------------------------------------------------------
|
||||
component ViewportWindow inherits Rectangle {
|
||||
in property <length> visible-top: 0px;
|
||||
in property <length> visible-height: 0px;
|
||||
// This content's approximate y inside the Flickable's CONTENT coordinates;
|
||||
// small errors are absorbed by the one-viewport margin.
|
||||
in property <length> content-offset: 0px;
|
||||
in property <length> row-stride: 1px;
|
||||
in property <int> columns: 1;
|
||||
callback window-changed(int /* first item index */, int /* last item index */);
|
||||
|
||||
// Snapshot band — plain values, sampler-written. The pre-seed [0, 3]
|
||||
// keeps roughly the first viewport of slots mounted for the <=80ms
|
||||
// between mount and the first tick (placeholder tiles cover the rest).
|
||||
property <int> target-first-row: 0;
|
||||
property <int> target-last-row: 3;
|
||||
// Mounted band = target band + contract hysteresis. Expansion follows the
|
||||
// target immediately; contraction lags by `hysteresis-rows` so a few-px
|
||||
// oscillation across a row boundary cannot mount/unmount a whole row on
|
||||
// every direction change.
|
||||
property <int> hysteresis-rows: 1;
|
||||
property <int> first-window-row: 0;
|
||||
property <int> last-window-row: 3;
|
||||
// Item-index form of the mounted band — the slot gates read these.
|
||||
out property <int> first-item: 0;
|
||||
out property <int> last-item: 3 * Math.max(1, root.columns) - 1;
|
||||
// Last column count reported through window-changed (columns shift on
|
||||
// resize, which re-keys the band's ITEM indices).
|
||||
property <int> notified-columns: 0;
|
||||
pure function desired-first-row() -> int {
|
||||
if (root.target-first-row < root.first-window-row) {
|
||||
return root.target-first-row;
|
||||
}
|
||||
if (root.target-first-row > root.first-window-row + root.hysteresis-rows) {
|
||||
return root.target-first-row - root.hysteresis-rows;
|
||||
}
|
||||
return root.first-window-row;
|
||||
}
|
||||
pure function desired-last-row() -> int {
|
||||
if (root.target-last-row > root.last-window-row) {
|
||||
return root.target-last-row;
|
||||
}
|
||||
if (root.target-last-row < root.last-window-row - root.hysteresis-rows) {
|
||||
return root.target-last-row + root.hysteresis-rows;
|
||||
}
|
||||
return root.last-window-row;
|
||||
}
|
||||
function apply-window() {
|
||||
if (root.desired-first-row() != root.first-window-row
|
||||
|| root.desired-last-row() != root.last-window-row) {
|
||||
root.first-window-row = root.desired-first-row();
|
||||
root.last-window-row = root.desired-last-row();
|
||||
root.publish();
|
||||
}
|
||||
}
|
||||
// The sampler: reads the live inputs (safe here — event-loop context),
|
||||
// recomputes the target band, applies hysteresis, and notifies on change.
|
||||
// ~12.5 Hz is enough: the band carries a full-viewport margin, and the
|
||||
// placeholder tiles are the designed fast-scroll fallback.
|
||||
function sample-window() {
|
||||
root.target-first-row = Math.max(0, Math.floor(
|
||||
((root.visible-top - root.content-offset) - root.visible-height)
|
||||
/ root.row-stride));
|
||||
root.target-last-row = Math.max(0, Math.floor(
|
||||
((root.visible-top - root.content-offset) + 2 * root.visible-height)
|
||||
/ root.row-stride));
|
||||
root.apply-window();
|
||||
if (root.columns != root.notified-columns) {
|
||||
root.notified-columns = root.columns;
|
||||
root.publish();
|
||||
}
|
||||
}
|
||||
Timer {
|
||||
interval: 80ms;
|
||||
running: true;
|
||||
triggered => { root.sample-window(); }
|
||||
}
|
||||
function publish() {
|
||||
root.first-item = root.first-window-row * Math.max(1, root.columns);
|
||||
root.last-item = (root.last-window-row + 1) * Math.max(1, root.columns) - 1;
|
||||
root.window-changed(root.first-item, root.last-item);
|
||||
}
|
||||
}
|
||||
|
||||
// One underline sub-tab for the Playlists tab (Library / Following).
|
||||
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
|
||||
component PlaylistSubTab inherits Rectangle {
|
||||
@@ -411,6 +509,18 @@ export component FavoritesView inherits Rectangle {
|
||||
clicked => { FavoritesActions.albums-shuffle(); }
|
||||
}
|
||||
}
|
||||
// Multi-select — LIST mode only (owner 2026-07-24): bulk
|
||||
// un-favorite / play-next / queue on the checked rows.
|
||||
if FavoritesState.albums-view-mode == "list": VerticalLayout {
|
||||
alignment: center;
|
||||
CircleAction {
|
||||
icon: @image-url("../assets/icons/square-check-big.svg");
|
||||
active: FavoritesState.albums-multi-select;
|
||||
on-surface: true;
|
||||
tooltip: @tr("Select multiple albums");
|
||||
clicked => { FavoritesActions.albums-toggle-multi-select(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if FavoritesState.active-tab == "artists"
|
||||
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
|
||||
@@ -693,9 +803,15 @@ export component FavoritesView inherits Rectangle {
|
||||
sm: true;
|
||||
query <=> LibraryAllState.search;
|
||||
edited(q) => {
|
||||
LibraryAllActions.search(q);
|
||||
all-search-debounce.restart();
|
||||
}
|
||||
}
|
||||
// Derive once per typing pause, not per keystroke — with
|
||||
// thousands of items each derive swaps the visible model.
|
||||
all-search-debounce := Timer {
|
||||
interval: 250ms;
|
||||
triggered => { LibraryAllActions.search(LibraryAllState.search); }
|
||||
}
|
||||
}
|
||||
// Source switches — Purchases / Favorites / Following. Tooltips
|
||||
// spell out each cryptic icon (owner request).
|
||||
@@ -729,7 +845,9 @@ export component FavoritesView inherits Rectangle {
|
||||
clicked => { LibraryAllActions.toggle-source("following"); }
|
||||
}
|
||||
}
|
||||
// Show local files + Plex (local-favorites layer).
|
||||
// Show local files + Plex (filter lives IN the view; the
|
||||
// Settings row only picks the SCOPE: favorites vs whole
|
||||
// local library).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
ToggleButton {
|
||||
@@ -932,10 +1050,12 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Grid — non-windowed flow (columns from width), one card per
|
||||
// item keyed off `kind`, reusing the existing card components.
|
||||
// Grid — WINDOWED flow (columns from width), one card-slot per
|
||||
// item keyed off `kind`. Only slots near the viewport mount a
|
||||
// real card; the rest are same-footprint placeholder tiles
|
||||
// (ViewportWindow engine — the AlbumGrid class-kill pattern).
|
||||
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
||||
&& LibraryAllState.view-mode != "list": Flickable {
|
||||
&& LibraryAllState.view-mode != "list": all-grid-flick := Flickable {
|
||||
viewport-height: all-grid.height + 32px;
|
||||
all-grid := Rectangle {
|
||||
property <length> cw: 200px;
|
||||
@@ -950,13 +1070,30 @@ export component FavoritesView inherits Rectangle {
|
||||
width: parent.width - 64px;
|
||||
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px;
|
||||
|
||||
for item[i] in LibraryAllState.items-visible: Rectangle {
|
||||
all-grid-win := ViewportWindow {
|
||||
visible-top: -all-grid-flick.viewport-y;
|
||||
visible-height: all-grid-flick.height;
|
||||
content-offset: 16px;
|
||||
row-stride: all-grid.ch + all-grid.gap;
|
||||
columns: all-grid.columns;
|
||||
window-changed(first, last) => {
|
||||
LibraryAllActions.window-changed(first, last);
|
||||
}
|
||||
}
|
||||
|
||||
for item[i] in LibraryAllState.items-visible: card-slot := Rectangle {
|
||||
x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap);
|
||||
y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
|
||||
width: all-grid.cw;
|
||||
height: all-grid.ch;
|
||||
property <bool> in-window: i >= all-grid-win.first-item
|
||||
&& i <= all-grid-win.last-item;
|
||||
// Placeholder tile while the real card is unmounted
|
||||
// (fast scroll).
|
||||
border-radius: 8px;
|
||||
background: card-slot.in-window ? transparent : Theme.surface-elevated;
|
||||
|
||||
if item.kind == "track": TrackCard {
|
||||
if card-slot.in-window && item.kind == "track": TrackCard {
|
||||
show-source-badge: LibraryAllState.show-local;
|
||||
track: {
|
||||
id: item.id,
|
||||
@@ -981,7 +1118,7 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "album": AlbumCard {
|
||||
if card-slot.in-window && item.kind == "album": AlbumCard {
|
||||
show-source-badge: LibraryAllState.show-local;
|
||||
album: {
|
||||
id: item.id,
|
||||
@@ -992,6 +1129,7 @@ export component FavoritesView inherits Rectangle {
|
||||
quality-tier: item.quality-tier,
|
||||
quality-label: item.quality-detail,
|
||||
source: item.source,
|
||||
is-favorite: item.is-favorite,
|
||||
};
|
||||
clicked(id) => { FavoritesActions.open-album(id); }
|
||||
media-action(kind, id, action) => {
|
||||
@@ -1002,16 +1140,21 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "artist": ArtistGridCard {
|
||||
if card-slot.in-window && item.kind == "artist": ArtistGridCard {
|
||||
artist: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
artwork: item.image,
|
||||
following: item.is-favorite,
|
||||
};
|
||||
follow-mode: "none";
|
||||
clicked(id) => { FavoritesActions.open-artist(id); }
|
||||
media-action(kind, id, action) => {
|
||||
if (action == "follow") {
|
||||
FavoritesActions.artist-action(id, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "playlist": PlaylistCard {
|
||||
if card-slot.in-window && item.kind == "playlist": PlaylistCard {
|
||||
playlist: {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
@@ -1032,7 +1175,7 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "label": LabelCard {
|
||||
if card-slot.in-window && item.kind == "label": LabelCard {
|
||||
label: {
|
||||
id: item.id,
|
||||
name: item.title,
|
||||
@@ -1044,10 +1187,23 @@ export component FavoritesView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
}
|
||||
// List view — compact mixed rows (cover + title + subtitle).
|
||||
// List view — WINDOWED compact mixed rows (cover + title +
|
||||
// subtitle). Only rows near the viewport mount their content
|
||||
// (cells + context menu); off-band rows are same-footprint
|
||||
// placeholder tiles (ViewportWindow engine, AlbumGrid pattern).
|
||||
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
||||
&& LibraryAllState.view-mode == "list": Flickable {
|
||||
&& LibraryAllState.view-mode == "list": all-list-flick := Flickable {
|
||||
viewport-height: all-list.preferred-height + 32px;
|
||||
all-list-win := ViewportWindow {
|
||||
visible-top: -all-list-flick.viewport-y;
|
||||
visible-height: all-list-flick.height;
|
||||
content-offset: 16px;
|
||||
row-stride: 58px; // 56px row + 2px spacing
|
||||
columns: 1;
|
||||
window-changed(first, last) => {
|
||||
LibraryAllActions.window-changed(first, last);
|
||||
}
|
||||
}
|
||||
all-list := VerticalLayout {
|
||||
x: 32px;
|
||||
y: 16px;
|
||||
@@ -1055,9 +1211,12 @@ export component FavoritesView inherits Rectangle {
|
||||
spacing: 2px;
|
||||
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
|
||||
height: 56px;
|
||||
property <bool> in-window: i >= all-list-win.first-item
|
||||
&& i <= all-list-win.last-item;
|
||||
border-radius: 6px;
|
||||
background: row-ta.has-hover || menu-btn-ta.has-hover
|
||||
? Theme.surface-hover : transparent;
|
||||
// Placeholder tile while the row content is
|
||||
// unmounted (fast scroll).
|
||||
background: row-rect.in-window ? transparent : Theme.surface-elevated;
|
||||
// Context-menu opener position (row-local px). Defaults
|
||||
// by the ⋯ button; a right-click anywhere on the row
|
||||
// overrides it to the cursor (window pointer minus the
|
||||
@@ -1065,315 +1224,320 @@ export component FavoritesView inherits Rectangle {
|
||||
// the right-click handler so the whole row is sensitive.
|
||||
property <length> menu-x: row-rect.width - 206px;
|
||||
property <length> menu-y: 48px;
|
||||
// Row body — click plays/opens by kind; right-click opens
|
||||
// the menu at the cursor. Behind the cell TouchAreas.
|
||||
row-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (item.kind == "track") {
|
||||
FavoritesActions.track-action(item.id, "play");
|
||||
} else if (item.kind == "album") {
|
||||
FavoritesActions.open-album(item.id);
|
||||
} else if (item.kind == "artist") {
|
||||
FavoritesActions.open-artist(item.id);
|
||||
} else if (item.kind == "playlist") {
|
||||
FavoritesActions.open-playlist(item.id);
|
||||
} else if (item.kind == "label") {
|
||||
FavoritesActions.open-label(item.id, item.title);
|
||||
}
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down
|
||||
&& e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 12px;
|
||||
// Col — artwork (round for artists) + hover play.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: item.kind == "artist" ? 22px : 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: item.image;
|
||||
image-fit: item.kind == "label" ? contain : cover;
|
||||
if row-rect.in-window: Rectangle {
|
||||
border-radius: 6px;
|
||||
background: row-ta.has-hover || menu-btn-ta.has-hover
|
||||
? Theme.surface-hover : transparent;
|
||||
// Row body — click plays/opens by kind; right-click
|
||||
// opens the menu at the cursor. Behind the cells.
|
||||
row-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (item.kind == "track") {
|
||||
FavoritesActions.track-action(item.id, "play");
|
||||
} else if (item.kind == "album") {
|
||||
FavoritesActions.open-album(item.id);
|
||||
} else if (item.kind == "artist") {
|
||||
FavoritesActions.open-artist(item.id);
|
||||
} else if (item.kind == "playlist") {
|
||||
FavoritesActions.open-playlist(item.id);
|
||||
} else if (item.kind == "label") {
|
||||
FavoritesActions.open-label(item.id, item.title);
|
||||
}
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000000a6;
|
||||
opacity: play-ta.has-hover ? 1.0 : 0.0;
|
||||
animate opacity { duration: 150ms; }
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/play-fill.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: #ffffff;
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down
|
||||
&& e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalLayout {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
spacing: 12px;
|
||||
// Col — artwork (round for artists) + hover play.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: item.kind == "artist" ? 22px : 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
Image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
source: item.image;
|
||||
image-fit: item.kind == "label" ? contain : cover;
|
||||
}
|
||||
play-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #000000a6;
|
||||
opacity: play-ta.has-hover ? 1.0 : 0.0;
|
||||
animate opacity { duration: 150ms; }
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/play-fill.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: #ffffff;
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
play-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Col — title + subtitle (artist link for track/album).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
title-ta := TouchArea {
|
||||
height: 18px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
|
||||
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
|
||||
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
|
||||
else { FavoritesActions.open-label(item.id, item.title); }
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 100%;
|
||||
text: item.title;
|
||||
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
|
||||
font-size: 14px;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
if item.subtitle != "": sub-ta := TouchArea {
|
||||
height: 16px;
|
||||
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
|
||||
clicked => {
|
||||
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
|
||||
FavoritesActions.open-artist(item.artist-id);
|
||||
}
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 100%;
|
||||
text: item.subtitle;
|
||||
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
|
||||
? Theme.accent : Theme.text-muted;
|
||||
font-size: 12px;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Col — type (icon + label).
|
||||
Rectangle {
|
||||
width: 116px;
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
|
||||
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
|
||||
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
|
||||
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
|
||||
: @image-url("../assets/icons/disc.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: item.kind == "track" ? @tr("Track")
|
||||
: item.kind == "album" ? @tr("Album")
|
||||
: item.kind == "artist" ? @tr("Artist")
|
||||
: item.kind == "playlist" ? @tr("Playlist")
|
||||
: @tr("Label");
|
||||
color: Theme.text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Col — source glyph. Only when local/Plex can
|
||||
// appear (show-local on); otherwise the whole feed
|
||||
// is Qobuz, so the column is hidden rather than
|
||||
// painting the Qobuz mark on every row.
|
||||
if LibraryAllState.show-local: Rectangle {
|
||||
width: 44px;
|
||||
SourceGlyph {
|
||||
kind: item.source;
|
||||
x: 0;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
}
|
||||
// Col — quality (albums + tracks; the row badge).
|
||||
Rectangle {
|
||||
width: 150px;
|
||||
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
|
||||
tier: item.quality-tier;
|
||||
detail: item.quality-detail;
|
||||
x: 0;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
}
|
||||
// Col — favorite (heart) / follow (person) indicator.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: item.kind == "artist"
|
||||
? @image-url("../assets/icons/user-plus.svg")
|
||||
: (item.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
: @image-url("../assets/icons/heart.svg"));
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
// Col — context-menu button.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
menu-btn := Rectangle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/ellipsis.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: Theme.text-secondary;
|
||||
}
|
||||
menu-btn-ta := TouchArea {
|
||||
// Col — title + subtitle (artist link for track/album).
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
horizontal-stretch: 1;
|
||||
spacing: 2px;
|
||||
title-ta := TouchArea {
|
||||
height: 18px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
// The ⋯ opener: anchor the menu by
|
||||
// the button (right-click uses the
|
||||
// cursor instead).
|
||||
row-rect.menu-x = row-rect.width - 206px;
|
||||
row-rect.menu-y = 48px;
|
||||
row-menu.show();
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
|
||||
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
|
||||
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
|
||||
else { FavoritesActions.open-label(item.id, item.title); }
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 100%;
|
||||
text: item.title;
|
||||
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
|
||||
font-size: 14px;
|
||||
font-weight: Typography.medium;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
if item.subtitle != "": sub-ta := TouchArea {
|
||||
height: 16px;
|
||||
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
|
||||
clicked => {
|
||||
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
|
||||
FavoritesActions.open-artist(item.artist-id);
|
||||
}
|
||||
}
|
||||
pointer-event(e) => {
|
||||
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
||||
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
||||
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: 100%;
|
||||
text: item.subtitle;
|
||||
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
|
||||
? Theme.accent : Theme.text-muted;
|
||||
font-size: 12px;
|
||||
overflow: elide;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Col — type (icon + label).
|
||||
Rectangle {
|
||||
width: 116px;
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
|
||||
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
|
||||
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
|
||||
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
|
||||
: @image-url("../assets/icons/disc.svg");
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: item.kind == "track" ? @tr("Track")
|
||||
: item.kind == "album" ? @tr("Album")
|
||||
: item.kind == "artist" ? @tr("Artist")
|
||||
: item.kind == "playlist" ? @tr("Playlist")
|
||||
: @tr("Label");
|
||||
color: Theme.text-muted;
|
||||
font-size: 10px;
|
||||
font-weight: Typography.semibold;
|
||||
letter-spacing: 1.2px;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Col — source glyph. Only when local/Plex can
|
||||
// appear (show-local on); otherwise the whole feed
|
||||
// is Qobuz, so the column is hidden rather than
|
||||
// painting the Qobuz mark on every row.
|
||||
if LibraryAllState.show-local: Rectangle {
|
||||
width: 44px;
|
||||
SourceGlyph {
|
||||
kind: item.source;
|
||||
x: 0;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
}
|
||||
// Col — quality (albums + tracks; the row badge).
|
||||
Rectangle {
|
||||
width: 150px;
|
||||
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
|
||||
tier: item.quality-tier;
|
||||
detail: item.quality-detail;
|
||||
x: 0;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
}
|
||||
}
|
||||
// Col — favorite (heart) / follow (person) indicator.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: item.kind == "artist"
|
||||
? @image-url("../assets/icons/user-plus.svg")
|
||||
: (item.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
: @image-url("../assets/icons/heart.svg"));
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
// Col — context-menu button.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
menu-btn := Rectangle {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/ellipsis.svg");
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
||||
tint: Theme.text-secondary;
|
||||
}
|
||||
menu-btn-ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
// The ⋯ opener: anchor the menu by
|
||||
// the button (right-click uses the
|
||||
// cursor instead).
|
||||
row-rect.menu-x = row-rect.width - 206px;
|
||||
row-rect.menu-y = 48px;
|
||||
row-menu.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-kind context menu (⋯ button + right-click).
|
||||
row-menu := PopupWindow {
|
||||
x: row-rect.menu-x;
|
||||
y: row-rect.menu-y;
|
||||
width: 196px;
|
||||
height: (item.kind == "track" ? 6
|
||||
: item.kind == "album" ? 6
|
||||
: item.kind == "playlist" ? 4
|
||||
: 1) * 33px + 10px;
|
||||
ContextMenu {
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/play.svg");
|
||||
label: @tr("Play");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||
// Per-kind context menu (⋯ button + right-click).
|
||||
row-menu := PopupWindow {
|
||||
x: row-rect.menu-x;
|
||||
y: row-rect.menu-y;
|
||||
width: 196px;
|
||||
height: (item.kind == "track" ? 6
|
||||
: item.kind == "album" ? 6
|
||||
: item.kind == "playlist" ? 4
|
||||
: 1) * 33px + 10px;
|
||||
ContextMenu {
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/play.svg");
|
||||
label: @tr("Play");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
label: @tr("Play next");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play-next"); }
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-plus.svg");
|
||||
label: @tr("Play next");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "play-next"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-end.svg");
|
||||
label: @tr("Add to queue");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "queue"); }
|
||||
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/list-end.svg");
|
||||
label: @tr("Add to queue");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
|
||||
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
|
||||
else { FavoritesActions.playlist-action(item.id, "queue"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.kind == "album": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Open album");
|
||||
clicked => { FavoritesActions.open-album(item.id); }
|
||||
}
|
||||
if item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Open playlist");
|
||||
clicked => { FavoritesActions.open-playlist(item.id); }
|
||||
}
|
||||
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/user.svg");
|
||||
label: @tr("Go to artist");
|
||||
clicked => { FavoritesActions.open-artist(item.artist-id); }
|
||||
}
|
||||
if item.kind == "track" && item.album-id != "": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/disc.svg");
|
||||
label: @tr("Go to album");
|
||||
clicked => { FavoritesActions.open-album(item.album-id); }
|
||||
}
|
||||
if item.kind == "artist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/user.svg");
|
||||
label: @tr("Go to artist");
|
||||
clicked => { FavoritesActions.open-artist(item.id); }
|
||||
}
|
||||
if item.kind == "label": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/disc-3.svg");
|
||||
label: @tr("Go to label");
|
||||
clicked => { FavoritesActions.open-label(item.id, item.title); }
|
||||
}
|
||||
if item.kind == "track" || item.kind == "album": ContextMenuItem {
|
||||
icon: item.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
: @image-url("../assets/icons/heart.svg");
|
||||
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
|
||||
else { FavoritesActions.album-action(item.id, "favorite"); }
|
||||
if item.kind == "album": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Open album");
|
||||
clicked => { FavoritesActions.open-album(item.id); }
|
||||
}
|
||||
if item.kind == "playlist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/library-big.svg");
|
||||
label: @tr("Open playlist");
|
||||
clicked => { FavoritesActions.open-playlist(item.id); }
|
||||
}
|
||||
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/user.svg");
|
||||
label: @tr("Go to artist");
|
||||
clicked => { FavoritesActions.open-artist(item.artist-id); }
|
||||
}
|
||||
if item.kind == "track" && item.album-id != "": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/disc.svg");
|
||||
label: @tr("Go to album");
|
||||
clicked => { FavoritesActions.open-album(item.album-id); }
|
||||
}
|
||||
if item.kind == "artist": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/user.svg");
|
||||
label: @tr("Go to artist");
|
||||
clicked => { FavoritesActions.open-artist(item.id); }
|
||||
}
|
||||
if item.kind == "label": ContextMenuItem {
|
||||
icon: @image-url("../assets/icons/disc-3.svg");
|
||||
label: @tr("Go to label");
|
||||
clicked => { FavoritesActions.open-label(item.id, item.title); }
|
||||
}
|
||||
if item.kind == "track" || item.kind == "album": ContextMenuItem {
|
||||
icon: item.is-favorite
|
||||
? @image-url("../assets/icons/heart-filled.svg")
|
||||
: @image-url("../assets/icons/heart.svg");
|
||||
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
|
||||
clicked => {
|
||||
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
|
||||
else { FavoritesActions.album-action(item.id, "favorite"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1719,24 @@ export component FavoritesView inherits Rectangle {
|
||||
// --- Albums tab ---
|
||||
if FavoritesState.active-tab == "albums": VerticalLayout {
|
||||
spacing: 0px;
|
||||
// Bulk action bar — visible in multi-select edit
|
||||
// mode (list view only).
|
||||
if FavoritesState.albums-multi-select
|
||||
&& FavoritesState.albums-view-mode == "list": MultiSelectBar {
|
||||
selected-count: FavoritesState.albums-selected-count;
|
||||
actions: [
|
||||
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
||||
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
||||
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
||||
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/trash-2.svg"), danger: true, needs-selection: true },
|
||||
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
||||
];
|
||||
action(id) => {
|
||||
FavoritesActions.albums-bulk-action(id);
|
||||
}
|
||||
}
|
||||
if FavoritesState.albums-multi-select
|
||||
&& FavoritesState.albums-view-mode == "list": Rectangle { height: 8px; }
|
||||
if FavoritesState.albums.length == 0: Text {
|
||||
text: @tr("No albums in your Library yet.");
|
||||
color: Theme.text-muted;
|
||||
@@ -1572,6 +1754,7 @@ export component FavoritesView inherits Rectangle {
|
||||
view-mode: FavoritesState.albums-view-mode;
|
||||
is-grouped: FavoritesState.albums-group-mode != "off";
|
||||
show-source: false;
|
||||
select-mode: FavoritesState.albums-multi-select;
|
||||
// Viewport windowing (Phase 1): only cards near the
|
||||
// visible band mount + get artwork dispatched. The
|
||||
// offset is the page's padding-top (16px); the grid's
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions } from "../state.slint";
|
||||
import { LibraryFoldersState, LibraryManageActions, LibraryScanState, PlexSettingsState, PlexAuthActions , UiFocusState, LocalLibraryState, LocalLibraryActions, LibraryAllState, LibraryAllActions } from "../state.slint";
|
||||
import { SettingRow } from "SettingRow.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
||||
@@ -448,6 +448,33 @@ export component LocalLibrarySettings inherits VerticalLayout {
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Library › All feed -------------------------------------------------
|
||||
// The All view's toolbar hard-drive toggle filters local items in/out;
|
||||
// THIS row picks what "local" means in the feed (owner 2026-07-24):
|
||||
// hearted items only, or the entire local library.
|
||||
GroupHeader { text: @tr("LIBRARY › ALL"); }
|
||||
SettingRow {
|
||||
label: @tr("Local items in Library All");
|
||||
description: @tr("Favorited items only: what you hearted. Entire local library: every folder and Plex album, artist and track.");
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzSelect {
|
||||
menu-width: 210px;
|
||||
sm: true;
|
||||
options: [
|
||||
@tr("Favorited items only"),
|
||||
@tr("Entire local library"),
|
||||
];
|
||||
current-index: LibraryAllState.local-scope == "all" ? 1 : 0;
|
||||
selected(i) => {
|
||||
LibraryAllActions.set-local-scope(i == 1 ? "all" : "favorites");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Maintenance -----------------------------------------------------
|
||||
GroupHeader { text: @tr("MAINTENANCE"); }
|
||||
SettingRow {
|
||||
|
||||
@@ -137,9 +137,11 @@ pub enum ArtworkTarget {
|
||||
/// main-column Playlists carousel). Single cover (slot 0), like
|
||||
/// `LabelPlaylistCover`.
|
||||
ArtistPlaylistCover { index: usize },
|
||||
/// A card in the Library "All" mixed feed (`LibraryAllState.items-visible[index]`).
|
||||
/// Dispatched against the VISIBLE model, re-dispatched on each derive.
|
||||
LibraryAllCover { index: usize },
|
||||
/// A card in the Library "All" mixed feed, keyed by `kind:id` (Qobuz
|
||||
/// numeric ids overlap across entity types). Id-keyed + generation-guarded
|
||||
/// so a derive re-sort between dispatch and apply cannot land a cover on
|
||||
/// the wrong card; applied onto BOTH `items` and `items-visible`.
|
||||
LibraryAllById { key: String, gen: u64 },
|
||||
/// A row in `ArtistState.library-tracks[index]` — the ArtistPage
|
||||
/// "In library" track list (library_by_artist seed). Row thumbnail.
|
||||
ArtistLibraryTrack { index: usize },
|
||||
@@ -1264,12 +1266,16 @@ fn apply_artwork(
|
||||
model.set_row_data(index, item);
|
||||
}
|
||||
}
|
||||
ArtworkTarget::LibraryAllCover { index } => {
|
||||
let model = window.global::<crate::LibraryAllState>().get_items_visible();
|
||||
if let Some(mut item) = model.row_data(index) {
|
||||
item.image = image;
|
||||
model.set_row_data(index, item);
|
||||
ArtworkTarget::LibraryAllById { key, gen } => {
|
||||
// The job is done either way — free its in-flight slot so the
|
||||
// window dispatcher can re-request it after an eviction.
|
||||
crate::library_all::artwork_job_done(&key);
|
||||
// Drop the cover if a reload superseded the set it belongs to.
|
||||
if !crate::library_all::library_all_gen_current(gen) {
|
||||
return;
|
||||
}
|
||||
// Set by key onto the full set + visible rows.
|
||||
crate::library_all::set_library_all_artwork(window, &key, image);
|
||||
}
|
||||
ArtworkTarget::ArtistLibraryTrack { index } => {
|
||||
let model = window.global::<ArtistState>().get_library_tracks();
|
||||
|
||||
@@ -1611,6 +1611,127 @@ pub fn selected_tracks(window: &AppWindow) -> Vec<Track> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---- Albums LIST-mode multi-select (mirrors the tracks section) ---------
|
||||
// Selection lives on AlbumCardItem.selected in `albums-visible` (flat) and
|
||||
// each `albums-grouped` section model (grouped list renders the same rows).
|
||||
|
||||
/// Enter/leave albums multi-select; leaving clears the selection.
|
||||
pub fn set_albums_multi_select(window: &AppWindow, on: bool) {
|
||||
window.global::<FavoritesState>().set_albums_multi_select(on);
|
||||
crate::selection::clear_anchor();
|
||||
if !on {
|
||||
clear_albums_selection(window);
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip one album's `selected` across the flat + grouped models.
|
||||
pub fn toggle_select_album(window: &AppWindow, id: &str) {
|
||||
let st = window.global::<FavoritesState>();
|
||||
let flip = |model: &slint::ModelRc<AlbumCardItem>| {
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.id.as_str() == id {
|
||||
item.selected = !item.selected;
|
||||
model.set_row_data(i, item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
flip(&st.get_albums_visible());
|
||||
let grouped = st.get_albums_grouped();
|
||||
for s in 0..grouped.row_count() {
|
||||
if let Some(section) = grouped.row_data(s) {
|
||||
flip(§ion.albums);
|
||||
}
|
||||
}
|
||||
recount_albums_selected(window);
|
||||
}
|
||||
|
||||
/// Recount selected album rows into `albums-selected-count`.
|
||||
pub fn recount_albums_selected(window: &AppWindow) {
|
||||
let st = window.global::<FavoritesState>();
|
||||
let visible = st.get_albums_visible();
|
||||
let mut count = (0..visible.row_count())
|
||||
.filter(|&i| visible.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||
.count();
|
||||
let grouped = st.get_albums_grouped();
|
||||
for s in 0..grouped.row_count() {
|
||||
if let Some(section) = grouped.row_data(s) {
|
||||
let albums = section.albums;
|
||||
count += (0..albums.row_count())
|
||||
.filter(|&i| albums.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||
.count();
|
||||
}
|
||||
}
|
||||
st.set_albums_selected_count(count as i32);
|
||||
}
|
||||
|
||||
/// Select-all toggle on the flat visible set (or clear when all selected).
|
||||
pub fn select_all_albums(window: &AppWindow) {
|
||||
let model = window.global::<FavoritesState>().get_albums_visible();
|
||||
let total = model.row_count();
|
||||
let selected = (0..total)
|
||||
.filter(|&i| model.row_data(i).map(|a| a.selected).unwrap_or(false))
|
||||
.count();
|
||||
let target = selected != total;
|
||||
for i in 0..total {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.selected != target {
|
||||
item.selected = target;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
recount_albums_selected(window);
|
||||
}
|
||||
|
||||
/// Deselect every album row (flat + grouped).
|
||||
pub fn clear_albums_selection(window: &AppWindow) {
|
||||
let st = window.global::<FavoritesState>();
|
||||
let clear = |model: &slint::ModelRc<AlbumCardItem>| {
|
||||
for i in 0..model.row_count() {
|
||||
if let Some(mut item) = model.row_data(i) {
|
||||
if item.selected {
|
||||
item.selected = false;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
clear(&st.get_albums_visible());
|
||||
let grouped = st.get_albums_grouped();
|
||||
for s in 0..grouped.row_count() {
|
||||
if let Some(section) = grouped.row_data(s) {
|
||||
clear(§ion.albums);
|
||||
}
|
||||
}
|
||||
st.set_albums_selected_count(0);
|
||||
}
|
||||
|
||||
/// The ids of the currently-selected album rows (flat + grouped).
|
||||
pub fn selected_album_ids(window: &AppWindow) -> Vec<String> {
|
||||
let st = window.global::<FavoritesState>();
|
||||
let mut ids: Vec<String> = (0..st.get_albums_visible().row_count())
|
||||
.filter_map(|i| st.get_albums_visible().row_data(i))
|
||||
.filter(|a| a.selected)
|
||||
.map(|a| a.id.to_string())
|
||||
.collect();
|
||||
let grouped = st.get_albums_grouped();
|
||||
for s in 0..grouped.row_count() {
|
||||
if let Some(section) = grouped.row_data(s) {
|
||||
ids.extend(
|
||||
(0..section.albums.row_count())
|
||||
.filter_map(|i| section.albums.row_data(i))
|
||||
.filter(|a| a.selected)
|
||||
.map(|a| a.id.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
|
||||
pub fn reset_loading(window: &AppWindow) {
|
||||
let state = window.global::<FavoritesState>();
|
||||
state.set_loading(true);
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::{AppWindow, FavoritesState};
|
||||
use crate::{AppWindow, FavoritesState, LibraryAllState};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Prefs {
|
||||
@@ -26,6 +26,17 @@ struct Prefs {
|
||||
artists_group: bool,
|
||||
#[serde(default = "d_grid")]
|
||||
artists_view: String,
|
||||
// Library "All": include local files + Plex in the feed (the toolbar's
|
||||
// hard-drive filter). Default ON — users expect their local items there
|
||||
// (issuers' report).
|
||||
#[serde(default = "d_true")]
|
||||
all_show_local: bool,
|
||||
// Library "All": what "local" means in the feed — "favorites" (only
|
||||
// hearted local items, webplayer parity) or "all" (the entire local
|
||||
// library: every folder + Plex album/artist/track). Settings > Local
|
||||
// Library selector (owner 2026-07-24).
|
||||
#[serde(default = "d_favorites")]
|
||||
all_local_scope: String,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -38,6 +49,8 @@ impl Default for Prefs {
|
||||
playlists_view: d_grid(),
|
||||
artists_group: false,
|
||||
artists_view: d_grid(),
|
||||
all_show_local: true,
|
||||
all_local_scope: d_favorites(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +64,12 @@ fn d_default() -> String {
|
||||
fn d_off() -> String {
|
||||
"off".to_string()
|
||||
}
|
||||
fn d_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn d_favorites() -> String {
|
||||
"favorites".to_string()
|
||||
}
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json"))
|
||||
@@ -77,9 +96,17 @@ pub fn load(window: &AppWindow) {
|
||||
st.set_playlists_view_mode(p.playlists_view.into());
|
||||
st.set_artists_group_enabled(p.artists_group);
|
||||
st.set_artists_view_mode(p.artists_view.into());
|
||||
window
|
||||
.global::<LibraryAllState>()
|
||||
.set_show_local(p.all_show_local);
|
||||
window
|
||||
.global::<LibraryAllState>()
|
||||
.set_local_scope(p.all_local_scope.clone().into());
|
||||
}
|
||||
|
||||
/// Persist the current toolbar choices read from `FavoritesState`.
|
||||
/// Persist the current toolbar choices read from `FavoritesState`. Preserves
|
||||
/// the Library-All local SCOPE (read-modify-write — it lives only here, not
|
||||
/// in any Slint global).
|
||||
pub fn save(window: &AppWindow) {
|
||||
let Some(path) = store_path() else {
|
||||
return;
|
||||
@@ -93,6 +120,28 @@ pub fn save(window: &AppWindow) {
|
||||
playlists_view: st.get_playlists_view_mode().into(),
|
||||
artists_group: st.get_artists_group_enabled(),
|
||||
artists_view: st.get_artists_view_mode().into(),
|
||||
all_show_local: window.global::<LibraryAllState>().get_show_local(),
|
||||
all_local_scope: read().all_local_scope,
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(json) = serde_json::to_vec_pretty(&p) {
|
||||
let _ = std::fs::write(&path, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted Library-All local scope: "favorites" (default) | "all".
|
||||
pub fn local_scope() -> String {
|
||||
read().all_local_scope
|
||||
}
|
||||
|
||||
/// Persist a new Library-All local scope (read-modify-write).
|
||||
pub fn set_local_scope(scope: &str) {
|
||||
let mut p = read();
|
||||
p.all_local_scope = scope.to_string();
|
||||
let Some(path) = store_path() else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
|
||||
+469
-16
@@ -7,13 +7,14 @@
|
||||
//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust
|
||||
//! (`derive`) — Slint renders the pre-computed `items-visible`.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use slint::{ComponentHandle, Model, ModelRc, VecModel};
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget};
|
||||
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
|
||||
use crate::favorites::{self, FavData, FavTab};
|
||||
use crate::{AppWindow, LibraryAllState, LibraryFeedItem};
|
||||
|
||||
@@ -295,9 +296,16 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Local + Plex favorites (source "local"/"plex"; gated by show-local
|
||||
// in derive). group "local" — bypasses the Qobuz source switches. ---
|
||||
{
|
||||
// --- Local + Plex (source "local"/"plex"; gated by show-local in
|
||||
// derive). group "local" — bypasses the Qobuz source switches. The
|
||||
// Settings scope picks the CONTENT: "favorites" (hearted items only,
|
||||
// webplayer parity) or "all" (the entire local library). ---
|
||||
if crate::favorites_prefs::local_scope() == "all" {
|
||||
match tokio::task::spawn_blocking(all_local_blocking).await {
|
||||
Ok(items) => feed.extend(items),
|
||||
Err(e) => log::error!("[qbz-slint] all-local feed load failed: {e}"),
|
||||
}
|
||||
} else {
|
||||
let locals = crate::local_favorites::list();
|
||||
let n = locals.len();
|
||||
for (i, lf) in locals.into_iter().enumerate() {
|
||||
@@ -331,6 +339,189 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
|
||||
Ok(feed)
|
||||
}
|
||||
|
||||
/// The "all" local scope (Settings > Local Library): every local-library
|
||||
/// album + artist + track, mirroring the Local Library tabs' own queries so
|
||||
/// both surfaces agree (same Plex union, same network-folder gate, same
|
||||
/// album-identity mode, offline-cache copies excluded — they must never
|
||||
/// duplicate Qobuz rows in this feed). Runs on a blocking thread — rusqlite
|
||||
/// is sync. Off-thread companion of the local arm of `load_library_all`.
|
||||
fn all_local_blocking() -> Vec<Feed> {
|
||||
let mut out: Vec<Feed> = Vec::new();
|
||||
let exclude_network = crate::local_library::exclude_network_folders_now();
|
||||
let group_mode = qbz_library::album_grouping::AlbumGroupMode::from_pref(
|
||||
&crate::locallibrary_prefs::albums_id_mode(),
|
||||
);
|
||||
let plex_path = crate::local_library::plex_cache_db_path();
|
||||
let plex_enabled = crate::plex_settings::get().enabled;
|
||||
|
||||
// Plex tracks ride TWO sections (artist names + the track list) — fetch
|
||||
// the bounded set once (mirrors the Tracks tab's page-1 merge).
|
||||
let plex_tracks: Vec<qbz_library::LocalTrack> = if plex_enabled {
|
||||
qbz_plex::plex_cache_search_tracks(String::new(), None)
|
||||
.map(|rows| {
|
||||
rows.into_iter()
|
||||
.map(crate::local_library::map_plex_cached_to_local_track)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// --- Albums — the Albums tab's own full-load page (Plex union folded in
|
||||
// by the query when enabled). ---
|
||||
let albums = crate::library_db::with_db(|db| {
|
||||
db.get_albums_metadata_page(
|
||||
0,
|
||||
crate::local_library::ALBUMS_FULL_LOAD_LIMIT,
|
||||
None,
|
||||
"artist",
|
||||
"asc",
|
||||
true,
|
||||
exclude_network,
|
||||
plex_path.as_deref(),
|
||||
group_mode,
|
||||
)
|
||||
.ok()
|
||||
.map(|p| p.albums)
|
||||
})
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let n = albums.len();
|
||||
for (i, a) in albums.into_iter().enumerate() {
|
||||
let source = if a.id.starts_with("plex:") {
|
||||
"plex"
|
||||
} else {
|
||||
"local"
|
||||
};
|
||||
let (tier, detail, _) =
|
||||
crate::quality::badge(&a.format.to_string(), a.bit_depth, Some(a.sample_rate));
|
||||
out.push(Feed {
|
||||
kind: "album".into(),
|
||||
group: "local".into(),
|
||||
source: source.into(),
|
||||
subtitle: a.artist.clone(),
|
||||
artist: a.artist,
|
||||
artist_id: String::new(),
|
||||
album: String::new(),
|
||||
album_id: String::new(),
|
||||
image_url: a.artwork_path.unwrap_or_default(),
|
||||
quality_tier: tier.into(),
|
||||
quality_detail: detail.into(),
|
||||
is_favorite: crate::local_favorites::is_favorite("album", &a.id),
|
||||
genre: String::new(),
|
||||
added_rank: rank(i, n),
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// --- Artists — local DB names + names that only exist on Plex tracks
|
||||
// (a local + Plex artist of the same name counts once). ---
|
||||
let local_artist_names: Vec<String> = crate::library_db::with_db(|db| db.get_artists().ok())
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| a.name)
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.collect();
|
||||
let local_keys: std::collections::HashSet<String> = local_artist_names
|
||||
.iter()
|
||||
.map(|n| n.trim().to_lowercase())
|
||||
.collect();
|
||||
let mut artist_names = local_artist_names;
|
||||
{
|
||||
let mut seen = local_keys.clone();
|
||||
for t in &plex_tracks {
|
||||
let name = t.artist.trim();
|
||||
if !name.is_empty() && seen.insert(name.to_lowercase()) {
|
||||
artist_names.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
artist_names.sort_by_key(|n| n.to_lowercase());
|
||||
let n = artist_names.len();
|
||||
for (i, name) in artist_names.into_iter().enumerate() {
|
||||
let source = if local_keys.contains(&name.trim().to_lowercase()) {
|
||||
"local"
|
||||
} else {
|
||||
"plex"
|
||||
};
|
||||
out.push(Feed {
|
||||
kind: "artist".into(),
|
||||
group: "local".into(),
|
||||
source: source.into(),
|
||||
subtitle: String::new(),
|
||||
artist: String::new(),
|
||||
artist_id: String::new(),
|
||||
album: String::new(),
|
||||
album_id: String::new(),
|
||||
image_url: String::new(),
|
||||
quality_tier: String::new(),
|
||||
quality_detail: String::new(),
|
||||
is_favorite: crate::local_favorites::is_favorite("artist", &name),
|
||||
genre: String::new(),
|
||||
added_rank: rank(i, n),
|
||||
id: name.clone(),
|
||||
title: name,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tracks — local pages (offline-cache copies excluded in SQL) + the
|
||||
// Plex set fetched above. ---
|
||||
let mut tracks: Vec<qbz_library::LocalTrack> = Vec::new();
|
||||
const PAGE: u64 = 200;
|
||||
let mut offset = 0u64;
|
||||
loop {
|
||||
let rows = crate::library_db::with_db(|db| {
|
||||
db.search_with_filter_page("", offset, PAGE, false, exclude_network, "default")
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
let Some(rows) = rows else { break };
|
||||
let full = rows.len() as u64 == PAGE;
|
||||
tracks.extend(rows);
|
||||
if !full || tracks.len() >= 200_000 {
|
||||
break;
|
||||
}
|
||||
offset += PAGE;
|
||||
}
|
||||
tracks.extend(plex_tracks);
|
||||
let n = tracks.len();
|
||||
for (i, t) in tracks.into_iter().enumerate() {
|
||||
let (id, source) = match t.source.as_deref() {
|
||||
Some("plex") => (format!("plex:{}", t.file_path), "plex"),
|
||||
Some("qobuz_download") => continue, // belt: the SQL already excludes
|
||||
_ => (t.file_path.clone(), "local"),
|
||||
};
|
||||
let (tier, detail, _) =
|
||||
crate::quality::badge(&t.format.to_string(), t.bit_depth, Some(t.sample_rate));
|
||||
out.push(Feed {
|
||||
kind: "track".into(),
|
||||
group: "local".into(),
|
||||
source: source.into(),
|
||||
subtitle: t.artist.clone(),
|
||||
artist: t.artist,
|
||||
artist_id: String::new(),
|
||||
album: t.album,
|
||||
album_id: t.album_group_key,
|
||||
image_url: t.artwork_path.unwrap_or_default(),
|
||||
quality_tier: tier.into(),
|
||||
quality_detail: detail.into(),
|
||||
is_favorite: crate::local_favorites::is_favorite("track", &id),
|
||||
genre: t.genre.unwrap_or_default(),
|
||||
added_rank: rank(i, n),
|
||||
id,
|
||||
title: t.title,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn to_item(f: &Feed) -> LibraryFeedItem {
|
||||
LibraryFeedItem {
|
||||
kind: f.kind.clone().into(),
|
||||
@@ -477,24 +668,286 @@ pub fn derive(window: &AppWindow) {
|
||||
}
|
||||
|
||||
st.set_items_visible(ModelRc::new(VecModel::from(out)));
|
||||
|
||||
// The grid/list only fire window-changed when the BAND changes, not when
|
||||
// the rows under it change — re-dispatch covers for the current band over
|
||||
// the fresh visible set (no-op where covers already ride the full model).
|
||||
dispatch_library_all_window(window);
|
||||
}
|
||||
|
||||
/// Build cover-download jobs for the CURRENT visible feed. Call after apply and
|
||||
/// after every derive (the ImageCache dedups already-decoded covers, so
|
||||
/// re-dispatching on filter/sort is cheap). Indices target `items-visible`.
|
||||
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> {
|
||||
let visible = window.global::<LibraryAllState>().get_items_visible();
|
||||
// ---- Windowed artwork (mirrors favorites' albums grid, Phase 1 pattern) ---
|
||||
|
||||
/// Generation guard, bumped on every Library-All (re)load. A stale in-flight
|
||||
/// cover fetch (an older load's job) is discarded on apply so it can't land
|
||||
/// on the replacement set.
|
||||
static LIB_ALL_GEN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// True if `gen` is still the current Library-All generation. The artwork
|
||||
/// pipeline calls this before applying a decoded cover so an in-flight job
|
||||
/// from a superseded load doesn't paint the new model.
|
||||
pub fn library_all_gen_current(gen: u64) -> bool {
|
||||
LIB_ALL_GEN.load(Ordering::SeqCst) == gen
|
||||
}
|
||||
|
||||
/// Last row band reported by the windowed grid/list (item indices into
|
||||
/// `items-visible`, prefetch margin already included by the view).
|
||||
static LIB_ALL_WINDOW: std::sync::Mutex<(usize, usize)> = std::sync::Mutex::new((0, 59));
|
||||
|
||||
/// Cover keys currently in the artwork pipeline (dedupe during fast scroll).
|
||||
/// Freed on apply; cleared on reloads.
|
||||
fn lib_all_inflight() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
|
||||
static S: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
S.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
|
||||
}
|
||||
|
||||
/// Image-cache handle captured at load time so the window dispatcher can
|
||||
/// spawn artwork jobs outside the load path. Plex params are re-read at
|
||||
/// dispatch time (`plex_settings::get`), so only the cache rides along here.
|
||||
fn lib_all_dispatch_ctx() -> &'static std::sync::Mutex<Option<ImageCache>> {
|
||||
static S: std::sync::OnceLock<std::sync::Mutex<Option<ImageCache>>> =
|
||||
std::sync::OnceLock::new();
|
||||
S.get_or_init(|| std::sync::Mutex::new(None))
|
||||
}
|
||||
|
||||
/// A windowed artwork job finished (applied or dropped) — free its slot so
|
||||
/// the dispatcher can request the key again after an eviction.
|
||||
pub fn artwork_job_done(key: &str) {
|
||||
lib_all_inflight().lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
/// Reset the windowed artwork pipeline for a fresh Library-All load: bump the
|
||||
/// generation (orphans every in-flight job — dropped on apply), free their
|
||||
/// dedupe slots and stash the image-cache handle the dispatchers spawn jobs
|
||||
/// with. Runs on the UI thread BEFORE `apply_library_all`.
|
||||
pub fn begin_library_all_artwork(image_cache: ImageCache) {
|
||||
LIB_ALL_GEN.fetch_add(1, Ordering::SeqCst);
|
||||
lib_all_inflight().lock().unwrap().clear();
|
||||
*lib_all_dispatch_ctx().lock().unwrap() = Some(image_cache);
|
||||
}
|
||||
|
||||
/// Dispatch throttle for the view's band reports (leading + trailing edge,
|
||||
/// UI thread) — same rationale as the favorites albums grid: coalesce fling
|
||||
/// crossings to one dispatch per interval.
|
||||
const LIB_ALL_DISPATCH_THROTTLE_MS: u64 = 180;
|
||||
thread_local! {
|
||||
static LIB_ALL_BAND: crate::viewport::BandDispatcher =
|
||||
crate::viewport::BandDispatcher::new(LIB_ALL_DISPATCH_THROTTLE_MS);
|
||||
}
|
||||
|
||||
/// The windowed grid/list reported a new visible row band. The band is stored
|
||||
/// immediately (model rebuilds re-read it); the artwork dispatch is throttled
|
||||
/// through the BandDispatcher.
|
||||
pub fn window_changed(window: &AppWindow, first: i32, last: i32) {
|
||||
let first = first.max(0) as usize;
|
||||
let last = last.max(first as i32) as usize;
|
||||
*LIB_ALL_WINDOW.lock().unwrap() = (first, last);
|
||||
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
|
||||
let weak = window.as_weak();
|
||||
LIB_ALL_BAND.with(|d| {
|
||||
d.report(Box::new(move || {
|
||||
if !library_all_gen_current(gen) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = weak.upgrade() {
|
||||
dispatch_library_all_window(&w);
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispatch covers for the current window (over `items-visible`) and evict
|
||||
/// decoded covers far outside it back to the placeholder, so cover RAM scales
|
||||
/// with the viewport instead of the library. Delivery is key-keyed
|
||||
/// (`LibraryAllById`), so a derive re-sort between dispatch and apply cannot
|
||||
/// land a cover on the wrong card; a reload bumps `LIB_ALL_GEN` and the apply
|
||||
/// arm drops the stale image.
|
||||
pub fn dispatch_library_all_window(window: &AppWindow) {
|
||||
let (first, last) = *LIB_ALL_WINDOW.lock().unwrap();
|
||||
let Some(image_cache) = lib_all_dispatch_ctx().lock().unwrap().clone() else {
|
||||
return;
|
||||
};
|
||||
let gen = LIB_ALL_GEN.load(Ordering::SeqCst);
|
||||
let state = window.global::<LibraryAllState>();
|
||||
let visible = state.get_items_visible();
|
||||
let len = visible.row_count();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
let last = last.min(len - 1);
|
||||
if first > last {
|
||||
return;
|
||||
}
|
||||
// Retention = the window plus one window-span on each side. Beyond it,
|
||||
// covers return to the placeholder; re-entry is cheap (byte-budgeted
|
||||
// decoded cache, else a bounded re-decode through the disk cache).
|
||||
let span = last - first + 1;
|
||||
let keep_lo = first.saturating_sub(span);
|
||||
let keep_hi = (last + span).min(len - 1);
|
||||
let mut jobs = Vec::new();
|
||||
for i in 0..visible.row_count() {
|
||||
if let Some(item) = visible.row_data(i) {
|
||||
let url = item.image_url.to_string();
|
||||
if !url.is_empty() {
|
||||
{
|
||||
let mut inflight = lib_all_inflight().lock().unwrap();
|
||||
for vi in first..=last {
|
||||
let Some(item) = visible.row_data(vi) else { continue };
|
||||
if item.image.size().width > 0 || item.image_url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = feed_key(item.kind.as_str(), item.id.as_str());
|
||||
if inflight.insert(key.clone()) {
|
||||
jobs.push(ArtworkJob {
|
||||
target: ArtworkTarget::LibraryAllCover { index: i },
|
||||
url,
|
||||
target: ArtworkTarget::LibraryAllById { key, gen },
|
||||
url: item.image_url.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
jobs
|
||||
for vi in (0..keep_lo).chain(keep_hi + 1..len) {
|
||||
let Some(item) = visible.row_data(vi) else { continue };
|
||||
if item.image.size().width > 0 {
|
||||
set_library_all_artwork(
|
||||
window,
|
||||
&feed_key(item.kind.as_str(), item.id.as_str()),
|
||||
slint::Image::default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !jobs.is_empty() {
|
||||
// Mixed payload (Qobuz http / local fs / Plex /library/) — route each
|
||||
// cover by scheme so local/Plex covers decode.
|
||||
let plex = crate::plex_settings::get();
|
||||
crate::artwork::spawn_search_loads(
|
||||
jobs,
|
||||
plex.base_url,
|
||||
plex.token,
|
||||
window.as_weak(),
|
||||
image_cache,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable artwork key for a feed row. Qobuz numeric ids overlap across entity
|
||||
/// types (a track id can equal an artist id), so the kind prefixes the key.
|
||||
fn feed_key(kind: &str, id: &str) -> String {
|
||||
format!("{kind}:{id}")
|
||||
}
|
||||
|
||||
/// Set a freshly-decoded (or evicted) cover BY KEY on the full `items` model
|
||||
/// AND the rendered `items-visible`. Writing the full model too is what keeps
|
||||
/// covers across derives: `derive` clones rows out of `items`, so a cover
|
||||
/// that only ever landed on the visible copy was blanked by every search
|
||||
/// keystroke (and re-fought by the whole dispatch pipeline each time).
|
||||
pub fn set_library_all_artwork(window: &AppWindow, key: &str, image: slint::Image) {
|
||||
let st = window.global::<LibraryAllState>();
|
||||
for model in [st.get_items(), st.get_items_visible()] {
|
||||
for i in 0..model.row_count() {
|
||||
let Some(mut item) = model.row_data(i) else { continue };
|
||||
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
|
||||
item.image = image.clone();
|
||||
model.set_row_data(i, item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flip the favorite/follow flag BY KEY on the full `items` model AND the
|
||||
/// rendered `items-visible` — the Library-All leg of the app-wide optimistic
|
||||
/// favorite flips (`set_row_favorite` / `set_album_row_favorite` /
|
||||
/// `search::mark_artist_followed` don't know this surface). Called for both
|
||||
/// the optimistic flip and the failure rollback.
|
||||
pub fn set_feed_favorite(window: &AppWindow, kind: &str, id: &str, value: bool) {
|
||||
let key = feed_key(kind, id);
|
||||
let st = window.global::<LibraryAllState>();
|
||||
for model in [st.get_items(), st.get_items_visible()] {
|
||||
for i in 0..model.row_count() {
|
||||
let Some(mut item) = model.row_data(i) else { continue };
|
||||
if feed_key(item.kind.as_str(), item.id.as_str()) == key {
|
||||
if item.is_favorite != value {
|
||||
item.is_favorite = value;
|
||||
model.set_row_data(i, item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a feed id belongs to the LOCAL/Plex world, not Qobuz. Local
|
||||
/// tracks are file paths (`/music/x.flac` or `plex:<path>` — Qobuz track ids
|
||||
/// are numeric), local albums are group keys (`plex:…` or containing `|`/`/`),
|
||||
/// local artists are plain names (Qobuz artist ids are numeric).
|
||||
fn is_local_feed_id(kind: &str, id: &str) -> bool {
|
||||
match kind {
|
||||
"track" | "artist" => id.parse::<u64>().is_err(),
|
||||
"album" => id.starts_with("plex:") || id.contains('|') || id.contains('/'),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a LOCAL/Plex feed row's heart against the local-favorites store.
|
||||
/// The All-feed media-action arms receive only `(kind, id, action)` and would
|
||||
/// otherwise fire the Qobuz favorite API at a file path / group key / artist
|
||||
/// name — always an error toast (owner report 2026-07-24). Returns
|
||||
/// `Some(new_state)` when the id was local and the toggle was handled here
|
||||
/// (the caller must NOT continue into its Qobuz path); `None` for Qobuz ids.
|
||||
///
|
||||
/// Every local row in the feed is favorited by construction (the store feeds
|
||||
/// the feed), so the common click is an UN-favorite; the re-favorite path
|
||||
/// rebuilds the store snapshot from the feed row itself (title / subtitle /
|
||||
/// artwork / source all ride the model). Works offline — the store is local.
|
||||
pub fn toggle_local_feed_favorite(window: &AppWindow, kind: &str, id: &str) -> Option<bool> {
|
||||
if !is_local_feed_id(kind, id) {
|
||||
return None;
|
||||
}
|
||||
let new_state = if crate::local_favorites::is_favorite(kind, id) {
|
||||
if let Err(e) = crate::local_favorites::unfavorite(kind, id) {
|
||||
log::error!("[qbz-slint] local unfavorite ({kind}:{id}) failed: {e}");
|
||||
return Some(true); // state unchanged — keep the heart filled
|
||||
}
|
||||
false
|
||||
} else {
|
||||
// Re-favorite: rebuild the snapshot from the feed row.
|
||||
let st = window.global::<LibraryAllState>();
|
||||
let items = st.get_items();
|
||||
let mut snap = None;
|
||||
for i in 0..items.row_count() {
|
||||
let Some(item) = items.row_data(i) else { continue };
|
||||
if feed_key(item.kind.as_str(), item.id.as_str()) == feed_key(kind, id) {
|
||||
snap = Some(crate::local_favorites::LocalFavItem {
|
||||
kind: kind.to_string(),
|
||||
id: id.to_string(),
|
||||
title: item.title.to_string(),
|
||||
subtitle: item.subtitle.to_string(),
|
||||
artwork_url: item.image_url.to_string(),
|
||||
artist: item.artist.to_string(),
|
||||
source: item.source.to_string(),
|
||||
favorited_at: 0, // the service stamps `now` itself
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
match snap {
|
||||
Some(s) if s.source == "local" || s.source == "plex" => {
|
||||
if let Err(e) = crate::local_favorites::favorite(&s) {
|
||||
log::error!("[qbz-slint] local favorite ({kind}:{id}) failed: {e}");
|
||||
return Some(false); // state unchanged — keep the heart hollow
|
||||
}
|
||||
true
|
||||
}
|
||||
// Not in the feed (or a malformed source) — nothing to snapshot;
|
||||
// treat as handled-but-unchanged rather than falling into Qobuz.
|
||||
_ => return Some(false),
|
||||
}
|
||||
};
|
||||
set_feed_favorite(window, kind, id, new_state);
|
||||
// Same toast wording as the Qobuz album/track favorite arms.
|
||||
crate::toast::success(
|
||||
window,
|
||||
if new_state {
|
||||
"Added to favorites"
|
||||
} else {
|
||||
"Removed from favorites"
|
||||
},
|
||||
);
|
||||
Some(new_state)
|
||||
}
|
||||
@@ -677,7 +677,7 @@ pub fn clear_album_filter(window: &AppWindow) {
|
||||
/// disabled OR the data dir is unavailable, so the Albums union degrades to
|
||||
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
|
||||
/// command's resolution (`commands_v2/library.rs`).
|
||||
fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
||||
pub(crate) fn plex_cache_db_path() -> Option<std::path::PathBuf> {
|
||||
if !crate::plex_settings::get().enabled {
|
||||
return None;
|
||||
}
|
||||
@@ -737,7 +737,7 @@ pub fn invalidate_artists(window: &AppWindow) {
|
||||
/// one shot (search/sort/filter/group are all derived client-side over the
|
||||
/// cached set in `derive_albums`), so we request a single large page rather
|
||||
/// than truly paginating. `total` from the page is informational here.
|
||||
const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
|
||||
pub(crate) const ALBUMS_FULL_LOAD_LIMIT: u64 = 1_000_000;
|
||||
|
||||
/// The user's album-identity mode (Albums tab dropdown / Settings, persisted
|
||||
/// in locallibrary_ui.json). Read on the UI thread and captured into the
|
||||
|
||||
@@ -100,6 +100,12 @@ pub fn ephemeral_path() -> Option<String> {
|
||||
read().ephemeral_folder.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// The persisted album-identity mode ("folder" | "metadata") — the feed
|
||||
/// loader reads it without a window handle (Settings/Library-All scope).
|
||||
pub fn albums_id_mode() -> String {
|
||||
read().albums_id_mode
|
||||
}
|
||||
|
||||
/// Persist (or clear) the ephemeral-folder path (read-modify-write).
|
||||
pub fn save_ephemeral_path(path: Option<&str>) {
|
||||
let mut p = read();
|
||||
|
||||
Reference in new issue
Block a user