play on next when paused

This commit is contained in:
Boof2015
2026-07-28 18:40:45 -04:00
parent d2bda52c86
commit b28a018ca0
7 changed files with 104 additions and 16 deletions
+2 -2
View File
@@ -65,7 +65,7 @@
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "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: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: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: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", "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: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: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: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: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: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", "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
+23 -8
View File
@@ -28,7 +28,10 @@ import {
prepareAudioProcessingForPlayback, prepareAudioProcessingForPlayback,
primePreparedTrackForPlayback, primePreparedTrackForPlayback,
} from './audioProcessingStartup'; } from './audioProcessingStartup';
import { shouldRestartOnPrevious } from './playbackNavigation'; import {
shouldRestartOnPrevious,
shouldResumeAfterExplicitNext,
} from './playbackNavigation';
import { import {
cancelManualRecentPlayTransition, cancelManualRecentPlayTransition,
markManualRecentPlayTransition, markManualRecentPlayTransition,
@@ -815,15 +818,19 @@ export async function togglePlay(): Promise<void> {
export async function skipToNext(): Promise<void> { export async function skipToNext(): Promise<void> {
markNowPlayingTrackTransitionDirection('next', 'phone'); markNowPlayingTrackTransitionDirection('next', 'phone');
await ensurePlayerReady(); await ensurePlayerReady();
const [nativeQueue, nativeIndex] = await Promise.all([ const [nativeQueue, nativeIndex, nativePlaybackState] = await Promise.all([
TrackPlayer.getQueue(), TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(), 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( await prepareAudioProcessingForPlayback(
dspTargetFromTrack( playbackTarget,
nativeIndex == null ? undefined : nativeQueue[nativeIndex + 1],
'none',
),
'skip-next', 'skip-next',
); );
const { tracks, activeIndex } = useQueueStore.getState(); const { tracks, activeIndex } = useQueueStore.getState();
@@ -831,7 +838,10 @@ export async function skipToNext(): Promise<void> {
const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null; const manualTransitionFromPath = usePlayerStore.getState().currentTrack?.path ?? null;
if (nextIndex >= 0 && nextIndex < tracks.length) { if (nextIndex >= 0 && nextIndex < tracks.length) {
useQueueStore.getState().setActiveIndex(nextIndex); useQueueStore.getState().setActiveIndex(nextIndex);
setOptimisticTrack(tracks[nextIndex], usePlayerStore.getState().playbackState); setOptimisticTrack(
tracks[nextIndex],
resumeAfterSkip ? 'loading' : playbackStateAtIntent,
);
} }
const manualTransition = markManualRecentPlayTransition(manualTransitionFromPath); const manualTransition = markManualRecentPlayTransition(manualTransitionFromPath);
let nativeTransitionSucceeded = false; let nativeTransitionSucceeded = false;
@@ -839,10 +849,15 @@ export async function skipToNext(): Promise<void> {
await TrackPlayer.skipToNext(); await TrackPlayer.skipToNext();
nativeTransitionSucceeded = true; nativeTransitionSucceeded = true;
await refreshActiveIndexFromNative(); await refreshActiveIndexFromNative();
if (resumeAfterSkip) {
await primePreparedTrackForPlayback(playbackTarget, 'skip-next-resume');
await TrackPlayer.play();
usePlayerStore.getState().setPlaybackState('playing');
}
} catch { } catch {
if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition); if (!nativeTransitionSucceeded) cancelManualRecentPlayTransition(manualTransition);
await reconcilePlayerFromNative(); await reconcilePlayerFromNative();
// no next track — ignore // No next track or resume failed — keep the reconciled native state.
} }
} }
+8
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { import {
PREVIOUS_RESTART_THRESHOLD_SECONDS, PREVIOUS_RESTART_THRESHOLD_SECONDS,
shouldResumeAfterExplicitNext,
shouldRestartOnPrevious, shouldRestartOnPrevious,
} from './playbackNavigation.ts'; } 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.NaN), false);
assert.equal(shouldRestartOnPrevious(Number.POSITIVE_INFINITY), 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);
});
+12
View File
@@ -1,3 +1,5 @@
import type { PlaybackState } from '@/types/audio';
export const PREVIOUS_RESTART_THRESHOLD_SECONDS = 3; export const PREVIOUS_RESTART_THRESHOLD_SECONDS = 3;
/** Match desktop Astra: restart only after crossing the previous-track cutoff. */ /** 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 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';
}
@@ -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']);
});
+23
View File
@@ -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];
}
+13 -6
View File
@@ -32,6 +32,7 @@ import { AstraLibraryData } from '../../modules/astra-library-scanner';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy'; import { identityMatchesPinnedConnection } from '@/services/desktopSyncPolicy';
import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession'; import { ensureDesktopRemoteCredentialsFresh } from '@/services/desktopRemoteSession';
import { desktopRemoteControlSequence } from '@/services/desktopRemoteTransport';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import type { import type {
DesktopRemoteConnection, DesktopRemoteConnection,
@@ -732,16 +733,22 @@ export const useDesktopRemoteStore = create<DesktopRemoteStore>((set, get) => {
}, },
sendControl: async (command, time) => { sendControl: async (command, time) => {
const { connection, token } = get(); const { connection, token, snapshot } = get();
if (!connection || !token) return; if (!connection || !token) return;
try { try {
await sendDesktopRemoteControl( const commands = desktopRemoteControlSequence(
connection.baseUrl,
token,
connection.certificateFingerprint,
command, command,
time snapshot?.playbackState,
); );
for (const nextCommand of commands) {
await sendDesktopRemoteControl(
connection.baseUrl,
token,
connection.certificateFingerprint,
nextCommand,
nextCommand === command ? time : undefined,
);
}
set({ errorMessage: '' }); set({ errorMessage: '' });
if (command === 'seek' && typeof time === 'number') { if (command === 'seek' && typeof time === 'number') {
set((state) => state.snapshot set((state) => state.snapshot