feat(slint): minimal playback wiring + Queue, plus detail/Settings fixes
Playback (minimal, album-focused): - playback.rs controller: play an album (set queue + start audio), play a single track, enqueue, play a queue index, transport (play/pause, next, previous, seek, volume) - the audible step uses Player::play_track (fetch -> download -> play); the protected audio backend is untouched - a 450ms poll loop pushes position/progress/times/playing onto NowPlayingState and auto-advances at end of track - NowPlayingState / QueueState globals; NowPlayingBar bound to real state (song card, seek bar, transport, volume); QueueSidebar lists the queue and rows play on click - a played track is recorded into the recently-played store Detail views: - album description and artist biography stay with the metadata, filling the width to the right of the artwork (not below the header) - header nav tabs gained a dropdown caret and 2px side padding Settings > Playback defaults are now opt-out: show context icon, restore session, resume position and stream-uncached default on; streaming-only stays opt-in; initial buffer defaults to 2s. Known gaps: queue/now-playing artwork not yet threaded (Image is not Send); autoplay-mode preference not consulted by auto-advance; buffer progress line stays at 0. cc
This commit is contained in:
12 files changed
+1012
-72
No files matched your search
@@ -22,6 +22,7 @@ mod auth;
|
||||
mod commands;
|
||||
mod home;
|
||||
mod nav;
|
||||
mod playback;
|
||||
mod recently;
|
||||
mod settings;
|
||||
mod ui_prefs;
|
||||
@@ -56,6 +57,11 @@ async fn enter_shell(
|
||||
w.set_screen(AppScreen::Shell);
|
||||
});
|
||||
|
||||
// Start the playback poll loop — it runs for the app lifetime,
|
||||
// ticking position/progress onto NowPlayingState and auto-advancing
|
||||
// the queue on track end. Safe to start once per shell entry.
|
||||
playback::start_poll_loop(runtime.clone(), weak.clone(), tokio::runtime::Handle::current());
|
||||
|
||||
// Load Audio + Playback settings into the Settings page in the
|
||||
// background — store reads and device enumeration are blocking.
|
||||
{
|
||||
@@ -557,11 +563,117 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
// Context-menu / overlay media actions. Playback wiring lands with the
|
||||
// playback session; for now the action is logged.
|
||||
window.on_media_action(move |kind, id, action| {
|
||||
log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}");
|
||||
});
|
||||
// Context-menu / overlay media actions — route play / queue actions
|
||||
// into the playback controller; favorite / download stay logged.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.on_media_action(move |kind, id, action| {
|
||||
let kind = kind.to_string();
|
||||
let id = id.to_string();
|
||||
let action = action.to_string();
|
||||
log::info!("[qbz-slint] media-action: kind={kind} id={id} action={action}");
|
||||
match (kind.as_str(), action.as_str()) {
|
||||
("album", "play") => playback::play_album(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
id,
|
||||
0,
|
||||
),
|
||||
("track", "play") => {
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
playback::play_track_now(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
track_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
("album", "queue") => playback::enqueue_album(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
id,
|
||||
),
|
||||
("track", "queue") => {
|
||||
if let Ok(track_id) = id.parse::<u64>() {
|
||||
playback::enqueue_track(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
track_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Transport — wired through the NowPlayingState global callbacks.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<NowPlayingState>()
|
||||
.on_toggle_play(move || {
|
||||
playback::toggle_play_pause(runtime.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<NowPlayingState>().on_next(move || {
|
||||
playback::next(runtime.clone(), weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window.global::<NowPlayingState>().on_previous(move || {
|
||||
playback::previous(runtime.clone(), weak.clone(), handle.clone());
|
||||
});
|
||||
}
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<NowPlayingState>()
|
||||
.on_seek(move |fraction| {
|
||||
playback::seek(runtime.clone(), handle.clone(), fraction);
|
||||
});
|
||||
}
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<NowPlayingState>()
|
||||
.on_set_volume(move |fraction| {
|
||||
playback::set_volume(runtime.clone(), handle.clone(), fraction);
|
||||
});
|
||||
}
|
||||
|
||||
// Queue sidebar — clicking a row jumps to that queue index.
|
||||
{
|
||||
let runtime = app_runtime.clone();
|
||||
let weak = window.as_weak();
|
||||
let handle = tokio_rt.handle().clone();
|
||||
window
|
||||
.global::<QueueState>()
|
||||
.on_play_index(move |index| {
|
||||
playback::play_queue_index(
|
||||
runtime.clone(),
|
||||
weak.clone(),
|
||||
handle.clone(),
|
||||
index.max(0) as usize,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Album track search — client-side filter, no backend round-trip.
|
||||
{
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
//! Playback / queue controller.
|
||||
//!
|
||||
//! Owns the orchestration between the UI and `QbzCore`'s player + queue.
|
||||
//! Albums and tracks are turned into a `Vec<QueueTrack>`, handed to the
|
||||
//! core's `QueueManager`, and then played audibly through
|
||||
//! `Player::play_track` (the self-contained "fetch URL → download → play"
|
||||
//! path — the protected bit-perfect audio backend is untouched).
|
||||
//!
|
||||
//! There is no event stream from the player, so a `tokio` poll task reads
|
||||
//! `Player::get_playback_event()` a few times a second and pushes the
|
||||
//! values onto the `NowPlayingState` global. The same task drives
|
||||
//! auto-advance when a track ends.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use qbz_app::shell::AppRuntime;
|
||||
use qbz_models::{Quality, QueueTrack};
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::adapter::SlintAdapter;
|
||||
use crate::{AppWindow, NowPlayingState, QueueItem, QueueState};
|
||||
|
||||
/// Streaming quality used for all playback in the MVP — highest tier,
|
||||
/// the player falls back internally when it is not available.
|
||||
const PLAYBACK_QUALITY: Quality = Quality::UltraHiRes;
|
||||
|
||||
/// Convenience alias for the runtime handle threaded through every call.
|
||||
type Runtime = Arc<AppRuntime<SlintAdapter>>;
|
||||
|
||||
/// Run the audible step for `track_id`: grab the Qobuz client and call
|
||||
/// the player's self-contained `play_track`. Errors are logged, not
|
||||
/// surfaced — the poll loop keeps the UI consistent regardless.
|
||||
async fn play_audible(runtime: &Runtime, track_id: u64) {
|
||||
let client_lock = runtime.core().client();
|
||||
let guard = client_lock.read().await;
|
||||
let Some(client) = guard.as_ref() else {
|
||||
log::error!("[qbz-slint] playback: no Qobuz client — cannot start audio");
|
||||
return;
|
||||
};
|
||||
let player = runtime.core().player();
|
||||
if let Err(e) = player.play_track(client, track_id, PLAYBACK_QUALITY).await {
|
||||
log::error!("[qbz-slint] playback: play_track {track_id} failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain, `Send` queue-row data produced on the worker thread. The
|
||||
/// `QueueItem` (which holds a non-`Send` `slint::Image`) is built from
|
||||
/// this inside the event-loop closure.
|
||||
struct QueueRowData {
|
||||
id: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
/// Push the current queue snapshot onto the `QueueState` global. Builds
|
||||
/// the ordered list (history → current → upcoming) so the sidebar shows
|
||||
/// the whole queue with the playing row marked.
|
||||
async fn refresh_queue(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
|
||||
let mut rows: Vec<QueueRowData> = Vec::new();
|
||||
let mut current_index: i32 = -1;
|
||||
|
||||
for track in &state.history {
|
||||
rows.push(queue_row(track, false));
|
||||
}
|
||||
if let Some(current) = state.current_track.as_ref() {
|
||||
current_index = rows.len() as i32;
|
||||
rows.push(queue_row(current, true));
|
||||
}
|
||||
for track in &state.upcoming {
|
||||
rows.push(queue_row(track, false));
|
||||
}
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let items: Vec<QueueItem> = rows
|
||||
.into_iter()
|
||||
.map(|row| QueueItem {
|
||||
id: row.id.into(),
|
||||
title: row.title.into(),
|
||||
artist: row.artist.into(),
|
||||
artwork: slint::Image::default(),
|
||||
playing: row.playing,
|
||||
})
|
||||
.collect();
|
||||
let qs = w.global::<QueueState>();
|
||||
qs.set_items(slint::ModelRc::new(slint::VecModel::from(items)));
|
||||
qs.set_current_index(current_index);
|
||||
});
|
||||
}
|
||||
|
||||
/// Map a `QueueTrack` to plain `Send` row data.
|
||||
fn queue_row(track: &QueueTrack, playing: bool) -> QueueRowData {
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
QueueRowData {
|
||||
id: track.id.to_string(),
|
||||
title,
|
||||
artist: track.artist.clone(),
|
||||
playing,
|
||||
}
|
||||
}
|
||||
|
||||
/// `M:SS` for the elapsed string.
|
||||
fn fmt_elapsed(secs: u64) -> String {
|
||||
format!("{}:{:02}", secs / 60, secs % 60)
|
||||
}
|
||||
|
||||
/// `-M:SS` for the remaining string.
|
||||
fn fmt_remaining(position: u64, duration: u64) -> String {
|
||||
let left = duration.saturating_sub(position);
|
||||
format!("-{}:{:02}", left / 60, left % 60)
|
||||
}
|
||||
|
||||
/// Push the now-playing values for the current queue track onto
|
||||
/// `NowPlayingState`. Called when a new track starts so the song card
|
||||
/// updates immediately (the poll loop only refreshes position/progress).
|
||||
async fn refresh_now_playing_meta(runtime: &Runtime, weak: &slint::Weak<AppWindow>) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
let Some(track) = state.current_track else {
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
w.global::<NowPlayingState>().set_has_track(false);
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
|
||||
Some(version) => format!("{} ({version})", track.title),
|
||||
None => track.title.clone(),
|
||||
};
|
||||
let artist = track.artist.clone();
|
||||
let album = track.album.clone();
|
||||
let album_id = track.album_id.clone().unwrap_or_default();
|
||||
let artist_id = track.artist_id.map(|id| id.to_string()).unwrap_or_default();
|
||||
let track_id = track.id.to_string();
|
||||
let duration = track.duration_secs;
|
||||
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let np = w.global::<NowPlayingState>();
|
||||
np.set_has_track(true);
|
||||
np.set_title(title.into());
|
||||
np.set_artist(artist.into());
|
||||
np.set_album(album.into());
|
||||
np.set_album_id(album_id.into());
|
||||
np.set_artist_id(artist_id.into());
|
||||
np.set_track_id(track_id.into());
|
||||
np.set_duration_secs(duration as i32);
|
||||
np.set_position_secs(0);
|
||||
np.set_progress(0.0);
|
||||
np.set_cache(0.0);
|
||||
np.set_elapsed("0:00".into());
|
||||
np.set_remaining(fmt_remaining(0, duration).into());
|
||||
np.set_playing(true);
|
||||
});
|
||||
}
|
||||
|
||||
/// Build a `QueueTrack` for the queue from the catalog `Track`, filling
|
||||
/// the album metadata from `album_meta` (the track's own album summary is
|
||||
/// often partial in album responses).
|
||||
fn make_queue_track(
|
||||
track: &qbz_models::Track,
|
||||
album_id: &str,
|
||||
album_title: &str,
|
||||
album_artist: &str,
|
||||
album_artwork: &str,
|
||||
) -> QueueTrack {
|
||||
QueueTrack {
|
||||
id: track.id,
|
||||
title: track.title.clone(),
|
||||
version: track.version.clone(),
|
||||
artist: track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or_else(|| album_artist.to_string()),
|
||||
album: album_title.to_string(),
|
||||
duration_secs: track.duration as u64,
|
||||
artwork_url: if album_artwork.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(album_artwork.to_string())
|
||||
},
|
||||
hires: track.hires,
|
||||
bit_depth: track.maximum_bit_depth,
|
||||
sample_rate: track.maximum_sampling_rate,
|
||||
is_local: false,
|
||||
album_id: Some(album_id.to_string()),
|
||||
artist_id: track.performer.as_ref().map(|p| p.id),
|
||||
streamable: track.streamable,
|
||||
source: Some("qobuz".to_string()),
|
||||
parental_warning: track.parental_warning,
|
||||
source_item_id_hint: Some(album_id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the currently playing queue track in the recently-played store
|
||||
/// so the Discover "Recently Played" sections fill.
|
||||
async fn record_recent(runtime: &Runtime) {
|
||||
let state = runtime.core().get_queue_state().await;
|
||||
let Some(track) = state.current_track else {
|
||||
return;
|
||||
};
|
||||
let artwork = track.artwork_url.clone().unwrap_or_default();
|
||||
crate::recently::record(crate::recently::RecentTrack {
|
||||
id: track.id.to_string(),
|
||||
title: track.title.clone(),
|
||||
subtitle: track.artist.clone(),
|
||||
artwork_url: artwork.clone(),
|
||||
album_id: track.album_id.clone().unwrap_or_default(),
|
||||
album_title: track.album.clone(),
|
||||
album_artist: track.artist.clone(),
|
||||
album_artwork_url: artwork,
|
||||
});
|
||||
}
|
||||
|
||||
/// Play `album_id` from `start_index`: fetch the album, build the queue,
|
||||
/// hand it to the core, and start audio on the start track.
|
||||
pub fn play_album(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
album_id: String,
|
||||
start_index: usize,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let album = match runtime.core().get_album(&album_id).await {
|
||||
Ok(album) => album,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] playback: get_album {album_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
|
||||
let tracks: Vec<QueueTrack> = album
|
||||
.tracks
|
||||
.as_ref()
|
||||
.map(|container| container.items.as_slice())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|track| {
|
||||
make_queue_track(
|
||||
track,
|
||||
&album.id,
|
||||
&album_title,
|
||||
&album_artist,
|
||||
&album_artwork,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if tracks.is_empty() {
|
||||
log::warn!("[qbz-slint] playback: album {album_id} has no tracks");
|
||||
return;
|
||||
}
|
||||
|
||||
let start = start_index.min(tracks.len() - 1);
|
||||
let start_track_id = tracks[start].id;
|
||||
|
||||
runtime.core().set_queue(tracks, Some(start)).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, start_track_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Play a single track immediately as a one-track queue.
|
||||
pub fn play_track_now(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
track_id: u64,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let track = match runtime.core().get_track(track_id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] playback: get_track {track_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
|
||||
Some(album) => (
|
||||
album.id.clone(),
|
||||
album.title.clone(),
|
||||
album.image.best().cloned().unwrap_or_default(),
|
||||
),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let album_artist = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let queue_track = make_queue_track(
|
||||
&track,
|
||||
&album_id,
|
||||
&album_title,
|
||||
&album_artist,
|
||||
&album_artwork,
|
||||
);
|
||||
|
||||
runtime.core().set_queue(vec![queue_track], Some(0)).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Enqueue an album's tracks at the end of the current queue.
|
||||
pub fn enqueue_album(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, album_id: String) {
|
||||
handle.spawn(async move {
|
||||
let album = match runtime.core().get_album(&album_id).await {
|
||||
Ok(album) => album,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] playback: enqueue get_album {album_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let album_title = album.title.clone();
|
||||
let album_artist = album.artist.name.clone();
|
||||
let album_artwork = album.image.best().cloned().unwrap_or_default();
|
||||
let tracks: Vec<QueueTrack> = album
|
||||
.tracks
|
||||
.as_ref()
|
||||
.map(|container| container.items.as_slice())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|track| {
|
||||
make_queue_track(track, &album.id, &album_title, &album_artist, &album_artwork)
|
||||
})
|
||||
.collect();
|
||||
if tracks.is_empty() {
|
||||
return;
|
||||
}
|
||||
runtime.core().add_tracks(tracks).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Enqueue a single track at the end of the current queue.
|
||||
pub fn enqueue_track(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle, track_id: u64) {
|
||||
handle.spawn(async move {
|
||||
let track = match runtime.core().get_track(track_id).await {
|
||||
Ok(track) => track,
|
||||
Err(e) => {
|
||||
log::error!("[qbz-slint] playback: enqueue get_track {track_id} failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
|
||||
Some(album) => (
|
||||
album.id.clone(),
|
||||
album.title.clone(),
|
||||
album.image.best().cloned().unwrap_or_default(),
|
||||
),
|
||||
None => (String::new(), String::new(), String::new()),
|
||||
};
|
||||
let album_artist = track
|
||||
.performer
|
||||
.as_ref()
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default();
|
||||
let queue_track =
|
||||
make_queue_track(&track, &album_id, &album_title, &album_artist, &album_artwork);
|
||||
runtime.core().add_track(queue_track).await;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Jump to queue entry `index` and start audio for it.
|
||||
pub fn play_queue_index(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
index: usize,
|
||||
) {
|
||||
handle.spawn(async move {
|
||||
let Some(track) = runtime.core().play_index(index).await else {
|
||||
log::warn!("[qbz-slint] playback: play_index {index} out of range");
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Toggle play / pause on the live player.
|
||||
pub fn toggle_play_pause(runtime: Runtime, handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let playing = runtime.core().get_playback_state().is_playing;
|
||||
let result = if playing {
|
||||
runtime.core().pause()
|
||||
} else {
|
||||
runtime.core().resume()
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::error!("[qbz-slint] playback: toggle play/pause failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Advance to the next queue track and play it.
|
||||
pub fn next(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let Some(track) = runtime.core().next_track().await else {
|
||||
log::info!("[qbz-slint] playback: end of queue");
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Go to the previous queue track and play it.
|
||||
pub fn previous(runtime: Runtime, weak: slint::Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||
handle.spawn(async move {
|
||||
let Some(track) = runtime.core().previous_track().await else {
|
||||
log::info!("[qbz-slint] playback: start of queue");
|
||||
return;
|
||||
};
|
||||
let track_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, track_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Seek to `fraction` (0..1) of the current track's duration.
|
||||
pub fn seek(runtime: Runtime, handle: tokio::runtime::Handle, fraction: f32) {
|
||||
handle.spawn(async move {
|
||||
let state = runtime.core().get_playback_state();
|
||||
if state.duration == 0 {
|
||||
return;
|
||||
}
|
||||
let fraction = fraction.clamp(0.0, 1.0);
|
||||
let position = (fraction as f64 * state.duration as f64).round() as u64;
|
||||
if let Err(e) = runtime.core().seek(position) {
|
||||
log::error!("[qbz-slint] playback: seek failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the player volume from `fraction` (0..1).
|
||||
pub fn set_volume(runtime: Runtime, handle: tokio::runtime::Handle, fraction: f32) {
|
||||
handle.spawn(async move {
|
||||
let fraction = fraction.clamp(0.0, 1.0);
|
||||
if let Err(e) = runtime.core().set_volume(fraction) {
|
||||
log::error!("[qbz-slint] playback: set_volume failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn elapsed_pads_seconds() {
|
||||
assert_eq!(fmt_elapsed(0), "0:00");
|
||||
assert_eq!(fmt_elapsed(9), "0:09");
|
||||
assert_eq!(fmt_elapsed(65), "1:05");
|
||||
assert_eq!(fmt_elapsed(605), "10:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_down_and_pads() {
|
||||
assert_eq!(fmt_remaining(0, 200), "-3:20");
|
||||
assert_eq!(fmt_remaining(195, 200), "-0:05");
|
||||
assert_eq!(fmt_remaining(200, 200), "-0:00");
|
||||
// Position past duration must not underflow.
|
||||
assert_eq!(fmt_remaining(250, 200), "-0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_row_appends_version() {
|
||||
let track = QueueTrack {
|
||||
id: 7,
|
||||
title: "Song".to_string(),
|
||||
version: Some("Live".to_string()),
|
||||
artist: "Artist".to_string(),
|
||||
album: "Album".to_string(),
|
||||
duration_secs: 100,
|
||||
artwork_url: None,
|
||||
hires: false,
|
||||
bit_depth: None,
|
||||
sample_rate: None,
|
||||
is_local: false,
|
||||
album_id: None,
|
||||
artist_id: None,
|
||||
streamable: true,
|
||||
source: None,
|
||||
parental_warning: false,
|
||||
source_item_id_hint: None,
|
||||
};
|
||||
let row = queue_row(&track, true);
|
||||
assert_eq!(row.title, "Song (Live)");
|
||||
assert_eq!(row.id, "7");
|
||||
assert!(row.playing);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the playback poll loop. Runs for the app lifetime: every ~450ms
|
||||
/// it reads the player event and pushes position / progress onto
|
||||
/// `NowPlayingState`. When a track ends it auto-advances the queue.
|
||||
pub fn start_poll_loop(
|
||||
runtime: Runtime,
|
||||
weak: slint::Weak<AppWindow>,
|
||||
handle: tokio::runtime::Handle,
|
||||
) {
|
||||
let spawn_handle = handle.clone();
|
||||
spawn_handle.spawn(async move {
|
||||
// Track whether the last poll observed an active track, so the
|
||||
// end-of-track edge is detected once rather than every tick.
|
||||
let mut last_track_id: u64 = 0;
|
||||
let mut was_playing = false;
|
||||
let mut seen_position: u64 = 0;
|
||||
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(450));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
|
||||
let event = runtime.core().player().get_playback_event();
|
||||
|
||||
let track_id = event.track_id;
|
||||
let position = event.position;
|
||||
let duration = event.duration;
|
||||
let is_playing = event.is_playing;
|
||||
let volume = event.volume;
|
||||
|
||||
// Detect end-of-track: there was a track, it has reached the
|
||||
// end (position within the duration) and is no longer playing.
|
||||
let track_ended = was_playing
|
||||
&& !is_playing
|
||||
&& last_track_id != 0
|
||||
&& (track_id == 0 || track_id == last_track_id)
|
||||
&& duration > 0
|
||||
&& seen_position + 2 >= duration;
|
||||
|
||||
// Push the live values onto NowPlayingState.
|
||||
let progress = if duration > 0 {
|
||||
(position as f32 / duration as f32).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let elapsed = fmt_elapsed(position);
|
||||
let remaining = fmt_remaining(position, duration);
|
||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||
let np = w.global::<NowPlayingState>();
|
||||
np.set_position_secs(position as i32);
|
||||
if duration > 0 {
|
||||
np.set_duration_secs(duration as i32);
|
||||
}
|
||||
np.set_progress(progress);
|
||||
np.set_elapsed(elapsed.into());
|
||||
np.set_remaining(remaining.into());
|
||||
np.set_playing(is_playing);
|
||||
np.set_volume(volume.clamp(0.0, 1.0));
|
||||
});
|
||||
|
||||
if track_id != 0 {
|
||||
last_track_id = track_id;
|
||||
seen_position = position;
|
||||
}
|
||||
was_playing = is_playing;
|
||||
|
||||
// Auto-advance on track end.
|
||||
if track_ended {
|
||||
last_track_id = 0;
|
||||
was_playing = false;
|
||||
seen_position = 0;
|
||||
if let Some(track) = runtime.core().next_track().await {
|
||||
let next_id = track.id;
|
||||
refresh_queue(&runtime, &weak).await;
|
||||
refresh_now_playing_meta(&runtime, &weak).await;
|
||||
record_recent(&runtime).await;
|
||||
play_audible(&runtime, next_id).await;
|
||||
} else {
|
||||
log::info!("[qbz-slint] playback: queue finished");
|
||||
let _ = weak.upgrade_in_event_loop(|w| {
|
||||
let np = w.global::<NowPlayingState>();
|
||||
np.set_playing(false);
|
||||
np.set_progress(0.0);
|
||||
np.set_position_secs(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -421,6 +421,34 @@ export component AlbumPageView inherits Rectangle {
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
// Editorial description — lives with the metadata and
|
||||
// fills the width to the right of the artwork; truncated,
|
||||
// with a Read more link to the full modal.
|
||||
if AlbumState.description != "": Rectangle { height: 12px; }
|
||||
if AlbumState.description != "": Text {
|
||||
text: AlbumState.description-short;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.legal;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
|
||||
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
|
||||
width: read-more-row.preferred-width;
|
||||
height: 20px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-description = true;
|
||||
}
|
||||
read-more-row := HorizontalLayout {
|
||||
Text {
|
||||
text: @tr("Read more");
|
||||
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 20px; }
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
@@ -474,33 +502,6 @@ export component AlbumPageView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Editorial description — full content width (the artwork no
|
||||
// longer caps it), truncated, with a Read more link.
|
||||
if AlbumState.description != "": Rectangle { height: 18px; }
|
||||
if AlbumState.description != "": Text {
|
||||
text: AlbumState.description-short;
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
|
||||
if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
|
||||
width: read-more-row.preferred-width;
|
||||
height: 20px;
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.show-description = true;
|
||||
}
|
||||
read-more-row := HorizontalLayout {
|
||||
Text {
|
||||
text: @tr("Read more");
|
||||
color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
|
||||
font-size: Typography.legal;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 20px; }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
|
||||
@@ -10,7 +10,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 } from "state.slint";
|
||||
export { HomeState, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, NowPlayingState, QueueState } 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`.
|
||||
|
||||
@@ -255,6 +255,14 @@ export component ArtistPageView inherits Rectangle {
|
||||
font-weight: Typography.bold;
|
||||
overflow: elide;
|
||||
}
|
||||
Rectangle { height: 12px; }
|
||||
Text {
|
||||
text: ArtistState.bio;
|
||||
visible: ArtistState.bio != "";
|
||||
color: Theme.text-secondary;
|
||||
font-size: Typography.body;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
Rectangle { height: 18px; }
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
|
||||
@@ -114,6 +114,13 @@ export component AppShell inherits Rectangle {
|
||||
if ShellState.right-panel == 2: LyricsSidebar { }
|
||||
}
|
||||
|
||||
PlayerBar { }
|
||||
PlayerBar {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
}
|
||||
open-artist(id) => {
|
||||
root.open-artist(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@ component NavTab inherits Rectangle {
|
||||
: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
tab-row := HorizontalLayout {
|
||||
padding-left: root.show-icon ? 9px : 11px;
|
||||
padding-right: 11px;
|
||||
padding-left: root.show-icon ? 11px : 13px;
|
||||
padding-right: 13px;
|
||||
spacing: 6px;
|
||||
if root.show-icon: VerticalLayout {
|
||||
alignment: center;
|
||||
@@ -49,6 +49,16 @@ component NavTab inherits Rectangle {
|
||||
font-weight: root.active ? Typography.semibold : Typography.regular;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
// Caret marking the tab as a dropdown.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/chevron-down.svg");
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
tint: root.active ? Theme.text-secondary : Theme.text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Radius } from "../foundation/radius.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { QbzSlider } from "../primitives/QbzSlider.slint";
|
||||
import { SeekBar } from "SeekBar.slint";
|
||||
import { NowPlayingState } from "../state.slint";
|
||||
|
||||
component IconButton inherits Rectangle {
|
||||
in property <image> icon;
|
||||
@@ -93,6 +94,9 @@ component MetaLink inherits TouchArea {
|
||||
}
|
||||
|
||||
export component PlayerBar inherits Rectangle {
|
||||
callback open-album(string);
|
||||
callback open-artist(string);
|
||||
|
||||
height: Layout.player-bar-height;
|
||||
background: Theme.surface-card;
|
||||
|
||||
@@ -106,7 +110,16 @@ export component PlayerBar inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.md;
|
||||
padding-right: Spacing.md;
|
||||
SeekBar { }
|
||||
SeekBar {
|
||||
progress: NowPlayingState.progress;
|
||||
cache: NowPlayingState.cache;
|
||||
elapsed: NowPlayingState.elapsed;
|
||||
remaining: NowPlayingState.remaining;
|
||||
duration-secs: NowPlayingState.duration-secs;
|
||||
seek(fraction) => {
|
||||
NowPlayingState.seek(fraction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Controls row ------------------------------------------------
|
||||
@@ -122,12 +135,29 @@ export component PlayerBar inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
spacing: 2px;
|
||||
IconButton { icon: @image-url("../assets/icons/shuffle.svg"); icon-size: 16px; }
|
||||
IconButton { icon: @image-url("../assets/icons/skip-back.svg"); }
|
||||
IconButton {
|
||||
icon: @image-url("../assets/icons/skip-back.svg");
|
||||
clicked => {
|
||||
NowPlayingState.previous();
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
PlayButton { icon: @image-url("../assets/icons/play-fill.svg"); }
|
||||
PlayButton {
|
||||
icon: NowPlayingState.playing
|
||||
? @image-url("../assets/icons/pause.svg")
|
||||
: @image-url("../assets/icons/play-fill.svg");
|
||||
clicked => {
|
||||
NowPlayingState.toggle-play();
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton {
|
||||
icon: @image-url("../assets/icons/skip-forward.svg");
|
||||
clicked => {
|
||||
NowPlayingState.next();
|
||||
}
|
||||
}
|
||||
IconButton { icon: @image-url("../assets/icons/skip-forward.svg"); }
|
||||
IconButton { icon: @image-url("../assets/icons/repeat.svg"); icon-size: 16px; }
|
||||
IconButton { icon: @image-url("../assets/icons/plus.svg"); icon-size: 16px; }
|
||||
IconButton { icon: @image-url("../assets/icons/heart.svg"); icon-size: 16px; }
|
||||
@@ -148,13 +178,21 @@ export component PlayerBar inherits Rectangle {
|
||||
border-radius: 6px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
if NowPlayingState.has-track && NowPlayingState.artwork.width > 0: Image {
|
||||
source: NowPlayingState.artwork;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 4px;
|
||||
Text {
|
||||
text: @tr("Nothing playing");
|
||||
text: NowPlayingState.has-track
|
||||
? NowPlayingState.title
|
||||
: @tr("Nothing playing");
|
||||
color: Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: Typography.medium;
|
||||
@@ -163,8 +201,15 @@ export component PlayerBar inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
spacing: 6px;
|
||||
alignment: start;
|
||||
MetaLink { label: "QBZ MVP"; }
|
||||
VerticalLayout {
|
||||
if NowPlayingState.has-track: MetaLink {
|
||||
label: NowPlayingState.artist;
|
||||
activated => {
|
||||
if (NowPlayingState.artist-id != "") {
|
||||
root.open-artist(NowPlayingState.artist-id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if NowPlayingState.has-track: VerticalLayout {
|
||||
alignment: center;
|
||||
QbzIcon {
|
||||
source: @image-url("../assets/icons/layers.svg");
|
||||
@@ -173,6 +218,14 @@ export component PlayerBar inherits Rectangle {
|
||||
tint: Theme.text-muted;
|
||||
}
|
||||
}
|
||||
if NowPlayingState.has-track: MetaLink {
|
||||
label: NowPlayingState.album;
|
||||
activated => {
|
||||
if (NowPlayingState.album-id != "") {
|
||||
root.open-album(NowPlayingState.album-id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +250,10 @@ export component PlayerBar inherits Rectangle {
|
||||
width: 90px;
|
||||
minimum: 0;
|
||||
maximum: 100;
|
||||
value: 70;
|
||||
value: NowPlayingState.volume * 100;
|
||||
changed(v) => {
|
||||
NowPlayingState.set-volume(v / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton { icon: @image-url("../assets/icons/minus.svg"); icon-size: 15px; }
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Spacing } from "../foundation/spacing.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { Radius } from "../foundation/radius.slint";
|
||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||
import { ShellState } from "../state.slint";
|
||||
import { ShellState, QueueState } from "../state.slint";
|
||||
|
||||
component CloseButton inherits Rectangle {
|
||||
callback clicked();
|
||||
@@ -36,6 +36,79 @@ component CloseButton inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// One queue entry row — thumbnail, title, artist, playing indicator.
|
||||
component QueueRow inherits Rectangle {
|
||||
in property <string> title;
|
||||
in property <string> artist;
|
||||
in property <image> artwork;
|
||||
in property <bool> playing: false;
|
||||
callback clicked();
|
||||
|
||||
height: 56px;
|
||||
background: ta.has-hover ? Theme.surface-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Spacing.md;
|
||||
padding-right: Spacing.md;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
spacing: 10px;
|
||||
|
||||
// Small album thumbnail.
|
||||
Rectangle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 4px;
|
||||
background: Theme.surface-elevated;
|
||||
clip: true;
|
||||
if root.artwork.width > 0: Image {
|
||||
source: root.artwork;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
// Title + artist.
|
||||
VerticalLayout {
|
||||
horizontal-stretch: 1;
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
Text {
|
||||
text: root.title;
|
||||
color: root.playing ? Theme.accent : Theme.text-primary;
|
||||
font-size: Typography.body;
|
||||
font-weight: root.playing ? Typography.semibold : Typography.regular;
|
||||
overflow: elide;
|
||||
}
|
||||
Text {
|
||||
text: root.artist;
|
||||
color: Theme.text-muted;
|
||||
font-size: 11px;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
// Now-playing indicator.
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
if root.playing: QbzIcon {
|
||||
source: @image-url("../assets/icons/audio-lines.svg");
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ta := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component QueueSidebar inherits Rectangle {
|
||||
width: 320px;
|
||||
background: Theme.surface-card;
|
||||
@@ -72,14 +145,33 @@ export component QueueSidebar inherits Rectangle {
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
|
||||
// Body — placeholder until queue contents are wired.
|
||||
// Body — the live queue, or an empty-state placeholder.
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
Text {
|
||||
|
||||
if QueueState.items.length == 0: Text {
|
||||
text: @tr("Nothing queued");
|
||||
color: Theme.text-muted;
|
||||
font-size: Typography.body;
|
||||
}
|
||||
|
||||
if QueueState.items.length > 0: Flickable {
|
||||
viewport-width: self.width;
|
||||
viewport-height: rows.preferred-height;
|
||||
|
||||
rows := VerticalLayout {
|
||||
spacing: 0px;
|
||||
for item[idx] in QueueState.items: QueueRow {
|
||||
title: item.title;
|
||||
artist: item.artist;
|
||||
artwork: item.artwork;
|
||||
playing: item.playing;
|
||||
clicked => {
|
||||
QueueState.play-index(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,3 +183,48 @@ export global SessionState {
|
||||
in property <string> user-name: "";
|
||||
in property <string> subscription: "";
|
||||
}
|
||||
|
||||
// One entry in the playback queue, for the Queue sidebar list.
|
||||
export struct QueueItem {
|
||||
id: string,
|
||||
title: string,
|
||||
artist: string,
|
||||
artwork: image,
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
// Now-playing / transport state. The Rust playback poll loop pushes the
|
||||
// live values here; the PlayerBar and SeekBar bind to it directly.
|
||||
export global NowPlayingState {
|
||||
in property <bool> has-track: false;
|
||||
in property <string> title;
|
||||
in property <string> artist;
|
||||
in property <string> album;
|
||||
in property <string> album-id;
|
||||
in property <string> artist-id;
|
||||
in property <image> artwork;
|
||||
in property <string> track-id;
|
||||
in property <bool> playing: false;
|
||||
in property <int> position-secs: 0;
|
||||
in property <int> duration-secs: 0;
|
||||
in property <float> progress: 0.0; // 0..1
|
||||
in property <float> cache: 0.0; // 0..1 buffered
|
||||
in property <string> elapsed: "0:00";
|
||||
in property <string> remaining: "0:00";
|
||||
in property <float> volume: 0.7; // 0..1
|
||||
|
||||
// Transport callbacks, wired to the Rust playback controller.
|
||||
callback toggle-play();
|
||||
callback next();
|
||||
callback previous();
|
||||
callback seek(float); // 0..1
|
||||
callback set-volume(float); // 0..1
|
||||
}
|
||||
|
||||
// Playback queue model, for the Queue sidebar.
|
||||
export global QueueState {
|
||||
in property <[QueueItem]> items: [];
|
||||
in property <int> current-index: -1;
|
||||
// Play the queue entry at `index`.
|
||||
callback play-index(int);
|
||||
}
|
||||
Reference in new issue
Block a user