feat: implement media echo fix and YouTube screenshare design
- Introduced a new `ScreenShareController` to manage YouTube screenshare functionality. - Updated `DiscordPlayer` to track ownership of audio streams, preventing conflicts between music playback and screenshare. - Added error handling for various states including voice connection checks and media busy states. - Created unit tests for `ScreenShareController` and `DiscordPlayer` ownership rules to ensure correct functionality. - Added documentation for the new media echo fix and screenshare design.
This commit is contained in:
@@ -3,10 +3,14 @@ import { discordPlayer } from "../player";
|
||||
import { MediaQueue } from "./mediaQueue";
|
||||
import { resolveMediaSource } from "./mediaResolver";
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaState,
|
||||
MusicPlayback,
|
||||
MusicPlayer,
|
||||
QueueMediaOptions,
|
||||
ResolvedMediaSource,
|
||||
ScreenShareController,
|
||||
ScreenSharePlayback,
|
||||
} from "./mediaTypes";
|
||||
import { createMusicPlayer } from "./musicPlayer";
|
||||
|
||||
@@ -15,6 +19,7 @@ export interface MediaControllerDependencies {
|
||||
isBrowserStreaming?: () => boolean;
|
||||
resolveMediaSource?: (source: string) => Promise<ResolvedMediaSource>;
|
||||
musicPlayer?: MusicPlayer;
|
||||
screenController?: ScreenShareController;
|
||||
onStateChange?: (state: MediaState) => void;
|
||||
}
|
||||
|
||||
@@ -24,6 +29,8 @@ export class MediaController {
|
||||
private playback: MusicPlayback | null = null;
|
||||
private playbackToken = 0;
|
||||
private skipInProgress = false;
|
||||
private screenPlayback: ScreenSharePlayback | null = null;
|
||||
private activeMode: MediaMode | null = null;
|
||||
|
||||
constructor(private readonly dependencies: MediaControllerDependencies = {}) {
|
||||
this.musicPlayer = dependencies.musicPlayer ?? createMusicPlayer();
|
||||
@@ -32,17 +39,27 @@ export class MediaController {
|
||||
getState(): MediaState {
|
||||
const snapshot = this.queueStore.snapshot();
|
||||
return {
|
||||
playing: snapshot.current?.status === "playing",
|
||||
playing:
|
||||
this.activeMode === "screen" || snapshot.current?.status === "playing",
|
||||
activeMode: this.activeMode ?? snapshot.current?.mode ?? null,
|
||||
...snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
async queue(source: string): Promise<MediaState> {
|
||||
this.assertCanStart();
|
||||
async queue(
|
||||
source: string,
|
||||
options: QueueMediaOptions = {},
|
||||
): Promise<MediaState> {
|
||||
const mode = options.mode ?? "music";
|
||||
if (mode === "screen") {
|
||||
return this.startScreen(source);
|
||||
}
|
||||
|
||||
this.assertCanStartMusic();
|
||||
const resolved = await (
|
||||
this.dependencies.resolveMediaSource ?? resolveMediaSource
|
||||
)(source);
|
||||
this.queueStore.add(resolved);
|
||||
this.queueStore.add(resolved, mode, options.requestedBy);
|
||||
this.startNextIfIdle();
|
||||
return this.emitState();
|
||||
}
|
||||
@@ -73,11 +90,14 @@ export class MediaController {
|
||||
this.playbackToken++;
|
||||
this.playback?.stop();
|
||||
this.playback = null;
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
this.activeMode = null;
|
||||
this.queueStore.clear();
|
||||
return this.emitState();
|
||||
}
|
||||
|
||||
private assertCanStart(): void {
|
||||
private assertCanStartMusic(): void {
|
||||
const isVoiceConnected =
|
||||
this.dependencies.isVoiceConnected ?? (() => discordPlayer.isConnected());
|
||||
if (!isVoiceConnected()) {
|
||||
@@ -88,6 +108,10 @@ export class MediaController {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.screenPlayback || this.dependencies.screenController?.isActive()) {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
|
||||
if (this.dependencies.isBrowserStreaming?.()) {
|
||||
throw new AppError(
|
||||
"Stop browser microphone streaming before playing media",
|
||||
@@ -97,6 +121,46 @@ export class MediaController {
|
||||
}
|
||||
}
|
||||
|
||||
private async startScreen(source: string): Promise<MediaState> {
|
||||
if (
|
||||
this.screenPlayback ||
|
||||
this.dependencies.screenController?.isActive() ||
|
||||
this.playback ||
|
||||
this.queueStore.snapshot().current
|
||||
) {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
private startNextIfIdle(): void {
|
||||
if (this.playback) return;
|
||||
const item = this.queueStore.startNext();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
MediaMode,
|
||||
MediaQueueItem,
|
||||
MediaState,
|
||||
ResolvedMediaSource,
|
||||
@@ -13,10 +14,14 @@ export class MediaQueue {
|
||||
private readonly now = () => Date.now(),
|
||||
) {}
|
||||
|
||||
add(source: ResolvedMediaSource, requestedBy = "dashboard"): MediaQueueItem {
|
||||
add(
|
||||
source: ResolvedMediaSource,
|
||||
mode: MediaQueueItem["mode"] = "music",
|
||||
requestedBy = "dashboard",
|
||||
): MediaQueueItem {
|
||||
const item: MediaQueueItem = {
|
||||
id: this.createId(),
|
||||
mode: "music",
|
||||
mode,
|
||||
requestedBy,
|
||||
addedAt: this.now(),
|
||||
status: "queued",
|
||||
|
||||
+24
-3
@@ -25,10 +25,16 @@ export interface MediaQueueItem extends ResolvedMediaSource {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
current: MediaQueueItem | null;
|
||||
queue: MediaQueueItem[];
|
||||
}
|
||||
|
||||
export interface QueueMediaOptions {
|
||||
mode?: MediaMode;
|
||||
requestedBy?: string;
|
||||
}
|
||||
|
||||
export interface MusicPlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
@@ -38,8 +44,23 @@ export interface MusicPlayer {
|
||||
play(source: ResolvedMediaSource): MusicPlayback;
|
||||
}
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
isConnected(): boolean;
|
||||
playStream(stream: Readable): void;
|
||||
export interface ScreenSharePlayback {
|
||||
done: Promise<void>;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface ScreenShareController {
|
||||
isActive(): boolean;
|
||||
start(source: string): Promise<ScreenSharePlayback>;
|
||||
}
|
||||
|
||||
export type DiscordPlayerOwner = "none" | "browser-bridge" | "music" | "screen";
|
||||
|
||||
export interface DiscordAudioPlayer {
|
||||
getOwner(): DiscordPlayerOwner;
|
||||
isConnected(): boolean;
|
||||
playStream(stream: Readable, owner: DiscordPlayerOwner): void;
|
||||
pause(owner?: DiscordPlayerOwner): void;
|
||||
unpause(owner?: DiscordPlayerOwner): boolean;
|
||||
stop(owner?: DiscordPlayerOwner): void;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,27 @@ export function createMusicPlayer(
|
||||
}) as unknown as ChildProcessWithoutNullStreams;
|
||||
proc.stderr.resume();
|
||||
|
||||
audioPlayer.playStream(proc.stdout);
|
||||
audioPlayer.playStream(proc.stdout, "music");
|
||||
|
||||
let stopped = false;
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
audioPlayer.stop("music");
|
||||
};
|
||||
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
proc.on("error", reject);
|
||||
proc.stdout.on("error", reject);
|
||||
proc.on("error", (error) => {
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.stdout.on("error", (error) => {
|
||||
release();
|
||||
reject(error);
|
||||
});
|
||||
proc.on("close", (code) => {
|
||||
release();
|
||||
if (code === 0 || stopped) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -51,7 +65,7 @@ export function createMusicPlayer(
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
proc.kill("SIGTERM");
|
||||
audioPlayer.stop();
|
||||
release();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import {
|
||||
playStream as defaultPlayStream,
|
||||
prepareStream as defaultPrepareStream,
|
||||
Encoders,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import { AppError } from "../errors";
|
||||
import { discordPlayer } from "../player";
|
||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||
import { createYtDlp } from "./ytdlp";
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
interface PreparedScreenStream {
|
||||
command: { kill?: (signal: NodeJS.Signals) => unknown };
|
||||
output: Readable;
|
||||
}
|
||||
|
||||
type PrepareScreenStream = (
|
||||
source: string,
|
||||
options: object,
|
||||
) => PreparedScreenStream;
|
||||
|
||||
type PlayScreenStream = (
|
||||
output: Readable,
|
||||
streamer: unknown,
|
||||
options: { type: "go-live" },
|
||||
) => Promise<void>;
|
||||
|
||||
export interface ScreenShareControllerDependencies {
|
||||
getVoiceStatus: () => ScreenShareVoiceStatus;
|
||||
getPlayerOwner?: () => DiscordPlayerOwner;
|
||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||
prepareStream?: PrepareScreenStream;
|
||||
playStream?: PlayScreenStream;
|
||||
streamer: unknown;
|
||||
}
|
||||
|
||||
export function createScreenShareController(
|
||||
dependencies: ScreenShareControllerDependencies,
|
||||
) {
|
||||
let active: ScreenSharePlayback | null = null;
|
||||
const ytdlp = createYtDlp();
|
||||
const getPlayerOwner =
|
||||
dependencies.getPlayerOwner ?? (() => discordPlayer.getOwner());
|
||||
const getDirectVideoUrl =
|
||||
dependencies.getDirectVideoUrl ??
|
||||
((source) => ytdlp.getDirectVideoUrl(source));
|
||||
const prepareStream =
|
||||
dependencies.prepareStream ??
|
||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
||||
const playStream =
|
||||
dependencies.playStream ??
|
||||
(defaultPlayStream as unknown as PlayScreenStream);
|
||||
|
||||
return {
|
||||
isActive(): boolean {
|
||||
return active !== null;
|
||||
},
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = dependencies.getVoiceStatus();
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
!status.activeChannelId
|
||||
) {
|
||||
throw new AppError(
|
||||
"Connect to a voice channel before sharing screen",
|
||||
"VOICE_NOT_CONNECTED",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
if (active || getPlayerOwner() !== "none") {
|
||||
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const directUrl = await getDirectVideoUrl(source);
|
||||
const { command, output } = prepareStream(directUrl, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
bitrateVideo: 2500,
|
||||
bitrateVideoMax: 4000,
|
||||
includeAudio: true,
|
||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
const done = playStream(output, dependencies.streamer, {
|
||||
type: "go-live",
|
||||
}).finally(() => {
|
||||
active = null;
|
||||
});
|
||||
|
||||
active = {
|
||||
done,
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
command.kill?.("SIGTERM");
|
||||
active = null;
|
||||
},
|
||||
};
|
||||
return active;
|
||||
} catch (error) {
|
||||
active = null;
|
||||
throw new AppError(
|
||||
error instanceof Error ? error.message : "Screen stream failed",
|
||||
"SCREEN_STREAM_FAILED",
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface YtDlpMetadata {
|
||||
export interface YtDlpClient {
|
||||
getMetadata(url: string): Promise<YtDlpMetadata>;
|
||||
getDirectAudioUrl(url: string): Promise<string>;
|
||||
getDirectVideoUrl(url: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface YtDlpDependencies {
|
||||
@@ -49,6 +50,19 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
|
||||
]);
|
||||
return value.trim().split("\n")[0] || url;
|
||||
},
|
||||
|
||||
async getDirectVideoUrl(url: string): Promise<string> {
|
||||
const value = await runYtDlp(spawn, [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
]);
|
||||
return value.trim().split("\n")[0] || url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user