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:
vicrodh committed 2026-05-18 22:16:13 -06:00
1 parent 33df7bce76
commit 7e6e548598
12 files changed
+1012 -72

No files matched your search

+20 -19
View File
@@ -64,9 +64,10 @@ impl Default for PlaybackPreferences {
fn default() -> Self {
Self {
autoplay_mode: AutoplayMode::ContinueWithinSource,
show_context_icon: false,
persist_session: false,
resume_playback_position: false,
// Playback preferences are opt-out: on by default.
show_context_icon: true,
persist_session: true,
resume_playback_position: true,
}
}
}
@@ -109,7 +110,7 @@ impl PlaybackPreferencesStore {
if !show_context_icon_exists {
info!("[PlaybackPrefs] Migrating: adding show_context_icon column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE playback_preferences ADD COLUMN show_context_icon INTEGER NOT NULL DEFAULT 1",
[],
)
.map_err(|e| format!("Failed to add show_context_icon column: {}", e))?;
@@ -119,7 +120,7 @@ impl PlaybackPreferencesStore {
if !column_exists(&conn, "playback_preferences", "persist_session") {
info!("[PlaybackPrefs] Migrating: adding persist_session column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE playback_preferences ADD COLUMN persist_session INTEGER NOT NULL DEFAULT 1",
[],
)
.map_err(|e| format!("Failed to add persist_session column: {}", e))?;
@@ -129,7 +130,7 @@ impl PlaybackPreferencesStore {
if !column_exists(&conn, "playback_preferences", "resume_playback_position") {
info!("[PlaybackPrefs] Migrating: adding resume_playback_position column");
conn.execute(
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE playback_preferences ADD COLUMN resume_playback_position INTEGER NOT NULL DEFAULT 1",
[],
)
.map_err(|e| format!("Failed to add resume_playback_position column: {}", e))?;
@@ -138,7 +139,7 @@ impl PlaybackPreferencesStore {
conn.execute(
"INSERT OR IGNORE INTO playback_preferences (id, autoplay_mode, show_context_icon, persist_session, resume_playback_position)
VALUES (1, 'continue', 0, 0, 0)",
VALUES (1, 'continue', 1, 1, 1)",
[],
)
.map_err(|e| format!("Failed to insert default preferences: {}", e))?;
@@ -354,9 +355,9 @@ mod tests {
let prefs = PlaybackPreferences::default();
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
}
#[test]
@@ -366,9 +367,9 @@ mod tests {
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
@@ -420,9 +421,9 @@ mod tests {
let prefs = store.get_preferences().expect("get prefs");
assert_eq!(prefs.autoplay_mode, AutoplayMode::PlayTrackOnly);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
@@ -443,9 +444,9 @@ mod tests {
assert_eq!(defaults.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert_eq!(prefs.autoplay_mode, AutoplayMode::ContinueWithinSource);
assert!(!prefs.show_context_icon);
assert!(!prefs.persist_session);
assert!(!prefs.resume_playback_position);
assert!(prefs.show_context_icon);
assert!(prefs.persist_session);
assert!(prefs.resume_playback_position);
let _ = std::fs::remove_dir_all(dir);
}
}
+6 -6
View File
@@ -91,8 +91,8 @@ impl Default for AudioSettings {
backend_type: Some(AudioBackendType::default()),
alsa_plugin: Some(AlsaPlugin::Hw), // Default to hw (bit-perfect)
alsa_hardware_volume: false, // Disabled by default (maximum compatibility)
stream_first_track: false, // Disabled by default — user opts in
stream_buffer_seconds: 3, // 3 seconds initial buffer
stream_first_track: true, // On by default (opt-out)
stream_buffer_seconds: 2, // 2 seconds initial buffer
streaming_only: false, // Disabled by default (cache tracks for instant replay)
limit_quality_to_device: false, // Disabled in 1.1.9 — detection logic unreliable (#45)
device_max_sample_rate: None, // Set when device is selected
@@ -136,8 +136,8 @@ impl AudioSettingsStore {
backend_type TEXT,
alsa_plugin TEXT,
alsa_hardware_volume INTEGER NOT NULL DEFAULT 0,
stream_first_track INTEGER NOT NULL DEFAULT 0,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 3
stream_first_track INTEGER NOT NULL DEFAULT 1,
stream_buffer_seconds INTEGER NOT NULL DEFAULT 2
);
INSERT OR IGNORE INTO audio_settings (id, exclusive_mode, dac_passthrough)
VALUES (1, 0, 0);",
@@ -155,11 +155,11 @@ impl AudioSettingsStore {
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 0",
"ALTER TABLE audio_settings ADD COLUMN stream_first_track INTEGER DEFAULT 1",
[],
);
let _ = conn.execute(
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 3",
"ALTER TABLE audio_settings ADD COLUMN stream_buffer_seconds INTEGER DEFAULT 2",
[],
);
let _ = conn.execute(
+117 -5
View File
@@ -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.
{
+608
View File
@@ -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);
});
}
}
}
});
}
+28 -27
View File
@@ -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;
+1 -1
View File
@@ -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;
+8 -1
View File
@@ -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);
}
}
}
}
+12 -2
View File
@@ -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 {
+64 -8
View File
@@ -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; }
+95 -3
View File
@@ -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);
}
}
}
}
}
}
}
+45
View File
@@ -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);
}