chore: update Discord-video-stream subproject to latest commit
This commit is contained in:
@@ -234,6 +234,7 @@ export default function App() {
|
|||||||
onStartScreen={(source) => media.enqueue(source, "screen")}
|
onStartScreen={(source) => media.enqueue(source, "screen")}
|
||||||
onSkip={media.skip}
|
onSkip={media.skip}
|
||||||
onStop={media.stop}
|
onStop={media.stop}
|
||||||
|
onVolumeChange={media.setVolume}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -19,3 +19,10 @@ export function skipMedia(): Promise<MediaState> {
|
|||||||
export function stopMedia(): Promise<MediaState> {
|
export function stopMedia(): Promise<MediaState> {
|
||||||
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
return request<MediaState>('/api/media/stop', { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setMediaVolume(volume: number): Promise<MediaState> {
|
||||||
|
return request<MediaState>('/api/media/volume', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ volume }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,18 @@ interface MediaPanelProps {
|
|||||||
onStartScreen: (source: string) => void;
|
onStartScreen: (source: string) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onStop: () => 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 (
|
return (
|
||||||
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||||
<Tabs defaultValue="music" className="min-w-0">
|
<Tabs defaultValue="music" className="min-w-0">
|
||||||
@@ -22,7 +31,14 @@ export function MediaPanel({ state, loading, onQueueMusic, onStartScreen, onSkip
|
|||||||
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
<TabsTrigger value="screen">Screen Share</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="music">
|
<TabsContent value="music">
|
||||||
<MusicPlayer loading={loading} onQueue={onQueueMusic} onSkip={onSkip} onStop={onStop} />
|
<MusicPlayer
|
||||||
|
loading={loading}
|
||||||
|
volume={state.musicVolume}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onQueue={onQueueMusic}
|
||||||
|
onSkip={onSkip}
|
||||||
|
onStop={onStop}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="screen">
|
<TabsContent value="screen">
|
||||||
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
<ScreenShare loading={loading} onStart={onStartScreen} onSkip={onSkip} onStop={onStop} />
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
import { Music2 } from "lucide-react";
|
import { Music2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||||
import { Input } from "../ui/input";
|
import { Input } from "../ui/input";
|
||||||
|
|
||||||
interface MusicPlayerProps {
|
interface MusicPlayerProps {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
volume: number;
|
||||||
|
onVolumeChange: (volume: number) => void;
|
||||||
onQueue: (source: string) => void;
|
onQueue: (source: string) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onStop: () => 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 [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 submit = () => {
|
||||||
const trimmed = source.trim();
|
const trimmed = source.trim();
|
||||||
@@ -34,6 +58,21 @@ export function MusicPlayer({ loading, onQueue, onSkip, onStop }: MusicPlayerPro
|
|||||||
onKeyDown={(event) => event.key === "Enter" && submit()}
|
onKeyDown={(event) => event.key === "Enter" && submit()}
|
||||||
placeholder="YouTube URL, Spotify track, or search terms"
|
placeholder="YouTube URL, Spotify track, or search terms"
|
||||||
/>
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">Volume</span>
|
||||||
|
<span className="text-muted-foreground">{draftVolume}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={draftVolume}
|
||||||
|
onChange={(event) => setDraftVolume(Number(event.target.value))}
|
||||||
|
className="h-2 w-full cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
<Button disabled={loading || !source.trim()} onClick={submit}>Queue / Play</Button>
|
||||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
<Button variant="secondary" disabled={loading} onClick={onSkip}>Skip</Button>
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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";
|
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() {
|
export function useMediaControl() {
|
||||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
const [mediaState, setMediaState] = useState<MediaState>(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(() => {
|
useEffect(() => {
|
||||||
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
refreshMedia().catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||||
}, [refreshMedia]);
|
}, [refreshMedia]);
|
||||||
|
|
||||||
return { mediaState, setMediaState, loading, error, refreshMedia, enqueue, skip, stop };
|
return {
|
||||||
|
mediaState,
|
||||||
|
setMediaState,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshMedia,
|
||||||
|
enqueue,
|
||||||
|
skip,
|
||||||
|
stop,
|
||||||
|
setVolume,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface MediaItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaItem | null;
|
current: MediaItem | null;
|
||||||
queue: MediaItem[];
|
queue: MediaItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface MediaControllerDependencies {
|
|||||||
musicPlayer?: MusicPlayer;
|
musicPlayer?: MusicPlayer;
|
||||||
screenController?: ScreenShareController;
|
screenController?: ScreenShareController;
|
||||||
onStateChange?: (state: MediaState) => void;
|
onStateChange?: (state: MediaState) => void;
|
||||||
|
initialMusicVolume?: number;
|
||||||
|
onMusicVolumeChange?: (volume: number) => void | Promise<void>;
|
||||||
|
setMusicVolume?: (volume: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
@@ -31,9 +34,18 @@ export class MediaController {
|
|||||||
private skipInProgress = false;
|
private skipInProgress = false;
|
||||||
private screenPlayback: ScreenSharePlayback | null = null;
|
private screenPlayback: ScreenSharePlayback | null = null;
|
||||||
private activeMode: MediaMode | null = null;
|
private activeMode: MediaMode | null = null;
|
||||||
|
private musicVolume: number;
|
||||||
|
private readonly setPlayerMusicVolume: (volume: number) => void;
|
||||||
|
|
||||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
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 {
|
getState(): MediaState {
|
||||||
@@ -42,10 +54,20 @@ export class MediaController {
|
|||||||
playing:
|
playing:
|
||||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||||
|
musicVolume: this.musicVolume,
|
||||||
...snapshot,
|
...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(
|
async queue(
|
||||||
source: string,
|
source: string,
|
||||||
options: QueueMediaOptions = {},
|
options: QueueMediaOptions = {},
|
||||||
@@ -201,3 +223,8 @@ export class MediaController {
|
|||||||
return 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));
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -1,4 +1,5 @@
|
|||||||
import type { Readable } from "node:stream";
|
import type { Readable } from "node:stream";
|
||||||
|
import type { StreamType } from "@discordjs/voice";
|
||||||
|
|
||||||
export type MediaMode = "music" | "screen";
|
export type MediaMode = "music" | "screen";
|
||||||
export type MediaSourceKind =
|
export type MediaSourceKind =
|
||||||
@@ -26,6 +27,7 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
|||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
activeMode: MediaMode | null;
|
activeMode: MediaMode | null;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
@@ -56,11 +58,23 @@ export interface ScreenShareController {
|
|||||||
|
|
||||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||||
|
|
||||||
|
export interface DiscordPlayOptions {
|
||||||
|
inputType?: StreamType;
|
||||||
|
inlineVolume?: boolean;
|
||||||
|
volume?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscordAudioPlayer {
|
export interface DiscordAudioPlayer {
|
||||||
getOwner(): DiscordPlayerOwner;
|
getOwner(): DiscordPlayerOwner;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
|
playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options?: DiscordPlayOptions,
|
||||||
|
): void;
|
||||||
pause(owner?: DiscordPlayerOwner): void;
|
pause(owner?: DiscordPlayerOwner): void;
|
||||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||||
stop(owner?: DiscordPlayerOwner): void;
|
stop(owner?: DiscordPlayerOwner): void;
|
||||||
|
getMusicVolume(): number;
|
||||||
|
setMusicVolume(volume: number): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import { discordPlayer } from "../player";
|
import { discordPlayer } from "../player";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
@@ -30,7 +31,10 @@ export function createMusicPlayer(
|
|||||||
}) as unknown as ChildProcessWithoutNullStreams;
|
}) as unknown as ChildProcessWithoutNullStreams;
|
||||||
proc.stderr.resume();
|
proc.stderr.resume();
|
||||||
|
|
||||||
audioPlayer.playStream(proc.stdout, "music");
|
audioPlayer.playStream(proc.stdout, "music", {
|
||||||
|
inputType: StreamType.Raw,
|
||||||
|
inlineVolume: true,
|
||||||
|
});
|
||||||
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let released = false;
|
let released = false;
|
||||||
@@ -81,13 +85,13 @@ export function buildFfmpegArgs(source: string): string[] {
|
|||||||
source,
|
source,
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface MediaQueueItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
musicVolume: number;
|
||||||
current: MediaQueueItem | null;
|
current: MediaQueueItem | null;
|
||||||
queue: MediaQueueItem[];
|
queue: MediaQueueItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-3
@@ -2,17 +2,23 @@ import { Readable } from "node:stream";
|
|||||||
import {
|
import {
|
||||||
AudioPlayer,
|
AudioPlayer,
|
||||||
AudioPlayerStatus,
|
AudioPlayerStatus,
|
||||||
|
type AudioResource,
|
||||||
createAudioPlayer,
|
createAudioPlayer,
|
||||||
createAudioResource,
|
createAudioResource,
|
||||||
StreamType,
|
StreamType,
|
||||||
VoiceConnection,
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
import type { DiscordPlayerOwner } from "./media/mediaTypes";
|
import type {
|
||||||
|
DiscordPlayOptions,
|
||||||
|
DiscordPlayerOwner,
|
||||||
|
} from "./media/mediaTypes";
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
private connection: VoiceConnection | null = null;
|
private connection: VoiceConnection | null = null;
|
||||||
private owner: DiscordPlayerOwner = "none";
|
private owner: DiscordPlayerOwner = "none";
|
||||||
|
private resource: AudioResource | null = null;
|
||||||
|
private musicVolume = 1;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.player = createAudioPlayer();
|
this.player = createAudioPlayer();
|
||||||
@@ -24,6 +30,7 @@ export class DiscordPlayer {
|
|||||||
this.player.on("error", (error) => {
|
this.player.on("error", (error) => {
|
||||||
console.error(`[player] Error: ${error.message}`);
|
console.error(`[player] Error: ${error.message}`);
|
||||||
this.owner = "none";
|
this.owner = "none";
|
||||||
|
this.resource = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,20 +47,34 @@ export class DiscordPlayer {
|
|||||||
return this.connection !== null;
|
return this.connection !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable, owner: DiscordPlayerOwner) {
|
public playStream(
|
||||||
|
stream: Readable,
|
||||||
|
owner: DiscordPlayerOwner,
|
||||||
|
options: DiscordPlayOptions = {},
|
||||||
|
) {
|
||||||
if (owner === "none") {
|
if (owner === "none") {
|
||||||
throw new Error("Discord audio player owner is required");
|
throw new Error("Discord audio player owner is required");
|
||||||
}
|
}
|
||||||
this.assertOwnerAvailable(owner);
|
this.assertOwnerAvailable(owner);
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
const resource = createAudioResource(stream, {
|
||||||
inputType: StreamType.OggOpus,
|
inputType: options.inputType ?? StreamType.OggOpus,
|
||||||
|
inlineVolume: options.inlineVolume ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.owner === owner) {
|
if (this.owner === owner) {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
|
this.resource = resource;
|
||||||
this.owner = owner;
|
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.player.play(resource);
|
||||||
this.connection?.subscribe(this.player);
|
this.connection?.subscribe(this.player);
|
||||||
}
|
}
|
||||||
@@ -76,6 +97,19 @@ export class DiscordPlayer {
|
|||||||
if (!this.canControl(owner)) return;
|
if (!this.canControl(owner)) return;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.owner = "none";
|
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 {
|
private assertOwnerAvailable(owner: DiscordPlayerOwner): void {
|
||||||
@@ -87,6 +121,16 @@ export class DiscordPlayer {
|
|||||||
private canControl(owner?: DiscordPlayerOwner): boolean {
|
private canControl(owner?: DiscordPlayerOwner): boolean {
|
||||||
return !owner || this.owner === "none" || this.owner === owner;
|
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();
|
export const discordPlayer = new DiscordPlayer();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { MediaMode } from "../media/mediaTypes";
|
|||||||
|
|
||||||
export type MediaRouteController = Pick<
|
export type MediaRouteController = Pick<
|
||||||
MediaController,
|
MediaController,
|
||||||
"getState" | "queue" | "skip" | "stop"
|
"getState" | "queue" | "skip" | "stop" | "setMusicVolume"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface MediaRouteOptions {
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ interface SharedUIState {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MediaSettings {
|
||||||
|
musicVolume: number;
|
||||||
|
}
|
||||||
|
|
||||||
type SharedUIStatePatch = Partial<SharedUIState> & {
|
type SharedUIStatePatch = Partial<SharedUIState> & {
|
||||||
selectedGuild?: string;
|
selectedGuild?: string;
|
||||||
};
|
};
|
||||||
@@ -74,6 +78,10 @@ const defaultSharedUIState: SharedUIState = {
|
|||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultMediaSettings: MediaSettings = {
|
||||||
|
musicVolume: 1,
|
||||||
|
};
|
||||||
|
|
||||||
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
let sharedUIState: SharedUIState = { ...defaultSharedUIState };
|
||||||
|
|
||||||
export function normalizeSharedUIState(
|
export function normalizeSharedUIState(
|
||||||
@@ -101,6 +109,17 @@ async function initializeSharedUIState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initializeMediaSettings(): Promise<MediaSettings> {
|
||||||
|
const stored = await getPersistedValue(
|
||||||
|
"media-settings",
|
||||||
|
defaultMediaSettings,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...defaultMediaSettings,
|
||||||
|
...(stored as MediaSettings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getSharedUIState(): SharedUIState {
|
function getSharedUIState(): SharedUIState {
|
||||||
return { ...sharedUIState };
|
return { ...sharedUIState };
|
||||||
}
|
}
|
||||||
@@ -174,6 +193,7 @@ export async function startWebserver(
|
|||||||
voiceController: VoiceController,
|
voiceController: VoiceController,
|
||||||
) {
|
) {
|
||||||
await initializeSharedUIState();
|
await initializeSharedUIState();
|
||||||
|
let mediaSettings = await initializeMediaSettings();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -200,6 +220,11 @@ export async function startWebserver(
|
|||||||
isBrowserStreaming: () => sharedUIState.isStreaming,
|
isBrowserStreaming: () => sharedUIState.isStreaming,
|
||||||
screenController,
|
screenController,
|
||||||
onStateChange: (state) => broadcaster.mediaState(state),
|
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.
|
// Security headers. CSP disabled because the current static UI uses inline scripts/styles.
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ describe("MediaController", () => {
|
|||||||
expect(state).toEqual({
|
expect(state).toEqual({
|
||||||
playing: false,
|
playing: false,
|
||||||
activeMode: null,
|
activeMode: null,
|
||||||
|
musicVolume: 1,
|
||||||
current: null,
|
current: null,
|
||||||
queue: [],
|
queue: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ type Spawn = typeof nodeSpawn;
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { StreamType } from "@discordjs/voice";
|
||||||
import type {
|
import type {
|
||||||
DiscordAudioPlayer,
|
DiscordAudioPlayer,
|
||||||
DiscordPlayerOwner,
|
DiscordPlayerOwner,
|
||||||
@@ -23,13 +24,15 @@ class FakeProcess extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createMusicPlayer", () => {
|
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 proc = new FakeProcess();
|
||||||
const spawn = vi.fn(() => proc);
|
const spawn = vi.fn(() => proc);
|
||||||
const discordPlayer: DiscordAudioPlayer = {
|
const discordPlayer: DiscordAudioPlayer = {
|
||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -57,18 +60,21 @@ describe("createMusicPlayer", () => {
|
|||||||
"https://example.com/song.mp3",
|
"https://example.com/song.mp3",
|
||||||
"-vn",
|
"-vn",
|
||||||
"-acodec",
|
"-acodec",
|
||||||
"libopus",
|
"pcm_s16le",
|
||||||
"-ar",
|
"-ar",
|
||||||
"48000",
|
"48000",
|
||||||
"-ac",
|
"-ac",
|
||||||
"2",
|
"2",
|
||||||
"-f",
|
"-f",
|
||||||
"ogg",
|
"s16le",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
],
|
],
|
||||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
{ 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", () => {
|
it("rejects playback when Discord is not connected", () => {
|
||||||
@@ -77,6 +83,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => false,
|
isConnected: () => false,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -102,6 +110,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
@@ -128,6 +138,8 @@ describe("createMusicPlayer", () => {
|
|||||||
isConnected: () => true,
|
isConnected: () => true,
|
||||||
playStream: vi.fn(),
|
playStream: vi.fn(),
|
||||||
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
getOwner: vi.fn((): DiscordPlayerOwner => "none"),
|
||||||
|
getMusicVolume: vi.fn(() => 1),
|
||||||
|
setMusicVolume: vi.fn(),
|
||||||
pause: vi.fn(),
|
pause: vi.fn(),
|
||||||
unpause: vi.fn(() => true),
|
unpause: vi.fn(() => true),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user