fix(library): tray-restore crash, local art/badges, LabelCard, live pins, rich list rows

- Fix the tray-restore wgpu crash (pre-existing, amplified by the app-wide
  background): clear ImmersiveState.shader-texture on RenderingTeardown so the
  first restored frame never hands a cross-instance stale texture to the new
  femtovg canvas.
- Local/Plex feed covers now decode: route the "All" artwork jobs through
  spawn_search_loads (http / local fs / Plex thumb) at all four dispatch sites;
  show the source badge on the feed's album/track cards (AlbumCard maps plain
  "qobuz" to the Qobuz logo).
- New card-sized LabelCard (200x246), used by the feed + the Favorites Labels
  tab (replaces the round FavLabelCard).
- Artist pin badge updates live in the Favorites Artists grid: add is-pinned to
  FavoriteArtistItem, stamp it, bind it in the ArtistGridCard, and flip the
  favorites artist models in set_artist_row_pinned.
- Rich "All" list view: type-aware subtitle, quality badge (album/track),
  source icon (local/Plex), favorite/follow indicator, and a per-kind context
  menu (⋯ button + right-click).

cc
This commit is contained in:
vicrodh committed 2026-07-18 11:27:28 -06:00
1 parent 8e0802768f
commit f484cc481f
6 files changed
+374 -54

No files matched your search

+2 -2
View File
@@ -339,14 +339,14 @@ export component AlbumCard inherits Rectangle {
Image {
source: root.album.source == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: (root.album.source == "qobuz_download" || root.album.source == "qobuz_purchase"
: (root.album.source == "qobuz_download" || root.album.source == "qobuz_purchase" || root.album.source == "qobuz"
? @image-url("../assets/icons/qobuz-logo-filled.svg")
: @image-url("../assets/icons/hard-drive.svg"));
colorize: root.album.source == "qobuz_purchase" ? #eab308
: (root.album.source == "local" ? #ffffff : transparent);
width: root.album.source == "plex" ? 16px
: (root.album.source == "qobuz_download" ? 22px
: (root.album.source == "qobuz_purchase" ? 18px : 14px));
: (root.album.source == "qobuz_purchase" || root.album.source == "qobuz" ? 18px : 14px));
height: self.width;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
+107
View File
@@ -0,0 +1,107 @@
// Label card — the album card's twin for record labels: a 200x200 square
// cover (the label logo, contain-fit over an elevated placeholder, or an
// indigo→violet gradient + disc glyph when no logo), then a title / album-count
// stack. No hover overlay buttons (a label is opened, not played). Matches the
// 200x246 footprint of AlbumCard / PlaylistCard so a mixed grid stays aligned.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { Spacing } from "../foundation/spacing.slint";
import { FavoriteLabelItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
export component LabelCard inherits Rectangle {
in property <FavoriteLabelItem> label;
// (id, name) — opens the label landing page.
callback clicked(string, string);
in property <length> card-height: 246px;
width: 200px;
height: root.card-height;
VerticalLayout {
alignment: start;
spacing: 0px;
// --- Logo -------------------------------------------------------
Rectangle {
width: 200px;
height: 200px;
border-radius: Radius.sm;
background: root.label.image-url == ""
? @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)
: Theme.surface-elevated;
clip: true;
// Logos have varied/transparent backgrounds — contain-fit so they
// are never cropped (unlike album art, which is cover-fit).
Image {
width: 100%;
height: 100%;
source: root.label.image;
image-fit: contain;
}
if root.label.image-url == "": QbzIcon {
source: @image-url("../assets/icons/disc-3.svg");
width: 56px;
height: 56px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
// Hover scrim (subtle — signals the card is clickable).
Rectangle {
width: 100%;
height: 100%;
background: #000000;
opacity: ta.has-hover ? 0.25 : 0.0;
animate opacity { duration: 150ms; }
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.label.id, root.label.name);
}
}
}
Rectangle { height: 6px; }
// --- Name / album count -----------------------------------------
HorizontalLayout {
height: 40px;
VerticalLayout {
alignment: center;
spacing: 2px;
horizontal-stretch: 1;
title-area := TouchArea {
height: 20px;
mouse-cursor: pointer;
clicked => {
root.clicked(root.label.id, root.label.name);
}
Text {
width: 100%;
text: root.label.name;
color: title-area.has-hover ? Theme.accent : Theme.text-primary;
font-size: Typography.body - 2px;
font-weight: Typography.medium;
vertical-alignment: center;
overflow: elide;
}
}
if root.label.albums-line != "": Text {
width: 100%;
text: root.label.albums-line;
color: Theme.text-muted;
font-size: Typography.link - 1px;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
+217 -48
View File
@@ -30,6 +30,7 @@ import { AlbumCard } from "../discover/AlbumCard.slint";
import { TrackCard } from "../discover/TrackCard.slint";
import { ArtistGridCard } from "../discover/ArtistGridCard.slint";
import { PlaylistCard } from "../discover/PlaylistCard.slint";
import { LabelCard } from "../discover/LabelCard.slint";
import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
@@ -38,6 +39,9 @@ import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { QualityBadge } from "../primitives/QualityBadge.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { PlaylistListRow } from "../primitives/PlaylistListRow.slint";
@@ -955,6 +959,7 @@ export component FavoritesView inherits Rectangle {
height: all-grid.ch;
if item.kind == "track": TrackCard {
show-source-badge: true;
track: {
id: item.id,
title: item.title,
@@ -976,6 +981,7 @@ export component FavoritesView inherits Rectangle {
}
}
if item.kind == "album": AlbumCard {
show-source-badge: true;
album: {
id: item.id,
title: item.title,
@@ -984,6 +990,7 @@ export component FavoritesView inherits Rectangle {
artwork: item.image,
quality-tier: item.quality-tier,
quality-label: item.quality-detail,
source: item.source,
};
clicked(id) => { FavoritesActions.open-album(id); }
media-action(kind, id, action) => {
@@ -1021,8 +1028,8 @@ export component FavoritesView inherits Rectangle {
}
}
}
if item.kind == "label": FavLabelCard {
item: {
if item.kind == "label": LabelCard {
label: {
id: item.id,
name: item.title,
image: item.image,
@@ -1043,47 +1050,13 @@ export component FavoritesView inherits Rectangle {
width: parent.width - 64px;
spacing: 2px;
for item[i] in LibraryAllState.items-visible: Rectangle {
height: 52px;
height: 56px;
border-radius: 6px;
background: row-ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 40px;
height: 40px;
border-radius: item.kind == "artist" ? 20px : 4px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: item.image;
image-fit: cover;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: item.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
Text {
text: item.subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
}
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 context menu. Declared FIRST so the action
// controls on the right win the pointer over it.
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
@@ -1099,6 +1072,200 @@ export component FavoritesView inherits Rectangle {
FavoritesActions.open-label(item.id, item.title);
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down
&& e.button == PointerEventButton.right) {
row-menu.show();
}
}
}
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
spacing: 12px;
// Cover (round for artists, square otherwise).
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;
}
}
}
// Title + type-aware subtitle.
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
spacing: 2px;
Text {
text: item.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
Text {
text: item.kind == "artist" ? @tr("Artist")
: item.kind == "album"
? (item.artist != "" ? @tr("Album") + " • " + item.artist : @tr("Album"))
: item.kind == "track"
? (item.artist != "" ? @tr("Track") + " • " + item.artist : @tr("Track"))
: item.kind == "playlist"
? (item.subtitle != "" ? @tr("Playlist") + " • " + item.subtitle : @tr("Playlist"))
: (item.subtitle != "" ? @tr("Label") + " • " + item.subtitle : @tr("Label"));
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
// Quality badge (albums + tracks).
if item.kind == "album" || item.kind == "track": VerticalLayout {
alignment: center;
QualityBadge {
tier: item.quality-tier;
label: item.quality-detail;
detail: item.quality-detail;
}
}
// Source badge (local / Plex).
if item.source == "local" || item.source == "plex": VerticalLayout {
alignment: center;
Rectangle {
width: 24px;
height: 24px;
border-radius: 4px;
background: #000000b3;
Image {
source: item.source == "plex"
? @image-url("../assets/icons/plex-logo.svg")
: @image-url("../assets/icons/hard-drive.svg");
colorize: item.source == "local" ? #ffffff : transparent;
width: item.source == "plex" ? 15px : 14px;
height: self.width;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
image-fit: contain;
}
}
}
// Favorite (heart) / follow (check) 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;
}
}
// 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 => { row-menu.show(); }
}
}
}
}
// Per-kind context menu (⋯ button + right-click).
row-menu := PopupWindow {
x: parent.width - 206px;
y: 48px;
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-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 == "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"); }
}
}
}
}
}
}
@@ -1354,6 +1521,7 @@ export component FavoritesView inherits Rectangle {
artwork-url: artist.image-url,
artwork: artist.image,
following: true,
is-pinned: artist.is-pinned,
};
follow-mode: "none";
clicked(id) => { root.open-artist(id); }
@@ -1387,6 +1555,7 @@ export component FavoritesView inherits Rectangle {
artwork-url: artist.image-url,
artwork: artist.image,
following: true,
is-pinned: artist.is-pinned,
};
follow-mode: "none";
clicked(id) => { root.open-artist(id); }
@@ -1478,10 +1647,10 @@ export component FavoritesView inherits Rectangle {
if FavoritesState.active-tab == "labels": labels-grid := Rectangle {
property <int> columns: Math.max(
1,
Math.floor((self.width + root.gap) / (root.artist-w + root.gap)));
Math.floor((self.width + root.gap) / (root.artist-card-w + root.gap)));
property <int> rows: Math.ceil(FavoritesState.labels-visible.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
? self.rows * root.artist-card-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
if FavoritesState.labels.length == 0: Text {
text: @tr("No labels in your Library yet.");
@@ -1494,10 +1663,10 @@ export component FavoritesView inherits Rectangle {
color: Theme.text-muted;
font-size: Typography.body;
}
for label[i] in FavoritesState.labels-visible: FavLabelCard {
x: Math.mod(i, labels-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / labels-grid.columns) * (root.artist-h + root.gap);
item: label;
for lbl[i] in FavoritesState.labels-visible: LabelCard {
x: Math.mod(i, labels-grid.columns) * (root.artist-card-w + root.gap);
y: Math.floor(i / labels-grid.columns) * (root.artist-card-h + root.gap);
label: lbl;
clicked(id, name) => { FavoritesActions.open-label(id, name); }
}
}
+3
View File
@@ -4830,6 +4830,9 @@ export struct FavoriteArtistItem {
name: string,
image-url: string,
image: image,
// Pin badge state — kept live by main::set_artist_row_pinned when a pin
// toggles anywhere (mirrors SlimItem.is-pinned).
is-pinned: bool,
}
// An A-Z section of favorite artists (grid grouped mode + sidepanel left
+1
View File
@@ -577,6 +577,7 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
let cards: Vec<FavoriteArtistItem> = items
.into_iter()
.map(|a| FavoriteArtistItem {
is_pinned: crate::pinned::is_pinned("artist", &a.id),
id: a.id.into(),
name: a.name.into(),
image_url: a.image_url.into(),
+44 -4
View File
@@ -1802,6 +1802,27 @@ fn set_artist_row_pinned(window: &AppWindow, artist_id: &str, pinned: bool) {
flip(&reco.get_rec_artists_recent());
flip(&reco.get_top_artists());
flip(&window.global::<LabelState>().get_artists());
// Library > Favorites artists grid uses FavoriteArtistItem (not SlimItem);
// flip it too so the pin badge updates live there (grid + A-Z sections).
let flip_fav = |model: &slint::ModelRc<FavoriteArtistItem>| {
for i in 0..model.row_count() {
if let Some(mut item) = model.row_data(i) {
if item.id == artist_id && item.is_pinned != pinned {
item.is_pinned = pinned;
model.set_row_data(i, item);
}
}
}
};
let favs = window.global::<FavoritesState>();
flip_fav(&favs.get_artists());
flip_fav(&favs.get_artists_visible());
let grouped = favs.get_artists_grouped();
for gi in 0..grouped.row_count() {
if let Some(sec) = grouped.row_data(gi) {
flip_fav(&sec.artists);
}
}
}
/// Feed Capa B (intelligent-search ranking) from a RESULTS-PAGE click, but only
@@ -3691,7 +3712,10 @@ fn navigate_library_all(
let _ = weak.upgrade_in_event_loop(move |w| {
library_all::apply_library_all(&w, feed);
let jobs = library_all::artwork_jobs(&w);
artwork::spawn_loads(jobs, weak_j.clone(), ic.clone());
// Mixed payload (Qobuz http / local fs / Plex /library/) —
// route each cover by scheme so local/Plex covers decode.
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak_j.clone(), ic.clone());
});
}
Err(e) => {
@@ -7820,6 +7844,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// mode there is no WGPU28 GraphicsAPI to hook, so registering it would be a no-op
// at best — skip it so software mode carries zero wgpu machinery.
if use_gpu_renderer {
let weak_for_underlay = window.as_weak();
if let Err(e) = window
.window()
.set_rendering_notifier(move |state, graphics_api| {
@@ -7831,6 +7856,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
slint::RenderingState::RenderingTeardown => {
crate::shader_underlay::teardown();
// The Image in ImmersiveState.shader-texture wraps a wgpu
// texture from the instance/device being destroyed. The
// property survives a Wayland hide (tray restore); the
// first frame after the next RenderingSetup would hand that
// stale texture to the NEW femtovg canvas — a cross-instance
// id lookup that panics in wgpu-core ("TextureView … is no
// longer alive"). Clear it now; the 30 fps drain repopulates
// it on the new device within a tick.
if let Some(w) = weak_for_underlay.upgrade() {
w.global::<ImmersiveState>()
.set_shader_texture(slint::Image::default());
}
}
_ => {}
}
@@ -20747,7 +20784,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
w.global::<LibraryAllState>().set_search(q);
library_all::derive(&w);
let jobs = library_all::artwork_jobs(&w);
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
}
});
}
@@ -20759,7 +20797,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
w.global::<LibraryAllState>().set_sort_by(key);
library_all::derive(&w);
let jobs = library_all::artwork_jobs(&w);
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
}
});
}
@@ -20788,7 +20827,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
library_all::derive(&w);
let jobs = library_all::artwork_jobs(&w);
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
let plex = crate::plex_settings::get();
artwork::spawn_search_loads(jobs, plex.base_url, plex.token, weak.clone(), image_cache.clone());
}
});
}