From c4b6acb760990a4d47eb64663dd7e39baad0f9a1 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 20 May 2026 18:24:39 -0600 Subject: [PATCH] fix(slint): smooth sticky scroll + artwork right-click menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated things landing together. Sticky scroll fix: - The natural-top binding on both jump-nav and the sidebar reads from `body-row.absolute-position.y` (a live, scroll-aware lookup). The y formula was `max(natural-top + viewport-y, ...)` — adding viewport-y on top of an already scroll-aware value double-counted the scroll. During mid-scroll the bar drifted ahead of the body row, opening a visible gap and briefly overlapping the header. - Drops `+ viewport-y` from both formulas. natural-top alone IS the viewport-relative y the elements want to occupy; the max() clamp just keeps them from going above the top. - Initial position and sticky position are unchanged; only the transition is fixed. Artwork right-click menu (artist portrait + album cover): - Both headers get a TouchArea on the artwork that opens a PopupWindow + ContextMenu on right-click, positioned at the cursor. Mirrors Tauri's `oncontextmenu` flow on the artist portrait and album cover. - Menu items match Tauri 1:1: Add image / cover, Change + Remove (when a custom image is registered), divider, Open in browser, Save as. - New `crate::custom_artwork` JSON-backed store at `/qbz/custom_artwork.json` keyed by artist name + album id (mirror of the Tauri library's `v2_library_set/remove_custom_*` persistence). - ArtistState / AlbumState expose `artwork-url` (network URL for Open / Save) + `has-custom-image` / `has-custom-cover` (drives the Add vs Change/Remove menu rendering), both populated from Rust at apply_artist / apply_album time. - ArtworkActions global + main.rs handlers: * open-in-browser → `open::that(url)`. * save-as → `rfd::AsyncFileDialog::save_file()` + reqwest download + tokio::fs::write. * add-custom → `rfd::AsyncFileDialog::pick_file()` then `custom_artwork::set_*` + flip the has-custom flag. * remove-custom → `custom_artwork::remove_*` + reset flag. - rfd added with xdg-portal + tokio features. external-link and image-plus SVGs ported from Lucide. Custom-image RENDERING (using the picked file in place of the network artwork) is deferred — the store persists the choice and the menu reflects it, but the displayed image still comes from Qobuz. Next pass. cc --- crates/Cargo.lock | 58 ++++++++- crates/qbz-slint/Cargo.toml | 1 + crates/qbz-slint/src/album.rs | 5 + crates/qbz-slint/src/artist.rs | 5 + crates/qbz-slint/src/custom_artwork.rs | 108 ++++++++++++++++ crates/qbz-slint/src/main.rs | 117 ++++++++++++++++++ crates/qbz-slint/ui/album/AlbumPageView.slint | 67 +++++++++- crates/qbz-slint/ui/app.slint | 2 +- .../qbz-slint/ui/artist/ArtistPageView.slint | 96 ++++++++++++-- .../ui/assets/icons/external-link.svg | 5 + .../qbz-slint/ui/assets/icons/image-plus.svg | 7 ++ crates/qbz-slint/ui/state.slint | 24 ++++ 12 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 crates/qbz-slint/src/custom_artwork.rs create mode 100644 crates/qbz-slint/ui/assets/icons/external-link.svg create mode 100644 crates/qbz-slint/ui/assets/icons/image-plus.svg diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 37bfb09f1..a647fc93c 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -367,6 +367,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.3", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus 5.15.0", +] + [[package]] name = "ashpd" version = "0.13.11" @@ -1551,6 +1572,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", + "block2 0.6.2", + "libc", "objc2 0.6.4", ] @@ -4683,6 +4706,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "polycool" version = "0.4.0" @@ -5029,7 +5058,7 @@ name = "qbz-credentials" version = "0.1.0" dependencies = [ "aes-gcm", - "ashpd", + "ashpd 0.13.11", "base64", "dirs 6.0.0", "keyring", @@ -5163,6 +5192,7 @@ dependencies = [ "qbz-integrations", "qbz-models", "reqwest", + "rfd", "rusqlite", "serde", "serde_json", @@ -5648,6 +5678,30 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd 0.11.1", + "block2 0.6.2", + "dispatch2", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -7429,6 +7483,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -9049,6 +9104,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 1.0.3", "zvariant_derive 5.11.0", "zvariant_utils 3.3.1", diff --git a/crates/qbz-slint/Cargo.toml b/crates/qbz-slint/Cargo.toml index b35ba77f4..842f18a33 100644 --- a/crates/qbz-slint/Cargo.toml +++ b/crates/qbz-slint/Cargo.toml @@ -30,6 +30,7 @@ reqwest = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } urlencoding = "2" open = "5" +rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } serde = { workspace = true } serde_json = { workspace = true } dirs = "5" diff --git a/crates/qbz-slint/src/album.rs b/crates/qbz-slint/src/album.rs index f2fb90d3a..3de51a263 100644 --- a/crates/qbz-slint/src/album.rs +++ b/crates/qbz-slint/src/album.rs @@ -238,9 +238,14 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) { let awards: Vec = data.awards.into_iter().map(Into::into).collect(); + let has_custom_cover = crate::custom_artwork::album_cover(&data.id).is_some(); + let artwork_url = data.artwork_url.clone(); + let state = window.global::(); state.set_id(data.id.into()); state.set_title(data.title.into()); + state.set_artwork_url(artwork_url.into()); + state.set_has_custom_cover(has_custom_cover); state.set_artist(data.artist.into()); state.set_artist_id(data.artist_id.into()); state.set_info_line(data.info_line.into()); diff --git a/crates/qbz-slint/src/artist.rs b/crates/qbz-slint/src/artist.rs index ab2c82963..4f749a71b 100644 --- a/crates/qbz-slint/src/artist.rs +++ b/crates/qbz-slint/src/artist.rs @@ -524,8 +524,13 @@ pub fn apply_artist(window: &AppWindow, data: ArtistData) { *cell.borrow_mut() = release_sections.clone(); }); + let has_custom_image = crate::custom_artwork::artist_image(&data.name).is_some(); + let artwork_url = data.artwork_url.clone(); + let state = window.global::(); state.set_name(data.name.into()); + state.set_artwork_url(artwork_url.into()); + state.set_has_custom_image(has_custom_image); state.set_bio(data.bio.into()); state.set_bio_short(data.bio_short.into()); state.set_bio_truncated(data.bio_truncated); diff --git a/crates/qbz-slint/src/custom_artwork.rs b/crates/qbz-slint/src/custom_artwork.rs new file mode 100644 index 000000000..e65266626 --- /dev/null +++ b/crates/qbz-slint/src/custom_artwork.rs @@ -0,0 +1,108 @@ +//! Persistent overrides for artist photos and album covers. +//! +//! The right-click menu on each artwork lets the user pick a local +//! image to replace what Qobuz serves. The replacement is kept in a +//! small JSON store at `/qbz/custom_artwork.json`, keyed by +//! artist name and album id. Two pairs of helpers mirror the +//! `v2_library_set/remove_custom_*` Tauri commands. +//! +//! The store records the absolute file path of the picked image. The +//! decode/render path that actually shows the override lives in +//! `crate::artwork`; this module just owns the persistence layer. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Default, Serialize, Deserialize)] +struct Store { + #[serde(default)] + artists: HashMap, + #[serde(default)] + albums: HashMap, +} + +fn store_path() -> Option { + Some( + dirs::data_dir()? + .join("qbz") + .join("custom_artwork.json"), + ) +} + +fn load_store() -> Store { + let Some(path) = store_path() else { + return Store::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Store::default(), + } +} + +fn write_store(store: &Store) { + let Some(path) = store_path() else { + return; + }; + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + log::warn!("[qbz-slint] custom-artwork dir failed: {e}"); + return; + } + } + match serde_json::to_vec_pretty(store) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + log::warn!("[qbz-slint] custom-artwork write failed: {e}"); + } + } + Err(e) => log::warn!("[qbz-slint] custom-artwork serialize failed: {e}"), + } +} + +/// Returns the absolute file path of the user-picked artist image, +/// if one is registered. Called by the artist controller to swap the +/// header portrait + flip the menu's "Add" / "Change" / "Remove" +/// rendering. +#[allow(dead_code)] // wired by artist controller +pub fn artist_image(name: &str) -> Option { + load_store().artists.get(name).cloned() +} + +#[allow(dead_code)] // wired by artist controller +pub fn set_artist_image(name: &str, path: &str) { + let mut store = load_store(); + store.artists.insert(name.to_string(), path.to_string()); + write_store(&store); +} + +#[allow(dead_code)] // wired by artist controller +pub fn remove_artist_image(name: &str) { + let mut store = load_store(); + if store.artists.remove(name).is_some() { + write_store(&store); + } +} + +/// Returns the absolute file path of the user-picked album cover, +/// if one is registered. Called by the album controller. +#[allow(dead_code)] // wired by album controller +pub fn album_cover(album_id: &str) -> Option { + load_store().albums.get(album_id).cloned() +} + +#[allow(dead_code)] // wired by album controller +pub fn set_album_cover(album_id: &str, path: &str) { + let mut store = load_store(); + store.albums.insert(album_id.to_string(), path.to_string()); + write_store(&store); +} + +#[allow(dead_code)] // wired by album controller +pub fn remove_album_cover(album_id: &str) { + let mut store = load_store(); + if store.albums.remove(album_id).is_some() { + write_store(&store); + } +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 0cf4b800a..3d53636e9 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -20,6 +20,7 @@ mod artist; mod artwork; mod auth; mod commands; +mod custom_artwork; mod discovery_dismiss; mod home; mod nav; @@ -1290,6 +1291,122 @@ fn main() -> Result<(), Box> { }); } + // Artwork right-click menu wiring — Open in browser / Save as / + // Add custom / Remove custom. Mirrors the v2_library_* + native + // dialog flow Tauri uses on artist portraits + album covers. + window + .global::() + .on_open_in_browser(|url| { + if url.is_empty() { + return; + } + if let Err(e) = open::that(url.as_str()) { + log::error!("[qbz-slint] artwork open-in-browser failed: {e}"); + } + }); + { + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_save_as(move |url, default_name| { + if url.is_empty() { + return; + } + let url = url.to_string(); + let default = default_name.to_string(); + handle.spawn(async move { + let Some(dest) = rfd::AsyncFileDialog::new() + .set_file_name(&default) + .add_filter("Images", &["jpg", "jpeg", "png"]) + .save_file() + .await + else { + return; + }; + let bytes = match reqwest::get(&url).await { + Ok(resp) => match resp.bytes().await { + Ok(b) => b, + Err(e) => { + log::error!( + "[qbz-slint] artwork save-as fetch body: {e}" + ); + return; + } + }, + Err(e) => { + log::error!("[qbz-slint] artwork save-as request: {e}"); + return; + } + }; + if let Err(e) = tokio::fs::write(dest.path(), &bytes).await { + log::error!("[qbz-slint] artwork save-as write: {e}"); + } + }); + }); + } + { + let handle = tokio_rt.handle().clone(); + let weak = window.as_weak(); + window + .global::() + .on_add_custom(move |kind, key| { + let kind = kind.to_string(); + let key = key.to_string(); + let weak = weak.clone(); + handle.spawn(async move { + let Some(file) = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp"]) + .pick_file() + .await + else { + return; + }; + let path = file.path().to_string_lossy().into_owned(); + match kind.as_str() { + "artist" => { + custom_artwork::set_artist_image(&key, &path); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_has_custom_image(true); + }); + } + "album" => { + custom_artwork::set_album_cover(&key, &path); + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_has_custom_cover(true); + }); + } + _ => log::warn!( + "[qbz-slint] artwork add-custom: unknown kind {kind}" + ), + } + }); + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_remove_custom(move |kind, key| { + match kind.as_str() { + "artist" => { + custom_artwork::remove_artist_image(key.as_str()); + if let Some(w) = weak.upgrade() { + w.global::().set_has_custom_image(false); + } + } + "album" => { + custom_artwork::remove_album_cover(key.as_str()); + if let Some(w) = weak.upgrade() { + w.global::().set_has_custom_cover(false); + } + } + _ => log::warn!( + "[qbz-slint] artwork remove-custom: unknown kind {kind}" + ), + } + }); + } + window.on_close_app(|| { log::info!("[qbz-slint] closing"); let _ = slint::quit_event_loop(); diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint index 8df4616f1..63f85c940 100644 --- a/crates/qbz-slint/ui/album/AlbumPageView.slint +++ b/crates/qbz-slint/ui/album/AlbumPageView.slint @@ -9,7 +9,7 @@ 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 { AlbumState, AlbumTrackItem, AlbumActions, NavState, ContentView } from "../state.slint"; +import { AlbumState, AlbumTrackItem, AlbumActions, ArtworkActions, NavState, ContentView } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { ContextMenu } from "../primitives/ContextMenu.slint"; import { ContextMenuItem } from "../primitives/ContextMenuItem.slint"; @@ -375,6 +375,71 @@ export component AlbumPageView inherits Rectangle { source: AlbumState.artwork; image-fit: cover; } + // Right-click → cover context menu (same pattern + // as the artist portrait): Add/Change/Remove + // custom cover, Open in browser, Save as. + cover-ta := TouchArea { + mouse-cursor: default; + pointer-event(e) => { + if (e.button == PointerEventButton.right + && e.kind == PointerEventKind.up) { + cover-menu.show(); + } + } + } + cover-menu := PopupWindow { + x: cover-ta.mouse-x; + y: cover-ta.mouse-y; + width: 220px; + close-policy: close-on-click; + ContextMenu { + if !AlbumState.has-custom-cover: ContextMenuItem { + icon: @image-url("../assets/icons/image-plus.svg"); + label: @tr("Add cover"); + clicked => { + ArtworkActions.add-custom( + "album", AlbumState.id); + } + } + if AlbumState.has-custom-cover: ContextMenuItem { + icon: @image-url("../assets/icons/image-plus.svg"); + label: @tr("Change cover"); + clicked => { + ArtworkActions.add-custom( + "album", AlbumState.id); + } + } + if AlbumState.has-custom-cover: ContextMenuItem { + icon: @image-url("../assets/icons/trash-2.svg"); + label: @tr("Remove cover"); + clicked => { + ArtworkActions.remove-custom( + "album", AlbumState.id); + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + ContextMenuItem { + icon: @image-url("../assets/icons/external-link.svg"); + label: @tr("Open in browser"); + clicked => { + ArtworkActions.open-in-browser( + AlbumState.artwork-url); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/cloud-download.svg"); + label: @tr("Save as…"); + clicked => { + ArtworkActions.save-as( + AlbumState.artwork-url, + AlbumState.title + " - Cover.jpg"); + } + } + } + } } VerticalLayout { diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index e16b9e789..25c0d7fb6 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions } from "state.slint"; +export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint index 9a42da028..27bff05bf 100644 --- a/crates/qbz-slint/ui/artist/ArtistPageView.slint +++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint @@ -11,6 +11,7 @@ import { Radius } from "../foundation/radius.slint"; import { ArtistState, ArtistActions, + ArtworkActions, AlbumTrackItem, NavState, ContentView, @@ -357,6 +358,72 @@ export component ArtistPageView inherits Rectangle { source: ArtistState.artwork; image-fit: cover; } + // Right-click → image context menu (Add/Change/ + // Remove custom image, Open in browser, Save as). + // Left clicks pass through (no action on the + // portrait itself). + artwork-ta := TouchArea { + mouse-cursor: default; + pointer-event(e) => { + if (e.button == PointerEventButton.right + && e.kind == PointerEventKind.up) { + artwork-menu.show(); + } + } + } + artwork-menu := PopupWindow { + x: artwork-ta.mouse-x; + y: artwork-ta.mouse-y; + width: 220px; + close-policy: close-on-click; + ContextMenu { + if !ArtistState.has-custom-image: ContextMenuItem { + icon: @image-url("../assets/icons/image-plus.svg"); + label: @tr("Add image"); + clicked => { + ArtworkActions.add-custom( + "artist", ArtistState.name); + } + } + if ArtistState.has-custom-image: ContextMenuItem { + icon: @image-url("../assets/icons/image-plus.svg"); + label: @tr("Change image"); + clicked => { + ArtworkActions.add-custom( + "artist", ArtistState.name); + } + } + if ArtistState.has-custom-image: ContextMenuItem { + icon: @image-url("../assets/icons/trash-2.svg"); + label: @tr("Remove image"); + clicked => { + ArtworkActions.remove-custom( + "artist", ArtistState.name); + } + } + Rectangle { + height: 1px; + background: Theme.border-subtle; + } + ContextMenuItem { + icon: @image-url("../assets/icons/external-link.svg"); + label: @tr("Open in browser"); + clicked => { + ArtworkActions.open-in-browser( + ArtistState.artwork-url); + } + } + ContextMenuItem { + icon: @image-url("../assets/icons/cloud-download.svg"); + label: @tr("Save as…"); + clicked => { + ArtworkActions.save-as( + ArtistState.artwork-url, + ArtistState.name + ".jpg"); + } + } + } + } } VerticalLayout { @@ -684,19 +751,22 @@ export component ArtistPageView inherits Rectangle { // y is the row just below the artist header in the page layout // (NavButtons + spacer + artist header + small gap, ~290px). jump-nav := JumpNavBar { - // Sits AT the body row's top, taking the 44 top pixels of - // the 52px reservation slot the body row's first child - // carves out — so the bar never overlaps the artist header - // above (header ends exactly at body-row.y) and never - // overlaps Popular Tracks below (Popular Tracks header - // starts 52px into the body row, an 8px gap below the bar). + // body-row.absolute-position.y already moves up with scroll + // (it's a window-coord lookup), so `natural-top` here is + // the LIVE viewport-relative y the bar wants to occupy at + // any given scroll position — not a static scroll=0 value. + // The sticky clamp just keeps it from going above the top + // of the page (sticky-top = 0); adding viewport-y to the + // formula would double-count the scroll and lift the bar + // ahead of the body row during the transition (visible gap + // + bar-over-header artifact during mid-scroll). property natural-top: body-row.absolute-position.y - page-flickable.absolute-position.y; property sticky-top: 0px; x: 0; - y: max(self.natural-top + page-flickable.viewport-y, self.sticky-top); + y: max(self.natural-top, self.sticky-top); width: root.width; tabs: ArtistState.jump-tabs; active-tab-id: root.active-jump-tab; @@ -728,10 +798,12 @@ export component ArtistPageView inherits Rectangle { // bottom edge is always flush with the now-playing bar // (height = root.height - y), so no gap appears when sticky. if NetworkSidebarState.open: sidebar-panel := Rectangle { - // Anchored 44px below the body row's top (= bottom edge - // of the JUMP TO bar, which itself starts AT body row - // top). The +44 ensures the sidebar always begins - // flush with the bar's bottom edge, no overlap. + // natural-top is the LIVE viewport-relative y of the + // body-row top + 44 (= bottom edge of the JUMP TO bar, + // which itself starts AT body row top). Same reasoning + // as the bar: body-row.absolute-position.y already + // accounts for scroll, so the formula is just a + // max() clamp — no extra `+ viewport-y` here either. property natural-top: body-row.absolute-position.y - page-flickable.absolute-position.y + jump-nav.height; @@ -740,7 +812,7 @@ export component ArtistPageView inherits Rectangle { property sticky-top: 44px; x: root.width - self.width; - y: max(self.natural-top + page-flickable.viewport-y, self.sticky-top); + y: max(self.natural-top, self.sticky-top); width: 300px; height: root.height - self.y; background: Theme.surface-card; diff --git a/crates/qbz-slint/ui/assets/icons/external-link.svg b/crates/qbz-slint/ui/assets/icons/external-link.svg new file mode 100644 index 000000000..26f057901 --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/external-link.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/qbz-slint/ui/assets/icons/image-plus.svg b/crates/qbz-slint/ui/assets/icons/image-plus.svg new file mode 100644 index 000000000..26535c0df --- /dev/null +++ b/crates/qbz-slint/ui/assets/icons/image-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index f8862abf5..e95de0748 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -66,6 +66,10 @@ export global AlbumState { in property title; in property artist; in property artist-id; + // Network URL of the cover — used by the artwork right-click + // menu for Open-in-browser and Save-as actions. + in property artwork-url; + in property has-custom-cover: false; in property info-line; in property quality-tier; // "24-bit / 96 kHz" — quality-badge detail line. @@ -213,6 +217,13 @@ export struct SimilarEntry { export global ArtistState { in property id; in property name; + // Network URL of the portrait — used by the artwork right-click + // menu for Open-in-browser and Save-as actions. + in property artwork-url; + // True when the user has registered a local image to override + // the Qobuz-supplied portrait. Drives the menu's + // "Add image" -> "Change image / Remove image" toggle. + in property has-custom-image: false; in property bio; // Truncated bio (word-boundary cut + ellipsis) for the at-rest view; // the full bio in `bio` is shown when the Read-more toggle expands. @@ -242,6 +253,19 @@ export global ArtistState { in property loading: false; } +// Artwork right-click actions — shared by ArtistPageView and +// AlbumPageView for their respective headers. `kind` is "artist" or +// "album"; `key` is the artist name or album id; `url` is the +// current network URL (for open-in-browser and save-as). +export global ArtworkActions { + callback open-in-browser(string /* url */); + callback save-as(string /* url */, string /* default file name */); + // Opens the system file picker and registers the chosen image + // as the custom override for this artist/album. + callback add-custom(string /* kind */, string /* key */); + callback remove-custom(string /* kind */, string /* key */); +} + // Artist-view actions wired to Rust callbacks. export global ArtistActions { // Filter the visible Popular Tracks and release-section albums by