Files
qbz/crates/qbz-qt/src/playback_qt.rs
T
vicrodh 86de977f3c feat(qbz-qt): phase 8 — AlbumView and ArtistView 1:1
AlbumPageView replica: 224px cover, credits, meta, description modal,
action row, quality badge + track search, exact tracklist (playing-row
pill, disc/work headers, live hearts, row menus), label/awards
sidebar, same-artist + suggestions carousels. ArtistPageView replica:
circular portrait, bio modal, From catalog/In library toggle (both
arms live), JUMP TO tabs with jump-scroll, Popular Tracks with Load
more, server-bucketed release sections paged per section, Appears On,
Network sidebar (labels, similar artists). Card clicks navigate
everywhere via the nav stack; new playback paths (play from track,
shuffled album, track lists). Owner data live: album 654ms, artist
670ms loads.

km
2026-07-27 17:49:08 -06:00

615 lines
23 KiB
Rust

//! Minimal playback controller — drives `QbzCore`'s EXISTING public API
//! only (qbz-audio / qbz-player are protected; no audio logic is
//! reimplemented here). Ports the *shape* of `crates/qbz/src/playback.rs`:
//! play_album (fetch -> QueueTrack vec -> set_queue -> play_track_resolved)
//! and the poll loop (PlaybackEvent -> UI state, track-change meta refresh,
//! end-of-track advance).
//!
//! POC-NOTEs (deliberate cuts vs playback.rs, named for the effort report):
//! - Streaming quality: the ui_prefs DEFAULT ("hires_plus" ->
//! `Quality::UltraHiRes`). The ui_prefs store and the #638 device-cap
//! clamp live in the Slint glue; there is no prefs UI in the POC.
//! - Blacklist filtering of album tracks: skipped (store not open).
//! - Offline-cache tier (offline bytes), gapless prefetch (`play_next`),
//! prefetch warming, stop-after, infinite refill, session persist,
//! QConnect/cast branches, recently-played recording: all out of scope.
//! Auto-advance therefore plays each successor at fetch time (audible
//! gap between tracks) — the engine's gapless path is NOT wired.
//! - `advance_to_playable` (skip-unavailable walk): the POC advance takes
//! the core queue's next directly; a failed play logs and stops.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::LoggingAdapter;
use cxx_qt_lib::QString;
use qbz_models::{Quality, QueueTrack, RepeatMode};
/// The request tier for every play: the ui_prefs default ("hires_plus").
/// See module docs (no prefs UI in the POC).
const POC_QUALITY: Quality = Quality::UltraHiRes;
// Mute bookkeeping (mirrors playback.rs MUTED / PREMUTE_VOLUME: there is no
// dedicated mute API on the core — mute is volume 0 with a stash).
static MUTED: AtomicBool = AtomicBool::new(false);
static PREMUTE_VOLUME: AtomicU64 = AtomicU64::new(0);
// ---------------------------------------------------------------------------
// Play an album (album-card click on Home)
// ---------------------------------------------------------------------------
/// Fetch the album, build the queue (playback.rs `make_queue_track` port),
/// set it starting at track 1, and play through the core's resolved path.
async fn fetch_album_queue(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<Vec<QueueTrack>, String> {
let album = runtime
.core()
.get_album(album_id)
.await
.map_err(|e| format!("get_album {album_id} failed: {e}"))?;
let album_title = album.title.clone();
let album_artist = album.artist.name.clone();
let album_artwork = album.image.best().cloned().unwrap_or_default();
let raw_tracks = album
.tracks
.as_ref()
.map(|container| container.items.as_slice())
.unwrap_or_default();
if raw_tracks.is_empty() {
return Err(format!("album {album_id} has no playable tracks"));
}
let tracks: Vec<QueueTrack> = raw_tracks
.iter()
.map(|track| 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.clone()),
album: album_title.clone(),
album_version: album
.version
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork.clone())
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: Some(album.id.clone()),
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.clone()),
context_kind: Some("album".to_string()),
context_id: Some(album.id.clone()),
})
.collect();
Ok(tracks)
}
/// "Play next" / "Add to queue" for an album (AlbumCard ⋯ menu): resolve
/// the album's tracks and insert them after the current track (mode
/// "next") or append them (mode "later").
pub async fn enqueue_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
mode: &str,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
log::info!("[qbz-qt] enqueue_album {album_id} ({mode}): {} tracks", tracks.len());
if mode == "next" {
// add_track_next inserts directly after the current track — feed
// in reverse so the album's first track lands first.
for track in tracks.into_iter().rev() {
runtime.core().add_track_next(track).await;
}
} else {
runtime.core().add_tracks(tracks).await;
}
publish_queue(runtime).await;
Ok(())
}
pub async fn play_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
play_album_from(runtime, album_id, 0).await
}
/// Play an album starting at track `start_index` (AlbumView row play —
/// Slint's play_album_from semantics: the queue keeps the whole album,
/// the cursor starts at the clicked track).
pub async fn play_album_from(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
start_index: usize,
) -> Result<(), String> {
log::info!("[qbz-qt] play_album: resolving {album_id} (start {start_index})");
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = start_index.min(tracks.len() - 1);
let first_id = tracks[start].id;
let count = tracks.len();
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_album: queue set ({count} tracks), playing track {first_id}");
runtime
.core()
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
log::info!("[qbz-qt] play_album: play_track_resolved started for {first_id}");
refresh_now_playing(runtime).await;
Ok(())
}
/// AlbumView row play: play the album starting AT the clicked track
/// (Slint play_album_from: the queue keeps the whole album).
pub async fn play_album_from_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
let start = tracks.iter().position(|t| t.id == track_id).unwrap_or(0);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
runtime
.core()
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// Play a pre-built queue starting at `start` (ArtistView Popular Tracks:
/// the visible list becomes the queue, anchored at the clicked track).
pub async fn play_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
start: usize,
shuffle: bool,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
if shuffle {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
}
let start = start.min(tracks.len() - 1);
let first_id = tracks[start].id;
runtime.core().set_queue(tracks, Some(start)).await;
publish_queue(runtime).await;
runtime
.core()
.play_track_resolved(first_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {first_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
/// Append a pre-built queue to the current one ("Add all to queue").
pub async fn enqueue_track_list(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<QueueTrack>,
) -> Result<(), String> {
if tracks.is_empty() {
return Err("empty track list".to_string());
}
runtime.core().add_tracks(tracks).await;
publish_queue(runtime).await;
Ok(())
}
/// One track from an album context into the queue ("Play next" / "Add to
/// queue" on an AlbumView row).
pub async fn enqueue_album_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
track_id: u64,
mode: &str,
) -> Result<(), String> {
let tracks = fetch_album_queue(runtime, album_id).await?;
let Some(track) = tracks.into_iter().find(|t| t.id == track_id) else {
return Err(format!("track {track_id} not in album {album_id}"));
};
if mode == "next" {
runtime.core().add_track_next(track).await;
} else {
runtime.core().add_track(track).await;
}
publish_queue(runtime).await;
Ok(())
}
/// Shuffle-play an album (AlbumView header Shuffle): enable shuffle, then
/// play — the core queue owns the shuffled order.
pub async fn play_album_shuffled(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
runtime.core().set_shuffle(true).await;
crate::now_playing::set_shuffle(true);
play_album(runtime, album_id).await
}
/// Play a single track as a one-element queue (Library track rows). The
/// queue meta is rebuilt from the Library feed row; the audio resolves by
/// id through the same `play_track_resolved` path.
pub async fn play_single_track(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
track_id: u64,
) -> Result<(), String> {
let id = track_id.to_string();
let item = crate::library_qt::with_library(|d| {
d.feed
.iter()
.find(|i| i.kind == "track" && i.id == id)
.cloned()
})
.flatten()
.ok_or_else(|| format!("track {track_id} not in the library feed"))?;
let duration_secs = {
let mut parts = item.duration.split(':');
parts.next().and_then(|m| m.parse::<u64>().ok()).unwrap_or(0) * 60
+ parts.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)
};
let qt = QueueTrack {
id: track_id,
title: item.title.clone(),
version: None,
artist: item.artist.clone(),
album: item.album.clone(),
album_version: None,
duration_secs,
artwork_url: if item.image_url.is_empty() {
None
} else {
Some(item.image_url.clone())
},
hires: item.quality_tier == "hires",
bit_depth: None,
sample_rate: None,
is_local: false,
album_id: if item.album_id.is_empty() {
None
} else {
Some(item.album_id.clone())
},
artist_id: item.artist_id.parse::<u64>().ok(),
streamable: true,
source: Some("qobuz".to_string()),
parental_warning: item.explicit,
source_item_id_hint: None,
context_kind: None,
context_id: None,
};
runtime.core().set_queue(vec![qt], Some(0)).await;
publish_queue(runtime).await;
log::info!("[qbz-qt] play_single_track: playing {track_id}");
runtime
.core()
.play_track_resolved(track_id, POC_QUALITY, None, None, 0)
.await
.map_err(|e| format!("play_track {track_id} failed: {e}"))?;
refresh_now_playing(runtime).await;
Ok(())
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
pub async fn toggle_play(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let event = runtime.core().player().get_playback_event();
let result = if event.is_playing {
runtime.core().pause()
} else {
runtime.core().resume()
};
if let Err(e) = result {
log::warn!("[qbz-qt] toggle-play failed: {e}");
}
}
pub async fn next(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
if let Some(track) = runtime.core().next_track().await {
play_queue_track(runtime, track.id).await;
}
}
pub async fn previous(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
if let Some(track) = runtime.core().previous_track().await {
play_queue_track(runtime, track.id).await;
}
}
async fn play_queue_track(runtime: &Arc<AppRuntime<LoggingAdapter>>, track_id: u64) {
if let Err(e) = runtime
.core()
.play_track_resolved(track_id, POC_QUALITY, None, None, 0)
.await
{
log::error!("[qbz-qt] playback: play_track {track_id} failed: {e}");
return;
}
refresh_now_playing(runtime).await;
publish_queue(runtime).await;
}
pub async fn seek_frac(runtime: &Arc<AppRuntime<LoggingAdapter>>, frac: f32) {
let event = runtime.core().player().get_playback_event();
if event.duration == 0 {
return;
}
let target = (frac.clamp(0.0, 1.0) * event.duration as f32) as u64;
if let Err(e) = runtime.core().seek(target) {
log::warn!("[qbz-qt] seek failed: {e}");
}
}
pub async fn set_volume(runtime: &Arc<AppRuntime<LoggingAdapter>>, volume: f32) {
let _ = runtime.core().set_volume(volume.clamp(0.0, 1.0));
MUTED.store(volume <= 0.0 && PREMUTE_VOLUME.load(Ordering::Relaxed) != 0, Ordering::Relaxed);
}
pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
if MUTED.swap(false, Ordering::Relaxed) {
// Unmute — restore the stored level.
let restored = f32::from_bits(PREMUTE_VOLUME.load(Ordering::Relaxed) as u32);
let restored = if restored > 0.0 { restored } else { 0.7 };
let _ = runtime.core().set_volume(restored);
crate::now_playing::set_muted(false);
} else {
// Mute — stash the current level, then drop to zero.
let current = runtime.core().player().get_playback_event().volume;
let current = if current > 0.0 { current } else { 0.7 };
PREMUTE_VOLUME.store(current.to_bits() as u64, Ordering::Relaxed);
MUTED.store(true, Ordering::Relaxed);
let _ = runtime.core().set_volume(0.0);
crate::now_playing::set_muted(true);
}
}
pub async fn toggle_shuffle(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let enabled = runtime.core().toggle_shuffle().await;
crate::now_playing::set_shuffle(enabled);
publish_queue(runtime).await;
}
pub async fn cycle_repeat(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let next_mode = match crate::now_playing::repeat_mode() {
0 => RepeatMode::All,
1 => RepeatMode::One,
_ => RepeatMode::Off,
};
runtime.core().set_repeat_mode(next_mode).await;
let value = match next_mode {
RepeatMode::Off => 0,
RepeatMode::All => 1,
RepeatMode::One => 2,
};
crate::now_playing::set_repeat_mode(value);
}
// ---------------------------------------------------------------------------
// Meta + queue publishing
// ---------------------------------------------------------------------------
/// Push the queue's current-track meta into the NowPlayingModel (title /
/// artist / quality / artwork via the phase-3 pipeline).
pub async fn refresh_now_playing(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state().await;
let Some(track) = state.current_track else {
crate::now_playing::clear_track();
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(""));
});
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
let track_id_str = track.id.to_string();
crate::ui(move |mut b| {
b.as_mut().set_np_track_id(QString::from(track_id_str.as_str()));
});
log::info!(
"[qbz-qt] now playing: '{title}' — {} ({}s)",
track.artist,
track.duration_secs,
);
let (tier, label) = quality_badge(&track);
crate::now_playing::set_track(crate::now_playing::TrackMeta {
title,
artist: track.artist.clone(),
duration_secs: track.duration_secs as i32,
quality_tier: tier,
quality_label: label,
artwork_url: track.artwork_url.clone().unwrap_or_default(),
shuffle: state.shuffle,
repeat_mode: match state.repeat {
RepeatMode::Off => 0,
RepeatMode::All => 1,
RepeatMode::One => 2,
},
});
// Artwork through the same cache pipeline as Home (attach + background
// download + republish — single url here).
crate::artwork_qt::attach_now_playing(&track.artwork_url.clone().unwrap_or_default());
}
/// Quality tier + exact label from a queue track's bit depth / sample rate
/// (kHz float, catalog), matching the discover card badge format.
fn quality_badge(track: &QueueTrack) -> (String, String) {
let tier = match track.bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None if track.hires => "hires",
None => "",
}
.to_string();
let label = match (track.bit_depth, track.sample_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, crate::home_qt::format_rate(sr)),
_ => String::new(),
};
(tier, label)
}
/// Publish the real queue into `queueModel` (now-playing row first, then
/// up-next rows) as one QVariant per row.
///
/// POC-NOTE: rows are JSON-encoded strings inside the QVariants — the same
/// cxx-qt-lib nesting limitation as homeSectionsJson (a QVariant cannot
/// hold a QVariantMap in 0.7.3); QML parses each row.
pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
let state = runtime.core().get_queue_state().await;
#[derive(serde::Serialize)]
struct QueueRow {
id: String,
title: String,
artist: String,
duration: String,
#[serde(rename = "artPath")]
art_path: String,
current: bool,
}
let mut rows: Vec<QueueRow> = Vec::new();
let mut push = |track: &QueueTrack, current: bool| {
rows.push(QueueRow {
id: track.id.to_string(),
title: track.title.clone(),
artist: track.artist.clone(),
duration: fmt_mmss(track.duration_secs),
art_path: crate::artwork_qt::cached_path(
&track.artwork_url.clone().unwrap_or_default(),
),
current,
});
};
if let Some(current) = state.current_track.as_ref() {
push(current, true);
}
for track in &state.upcoming {
push(track, false);
}
let json_rows: Vec<String> = rows
.iter()
.map(|r| serde_json::to_string(r).unwrap_or_default())
.collect();
crate::ui(move |mut b| {
b.as_mut()
.set_queue_model(crate::json_rows_to_qvariant_list(json_rows));
});
}
fn fmt_mmss(secs: u64) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
// ---------------------------------------------------------------------------
// 1 Hz state pump (playback.rs `start_poll_loop`, minimized)
// ---------------------------------------------------------------------------
static POLL_STARTED: AtomicBool = AtomicBool::new(false);
/// Start the 1 Hz pump on shell entry (idempotent). Publishes position /
/// playing / cache into the NowPlayingModel, detects track changes (meta +
/// queue refresh), and advances the queue at end-of-track through the core.
pub fn start_poll_loop(runtime: Arc<AppRuntime<LoggingAdapter>>) {
if POLL_STARTED.swap(true, Ordering::SeqCst) {
return;
}
crate::spawn(async move {
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_secs(1));
loop {
ticker.tick().await;
// Surface audio-stream failures once (playback.rs drains the same
// slot for its toast).
if let Some(msg) = runtime.core().player().state.take_stream_error_message() {
log::error!("[qbz-qt] audio output error: {msg}");
}
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 cache = event.buffer_progress.unwrap_or(0.0);
// --- Track-change edge (new current track surfaced) ----------
if track_id != 0 && track_id != last_track_id {
// Reconcile the queue pointer (a real advance moves it; a
// manual play already did) and refresh meta + queue.
let _ = runtime.core().sync_current_to_id(track_id).await;
refresh_now_playing(&runtime).await;
publish_queue(&runtime).await;
last_track_id = track_id;
}
if track_id != 0 {
log::debug!("[qbz-qt] poll: id={track_id} pos={position}/{duration} playing={is_playing} cache={cache:.2}");
}
// --- Position / state push ------------------------------------
crate::now_playing::set_position(
position as i32,
duration as i32,
is_playing,
cache,
track_id != 0,
);
// --- End-of-track edge (playback.rs condition, verbatim) ------
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;
if track_ended {
last_track_id = 0;
// POC-NOTE: no stop-after / infinite-refill / skip-unavailable
// walk — the core queue's own next (repeat/shuffle aware).
if let Some(track) = runtime.core().next_track().await {
let next_id = track.id;
log::info!("[qbz-qt] poll: advancing to {next_id}");
play_queue_track(&runtime, next_id).await;
} else {
log::info!("[qbz-qt] poll: queue finished");
crate::now_playing::set_playing(false);
}
}
seen_position = position;
was_playing = is_playing;
}
});
}