Files
dc-recorder/src/media/mediaController.ts
T

238 lines
6.8 KiB
TypeScript
Raw Normal View History

2026-05-15 17:27:27 +07:00
import { AppError } from "../errors";
import { discordPlayer } from "../player";
import { MediaQueue } from "./mediaQueue";
import { resolveMediaSource } from "./mediaResolver";
import type {
MediaMode,
2026-05-15 17:27:27 +07:00
MediaState,
MusicPlayback,
MusicPlayer,
QueueMediaOptions,
2026-05-15 17:27:27 +07:00
ResolvedMediaSource,
ScreenShareController,
ScreenSharePlayback,
2026-05-15 17:27:27 +07:00
} from "./mediaTypes";
2026-05-15 18:04:39 +07:00
import { createMusicPlayer } from "./musicPlayer";
2026-05-15 17:27:27 +07:00
export interface MediaControllerDependencies {
isVoiceConnected?: () => boolean;
isBrowserStreaming?: () => boolean;
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
2026-05-15 17:27:27 +07:00
musicPlayer?: MusicPlayer;
screenController?: ScreenShareController;
2026-05-15 17:27:27 +07:00
onStateChange?: (state: MediaState) => void;
initialMusicVolume?: number;
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
setMusicVolume?: (volume: number) => void;
2026-05-15 17:27:27 +07:00
}
export class MediaController {
private readonly queueStore = new MediaQueue();
private readonly musicPlayer: MusicPlayer;
2026-05-15 17:27:27 +07:00
private playback: MusicPlayback | null = null;
private playbackToken = 0;
2026-05-15 17:27:27 +07:00
private skipInProgress = false;
private screenPlayback: ScreenSharePlayback | null = null;
private activeMode: MediaMode | null = null;
private musicVolume: number;
private readonly setPlayerMusicVolume: (volume: number) => void;
2026-05-15 17:27:27 +07:00
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
this.setPlayerMusicVolume =
dependencies.setMusicVolume ??
((volume) => {
discordPlayer.setMusicVolume(volume);
});
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
this.setPlayerMusicVolume(this.musicVolume);
}
2026-05-15 17:27:27 +07:00
getState(): MediaState {
const snapshot = this.queueStore.snapshot();
return {
playing:
this.activeMode === "screen" || snapshot.current?.status === "playing",
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
musicVolume: this.musicVolume,
2026-05-15 17:27:27 +07:00
...snapshot,
};
}
async setMusicVolume(volume: number): Promise<MediaState> {
const nextVolume = normalizeVolume(volume, this.musicVolume);
if (this.musicVolume === nextVolume) return this.emitState();
this.musicVolume = nextVolume;
this.setPlayerMusicVolume(nextVolume);
await this.dependencies.onMusicVolumeChange?.(nextVolume);
return this.emitState();
}
async queue(
source: string,
options: QueueMediaOptions = {},
): Promise<MediaState> {
const mode = options.mode ?? "music";
const resolved = await (
this.dependencies.resolveMediaSource ?? resolveMediaSource
)(source, mode);
if (mode === "screen") {
// Stop current music if any
this.playbackToken++;
this.playback?.stop();
this.playback = null;
return this.startScreen(resolved.source);
}
// mode === "music"
// If a screen share is active outside of this controller (browser-owned),
// reject to avoid stealing the shared player. If this controller started
// the screenPlayback, stop it and proceed.
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
}
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
}
this.assertCanStartMusic();
this.queueStore.add(resolved, mode, options.requestedBy);
2026-05-15 17:27:27 +07:00
this.startNextIfIdle();
return this.emitState();
}
async skip(): Promise<MediaState> {
if (this.skipInProgress) {
2026-05-15 18:04:39 +07:00
throw new AppError(
"Skip already in progress",
"MEDIA_SKIP_IN_PROGRESS",
409,
);
2026-05-15 17:27:27 +07:00
}
this.skipInProgress = true;
try {
this.playbackToken++;
2026-05-15 17:27:27 +07:00
this.playback?.stop();
this.playback = null;
this.queueStore.completeCurrent();
this.startNextIfIdle();
return this.emitState();
} finally {
this.skipInProgress = false;
}
}
async stop(): Promise<MediaState> {
this.playbackToken++;
2026-05-15 17:27:27 +07:00
this.playback?.stop();
this.playback = null;
this.screenPlayback?.stop();
this.screenPlayback = null;
this.activeMode = null;
2026-05-15 17:27:27 +07:00
this.queueStore.clear();
return this.emitState();
}
private assertCanStartMusic(): void {
2026-05-15 18:04:39 +07:00
const isVoiceConnected =
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
2026-05-15 17:27:27 +07:00
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 async startScreen(source: string): Promise<MediaState> {
const screenController = this.dependencies.screenController;
if (!screenController) {
throw new AppError(
"Screen sharing is unavailable",
"SCREEN_UNAVAILABLE",
500,
);
}
this.activeMode = "screen";
try {
this.screenPlayback = await screenController.start(source);
} catch (error) {
this.activeMode = null;
throw error;
}
this.screenPlayback.done.then(
() => this.finishScreen(),
() => this.finishScreen(),
);
return this.emitState();
}
private finishScreen(): void {
if (!this.screenPlayback || this.activeMode !== "screen") return;
this.screenPlayback = null;
this.activeMode = null;
this.emitState();
}
2026-05-15 17:27:27 +07:00
private startNextIfIdle(): void {
if (this.playback) return;
const item = this.queueStore.startNext();
if (!item) return;
const token = ++this.playbackToken;
try {
this.playback = this.musicPlayer.play(item);
} catch {
this.queueStore.failCurrent();
this.playback = null;
this.startNextIfIdle();
this.emitState();
return;
}
2026-05-15 17:27:27 +07:00
this.playback.done.then(
() => this.finishCurrent(token, false),
() => this.finishCurrent(token, true),
2026-05-15 17:27:27 +07:00
);
}
private finishCurrent(token: number, failed: boolean): void {
if (token !== this.playbackToken) return;
2026-05-15 17:27:27 +07:00
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;
}
}
function normalizeVolume(value: number | undefined, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.max(0, Math.min(1, value as number));
}