fix(qconnect): restore local repeat renderer flow
cx
This commit is contained in:
4 files changed
+125
-19
No files matched your search
@@ -2463,6 +2463,10 @@ async fn apply_renderer_command_to_corebridge(
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let resolved_playing_state = renderer_state.playing_state.or(*playing_state);
|
let resolved_playing_state = renderer_state.playing_state.or(*playing_state);
|
||||||
|
let target_position_secs = renderer_state
|
||||||
|
.current_position_ms
|
||||||
|
.or(*current_position_ms)
|
||||||
|
.map(|position_ms| position_ms / 1000);
|
||||||
let mut projection_renderer_state = renderer_state.clone();
|
let mut projection_renderer_state = renderer_state.clone();
|
||||||
if projection_renderer_state.current_track.is_none() {
|
if projection_renderer_state.current_track.is_none() {
|
||||||
projection_renderer_state.current_track = current_track.clone();
|
projection_renderer_state.current_track = current_track.clone();
|
||||||
@@ -2471,6 +2475,7 @@ async fn apply_renderer_command_to_corebridge(
|
|||||||
projection_renderer_state.next_track = next_track.clone();
|
projection_renderer_state.next_track = next_track.clone();
|
||||||
}
|
}
|
||||||
let resolved_current_track = projection_renderer_state.current_track.as_ref();
|
let resolved_current_track = projection_renderer_state.current_track.as_ref();
|
||||||
|
let mut reloaded_for_restart = false;
|
||||||
if let Some(current_track) = resolved_current_track {
|
if let Some(current_track) = resolved_current_track {
|
||||||
let queue_state = {
|
let queue_state = {
|
||||||
let state = sync_state.lock().await;
|
let state = sync_state.lock().await;
|
||||||
@@ -2500,13 +2505,33 @@ async fn apply_renderer_command_to_corebridge(
|
|||||||
resolved_playing_state,
|
resolved_playing_state,
|
||||||
Some(PLAYING_STATE_PLAYING | PLAYING_STATE_PAUSED)
|
Some(PLAYING_STATE_PLAYING | PLAYING_STATE_PAUSED)
|
||||||
) {
|
) {
|
||||||
if let Err(err) =
|
if let Some(target_position_secs) = target_position_secs {
|
||||||
ensure_remote_track_loaded(bridge, current_track.track_id).await
|
let playback_state = bridge.get_playback_state();
|
||||||
{
|
if should_force_remote_track_restart(
|
||||||
log::warn!(
|
&playback_state,
|
||||||
"[QConnect] Failed to load remote track {}: {err}",
|
current_track.track_id,
|
||||||
current_track.track_id
|
target_position_secs,
|
||||||
);
|
) {
|
||||||
|
log::info!(
|
||||||
|
"[QConnect] Restarting remote track {} from the beginning (current={}s target={}s)",
|
||||||
|
current_track.track_id,
|
||||||
|
playback_state.position,
|
||||||
|
target_position_secs
|
||||||
|
);
|
||||||
|
load_remote_track_into_player(bridge, current_track.track_id).await?;
|
||||||
|
reloaded_for_restart = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reloaded_for_restart {
|
||||||
|
if let Err(err) =
|
||||||
|
ensure_remote_track_loaded(bridge, current_track.track_id).await
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"[QConnect] Failed to load remote track {}: {err}",
|
||||||
|
current_track.track_id
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2532,6 +2557,9 @@ async fn apply_renderer_command_to_corebridge(
|
|||||||
if let Some(position_ms) = renderer_state.current_position_ms.or(*current_position_ms) {
|
if let Some(position_ms) = renderer_state.current_position_ms.or(*current_position_ms) {
|
||||||
let current_pos_secs = bridge.get_playback_state().position;
|
let current_pos_secs = bridge.get_playback_state().position;
|
||||||
let target_secs = position_ms / 1000;
|
let target_secs = position_ms / 1000;
|
||||||
|
if reloaded_for_restart && target_secs <= 1 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
// Only seek if the position differs by more than 2 seconds to
|
// Only seek if the position differs by more than 2 seconds to
|
||||||
// avoid audio hiccups from redundant seeks (e.g. when the server
|
// avoid audio hiccups from redundant seeks (e.g. when the server
|
||||||
// echoes back our own position in a SET_STATE after queue_load).
|
// echoes back our own position in a SET_STATE after queue_load).
|
||||||
@@ -2969,12 +2997,18 @@ fn should_reload_remote_track(
|
|||||||
playback_state.track_id != track_id || !has_loaded_audio
|
playback_state.track_id != track_id || !has_loaded_audio
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
|
fn should_force_remote_track_restart(
|
||||||
let playback_state = bridge.get_playback_state();
|
playback_state: &qbz_player::PlaybackState,
|
||||||
if !should_reload_remote_track(&playback_state, bridge.has_loaded_audio(), track_id) {
|
track_id: u64,
|
||||||
return Ok(());
|
target_position_secs: u64,
|
||||||
}
|
) -> bool {
|
||||||
|
playback_state.track_id == track_id
|
||||||
|
&& track_id != 0
|
||||||
|
&& target_position_secs <= 1
|
||||||
|
&& playback_state.position > target_position_secs.saturating_add(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_remote_track_into_player(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
|
||||||
let stream_url = bridge
|
let stream_url = bridge
|
||||||
.get_stream_url(track_id, Quality::UltraHiRes)
|
.get_stream_url(track_id, Quality::UltraHiRes)
|
||||||
.await
|
.await
|
||||||
@@ -3003,6 +3037,15 @@ async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Resul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn ensure_remote_track_loaded(bridge: &CoreBridge, track_id: u64) -> Result<(), String> {
|
||||||
|
let playback_state = bridge.get_playback_state();
|
||||||
|
if !should_reload_remote_track(&playback_state, bridge.has_loaded_audio(), track_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
load_remote_track_into_player(bridge, track_id).await
|
||||||
|
}
|
||||||
|
|
||||||
struct QconnectRemoteStreamInfo {
|
struct QconnectRemoteStreamInfo {
|
||||||
content_length: u64,
|
content_length: u64,
|
||||||
sample_rate: u32,
|
sample_rate: u32,
|
||||||
@@ -4729,6 +4772,33 @@ mod tests {
|
|||||||
assert_eq!(super::qconnect_repeat_mode_from_loop_mode(99), None);
|
assert_eq!(super::qconnect_repeat_mode_from_loop_mode(99), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forces_remote_restart_for_same_track_reset_to_zero() {
|
||||||
|
let playback_state = qbz_player::PlaybackState {
|
||||||
|
is_playing: false,
|
||||||
|
position: 248,
|
||||||
|
duration: 251,
|
||||||
|
track_id: 72930174,
|
||||||
|
volume: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(super::should_force_remote_track_restart(
|
||||||
|
&playback_state,
|
||||||
|
72930174,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
assert!(!super::should_force_remote_track_restart(
|
||||||
|
&playback_state,
|
||||||
|
72930174,
|
||||||
|
120,
|
||||||
|
));
|
||||||
|
assert!(!super::should_force_remote_track_restart(
|
||||||
|
&playback_state,
|
||||||
|
72930175,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_current_and_next_queue_item_ids_from_queue_order() {
|
fn resolves_current_and_next_queue_item_ids_from_queue_order() {
|
||||||
let queue: QConnectQueueState = serde_json::from_value(json!({
|
let queue: QConnectQueueState = serde_json::from_value(json!({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isQconnectPeerRendererActive,
|
isQconnectPeerRendererActive,
|
||||||
isQconnectRemoteModeActive,
|
isQconnectRemoteModeActive,
|
||||||
resolveQconnectPlayNextAuthoritativeTrackId,
|
resolveQconnectPlayNextAuthoritativeTrackId,
|
||||||
|
shouldQconnectSuppressLocalPlaybackAutomation,
|
||||||
type QconnectConnectionStatus
|
type QconnectConnectionStatus
|
||||||
} from './qconnectRuntime';
|
} from './qconnectRuntime';
|
||||||
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
|
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
|
||||||
@@ -182,6 +183,28 @@ describe('QConnect runtime state helpers', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suppresses local playback automation only when a peer renderer is active', () => {
|
||||||
|
expect(
|
||||||
|
shouldQconnectSuppressLocalPlaybackAutomation(true, {
|
||||||
|
session_uuid: 'session-1',
|
||||||
|
active_renderer_id: 4,
|
||||||
|
local_renderer_id: 11,
|
||||||
|
renderers: []
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldQconnectSuppressLocalPlaybackAutomation(true, {
|
||||||
|
session_uuid: 'session-1',
|
||||||
|
active_renderer_id: 11,
|
||||||
|
local_renderer_id: 11,
|
||||||
|
renderers: []
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(shouldQconnectSuppressLocalPlaybackAutomation(false, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not trust the local current track for play next when a peer renderer is active', () => {
|
it('does not trust the local current track for play next when a peer renderer is active', () => {
|
||||||
expect(
|
expect(
|
||||||
resolveQconnectPlayNextAuthoritativeTrackId({
|
resolveQconnectPlayNextAuthoritativeTrackId({
|
||||||
|
|||||||
@@ -194,6 +194,13 @@ export function isQconnectPeerRendererActive(
|
|||||||
return activeRendererId !== localRendererId;
|
return activeRendererId !== localRendererId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldQconnectSuppressLocalPlaybackAutomation(
|
||||||
|
connected: boolean,
|
||||||
|
sessionSnapshot: QconnectSessionSnapshot | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return connected && isQconnectPeerRendererActive(sessionSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveQconnectPlayNextAuthoritativeTrackId(params: {
|
export function resolveQconnectPlayNextAuthoritativeTrackId(params: {
|
||||||
sessionSnapshot: QconnectSessionSnapshot | null | undefined;
|
sessionSnapshot: QconnectSessionSnapshot | null | undefined;
|
||||||
localCurrentTrackId: number | null | undefined;
|
localCurrentTrackId: number | null | undefined;
|
||||||
|
|||||||
+13
-7
@@ -325,6 +325,7 @@
|
|||||||
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
isQconnectRemoteModeActive as computeQconnectRemoteModeActive,
|
||||||
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
logQconnectPlaybackReport as appendQconnectPlaybackReport,
|
||||||
qconnectAdmissionReasonKey,
|
qconnectAdmissionReasonKey,
|
||||||
|
shouldQconnectSuppressLocalPlaybackAutomation,
|
||||||
toggleQconnectConnection
|
toggleQconnectConnection
|
||||||
} from '$lib/services/qconnectRuntime';
|
} from '$lib/services/qconnectRuntime';
|
||||||
import type {
|
import type {
|
||||||
@@ -914,6 +915,12 @@
|
|||||||
const qconnectPeerRendererActive = $derived(
|
const qconnectPeerRendererActive = $derived(
|
||||||
isQconnectPeerRendererActive(qobuzConnectSessionSnapshot)
|
isQconnectPeerRendererActive(qobuzConnectSessionSnapshot)
|
||||||
);
|
);
|
||||||
|
const qconnectSuppressLocalPlaybackAutomation = $derived(
|
||||||
|
shouldQconnectSuppressLocalPlaybackAutomation(
|
||||||
|
isQobuzConnectConnected,
|
||||||
|
qobuzConnectSessionSnapshot
|
||||||
|
)
|
||||||
|
);
|
||||||
const effectiveIsPlaying = $derived(
|
const effectiveIsPlaying = $derived(
|
||||||
qconnectPeerRendererActive
|
qconnectPeerRendererActive
|
||||||
? qobuzConnectRendererSnapshot?.playing_state === 2
|
? qobuzConnectRendererSnapshot?.playing_state === 2
|
||||||
@@ -1022,14 +1029,13 @@
|
|||||||
function applyQobuzConnectStatus(status: QconnectConnectionStatus): void {
|
function applyQobuzConnectStatus(status: QconnectConnectionStatus): void {
|
||||||
qobuzConnectStatus = status;
|
qobuzConnectStatus = status;
|
||||||
const nextConnected = Boolean(status.transport_connected);
|
const nextConnected = Boolean(status.transport_connected);
|
||||||
if (nextConnected !== isQobuzConnectConnected) {
|
|
||||||
isQobuzConnectConnected = nextConnected;
|
|
||||||
setRemoteControlMode(nextConnected);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isQobuzConnectConnected = nextConnected;
|
isQobuzConnectConnected = nextConnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
setRemoteControlMode(qconnectSuppressLocalPlaybackAutomation);
|
||||||
|
});
|
||||||
|
|
||||||
function mapBackendQueueTrackToPlayingTrack(track: BackendQueueTrack): PlayingTrack {
|
function mapBackendQueueTrackToPlayingTrack(track: BackendQueueTrack): PlayingTrack {
|
||||||
const rawRate = track.sample_rate ?? undefined;
|
const rawRate = track.sample_rate ?? undefined;
|
||||||
const normalizedRate = rawRate == null
|
const normalizedRate = rawRate == null
|
||||||
@@ -4146,7 +4152,7 @@
|
|||||||
setOnTrackEnded(async () => {
|
setOnTrackEnded(async () => {
|
||||||
// When QConnect controls playback, the server/controller manages track advancement.
|
// When QConnect controls playback, the server/controller manages track advancement.
|
||||||
// The frontend must NOT auto-advance or it will fight the remote controller.
|
// The frontend must NOT auto-advance or it will fight the remote controller.
|
||||||
if (isQobuzConnectConnected) {
|
if (qconnectSuppressLocalPlaybackAutomation) {
|
||||||
console.log('[Player] Auto-advance suppressed: QConnect is controlling playback');
|
console.log('[Player] Auto-advance suppressed: QConnect is controlling playback');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4188,7 +4194,7 @@
|
|||||||
|
|
||||||
// Set up resume-from-stop callback: re-play the queue's current track
|
// Set up resume-from-stop callback: re-play the queue's current track
|
||||||
setOnResumeFromStop(async () => {
|
setOnResumeFromStop(async () => {
|
||||||
if (isQobuzConnectConnected) return;
|
if (qconnectSuppressLocalPlaybackAutomation) return;
|
||||||
const queueState = await getBackendQueueState();
|
const queueState = await getBackendQueueState();
|
||||||
if (!queueState) return;
|
if (!queueState) return;
|
||||||
const tryQueueIndices = async (indices: number[]): Promise<BackendQueueTrack | null> => {
|
const tryQueueIndices = async (indices: number[]): Promise<BackendQueueTrack | null> => {
|
||||||
|
|||||||
Reference in new issue
Block a user