fix(slint): smooth sticky scroll + artwork right-click menu
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
`<data-dir>/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
This commit is contained in:
12 files changed
+480
-15
No files matched your search
Generated
+57
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -238,9 +238,14 @@ pub fn apply_album(window: &AppWindow, data: AlbumData) {
|
||||
let awards: Vec<slint::SharedString> =
|
||||
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::<AlbumState>();
|
||||
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());
|
||||
|
||||
@@ -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::<ArtistState>();
|
||||
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);
|
||||
|
||||
@@ -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 `<data-dir>/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<String, String>,
|
||||
#[serde(default)]
|
||||
albums: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn store_path() -> Option<PathBuf> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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::<ArtworkActions>()
|
||||
.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::<ArtworkActions>()
|
||||
.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::<ArtworkActions>()
|
||||
.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::<ArtistState>().set_has_custom_image(true);
|
||||
});
|
||||
}
|
||||
"album" => {
|
||||
custom_artwork::set_album_cover(&key, &path);
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<AlbumState>().set_has_custom_cover(true);
|
||||
});
|
||||
}
|
||||
_ => log::warn!(
|
||||
"[qbz-slint] artwork add-custom: unknown kind {kind}"
|
||||
),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window
|
||||
.global::<ArtworkActions>()
|
||||
.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::<ArtistState>().set_has_custom_image(false);
|
||||
}
|
||||
}
|
||||
"album" => {
|
||||
custom_artwork::remove_album_cover(key.as_str());
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.global::<AlbumState>().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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 <length> natural-top:
|
||||
body-row.absolute-position.y
|
||||
- page-flickable.absolute-position.y;
|
||||
property <length> 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 <length> 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 <length> 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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 308 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 5h6" />
|
||||
<path d="M19 2v6" />
|
||||
<path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 397 B |
@@ -66,6 +66,10 @@ export global AlbumState {
|
||||
in property <string> title;
|
||||
in property <string> artist;
|
||||
in property <string> artist-id;
|
||||
// Network URL of the cover — used by the artwork right-click
|
||||
// menu for Open-in-browser and Save-as actions.
|
||||
in property <string> artwork-url;
|
||||
in property <bool> has-custom-cover: false;
|
||||
in property <string> info-line;
|
||||
in property <string> quality-tier;
|
||||
// "24-bit / 96 kHz" — quality-badge detail line.
|
||||
@@ -213,6 +217,13 @@ export struct SimilarEntry {
|
||||
export global ArtistState {
|
||||
in property <string> id;
|
||||
in property <string> name;
|
||||
// Network URL of the portrait — used by the artwork right-click
|
||||
// menu for Open-in-browser and Save-as actions.
|
||||
in property <string> 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 <bool> has-custom-image: false;
|
||||
in property <string> 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 <bool> 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
|
||||
|
||||
Reference in new issue
Block a user