From c509f48f956f91229c94f76c231e144069a01095 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 17:27:27 +0700 Subject: [PATCH] feat: coordinate media playback state Co-Authored-By: Claude Opus 4.7 --- src/media/mediaController.ts | 119 ++++++++++++++++++++++++++++ tests/media/mediaController.test.ts | 109 +++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/media/mediaController.ts create mode 100644 tests/media/mediaController.test.ts diff --git a/src/media/mediaController.ts b/src/media/mediaController.ts new file mode 100644 index 0000000..ef9d310 --- /dev/null +++ b/src/media/mediaController.ts @@ -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; + 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 { + this.assertCanStart(); + const resolved = await (this.dependencies.resolveMediaSource ?? resolveMediaSource)( + source, + ); + this.queueStore.add(resolved); + this.startNextIfIdle(); + return this.emitState(); + } + + async skip(): Promise { + 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 { + 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; + } +} \ No newline at end of file diff --git a/tests/media/mediaController.test.ts b/tests/media/mediaController.test.ts new file mode 100644 index 0000000..0bb56e7 --- /dev/null +++ b/tests/media/mediaController.test.ts @@ -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((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); + }); + + 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 }), + ); + }); +});