Skip to content

Key-based queue editing, repeat, snapshot/restore and onChange; tracks identified by reference - #31

Merged
switz merged 8 commits into
masterfrom
feat/play-order
Nov 7, 2026
Merged

switz merged 8 commits into
masterfrom
feat/play-order

Conversation

@switz

@switz switz commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

This adds a key-based API for editing the queue while it plays, plus repeat modes, snapshot/restore and change events. It's groundwork for Relisten's editable play queue ("Next up" plus the show or song list it came from).

It also changes how the queue identifies tracks internally: the state machine holds the current track by reference, and indices only mean positions. The public API is additive; addTrack, removeTrack(index) and gotoTrack(index) still work.

API

type Entry = { key: string; url: string; metadata?: TrackMetadata; skipHEAD?: boolean };

queue.setEntries(entries, { startKey?, startTime?, play? }) // reconcile by key
queue.insert(entries, { after: key | 'current' | 'start' | 'end' })
queue.move(key, { after })
queue.remove(key)                                           // removing the playing entry advances
queue.goto(key, { play?, startTime? })
queue.setRepeat('off' | 'all' | 'one')
queue.snapshot() / queue.restore(snapshot, { play? })
queue.onChange(({ keys, currentKey, repeat }) => {})        // returns unsubscribe
queue.keys, queue.currentKey, queue.repeat, TrackInfo.key
  • Reordering mid-track is safe, including shuffling. The current entry keeps playing, untouched. If the entry after it changed, the old one's scheduled gapless start is cancelled and the new one is preloaded and scheduled. Downloads for entries that left the preload window are aborted, so they don't compete with what's about to play, and decoded audio is kept.
  • gapless doesn't decide the shuffled order. The app passes the order it wants to setEntries. Relisten keeps "Next up" out of the shuffle, and that rule belongs in the app.
  • Repeat: 'all' wraps gaplessly from the last entry to the first. 'one' restarts the entry and fires onStartNewTrack again; the restart isn't sample-gapless. next() still moves on under 'one'.
  • onChange fires once per edit, in a microtask after it, so listeners can edit the queue.

How it works

One identity model. Tracks are identified by reference, and indices are only positions:

  • The queue machine is generic over its entries. Its context is { current, list }, where list is a PlayList the queue implements (next, previous, first, size, repeatsCurrent). Repeat lives only in the queue.
  • Events carry entries: GOTO { track }, REMOVE_TRACK { track, successor }, TRACK_LOADED { track }, and LIST_CHANGED { current }, where the queue says which entry is current after an edit.
  • Track.index is gone. Queue._nextOf(track) is the one answer to "what plays next" (it wraps under repeat 'all'), for the machine, preloading, gapless scheduling and the preload window.
  • The scheduled gapless track is a reference (_scheduledNext), so a reorder can't point it at the wrong track.
  • Positions are derived at the edge: currentTrackIndex, TrackInfo.index, gotoTrack(i), removeTrack(i), queueSnapshot.

Edits. Every edit that keeps the current track goes through _applyOrder. It swaps the array, tells the machine LIST_CHANGED, and reschedules gapless only if the next track changed.

Aborting displaced downloads. Every preload pass, and every reorder, calls Track.cancelPreload() on tracks outside the window. That stops the fetch/decode child, which aborts its fetch. It only cancels while the URL is being resolved or the file fetched: a decode can't be aborted, and by then the bytes have arrived.

Behaviour changes

  • gotoTrack(i, false) while playing now pauses on the new track. Before, the old track stopped, the new one didn't start, and the queue stayed playing with nothing playing. play() couldn't recover. This already happened on master.
  • A track ending while paused now preloads what's ahead, like every other move to a new track.
  • setEntries, insert and addTrack throw on duplicate keys. They check before changing anything.

Testing

  • 422 unit tests pass. New files:
    • play-order.test.ts: no interruption, schedule kept or redone, buffer reuse, stale callbacks;
    • repeat-snapshot.test.ts: repeat, snapshot round-trip, onChange, review fixes;
    • preload-cancel.test.ts: displaced downloads aborted, buffers kept, the paused case;
    • queue.machine.test.ts: rewritten against a string PlayList.
  • Fuzz test: random inserts, moves, removals, reorders, replacements and repeat changes, mixed with navigation. One scenario runs async in HYBRID mode with slow downloads, so they're in flight across edits. The invariants read the machine's real current entry and check against an independent spec of play order:
    • a scheduled track is the one that plays next;
    • nothing outside the preload window is still downloading;
    • keys are unique;
    • public positions match the array.
    • A 60-seed sweep (FUZZ_SEEDS=60) passes all 1037 cases.
    • With cancelling disabled, the window invariant fails within a few operations.
    • The fuzz test found bugs, all fixed: SYNC/LIST_CHANGED while ended, emptying while playing, and the paused TRACK_ENDED preload.
  • Chromium, real decoded audio:
    • inserting ahead of a scheduled next track hands off gaplessly (a → n1 → b);
    • the repeat-'all' wrap is gapless, and 'one' restarts every 2.00 s;
    • shuffling while the next track downloads aborts it (net::ERR_ABORTED), and the new next track plays gaplessly.
  • Chromium, 400 entries: about 36–43 ms to build, about 15–17 MB of heap, 0.1–0.2 ms to reorder or insert.

Not in this PR

The version bump and npm publish (4.5.0), after review.

switz added 2 commits November 6, 2026 22:47
…(key)

Every edit that keeps the current track goes through Queue._applyOrder,
which reorders _tracks, reassigns Track.index and sends the machine a
SYNC event. The scheduled gapless track is held by reference, and is
cancelled and rescheduled only when the track after the current one
changes. Callbacks from dropped tracks are ignored.

Fuzz tests cover edits mixed with navigation, including an async HYBRID
run with real gapless scheduling and a 'scheduled track is current + 1'
invariant.
- setRepeat('off' | 'all' | 'one') and a repeat constructor option.
  'all' wraps from the last entry to the first, gaplessly; 'one' restarts
  the current entry when it ends (not sample-gapless) and fires
  onStartNewTrack again. One helper, _nextIndexOf, answers 'what plays
  after this' for next(), preloading, scheduling and _applyOrder.
- snapshot() / restore(): plain data (entries, currentKey, currentTime,
  repeat) for persisting a queue and rebuilding it paused.
- onChange(listener): order, current entry or repeat changed. Batched into
  a microtask, once per edit, so listeners can edit the queue.
- README documents the key-based API.
- Fuzz: setRepeat op, a repeat-aware scheduling invariant, FUZZ_SEEDS for
  wider sweeps, and a 30 s timeout for loaded machines.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T05:25:54.205777Z a81669b New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Deploying gapless with  Cloudflare Pages  Cloudflare Pages

Latest commit: a81669b
Status: ✅  Deploy successful!
Preview URL: https://c5e5b6b6.gapless-e1l.pages.dev
Branch Preview URL: https://feat-play-order.gapless-e1l.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ecb18c5f4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Queue.ts Outdated
Comment on lines 365 to 366
this._orderVersion++;
this._actor.send({ type: 'ADD_TRACK' });

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 Reschedule the repeat wrap when appending a track

When the last entry is playing under repeat 'all', the first entry may already have a scheduled gapless start. Calling addTrack() changes the next entry, but ADD_TRACK only increments the machine's track count and never cancels that wrap schedule. When the current track ends, the machine starts the newly appended entry while the first entry's scheduled source also starts, producing overlapping audio. Reconcile the scheduled successor after appending, as the key-based editing path does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2d0d7a6. addTrack now re-checks what plays next, using the same helper as setRepeat, so a scheduled wrap to the first track is cancelled and the appended track gets scheduled instead. Test: "addTrack while the last entry plays under repeat 'all' moves the schedule off the wrap".

Comment thread src/Queue.ts Outdated
Comment on lines +503 to +505
// setEntries keeps a surviving current track where it is; restore moves it.
if (snapshot.currentKey && current && current.key === snapshot.currentKey) {
this.goto(snapshot.currentKey, { play, startTime: snapshot.currentTime });

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 Restore the saved entry when a different current entry survives

When restoring into a paused queue whose current entry survives in the snapshot but differs from snapshot.currentKey, setEntries() preserves that current entry and ignores startKey and startTime. This condition then skips the corrective goto(), so neither the saved entry nor its position is restored. For example, restoring a snapshot at b:30 into a paused queue currently on a, with both entries present, leaves it on a. Perform the corrective navigation whenever reconciliation preserves the old current entry, including when its key differs from the saved key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2d0d7a6. restore() now runs the corrective goto(currentKey, { startTime }) whenever setEntries kept the old current track, whether or not it's the saved one. Test: "restore moves to the saved entry when a different current entry survives" (a → b at 30 s).

Comment thread src/Track.ts Outdated
metadata?: TrackMetadata;
}) {
this.index = opts.index;
this.key = opts.key ?? `gapless-${++_autoKey}`;

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 Avoid generated-key collisions after restoring a snapshot

After a page reload, _autoKey resets to zero, but restoring entries with explicit keys does not advance it. Consequently, restoring a snapshot containing an automatically generated gapless-1 entry and then calling addTrack(url) generates another gapless-1. Key-based navigation and removal then cannot distinguish the entries, and a subsequent setEntries() reconciliation can reuse the same Track instance for both positions. Generate keys that cannot collide with keys already restored into the queue.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2d0d7a6. Queue now generates keys itself and skips any already in the queue, so keys restored from a snapshot can't collide with new ones. The test restores exactly the keys the counter would generate next, then adds tracks. The same commit also makes setEntries, insert and addTrack throw on duplicate keys supplied by the caller, before changing anything. That covers the same corruption when the caller passes the key.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0b6aad8a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Queue.ts
Comment on lines +416 to +420
this._actor.send({
type: 'GOTO',
index: startIndex,
playImmediately: opts.play ?? this.isPlaying,
startTime: opts.startTime,

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 Transition to paused when replacing entries with play disabled

When a playing queue's current entry is removed by setEntries(newEntries, { play: false }), the old track is stopped, but SYNC leaves the machine in playing. The subsequent GOTO takes the playing state's non-immediate branch, which also has no state transition and never starts the replacement track. The queue therefore remains silent while isPlaying reports true; calling play() cannot recover because the playing state has no PLAY handler. Explicitly transition to paused when replacing the current entry with playback disabled.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 1ed74a5. The cause is older than this PR. On master, gotoTrack(i, false) while playing does the same thing: the playing state's non-immediate GOTO had no target, so the machine stayed in playing with nothing playing. That transition now targets paused, as it already did from idle and ended, which fixes both gotoTrack(i, false) and setEntries(..., { play: false }). Tests: "setEntries({ play: false }) that drops the current entry pauses on the new one", "gotoTrack(i, false) pauses on the new track", and a machine test split into the immediate and non-immediate cases.

switz added 5 commits November 6, 2026 23:46
…que keys

- addTrack re-checks what plays next. Appending while the last track plays
  under repeat 'all' used to leave the first track's gapless start
  scheduled, so it and the new track both started.
- restore() moves to the saved entry and time whenever setEntries kept the
  old current track, not only when it was the saved one.
- Queue generates keys itself and skips keys already in the queue: after a
  reload the counter starts over and could collide with restored keys.
- setEntries, insert and addTrack throw on duplicate keys before changing
  anything.
GOTO without playImmediately from 'playing' deactivated the old track
and didn't start the new one, but stayed in 'playing': silent, with
isPlaying true and no PLAY handler to recover. It now moves to 'paused',
as it already did from idle and ended. This affected gotoTrack(i, false)
on master, and setEntries(..., { play: false }) when it drops the
current entry.
When the order changes mid-track (a shuffle, insert or move), the old next
track's gapless start was cancelled but its download kept going, competing
with the new next track for bandwidth. Now every preload pass, and every
reorder, aborts in-flight downloads outside the window (the current track
plus the next preloadNumTracks in play order). This also covers a goto to a
distant track.

- Track.cancelPreload(): stops the fetch/decode child (aborting its fetch)
  and resets the loading state, only while resolving the URL or fetching:
  a decode can't be aborted and assigns the buffer itself, and by then the
  bytes are already here. Decoded buffers are kept.
- The paused state's TRACK_ENDED didn't run preloadAhead, unlike every
  other move to a new track; it does now.
- Fuzz: the async scenario uses slow downloads so they're in flight across
  edits, with a 'nothing outside the window is downloading' invariant. With
  cancelling disabled it fails within a few ops.
The machine held currentTrackIndex and trackCount, events carried
indices, and Track kept a mutable index that every reorder renumbered,
while the public API identified tracks by key. Now there's one model:

- The queue machine is generic over its entries. Its context is
  { current, list }, where list is a PlayList the queue implements
  (next, previous, first, size, repeatsCurrent). Repeat lives only in
  the queue; SET_REPEAT is gone.
- Events carry entries: GOTO { track }, REMOVE_TRACK { track, successor },
  TRACK_LOADED { track }. SYNC and ADD_TRACK become LIST_CHANGED
  { current }: the queue says which entry is current.
- Track.index is gone. Queue._nextOf(track) is the one answer to 'what
  plays next' (wrapping under repeat 'all'), for the machine, preloading,
  scheduling and the preload window. onProgress and onPreloadReady
  compare tracks, not indices.
- Positions are derived at the edge: currentTrackIndex, TrackInfo.index,
  gotoTrack(i), removeTrack(i), queueSnapshot. The public API is
  unchanged.

Tests: the machine tests use a string PlayList and cover wrap, repeat
'one', LIST_CHANGED and removing the current entry. Fuzz invariants now
read the machine's real current entry and check scheduling and the
preload window against an independent position spec, instead of calling
the helpers they test.
@switz switz changed the title Key-based queue editing, repeat, snapshot/restore and onChange Key-based queue editing, repeat, snapshot/restore and onChange; tracks identified by reference Nov 7, 2026
@switz
switz merged commit 9e5b6e3 into master Nov 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant