diff --git a/src/media/mediaController.ts b/src/media/mediaController.ts index ef9d310..96f72cd 100644 --- a/src/media/mediaController.ts +++ b/src/media/mediaController.ts @@ -20,10 +20,14 @@ export interface MediaControllerDependencies { export class MediaController { private readonly queueStore = new MediaQueue(); + private readonly musicPlayer: MusicPlayer; private playback: MusicPlayback | null = null; + private playbackToken = 0; private skipInProgress = false; - constructor(private readonly dependencies: MediaControllerDependencies = {}) {} + constructor(private readonly dependencies: MediaControllerDependencies = {}) { + this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer(); + } getState(): MediaState { const snapshot = this.queueStore.snapshot(); @@ -50,6 +54,7 @@ export class MediaController { this.skipInProgress = true; try { + this.playbackToken++; this.playback?.stop(); this.playback = null; this.queueStore.completeCurrent(); @@ -61,6 +66,7 @@ export class MediaController { } async stop(): Promise { + this.playbackToken++; this.playback?.stop(); this.playback = null; this.queueStore.clear(); @@ -92,15 +98,25 @@ export class MediaController { const item = this.queueStore.startNext(); if (!item) return; - const player = this.dependencies.musicPlayer ?? createMusicPlayer(); - this.playback = player.play(item); + const token = ++this.playbackToken; + try { + this.playback = this.musicPlayer.play(item); + } catch { + this.queueStore.failCurrent(); + this.playback = null; + this.startNextIfIdle(); + this.emitState(); + return; + } + this.playback.done.then( - () => this.finishCurrent(false), - () => this.finishCurrent(true), + () => this.finishCurrent(token, false), + () => this.finishCurrent(token, true), ); } - private finishCurrent(failed: boolean): void { + private finishCurrent(token: number, failed: boolean): void { + if (token !== this.playbackToken) return; this.playback = null; if (failed) { this.queueStore.failCurrent(); @@ -116,4 +132,4 @@ export class MediaController { this.dependencies.onStateChange?.(state); return state; } -} \ No newline at end of file +} diff --git a/tests/media/mediaController.test.ts b/tests/media/mediaController.test.ts index 4bac3c2..9d62186 100644 --- a/tests/media/mediaController.test.ts +++ b/tests/media/mediaController.test.ts @@ -112,6 +112,58 @@ describe("MediaController", () => { expect(state.current?.title).toBe("second.mp3"); }); + it("ignores stale completion after skip starts the next item", async () => { + const first = deferred(); + const second = deferred(); + const third = deferred(); + const musicPlayer: MusicPlayer = { + play: vi + .fn() + .mockReturnValueOnce({ done: first.promise, stop: vi.fn() }) + .mockReturnValueOnce({ done: second.promise, stop: vi.fn() }) + .mockReturnValueOnce({ done: third.promise, stop: vi.fn() }), + }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer, + }); + await controller.queue("https://example.com/first.mp3"); + await controller.queue("https://example.com/second.mp3"); + await controller.queue("https://example.com/third.mp3"); + + await controller.skip(); + first.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(controller.getState().current?.title).toBe("second.mp3"); + expect(musicPlayer.play).toHaveBeenCalledTimes(2); + }); + + it("advances when player throws while starting an item", async () => { + const second = deferred(); + const musicPlayer: MusicPlayer = { + play: vi + .fn() + .mockImplementationOnce(() => { + throw new Error("not connected"); + }) + .mockReturnValueOnce({ done: second.promise, stop: vi.fn() }), + }; + const controller = new MediaController({ + isVoiceConnected: () => true, + isBrowserStreaming: () => false, + resolveMediaSource: async (input) => source(input), + musicPlayer, + }); + + await controller.queue("https://example.com/first.mp3"); + await controller.queue("https://example.com/second.mp3"); + + expect(controller.getState().current?.title).toBe("second.mp3"); + }); + it("stops current playback and clears the queue", async () => { const stop = vi.fn(); const controller = new MediaController({