fix(qbzd): use SetState.next_track for auto-advance + periodic state reports

When iOS controls via QConnect without sending a queue, the local
QbzCore queue is empty so auto-advance fails. Now we extract next_track
from each SetState command, store it in DaemonCore, and the orchestrator
uses it as a fallback when the local queue is empty.

Also adds a periodic state reporter (every 2s) so iOS knows the
renderer's actual state (playing/paused/stopped + position). Without
this iOS thinks the daemon is still playing after a track ends.

Bump version to 0.1.9.

cc
This commit is contained in:
vicrodh committed 2026-04-10 00:42:02 -06:00
1 parent 4112cc77bd
commit 64a42fd156
3 files changed
+100 -18

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "qbzd"
version = "0.1.8"
version = "0.1.9"
edition.workspace = true
license.workspace = true
description = "QBZ headless music daemon for SBCs and servers"
+24 -13
View File
@@ -24,6 +24,9 @@ pub struct DaemonCore {
pub user: RwLock<Option<UserSession>>,
/// Flag to prevent orchestrator auto-advance during explicit play commands
pub skip_auto_advance: std::sync::atomic::AtomicBool,
/// QConnect next track id (from SetState.next_track) for auto-advance
/// when iOS controls without sending a queue. 0 = no next track known.
pub qconnect_next_track_id: Arc<std::sync::atomic::AtomicU64>,
}
/// Run the daemon main loop.
@@ -78,6 +81,7 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
event_bus: event_tx.clone(),
user: RwLock::new(None),
skip_auto_advance: std::sync::atomic::AtomicBool::new(false),
qconnect_next_track_id: Arc::new(std::sync::atomic::AtomicU64::new(0)),
});
// Try auto-login from saved OAuth token
@@ -109,6 +113,7 @@ pub async fn run(mut config: DaemonConfig) -> Result<(), String> {
&core,
event_tx.clone(),
&device_name,
daemon.qconnect_next_track_id.clone(),
)
.await;
}
@@ -322,23 +327,29 @@ fn spawn_playback_orchestrator(daemon: Arc<DaemonCore>) {
let _ = player.play_data(audio_data, last_track_id);
}
} else {
// Normal advance
// Normal advance: try local queue first, then QConnect next_track fallback
let previous_id = last_track_id;
if let Some(next_track) = daemon.core.next_track().await {
// Defensive: if same track returned and not repeat-one, try one more
let final_track = if next_track.id == previous_id {
log::warn!("[qbzd/advance] Same track returned, forcing extra advance");
daemon.core.next_track().await.unwrap_or(next_track)
} else {
next_track
};
let local_next = daemon.core.next_track().await
.filter(|t| t.id != previous_id);
log::info!("[qbzd/advance] Next: {} - {}", final_track.artist, final_track.title);
if let Ok(audio_data) = download_track(&daemon, final_track.id).await {
let _ = player.play_data(audio_data, final_track.id);
if let Some(next_track) = local_next {
log::info!("[qbzd/advance] Next (local queue): {} - {}", next_track.artist, next_track.title);
if let Ok(audio_data) = download_track(&daemon, next_track.id).await {
let _ = player.play_data(audio_data, next_track.id);
}
} else {
log::info!("[qbzd/advance] Queue empty, playback stopped");
// Fallback: use next_track from last QConnect SetState
let qconnect_next = daemon.qconnect_next_track_id.load(std::sync::atomic::Ordering::Acquire);
if qconnect_next != 0 && qconnect_next != previous_id {
log::info!("[qbzd/advance] Next (QConnect fallback): {}", qconnect_next);
// Clear before downloading to prevent re-use on next iteration
daemon.qconnect_next_track_id.store(0, std::sync::atomic::Ordering::Release);
if let Ok(audio_data) = download_track(&daemon, qconnect_next).await {
let _ = player.play_data(audio_data, qconnect_next);
}
} else {
log::info!("[qbzd/advance] Queue empty, playback stopped");
}
}
}
gapless_queued_for = 0;
+75 -4
View File
@@ -35,14 +35,16 @@ type App = QconnectApp<NativeWsTransport, HeadlessQconnectSink>;
pub struct HeadlessQconnectSink {
event_tx: broadcast::Sender<DaemonEvent>,
core: Arc<qbz_core::QbzCore<DaemonAdapter>>,
qconnect_next_track_id: Arc<std::sync::atomic::AtomicU64>,
}
impl HeadlessQconnectSink {
pub fn new(
event_tx: broadcast::Sender<DaemonEvent>,
core: Arc<qbz_core::QbzCore<DaemonAdapter>>,
qconnect_next_track_id: Arc<std::sync::atomic::AtomicU64>,
) -> Self {
Self { event_tx, core }
Self { event_tx, core, qconnect_next_track_id }
}
}
@@ -58,7 +60,7 @@ impl QconnectEventSink for HeadlessQconnectSink {
}
QconnectAppEvent::RendererCommandApplied { command, .. } => {
log::info!("[qbzd/qconnect] Command: {:?}", command);
handle_renderer_command(&self.core, command).await;
handle_renderer_command(&self.core, command, &self.qconnect_next_track_id).await;
}
QconnectAppEvent::QueueUpdated(queue_state) => {
log::info!("[qbzd/qconnect] Queue: {} items", queue_state.queue_items.len());
@@ -77,9 +79,16 @@ impl QconnectEventSink for HeadlessQconnectSink {
async fn handle_renderer_command(
core: &qbz_core::QbzCore<DaemonAdapter>,
command: &RendererCommand,
qconnect_next_track_id: &Arc<std::sync::atomic::AtomicU64>,
) {
match command {
RendererCommand::SetState { playing_state, current_position_ms, current_track, .. } => {
RendererCommand::SetState { playing_state, current_position_ms, current_track, next_track } => {
// Track the next_track for orchestrator's auto-advance fallback
if let Some(nt) = next_track {
qconnect_next_track_id.store(nt.track_id, std::sync::atomic::Ordering::Release);
log::info!("[qbzd/qconnect] Stored next_track for auto-advance: {}", nt.track_id);
}
// Step 1: If a current_track is specified, ensure it's loaded
// (matches desktop's ensure_remote_track_loaded pattern)
if let Some(track) = current_track {
@@ -307,6 +316,7 @@ pub async fn start_qconnect(
core: &Arc<qbz_core::QbzCore<DaemonAdapter>>,
event_tx: broadcast::Sender<DaemonEvent>,
device_name: &str,
qconnect_next_track_id: Arc<std::sync::atomic::AtomicU64>,
) -> Option<Arc<App>> {
// Step 1: Get credentials
let client_arc = core.client();
@@ -317,7 +327,7 @@ pub async fn start_qconnect(
// Step 2: Create transport + sink + app
let transport = Arc::new(NativeWsTransport::new());
let sink = Arc::new(HeadlessQconnectSink::new(event_tx, core.clone()));
let sink = Arc::new(HeadlessQconnectSink::new(event_tx, core.clone(), qconnect_next_track_id));
let app = Arc::new(QconnectApp::new(transport, sink));
// Step 3: Connect transport
@@ -420,10 +430,71 @@ pub async fn start_qconnect(
return None;
}
// Step 7: Spawn periodic state reporter so iOS knows the renderer's state
spawn_state_reporter(app.clone(), core.clone());
log::info!("[qbzd/qconnect] Started as '{}'", device_name);
Some(app)
}
/// Periodic renderer state reporter — keeps iOS in sync with daemon playback.
/// Reports every 2 seconds, and immediately on track end / state change.
fn spawn_state_reporter(app: Arc<App>, core: Arc<qbz_core::QbzCore<DaemonAdapter>>) {
tokio::spawn(async move {
let mut last_playing_state: i32 = 0;
let mut last_track_id: u64 = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let player = core.player();
let state = &player.state;
let track_id = state.current_track_id();
let is_playing = state.is_playing();
let position = state.current_position();
let duration = state.duration();
let playing_state = if is_playing {
PLAYING_STATE_PLAYING
} else if track_id != 0 {
PLAYING_STATE_PAUSED
} else {
PLAYING_STATE_STOPPED
};
let state_changed = playing_state != last_playing_state || track_id != last_track_id;
last_playing_state = playing_state;
last_track_id = track_id;
let queue_version = app.queue_state_snapshot().await.version;
let payload = serde_json::json!({
"playing_state": playing_state,
"buffer_state": BUFFER_STATE_OK,
"current_position": position * 1000, // ms
"duration": duration * 1000, // ms
"queue_version": {
"major": queue_version.major,
"minor": queue_version.minor
}
});
let report = RendererReport::new(
RendererReportType::RndrSrvrStateUpdated,
Uuid::new_v4().to_string(),
queue_version,
payload,
);
if let Err(e) = app.send_renderer_report_command(report).await {
log::debug!("[qbzd/qconnect] State report failed: {}", e);
} else if state_changed {
log::info!(
"[qbzd/qconnect] State report sent: playing_state={} track={} pos={}s",
playing_state, track_id, position
);
}
}
});
}
/// Bootstrap: controller JoinSession + AskForQueueState.
/// Exact replica of desktop's bootstrap_remote_presence (lines 3801-3846).
async fn bootstrap_remote_presence(app: &Arc<App>, device_name: &str) -> Result<(), String> {