feat(qbz-qt): phase 4 — live playback, queue, and transport via QbzCore

Album-card click plays the album: get_album -> QueueTrack vec ->
set_queue -> play_track_resolved (tier walk stays in the core). 1 Hz
poll publishes position/meta/quality into the NowPlaying model; end-of-
track edge auto-advances with meta refresh (traced live: 24-bit/48kHz
CMAF stream, position advancing, track 2/3 transitions). Transport
invokables rewired to real core calls; queue panel shows NOW PLAYING +
numbered UP NEXT from get_queue_state. Public QbzCore API proved
sufficient — protected audio/player crates untouched. Named gaps:
no mute API, no playback event stream, auto-advance/prefetch semantics
remain frontend glue (~150 lines every UI must port).

km
This commit is contained in:
vicrodh committed 2026-07-27 14:04:58 -06:00
1 parent 95df643a25
commit 80dcd85a91
7 files changed
+890 -85

No files matched your search

+12 -3
View File
@@ -162,7 +162,9 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: the album page is out of scope — inert.
// Phase 4: a card click plays the album (the album page
// itself is out of scope — POC-NOTE).
onClicked: QbzBridge.playAlbum(card.id)
}
// Hover action buttons — favorite / play / more (INERT;
// POC-NOTE: playback + favorites land in later phases).
@@ -208,7 +210,13 @@ Rectangle {
anchors.centerIn: parent
tintName: "black"
}
MouseArea { id: playOvArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor }
MouseArea {
id: playOvArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: QbzBridge.playAlbum(card.id)
}
}
Rectangle {
width: 36
@@ -437,7 +445,8 @@ Rectangle {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: album page out of scope — inert.
// Phase 4: slim rows are albums — click plays (page out of scope).
onClicked: QbzBridge.playAlbum(card.id)
}
}
+221 -56
View File
@@ -1,15 +1,18 @@
// Right-side Queue panel — QML port of
// crates/qbz-ui/ui/shell/QueueSidebar.slint.
//
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card
// area, the UP NEXT list region, and the EMPTY states (the queue is empty
// until phase 4 — QbzBridge.queueModel is an empty QVariantList).
// Header with the two tabs ("Queue" / "History"), the NOW PLAYING card,
// the UP NEXT list, and the empty states. Phase 4: the Queue tab renders
// the REAL queue (QbzBridge.queueModel — one JSON-encoded row per QVariant,
// see the nesting POC-NOTE in playback_qt.rs `publish_queue`).
//
// POC-NOTE: the footer (count line + clear / save-as-playlist / infinite /
// sleep-timer actions + inline queue search) is omitted — in the Slint
// panel it only mounts when the queue is non-empty, which is never until
// phase 4. Drag-reorder, pagination, the now-playing favorite toggle and
// the sleep-timer popover are likewise phase-4 scope.
// POC-NOTEs:
// - The footer (count line + clear / save-as-playlist / infinite /
// sleep-timer actions + inline queue search) is omitted — phase 4 brings
// no queue mutations beyond play/next/previous.
// - The History tab stays empty (no local play-history store in the POC).
// - Row context menus, drag-reorder, pagination and the now-playing
// favorite toggle are out of scope.
import QtQuick
import com.blitzfc.qbz
@@ -21,7 +24,17 @@ Rectangle {
// Queue (0) / History (1) — Slint QueueState.tab.
property int tab: 0
readonly property bool queueEmpty: QbzBridge.queueModel.length === 0
// Parsed queue rows: [{id, title, artist, duration, artPath, current}].
readonly property var rows: {
var out = []
for (var i = 0; i < QbzBridge.queueModel.length; i++) {
try { out.push(JSON.parse(QbzBridge.queueModel[i])) } catch (e) {}
}
return out
}
readonly property var currentRow: rows.length > 0 && rows[0].current ? rows[0] : null
readonly property var upcomingRows: currentRow ? rows.slice(1) : rows
readonly property bool queueEmpty: rows.length === 0
QbzTheme { id: theme }
@@ -47,6 +60,87 @@ Rectangle {
}
}
// One queue row (QueueRow.slint): index or thumbnail, title, artist,
// duration.
component QueueRow: Rectangle {
property var row: ({})
property int rowIndex: 0
property bool showNumber: true
height: 44
radius: theme.radiusSm
color: qrArea.containsMouse ? theme.surfaceHover
: (rowIndex % 2 === 1 ? "#592a2a2a" : "transparent")
Row {
anchors.fill: parent
anchors.leftMargin: theme.spacingSm
anchors.rightMargin: theme.spacingXs
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
Text {
visible: showNumber
width: 22
anchors.verticalCenter: parent.verticalCenter
text: rowIndex + 1
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
}
Rectangle {
visible: !showNumber
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceElevated
clip: true
Image {
anchors.fill: parent
source: row.artPath
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - (showNumber ? 22 : 34) - 9 * 2 - durationText.implicitWidth
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.title
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: row.artist
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
Text {
id: durationText
anchors.verticalCenter: parent.verticalCenter
text: row.duration
color: theme.textMuted
font.pixelSize: 11
}
}
MouseArea {
id: qrArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// POC-NOTE: click-to-play / context menu out of scope.
}
}
Column {
anchors.fill: parent
spacing: 0
@@ -121,58 +215,128 @@ Rectangle {
height: parent.height - 45
// ===== Queue tab =====
Column {
Flickable {
visible: root.tab === 0
anchors.fill: parent
anchors.margins: 10
spacing: 12
clip: true
contentWidth: width
contentHeight: queueBody.implicitHeight
boundsBehavior: Flickable.StopAtBounds
// NOW PLAYING section — mounts only with a current track.
Text {
visible: !root.queueEmpty
text: QbzBridge.tr("NOW PLAYING")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// UP NEXT section — mounts only with upcoming tracks.
Text {
visible: !root.queueEmpty
text: QbzBridge.tr("UP NEXT")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: QbzBridge.queueModel
// Phase 4 renders real rows (44px QueueRow); unreachable
// while the model is empty.
delegate: Item {}
}
// Empty state — no current track and no upcoming.
Column {
visible: root.queueEmpty
id: queueBody
width: parent.width
topPadding: 48 - 10
spacing: 6
Text {
width: parent.width
text: QbzBridge.tr("Your queue is empty")
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
padding: 10
spacing: 12
// NOW PLAYING section.
Column {
visible: root.currentRow !== null
width: parent.width - 20
spacing: 10
Text {
text: QbzBridge.tr("NOW PLAYING")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// Highlighted current-track card.
Rectangle {
width: parent.width
height: 44
radius: theme.radiusSm
color: theme.surfaceElevated
Row {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
anchors.topMargin: 4
anchors.bottomMargin: 4
spacing: 9
Rectangle {
width: 34
height: 34
radius: 4
anchors.verticalCenter: parent.verticalCenter
color: theme.surfaceCard
clip: true
Image {
anchors.fill: parent
source: root.currentRow ? root.currentRow.artPath : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
}
Column {
width: parent.width - 34 - 9
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: root.currentRow ? root.currentRow.title : ""
color: theme.textPrimary
font.pixelSize: 12
font.weight: theme.weightMedium
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.currentRow ? root.currentRow.artist : ""
color: theme.textMuted
font.pixelSize: 10
elide: Text.ElideRight
}
}
}
}
}
Text {
width: parent.width
text: QbzBridge.tr("Play an album or track to get started")
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
// UP NEXT section.
Column {
visible: root.upcomingRows.length > 0
width: parent.width - 20
spacing: 8
Text {
text: QbzBridge.tr("UP NEXT")
color: theme.textMuted
font.pixelSize: 11
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
Repeater {
model: root.upcomingRows
delegate: QueueRow {
width: parent ? parent.width : 0
row: modelData
rowIndex: index
showNumber: true
}
}
}
// Empty state — no current track and no upcoming.
Column {
visible: root.queueEmpty
width: parent.width - 20
topPadding: 48 - 10
spacing: 6
Text {
width: parent.width
text: QbzBridge.tr("Your queue is empty")
color: theme.textSecondary
font.pixelSize: theme.fontBody
font.weight: theme.weightMedium
horizontalAlignment: Text.AlignHCenter
}
Text {
width: parent.width
text: QbzBridge.tr("Play an album or track to get started")
color: theme.textMuted
font.pixelSize: 12
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
}
}
}
}
@@ -191,7 +355,8 @@ Rectangle {
font.weight: theme.weightSemibold
font.letterSpacing: 0.5
}
// POC-NOTE: history is always empty until phase 4.
// POC-NOTE: history is always empty until the local
// play-history store is wired.
Text {
width: parent.width
topPadding: 48 - 10
+39
View File
@@ -109,3 +109,42 @@ pub async fn download_missing(urls: Vec<String>) {
let _ = handle.await;
}
}
// ---------------------------------------------------------------------------
// Now-playing artwork (phase 4)
// ---------------------------------------------------------------------------
/// `file://<cached path>` when the url is already on disk ("" otherwise) —
/// synchronous, no download. Used for queue rows.
pub fn cached_path(url: &str) -> String {
if url.is_empty() {
return String::new();
}
cache()
.and_then(|c| c.lock().unwrap().get(url))
.map(|p| format!("file://{}", p.display()))
.unwrap_or_default()
}
/// Attach the current track's artwork to the NowPlayingModel: disk hit
/// applies immediately; a miss downloads in the background and then
/// republishes (mirrors `attach_cached` + `download_missing` for one url).
pub fn attach_now_playing(url: &str) {
if url.is_empty() {
crate::now_playing::set_artwork_path(String::new());
return;
}
let hit = cached_path(url);
if !hit.is_empty() {
crate::now_playing::set_artwork_path(hit);
return;
}
let url = url.to_string();
crate::spawn(async move {
download_missing(vec![url.clone()]).await;
let path = cached_path(&url);
if !path.is_empty() {
crate::now_playing::set_artwork_path(path);
}
});
}
+17 -8
View File
@@ -178,6 +178,11 @@ pub mod qbz_bridge {
/// index + rails and republish `homeSectionsJson`.
#[qinvokable]
fn reload_home(self: Pin<&mut QbzBridge>);
/// Album-card click on Home: resolve the album's tracks, enqueue,
/// and play through the core's resolved path.
#[qinvokable]
fn play_album(self: Pin<&mut QbzBridge>, album_id: QString);
}
impl cxx_qt::Threading for QbzBridge {}
@@ -340,38 +345,42 @@ impl qbz_bridge::QbzBridge {
}
pub fn toggle_play(self: Pin<&mut Self>) {
crate::now_playing::toggle_play();
crate::transport_toggle_play();
}
pub fn next(self: Pin<&mut Self>) {
crate::now_playing::next();
crate::transport_next();
}
pub fn previous(self: Pin<&mut Self>) {
crate::now_playing::previous();
crate::transport_previous();
}
pub fn seek(self: Pin<&mut Self>, frac: f32) {
crate::now_playing::seek(frac);
crate::transport_seek(frac);
}
pub fn set_volume(self: Pin<&mut Self>, volume: f32) {
crate::now_playing::set_volume(volume);
crate::transport_set_volume(volume);
}
pub fn toggle_mute(self: Pin<&mut Self>) {
crate::now_playing::toggle_mute();
crate::transport_toggle_mute();
}
pub fn toggle_shuffle(self: Pin<&mut Self>) {
crate::now_playing::toggle_shuffle();
crate::transport_toggle_shuffle();
}
pub fn cycle_repeat(self: Pin<&mut Self>) {
crate::now_playing::cycle_repeat();
crate::transport_cycle_repeat();
}
pub fn reload_home(self: Pin<&mut Self>) {
crate::reload_home();
}
pub fn play_album(self: Pin<&mut Self>, album_id: QString) {
crate::play_album(album_id.to_string());
}
}
+74
View File
@@ -13,6 +13,7 @@ mod home_qt;
mod nav_qt;
mod now_playing;
mod offline_fwd;
mod playback_qt;
use std::pin::Pin;
use std::sync::{Arc, Mutex, OnceLock};
@@ -118,6 +119,8 @@ fn enter_shell(session: auth_qt::SessionInfo) {
// engine gates Qobuz calls anyway, and the view shows the offline
// placeholder instead).
load_home_once();
// Phase 4: the 1 Hz playback state pump (idempotent).
playback_qt::start_poll_loop(app());
ui(move |mut b| {
b.as_mut().set_session_user_name(QString::from(session.display_name.as_str()));
b.as_mut().set_session_subscription(QString::from(session.subscription.as_str()));
@@ -246,6 +249,60 @@ fn load_home_once() {
reload_home();
}
// ============================ Playback (phase 4) ===========================
/// Album-card click on Home: resolve + enqueue + play through the core.
pub(crate) fn play_album(album_id: String) {
let runtime = app();
spawn(async move {
if let Err(e) = playback_qt::play_album(&runtime, &album_id).await {
log::error!("[qbz-qt] play_album failed: {e}");
}
});
}
pub(crate) fn transport_toggle_play() {
let runtime = app();
spawn(async move { playback_qt::toggle_play(&runtime).await });
}
pub(crate) fn transport_next() {
let runtime = app();
spawn(async move { playback_qt::next(&runtime).await });
}
pub(crate) fn transport_previous() {
let runtime = app();
spawn(async move { playback_qt::previous(&runtime).await });
}
pub(crate) fn transport_seek(frac: f32) {
let runtime = app();
spawn(async move { playback_qt::seek_frac(&runtime, frac).await });
}
pub(crate) fn transport_set_volume(volume: f32) {
// Local model first (instant UI), then the engine.
now_playing::set_volume(volume);
let runtime = app();
spawn(async move { playback_qt::set_volume(&runtime, volume).await });
}
pub(crate) fn transport_toggle_mute() {
let runtime = app();
spawn(async move { playback_qt::toggle_mute(&runtime).await });
}
pub(crate) fn transport_toggle_shuffle() {
let runtime = app();
spawn(async move { playback_qt::toggle_shuffle(&runtime).await });
}
pub(crate) fn transport_cycle_repeat() {
let runtime = app();
spawn(async move { playback_qt::cycle_repeat(&runtime).await });
}
/// `reloadHome()` invokable / auto-load worker: fetch + publish + artwork.
pub(crate) fn reload_home() {
if offline_fwd::engine().status().is_offline() {
@@ -264,6 +321,7 @@ pub(crate) fn reload_home() {
// artwork_qt.rs — per-row model updates are the follow-up).
let missing = artwork_qt::attach_cached(&mut sections);
let count: usize = sections.iter().map(|s| s.items.len()).sum();
publish_home_sections(&sections);
log::info!(
"[qbz-qt] home published: {} sections, {} cards, {} artwork misses",
@@ -280,6 +338,7 @@ pub(crate) fn reload_home() {
log::info!("[qbz-qt] home republished after artwork downloads");
});
}
ui(|mut b| b.as_mut().set_home_loading(false));
}
Err(e) => {
@@ -300,6 +359,21 @@ fn publish_home_sections(sections: &[home_qt::HomeSection]) {
});
}
/// Build the bridge's `queueModel` QVariantList from per-row JSON strings
/// (each QVariant carries one QString — see the nesting POC-NOTE in
/// playback_qt.rs `publish_queue`).
pub(crate) fn json_rows_to_qvariant_list(
rows: Vec<String>,
) -> cxx_qt_lib::QList<cxx_qt_lib::QVariant> {
let mut list = cxx_qt_lib::QList::<cxx_qt_lib::QVariant>::default();
for row in rows {
list.append(<QString as cxx_qt_lib::QVariantValue>::construct(&QString::from(
row.as_str(),
)));
}
list
}
fn main() {
qbz_log::install("info");
qbz_i18n::set_language(qbz_i18n::resolve_auto());
+120 -18
View File
@@ -21,6 +21,8 @@ pub struct NowPlayingModel {
pub duration_secs: i32,
pub playing: bool,
pub loading: bool,
/// Streaming buffer fill 0..1 (seekbar cache overlay); 0 = not streaming.
pub cache: f32,
pub volume: f32,
pub muted: bool,
pub shuffle: bool,
@@ -43,6 +45,7 @@ impl Default for NowPlayingModel {
duration_secs: 0,
playing: false,
loading: false,
cache: 0.0,
volume: 1.0,
muted: false,
shuffle: false,
@@ -62,6 +65,7 @@ static MODEL: Mutex<NowPlayingModel> = Mutex::new(NowPlayingModel {
duration_secs: 0,
playing: false,
loading: false,
cache: 0.0,
volume: 1.0,
muted: false,
shuffle: false,
@@ -127,34 +131,132 @@ pub fn set_volume(volume: f32) {
});
}
pub fn toggle_mute() {
mutate(|m| m.muted = !m.muted);
pub fn set_muted(muted: bool) {
mutate(|m| m.muted = muted);
}
pub fn toggle_shuffle() {
mutate(|m| m.shuffle = !m.shuffle);
pub fn set_shuffle(shuffle: bool) {
mutate(|m| m.shuffle = shuffle);
}
pub fn cycle_repeat() {
mutate(|m| m.repeat_mode = (m.repeat_mode + 1) % 3);
pub fn set_repeat_mode(mode: i32) {
mutate(|m| m.repeat_mode = mode);
}
// --- Transport (log-and-noop until phase 4) ------------------------------
pub fn toggle_play() {
// POC-NOTE: no player wired — phase 4 replaces this with the core
// playback command through AppRuntime.
log::info!("[qbz-qt] transport toggle-play (no-op until phase 4)");
pub fn repeat_mode() -> i32 {
MODEL.lock().unwrap().repeat_mode
}
pub fn next() {
log::info!("[qbz-qt] transport next (no-op until phase 4)");
pub fn set_playing(playing: bool) {
mutate(|m| m.playing = playing);
}
pub fn previous() {
log::info!("[qbz-qt] transport previous (no-op until phase 4)");
// --- Track meta + position (phase 4: fed by the poll pump) ---------------
/// Current-track metadata as published by the playback controller.
pub struct TrackMeta {
pub title: String,
pub artist: String,
pub duration_secs: i32,
pub quality_tier: String,
pub quality_label: String,
pub artwork_url: String,
pub shuffle: bool,
pub repeat_mode: i32,
}
pub fn seek(frac: f32) {
log::info!("[qbz-qt] transport seek({frac:.3}) (no-op until phase 4)");
/// A new current track: full meta swap (art path attaches separately via
/// the artwork pipeline — see `artwork_qt::attach_now_playing`).
pub fn set_track(meta: TrackMeta) {
mutate(|m| {
m.has_track = true;
m.title = meta.title;
m.artist = meta.artist;
m.duration_secs = meta.duration_secs;
m.elapsed_secs = 0;
m.quality_tier = meta.quality_tier;
m.quality_label = meta.quality_label;
m.artwork_path = String::new();
m.loading = true;
m.playing = true;
m.shuffle = meta.shuffle;
m.repeat_mode = meta.repeat_mode;
});
// Stash the url out-of-band so the artwork pipeline can resolve it
// without a second full publish.
*ARTWORK_URL.lock().unwrap() = meta.artwork_url;
}
/// No current track (queue cleared / finished): back to the idle bar.
pub fn clear_track() {
mutate(|m| {
*m = NowPlayingModel {
volume: m.volume,
muted: m.muted,
..NowPlayingModel::default()
};
});
}
/// The artwork url of the current track, stashed for the artwork pipeline.
static ARTWORK_URL: Mutex<String> = Mutex::new(String::new());
/// Attach a resolved artwork path to the current track (artwork pipeline).
pub fn set_artwork_path(path: String) {
mutate(|m| m.artwork_path = path);
}
/// 1 Hz position push from the poll pump. `has_audio` = the engine
/// surfaced a track id (clears the loading spinner).
pub fn set_position(elapsed_secs: i32, duration_secs: i32, playing: bool, cache: f32, has_audio: bool) {
let mut guard = MODEL.lock().unwrap();
{
let m = &mut *guard;
m.elapsed_secs = elapsed_secs;
if duration_secs > 0 {
m.duration_secs = duration_secs;
}
m.playing = playing;
if has_audio {
m.loading = false;
}
// progress is derived in publish() from elapsed/duration; cache is
// stored raw for the seekbar's buffer overlay.
m.cache = cache;
}
let m = guard.clone();
drop(guard);
publish_with_cache(&m);
}
/// Same as `publish` but honors the poll-fed cache value instead of
/// mirroring progress.
fn publish_with_cache(m: &NowPlayingModel) {
let m = m.clone();
crate::ui(move |mut b| {
let progress = if m.duration_secs > 0 {
(m.elapsed_secs as f32 / m.duration_secs as f32).clamp(0.0, 1.0)
} else {
0.0
};
b.as_mut().set_np_has_track(m.has_track);
b.as_mut().set_np_title(QString::from(m.title.as_str()));
b.as_mut().set_np_artist(QString::from(m.artist.as_str()));
b.as_mut()
.set_np_artwork_path(QString::from(m.artwork_path.as_str()));
b.as_mut().set_np_elapsed_secs(m.elapsed_secs);
b.as_mut().set_np_duration_secs(m.duration_secs);
b.as_mut().set_np_progress(progress);
b.as_mut().set_np_cache_progress(m.cache);
b.as_mut().set_np_playing(m.playing);
b.as_mut().set_np_loading(m.loading);
b.as_mut().set_np_volume(m.volume);
b.as_mut().set_np_muted(m.muted);
b.as_mut().set_np_shuffle(m.shuffle);
b.as_mut().set_np_repeat_mode(m.repeat_mode);
b.as_mut()
.set_np_quality_tier(QString::from(m.quality_tier.as_str()));
b.as_mut()
.set_np_quality_label(QString::from(m.quality_label.as_str()));
});
}
+407
View File
@@ -0,0 +1,407 @@
//! 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 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.
pub async fn play_album(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
album_id: &str,
) -> Result<(), String> {
log::info!("[qbz-qt] play_album: resolving {album_id}");
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();
let first_id = tracks[0].id;
let count = tracks.len();
runtime.core().set_queue(tracks, Some(0)).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(())
}
// ---------------------------------------------------------------------------
// 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();
return;
};
let title = match track.version.as_deref().filter(|v| !v.is_empty()) {
Some(version) => format!("{} ({version})", track.title),
None => track.title.clone(),
};
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;
}
});
}