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); }
}
// ---------------------------------------------------------------------------
// 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 {
+14 -8
View File
@@ -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();
+121
View File
@@ -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(&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) {
let state = window.global::<FavoritesState>();
state.set_loading(true);
+51 -2
View File
@@ -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
View File
@@ -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)
}
+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
/// 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
+6
View File
@@ -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();