2026-05-16 15:48:28 +07:00
|
|
|
import type { Readable } from "node:stream";
|
|
|
|
|
import {
|
|
|
|
|
playStream as defaultPlayStream,
|
|
|
|
|
prepareStream as defaultPrepareStream,
|
|
|
|
|
Encoders,
|
2026-05-17 01:01:40 +07:00
|
|
|
Streamer,
|
2026-05-16 15:48:28 +07:00
|
|
|
Utils,
|
2026-05-17 05:10:46 +07:00
|
|
|
} from "../streaming";
|
2026-05-16 15:48:28 +07:00
|
|
|
import { AppError } from "../errors";
|
2026-05-16 23:11:46 +07:00
|
|
|
import { createChildLogger } from "../logger";
|
2026-05-16 15:48:28 +07:00
|
|
|
import { discordPlayer } from "../player";
|
2026-05-16 23:11:46 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("screen-share");
|
2026-05-17 01:01:40 +07:00
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
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,
|
2026-05-17 01:01:40 +07:00
|
|
|
streamer: Streamer,
|
2026-05-16 15:48:28 +07:00
|
|
|
options: { type: "go-live" },
|
|
|
|
|
) => Promise<void>;
|
|
|
|
|
|
|
|
|
|
export interface ScreenShareControllerDependencies {
|
|
|
|
|
getVoiceStatus: () => ScreenShareVoiceStatus;
|
|
|
|
|
getPlayerOwner?: () => DiscordPlayerOwner;
|
|
|
|
|
getDirectVideoUrl?: (source: string) => Promise<string>;
|
|
|
|
|
prepareStream?: PrepareScreenStream;
|
|
|
|
|
playStream?: PlayScreenStream;
|
2026-05-17 01:01:40 +07:00
|
|
|
streamer: Streamer;
|
2026-05-17 05:10:46 +07:00
|
|
|
joinVoice?: (guildId: string, channelId: string) => Promise<unknown>;
|
2026-05-17 01:01:40 +07:00
|
|
|
onStreamStart?: () => void;
|
|
|
|
|
onStreamEnd?: () => void;
|
2026-05-16 15:48:28 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 =
|
2026-05-17 01:01:40 +07:00
|
|
|
dependencies.prepareStream ?? (defaultPrepareStream as PrepareScreenStream);
|
2026-05-16 15:48:28 +07:00
|
|
|
const playStream =
|
2026-05-17 01:01:40 +07:00
|
|
|
dependencies.playStream ?? (defaultPlayStream as PlayScreenStream);
|
2026-05-16 15:48:28 +07:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
isActive(): boolean {
|
|
|
|
|
return active !== null;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async start(source: string): Promise<ScreenSharePlayback> {
|
|
|
|
|
const status = dependencies.getVoiceStatus();
|
2026-05-17 01:01:40 +07:00
|
|
|
|
|
|
|
|
if (active) {
|
|
|
|
|
active.stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure bot is in the voice channel via Streamer for video streaming
|
2026-05-16 15:48:28 +07:00
|
|
|
if (
|
|
|
|
|
!status.connected ||
|
|
|
|
|
!status.activeGuildId ||
|
|
|
|
|
!status.activeChannelId
|
|
|
|
|
) {
|
|
|
|
|
throw new AppError(
|
|
|
|
|
"Connect to a voice channel before sharing screen",
|
|
|
|
|
"VOICE_NOT_CONNECTED",
|
|
|
|
|
409,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 05:10:46 +07:00
|
|
|
// If another media owner (e.g. music) holds the shared player, reject
|
|
|
|
|
const owner = getPlayerOwner();
|
|
|
|
|
if (owner === "music") {
|
|
|
|
|
throw new AppError("Another media mode is active", "MEDIA_BUSY", 409);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
try {
|
2026-05-17 01:01:40 +07:00
|
|
|
// Join voice via Streamer if not already connected for streaming
|
|
|
|
|
if (dependencies.joinVoice) {
|
|
|
|
|
logger.info("Joining voice channel for screen share via Streamer");
|
|
|
|
|
await dependencies.joinVoice(
|
|
|
|
|
status.activeGuildId,
|
|
|
|
|
status.activeChannelId,
|
|
|
|
|
);
|
|
|
|
|
logger.info("Voice channel joined via Streamer for screen share");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
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"),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-16 23:11:46 +07:00
|
|
|
// Add FFmpeg error logging
|
|
|
|
|
if (command && "stderr" in command && (command as any).stderr) {
|
|
|
|
|
(command as any).stderr.on("data", (data: Buffer) => {
|
|
|
|
|
if (data.toString().includes("Error")) {
|
|
|
|
|
logger.error({ error: data.toString() }, "FFmpeg Screen Error");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 01:01:40 +07:00
|
|
|
dependencies.onStreamStart?.();
|
|
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
let stopped = false;
|
|
|
|
|
const done = playStream(output, dependencies.streamer, {
|
|
|
|
|
type: "go-live",
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
active = null;
|
2026-05-17 01:01:40 +07:00
|
|
|
dependencies.onStreamEnd?.();
|
2026-05-16 15:48:28 +07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|