fix(library): proper All list rows, NVIDIA seam, close-to-tray crash, Go-to-album

- Rework the "All" list view to the aligned column layout of the mixtape detail
  list: artwork + hover play, title + clickable-artist subtitle, a TYPE column,
  a dedicated SOURCE column (SourceGlyph), the row QualityBadgeFull in a Quality
  column (was the uneven grid badge), favorite/follow indicator, and the per-kind
  context menu — no longer bare and out of tune.
- Fix the NVIDIA "wallpaper join" background seam: replace the float hash2 in
  ambient.wgsl + plasma.wgsl with a bit-exact integer (u32) lattice hash so the
  same cell corner hashes identically from adjacent cells on NVIDIA.
- Fix the close-to-tray "RefCell already mutably borrowed" panic introduced by
  the teardown texture-clear: defer the set via upgrade_in_event_loop so it runs
  after the window adapter releases its borrow.
- Add "Go to album" to the TrackCard context menu and the list-row menu (adds
  album-id to the feed model).

cc
This commit is contained in:
vicrodh committed 2026-07-18 15:11:15 -06:00
1 parent f484cc481f
commit e1bdec5094
7 files changed
+194 -82

No files matched your search

+9 -2
View File
@@ -202,8 +202,8 @@ export component TrackCard inherits Rectangle {
x: 26px;
y: 56px;
width: 196px;
// Open-artist? Play / Play-next / Queue + favorite = 4-5 items.
height: 5 * 33px + 10px;
// Play / Play-next / Queue / Go-to-artist / Go-to-album / favorite.
height: 6 * 33px + 10px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
@@ -233,6 +233,13 @@ export component TrackCard inherits Rectangle {
root.media-action("artist", root.track.artist-id, "open");
}
}
if root.track.album-id != "": ContextMenuItem {
icon: @image-url("../assets/icons/disc.svg");
label: @tr("Go to album");
clicked => {
root.media-action("album", root.track.album-id, "open");
}
}
if root.show-favorite: ContextMenuItem {
icon: root.track.is-favorite
? @image-url("../assets/icons/heart-filled.svg")
+126 -53
View File
@@ -40,6 +40,8 @@ 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 { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
import { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
@@ -966,6 +968,7 @@ export component FavoritesView inherits Rectangle {
artist: item.artist,
artist-id: item.artist-id,
album: item.album,
album-id: item.album-id,
artwork: item.image,
quality-tier: item.quality-tier,
quality-detail: item.quality-detail,
@@ -975,6 +978,8 @@ export component FavoritesView inherits Rectangle {
media-action(kind, id, action) => {
if (kind == "artist") {
FavoritesActions.open-artist(id);
} else if (kind == "album") {
FavoritesActions.open-album(id);
} else {
FavoritesActions.track-action(id, action);
}
@@ -1080,10 +1085,10 @@ export component FavoritesView inherits Rectangle {
}
}
HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
// Cover (round for artists, square otherwise).
// Col — artwork (round for artists) + hover play.
VerticalLayout {
alignment: center;
Rectangle {
@@ -1098,65 +1103,128 @@ export component FavoritesView inherits Rectangle {
source: item.image;
image-fit: item.kind == "label" ? contain : cover;
}
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;
}
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"); }
}
}
}
}
}
// Title + type-aware subtitle.
// Col — title + subtitle (artist link for track/album).
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;
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); }
}
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;
}
}
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;
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);
}
}
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;
}
}
}
// Favorite (heart) / follow (check) indicator.
// 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 (its own column).
Rectangle {
width: 44px;
if item.source != "": 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 {
@@ -1170,7 +1238,7 @@ export component FavoritesView inherits Rectangle {
tint: Theme.accent;
}
}
// Context-menu button.
// Col — context-menu button.
VerticalLayout {
alignment: center;
menu-btn := Rectangle {
@@ -1245,6 +1313,11 @@ export component FavoritesView inherits Rectangle {
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");
+13 -8
View File
@@ -52,15 +52,20 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
// Cheap smooth value noise (hash + bilinear), a couple of octaves. No loops over
// large ranges — this is a background that must stay near-free on an integrated
// GPU.
// Sin-FREE hash (Dave Hoskins). The old `fract(sin(dot(p,k))*43758)` breaks on
// some GPUs (NVIDIA): as the noise coords grow with time, sin's argument gets
// huge and the driver's range-reduction precision diverges from Intel's,
// degenerating the noise into big blocks. This integer-style fract hash is
// stable across GPUs regardless of coordinate magnitude.
// INTEGER lattice hash (bit-exact). vnoise only ever hashes floor() outputs, so
// the i32 conversion is exact. Float hashes (the old sin-based one AND the
// fract-based Hoskins one) leave the result to f32 rounding: NVIDIA may
// contract/round the same expression differently at different inline sites, so
// the SAME lattice corner hashed from two adjacent cells can disagree (worst
// case fract() flips by ~1.0), drawing the cell border as a straight "wallpaper
// join" seam through the warp field. u32 arithmetic has no rounding: same input,
// same output, at every call site, on every GPU, at any coordinate magnitude.
fn hash2(p: vec2<f32>) -> f32 {
var p3 = fract(p.xyx * 0.1031);
p3 = p3 + dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
var h = bitcast<u32>(i32(p.x)) * 0x8da6b343u + bitcast<u32>(i32(p.y)) * 0xd8163841u;
h = (h ^ (h >> 15u)) * 0x2c1b3c6du;
h = (h ^ (h >> 12u)) * 0x297a2d39u;
h = h ^ (h >> 15u);
return f32(h >> 8u) * (1.0 / 16777216.0);
}
fn vnoise(p: vec2<f32>) -> f32 {
+11 -7
View File
@@ -52,14 +52,18 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
return out;
}
// Sin-FREE hash (Dave Hoskins). The old `fract(sin(dot(p,k))*43758)` breaks on
// NVIDIA: as the noise coords grow with time, sin's argument gets huge and the
// driver's range-reduction precision diverges from Intel's, degenerating the
// noise into big blocks. This integer-style fract hash is stable across GPUs.
// INTEGER lattice hash (bit-exact) — vnoise only hashes floor() outputs, so the
// i32 conversion is exact. A float hash leaves the result to f32 rounding, which
// NVIDIA can contract/round differently per inline call site, so the SAME
// lattice corner hashed from two adjacent cells disagrees and draws the cell
// border as a straight "wallpaper join" seam. u32 arithmetic is bit-exact: same
// input, same output, every call site, every GPU, any coordinate magnitude.
fn hash2(p: vec2<f32>) -> f32 {
var p3 = fract(p.xyx * 0.1031);
p3 = p3 + dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
var h = bitcast<u32>(i32(p.x)) * 0x8da6b343u + bitcast<u32>(i32(p.y)) * 0xd8163841u;
h = (h ^ (h >> 15u)) * 0x2c1b3c6du;
h = (h ^ (h >> 12u)) * 0x297a2d39u;
h = h ^ (h >> 15u);
return f32(h >> 8u) * (1.0 / 16777216.0);
}
fn vnoise(p: vec2<f32>) -> f32 {
+1
View File
@@ -4978,6 +4978,7 @@ export struct LibraryFeedItem {
artist: string,
artist-id: string,
album: string,
album-id: string, // for "Go to album" (tracks)
image-url: string,
image: image, // decoded cover (empty until the pipeline resolves it)
quality-tier: string, // "hires" | "cd" | ""
+21 -4
View File
@@ -31,6 +31,7 @@ pub struct Feed {
pub artist: String,
pub artist_id: String,
pub album: String,
pub album_id: String,
pub image_url: String,
pub quality_tier: String,
pub quality_detail: String,
@@ -69,6 +70,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: t.artist,
artist_id: t.artist_id,
album: t.album,
album_id: t.album_id,
image_url: t.artwork_url,
quality_tier: t.quality_tier,
quality_detail: t.quality_detail,
@@ -92,6 +94,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: a.artist,
artist_id: a.artist_id,
album: String::new(),
album_id: String::new(),
image_url: a.artwork_url,
quality_tier: a.quality_tier,
quality_detail: a.quality_detail,
@@ -117,6 +120,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: String::new(),
artist_id: ar.id.clone(),
album: String::new(),
album_id: String::new(),
image_url: ar.image_url,
quality_tier: String::new(),
quality_detail: String::new(),
@@ -140,6 +144,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: String::new(),
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url: l.image_url,
quality_tier: String::new(),
quality_detail: String::new(),
@@ -168,6 +173,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: String::new(),
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url,
quality_tier: String::new(),
quality_detail: String::new(),
@@ -188,6 +194,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: String::new(),
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url,
quality_tier: String::new(),
quality_detail: String::new(),
@@ -214,6 +221,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: a.artist.name,
artist_id: a.artist.id.to_string(),
album: String::new(),
album_id: String::new(),
image_url,
quality_tier: tier.into(),
quality_detail: String::new(),
@@ -225,14 +233,20 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
}
let n = tracks.len();
for (i, t) in tracks.into_iter().enumerate() {
let (artist, image_url, album) = {
let (artist, image_url, album, album_id) = {
let artist = t.performer.name.clone();
let (img, alb) = t
let (img, alb, aid) = t
.album
.as_ref()
.map(|a| (a.image.best().cloned().unwrap_or_default(), a.title.clone()))
.map(|a| {
(
a.image.best().cloned().unwrap_or_default(),
a.title.clone(),
a.id.clone(),
)
})
.unwrap_or_default();
(artist, img, alb)
(artist, img, alb, aid)
};
let tier = if t.hires { "hires" } else { "cd" };
feed.push(Feed {
@@ -243,6 +257,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist,
artist_id: t.performer.id.to_string(),
album,
album_id,
image_url,
quality_tier: tier.into(),
quality_detail: String::new(),
@@ -268,6 +283,7 @@ pub async fn load_library_all(runtime: &Runtime) -> Result<Vec<Feed>, String> {
artist: lf.artist.clone(),
artist_id: String::new(),
album: String::new(),
album_id: String::new(),
image_url: lf.artwork_url,
quality_tier: String::new(),
quality_detail: String::new(),
@@ -299,6 +315,7 @@ fn to_item(f: &Feed) -> LibraryFeedItem {
artist: f.artist.clone().into(),
artist_id: f.artist_id.clone().into(),
album: f.album.clone().into(),
album_id: f.album_id.clone().into(),
image_url: f.image_url.clone().into(),
image: slint::Image::default(),
quality_tier: f.quality_tier.clone().into(),
+13 -8
View File
@@ -7858,16 +7858,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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() {
// property survives a Wayland hide (tray); 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 — but DEFERRED via
// upgrade_in_event_loop, NOT synchronously here: the
// notifier runs while the winit window adapter holds its
// RefCell borrowed, and setting a property re-borrows it
// ("RefCell already mutably borrowed" panic on close-to-
// tray). The queued closure runs on the next loop tick,
// after the borrow is released and long before restore.
let _ = weak_for_underlay.upgrade_in_event_loop(|w| {
w.global::<ImmersiveState>()
.set_shader_texture(slint::Image::default());
}
});
}
_ => {}
}