feat(slint): wire track context-menu actions (favorite/radio/share/playlist/nav)

Connects the previously-inert track context-menu items now that the
backends exist:

  - Add to library -> add_favorite("track").
  - Go to album / Go to artist -> resolve the track (get_track) to its
    album / performer and navigate there.
  - Create radio -> Qobuz track radio (new get_radio_track core wrapper
    over the existing qbz-qobuz endpoint + playback::play_track_radio).
  - Share Qobuz link -> https://www.qobuz.com/track/{id} to clipboard
    (new arboard dep + share module).
  - Share Song.link -> Odesli (api.song.link) pageUrl resolution to
    clipboard.
  - Add to playlist -> a global picker modal (PlaylistPickerModal +
    PlaylistPickerState/Actions + playlist_picker controller) listing
    the user's playlists; picking one calls add_tracks_to_playlist.

Add to mixtape / Make available offline / Track info stay unwired
(no backend yet).

cc
This commit is contained in:
vicrodh committed 2026-05-22 02:54:52 -06:00
1 parent 1ebec6cbc1
commit db8a6ac5b1
10 files changed
+450 -1

No files matched your search

+13
View File
@@ -1138,6 +1138,19 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.map_err(CoreError::Api) .map_err(CoreError::Api)
} }
/// Qobuz track radio (`/radio/track`).
pub async fn get_radio_track(
&self,
track_id: &str,
) -> Result<qbz_models::RadioResponse, CoreError> {
let client = self.client.read().await;
let client = client.as_ref().ok_or(CoreError::NotInitialized)?;
client
.get_radio_track(track_id)
.await
.map_err(CoreError::Api)
}
/// Dynamic mix suggestions (`/dynamic/suggest`) seeded from /// Dynamic mix suggestions (`/dynamic/suggest`) seeded from
/// recently-listened track ids. Returns the suggested tracks. /// recently-listened track ids. Returns the suggested tracks.
pub async fn get_dynamic_suggest( pub async fn get_dynamic_suggest(
+1
View File
@@ -30,6 +30,7 @@ reqwest = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
urlencoding = "2" urlencoding = "2"
open = "5" open = "5"
arboard = "3"
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+130
View File
@@ -35,9 +35,11 @@ mod play_history;
mod strip_html; mod strip_html;
mod playback; mod playback;
mod queue; mod queue;
mod playlist_picker;
mod recently; mod recently;
mod search; mod search;
mod settings; mod settings;
mod share;
mod ui_prefs; mod ui_prefs;
use std::sync::Arc; use std::sync::Arc;
@@ -1256,6 +1258,96 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
} }
} }
("track", "favorite") => {
let runtime = runtime.clone();
let track_id = id.clone();
handle.spawn(async move {
if let Err(e) = runtime.core().add_favorite("track", &track_id).await {
log::error!("[qbz-slint] favorite track failed: {e}");
}
});
}
("track", "create-radio") => playback::play_track_radio(
runtime.clone(),
weak.clone(),
handle.clone(),
id.clone(),
),
("track", "add-to-playlist") => {
// Open the global picker for this track + load the
// user's playlists.
let Some(w) = weak.upgrade() else {
return;
};
playlist_picker::open(&w, &id);
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
let playlists = playlist_picker::load(&runtime).await;
let _ = weak.upgrade_in_event_loop(move |w| {
playlist_picker::apply(&w, playlists);
});
});
}
("track", "share-qobuz") => {
share::copy_to_clipboard(share::qobuz_track_url(&id));
log::info!("[qbz-slint] copied Qobuz link for track {id}");
}
("track", "share-songlink") => {
let source = share::qobuz_track_url(&id);
let track = id.clone();
handle.spawn(async move {
match share::songlink_url(&source).await {
Some(url) => {
share::copy_to_clipboard(url);
log::info!("[qbz-slint] copied Song.link for track {track}");
}
None => log::warn!("[qbz-slint] Song.link resolution failed for {track}"),
}
});
}
("track", "go-to-album") => {
// The menu only carries the track id — resolve the
// track to find its album, then open it.
if let Ok(track_id) = id.parse::<u64>() {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
if let Ok(track) = runtime.core().get_track(track_id).await {
if let Some(album_id) =
track.album.as_ref().map(|a| a.id.clone())
{
let _ = weak.upgrade_in_event_loop(move |w| {
w.invoke_open_album(album_id.into());
});
}
}
});
}
}
("track", "go-to-artist") => {
if let Ok(track_id) = id.parse::<u64>() {
let runtime = runtime.clone();
let weak = weak.clone();
handle.spawn(async move {
if let Ok(track) = runtime.core().get_track(track_id).await {
if let Some(artist_id) =
track.performer.as_ref().map(|p| p.id)
{
let _ = weak.upgrade_in_event_loop(move |w| {
w.invoke_open_artist(artist_id.to_string().into());
});
}
}
});
}
}
// Clickable artist name (album cards) -> artist page.
("artist", "open") => {
if let Some(w) = weak.upgrade() {
w.invoke_open_artist(id.clone().into());
}
}
("artist", "play-top") => playback::play_artist_top_tracks( ("artist", "play-top") => playback::play_artist_top_tracks(
runtime.clone(), runtime.clone(),
weak.clone(), weak.clone(),
@@ -2125,6 +2217,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// Favorites view actions — tab switch (lazy-load), open album / // Favorites view actions — tab switch (lazy-load), open album /
// artist, and per-row track actions routed to the media-action // artist, and per-row track actions routed to the media-action
// "Add to playlist" picker — pick adds the pending track to the
// chosen playlist; close dismisses.
{
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<PlaylistPickerActions>()
.on_pick(move |playlist_id| {
let Some(w) = weak.upgrade() else {
return;
};
let track_id = w.global::<PlaylistPickerState>().get_track_id().to_string();
w.global::<PlaylistPickerState>().set_open(false);
let (Ok(pid), Ok(tid)) =
(playlist_id.parse::<u64>(), track_id.parse::<u64>())
else {
return;
};
let runtime = runtime.clone();
handle.spawn(async move {
if let Err(e) = runtime.core().add_tracks_to_playlist(pid, &[tid]).await {
log::error!("[qbz-slint] add to playlist failed: {e}");
}
});
});
}
{
let weak = window.as_weak();
window
.global::<PlaylistPickerActions>()
.on_close(move || {
if let Some(w) = weak.upgrade() {
w.global::<PlaylistPickerState>().set_open(false);
}
});
}
// handler. // handler.
{ {
let runtime = app_runtime.clone(); let runtime = app_runtime.clone();
+20
View File
@@ -614,6 +614,26 @@ pub fn play_smart_artist_radio(
}); });
} }
/// Start a Qobuz track radio (`/radio/track`).
pub fn play_track_radio(
runtime: Runtime,
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
track_id: String,
) {
handle.spawn(async move {
match runtime.core().get_radio_track(&track_id).await {
Ok(resp) => {
let tracks = resp.tracks.map(|p| p.items).unwrap_or_default();
if !play_radio_response(runtime, weak, tracks) {
log::warn!("[qbz-slint] track radio {track_id} returned no tracks");
}
}
Err(e) => log::error!("[qbz-slint] track radio {track_id} failed: {e}"),
}
});
}
/// Start a Qobuz album radio (`/radio/album`). /// Start a Qobuz album radio (`/radio/album`).
pub fn play_album_radio( pub fn play_album_radio(
runtime: Runtime, runtime: Runtime,
+63
View File
@@ -0,0 +1,63 @@
//! "Add to playlist" picker controller. Loads the user's playlists
//! into PlaylistPickerState for the global picker modal; the pick
//! handler in main.rs adds the pending track to the chosen playlist.
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::{AppWindow, PlaylistPickItem, PlaylistPickerState};
pub struct PickPlaylist {
pub id: String,
pub name: String,
pub tracks: u32,
}
/// Open the picker for `track_id` and mark it loading. UI thread.
pub fn open(window: &AppWindow, track_id: &str) {
let state = window.global::<PlaylistPickerState>();
state.set_track_id(track_id.into());
state.set_playlists(ModelRc::new(VecModel::from(Vec::<PlaylistPickItem>::new())));
state.set_loading(true);
state.set_open(true);
}
/// Fetch the user's playlists (worker thread).
pub async fn load<A>(runtime: &AppRuntime<A>) -> Vec<PickPlaylist>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_user_playlists().await {
Ok(playlists) => playlists
.into_iter()
.map(|p| PickPlaylist {
id: p.id.to_string(),
name: p.name,
tracks: p.tracks_count,
})
.collect(),
Err(e) => {
log::warn!("[qbz-slint] playlist picker load failed: {e}");
Vec::new()
}
}
}
pub fn apply(window: &AppWindow, playlists: Vec<PickPlaylist>) {
let items: Vec<PlaylistPickItem> = playlists
.into_iter()
.map(|p| PlaylistPickItem {
id: p.id.into(),
name: p.name.into(),
tracks_line: if p.tracks > 0 {
format!("{} tracks", p.tracks).into()
} else {
"".into()
},
})
.collect();
let state = window.global::<PlaylistPickerState>();
state.set_playlists(ModelRc::new(VecModel::from(items)));
state.set_loading(false);
}
+41
View File
@@ -0,0 +1,41 @@
//! Share-link helpers — Qobuz track URL + Song.link (Odesli) resolution
//! + clipboard copy. Used by the track context menu's Share actions.
/// Canonical Qobuz track URL (the same form Tauri feeds to Song.link).
pub fn qobuz_track_url(track_id: &str) -> String {
format!("https://www.qobuz.com/track/{track_id}")
}
/// Copy `text` to the system clipboard. Runs on a blocking thread —
/// clipboard backends (X11/Wayland) can block, and arboard keeps an
/// owner thread alive so the contents persist after this returns.
pub fn copy_to_clipboard(text: String) {
tokio::task::spawn_blocking(move || match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(text) {
log::warn!("[qbz-slint] clipboard set failed: {e}");
}
}
Err(e) => log::warn!("[qbz-slint] clipboard unavailable: {e}"),
});
}
/// Resolve a source URL to its universal Song.link (Odesli) page URL.
/// One GET to the Odesli API; returns the `pageUrl` field.
pub async fn songlink_url(source_url: &str) -> Option<String> {
let resp = reqwest::Client::new()
.get("https://api.song.link/v1-alpha.1/links")
.query(&[("url", source_url)])
.send()
.await
.ok()?;
if !resp.status().is_success() {
log::warn!("[qbz-slint] song.link status {}", resp.status());
return None;
}
let value: serde_json::Value = resp.json().await.ok()?;
value
.get("pageUrl")
.and_then(|p| p.as_str())
.map(|s| s.to_string())
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint"; import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them. // Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint"; export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it // Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`. // restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,153 @@
// "Add to playlist" picker — a global modal overlay. Lists the user's
// playlists; clicking one adds the pending track to it. Opened from
// the track context menu (PlaylistPickerState.open).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { PlaylistPickerState, PlaylistPickerActions, PlaylistPickItem } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { LoadingSpinner } from "LoadingSpinner.slint";
component PickRow inherits Rectangle {
in property <PlaylistPickItem> item;
in property <int> index;
callback clicked(string);
height: 52px;
border-radius: 8px;
background: ta.has-hover
? #ffffff16
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 34px;
height: 34px;
border-radius: 6px;
background: Theme.surface-elevated;
QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 18px;
height: 18px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
if root.item.tracks-line != "": Text {
text: root.item.tracks-line;
color: Theme.text-muted;
font-size: Typography.legal;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.id);
}
}
}
export component PlaylistPickerModal inherits Rectangle {
visible: PlaylistPickerState.open;
if PlaylistPickerState.open: Rectangle {
background: #000000bf;
// Scrim — click outside to dismiss.
TouchArea {
mouse-cursor: default;
clicked => {
PlaylistPickerActions.close();
}
}
Rectangle {
width: Math.min(root.width - 80px, 460px);
height: Math.min(root.height - 120px, 520px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
// Swallow clicks so they don't reach the scrim.
TouchArea { }
VerticalLayout {
padding: 20px;
spacing: 14px;
HorizontalLayout {
Text {
text: @tr("Add to playlist");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
PlaylistPickerActions.close();
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
if PlaylistPickerState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 30px; }
}
if !PlaylistPickerState.loading
&& PlaylistPickerState.playlists.length == 0: Text {
text: @tr("No playlists yet.");
color: Theme.text-muted;
font-size: Typography.body;
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: list.preferred-height;
list := VerticalLayout {
spacing: 2px;
for pl[i] in PlaylistPickerState.playlists: PickRow {
item: pl;
index: i;
clicked(id) => {
PlaylistPickerActions.pick(id);
}
}
}
}
}
}
}
}
+5
View File
@@ -10,6 +10,7 @@ import { NavState, ContentView, ShellState } from "../state.slint";
import { Sidebar } from "Sidebar.slint"; import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint"; import { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint"; import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
import { PlayerBar } from "PlayerBar.slint"; import { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint"; import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint"; import { LyricsSidebar } from "LyricsSidebar.slint";
@@ -199,4 +200,8 @@ export component AppShell inherits Rectangle {
root.header-menu-navigate(route); root.header-menu-navigate(route);
} }
} }
// Global "Add to playlist" picker overlay, opened from any track
// context menu.
PlaylistPickerModal { }
} }
+23
View File
@@ -10,6 +10,7 @@ export struct AlbumCardItem {
id: string, id: string,
title: string, title: string,
artist: string, artist: string,
artist-id: string, // for the clickable artist name -> artist page
genre: string, // shown in the hover overlay genre: string, // shown in the hover overlay
year: string, // shown in the hover overlay year: string, // shown in the hover overlay
quality-tier: string, // "hires" | "cd" | "" — drives the badge icon quality-tier: string, // "hires" | "cd" | "" — drives the badge icon
@@ -712,6 +713,28 @@ export global QueueState {
in property <bool> infinite-play: false; in property <bool> infinite-play: false;
} }
// One row in the "Add to playlist" picker.
export struct PlaylistPickItem {
id: string,
name: string,
tracks-line: string, // "N tracks"
}
// "Add to playlist" picker — a global overlay opened from the track
// context menu. Holds the pending track id while the user picks a
// destination playlist. Populated by crate::playlist_picker.
export global PlaylistPickerState {
in-out property <bool> open: false;
in property <string> track-id;
in property <[PlaylistPickItem]> playlists: [];
in property <bool> loading: false;
}
export global PlaylistPickerActions {
callback pick(string /* playlist id */);
callback close();
}
// A playlist result in search. Qobuz returns up to four cover URLs per // A playlist result in search. Qobuz returns up to four cover URLs per
// playlist; the card renders them as a count-based collage. // playlist; the card renders them as a count-based collage.
export struct SearchPlaylistItem { export struct SearchPlaylistItem {