feat: coordinate media playback state
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
119
src/media/mediaController.ts
Normal file
119
src/media/mediaController.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { AppError } from "../errors";
|
||||||
|
import { discordPlayer } from "../player";
|
||||||
|
import { MediaQueue } from "./mediaQueue";
|
||||||
|
import { resolveMediaSource } from "./mediaResolver";
|
||||||
|
import { createMusicPlayer } from "./musicPlayer";
|
||||||
|
import type {
|
||||||
|
MediaState,
|
||||||
|
MusicPlayback,
|
||||||
|
MusicPlayer,
|
||||||
|
ResolvedMediaSource,
|
||||||
|
} from "./mediaTypes";
|
||||||
|
|
||||||
|
export interface MediaControllerDependencies {
|
||||||
|
isVoiceConnected?: () => boolean;
|
||||||
|
isBrowserStreaming?: () => boolean;
|
||||||
|
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
||||||
|
musicPlayer?: MusicPlayer;
|
||||||
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaController {
|
||||||
|
private readonly queueStore = new MediaQueue();
|
||||||
|
private playback: MusicPlayback | null = null;
|
||||||
|
private skipInProgress = false;
|
||||||
|
|
||||||
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {}
|
||||||
|
|
||||||
|
getState(): MediaState {
|
||||||
|
const snapshot = this.queueStore.snapshot();
|
||||||
|
return {
|
||||||
|
playing: snapshot.current?.status === "playing",
|
||||||
|
...snapshot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async queue(source: string): Promise<MediaState> {
|
||||||
|
this.assertCanStart();
|
||||||
|
const resolved = await (this.dependencies.resolveMediaSource ?? resolveMediaSource)(
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
this.queueStore.add(resolved);
|
||||||
|
this.startNextIfIdle();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async skip(): Promise<MediaState> {
|
||||||
|
if (this.skipInProgress) {
|
||||||
|
throw new AppError("Skip already in progress", "MEDIA_SKIP_IN_PROGRESS", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.skipInProgress = true;
|
||||||
|
try {
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
this.startNextIfIdle();
|
||||||
|
return this.emitState();
|
||||||
|
} finally {
|
||||||
|
this.skipInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<MediaState> {
|
||||||
|
this.playback?.stop();
|
||||||
|
this.playback = null;
|
||||||
|
this.queueStore.clear();
|
||||||
|
return this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanStart(): void {
|
||||||
|
const isVoiceConnected = this.dependencies.isVoiceConnected ??
|
||||||
|
(() => discordPlayer.isConnected());
|
||||||
|
if (!isVoiceConnected()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Connect to a voice channel before playing media",
|
||||||
|
"VOICE_NOT_CONNECTED",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dependencies.isBrowserStreaming?.()) {
|
||||||
|
throw new AppError(
|
||||||
|
"Stop browser microphone streaming before playing media",
|
||||||
|
"BROWSER_STREAM_ACTIVE",
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startNextIfIdle(): void {
|
||||||
|
if (this.playback) return;
|
||||||
|
const item = this.queueStore.startNext();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const player = this.dependencies.musicPlayer ?? createMusicPlayer();
|
||||||
|
this.playback = player.play(item);
|
||||||
|
this.playback.done.then(
|
||||||
|
() => this.finishCurrent(false),
|
||||||
|
() => this.finishCurrent(true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishCurrent(failed: boolean): void {
|
||||||
|
this.playback = null;
|
||||||
|
if (failed) {
|
||||||
|
this.queueStore.failCurrent();
|
||||||
|
} else {
|
||||||
|
this.queueStore.completeCurrent();
|
||||||
|
}
|
||||||
|
this.startNextIfIdle();
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitState(): MediaState {
|
||||||
|
const state = this.getState();
|
||||||
|
this.dependencies.onStateChange?.(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
tests/media/mediaController.test.ts
Normal file
109
tests/media/mediaController.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AppError } from "../../src/errors";
|
||||||
|
import { MediaController } from "../../src/media/mediaController";
|
||||||
|
import type { MusicPlayback, MusicPlayer, ResolvedMediaSource } from "../../src/media/mediaTypes";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<void>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function source(input: string): ResolvedMediaSource {
|
||||||
|
return { source: input, title: input.split("/").pop() || input, kind: "url" };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MediaController", () => {
|
||||||
|
it("rejects queue playback when voice is not connected", async () => {
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => false,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async () => source("https://example.com/song.mp3"),
|
||||||
|
musicPlayer: { play: vi.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(controller.queue("https://example.com/song.mp3")).rejects.toMatchObject({
|
||||||
|
code: "VOICE_NOT_CONNECTED",
|
||||||
|
statusCode: 409,
|
||||||
|
} satisfies Partial<AppError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues and starts the first item", async () => {
|
||||||
|
const done = deferred();
|
||||||
|
const playback: MusicPlayback = { done: done.promise, stop: vi.fn() };
|
||||||
|
const musicPlayer: MusicPlayer = { play: vi.fn(() => playback) };
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async () => source("https://example.com/song.mp3"),
|
||||||
|
musicPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
expect(state.playing).toBe(true);
|
||||||
|
expect(state.current?.title).toBe("song.mp3");
|
||||||
|
expect(musicPlayer.play).toHaveBeenCalledWith(state.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances to the next item when playback finishes", async () => {
|
||||||
|
const first = deferred();
|
||||||
|
const second = deferred();
|
||||||
|
const musicPlayer: MusicPlayer = {
|
||||||
|
play: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce({ done: first.promise, stop: vi.fn() })
|
||||||
|
.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");
|
||||||
|
first.resolve();
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
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({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer: { play: vi.fn(() => ({ done: new Promise(() => {}), stop })) },
|
||||||
|
});
|
||||||
|
await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
const state = await controller.stop();
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalled();
|
||||||
|
expect(state).toEqual({ playing: false, current: null, queue: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits state changes", async () => {
|
||||||
|
const onStateChange = vi.fn();
|
||||||
|
const controller = new MediaController({
|
||||||
|
isVoiceConnected: () => true,
|
||||||
|
isBrowserStreaming: () => false,
|
||||||
|
resolveMediaSource: async (input) => source(input),
|
||||||
|
musicPlayer: { play: vi.fn(() => ({ done: new Promise(() => {}), stop: vi.fn() })) },
|
||||||
|
onStateChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.queue("https://example.com/song.mp3");
|
||||||
|
|
||||||
|
expect(onStateChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ playing: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user