diff --git a/services/backend/src/modules/media/media.routes.ts b/services/backend/src/modules/media/media.routes.ts index 443009e..3ab6283 100644 --- a/services/backend/src/modules/media/media.routes.ts +++ b/services/backend/src/modules/media/media.routes.ts @@ -2,8 +2,8 @@ import type { Request, Response, Router } from "express"; import express from "express"; import { createChildLogger } from "@/shared/logger/index"; import { asyncHandler, validateBody } from "../../shared/middlewares/index.js"; -import { mediaQueueSchema } from "./media.schema.js"; -import { getStatus, queue, skip, stop } from "./media.service.js"; +import { mediaLoopSchema, mediaQueueSchema } from "./media.schema.js"; +import { getStatus, queue, setLoop, skip, stop } from "./media.service.js"; const logger = createChildLogger("media.routes"); @@ -55,5 +55,17 @@ export function createMediaRouter(): Router { }), ); + // POST /api/media/loop + router.post( + "/media/loop", + validateBody(mediaLoopSchema), + asyncHandler(async (req: Request, res: Response) => { + const { loop } = req.body as { loop: boolean }; + logger.debug({ loop }, "Media loop requested"); + const state = await setLoop(loop); + res.json(state); + }), + ); + return router; } diff --git a/services/backend/src/modules/media/media.schema.ts b/services/backend/src/modules/media/media.schema.ts index 7f91999..976d891 100644 --- a/services/backend/src/modules/media/media.schema.ts +++ b/services/backend/src/modules/media/media.schema.ts @@ -5,4 +5,9 @@ export const mediaQueueSchema = z.object({ mode: z.enum(["music", "screen"]).default("music"), }); +export const mediaLoopSchema = z.object({ + loop: z.boolean().default(false), +}); + export type MediaQueueInput = z.infer; +export type MediaLoopInput = z.infer; diff --git a/services/backend/src/modules/media/media.service.ts b/services/backend/src/modules/media/media.service.ts index 3488c60..28e0b54 100644 --- a/services/backend/src/modules/media/media.service.ts +++ b/services/backend/src/modules/media/media.service.ts @@ -3,6 +3,7 @@ import { tryCommandThenFallback, } from "../../shared/commandHelper.js"; import { + COMMAND_MEDIA_LOOP, COMMAND_MEDIA_QUEUE, COMMAND_MEDIA_SKIP, COMMAND_MEDIA_STOP, @@ -30,6 +31,7 @@ export interface MediaState { /** null/absent when idle; "music" | "screen" while a track is active. */ activeMode?: "music" | "screen" | null; musicVolume: number; + loop: boolean; current: MediaItem | null; queue: MediaItem[]; } @@ -44,6 +46,7 @@ const DEFAULT_STATE: MediaState = { playing: false, activeMode: null, musicVolume: 0.3, + loop: false, current: null, queue: [], }; @@ -65,6 +68,7 @@ function normalizeMediaState(raw: Record): MediaState { playing, activeMode, musicVolume: Number(raw.musicVolume ?? 0.3), + loop: Boolean(raw.loop ?? false), current: (raw.current as MediaItem | null) ?? null, queue: (raw.queue as MediaItem[]) ?? [], }; @@ -148,3 +152,20 @@ export async function stop(): Promise { "stop", ); } + +/** + * Toggle loop mode (replay current track on natural end) via Redis command. + */ +export async function setLoop(loop: boolean): Promise { + logger.info({ loop }, "setLoop called"); + return tryCommandThenFallback( + () => + publishCommand( + COMMAND_MEDIA_LOOP, + { loop }, + DEFAULT_COMMAND_TIMEOUT_MS, + ), + () => readStatusFallback(), + "setLoop", + ); +} diff --git a/services/backend/src/shared/redis-channels.ts b/services/backend/src/shared/redis-channels.ts index 0b70c05..b42ad6c 100644 --- a/services/backend/src/shared/redis-channels.ts +++ b/services/backend/src/shared/redis-channels.ts @@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue"; export const COMMAND_MEDIA_SKIP = "media:skip"; export const COMMAND_MEDIA_STOP = "media:stop"; export const COMMAND_MEDIA_VOLUME = "media:volume"; +export const COMMAND_MEDIA_LOOP = "media:loop"; export const COMMAND_MODERATION_ACTION = "moderation:action"; export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed"; diff --git a/services/discord-gateway/src/modules/command-handler/handler-registry.ts b/services/discord-gateway/src/modules/command-handler/handler-registry.ts index c4c51bf..f4a6840 100644 --- a/services/discord-gateway/src/modules/command-handler/handler-registry.ts +++ b/services/discord-gateway/src/modules/command-handler/handler-registry.ts @@ -1,6 +1,7 @@ import { COMMAND_GUILDS_LIST, COMMAND_GUILDS_TEXT_CHANNELS, + COMMAND_MEDIA_LOOP, COMMAND_MEDIA_QUEUE, COMMAND_MEDIA_SKIP, COMMAND_MEDIA_STOP, @@ -69,6 +70,7 @@ export function createHandlerRegistry( registry.set(COMMAND_MEDIA_VOLUME, (cmd) => mediaHandler.handleMediaVolume(cmd), ); + registry.set(COMMAND_MEDIA_LOOP, (cmd) => mediaHandler.handleMediaLoop(cmd)); // Guild commands registry.set(COMMAND_GUILDS_LIST, (cmd) => diff --git a/services/discord-gateway/src/modules/command-handler/media.handler.ts b/services/discord-gateway/src/modules/command-handler/media.handler.ts index b16ceab..19c9ecb 100644 --- a/services/discord-gateway/src/modules/command-handler/media.handler.ts +++ b/services/discord-gateway/src/modules/command-handler/media.handler.ts @@ -6,6 +6,7 @@ import { createChildLogger } from "../../shared/logger/index.js"; import { extractMediaInfo, resolveMediaUrl, + transcodeToHighQualityOgg, } from "../voice-recording/mediaSource.js"; import type { MediaMode, @@ -35,6 +36,7 @@ export interface MediaStatusPayload { playing: boolean; activeMode: MediaMode | null; musicVolume: number; + loop: boolean; current: MediaStatusItem | null; queue: MediaStatusItem[]; } @@ -45,6 +47,9 @@ export interface MediaStatusPayload { const mediaQueue: MediaQueueItem[] = []; let currentTrackItem: MediaQueueItem | null = null; +let loopEnabled = false; +/** Active ffmpeg transcode (killed on stop/skip). */ +let currentTranscodeCleanup: (() => void) | null = null; // --------------------------------------------------------------------------- // Helpers @@ -67,6 +72,7 @@ function buildStatusPayload(): MediaStatusPayload { currentTrackItem !== null && discordPlayer.getStatus() === "playing", activeMode: currentTrackItem?.mode ?? null, musicVolume: discordPlayer.getMusicVolume(), + loop: loopEnabled, current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null, queue: mediaQueue.map(mapToStatusItem), }; @@ -336,6 +342,17 @@ export class MediaHandler { }; } + async handleMediaLoop(cmd: CommandMessage): Promise> { + loopEnabled = Boolean(cmd.payload.loop); + this.logger.info({ loop: loopEnabled }, "Media loop toggled"); + this.publishStatus(); + return { + id: cmd.id, + success: true, + data: buildStatusPayload(), + }; + } + // --------------------------------------------------------------------------- // Internal // --------------------------------------------------------------------------- @@ -350,6 +367,8 @@ export class MediaHandler { discordPlayer.stop("music"); currentTrackItem = null; } + currentTranscodeCleanup?.(); + currentTranscodeCleanup = null; const next = mediaQueue.shift(); if (!next) { @@ -372,11 +391,20 @@ export class MediaHandler { next.title = resolution.title ?? next.title; next.duration = resolution.duration ?? next.duration; - discordPlayer.playStream(resolution.stream, "music", { - inputType: StreamType.Arbitrary, - inlineVolume: true, - volume: discordPlayer.getMusicVolume(), + // Music playback: transcode once to high-quality OggOpus (48kHz stereo, + // 192kbps) with volume baked into the encode. This avoids the double + // lossy encode that inlineVolume would cause and gives Discord the + // cleanest possible stream. Screen share bypasses this entirely. + const transcoded = transcodeToHighQualityOgg( + resolution.stream, + discordPlayer.getMusicVolume(), + ); + + discordPlayer.playStream(transcoded.stream, "music", { + inputType: StreamType.OggOpus, + inlineVolume: false, }); + currentTranscodeCleanup = transcoded.cleanup; this.logger.info({ title: next.title }, "Playback started"); } catch (err) { @@ -403,10 +431,21 @@ export class MediaHandler { /** * Called by the idle callback — delegates to playNext since the player is - * already idle and currentTrackItem is already null. + * already idle and currentTrackItem is already null. When loop mode is + * enabled and a music track ended naturally, requeue it so it plays again. */ private async advanceQueue(): Promise { + const finished = currentTrackItem; currentTrackItem = null; + + if (loopEnabled && finished && finished.mode === "music") { + mediaQueue.unshift(finished); + this.logger.info( + { title: finished.title }, + "Loop enabled — replaying finished track", + ); + } + await this.playNext(); } } diff --git a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts index 0b5b65b..1e01cd1 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaSource.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaSource.ts @@ -33,6 +33,71 @@ export interface ResolveOptions { quality?: string; } +export interface TranscodeResult { + stream: Readable; + /** Kill the ffmpeg child (used on stop/skip). */ + cleanup: () => void; +} + +/** + * Re-encode a source stream to high-quality OggOpus (48kHz stereo, 192kbps). + * + * Discord voice downmixes whatever we feed it to the channel's bitrate, so the + * best we can do is hand it a clean 48kHz stereo Opus stream instead of the + * raw source (which may be mono, low-bitrate, or a non-Opus container). The + * volume is baked into the encode with `-af volume=` so the player does not + * need inlineVolume re-encoding (double lossy encode). + */ +export function transcodeToHighQualityOgg( + input: Readable, + volume: number, +): TranscodeResult { + const proc = spawn( + "ffmpeg", + [ + "-hide_banner", + "-loglevel", + "error", + "-i", + "pipe:0", + "-vn", + "-ac", + "2", + "-ar", + "48000", + "-c:a", + "libopus", + "-b:a", + "192k", + "-af", + `volume=${volume}`, + "-f", + "ogg", + "pipe:1", + ], + { stdio: ["pipe", "pipe", "ignore"] }, + ); + + input.pipe(proc.stdin); + activeProcesses.add(proc); + + const cleanup = () => { + activeProcesses.delete(proc); + if (proc.exitCode === null) { + proc.kill("SIGKILL"); + } + }; + + proc.once("exit", () => activeProcesses.delete(proc)); + + // If ffmpeg fails, surface the error to the consumer stream so the + // AudioPlayer's error handler can advance the queue. + const output = proc.stdout; + output.on("error", () => cleanup()); + + return { stream: output, cleanup }; +} + // --------------------------------------------------------------------------- // Internal state // --------------------------------------------------------------------------- diff --git a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts index c87a3f5..959d615 100644 --- a/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts +++ b/services/discord-gateway/src/modules/voice-recording/mediaTypes.ts @@ -32,6 +32,7 @@ export interface MediaState { playing: boolean; activeMode: MediaMode | null; musicVolume: number; + loop: boolean; current: MediaQueueItem | null; queue: MediaQueueItem[]; } diff --git a/services/discord-gateway/src/shared/redis-channels.ts b/services/discord-gateway/src/shared/redis-channels.ts index 0b70c05..b42ad6c 100644 --- a/services/discord-gateway/src/shared/redis-channels.ts +++ b/services/discord-gateway/src/shared/redis-channels.ts @@ -62,6 +62,7 @@ export const COMMAND_MEDIA_QUEUE = "media:queue"; export const COMMAND_MEDIA_SKIP = "media:skip"; export const COMMAND_MEDIA_STOP = "media:stop"; export const COMMAND_MEDIA_VOLUME = "media:volume"; +export const COMMAND_MEDIA_LOOP = "media:loop"; export const COMMAND_MODERATION_ACTION = "moderation:action"; export const DISCORD_VOICE_ANALYZED = "discord:voice:analyzed"; diff --git a/services/frontend/src/components/media/mini-player.tsx b/services/frontend/src/components/media/mini-player.tsx index 2777ba6..50e3950 100644 --- a/services/frontend/src/components/media/mini-player.tsx +++ b/services/frontend/src/components/media/mini-player.tsx @@ -1,10 +1,11 @@ "use client"; -import { Disc3, Music, SkipForward, Square } from "lucide-react"; +import { Disc3, Music, Repeat, SkipForward, Square } from "lucide-react"; import { useMediaPlayer } from "@/lib/hooks/use-media-player"; export function MiniPlayer() { - const { playing, current, queue, pending, skip, stop } = useMediaPlayer(); + const { playing, current, queue, loop, pending, skip, stop, toggleLoop } = + useMediaPlayer(); // Nothing to show if no track is playing and nothing is queued if (!current && queue.length === 0) return null; @@ -59,6 +60,20 @@ export function MiniPlayer() { )} + ); diff --git a/services/frontend/src/components/media/music-player.tsx b/services/frontend/src/components/media/music-player.tsx index c5c3569..e80bb0a 100644 --- a/services/frontend/src/components/media/music-player.tsx +++ b/services/frontend/src/components/media/music-player.tsx @@ -1,6 +1,6 @@ "use client"; -import { Disc3, Music, Play, SkipForward, Square } from "lucide-react"; +import { Disc3, Music, Play, Repeat, SkipForward, Square } from "lucide-react"; import Image from "next/image"; import { useCallback, useState } from "react"; @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { + useMediaLoop, useMediaQueue, useMediaSkip, useMediaState, @@ -28,6 +29,7 @@ export function MusicPlayer({ ws, initialData }: MusicPlayerProps) { const queueMut = useMediaQueue(); const skipMut = useMediaSkip(); const stopMut = useMediaStop(); + const loopMut = useMediaLoop(); const [queueUrl, setQueueUrl] = useState(""); const [screenMode, setScreenMode] = useState(false); @@ -131,6 +133,21 @@ export function MusicPlayer({ ws, initialData }: MusicPlayerProps) { Skip + {mediaState && mediaState.queue.length > 0 && ( diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index c22370d..1f33ca6 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -11,6 +11,7 @@ export { } from "./use-dashboard"; export { useGuilds } from "./use-guilds"; export { + useMediaLoop, useMediaQueue, useMediaSkip, useMediaState, diff --git a/services/frontend/src/hooks/use-media.ts b/services/frontend/src/hooks/use-media.ts index bc3ebf1..2e56c3b 100644 --- a/services/frontend/src/hooks/use-media.ts +++ b/services/frontend/src/hooks/use-media.ts @@ -38,6 +38,10 @@ export function useMediaStop() { return useMediaAction(() => mediaApi.stop()); } +export function useMediaLoop() { + return useMediaAction((loop: boolean) => mediaApi.loop(loop)); +} + /** Subscribe to WS media_state events to keep cache fresh */ export function useMediaWsSync(ws: WsHook) { const { mutate } = useSWRConfig(); diff --git a/services/frontend/src/lib/api/media.ts b/services/frontend/src/lib/api/media.ts index 94e133d..470e827 100644 --- a/services/frontend/src/lib/api/media.ts +++ b/services/frontend/src/lib/api/media.ts @@ -7,4 +7,5 @@ export const mediaApi = { api.post("/api/media/queue", { source, mode }), skip: () => api.post("/api/media/skip", {}), stop: () => api.post("/api/media/stop", {}), + loop: (loop: boolean) => api.post("/api/media/loop", { loop }), }; diff --git a/services/frontend/src/lib/hooks/use-media-player.tsx b/services/frontend/src/lib/hooks/use-media-player.tsx index 13295e7..1af2947 100644 --- a/services/frontend/src/lib/hooks/use-media-player.tsx +++ b/services/frontend/src/lib/hooks/use-media-player.tsx @@ -20,6 +20,8 @@ interface MediaPlayerContextValue { current: MediaItem | null; /** Upcoming queue */ queue: MediaItem[]; + /** Loop mode (replay current track on natural end) */ + loop: boolean; /** True while a mutation is in flight */ pending: boolean; @@ -27,6 +29,8 @@ interface MediaPlayerContextValue { skip: () => void; /** Stop playback */ stop: () => void; + /** Toggle loop mode */ + toggleLoop: () => void; /** Queue a URL for playback */ queueUrl: (url: string) => void; } @@ -38,6 +42,7 @@ export function MediaPlayerProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ playing: false, musicVolume: 0.3, + loop: false, current: null, queue: [], }); @@ -105,15 +110,30 @@ export function MediaPlayerProvider({ children }: { children: ReactNode }) { .finally(() => setPending(false)); }, []); + const toggleLoop = useCallback(() => { + setPending(true); + mediaApi + .loop(!state.loop) + .then((data) => { + if (data) setState(data as MediaState); + }) + .catch(() => { + // ignore + }) + .finally(() => setPending(false)); + }, [state.loop]); + return ( diff --git a/services/frontend/src/lib/types/media.ts b/services/frontend/src/lib/types/media.ts index e400120..5e04bb1 100644 --- a/services/frontend/src/lib/types/media.ts +++ b/services/frontend/src/lib/types/media.ts @@ -13,6 +13,7 @@ export interface MediaState { playing: boolean; activeMode?: MediaMode | null; musicVolume: number; + loop: boolean; current: MediaItem | null; queue: MediaItem[]; }