From b28a018ca03f16adf762a11065199a5a72dbae6c Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:40:45 -0400 Subject: [PATCH] play on next when paused --- package.json | 4 +-- src/audio/playbackController.ts | 31 +++++++++++++++----- src/audio/playbackNavigation.test.mts | 8 +++++ src/audio/playbackNavigation.ts | 12 ++++++++ src/services/desktopRemoteTransport.test.mts | 23 +++++++++++++++ src/services/desktopRemoteTransport.ts | 23 +++++++++++++++ src/stores/desktopRemoteStore.ts | 19 ++++++++---- 7 files changed, 104 insertions(+), 16 deletions(-) create mode 100644 src/services/desktopRemoteTransport.test.mts create mode 100644 src/services/desktopRemoteTransport.ts diff --git a/package.json b/package.json index 64ceea0..8f30eab 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "web": "expo start --web", "lint": "expo lint", "test:queue-actions": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/queue/queueActions.test.mts src/components/queue/queuePerformance.test.mts src/components/queue/virtualQueuePaging.test.mts src/components/swipeableRowState.test.mts", - "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts", + "test:desktop-remote": "node --experimental-strip-types --test src/services/desktopRemotePairing.test.mts src/services/desktopRemoteTransport.test.mts", "test:dynamic-playlists": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/playlists/dynamicPlaylist.test.mts", "test:album-grouping": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/shared/library/albumGrouping.test.mts src/shared/library/albumEligibility.test.mts src/library/albumIdentity.test.mts src/library/albumSummary.test.mts", "test:artist-grouping": "node --experimental-strip-types --test src/library/artistGrouping.test.mts src/audio/artistCreditTransport.test.mts", @@ -75,7 +75,7 @@ "test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", - "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts", + "test:seek-bar": "node --experimental-strip-types --test src/audio/playbackClock.test.mts src/audio/playbackNavigation.test.mts src/audio/playbackProgressProjection.test.mts src/components/waveformScrubDetents.test.mts", "test:recent-play": "node --experimental-strip-types --test src/audio/recentPlayTracking.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index a31bb9c..a95a4bd 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -28,7 +28,10 @@ import { prepareAudioProcessingForPlayback, primePreparedTrackForPlayback, } from './audioProcessingStartup'; -import { shouldRestartOnPrevious } from './playbackNavigation'; +import { + shouldRestartOnPrevious, + shouldResumeAfterExplicitNext, +} from './playbackNavigation'; import { cancelManualRecentPlayTransition, markManualRecentPlayTransition, @@ -815,15 +818,19 @@ export async function togglePlay(): Promise { export async function skipToNext(): Promise { markNowPlayingTrackTransitionDirection('next', 'phone'); await ensurePlayerReady(); - const [nativeQueue, nativeIndex] = await Promise.all([ + const [nativeQueue, nativeIndex, nativePlaybackState] = await Promise.all([ TrackPlayer.getQueue(), TrackPlayer.getActiveTrackIndex(), + TrackPlayer.getPlaybackState(), ]); + const playbackStateAtIntent = mapRntpState(nativePlaybackState.state); + const resumeAfterSkip = shouldResumeAfterExplicitNext(playbackStateAtIntent); + const playbackTarget = dspTargetFromTrack( + nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1], + 'none', + ); await prepareAudioProcessingForPlayback( - dspTargetFromTrack( - nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1], - 'none', - ), + playbackTarget, 'skip-next', ); const { tracks, activeIndex } = useQueueStore.getState(); @@ -831,7 +838,10 @@ export async function skipToNext(): Promise { const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; if (nextIndex >= 0 && nextIndex < tracks.length) { useQueueStore.getState().setActiveIndex(nextIndex); - setOptimisticTrack(tracks[nextIndex], usePlayerStore.getState().playbackState); + setOptimisticTrack( + tracks[nextIndex], + resumeAfterSkip ? 'loading' : playbackStateAtIntent, + ); } const manualTransition = markManualRecentPlayTransition(manualTransitionFromPath); let nativeTransitionSucceeded = false; @@ -839,10 +849,15 @@ export async function skipToNext(): Promise { await TrackPlayer.skipToNext(); nativeTransitionSucceeded = true; await refreshActiveIndexFromNative(); + if (resumeAfterSkip) { + await primePreparedTrackForPlayback(playbackTarget, 'skip-next-resume'); + await TrackPlayer.play(); + usePlayerStore.getState().setPlaybackState('playing'); + } } catch { if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition); await reconcilePlayerFromNative(); - // no next track — ignore + // No next track or resume failed — keep the reconciled native state. } } diff --git a/src/audio/playbackNavigation.test.mts b/src/audio/playbackNavigation.test.mts index 1161619..87689ab 100644 --- a/src/audio/playbackNavigation.test.mts +++ b/src/audio/playbackNavigation.test.mts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { PREVIOUS_RESTART_THRESHOLD_SECONDS, + shouldResumeAfterExplicitNext, shouldRestartOnPrevious, } from './playbackNavigation.ts'; @@ -16,3 +17,10 @@ test('invalid and negative positions retain previous-track behavior', () => { assert.equal(shouldRestartOnPrevious(Number.NaN), false); assert.equal(shouldRestartOnPrevious(Number.POSITIVE_INFINITY), false); }); + +test('only a paused explicit Next intent resumes playback', () => { + assert.equal(shouldResumeAfterExplicitNext('paused'), true); + assert.equal(shouldResumeAfterExplicitNext('playing'), false); + assert.equal(shouldResumeAfterExplicitNext('loading'), false); + assert.equal(shouldResumeAfterExplicitNext('stopped'), false); +}); diff --git a/src/audio/playbackNavigation.ts b/src/audio/playbackNavigation.ts index a4c4dac..98de5f4 100644 --- a/src/audio/playbackNavigation.ts +++ b/src/audio/playbackNavigation.ts @@ -1,3 +1,5 @@ +import type { PlaybackState } from '@/types/audio'; + export const PREVIOUS_RESTART_THRESHOLD_SECONDS = 3; /** Match desktop Astra: restart only after crossing the previous-track cutoff. */ @@ -7,3 +9,13 @@ export function shouldRestartOnPrevious(positionSeconds: number): boolean { positionSeconds > PREVIOUS_RESTART_THRESHOLD_SECONDS ); } + +/** + * An explicit Next press while paused is also an instruction to resume. + * Loading, stopped, and already-playing sessions retain their native behavior. + */ +export function shouldResumeAfterExplicitNext( + playbackState: PlaybackState, +): boolean { + return playbackState === 'paused'; +} diff --git a/src/services/desktopRemoteTransport.test.mts b/src/services/desktopRemoteTransport.test.mts new file mode 100644 index 0000000..f699e43 --- /dev/null +++ b/src/services/desktopRemoteTransport.test.mts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { desktopRemoteControlSequence } from './desktopRemoteTransport.ts'; + +test('paused desktop Next advances before resuming playback', () => { + assert.deepEqual( + desktopRemoteControlSequence('next', 'paused'), + ['next', 'play'], + ); +}); + +test('desktop Next preserves non-paused transport behavior', () => { + assert.deepEqual(desktopRemoteControlSequence('next', 'playing'), ['next']); + assert.deepEqual(desktopRemoteControlSequence('next', 'loading'), ['next']); + assert.deepEqual(desktopRemoteControlSequence('next', 'stopped'), ['next']); + assert.deepEqual(desktopRemoteControlSequence('next', undefined), ['next']); +}); + +test('other desktop commands are never expanded', () => { + assert.deepEqual(desktopRemoteControlSequence('previous', 'paused'), ['previous']); + assert.deepEqual(desktopRemoteControlSequence('play', 'paused'), ['play']); + assert.deepEqual(desktopRemoteControlSequence('seek', 'paused'), ['seek']); +}); diff --git a/src/services/desktopRemoteTransport.ts b/src/services/desktopRemoteTransport.ts new file mode 100644 index 0000000..1039825 --- /dev/null +++ b/src/services/desktopRemoteTransport.ts @@ -0,0 +1,23 @@ +import { shouldResumeAfterExplicitNext } from '../audio/playbackNavigation.ts'; +import type { + DesktopRemoteControlCommand, + DesktopRemotePlaybackState, +} from '@/types/desktopRemote'; + +/** + * The desktop protocol exposes discrete transport commands. Expand a paused + * Next intent into ordered Next + Play requests so the transition also resumes. + */ +export function desktopRemoteControlSequence( + command: DesktopRemoteControlCommand, + playbackState: DesktopRemotePlaybackState | undefined, +): DesktopRemoteControlCommand[] { + if ( + command === 'next' + && playbackState + && shouldResumeAfterExplicitNext(playbackState) + ) { + return ['next', 'play']; + } + return [command]; +} diff --git a/src/stores/desktopRemoteStore.ts b/src/stores/desktopRemoteStore.ts index 409dd15..6fb8250 100644 --- a/src/stores/desktopRemoteStore.ts +++ b/src/stores/desktopRemoteStore.ts @@ -32,6 +32,7 @@ import { AstraLibraryData } from '../../modules/astra-library-scanner'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession'; +import { desktopRemoteControlSequence } from '@/services/desktopRemoteTransport'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import type { DesktopRemoteConnection, @@ -732,16 +733,22 @@ export const useDesktopRemoteStore = create((set, get) => { }, sendControl: async (command, time) => { - const { connection, token } = get(); + const { connection, token, snapshot } = get(); if (!connection || !token) return; try { - await sendDesktopRemoteControl( - connection.baseUrl, - token, - connection.certificateFingerprint, + const commands = desktopRemoteControlSequence( command, - time + snapshot?.playbackState, ); + for (const nextCommand of commands) { + await sendDesktopRemoteControl( + connection.baseUrl, + token, + connection.certificateFingerprint, + nextCommand, + nextCommand === command ? time : undefined, + ); + } set({ errorMessage: '' }); if (command === 'seek' && typeof time === 'number') { set((state) => state.snapshot