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",
"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",
+23 -8
View File
@@ -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<void> {
export async function skipToNext(): Promise<void> {
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<void> {
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<void> {
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.
}
}
+8
View File
@@ -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);
});
+12
View File
@@ -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';
}
@@ -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 { 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<DesktopRemoteStore>((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