Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions src/Queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,13 +146,8 @@ export class Queue implements TrackQueueRef {
deactivateEndedTrack: ({ context }) => {
this._trackAt(context.currentTrackIndex)?.deactivate();
},
activateAndPlayCurrent: ({ context }) => {
const track = this._trackAt(context.currentTrackIndex);
if (!track) return;
track.activate();
if (this._scheduledNextIndex !== track.index) {
track.play();
}
activateCurrent: ({ context }) => {
this._trackAt(context.currentTrackIndex)?.activate();
},
playOrContinueGapless: ({ context }) => {
const cur = this._trackAt(context.currentTrackIndex);
Expand Down
169 changes: 58 additions & 111 deletions src/Track.ts

Large diffs are not rendered by default.

31 changes: 20 additions & 11 deletions src/machines/queue.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// Named actions (no-op defaults here, real implementations via .provide()):
// Actions before assign() see the OLD context (e.g. deactivateCurrent
// sees the old currentTrackIndex). Actions after assign() see the NEW
// context (e.g. activateAndPlayCurrent sees the incremented index).
// context (e.g. activateCurrent sees the incremented index).
// Implementations MUST use the ({ context }) parameter, NOT getSnapshot().
// ---------------------------------------------------------------------------

Expand Down Expand Up @@ -101,7 +101,7 @@ export function createQueueMachine(initialContext: QueueContext) {
// --- side-effect actions (no-op defaults, provided by Queue.ts) ---
deactivateCurrent: () => {},
deactivateEndedTrack: () => {},
activateAndPlayCurrent: () => {},
activateCurrent: () => {},
playOrContinueGapless: () => {},
cancelAllGapless: () => {},
notifyStartNewTrack: () => {},
Expand Down Expand Up @@ -151,8 +151,9 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'gotoTrackIndex',
'activateCurrent',
'seekCurrentToStartTime',
'activateAndPlayCurrent',
'playCurrent',
'notifyStartNewTrack',
'updateMediaSessionMetadata',
'preloadAhead',
Expand Down Expand Up @@ -191,7 +192,8 @@ export function createQueueMachine(initialContext: QueueContext) {
actions: [
'cancelAllGapless',
'decrementTrackCount',
'activateAndPlayCurrent',
'activateCurrent',
'playCurrent',
'notifyStartNewTrack',
'updateMediaSessionMetadata',
'preloadAhead',
Expand All @@ -214,7 +216,8 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'advanceToNextTrack',
'activateAndPlayCurrent',
'activateCurrent',
'playCurrent',
'notifyStartNewTrack',
'notifyPlayNextTrack',
'updateMediaSessionMetadata',
Expand All @@ -226,7 +229,8 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'goToPreviousTrack',
'activateAndPlayCurrent',
'activateCurrent',
'playCurrent',
'notifyStartNewTrack',
'notifyPlayPreviousTrack',
'updateMediaSessionMetadata',
Expand All @@ -240,8 +244,9 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'gotoTrackIndex',
'activateCurrent',
'seekCurrentToStartTime',
'activateAndPlayCurrent',
'playCurrent',
'notifyStartNewTrack',
'updateMediaSessionMetadata',
'preloadAhead',
Expand Down Expand Up @@ -328,7 +333,8 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'advanceToNextTrack',
'activateAndPlayCurrent',
'activateCurrent',
'playCurrent',
'notifyStartNewTrack',
'notifyPlayNextTrack',
'updateMediaSessionMetadata',
Expand All @@ -340,7 +346,8 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'goToPreviousTrack',
'activateAndPlayCurrent',
'activateCurrent',
'playCurrent',
'notifyStartNewTrack',
'notifyPlayPreviousTrack',
'updateMediaSessionMetadata',
Expand All @@ -355,8 +362,9 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'gotoTrackIndex',
'activateCurrent',
'seekCurrentToStartTime',
'activateAndPlayCurrent',
'playCurrent',
'notifyStartNewTrack',
'updateMediaSessionMetadata',
'preloadAhead',
Expand Down Expand Up @@ -424,8 +432,9 @@ export function createQueueMachine(initialContext: QueueContext) {
'deactivateCurrent',
'cancelAllGapless',
'gotoTrackIndex',
'activateCurrent',
'seekCurrentToStartTime',
'activateAndPlayCurrent',
'playCurrent',
'notifyStartNewTrack',
'updateMediaSessionMetadata',
'preloadAhead',
Expand Down
58 changes: 26 additions & 32 deletions src/machines/track.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface TrackContext {
fetchStarted: boolean;
/** True when PLAY was received in webAudioOnly mode before buffer is ready. */
pendingPlay: boolean;
/** Track-time (seconds) the user last seeked to, or frozen pause position. */
seekTarget: number;
}

// ---- Events ----------------------------------------------------------------
Expand Down Expand Up @@ -91,6 +93,7 @@ export function createTrackMachine(initialContext: TrackContext) {
playHtml5: () => {},
startSourceNode: () => {},
crossoverHtml5ToWebAudio: () => {},
syncSeekTargetFromCrossover: () => {},
notifyBufferReady: () => {},
startScheduledSourceNode: () => {},
startProgressLoop: () => {},
Expand All @@ -105,6 +108,10 @@ export function createTrackMachine(initialContext: TrackContext) {
resetTiming: () => {},
notifyTrackEnded: () => {},
triggerFetchForPendingPlay: () => {},
setSeekTarget: assign({
seekTarget: ({ event }) => (event as { type: 'SEEK'; time: number }).time,
}),
clearSeekTarget: assign({ seekTarget: () => 0 }),
setPendingPlay: assign({ pendingPlay: () => true }),
clearPendingPlay: assign({ pendingPlay: () => false }),
setIsPlaying: assign({ isPlaying: () => true }),
Expand Down Expand Up @@ -177,6 +184,9 @@ export function createTrackMachine(initialContext: TrackContext) {
// -----------------------------------------------------------------
idle: {
on: {
SEEK: {
actions: ['setSeekTarget', 'reportProgress'],
},
HTML5_ENDED: {
actions: ['notifyTrackEnded'],
},
Expand All @@ -188,6 +198,7 @@ export function createTrackMachine(initialContext: TrackContext) {
'stopProgressLoop',
'clearScheduleAndLookahead',
'clearPendingPlay',
'clearSeekTarget',
],
},
ACTIVATE: {
Expand All @@ -196,6 +207,7 @@ export function createTrackMachine(initialContext: TrackContext) {
'resetHtml5Element',
'clearScheduleAndLookahead',
'clearPendingPlay',
'clearSeekTarget',
],
},
PLAY: [
Expand All @@ -214,12 +226,6 @@ export function createTrackMachine(initialContext: TrackContext) {
},
{
target: 'html5',
// triggerFetchForPendingPlay also kicks off fetch+decode for the
// CURRENT track, not just the next one. That way BUFFER_READY
// fires while we're in html5, and crossoverHtml5ToWebAudio can
// hand the active track over to Web Audio mid-stream. The
// canStartFetch guard inside START_FETCH makes this a no-op if
// a fetch is already in flight (e.g. PLAY from loading state).
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'],
},
],
Expand Down Expand Up @@ -267,7 +273,7 @@ export function createTrackMachine(initialContext: TrackContext) {
html5: {
on: {
PAUSE: {
actions: ['clearIsPlaying', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'],
actions: ['clearIsPlaying', 'freezePausedTime', 'pauseHtml5', 'stopProgressLoop', 'reportProgress'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending seeks when pausing before metadata

When HTML5 playback is paused before loadedmetadata applies a requested nonzero seek, audio.currentTime is still typically 0, so freezePausedTime overwrites the pending seekTarget with 0. In HYBRID mode, if Web Audio decoding then completes before the metadata listener fires, the BUFFER_READY crossover uses that erased target and starts the decoded source from the beginning. Preserve the existing target while an HTML5 seek is still pending, and only freeze audio.currentTime after the seek has taken effect.

Useful? React with 👍 / 👎.

},
PLAY: {
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop'],
Expand All @@ -279,21 +285,12 @@ export function createTrackMachine(initialContext: TrackContext) {
target: 'webaudio',
actions: 'setPlayingWebAudio',
},
// Mid-stream crossover to Web Audio. Running `crossoverHtml5ToWebAudio`
// synchronously captures audio.currentTime, pauses the HTML5 element,
// and starts a Web Audio source node at that exact offset. After the
// transition completes we're in webaudio state with isPlaying preserved
// (true if the track was playing, false if paused), so a subsequent
// PLAY in webaudio state will resume from pausedAtTrackTime.
//
// clearNotifiedLookahead resets the gapless lookahead flag so the
// webaudio progress loop can re-trigger scheduling with the accurate
// shared-clock end time, replacing any stale HTML5-clock prediction.
BUFFER_READY: {
target: 'webaudio',
actions: [
'setLoadedState',
'crossoverHtml5ToWebAudio',
'syncSeekTargetFromCrossover',
'setPlaybackTypeWebAudio',
'clearNotifiedLookahead',
'notifyBufferReady',
Expand All @@ -304,6 +301,7 @@ export function createTrackMachine(initialContext: TrackContext) {
},
SEEK: {
actions: [
'setSeekTarget',
'seekHtml5',
'reportProgress',
],
Expand All @@ -323,14 +321,15 @@ export function createTrackMachine(initialContext: TrackContext) {
'stopProgressLoop',
'resetTiming',
'resetHtml5Element',
'clearSeekTarget',
],
},
URL_RESOLVED: {
actions: 'setResolvedUrl',
},
DEACTIVATE: {
target: 'idle',
actions: ['clearIsPlaying', 'pauseHtml5', 'resetHtml5Element', 'resetTiming', 'stopProgressLoop'],
actions: ['clearIsPlaying', 'pauseHtml5', 'resetHtml5Element', 'resetTiming', 'stopProgressLoop', 'clearSeekTarget'],
},
},
},
Expand All @@ -340,6 +339,9 @@ export function createTrackMachine(initialContext: TrackContext) {
// -----------------------------------------------------------------
loading: {
on: {
SEEK: {
actions: ['setSeekTarget', 'reportProgress'],
},
BUFFER_LOADING: {
actions: 'setLoadingState',
},
Expand Down Expand Up @@ -374,12 +376,6 @@ export function createTrackMachine(initialContext: TrackContext) {
},
{
target: 'html5',
// triggerFetchForPendingPlay also kicks off fetch+decode for the
// CURRENT track, not just the next one. That way BUFFER_READY
// fires while we're in html5, and crossoverHtml5ToWebAudio can
// hand the active track over to Web Audio mid-stream. The
// canStartFetch guard inside START_FETCH makes this a no-op if
// a fetch is already in flight (e.g. PLAY from loading state).
actions: ['setIsPlaying', 'playHtml5', 'startProgressLoop', 'triggerFetchForPendingPlay'],
},
],
Expand All @@ -405,11 +401,12 @@ export function createTrackMachine(initialContext: TrackContext) {
'clearPendingPlay',
'resetTiming',
'resetHtml5Element',
'clearSeekTarget',
],
},
DEACTIVATE: {
target: 'idle',
actions: ['clearIsPlaying', 'clearPendingPlay', 'resetTiming'],
actions: ['clearIsPlaying', 'clearPendingPlay', 'resetTiming', 'clearSeekTarget'],
},
URL_RESOLVED: {
actions: 'setResolvedUrl',
Expand All @@ -422,12 +419,6 @@ export function createTrackMachine(initialContext: TrackContext) {
// -----------------------------------------------------------------
webaudio: {
on: {
// clearScheduledStart: once the source node is stopped, the
// scheduled start time no longer describes anything real — resume
// re-anchors the source at a later context time, so any end-time
// math based on scheduledStartContextTime + duration would land
// mid-track (scheduling the next track to start while this one is
// still audible).
PAUSE: {
actions: [
'clearIsPlaying',
Expand All @@ -452,6 +443,7 @@ export function createTrackMachine(initialContext: TrackContext) {
},
SEEK: {
actions: [
'setSeekTarget',
'clearScheduledStart',
'seekWebAudio',
'reportProgress',
Expand All @@ -465,6 +457,7 @@ export function createTrackMachine(initialContext: TrackContext) {
'stopSourceNode',
'stopProgressLoop',
'resetTiming',
'clearSeekTarget',
],
},
LOOKAHEAD_REACHED: {
Expand All @@ -482,9 +475,9 @@ export function createTrackMachine(initialContext: TrackContext) {
'stopProgressLoop',
'resetTiming',
'resetHtml5Element',
'clearSeekTarget',
],
},
// Bug #3 fix: DEACTIVATE from webaudio → idle (was staying in webaudio)
DEACTIVATE: {
target: 'idle',
actions: [
Expand All @@ -493,6 +486,7 @@ export function createTrackMachine(initialContext: TrackContext) {
'resetTiming',
'resetHtml5Element',
'stopProgressLoop',
'clearSeekTarget',
],
},
},
Expand Down
1 change: 1 addition & 0 deletions tests/unit/features.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function makeCtx(overrides: Partial<TrackContext> = {}): TrackContext {
notifiedLookahead: false,
fetchStarted: false,
pendingPlay: false,
seekTarget: 0,
...overrides,
};
}
Expand Down
10 changes: 5 additions & 5 deletions tests/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ describe('Queue onTrackEnded resets finished track', () => {
currentTime: number;
_waRefCtxTime: number;
_waRefTrackTime: number;
pausedAtTrackTime: number;
seekTarget: number;
sourceNode: { simulateEnded(): void } | null;
};
type InternalQueue = { _tracks: InternalTrack[] };
Expand Down Expand Up @@ -418,7 +418,7 @@ describe('Queue onTrackEnded resets finished track', () => {

expect(internal._tracks[0]._waRefCtxTime).toBe(0);
expect(internal._tracks[0]._waRefTrackTime).toBe(0);
expect(internal._tracks[0].pausedAtTrackTime).toBe(0);
expect(internal._tracks[0].seekTarget).toBe(0);
expect(audioOf(q, 0).currentTime).toBe(0);
});
});
Expand Down Expand Up @@ -1467,10 +1467,10 @@ describe('Queue seek targets correct track', () => {
q.next(); // → track 1
q.seek(42);
// Track 1 (current) should have been seeked
const tracks = (q as unknown as { _tracks: Array<{ pausedAtTrackTime: number }> })._tracks;
expect(tracks[1].pausedAtTrackTime).toBe(42);
const tracks = (q as unknown as { _tracks: Array<{ seekTarget: number }> })._tracks;
expect(tracks[1].seekTarget).toBe(42);
// Track 0 should NOT have been seeked
expect(tracks[0].pausedAtTrackTime).toBe(0);
expect(tracks[0].seekTarget).toBe(0);
});
});

Expand Down
1 change: 1 addition & 0 deletions tests/unit/track.machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function makeCtx(overrides: Partial<TrackContext> = {}): TrackContext {
notifiedLookahead: false,
fetchStarted: false,
pendingPlay: false,
seekTarget: 0,
...overrides,
};
}
Expand Down
Loading