fix: guard media controller playback transitions
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user