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:
vicrodh committed 2026-05-20 18:24:39 -06:00
1 parent 69029238ee
commit c4b6acb760
12 files changed
+480 -15

No files matched your search

+57 -1
View File
@@ -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",
+1
View File
@@ -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"
+5
View File
@@ -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());
+5
View File
@@ -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);
+108
View File
@@ -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);
}
}
+117
View File
@@ -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();
+66 -1
View File
@@ -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 {
+1 -1
View File
@@ -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`.
+84 -12
View File
@@ -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

+24
View File
@@ -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