Repository navigation
Abort a blocked play()'s downloads (5.0.0-alpha.2) #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
d4b278c
0c9f5cf
a7d1f3b
a24bece
269f7af
0d8f3f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,8 @@ export class Track { | |
| readonly skipHEAD: boolean; | ||
| /** Temporary holder between fetch and decode steps (unserializable — stays on Track class). */ | ||
| private _pendingArrayBuffer: ArrayBuffer | null = null; | ||
| /** Bumped by unload(): a fetch or decode that finishes after it is dropped. */ | ||
| private _loadGeneration = 0; | ||
|
|
||
| // ---- HTML5 Audio --------------------------------------------------------- | ||
| readonly audio: HTMLAudioElement; | ||
|
|
@@ -236,15 +238,21 @@ export class Track { | |
| }), | ||
| fetchAudio: fromPromise(async ({ input, signal }) => { | ||
| const { resolvedUrl } = input as { resolvedUrl: string }; | ||
| const generation = this._loadGeneration; | ||
| const res = await fetch(resolvedUrl, { signal }); | ||
| if (!res.ok) throw new Error(`HTTP ${res.status} for ${resolvedUrl}`); | ||
| this._pendingArrayBuffer = await res.arrayBuffer(); | ||
| const bytes = await res.arrayBuffer(); | ||
| if (generation === this._loadGeneration) this._pendingArrayBuffer = bytes; | ||
| }), | ||
| decodeAudio: fromPromise(async () => { | ||
| const buf = this._pendingArrayBuffer; | ||
| this._pendingArrayBuffer = null; | ||
| if (!buf || !this.ctx) throw new Error('No ArrayBuffer or AudioContext'); | ||
| this.audioBuffer = await this.ctx.decodeAudioData(buf); | ||
| const generation = this._loadGeneration; | ||
| const decoded = await this.ctx.decodeAudioData(buf); | ||
| // Unloaded mid-decode (which can't be aborted): drop the result. | ||
| if (generation !== this._loadGeneration) return; | ||
| this.audioBuffer = decoded; | ||
| this._maybeComputeBufferAlignment(); | ||
| }), | ||
| }, | ||
|
|
@@ -309,6 +317,8 @@ export class Track { | |
| this._seekAbort = new AbortController(); | ||
| this.audio.currentTime = 0; | ||
| }, | ||
| releaseHtml5Element: () => this._releaseHtml5Element(), | ||
| dropPendingDecode: () => this._dropPendingDecode(), | ||
| resetTiming: () => { | ||
| this._waRefCtxTime = 0; | ||
| this._waRefTrackTime = 0; | ||
|
|
@@ -407,21 +417,23 @@ export class Track { | |
| } | ||
|
|
||
| /** | ||
| * Abort an in-flight Web Audio download, so the track can be preloaded again | ||
| * later from scratch. Only while the URL is being resolved or the file | ||
| * fetched: once decoding starts the bytes are already here, and the decode | ||
| * (which assigns audioBuffer itself) can't be aborted. Returns whether a | ||
| * download was cancelled. | ||
| * Let go of this track's Web Audio data, so a track out of the preload | ||
| * window doesn't hold memory (a decoded hour of audio is hundreds of MB): | ||
| * abort its download, drop a decode in progress when it finishes (decoding | ||
| * can't be aborted), or release its decoded buffer. It loads again from | ||
| * scratch if it comes back into the window. Never while it's playing, | ||
| * scheduled to play, or waiting for its buffer to play. Returns whether | ||
| * anything was let go. | ||
| */ | ||
| cancelPreload(): boolean { | ||
| const snap = this._actor.getSnapshot(); | ||
| if (snap.context.webAudioLoadingState !== 'LOADING' || snap.context.isPlaying) return false; | ||
| const child = snap.children.fetchDecode as { getSnapshot(): { value: unknown } } | undefined; | ||
| const stage = child?.getSnapshot().value; | ||
| if (stage !== 'resolvingUrl' && stage !== 'fetching') return false; | ||
| this.queueRef.onDebug(`cancelPreload() track=${this.key} stage=${String(stage)}`); | ||
| this._pendingArrayBuffer = null; | ||
| this._actor.send({ type: 'CANCEL_PRELOAD' }); | ||
| unload(): boolean { | ||
| const { context } = this._actor.getSnapshot(); | ||
| const state = context.webAudioLoadingState; | ||
| if (state !== 'LOADING' && state !== 'LOADED') return false; | ||
| if (context.isPlaying || context.pendingPlay) return false; | ||
| this.queueRef.onDebug(`unload() track=${this.key} state=${state}`); | ||
| this._dropPendingDecode(); | ||
| this.audioBuffer = null; | ||
| this._actor.send({ type: 'UNLOAD' }); | ||
|
Comment on lines
+434
to
+439
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Web Audio track ends naturally, Useful? React with 👍 / 👎. |
||
| return true; | ||
| } | ||
|
|
||
|
|
@@ -609,6 +621,8 @@ export class Track { | |
| if (promise) { | ||
| promise.catch((err: unknown) => { | ||
| if (err instanceof Error && err.name === 'NotAllowedError') { | ||
| // First, so the queue (and the app's callback) see a stopped track. | ||
| this._actor.send({ type: 'PLAY_BLOCKED' }); | ||
| this.queueRef.onPlayBlocked(); | ||
| } else if (err instanceof Error && err.name === 'AbortError') { | ||
| // Browser aborted — element will recover on next play() | ||
|
|
@@ -619,6 +633,27 @@ export class Track { | |
| } | ||
| } | ||
|
|
||
| /** A fetch or decode that finishes from now on is dropped (see unload()). */ | ||
| private _dropPendingDecode(): void { | ||
| this._loadGeneration++; | ||
| this._pendingArrayBuffer = null; | ||
| } | ||
|
|
||
| /** | ||
| * Stop the element's download without an error event: drop its source | ||
| * (which aborts the fetch) and put it back unloaded, so the next play() | ||
| * starts it fresh. | ||
| */ | ||
| private _releaseHtml5Element(): void { | ||
| this.audio.pause(); | ||
| this._seekAbort.abort(); | ||
| this._seekAbort = new AbortController(); | ||
| this.audio.preload = 'none'; | ||
| this.audio.removeAttribute('src'); | ||
| this.audio.load(); | ||
| this.audio.src = this._trackUrl; | ||
| } | ||
|
|
||
| private _seekHtml5(target: number): void { | ||
| if (!isFinite(target)) return; | ||
| if (this.audio.preload !== 'auto') this.audio.preload = 'auto'; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a track that previously played through Web Audio leaves the preload window, unloading clears its buffer but leaves
context.playbackTypeasWEBAUDIO. Returning to that track before it is preloaded again starts HTML5 playback, but neitherACTIVATEnor the HTML5PLAYtransition resets this field. Besides reporting the wrong playback type throughTrackInfo, this bypasses the bandwidth-contention guard inQueue._preloadAhead, which checks forplaybackType === 'HTML5': after jumping away and back, the next track can download concurrently with the current track's fresh fetch, delaying its crossover. Reset the playback type when entering HTML5 playback so replaying an unloaded track follows the same loading policy as its first play.Useful? React with 👍 / 👎.