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:
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 --------------------------------------------
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user