diff --git a/crates/qconnect-protocol/src/decoder.rs b/crates/qconnect-protocol/src/decoder.rs index 223d3070c..06d8028a5 100644 --- a/crates/qconnect-protocol/src/decoder.rs +++ b/crates/qconnect-protocol/src/decoder.rs @@ -1290,4 +1290,30 @@ mod tests { ); assert_eq!(commands[0].payload["value"], true); } + + #[test] + fn decodes_official_renderer_state_updated_fixed64_timestamp_frame() { + let raw_message = vec![ + 0x08, 0x17, 0xba, 0x01, 0x21, 0x0a, 0x1f, 0x08, 0x02, 0x10, 0x02, 0x1a, 0x0d, + 0x09, 0xe1, 0x08, 0x7e, 0xe5, 0x9c, 0x01, 0x00, 0x00, 0x10, 0xa1, 0xec, 0x01, + 0x20, 0xa8, 0x9e, 0x14, 0x2a, 0x04, 0x08, 0x09, 0x10, 0x02, 0x38, 0x0c, + ]; + + let message = QConnectMessage::decode(raw_message.as_slice()).expect("decode message"); + let state = message + .rndr_srvr_state_updated + .as_ref() + .and_then(|payload| payload.state.as_ref()) + .expect("renderer state"); + let position = state.current_position.as_ref().expect("playback position"); + + assert_eq!( + message.message_type, + Some(QConnectMessageType::MessageTypeRndrSrvrStateUpdated as i32) + ); + assert_eq!(position.timestamp, Some(1_773_376_768_225)); + assert_eq!(position.value, Some(30_241)); + assert_eq!(state.current_queue_item_id, None); + assert_eq!(state.next_queue_item_id, Some(12)); + } } diff --git a/crates/qconnect-protocol/src/queue_command_proto.rs b/crates/qconnect-protocol/src/queue_command_proto.rs index b7d4eef7a..f1f851a19 100644 --- a/crates/qconnect-protocol/src/queue_command_proto.rs +++ b/crates/qconnect-protocol/src/queue_command_proto.rs @@ -661,7 +661,7 @@ pub struct RendererMuteVolumeMessage { #[derive(Clone, PartialEq, ::prost::Message)] pub struct PlaybackPositionMessage { - #[prost(uint64, optional, tag = "1")] + #[prost(fixed64, optional, tag = "1")] pub timestamp: Option, #[prost(int32, optional, tag = "2")] pub value: Option, diff --git a/src-tauri/src/qconnect_service.rs b/src-tauri/src/qconnect_service.rs index e29ffb7d5..2cec4be57 100644 --- a/src-tauri/src/qconnect_service.rs +++ b/src-tauri/src/qconnect_service.rs @@ -15,7 +15,7 @@ use qconnect_transport_ws::{NativeWsTransport, WsTransportConfig}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::async_runtime::JoinHandle; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::{Mutex, RwLock}; use uuid::Uuid; @@ -39,8 +39,13 @@ const DEFAULT_QCONNECT_DEVICE_TYPE: i32 = 5; // computer const DEFAULT_QCONNECT_SOFTWARE_PREFIX: &str = "qbz"; const QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote"; // AudioQuality enum: 0=unknown, 1=mp3, 2=cd, 3=hires_l1, 4=hires_l2(192k), 5=hires_l3(384k) +const AUDIO_QUALITY_UNKNOWN: i32 = 0; const AUDIO_QUALITY_MP3: i32 = 1; +const AUDIO_QUALITY_CD: i32 = 2; +const AUDIO_QUALITY_HIRES_LEVEL1: i32 = 3; const AUDIO_QUALITY_HIRES_LEVEL2: i32 = 4; +const AUDIO_QUALITY_HIRES_LEVEL3: i32 = 5; +const DEFAULT_QCONNECT_CHANNEL_COUNT: i32 = 2; // VolumeRemoteControl enum: 0=unknown, 1=not_allowed, 2=allowed const VOLUME_REMOTE_CONTROL_ALLOWED: i32 = 2; // JoinSessionReason: 0=unknown, 1=controller_request, 2=reconnection @@ -304,11 +309,20 @@ struct QconnectRemoteSyncState { last_renderer_track_id: Option, last_renderer_next_track_id: Option, last_renderer_playing_state: Option, + last_reported_file_audio_quality: Option, last_applied_queue_version: Option<(u64, u64)>, /// Session topology — stored from session management events (types 81-87). session: QconnectSessionState, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct QconnectFileAudioQualitySnapshot { + sampling_rate: i32, + bit_depth: i32, + nb_channels: i32, + audio_quality: i32, +} + fn queue_payload_track_preview(payload: &Value, key: &str) -> Vec { payload .get(key) @@ -819,6 +833,47 @@ impl QconnectServiceState { .map_err(|err| format!("send renderer report failed: {err}")) } + async fn report_file_audio_quality_if_changed( + &self, + queue_version: qconnect_app::QueueVersion, + audio_quality: QconnectFileAudioQualitySnapshot, + ) -> Result { + let (app, sync_state) = { + let guard = self.inner.lock().await; + let Some(runtime) = guard.runtime.as_ref() else { + return Err("QConnect service is not running".to_string()); + }; + (Arc::clone(&runtime.app), Arc::clone(&runtime.sync_state)) + }; + + { + let state = sync_state.lock().await; + if state.last_reported_file_audio_quality == Some(audio_quality) { + return Ok(false); + } + } + + let report = RendererReport::new( + RendererReportType::RndrSrvrFileAudioQualityChanged, + Uuid::new_v4().to_string(), + queue_version, + serde_json::json!({ + "sampling_rate": audio_quality.sampling_rate, + "bit_depth": audio_quality.bit_depth, + "nb_channels": audio_quality.nb_channels, + "audio_quality": audio_quality.audio_quality + }), + ); + + app.send_renderer_report_command(report) + .await + .map_err(|err| format!("send file audio quality report failed: {err}"))?; + + let mut state = sync_state.lock().await; + state.last_reported_file_audio_quality = Some(audio_quality); + Ok(true) + } + /// Update the renderer's internal position from the frontend's actual playback position. /// This keeps the QConnect app's renderer state in sync with audio playback so that /// subsequent renderer reports (e.g. after pause/resume) include the correct position. @@ -923,7 +978,7 @@ fn resolve_queue_item_ids_from_queue_state( .iter() .position(|item| item.track_id == track_id) { - let current_item = &queue.queue_items[current_index]; + let current_qid = normalize_current_queue_item_id_from_queue_state(queue, current_index); let next_item = if queue.shuffle_mode { queue .shuffle_order @@ -945,7 +1000,7 @@ fn resolve_queue_item_ids_from_queue_state( }; return ( - Some(current_item.queue_item_id), + Some(current_qid), next_item.map(|item| item.queue_item_id), next_item.map(|item| item.track_id), ); @@ -1402,6 +1457,90 @@ fn normalize_volume_to_fraction(volume: i32) -> f32 { volume.clamp(0, 100) as f32 / 100.0 } +fn is_cloud_placeholder_current_queue_item( + queue: &QConnectQueueState, + current_index: usize, +) -> bool { + let Some(current_item) = queue.queue_items.get(current_index) else { + return false; + }; + + current_index == 0 + && current_item.queue_item_id == current_item.track_id + && queue + .queue_items + .iter() + .skip(1) + .any(|item| item.queue_item_id < current_item.queue_item_id) +} + +fn normalize_current_queue_item_id_from_queue_state( + queue: &QConnectQueueState, + current_index: usize, +) -> u64 { + if is_cloud_placeholder_current_queue_item(queue, current_index) { + 0 + } else { + queue.queue_items[current_index].queue_item_id + } +} + +fn classify_qconnect_audio_quality(sample_rate: u32, bit_depth: u32) -> i32 { + if sample_rate == 0 || bit_depth == 0 { + AUDIO_QUALITY_UNKNOWN + } else if sample_rate >= 384_000 { + AUDIO_QUALITY_HIRES_LEVEL3 + } else if sample_rate >= 192_000 { + AUDIO_QUALITY_HIRES_LEVEL2 + } else if bit_depth > 16 || sample_rate > 48_000 { + AUDIO_QUALITY_HIRES_LEVEL1 + } else if sample_rate >= 44_100 { + AUDIO_QUALITY_CD + } else { + AUDIO_QUALITY_MP3 + } +} + +fn build_qconnect_file_audio_quality_snapshot( + sample_rate: u32, + bit_depth: u32, + nb_channels: i32, +) -> Option { + if sample_rate == 0 || bit_depth == 0 { + return None; + } + + Some(QconnectFileAudioQualitySnapshot { + sampling_rate: sample_rate as i32, + bit_depth: bit_depth as i32, + nb_channels, + audio_quality: classify_qconnect_audio_quality(sample_rate, bit_depth), + }) +} + +async fn resolve_active_playback_audio_quality( + app_handle: &AppHandle, +) -> Option { + if let Some(bridge_state) = app_handle.try_state::() { + if let Some(bridge) = bridge_state.try_get().await { + if let Some(snapshot) = build_qconnect_file_audio_quality_snapshot( + bridge.player().state.get_sample_rate(), + bridge.player().state.get_bit_depth(), + DEFAULT_QCONNECT_CHANNEL_COUNT, + ) { + return Some(snapshot); + } + } + } + + let app_state = app_handle.try_state::()?; + build_qconnect_file_audio_quality_snapshot( + app_state.player.state.get_sample_rate(), + app_state.player.state.get_bit_depth(), + DEFAULT_QCONNECT_CHANNEL_COUNT, + ) +} + async fn bootstrap_remote_presence( app: &Arc>, ) -> Result<(), String> { @@ -2057,6 +2196,15 @@ pub async fn v2_qconnect_report_playback_state( log::warn!("[QConnect] Failed to report playback state: {err}"); } + if let Some(audio_quality) = resolve_active_playback_audio_quality(&app_handle).await { + if let Err(err) = service + .report_file_audio_quality_if_changed(queue_version, audio_quality) + .await + { + log::warn!("[QConnect] Failed to report file audio quality: {err}"); + } + } + if let Err(err) = app_handle.emit( "qconnect:renderer_report_debug", &QconnectRendererReportDebugEvent { @@ -2380,6 +2528,9 @@ fn decode_hex_channel(raw: &str) -> Result, String> { #[cfg(test)] mod tests { use super::{ + QconnectFileAudioQualitySnapshot, + AUDIO_QUALITY_HIRES_LEVEL1, + build_qconnect_file_audio_quality_snapshot, classify_qconnect_audio_quality, decode_hex_channel, determine_queue_lookup_report_strategy, normalize_volume_to_fraction, parse_subscribe_channels, resolve_queue_item_ids_from_queue_state, @@ -2518,7 +2669,7 @@ mod tests { Some(123452387), Some(123452387_i32), Some(1), - Some(123452387_i32), + Some(0), Some(12), ), Some("queue_lookup_queue_drift"), @@ -2565,6 +2716,30 @@ mod tests { ); } + #[test] + fn normalizes_placeholder_current_queue_item_id_to_zero() { + let queue: QConnectQueueState = serde_json::from_value(json!({ + "version": { "major": 8, "minor": 2 }, + "queue_items": [ + { "track_context_uuid": "ctx", "track_id": 126886853, "queue_item_id": 126886853 }, + { "track_context_uuid": "ctx", "track_id": 123452387, "queue_item_id": 10 }, + { "track_context_uuid": "ctx", "track_id": 126886854, "queue_item_id": 1 } + ], + "shuffle_mode": false, + "shuffle_order": null, + "autoplay_mode": false, + "autoplay_loading": false, + "autoplay_items": [], + "updated_at_ms": 0 + })) + .expect("queue state"); + + assert_eq!( + resolve_queue_item_ids_from_queue_state(&queue, 126886853), + (Some(0), Some(10), Some(123452387)), + ); + } + #[test] fn resolves_next_queue_item_id_from_shuffle_order() { let queue: QConnectQueueState = serde_json::from_value(json!({ @@ -2588,4 +2763,18 @@ mod tests { (Some(100), Some(200), Some(20)), ); } + + #[test] + fn classifies_24_bit_streams_as_hires_level1() { + assert_eq!(classify_qconnect_audio_quality(44_100, 24), AUDIO_QUALITY_HIRES_LEVEL1); + assert_eq!( + build_qconnect_file_audio_quality_snapshot(96_000, 24, 2), + Some(QconnectFileAudioQualitySnapshot { + sampling_rate: 96_000, + bit_depth: 24, + nb_channels: 2, + audio_quality: AUDIO_QUALITY_HIRES_LEVEL1, + }), + ); + } } diff --git a/src/lib/services/qconnectRemoteQueue.test.ts b/src/lib/services/qconnectRemoteQueue.test.ts index b06ddfacc..850d30543 100644 --- a/src/lib/services/qconnectRemoteQueue.test.ts +++ b/src/lib/services/qconnectRemoteQueue.test.ts @@ -55,6 +55,23 @@ describe('resolveQconnectPlayNextInsertAfter', () => { }); }); + it('normalizes the placeholder first queue item id to zero for play next', () => { + const queueSnapshot = buildQueueSnapshot([126886853, 123452387, 126886854], [126886853, 10, 1]); + const rendererSnapshot: QconnectRendererSnapshot = { + current_track: { track_id: 126886853, queue_item_id: 126886853 }, + next_track: { track_id: 126886854, queue_item_id: 1 } + }; + + expect(resolveQconnectPlayNextInsertAfter(queueSnapshot, rendererSnapshot)).toEqual({ + insertAfter: 0, + strategy: 'renderer_current_queue_item_id_verified', + queueIndex: 0, + nextQueueIndex: 2, + matchedTrackId: 126886853, + matchedQueueItemId: 0 + }); + }); + it('prefers the authoritative current track when renderer current is stale but still present in queue', () => { const queueSnapshot = buildQueueSnapshot([123452387, 123452388, 123452389], [123452387, 1, 2]); const rendererSnapshot: QconnectRendererSnapshot = { diff --git a/src/lib/services/qconnectRemoteQueue.ts b/src/lib/services/qconnectRemoteQueue.ts index 54d0405ca..4b86be265 100644 --- a/src/lib/services/qconnectRemoteQueue.ts +++ b/src/lib/services/qconnectRemoteQueue.ts @@ -108,6 +108,28 @@ function findTrackIndex( return index >= 0 ? index : null; } +function isCloudPlaceholderCurrentQueueItem( + queueItems: QconnectQueueItemSnapshot[], + queueIndex: number | null +): queueIndex is number { + if (queueIndex !== 0) return false; + + const currentItem = queueItems[queueIndex]; + if (!currentItem) return false; + if (currentItem.queue_item_id !== currentItem.track_id) return false; + + return queueItems.slice(1).some((item) => isNonNegativeNumber(item.queue_item_id) && item.queue_item_id < currentItem.queue_item_id); +} + +function resolvedQueueItemId( + queueItems: QconnectQueueItemSnapshot[], + queueIndex: number +): number { + return isCloudPlaceholderCurrentQueueItem(queueItems, queueIndex) + ? 0 + : queueItems[queueIndex].queue_item_id; +} + export function resolveQconnectPlayNextInsertAfter( queueSnapshot: QconnectQueueSnapshot | null | undefined, rendererSnapshot: QconnectRendererSnapshot | null | undefined, @@ -139,35 +161,38 @@ export function resolveQconnectPlayNextInsertAfter( && authoritativeCurrentTrackId !== currentTrack?.track_id; if (authoritativeTrackDisagreesWithRenderer && authoritativeTrackIndexBeforeNext !== null) { + const queueItemId = resolvedQueueItemId(queueItems, authoritativeTrackIndexBeforeNext); return { - insertAfter: queueItems[authoritativeTrackIndexBeforeNext].queue_item_id, + insertAfter: queueItemId, strategy: 'authoritative_track_id_before_renderer_next', queueIndex: authoritativeTrackIndexBeforeNext, nextQueueIndex, matchedTrackId: queueItems[authoritativeTrackIndexBeforeNext].track_id, - matchedQueueItemId: queueItems[authoritativeTrackIndexBeforeNext].queue_item_id + matchedQueueItemId: queueItemId }; } if (authoritativeTrackDisagreesWithRenderer && authoritativeCurrentTrackIndex !== null) { + const queueItemId = resolvedQueueItemId(queueItems, authoritativeCurrentTrackIndex); return { - insertAfter: queueItems[authoritativeCurrentTrackIndex].queue_item_id, + insertAfter: queueItemId, strategy: 'authoritative_track_id_match', queueIndex: authoritativeCurrentTrackIndex, nextQueueIndex, matchedTrackId: queueItems[authoritativeCurrentTrackIndex].track_id, - matchedQueueItemId: queueItems[authoritativeCurrentTrackIndex].queue_item_id + matchedQueueItemId: queueItemId }; } if (currentQueueIndex !== null) { + const queueItemId = resolvedQueueItemId(queueItems, currentQueueIndex); return { - insertAfter: queueItems[currentQueueIndex].queue_item_id, + insertAfter: queueItemId, strategy: 'renderer_current_queue_item_id_verified', queueIndex: currentQueueIndex, nextQueueIndex, matchedTrackId: queueItems[currentQueueIndex].track_id, - matchedQueueItemId: queueItems[currentQueueIndex].queue_item_id + matchedQueueItemId: queueItemId }; } @@ -175,37 +200,40 @@ export function resolveQconnectPlayNextInsertAfter( ? findTrackIndexBefore(queueItems, currentTrack?.track_id, nextQueueIndex) : null; if (trackIndexBeforeNext !== null) { + const queueItemId = resolvedQueueItemId(queueItems, trackIndexBeforeNext); return { - insertAfter: queueItems[trackIndexBeforeNext].queue_item_id, + insertAfter: queueItemId, strategy: 'queue_track_id_before_renderer_next', queueIndex: trackIndexBeforeNext, nextQueueIndex, matchedTrackId: queueItems[trackIndexBeforeNext].track_id, - matchedQueueItemId: queueItems[trackIndexBeforeNext].queue_item_id + matchedQueueItemId: queueItemId }; } const currentTrackIndex = findTrackIndex(queueItems, currentTrack?.track_id); if (currentTrackIndex !== null) { + const queueItemId = resolvedQueueItemId(queueItems, currentTrackIndex); return { - insertAfter: queueItems[currentTrackIndex].queue_item_id, + insertAfter: queueItemId, strategy: 'queue_track_id_match', queueIndex: currentTrackIndex, nextQueueIndex, matchedTrackId: queueItems[currentTrackIndex].track_id, - matchedQueueItemId: queueItems[currentTrackIndex].queue_item_id + matchedQueueItemId: queueItemId }; } if (nextQueueIndex !== null && nextQueueIndex > 0) { const fallbackIndex = nextQueueIndex - 1; + const queueItemId = resolvedQueueItemId(queueItems, fallbackIndex); return { - insertAfter: queueItems[fallbackIndex].queue_item_id, + insertAfter: queueItemId, strategy: 'queue_item_before_renderer_next', queueIndex: fallbackIndex, nextQueueIndex, matchedTrackId: queueItems[fallbackIndex].track_id, - matchedQueueItemId: queueItems[fallbackIndex].queue_item_id + matchedQueueItemId: queueItemId }; }