fix: guard media controller playback transitions

This commit is contained in:
MythEclipse
2026-05-15 17:40:28 +07:00
parent dbae042279
commit b00def2d4d
2 changed files with 75 additions and 7 deletions

View File

@@ -20,10 +20,14 @@ export interface MediaControllerDependencies {
export class MediaController { export class MediaController {
private readonly queueStore = new MediaQueue(); private readonly queueStore = new MediaQueue();
private readonly musicPlayer: MusicPlayer;
private playback: MusicPlayback | null = null; private playback: MusicPlayback | null = null;
private playbackToken = 0;
private skipInProgress = false; private skipInProgress = false;
constructor(private readonly dependencies: MediaControllerDependencies = {}) {} constructor(private readonly dependencies: MediaControllerDependencies = {}) {
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
}
getState(): MediaState { getState(): MediaState {
const snapshot = this.queueStore.snapshot(); const snapshot = this.queueStore.snapshot();
@@ -50,6 +54,7 @@ export class MediaController {
this.skipInProgress = true; this.skipInProgress = true;
try { try {
this.playbackToken++;
this.playback?.stop(); this.playback?.stop();
this.playback = null; this.playback = null;
this.queueStore.completeCurrent(); this.queueStore.completeCurrent();
@@ -61,6 +66,7 @@ export class MediaController {
} }
async stop(): Promise<MediaState> { async stop(): Promise<MediaState> {
this.playbackToken++;
this.playback?.stop(); this.playback?.stop();
this.playback = null; this.playback = null;
this.queueStore.clear(); this.queueStore.clear();
@@ -92,15 +98,25 @@ export class MediaController {
const item = this.queueStore.startNext(); const item = this.queueStore.startNext();
if (!item) return; if (!item) return;
const player = this.dependencies.musicPlayer ?? createMusicPlayer(); const token = ++this.playbackToken;
this.playback = player.play(item); try {
this.playback = this.musicPlayer.play(item);
} catch {
this.queueStore.failCurrent();
this.playback = null;
this.startNextIfIdle();
this.emitState();
return;
}
this.playback.done.then( this.playback.done.then(
() => this.finishCurrent(false), () => this.finishCurrent(token, false),
() => this.finishCurrent(true), () => this.finishCurrent(token, true),
); );
} }
private finishCurrent(failed: boolean): void { private finishCurrent(token: number, failed: boolean): void {
if (token !== this.playbackToken) return;
this.playback = null; this.playback = null;
if (failed) { if (failed) {
this.queueStore.failCurrent(); this.queueStore.failCurrent();

View File

@@ -112,6 +112,58 @@ describe("MediaController", () => {
expect(state.current?.title).toBe("second.mp3"); 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 () => { it("stops current playback and clears the queue", async () => {
const stop = vi.fn(); const stop = vi.fn();
const controller = new MediaController({ const controller = new MediaController({