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:
vicrodh committed 2026-07-25 12:04:22 -06:00
1 parent 6fff4aae31
commit 54c2779eb5
8 files changed
+1182 -337

No files matched your search

+491 -308
View File
@@ -68,6 +68,104 @@ component FavTabMenu inherits SegmentedTabBar {
selected(id) => { FavoritesActions.select-tab(id); } 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). // One underline sub-tab for the Playlists tab (Library / Following).
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern. // Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
component PlaylistSubTab inherits Rectangle { component PlaylistSubTab inherits Rectangle {
@@ -411,6 +509,18 @@ export component FavoritesView inherits Rectangle {
clicked => { FavoritesActions.albums-shuffle(); } 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" if FavoritesState.active-tab == "artists"
&& FavoritesState.artists-visible.length > 0: HorizontalLayout { && FavoritesState.artists-visible.length > 0: HorizontalLayout {
@@ -693,9 +803,15 @@ export component FavoritesView inherits Rectangle {
sm: true; sm: true;
query <=> LibraryAllState.search; query <=> LibraryAllState.search;
edited(q) => { 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 // Source switches — Purchases / Favorites / Following. Tooltips
// spell out each cryptic icon (owner request). // spell out each cryptic icon (owner request).
@@ -729,7 +845,9 @@ export component FavoritesView inherits Rectangle {
clicked => { LibraryAllActions.toggle-source("following"); } 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 { VerticalLayout {
alignment: center; alignment: center;
ToggleButton { ToggleButton {
@@ -932,10 +1050,12 @@ export component FavoritesView inherits Rectangle {
} }
} }
} }
// Grid — non-windowed flow (columns from width), one card per // Grid — WINDOWED flow (columns from width), one card-slot per
// item keyed off `kind`, reusing the existing card components. // 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 == "" 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; viewport-height: all-grid.height + 32px;
all-grid := Rectangle { all-grid := Rectangle {
property <length> cw: 200px; property <length> cw: 200px;
@@ -950,13 +1070,30 @@ export component FavoritesView inherits Rectangle {
width: parent.width - 64px; width: parent.width - 64px;
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px; 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); 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); y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
width: all-grid.cw; width: all-grid.cw;
height: all-grid.ch; 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; show-source-badge: LibraryAllState.show-local;
track: { track: {
id: item.id, 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; show-source-badge: LibraryAllState.show-local;
album: { album: {
id: item.id, id: item.id,
@@ -992,6 +1129,7 @@ export component FavoritesView inherits Rectangle {
quality-tier: item.quality-tier, quality-tier: item.quality-tier,
quality-label: item.quality-detail, quality-label: item.quality-detail,
source: item.source, source: item.source,
is-favorite: item.is-favorite,
}; };
clicked(id) => { FavoritesActions.open-album(id); } clicked(id) => { FavoritesActions.open-album(id); }
media-action(kind, id, action) => { 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: { artist: {
id: item.id, id: item.id,
title: item.title, title: item.title,
artwork: item.image, artwork: item.image,
following: item.is-favorite,
}; };
follow-mode: "none";
clicked(id) => { FavoritesActions.open-artist(id); } 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: { playlist: {
id: item.id, id: item.id,
title: item.title, 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: { label: {
id: item.id, id: item.id,
name: item.title, 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 == "" 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; 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 { all-list := VerticalLayout {
x: 32px; x: 32px;
y: 16px; y: 16px;
@@ -1055,9 +1211,12 @@ export component FavoritesView inherits Rectangle {
spacing: 2px; spacing: 2px;
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle { for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
height: 56px; height: 56px;
property <bool> in-window: i >= all-list-win.first-item
&& i <= all-list-win.last-item;
border-radius: 6px; border-radius: 6px;
background: row-ta.has-hover || menu-btn-ta.has-hover // Placeholder tile while the row content is
? Theme.surface-hover : transparent; // unmounted (fast scroll).
background: row-rect.in-window ? transparent : Theme.surface-elevated;
// Context-menu opener position (row-local px). Defaults // Context-menu opener position (row-local px). Defaults
// by the ⋯ button; a right-click anywhere on the row // by the ⋯ button; a right-click anywhere on the row
// overrides it to the cursor (window pointer minus the // 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. // the right-click handler so the whole row is sensitive.
property <length> menu-x: row-rect.width - 206px; property <length> menu-x: row-rect.width - 206px;
property <length> menu-y: 48px; property <length> menu-y: 48px;
// Row body — click plays/opens by kind; right-click opens if row-rect.in-window: Rectangle {
// the menu at the cursor. Behind the cell TouchAreas. border-radius: 6px;
row-ta := TouchArea { background: row-ta.has-hover || menu-btn-ta.has-hover
mouse-cursor: pointer; ? Theme.surface-hover : transparent;
clicked => { // Row body — click plays/opens by kind; right-click
if (item.kind == "track") { // opens the menu at the cursor. Behind the cells.
FavoritesActions.track-action(item.id, "play"); row-ta := TouchArea {
} else if (item.kind == "album") { mouse-cursor: pointer;
FavoritesActions.open-album(item.id); clicked => {
} else if (item.kind == "artist") { if (item.kind == "track") {
FavoritesActions.open-artist(item.id); FavoritesActions.track-action(item.id, "play");
} else if (item.kind == "playlist") { } else if (item.kind == "album") {
FavoritesActions.open-playlist(item.id); FavoritesActions.open-album(item.id);
} else if (item.kind == "label") { } else if (item.kind == "artist") {
FavoritesActions.open-label(item.id, item.title); FavoritesActions.open-artist(item.id);
} } else if (item.kind == "playlist") {
} FavoritesActions.open-playlist(item.id);
pointer-event(e) => { } else if (item.kind == "label") {
if (e.kind == PointerEventKind.down FavoritesActions.open-label(item.id, item.title);
&& 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 item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle { }
width: 100%; pointer-event(e) => {
height: 100%; if (e.kind == PointerEventKind.down
background: #000000a6; && e.button == PointerEventButton.right) {
opacity: play-ta.has-hover ? 1.0 : 0.0; row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
animate opacity { duration: 150ms; } row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
QbzIcon { row-menu.show();
source: @image-url("../assets/icons/play-fill.svg"); }
width: 16px; }
height: 16px; }
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px; HorizontalLayout {
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px; padding-left: 12px;
tint: #ffffff; 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 { if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
mouse-cursor: pointer; width: 100%;
clicked => { height: 100%;
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); } background: #000000a6;
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); } opacity: play-ta.has-hover ? 1.0 : 0.0;
else { FavoritesActions.playlist-action(item.id, "play"); } 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) => { play-ta := TouchArea {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) { mouse-cursor: pointer;
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x; clicked => {
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y; if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
row-menu.show(); 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).
// Col — title + subtitle (artist link for track/album). VerticalLayout {
VerticalLayout { alignment: center;
alignment: center; horizontal-stretch: 1;
horizontal-stretch: 1; spacing: 2px;
spacing: 2px; title-ta := TouchArea {
title-ta := TouchArea { height: 18px;
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 {
mouse-cursor: pointer; mouse-cursor: pointer;
clicked => { clicked => {
// The ⋯ opener: anchor the menu by if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
// the button (right-click uses the else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
// cursor instead). else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
row-rect.menu-x = row-rect.width - 206px; else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
row-rect.menu-y = 48px; else { FavoritesActions.open-label(item.id, item.title); }
row-menu.show(); }
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).
// Per-kind context menu (⋯ button + right-click). row-menu := PopupWindow {
row-menu := PopupWindow { x: row-rect.menu-x;
x: row-rect.menu-x; y: row-rect.menu-y;
y: row-rect.menu-y; width: 196px;
width: 196px; height: (item.kind == "track" ? 6
height: (item.kind == "track" ? 6 : item.kind == "album" ? 6
: item.kind == "album" ? 6 : item.kind == "playlist" ? 4
: item.kind == "playlist" ? 4 : 1) * 33px + 10px;
: 1) * 33px + 10px; ContextMenu {
ContextMenu { if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { icon: @image-url("../assets/icons/play.svg");
icon: @image-url("../assets/icons/play.svg"); label: @tr("Play");
label: @tr("Play"); clicked => {
clicked => { if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); } else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); } else { FavoritesActions.playlist-action(item.id, "play"); }
else { FavoritesActions.playlist-action(item.id, "play"); } }
} }
} if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { icon: @image-url("../assets/icons/list-plus.svg");
icon: @image-url("../assets/icons/list-plus.svg"); label: @tr("Play next");
label: @tr("Play next"); clicked => {
clicked => { if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); } else if (item.kind == "album") { FavoritesActions.album-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"); }
else { FavoritesActions.playlist-action(item.id, "play-next"); } }
} }
} if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem { icon: @image-url("../assets/icons/list-end.svg");
icon: @image-url("../assets/icons/list-end.svg"); label: @tr("Add to queue");
label: @tr("Add to queue"); clicked => {
clicked => { if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); } else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); } else { FavoritesActions.playlist-action(item.id, "queue"); }
else { FavoritesActions.playlist-action(item.id, "queue"); } }
} }
} if item.kind == "album": ContextMenuItem {
if item.kind == "album": ContextMenuItem { icon: @image-url("../assets/icons/library-big.svg");
icon: @image-url("../assets/icons/library-big.svg"); label: @tr("Open album");
label: @tr("Open album"); clicked => { FavoritesActions.open-album(item.id); }
clicked => { FavoritesActions.open-album(item.id); } }
} if item.kind == "playlist": ContextMenuItem {
if item.kind == "playlist": ContextMenuItem { icon: @image-url("../assets/icons/library-big.svg");
icon: @image-url("../assets/icons/library-big.svg"); label: @tr("Open playlist");
label: @tr("Open playlist"); clicked => { FavoritesActions.open-playlist(item.id); }
clicked => { FavoritesActions.open-playlist(item.id); } }
} if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem { icon: @image-url("../assets/icons/user.svg");
icon: @image-url("../assets/icons/user.svg"); label: @tr("Go to artist");
label: @tr("Go to artist"); clicked => { FavoritesActions.open-artist(item.artist-id); }
clicked => { FavoritesActions.open-artist(item.artist-id); } }
} if item.kind == "track" && item.album-id != "": ContextMenuItem {
if item.kind == "track" && item.album-id != "": ContextMenuItem { icon: @image-url("../assets/icons/disc.svg");
icon: @image-url("../assets/icons/disc.svg"); label: @tr("Go to album");
label: @tr("Go to album"); clicked => { FavoritesActions.open-album(item.album-id); }
clicked => { FavoritesActions.open-album(item.album-id); } }
} if item.kind == "artist": ContextMenuItem {
if item.kind == "artist": ContextMenuItem { icon: @image-url("../assets/icons/user.svg");
icon: @image-url("../assets/icons/user.svg"); label: @tr("Go to artist");
label: @tr("Go to artist"); clicked => { FavoritesActions.open-artist(item.id); }
clicked => { FavoritesActions.open-artist(item.id); } }
} if item.kind == "label": ContextMenuItem {
if item.kind == "label": ContextMenuItem { icon: @image-url("../assets/icons/disc-3.svg");
icon: @image-url("../assets/icons/disc-3.svg"); label: @tr("Go to label");
label: @tr("Go to label"); clicked => { FavoritesActions.open-label(item.id, item.title); }
clicked => { FavoritesActions.open-label(item.id, item.title); } }
} if item.kind == "track" || item.kind == "album": ContextMenuItem {
if item.kind == "track" || item.kind == "album": ContextMenuItem { icon: item.is-favorite
icon: item.is-favorite ? @image-url("../assets/icons/heart-filled.svg")
? @image-url("../assets/icons/heart-filled.svg") : @image-url("../assets/icons/heart.svg");
: @image-url("../assets/icons/heart.svg"); label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library"); clicked => {
clicked => { if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); } else { FavoritesActions.album-action(item.id, "favorite"); }
else { FavoritesActions.album-action(item.id, "favorite"); } }
} }
} }
} }
@@ -1555,6 +1719,24 @@ export component FavoritesView inherits Rectangle {
// --- Albums tab --- // --- Albums tab ---
if FavoritesState.active-tab == "albums": VerticalLayout { if FavoritesState.active-tab == "albums": VerticalLayout {
spacing: 0px; 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 { if FavoritesState.albums.length == 0: Text {
text: @tr("No albums in your Library yet."); text: @tr("No albums in your Library yet.");
color: Theme.text-muted; color: Theme.text-muted;
@@ -1572,6 +1754,7 @@ export component FavoritesView inherits Rectangle {
view-mode: FavoritesState.albums-view-mode; view-mode: FavoritesState.albums-view-mode;
is-grouped: FavoritesState.albums-group-mode != "off"; is-grouped: FavoritesState.albums-group-mode != "off";
show-source: false; show-source: false;
select-mode: FavoritesState.albums-multi-select;
// Viewport windowing (Phase 1): only cards near the // Viewport windowing (Phase 1): only cards near the
// visible band mount + get artwork dispatched. The // visible band mount + get artwork dispatched. The
// offset is the page's padding-top (16px); the grid's // offset is the page's padding-top (16px); the grid's
@@ -9,7 +9,7 @@
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.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 { SettingRow } from "SettingRow.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint"; import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
@@ -448,6 +448,33 @@ export component LocalLibrarySettings inherits VerticalLayout {
Rectangle { height: 22px; } 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 ----------------------------------------------------- // --- Maintenance -----------------------------------------------------
GroupHeader { text: @tr("MAINTENANCE"); } GroupHeader { text: @tr("MAINTENANCE"); }
SettingRow { SettingRow {
+14 -8
View File
@@ -137,9 +137,11 @@ pub enum ArtworkTarget {
/// main-column Playlists carousel). Single cover (slot 0), like /// main-column Playlists carousel). Single cover (slot 0), like
/// `LabelPlaylistCover`. /// `LabelPlaylistCover`.
ArtistPlaylistCover { index: usize }, ArtistPlaylistCover { index: usize },
/// A card in the Library "All" mixed feed (`LibraryAllState.items-visible[index]`). /// A card in the Library "All" mixed feed, keyed by `kind:id` (Qobuz
/// Dispatched against the VISIBLE model, re-dispatched on each derive. /// numeric ids overlap across entity types). Id-keyed + generation-guarded
LibraryAllCover { index: usize }, /// 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 /// A row in `ArtistState.library-tracks[index]` — the ArtistPage
/// "In library" track list (library_by_artist seed). Row thumbnail. /// "In library" track list (library_by_artist seed). Row thumbnail.
ArtistLibraryTrack { index: usize }, ArtistLibraryTrack { index: usize },
@@ -1264,12 +1266,16 @@ fn apply_artwork(
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::LibraryAllCover { index } => { ArtworkTarget::LibraryAllById { key, gen } => {
let model = window.global::<crate::LibraryAllState>().get_items_visible(); // The job is done either way — free its in-flight slot so the
if let Some(mut item) = model.row_data(index) { // window dispatcher can re-request it after an eviction.
item.image = image; crate::library_all::artwork_job_done(&key);
model.set_row_data(index, item); // 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 } => { ArtworkTarget::ArtistLibraryTrack { index } => {
let model = window.global::<ArtistState>().get_library_tracks(); let model = window.global::<ArtistState>().get_library_tracks();
+121
View File
@@ -1611,6 +1611,127 @@ pub fn selected_tracks(window: &AppWindow) -> Vec<Track> {
.unwrap_or_default() .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(&section.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(&section.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) { pub fn reset_loading(window: &AppWindow) {
let state = window.global::<FavoritesState>(); let state = window.global::<FavoritesState>();
state.set_loading(true); state.set_loading(true);
+51 -2
View File
@@ -8,7 +8,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use slint::ComponentHandle; use slint::ComponentHandle;
use crate::{AppWindow, FavoritesState}; use crate::{AppWindow, FavoritesState, LibraryAllState};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct Prefs { struct Prefs {
@@ -26,6 +26,17 @@ struct Prefs {
artists_group: bool, artists_group: bool,
#[serde(default = "d_grid")] #[serde(default = "d_grid")]
artists_view: String, 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 { impl Default for Prefs {
@@ -38,6 +49,8 @@ impl Default for Prefs {
playlists_view: d_grid(), playlists_view: d_grid(),
artists_group: false, artists_group: false,
artists_view: d_grid(), 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 { fn d_off() -> String {
"off".to_string() "off".to_string()
} }
fn d_true() -> bool {
true
}
fn d_favorites() -> String {
"favorites".to_string()
}
fn store_path() -> Option<PathBuf> { fn store_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("qbz").join("favorites_ui.json")) 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_playlists_view_mode(p.playlists_view.into());
st.set_artists_group_enabled(p.artists_group); st.set_artists_group_enabled(p.artists_group);
st.set_artists_view_mode(p.artists_view.into()); 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) { pub fn save(window: &AppWindow) {
let Some(path) = store_path() else { let Some(path) = store_path() else {
return; return;
@@ -93,6 +120,28 @@ pub fn save(window: &AppWindow) {
playlists_view: st.get_playlists_view_mode().into(), playlists_view: st.get_playlists_view_mode().into(),
artists_group: st.get_artists_group_enabled(), artists_group: st.get_artists_group_enabled(),
artists_view: st.get_artists_view_mode().into(), 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() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
+469 -16
View File
@@ -7,13 +7,14 @@
//! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust //! into `LibraryAllState`. Search / sort / source-switch filtering all run in Rust
//! (`derive`) — Slint renders the pre-computed `items-visible`. //! (`derive`) — Slint renders the pre-computed `items-visible`.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use slint::{ComponentHandle, Model, ModelRc, VecModel}; use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::adapter::SlintAdapter; use crate::adapter::SlintAdapter;
use crate::artwork::{ArtworkJob, ArtworkTarget}; use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::favorites::{self, FavData, FavTab}; use crate::favorites::{self, FavData, FavTab};
use crate::{AppWindow, LibraryAllState, LibraryFeedItem}; 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 // --- Local + Plex (source "local"/"plex"; gated by show-local in
// in derive). group "local" — bypasses the Qobuz source switches. --- // 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 locals = crate::local_favorites::list();
let n = locals.len(); let n = locals.len();
for (i, lf) in locals.into_iter().enumerate() { 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) 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 { fn to_item(f: &Feed) -> LibraryFeedItem {
LibraryFeedItem { LibraryFeedItem {
kind: f.kind.clone().into(), kind: f.kind.clone().into(),
@@ -477,24 +668,286 @@ pub fn derive(window: &AppWindow) {
} }
st.set_items_visible(ModelRc::new(VecModel::from(out))); 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 // ---- Windowed artwork (mirrors favorites' albums grid, Phase 1 pattern) ---
/// after every derive (the ImageCache dedups already-decoded covers, so
/// re-dispatching on filter/sort is cheap). Indices target `items-visible`. /// Generation guard, bumped on every Library-All (re)load. A stale in-flight
pub fn artwork_jobs(window: &AppWindow) -> Vec<ArtworkJob> { /// cover fetch (an older load's job) is discarded on apply so it can't land
let visible = window.global::<LibraryAllState>().get_items_visible(); /// 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(); let mut jobs = Vec::new();
for i in 0..visible.row_count() { {
if let Some(item) = visible.row_data(i) { let mut inflight = lib_all_inflight().lock().unwrap();
let url = item.image_url.to_string(); for vi in first..=last {
if !url.is_empty() { 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 { jobs.push(ArtworkJob {
target: ArtworkTarget::LibraryAllCover { index: i }, target: ArtworkTarget::LibraryAllById { key, gen },
url, 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)
} }
+2 -2
View File
@@ -677,7 +677,7 @@ pub fn clear_album_filter(window: &AppWindow) {
/// disabled OR the data dir is unavailable, so the Albums union degrades to /// disabled OR the data dir is unavailable, so the Albums union degrades to
/// local-only (identical behaviour to before Plex existed). Mirrors the Tauri /// local-only (identical behaviour to before Plex existed). Mirrors the Tauri
/// command's resolution (`commands_v2/library.rs`). /// 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 { if !crate::plex_settings::get().enabled {
return None; 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 /// 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 /// cached set in `derive_albums`), so we request a single large page rather
/// than truly paginating. `total` from the page is informational here. /// 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 /// The user's album-identity mode (Albums tab dropdown / Settings, persisted
/// in locallibrary_ui.json). Read on the UI thread and captured into the /// in locallibrary_ui.json). Read on the UI thread and captured into the
+6
View File
@@ -100,6 +100,12 @@ pub fn ephemeral_path() -> Option<String> {
read().ephemeral_folder.filter(|s| !s.is_empty()) 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). /// Persist (or clear) the ephemeral-folder path (read-modify-write).
pub fn save_ephemeral_path(path: Option<&str>) { pub fn save_ephemeral_path(path: Option<&str>) {
let mut p = read(); let mut p = read();