feat: enhance screen share controller with Streamer integration and voice channel management
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import type { WebRtcConnWrapper } from "@dank074/discord-video-stream";
|
||||
import {
|
||||
playStream as defaultPlayStream,
|
||||
prepareStream as defaultPrepareStream,
|
||||
Encoders,
|
||||
Streamer,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import { AppError } from "../errors";
|
||||
@@ -10,6 +12,7 @@ import { createChildLogger } from "../logger";
|
||||
import { discordPlayer } from "../player";
|
||||
|
||||
const logger = createChildLogger("screen-share");
|
||||
|
||||
import type { DiscordPlayerOwner, ScreenSharePlayback } from "./mediaTypes";
|
||||
import { createYtDlp } from "./ytdlp";
|
||||
|
||||
@@ -31,7 +34,7 @@ type PrepareScreenStream = (
|
||||
|
||||
type PlayScreenStream = (
|
||||
output: Readable,
|
||||
streamer: unknown,
|
||||
streamer: Streamer,
|
||||
options: { type: "go-live" },
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -41,7 +44,13 @@ export interface ScreenShareControllerDependencies {
|
||||
getDirectVideoUrl?: (source: string) => Promise<string>;
|
||||
prepareStream?: PrepareScreenStream;
|
||||
playStream?: PlayScreenStream;
|
||||
streamer: unknown;
|
||||
streamer: Streamer;
|
||||
joinVoice?: (
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
) => Promise<WebRtcConnWrapper>;
|
||||
onStreamStart?: () => void;
|
||||
onStreamEnd?: () => void;
|
||||
}
|
||||
|
||||
export function createScreenShareController(
|
||||
@@ -55,11 +64,9 @@ export function createScreenShareController(
|
||||
dependencies.getDirectVideoUrl ??
|
||||
((source) => ytdlp.getDirectVideoUrl(source));
|
||||
const prepareStream =
|
||||
dependencies.prepareStream ??
|
||||
(defaultPrepareStream as unknown as PrepareScreenStream);
|
||||
dependencies.prepareStream ?? (defaultPrepareStream as PrepareScreenStream);
|
||||
const playStream =
|
||||
dependencies.playStream ??
|
||||
(defaultPlayStream as unknown as PlayScreenStream);
|
||||
dependencies.playStream ?? (defaultPlayStream as PlayScreenStream);
|
||||
|
||||
return {
|
||||
isActive(): boolean {
|
||||
@@ -68,6 +75,12 @@ export function createScreenShareController(
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = dependencies.getVoiceStatus();
|
||||
|
||||
if (active) {
|
||||
active.stop();
|
||||
}
|
||||
|
||||
// Ensure bot is in the voice channel via Streamer for video streaming
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
@@ -80,11 +93,17 @@ export function createScreenShareController(
|
||||
);
|
||||
}
|
||||
|
||||
if (active) {
|
||||
active.stop();
|
||||
}
|
||||
|
||||
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" } }),
|
||||
@@ -105,11 +124,14 @@ export function createScreenShareController(
|
||||
});
|
||||
}
|
||||
|
||||
dependencies.onStreamStart?.();
|
||||
|
||||
let stopped = false;
|
||||
const done = playStream(output, dependencies.streamer, {
|
||||
type: "go-live",
|
||||
}).finally(() => {
|
||||
active = null;
|
||||
dependencies.onStreamEnd?.();
|
||||
});
|
||||
|
||||
active = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||
@@ -43,4 +44,5 @@ Module.prototype.require = function (id: string) {
|
||||
};
|
||||
|
||||
console.log("[mock] node-crc has been mocked globally for ESM.");
|
||||
|
||||
export {};
|
||||
|
||||
@@ -42,7 +42,12 @@ async function processAnalysisRequest({
|
||||
}
|
||||
} catch (dbError) {
|
||||
const msg = dbError instanceof Error ? dbError.message : String(dbError);
|
||||
return { ok: false, conversationKey, rows: [], error: `Database init failed: ${msg}` };
|
||||
return {
|
||||
ok: false,
|
||||
conversationKey,
|
||||
rows: [],
|
||||
error: `Database init failed: ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
const firstMessage = messages[0];
|
||||
|
||||
@@ -42,7 +42,8 @@ export function parseModerationResponse(
|
||||
parsed = JSON.parse(candidate);
|
||||
} catch (error) {
|
||||
// If full substring fails, try scanning backwards from the last }
|
||||
let lastError: Error = error instanceof Error ? error : new Error(String(error));
|
||||
let lastError: Error =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
for (let i = endIdx - 1; i > startIdx; i--) {
|
||||
if (content[i] === "}") {
|
||||
@@ -50,7 +51,10 @@ export function parseModerationResponse(
|
||||
parsed = JSON.parse(content.substring(startIdx, i + 1));
|
||||
break;
|
||||
} catch (innerError) {
|
||||
lastError = innerError instanceof Error ? innerError : new Error(String(innerError));
|
||||
lastError =
|
||||
innerError instanceof Error
|
||||
? innerError
|
||||
: new Error(String(innerError));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +113,10 @@ export function parseModerationResponse(
|
||||
}
|
||||
|
||||
if (foundIds.has(finalId)) {
|
||||
log.warn({ duplicateId: finalId }, "Skipping duplicate/rounded message_id");
|
||||
log.warn(
|
||||
{ duplicateId: finalId },
|
||||
"Skipping duplicate/rounded message_id",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,51 +30,66 @@ export function createMediaRoutes(
|
||||
}
|
||||
};
|
||||
|
||||
router.get("/media/status", (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(controller.getState());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/media/queue", adminAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { source, mode = "music" } = req.body as {
|
||||
source?: string;
|
||||
mode?: MediaMode;
|
||||
};
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
"Media source is required",
|
||||
"MISSING_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
router.get(
|
||||
"/media/status",
|
||||
(_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(controller.getState());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
if (mode !== "music" && mode !== "screen") {
|
||||
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/media/queue",
|
||||
adminAuth,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { source, mode = "music" } = req.body as {
|
||||
source?: string;
|
||||
mode?: MediaMode;
|
||||
};
|
||||
if (!source) {
|
||||
throw new AppError(
|
||||
"Media source is required",
|
||||
"MISSING_MEDIA_SOURCE",
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (mode !== "music" && mode !== "screen") {
|
||||
throw new AppError("Invalid media mode", "INVALID_MEDIA_MODE", 400);
|
||||
}
|
||||
res.json(await controller.queue(source, { mode }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
res.json(await controller.queue(source, { mode }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/media/skip", adminAuth, async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.skip());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
"/media/skip",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.skip());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post("/media/stop", adminAuth, async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.stop());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
"/media/stop",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.json(await controller.stop());
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -71,93 +71,111 @@ export function createVoiceRoutes(
|
||||
});
|
||||
|
||||
// GET /api/guilds/:guildId/voice-channels - List voice channels in a guild
|
||||
router.get("/guilds/:guildId/voice-channels", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
router.get(
|
||||
"/guilds/:guildId/voice-channels",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listVoiceChannels(
|
||||
guildId as string,
|
||||
);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listVoiceChannels(guildId as string);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/guilds/:guildId/channels - List text channels in a guild
|
||||
router.get("/guilds/:guildId/channels", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
router.get(
|
||||
"/guilds/:guildId/channels",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId } = req.params;
|
||||
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
if (!guildId) {
|
||||
throw new AppError("Guild ID is required", "MISSING_GUILD_ID", 400);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listWatchableChannels(
|
||||
guildId as string,
|
||||
);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
const channels = await voiceController.listWatchableChannels(guildId as string);
|
||||
res.json(channels);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/connect - Connect to a voice channel
|
||||
router.post("/connect", adminAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
router.post(
|
||||
"/connect",
|
||||
adminAuth,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { guildId, channelId } = req.body as {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
};
|
||||
|
||||
if (!guildId || !channelId) {
|
||||
throw new AppError(
|
||||
"guildId and channelId are required",
|
||||
"MISSING_CONNECT_FIELDS",
|
||||
400,
|
||||
);
|
||||
if (!guildId || !channelId) {
|
||||
throw new AppError(
|
||||
"guildId and channelId are required",
|
||||
"MISSING_CONNECT_FIELDS",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ guildId, channelId }, "Connecting to voice channel");
|
||||
|
||||
const status = await voiceController.connect(guildId, channelId);
|
||||
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedVoiceGuild: guildId,
|
||||
selectedVoiceChannel: channelId,
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
logger.info({ guildId, channelId }, "Connecting to voice channel");
|
||||
|
||||
const status = await voiceController.connect(guildId, channelId);
|
||||
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedVoiceGuild: guildId,
|
||||
selectedVoiceChannel: channelId,
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/disconnect - Disconnect from voice channel
|
||||
router.post("/disconnect", adminAuth, async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
logger.info("Disconnecting from voice channel");
|
||||
router.post(
|
||||
"/disconnect",
|
||||
adminAuth,
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
logger.info("Disconnecting from voice channel");
|
||||
|
||||
const status = await voiceController.disconnect();
|
||||
const status = await voiceController.disconnect();
|
||||
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
// Update UI state and broadcast to connected clients
|
||||
if (patchSharedUIState && broadcaster) {
|
||||
const updatedState = patchSharedUIState({
|
||||
selectedVoiceGuild: "",
|
||||
selectedVoiceChannel: "",
|
||||
});
|
||||
broadcaster.uiState(updatedState);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
|
||||
import { Streamer } from "@dank074/discord-video-stream";
|
||||
import { AudioPlayerStatus } from "@discordjs/voice";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { config } from "./config";
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
@@ -14,6 +13,7 @@ import express, {
|
||||
import helmet from "helmet";
|
||||
import * as prism from "prism-media";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { config } from "./config";
|
||||
import { AppError } from "./errors";
|
||||
import { createChildLogger, logger } from "./logger";
|
||||
import { MediaController } from "./media/mediaController";
|
||||
@@ -122,7 +122,9 @@ function patchSharedUIState(patch: SharedUIStatePatch) {
|
||||
if (typeof patch.selectedTextChannel === "string") {
|
||||
sharedUIState.selectedTextChannel = patch.selectedTextChannel;
|
||||
}
|
||||
if (["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")) {
|
||||
if (
|
||||
["voice", "messages", "media", "review"].includes(patch.activeTab ?? "")
|
||||
) {
|
||||
sharedUIState.activeTab = patch.activeTab as
|
||||
| "voice"
|
||||
| "messages"
|
||||
@@ -189,6 +191,8 @@ export async function startWebserver(
|
||||
const screenController = createScreenShareController({
|
||||
getVoiceStatus: () => voiceController.getStatus(),
|
||||
streamer,
|
||||
joinVoice: (guildId: string, channelId: string) =>
|
||||
streamer.joinVoice(guildId, channelId),
|
||||
});
|
||||
|
||||
const mediaController = new MediaController({
|
||||
|
||||
Reference in New Issue
Block a user