diff --git a/crates/qbz-ui/ui/discover/TrackCard.slint b/crates/qbz-ui/ui/discover/TrackCard.slint index 7f8fd32cc..f0d6b0bb6 100644 --- a/crates/qbz-ui/ui/discover/TrackCard.slint +++ b/crates/qbz-ui/ui/discover/TrackCard.slint @@ -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") diff --git a/crates/qbz-ui/ui/favorites/FavoritesView.slint b/crates/qbz-ui/ui/favorites/FavoritesView.slint index aa5ef6f1b..c090e7d1b 100644 --- a/crates/qbz-ui/ui/favorites/FavoritesView.slint +++ b/crates/qbz-ui/ui/favorites/FavoritesView.slint @@ -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"); diff --git a/crates/qbz-ui/ui/shaders/ambient.wgsl b/crates/qbz-ui/ui/shaders/ambient.wgsl index 74b3ba95a..5a28f09f8 100644 --- a/crates/qbz-ui/ui/shaders/ambient.wgsl +++ b/crates/qbz-ui/ui/shaders/ambient.wgsl @@ -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 { - 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(i32(p.x)) * 0x8da6b343u + bitcast(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 { diff --git a/crates/qbz-ui/ui/shaders/plasma.wgsl b/crates/qbz-ui/ui/shaders/plasma.wgsl index 94ad30047..d32b41c92 100644 --- a/crates/qbz-ui/ui/shaders/plasma.wgsl +++ b/crates/qbz-ui/ui/shaders/plasma.wgsl @@ -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 { - 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(i32(p.x)) * 0x8da6b343u + bitcast(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 { diff --git a/crates/qbz-ui/ui/state.slint b/crates/qbz-ui/ui/state.slint index 801c7637c..99be3a2b4 100644 --- a/crates/qbz-ui/ui/state.slint +++ b/crates/qbz-ui/ui/state.slint @@ -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" | "" diff --git a/crates/qbz/src/library_all.rs b/crates/qbz/src/library_all.rs index 48c90cf27..f20336d8a 100644 --- a/crates/qbz/src/library_all.rs +++ b/crates/qbz/src/library_all.rs @@ -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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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(), diff --git a/crates/qbz/src/main.rs b/crates/qbz/src/main.rs index 7e522a1a2..a55a5a861 100644 --- a/crates/qbz/src/main.rs +++ b/crates/qbz/src/main.rs @@ -7858,16 +7858,21 @@ fn main() -> Result<(), Box> { 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::() .set_shader_texture(slint::Image::default()); - } + }); } _ => {} }