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 {
|
2026-05-16 15:48:28 +07:00
|
|
|
MediaMode,
|
2026-05-15 17:27:27 +07:00
|
|
|
MediaState,
|
|
|
|
|
MusicPlayback,
|
|
|
|
|
MusicPlayer,
|
2026-05-16 15:48:28 +07:00
|
|
|
QueueMediaOptions,
|
2026-05-15 17:27:27 +07:00
|
|
|
ResolvedMediaSource,
|
2026-05-16 15:48:28 +07:00
|
|
|
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;
|
2026-05-17 20:44:13 +07:00
|
|
|
resolveMediaSource?: (source: string, mode?: MediaMode) => Promise<ResolvedMediaSource>;
|
2026-05-15 17:27:27 +07:00
|
|
|
musicPlayer?: MusicPlayer;
|
2026-05-16 15:48:28 +07:00
|
|
|
screenController?: ScreenShareController;
|
2026-05-15 17:27:27 +07:00
|
|
|
onStateChange?: (state: MediaState) => void;
|
2026-05-17 04:52:20 +07:00
|
|
|
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();
|
2026-05-15 17:40:28 +07:00
|
|
|
private readonly musicPlayer: MusicPlayer;
|
2026-05-15 17:27:27 +07:00
|
|
|
private playback: MusicPlayback | null = null;
|
2026-05-15 17:40:28 +07:00
|
|
|
private playbackToken = 0;
|
2026-05-15 17:27:27 +07:00
|
|
|
private skipInProgress = false;
|
2026-05-16 15:48:28 +07:00
|
|
|
private screenPlayback: ScreenSharePlayback | null = null;
|
|
|
|
|
private activeMode: MediaMode | null = null;
|
2026-05-17 04:52:20 +07:00
|
|
|
private musicVolume: number;
|
|
|
|
|
private readonly setPlayerMusicVolume: (volume: number) => void;
|
2026-05-15 17:27:27 +07:00
|
|
|
|
2026-05-15 17:40:28 +07:00
|
|
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
|
|
|
|
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
2026-05-17 04:52:20 +07:00
|
|
|
this.setPlayerMusicVolume =
|
|
|
|
|
dependencies.setMusicVolume ??
|
|
|
|
|
((volume) => {
|
|
|
|
|
discordPlayer.setMusicVolume(volume);
|
|
|
|
|
});
|
|
|
|
|
this.musicVolume = normalizeVolume(dependencies.initialMusicVolume, 1);
|
|
|
|
|
this.setPlayerMusicVolume(this.musicVolume);
|
2026-05-15 17:40:28 +07:00
|
|
|
}
|
2026-05-15 17:27:27 +07:00
|
|
|
|
|
|
|
|
getState(): MediaState {
|
|
|
|
|
const snapshot = this.queueStore.snapshot();
|
|
|
|
|
return {
|
2026-05-16 15:48:28 +07:00
|
|
|
playing:
|
|
|
|
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
|
|
|
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
2026-05-17 04:52:20 +07:00
|
|
|
musicVolume: this.musicVolume,
|
2026-05-15 17:27:27 +07:00
|
|
|
...snapshot,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 04:52:20 +07:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
async queue(
|
|
|
|
|
source: string,
|
|
|
|
|
options: QueueMediaOptions = {},
|
|
|
|
|
): Promise<MediaState> {
|
|
|
|
|
const mode = options.mode ?? "music";
|
2026-05-17 20:44:13 +07:00
|
|
|
|
|
|
|
|
const resolved = await (
|
|
|
|
|
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
|
|
|
|
)(source, mode);
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
if (mode === "screen") {
|
2026-05-16 23:11:46 +07:00
|
|
|
// Stop current music if any
|
|
|
|
|
this.playbackToken++;
|
|
|
|
|
this.playback?.stop();
|
|
|
|
|
this.playback = null;
|
2026-05-17 20:44:13 +07:00
|
|
|
return this.startScreen(resolved.source);
|
2026-05-16 15:48:28 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-16 23:11:46 +07:00
|
|
|
// mode === "music"
|
2026-05-17 05:10:46 +07:00
|
|
|
// 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.
|
2026-05-16 23:11:46 +07:00
|
|
|
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
2026-05-17 05:10:46 +07:00
|
|
|
if (this.dependencies.screenController?.isActive() && !this.screenPlayback) {
|
|
|
|
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
|
|
|
|
}
|
2026-05-16 23:11:46 +07:00
|
|
|
this.screenPlayback?.stop();
|
|
|
|
|
this.screenPlayback = null;
|
|
|
|
|
this.activeMode = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
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 {
|
2026-05-15 17:40:28 +07:00
|
|
|
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> {
|
2026-05-15 17:40:28 +07:00
|
|
|
this.playbackToken++;
|
2026-05-15 17:27:27 +07:00
|
|
|
this.playback?.stop();
|
|
|
|
|
this.playback = null;
|
2026-05-16 15:48:28 +07:00
|
|
|
this.screenPlayback?.stop();
|
|
|
|
|
this.screenPlayback = null;
|
|
|
|
|
this.activeMode = null;
|
2026-05-15 17:27:27 +07:00
|
|
|
this.queueStore.clear();
|
|
|
|
|
return this.emitState();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
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;
|
|
|
|
|
|
2026-05-15 17:40:28 +07:00
|
|
|
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(
|
2026-05-15 17:40:28 +07:00
|
|
|
() => this.finishCurrent(token, false),
|
|
|
|
|
() => this.finishCurrent(token, true),
|
2026-05-15 17:27:27 +07:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:40:28 +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;
|
|
|
|
|
}
|
2026-05-15 17:40:28 +07:00
|
|
|
}
|
2026-05-17 04:52:20 +07:00
|
|
|
|
|
|
|
|
function normalizeVolume(value: number | undefined, fallback: number): number {
|
|
|
|
|
if (!Number.isFinite(value)) return fallback;
|
|
|
|
|
return Math.max(0, Math.min(1, value as number));
|
|
|
|
|
}
|