fix(qconnect): align renderer state with official queue semantics
cx
This commit is contained in:
5 files changed
+277
-17
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<u64>,
|
||||
#[prost(int32, optional, tag = "2")]
|
||||
pub value: Option<i32>,
|
||||
|
||||
@@ -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<u64>,
|
||||
last_renderer_next_track_id: Option<u64>,
|
||||
last_renderer_playing_state: Option<i32>,
|
||||
last_reported_file_audio_quality: Option<QconnectFileAudioQualitySnapshot>,
|
||||
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<i64> {
|
||||
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<bool, String> {
|
||||
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<QconnectFileAudioQualitySnapshot> {
|
||||
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<QconnectFileAudioQualitySnapshot> {
|
||||
if let Some(bridge_state) = app_handle.try_state::<CoreBridgeState>() {
|
||||
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::<AppState>()?;
|
||||
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<QconnectApp<NativeWsTransport, TauriQconnectEventSink>>,
|
||||
) -> 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<Vec<u8>, 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user