feat: refactor screen share controller to use Streamer for session management and simplify stream handling

This commit is contained in:
MythEclipse
2026-05-17 05:15:38 +07:00
parent 5a926dbd17
commit 6de5342703
4 changed files with 162 additions and 89 deletions

View File

@@ -1,10 +1,6 @@
import type { Readable } from "node:stream";
import {
playStream as defaultPlayStream,
prepareStream as defaultPrepareStream,
Encoders,
Streamer,
Utils,
playPreparedStream,
} from "../streaming";
import { AppError } from "../errors";
import { createChildLogger } from "../logger";
@@ -21,30 +17,11 @@ export interface ScreenShareVoiceStatus {
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: Streamer,
options: { type: "go-live" },
) => Promise<void>;
export interface ScreenShareControllerDependencies {
getVoiceStatus: () => ScreenShareVoiceStatus;
getPlayerOwner?: () => DiscordPlayerOwner;
getDirectVideoUrl?: (source: string) => Promise<string>;
prepareStream?: PrepareScreenStream;
playStream?: PlayScreenStream;
streamer: Streamer;
joinVoice?: (guildId: string, channelId: string) => Promise<unknown>;
onStreamStart?: () => void;
onStreamEnd?: () => void;
}
@@ -59,10 +36,6 @@ export function createScreenShareController(
const getDirectVideoUrl =
dependencies.getDirectVideoUrl ??
((source) => ytdlp.getDirectVideoUrl(source));
const prepareStream =
dependencies.prepareStream ?? (defaultPrepareStream as PrepareScreenStream);
const playStream =
dependencies.playStream ?? (defaultPlayStream as PlayScreenStream);
return {
isActive(): boolean {
@@ -76,7 +49,7 @@ export function createScreenShareController(
active.stop();
}
// Ensure bot is in the voice channel via Streamer for video streaming
// Ensure bot is in the voice channel and owns the screen-share stream
if (
!status.connected ||
!status.activeGuildId ||
@@ -96,52 +69,32 @@ export function createScreenShareController(
}
try {
// 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");
}
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"),
});
// 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");
}
});
}
const session = await dependencies.streamer.createSession(
status.activeGuildId,
status.activeChannelId,
);
dependencies.onStreamStart?.();
let stopped = false;
const done = playStream(output, dependencies.streamer, {
type: "go-live",
const done = playPreparedStream(directUrl, session, {
fps: 30,
bitrate: 2500,
includeAudio: true,
presetH26x: "superfast",
}).finally(() => {
active = null;
dependencies.onStreamEnd?.();
});
done.catch(() => undefined);
active = {
done,
stop() {
if (stopped) return;
stopped = true;
command.kill?.("SIGTERM");
session.stop();
active = null;
},
};

View File

@@ -1,8 +1,43 @@
import { spawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { Readable } from "node:stream";
import type { Client } from "discord.js-selfbot-v13";
type VoiceConnectionLike = {
channel: {
id: string;
};
createStreamConnection: () => Promise<StreamConnectionLike>;
disconnect?: () => void;
};
type StreamConnectionLike = {
playVideo: (resource: string | Readable, options?: Record<string, unknown>) => DispatcherLike;
playAudio: (resource: string | Readable, options?: Record<string, unknown>) => DispatcherLike;
disconnect?: () => void;
};
type DispatcherLike = EventEmitter & {
stop?: () => void;
pause?: () => void;
resume?: () => void;
};
export interface StreamPlayOptions {
fps?: number;
bitrate?: number | string;
includeAudio?: boolean;
presetH26x?: string;
}
export interface StreamSession {
connection: VoiceConnectionLike;
stream: StreamConnectionLike;
play(source: string | Readable, options?: StreamPlayOptions): Promise<void>;
stop(): void;
}
export const Encoders = {
software: (opts: any) => opts,
};
@@ -17,11 +52,81 @@ export class Streamer {
this.client = client;
}
// Lightweight joinVoice placeholder. Real implementation may create a
// WebRTC connection using private discord.js-selfbot-v13 internals.
async joinVoice(_guildId: string, _channelId: string): Promise<unknown> {
// No-op for now; consumers may override with a richer implementation.
return Promise.resolve({});
async joinVoice(guildId: string, channelId: string): Promise<VoiceConnectionLike> {
const channel = (this.client.channels.resolve(channelId) ?? this.client.channels.cache.get(channelId)) as any;
if (!channel || channel.guild?.id !== guildId) {
throw new Error("VOICE_CHANNEL_NOT_FOUND");
}
const voiceConnection = (await this.client.voice.joinChannel(channel as any, {
selfMute: true,
selfDeaf: true,
selfVideo: false,
videoCodec: "H264",
})) as unknown as VoiceConnectionLike;
return voiceConnection;
}
async createSession(guildId: string, channelId: string): Promise<StreamSession> {
const connection = await this.joinVoice(guildId, channelId);
const stream = await connection.createStreamConnection();
let activeVideo: DispatcherLike | null = null;
let activeAudio: DispatcherLike | null = null;
let finished = false;
const stop = () => {
activeVideo?.stop?.();
activeAudio?.stop?.();
stream.disconnect?.();
connection.disconnect?.();
};
const waitForFinish = () =>
new Promise<void>((resolve, reject) => {
const maybeResolve = () => {
if (finished) return;
finished = true;
resolve();
};
const handleError = (error: unknown) => {
if (finished) return;
finished = true;
stop();
reject(error instanceof Error ? error : new Error(String(error)));
};
activeVideo?.on("finish", maybeResolve);
activeAudio?.on("finish", maybeResolve);
activeVideo?.on("error", handleError);
activeAudio?.on("error", handleError);
});
return {
connection,
stream,
async play(source: string | Readable, options: StreamPlayOptions = {}) {
const videoOptions = {
fps: options.fps ?? 30,
bitrate: options.bitrate ?? 2500,
presetH26x: options.presetH26x ?? "superfast",
};
activeVideo = stream.playVideo(source, videoOptions);
if (options.includeAudio !== false) {
activeAudio = stream.playAudio(source, { volume: false });
}
try {
await waitForFinish();
} finally {
stop();
}
},
stop,
};
}
}
@@ -78,3 +183,19 @@ export async function playStream(
if (output.readable) output.resume();
});
}
export async function createStreamSession(
client: Client,
guildId: string,
channelId: string,
): Promise<StreamSession> {
return new Streamer(client).createSession(guildId, channelId);
}
export async function playPreparedStream(
source: string | Readable,
session: StreamSession,
options: StreamPlayOptions = {},
): Promise<void> {
await session.play(source, options);
}

View File

@@ -211,8 +211,6 @@ export async function startWebserver(
const screenController = createScreenShareController({
getVoiceStatus: () => voiceController.getStatus(),
streamer,
joinVoice: (guildId: string, channelId: string) =>
streamer.joinVoice(guildId, channelId),
});
const mediaController = new MediaController({