feat: coordinate media playback state

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-15 17:27:27 +07:00
co-authored by Claude Opus 4.7
parent 1e0a00d82d
commit c509f48f95
2 changed files with 228 additions and 0 deletions
+119
View 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;
}
}