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:
3 files changed
+100
-18
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user