fix(shader)+feat(ui): nvidia shader tiling fix + scrollbar/mixtape/track-listing batch

NVIDIA shader fix (root-caused via diagnostic bisection on the discrete GPU):
- ambient.wgsl + plasma.wgsl: replace the sin-based hash `fract(sin(dot(p,k))*N)`
  with the sin-free Dave Hoskins hash. On NVIDIA, as noise coords grow with
  time, sin's argument gets huge and range-reduction precision diverges from
  Intel's, degenerating the noise into big blocks. Confirmed via a solid-color
  clear (clean) then a uniform-passthrough (clean) — isolating it to the shader
  hash, not the texture handoff.
- ImmersiveView: the 3 immersive-exit paths (X button, in-menu exit, ESC) kept
  the drain alive only for the Large NPB dock, freezing the shader texture on
  the last immersive frame when the app-wide Ambient background was on. Now they
  also keep it alive for Ambient so it renders the ambient scene after exit.

UI batch:
- Auto-hide scrollbar (Discover's ListScrollbar auto-hide + pointer hit-test)
  on AlbumView, ArtistView, PlaylistView, PlaylistManagerView.
- #631: artist Popular Tracks now show the album cover thumbnail (thread
  artwork_url through TrackData -> track_data_to_item + a new
  ArtworkTarget::ArtistTopTrack decode job, mirroring LabelTopTrack); label
  Popular Tracks fall back to the album's main artist for the name+id. Quality
  badge already rendered by TrackRow — reused, not reinvented.
- #446: "Add to Mixtape/Collection" bulk button in the Favorites/Artist/Label/
  Mix selection bars, mirroring the existing add-to-mixtape path (track items
  auto-restrict the picker to mixtapes, not collections).

slintcheck + Opus Rust review green (no compile blockers). Base: 8c2f84b7.

cc
This commit is contained in:
vicrodh committed 2026-07-17 14:51:18 -06:00
1 parent 8c2f84b798
commit 6c41f58f44
15 files changed
+235 -15

No files matched your search

@@ -1162,6 +1162,12 @@ export component AlbumPageView inherits Rectangle {
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
auto-hide: true;
hover-active: ShellState.pointer-in-window
&& ShellState.pointer-x >= flick.absolute-position.x
&& ShellState.pointer-x <= flick.absolute-position.x + flick.width
&& ShellState.pointer-y >= flick.absolute-position.y
&& ShellState.pointer-y <= flick.absolute-position.y + flick.height;
}
// --- Full-description modal --------------------------------------------
+11 -3
View File
@@ -791,6 +791,7 @@ export component ArtistPageView inherits Rectangle {
{ 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: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "add-to-favorites", label: @tr("Add to Library"), icon: @image-url("../assets/icons/heart.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
@@ -806,9 +807,10 @@ export component ArtistPageView inherits Rectangle {
height: self.visible ? 50px : 0px;
track: track;
index: index;
// Artist Popular Tracks: number cell (no cover), with
// the multi-select checkbox.
show-artwork: false;
// Artist Popular Tracks: show the album cover thumbnail so you
// can tell which album/EP a track is from (discussion #631). The
// artwork URL is threaded from Rust (artist.rs top-tracks).
show-artwork: true;
multi-select-mode: ArtistState.top-tracks-multi-select;
media-action(kind, id, action) => {
root.media-action(kind, id, action);
@@ -1082,6 +1084,12 @@ export component ArtistPageView inherits Rectangle {
viewport-height: page-flickable.viewport-height;
visible-height: page-flickable.height;
viewport-y <=> page-flickable.viewport-y;
auto-hide: true;
hover-active: ShellState.pointer-in-window
&& ShellState.pointer-x >= page-flickable.absolute-position.x
&& ShellState.pointer-x <= page-flickable.absolute-position.x + page-flickable.width
&& ShellState.pointer-y >= page-flickable.absolute-position.y
&& ShellState.pointer-y <= page-flickable.absolute-position.y + page-flickable.height;
}
// Network sidebar — overlay anchored to the right edge of the
@@ -810,6 +810,7 @@ export component FavoritesView inherits Rectangle {
{ 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: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.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 },
+25 -4
View File
@@ -5,7 +5,7 @@
import { Radius } from "../foundation/radius.slint";
import { Typography } from "../foundation/typography.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { NowPlayingState, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, SettingsState, LyricsState, QueueState, TrackInfoActions, SuggestionsActions, WindowControlActions, ShellState, UiFocusState } from "../state.slint";
import { NowPlayingState, VisualizerState, ImmersiveState, ImmersiveSearchActions, ImmersiveActions, SettingsState, LyricsState, QueueState, TrackInfoActions, SuggestionsActions, WindowControlActions, ShellState, UiFocusState, AppearanceState } from "../state.slint";
import { ImmersiveSearchCortinilla } from "ImmersiveSearchCortinilla.slint";
import { ImmersiveAtmosphere } from "ImmersiveAtmosphere.slint";
import { AlbumReactivePanel } from "AlbumReactivePanel.slint";
@@ -414,7 +414,14 @@ component ImmersivePlayerBar inherits Rectangle {
ImmersiveState.open = false;
// Keep the tap alive if the Large NPB dock still needs
// its spectrum; only idle it when nothing else does.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
VisualizerState.set-enabled(
(ShellState.large-active && ShellState.large-visualizer-on)
// Keep the drain alive for the app-wide Ambient background so
// it renders the ambient scene after exit instead of freezing
// on the last immersive frame (stale line-bed/etc. bug).
|| (AppearanceState.app-background-mode-index == 1
&& AppearanceState.app-background-available
&& NowPlayingState.has-track));
}
}
QbzIcon {
@@ -1116,7 +1123,14 @@ component ImmersiveWindowControls inherits Rectangle {
clicked => {
ImmersiveState.open = false;
// Keep the tap alive for the Large NPB dock if it is active.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
VisualizerState.set-enabled(
(ShellState.large-active && ShellState.large-visualizer-on)
// Keep the drain alive for the app-wide Ambient background so
// it renders the ambient scene after exit instead of freezing
// on the last immersive frame (stale line-bed/etc. bug).
|| (AppearanceState.app-background-mode-index == 1
&& AppearanceState.app-background-available
&& NowPlayingState.has-track));
}
}
@@ -1218,7 +1232,14 @@ export component ImmersiveView inherits Rectangle {
if (event.text == Key.Escape) {
ImmersiveState.open = false;
// Keep the tap alive for the Large NPB dock if it is active.
VisualizerState.set-enabled(ShellState.large-active && ShellState.large-visualizer-on);
VisualizerState.set-enabled(
(ShellState.large-active && ShellState.large-visualizer-on)
// Keep the drain alive for the app-wide Ambient background so
// it renders the ambient scene after exit instead of freezing
// on the last immersive frame (stale line-bed/etc. bug).
|| (AppearanceState.app-background-mode-index == 1
&& AppearanceState.app-background-available
&& NowPlayingState.has-track));
return accept;
}
// 'g' (GPU) cycles the shader scene 0..5 (Off -> Plasma -> Tunnel ->
@@ -258,6 +258,7 @@ export component LabelPageView inherits Rectangle {
{ 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: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
+1
View File
@@ -222,6 +222,7 @@ export component MixView inherits Rectangle {
{ 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: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
];
action(id) => {
@@ -1473,5 +1473,11 @@ export component PlaylistManagerView inherits Rectangle {
viewport-height: pm-flick.viewport-height;
visible-height: pm-flick.height;
viewport-y <=> pm-flick.viewport-y;
auto-hide: true;
hover-active: ShellState.pointer-in-window
&& ShellState.pointer-x >= pm-flick.absolute-position.x
&& ShellState.pointer-x <= pm-flick.absolute-position.x + pm-flick.width
&& ShellState.pointer-y >= pm-flick.absolute-position.y
&& ShellState.pointer-y <= pm-flick.absolute-position.y + pm-flick.height;
}
}
@@ -1158,6 +1158,12 @@ export component PlaylistView inherits Rectangle {
viewport-height: track-list.viewport-height;
visible-height: track-list.visible-height;
viewport-y <=> track-list.viewport-y;
auto-hide: true;
hover-active: ShellState.pointer-in-window
&& ShellState.pointer-x >= track-list.absolute-position.x
&& ShellState.pointer-x <= track-list.absolute-position.x + track-list.width
&& ShellState.pointer-y >= track-list.absolute-position.y
&& ShellState.pointer-y <= track-list.absolute-position.y + track-list.height;
}
}
+8 -2
View File
@@ -52,9 +52,15 @@ 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.
fn hash2(p: vec2<f32>) -> f32 {
let h = dot(p, vec2<f32>(127.1, 311.7));
return fract(sin(h) * 43758.5453123);
var p3 = fract(p.xyx * 0.1031);
p3 = p3 + dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
fn vnoise(p: vec2<f32>) -> f32 {
+7 -1
View File
@@ -52,8 +52,14 @@ 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.
fn hash2(p: vec2<f32>) -> f32 {
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
var p3 = fract(p.xyx * 0.1031);
p3 = p3 + dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
fn vnoise(p: vec2<f32>) -> f32 {
+12
View File
@@ -93,6 +93,14 @@ pub struct TrackData {
/// apply layer stamps the viewed album's id); artist top-tracks set it
/// per-track since they span different albums.
pub album_id: String,
/// Album TITLE for the row's album column ("" when unavailable). Album view
/// leaves this empty (the apply layer stamps the viewed album's title);
/// artist top-tracks fill it per-track since they span different albums.
pub album: String,
/// Album cover URL for the row thumbnail ("" when unavailable). Same
/// ownership split as `album`/`album_id`: filled by artist top-tracks,
/// left empty by the album view (whose rows share the header cover).
pub artwork_url: String,
pub duration: String,
pub quality_tier: String,
pub quality_detail: String,
@@ -422,6 +430,10 @@ fn map_track(track: Track) -> TrackData {
artist_id,
// The album view stamps the viewed album's id at the apply layer.
album_id: String::new(),
// Album title + cover are stamped by the apply layer (album view rows
// all belong to the viewed album); leave empty here.
album: String::new(),
artwork_url: String::new(),
duration: mmss(track.duration),
quality_tier: tier(track.maximum_bit_depth).to_string(),
quality_detail: crate::quality::detail(
+27 -3
View File
@@ -487,6 +487,18 @@ pub fn artwork_jobs(data: &ArtistData) -> Vec<ArtworkJob> {
album_idx += 1;
}
}
// "Popular Tracks" rows carry the album-cover URL but Slint can't fetch
// network images — decode each into the row's `artwork` (#631). The
// top-tracks model is built 1:1 from `data.top_tracks` (no blacklist
// filter, unlike releases), so the enumerate index matches the row.
for (i, track) in data.top_tracks.iter().enumerate() {
if !track.artwork_url.is_empty() {
jobs.push(ArtworkJob {
target: ArtworkTarget::ArtistTopTrack { index: i },
url: track.artwork_url.clone(),
});
}
}
jobs
}
@@ -499,7 +511,17 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackData {
.artist
.map(|a| (a.name.display, a.id.to_string()))
.unwrap_or_default();
let album_id = track.album.map(|a| a.id).unwrap_or_default();
// Pull the album id, title AND cover from the same nested album object —
// the /artist/page response already carries all three, so the row can show
// the album name + thumbnail without an extra request. `smallest()` is the
// list-row thumbnail variant (best() would download the mega/large cover).
let (album_id, album, artwork_url) = track
.album
.map(|a| {
let url = a.image.and_then(|img| img.smallest().cloned()).unwrap_or_default();
(a.id, a.title, url)
})
.unwrap_or_default();
let bit_depth = track.audio_info.as_ref().and_then(|a| a.maximum_bit_depth);
let sample_rate = track.audio_info.as_ref().and_then(|a| a.maximum_sampling_rate);
TrackData {
@@ -509,6 +531,8 @@ fn map_track(index: usize, track: PageArtistTrack) -> TrackData {
artist,
artist_id,
album_id,
album,
artwork_url,
duration: mmss(track.duration.unwrap_or(0)),
quality_tier: tier(bit_depth).to_string(),
quality_detail: crate::quality::detail(bit_depth, sample_rate),
@@ -654,13 +678,13 @@ fn track_data_to_item(track: TrackData) -> TrackItem {
number: track.number.into(),
title: track.title.into(),
artist: track.artist.into(),
album: "".into(),
album: track.album.clone().into(),
duration: track.duration.into(),
quality_tier: track.quality_tier.into(),
quality_detail: track.quality_detail.into(),
explicit: track.explicit,
selected: false,
artwork_url: "".into(),
artwork_url: track.artwork_url.clone().into(),
artwork: slint::Image::default(),
is_favorite: crate::fav_cache::is_favorite(&track.id),
artist_id: track.artist_id.into(),
+16
View File
@@ -122,6 +122,11 @@ pub enum ArtworkTarget {
ArtistReleasesAlbum { index: usize },
/// A Magazine story thumbnail in `ArtistState.stories[index]`.
ArtistStory { index: usize },
/// A row in `ArtistState.top-tracks[index]` (artist "Popular Tracks"
/// list). Mirrors `LabelTopTrack`: Slint can't fetch network images, so
/// the row's album-cover thumbnail only paints once this job decodes the
/// `artwork_url` bytes into the row's `artwork` field (#631).
ArtistTopTrack { index: usize },
/// A card in MusicianState.appearances[index].
MusicianAppearance { index: usize },
/// A card in LabelState.albums[index] (releases sub-view grid).
@@ -295,6 +300,10 @@ impl ArtworkTarget {
| ArtworkTarget::PlaylistTrack { .. }
| ArtworkTarget::SuggestionTrackCover { .. }
| ArtworkTarget::PlaylistSuggestionCover { .. }
// Label/Artist "Popular Tracks" rows are the same list-row
// thumbnail as the track targets above (~40px rendered).
| ArtworkTarget::LabelTopTrack { .. }
| ArtworkTarget::ArtistTopTrack { .. }
| ArtworkTarget::LocalArtistRowImage { .. } => 96,
// Sidebar micro-collage tiles render at ~10-20px; decode tiny.
ArtworkTarget::SidebarPlaylistCover { .. } => 48,
@@ -1212,6 +1221,13 @@ fn apply_artwork(
model.set_row_data(index, item);
}
}
ArtworkTarget::ArtistTopTrack { index } => {
let model = window.global::<ArtistState>().get_top_tracks();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::MusicianAppearance { index } => {
let model = window.global::<crate::MusicianState>().get_appearances();
if let Some(mut item) = model.row_data(index) {
+14 -2
View File
@@ -641,8 +641,20 @@ fn parse_top_track(raw: &Value) -> TopTrack {
.and_then(|a| a.get("image"))
.map(parse_image_value)
.unwrap_or_default();
// performer OR artist (the page uses either).
let perf = raw.get("performer").or_else(|| raw.get("artist"));
// performer OR artist (the page uses either). The label /page top_tracks
// sometimes omit a per-track performer, which left rows nameless
// (discussion #631) — fall back to the album's main artist so the name (and
// its clickable id) is populated.
let perf = raw
.get("performer")
.or_else(|| raw.get("artist"))
.filter(|p| {
p.get("name")
.and_then(|v| v.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
})
.or_else(|| album.and_then(|a| a.get("artist")));
let artist = perf
.and_then(|p| p.get("name"))
.map(name_display)
+94
View File
@@ -1917,6 +1917,65 @@ fn open_add_to_mixtape(
});
}
/// Build "Add to Mixtape" track payloads from Qobuz `Track` objects (the
/// Favorites, Label and Mix bulk bars — issue #446). `item_type: "track"`
/// auto-restricts the picker to mixtapes; title/artist/artwork enrich the
/// picker rows. Rows without a numeric id are dropped (they cannot be added).
fn mixtape_items_from_qobuz_tracks(tracks: &[qbz_models::Track]) -> Vec<myqbz_add::AddItem> {
tracks
.iter()
.filter(|t| t.id != 0)
.map(|t| {
let subtitle = t
.performer
.as_ref()
.map(|a| a.name.clone())
.filter(|s| !s.is_empty());
let artwork_url = t
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.filter(|u| !u.is_empty());
myqbz_add::AddItem {
item_type: "track".into(),
source: "qobuz".into(),
source_item_id: t.id.to_string(),
title: t.title.clone(),
subtitle,
artwork_url,
year: None,
track_count: None,
}
})
.collect()
}
/// Build "Add to Mixtape" track payloads from the Artist Popular-Tracks
/// selection (issue #446). The artist view only exposes `selected_ids`, so we
/// read the selected `TrackItem` rows directly for their display fields.
fn mixtape_items_from_artist_selection(window: &AppWindow) -> Vec<myqbz_add::AddItem> {
use slint::Model;
let model = window.global::<ArtistState>().get_top_tracks();
(0..model.row_count())
.filter_map(|i| model.row_data(i))
.filter(|t| t.selected && t.id.as_str().parse::<u64>().is_ok())
.map(|t| {
let subtitle = (!t.artist.is_empty()).then(|| t.artist.to_string());
let artwork_url = (!t.artwork_url.is_empty()).then(|| t.artwork_url.to_string());
myqbz_add::AddItem {
item_type: "track".into(),
source: "qobuz".into(),
source_item_id: t.id.to_string(),
title: t.title.to_string(),
subtitle,
artwork_url,
year: None,
track_count: None,
}
})
.collect()
}
/// Update the offline cache-status (+ progress) of every visible row matching
/// `track_id`. Mirrors `set_row_favorite`. status: 0 none / 1 queued / 2
/// downloading / 3 ready / 4 failed; `progress` is 0.0..1.0.
@@ -11666,6 +11725,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
("label", "add-to-mixtape") => {
if let Some(w) = weak.upgrade() {
let items =
mixtape_items_from_qobuz_tracks(&label::selected_play_tracks(&w));
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
label::clear_selection(&w);
}
}
}
// More-Labels card click -> open that label's landing.
("label", "open") => {
if let Ok(label_id) = id.parse::<u64>() {
@@ -11938,6 +12007,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
}
("mix", "add-to-mixtape") => {
if let Some(w) = weak.upgrade() {
let items =
mixtape_items_from_qobuz_tracks(&mix::selected_play_tracks(&w));
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
mix::clear_selection(&w);
}
}
}
("playlist", "cache") => {
if let Ok(pid) = id.parse::<u64>() {
offline_cache::cache_playlist(
@@ -13677,6 +13756,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
});
}
"add-to-mixtape" => {
let items = mixtape_items_from_artist_selection(&w);
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
artist::clear_selection(&w);
}
}
_ => {}
}
});
@@ -20810,6 +20896,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
);
favorites::clear_selection(&w);
}
"add-to-mixtape" => {
let items =
mixtape_items_from_qobuz_tracks(&favorites::selected_tracks(&w));
if !items.is_empty() {
open_add_to_mixtape(weak.clone(), handle.clone(), items);
favorites::clear_selection(&w);
}
}
"add-to-playlist" => {
let ids = favorites::selected_ids(&w);
if !ids.is_empty() {