diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dad95f7..90afd6d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -234,6 +234,7 @@ export default function App() { onStartScreen={(source) => media.enqueue(source, "screen")} onSkip={media.skip} onStop={media.stop} + onVolumeChange={media.setVolume} /> )} diff --git a/frontend/src/api/media.ts b/frontend/src/api/media.ts index b23cea5..931b21b 100644 --- a/frontend/src/api/media.ts +++ b/frontend/src/api/media.ts @@ -19,3 +19,10 @@ export function skipMedia(): Promise { export function stopMedia(): Promise { return request('/api/media/stop', { method: 'POST' }); } + +export function setMediaVolume(volume: number): Promise { + return request('/api/media/volume', { + method: 'POST', + body: JSON.stringify({ volume }), + }); +} diff --git a/frontend/src/components/media/MediaPanel.tsx b/frontend/src/components/media/MediaPanel.tsx index 15ee4ff..2acefb9 100644 --- a/frontend/src/components/media/MediaPanel.tsx +++ b/frontend/src/components/media/MediaPanel.tsx @@ -11,9 +11,18 @@ interface MediaPanelProps { onStartScreen: (source: string) => void; onSkip: () => void; onStop: () => void; + onVolumeChange: (volume: number) => void; } -export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip, onStop }: MediaPanelProps) { +export function MediaPanel({ + state, + loading, + onQueueMusic, + onStartScreen, + onSkip, + onStop, + onVolumeChange, +}: MediaPanelProps) { return (
@@ -22,7 +31,14 @@ export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip Screen Share - + diff --git a/frontend/src/components/media/MusicPlayer.tsx b/frontend/src/components/media/MusicPlayer.tsx index 403263b..981aad8 100644 --- a/frontend/src/components/media/MusicPlayer.tsx +++ b/frontend/src/components/media/MusicPlayer.tsx @@ -1,18 +1,42 @@ import { Music2 } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "../ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { Input } from "../ui/input"; interface MusicPlayerProps { loading: boolean; + volume: number; + onVolumeChange: (volume: number) => void; onQueue: (source: string) => void; onSkip: () => void; onStop: () => void; } -export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerProps) { +export function MusicPlayer({ + loading, + volume, + onVolumeChange, + onQueue, + onSkip, + onStop, +}: MusicPlayerProps) { const [source, setSource] = useState(""); + const safeVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1; + const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100)); + + useEffect(() => { + setDraftVolume(Math.round(safeVolume * 100)); + }, [safeVolume]); + + useEffect(() => { + const normalized = draftVolume / 100; + if (Math.abs(normalized - safeVolume) < 0.001) return; + const timer = window.setTimeout(() => { + onVolumeChange(normalized); + }, 150); + return () => window.clearTimeout(timer); + }, [draftVolume, onVolumeChange, safeVolume]); const submit = () => { const trimmed = source.trim(); @@ -34,6 +58,21 @@ export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerPro onKeyDown={(event) => event.key === "Enter" && submit()} placeholder="YouTube URL, Spotify track, or search terms" /> +
+
+ Volume + {draftVolume}% +
+ setDraftVolume(Number(event.target.value))} + className="h-2 w-full cursor-pointer accent-primary" + /> +
diff --git a/frontend/src/hooks/useMediaControl.ts b/frontend/src/hooks/useMediaControl.ts index 7719bb1..4dff457 100644 --- a/frontend/src/hooks/useMediaControl.ts +++ b/frontend/src/hooks/useMediaControl.ts @@ -1,8 +1,19 @@ import { useCallback, useEffect, useState } from "react"; -import { getMediaStatus, queueMedia, skipMedia, stopMedia } from "../api/media"; +import { + getMediaStatus, + queueMedia, + setMediaVolume, + skipMedia, + stopMedia, +} from "../api/media"; import type { MediaMode, MediaState } from "../types/media"; -const emptyMediaState: MediaState = { playing: false, current: null, queue: [] }; +const emptyMediaState: MediaState = { + playing: false, + musicVolume: 1, + current: null, + queue: [], +}; export function useMediaControl() { const [mediaState, setMediaState] = useState(emptyMediaState); @@ -55,9 +66,32 @@ export function useMediaControl() { } }, []); + const setVolume = useCallback(async (volume: number) => { + setError(null); + try { + const state = await setMediaVolume(volume); + setMediaState(state); + return state; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + throw err; + } + }, []); + useEffect(() => { refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [refreshMedia]); - return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop }; + return { + mediaState, + setMediaState, + loading, + error, + refreshMedia, + enqueue, + skip, + stop, + setVolume, + }; } diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts index 134dc72..b54573d 100644 --- a/frontend/src/types/media.ts +++ b/frontend/src/types/media.ts @@ -11,6 +11,7 @@ export interface MediaItem { export interface MediaState { playing: boolean; + musicVolume: number; current: MediaItem | null; queue: MediaItem[]; } diff --git a/src/media/mediaController.ts b/src/media/mediaController.ts index 7b33ccd..ff8b7a4 100644 --- a/src/media/mediaController.ts +++ b/src/media/mediaController.ts @@ -21,6 +21,9 @@ export interface MediaControllerDependencies { musicPlayer?: MusicPlayer; screenController?: ScreenShareController; onStateChange?: (state: MediaState) => void; + initialMusicVolume?: number; + onMusicVolumeChange?: (volume: number) => void | Promise; + setMusicVolume?: (volume: number) => void; } export class MediaController { @@ -31,9 +34,18 @@ export class MediaController { private skipInProgress = false; private screenPlayback: ScreenSharePlayback | null = null; private activeMode: MediaMode | null = null; + private musicVolume: number; + private readonly setPlayerMusicVolume: (volume: number) => void; 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); } getState(): MediaState { @@ -42,10 +54,20 @@ export class MediaController { playing: this.activeMode === "screen" || snapshot.current?.status === "playing", activeMode: this.activeMode ?? snapshot.current?.mode ?? null, + musicVolume: this.musicVolume, ...snapshot, }; } + async setMusicVolume(volume: number): Promise { + 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 = {}, @@ -201,3 +223,8 @@ export class MediaController { 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)); +} diff --git a/src/media/mediaTypes.ts b/src/media/mediaTypes.ts index ec77c53..0694522 100644 --- a/src/media/mediaTypes.ts +++ b/src/media/mediaTypes.ts @@ -1,4 +1,5 @@ import type { Readable } from "node:stream"; +import type { StreamType } from "@discordjs/voice"; export type MediaMode = "music" | "screen"; export type MediaSourceKind = @@ -26,6 +27,7 @@ export interface MediaQueueItem extends ResolvedMediaSource { export interface MediaState { playing: boolean; activeMode: MediaMode | null; + musicVolume: number; current: MediaQueueItem | null; queue: MediaQueueItem[]; } @@ -56,11 +58,23 @@ export interface ScreenShareController { export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen"; +export interface DiscordPlayOptions { + inputType?: StreamType; + inlineVolume?: boolean; + volume?: number; +} + export interface DiscordAudioPlayer { getOwner(): DiscordPlayerOwner; isConnected(): boolean; - playStream(stream: Readable, owner: DiscordPlayerOwner): void; + playStream( + stream: Readable, + owner: DiscordPlayerOwner, + options?: DiscordPlayOptions, + ): void; pause(owner?: DiscordPlayerOwner): void; unpause(owner?: DiscordPlayerOwner): boolean; stop(owner?: DiscordPlayerOwner): void; + getMusicVolume(): number; + setMusicVolume(volume: number): void; } diff --git a/src/media/musicPlayer.ts b/src/media/musicPlayer.ts index 75b1418..21d0d0e 100644 --- a/src/media/musicPlayer.ts +++ b/src/media/musicPlayer.ts @@ -1,5 +1,6 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn as nodeSpawn } from "node:child_process"; +import { StreamType } from "@discordjs/voice"; import { discordPlayer } from "../player"; import type { DiscordAudioPlayer, @@ -30,7 +31,10 @@ export function createMusicPlayer( }) as unknown as ChildProcessWithoutNullStreams; proc.stderr.resume(); - audioPlayer.playStream(proc.stdout, "music"); + audioPlayer.playStream(proc.stdout, "music", { + inputType: StreamType.Raw, + inlineVolume: true, + }); let stopped = false; let released = false; @@ -81,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] { source, "-vn", "-acodec", - "libopus", + "pcm_s16le", "-ar", "48000", "-ac", "2", "-f", - "ogg", + "s16le", "pipe:1", ]; } diff --git a/src/moderation/types.ts b/src/moderation/types.ts index 055f190..501be3e 100644 --- a/src/moderation/types.ts +++ b/src/moderation/types.ts @@ -114,6 +114,7 @@ export interface MediaQueueItem { export interface MediaState { playing: boolean; + musicVolume: number; current: MediaQueueItem | null; queue: MediaQueueItem[]; } diff --git a/src/player.ts b/src/player.ts index efdb47d..84117ab 100644 --- a/src/player.ts +++ b/src/player.ts @@ -2,17 +2,23 @@ import { Readable } from "node:stream"; import { AudioPlayer, AudioPlayerStatus, + type AudioResource, createAudioPlayer, createAudioResource, StreamType, VoiceConnection, } from "@discordjs/voice"; -import type { DiscordPlayerOwner } from "./media/mediaTypes"; +import type { + DiscordPlayOptions, + DiscordPlayerOwner, +} from "./media/mediaTypes"; export class DiscordPlayer { private player: AudioPlayer; private connection: VoiceConnection | null = null; private owner: DiscordPlayerOwner = "none"; + private resource: AudioResource | null = null; + private musicVolume = 1; constructor() { this.player = createAudioPlayer(); @@ -24,6 +30,7 @@ export class DiscordPlayer { this.player.on("error", (error) => { console.error(`[player] Error: ${error.message}`); this.owner = "none"; + this.resource = null; }); } @@ -40,20 +47,34 @@ export class DiscordPlayer { return this.connection !== null; } - public playStream(stream: Readable, owner: DiscordPlayerOwner) { + public playStream( + stream: Readable, + owner: DiscordPlayerOwner, + options: DiscordPlayOptions = {}, + ) { if (owner === "none") { throw new Error("Discord audio player owner is required"); } this.assertOwnerAvailable(owner); const resource = createAudioResource(stream, { - inputType: StreamType.OggOpus, + inputType: options.inputType ?? StreamType.OggOpus, + inlineVolume: options.inlineVolume ?? false, }); if (this.owner === owner) { this.player.stop(); } + this.resource = resource; this.owner = owner; + if (owner === "music") { + const nextVolume = + options.volume !== undefined + ? this.normalizeVolume(options.volume) + : this.musicVolume; + this.musicVolume = nextVolume; + this.setResourceVolume(nextVolume); + } this.player.play(resource); this.connection?.subscribe(this.player); } @@ -76,6 +97,19 @@ export class DiscordPlayer { if (!this.canControl(owner)) return; this.player.stop(); this.owner = "none"; + this.resource = null; + } + + public getMusicVolume(): number { + return this.musicVolume; + } + + public setMusicVolume(volume: number): void { + const nextVolume = this.normalizeVolume(volume); + this.musicVolume = nextVolume; + if (this.owner === "music") { + this.setResourceVolume(nextVolume); + } } private assertOwnerAvailable(owner: DiscordPlayerOwner): void { @@ -87,6 +121,16 @@ export class DiscordPlayer { private canControl(owner?: DiscordPlayerOwner): boolean { return !owner || this.owner === "none" || this.owner === owner; } + + private normalizeVolume(volume: number): number { + if (!Number.isFinite(volume)) return this.musicVolume; + return Math.max(0, Math.min(1, volume)); + } + + private setResourceVolume(volume: number): void { + if (!this.resource?.volume) return; + this.resource.volume.setVolume(volume); + } } export const discordPlayer = new DiscordPlayer(); diff --git a/src/routes/mediaRoutes.ts b/src/routes/mediaRoutes.ts index 6c0e4ab..515cdfd 100644 --- a/src/routes/mediaRoutes.ts +++ b/src/routes/mediaRoutes.ts @@ -6,7 +6,7 @@ import type { MediaMode } from "../media/mediaTypes"; export type MediaRouteController = Pick< MediaController, - "getState" | "queue" | "skip" | "stop" + "getState" | "queue" | "skip" | "stop" | "setMusicVolume" >; export interface MediaRouteOptions { @@ -91,5 +91,28 @@ export function createMediaRoutes( }, ); + router.post( + "/media/volume", + adminAuth, + async (req: Request, res: Response, next: NextFunction) => { + try { + const { volume } = req.body as { volume?: number }; + if (typeof volume !== "number" || Number.isNaN(volume)) { + throw new AppError("Volume is required", "INVALID_VOLUME", 400); + } + if (volume < 0 || volume > 1) { + throw new AppError( + "Volume must be between 0 and 1", + "INVALID_VOLUME", + 400, + ); + } + res.json(await controller.setMusicVolume(volume)); + } catch (error) { + next(error); + } + }, + ); + return router; } diff --git a/src/webserver.ts b/src/webserver.ts index 5eaee67..1b5a6e6 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -60,6 +60,10 @@ interface SharedUIState { isStreaming: boolean; } +interface MediaSettings { + musicVolume: number; +} + type SharedUIStatePatch = Partial & { selectedGuild?: string; }; @@ -74,6 +78,10 @@ const defaultSharedUIState: SharedUIState = { isStreaming: false, }; +const defaultMediaSettings: MediaSettings = { + musicVolume: 1, +}; + let sharedUIState: SharedUIState = { ...defaultSharedUIState }; export function normalizeSharedUIState( @@ -101,6 +109,17 @@ async function initializeSharedUIState() { ); } +async function initializeMediaSettings(): Promise { + const stored = await getPersistedValue( + "media-settings", + defaultMediaSettings, + ); + return { + ...defaultMediaSettings, + ...(stored as MediaSettings), + }; +} + function getSharedUIState(): SharedUIState { return { ...sharedUIState }; } @@ -174,6 +193,7 @@ export async function startWebserver( voiceController: VoiceController, ) { await initializeSharedUIState(); + let mediaSettings = await initializeMediaSettings(); const app = express(); const server = http.createServer(app); @@ -200,6 +220,11 @@ export async function startWebserver( isBrowserStreaming: () => sharedUIState.isStreaming, screenController, onStateChange: (state) => broadcaster.mediaState(state), + initialMusicVolume: mediaSettings.musicVolume, + onMusicVolumeChange: async (volume) => { + mediaSettings = { ...mediaSettings, musicVolume: volume }; + await setPersistedValue("media-settings", mediaSettings); + }, }); // Security headers. CSP disabled because the current static UI uses inline scripts/styles. diff --git a/tests/media/mediaController.test.ts b/tests/media/mediaController.test.ts index d5d813b..dbb3230 100644 --- a/tests/media/mediaController.test.ts +++ b/tests/media/mediaController.test.ts @@ -194,6 +194,7 @@ describe("MediaController", () => { expect(state).toEqual({ playing: false, activeMode: null, + musicVolume: 1, current: null, queue: [], }); diff --git a/tests/media/musicPlayer.test.ts b/tests/media/musicPlayer.test.ts index 7f646a0..708e45f 100644 --- a/tests/media/musicPlayer.test.ts +++ b/tests/media/musicPlayer.test.ts @@ -5,6 +5,7 @@ type Spawn = typeof nodeSpawn; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { describe, expect, it, vi } from "vitest"; +import { StreamType } from "@discordjs/voice"; import type { DiscordAudioPlayer, DiscordPlayerOwner, @@ -23,13 +24,15 @@ class FakeProcess extends EventEmitter { } describe("createMusicPlayer", () => { - it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => { + it("spawns ffmpeg as raw PCM and passes stdout to Discord", async () => { const proc = new FakeProcess(); const spawn = vi.fn(() => proc); const discordPlayer: DiscordAudioPlayer = { isConnected: () => true, playStream: vi.fn(), getOwner: vi.fn((): DiscordPlayerOwner => "none"), + getMusicVolume: vi.fn(() => 1), + setMusicVolume: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), @@ -57,18 +60,21 @@ describe("createMusicPlayer", () => { "https://example.com/song.mp3", "-vn", "-acodec", - "libopus", + "pcm_s16le", "-ar", "48000", "-ac", "2", "-f", - "ogg", + "s16le", "pipe:1", ], { stdio: ["ignore", "pipe", "pipe"] }, ); - expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music"); + expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music", { + inputType: StreamType.Raw, + inlineVolume: true, + }); }); it("rejects playback when Discord is not connected", () => { @@ -77,6 +83,8 @@ describe("createMusicPlayer", () => { isConnected: () => false, playStream: vi.fn(), getOwner: vi.fn((): DiscordPlayerOwner => "none"), + getMusicVolume: vi.fn(() => 1), + setMusicVolume: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), @@ -102,6 +110,8 @@ describe("createMusicPlayer", () => { isConnected: () => true, playStream: vi.fn(), getOwner: vi.fn((): DiscordPlayerOwner => "none"), + getMusicVolume: vi.fn(() => 1), + setMusicVolume: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(), @@ -128,6 +138,8 @@ describe("createMusicPlayer", () => { isConnected: () => true, playStream: vi.fn(), getOwner: vi.fn((): DiscordPlayerOwner => "none"), + getMusicVolume: vi.fn(() => 1), + setMusicVolume: vi.fn(), pause: vi.fn(), unpause: vi.fn(() => true), stop: vi.fn(),