feat: coordinate media playback state
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1e0a00d82d
commit
c509f48f95
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user