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)
|
.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(
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user