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:
10 files changed
+450
-1
No files matched your search
@@ -1138,6 +1138,19 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
||||
.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
|
||||
/// recently-listened track ids. Returns the suggested tracks.
|
||||
pub async fn get_dynamic_suggest(
|
||||
|
||||
@@ -30,6 +30,7 @@ reqwest = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
urlencoding = "2"
|
||||
open = "5"
|
||||
arboard = "3"
|
||||
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -35,9 +35,11 @@ mod play_history;
|
||||
mod strip_html;
|
||||
mod playback;
|
||||
mod queue;
|
||||
mod playlist_picker;
|
||||
mod recently;
|
||||
mod search;
|
||||
mod settings;
|
||||
mod share;
|
||||
mod ui_prefs;
|
||||
|
||||
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(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
@@ -2125,6 +2217,44 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Favorites view actions — tab switch (lazy-load), open album /
|
||||
// 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.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
|
||||
@@ -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`).
|
||||
pub fn play_album_radio(
|
||||
runtime: Runtime,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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, 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
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { NavState, ContentView, ShellState } from "../state.slint";
|
||||
import { Sidebar } from "Sidebar.slint";
|
||||
import { HeaderBar } from "HeaderBar.slint";
|
||||
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
|
||||
import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
|
||||
import { PlayerBar } from "PlayerBar.slint";
|
||||
import { QueueSidebar } from "QueueSidebar.slint";
|
||||
import { LyricsSidebar } from "LyricsSidebar.slint";
|
||||
@@ -199,4 +200,8 @@ export component AppShell inherits Rectangle {
|
||||
root.header-menu-navigate(route);
|
||||
}
|
||||
}
|
||||
|
||||
// Global "Add to playlist" picker overlay, opened from any track
|
||||
// context menu.
|
||||
PlaylistPickerModal { }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export struct AlbumCardItem {
|
||||
id: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
artist-id: string, // for the clickable artist name -> artist page
|
||||
genre: string, // shown in the hover overlay
|
||||
year: string, // shown in the hover overlay
|
||||
quality-tier: string, // "hires" | "cd" | "" — drives the badge icon
|
||||
@@ -712,6 +713,28 @@ export global QueueState {
|
||||
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
|
||||
// playlist; the card renders them as a count-based collage.
|
||||
export struct SearchPlaylistItem {
|
||||
|
||||
Reference in new issue
Block a user