feat(gateway): restore Discord GoLive screenshare (dulu pernah ada, hilang saat split microservices)
User: 'dulu sharescreen juga bisa'. Terbukti: commit d50ce86 (Mei 2026)
punya src/media/screenShareController.ts + vendor @dank074/discord-video-stream,
hilang saat rombak monolith -> microservices. Interface ScreenShareController
masih ada di mediaTypes.ts tapi implementasinya tidak.
Restore:
- dep @dank074/discord-video-stream@6.0.0 (npm, dibangun untuk
discord.js-selfbot-v13 — cocok dengan stack gateway)
- mediaSource.getDirectVideoUrl (yt-dlp --get-url bestvideo+bestaudio)
- screenShareController.ts (BARU): Streamer(client) + prepareStream H264
720p30 + playStream go-live; owner check via discordPlayer
- media.handler: mode:'screen' di media:queue -> screen path; status
expose activeMode; stop matiin screen
- FE: tombol Screen di MusicPlayer + hook useMediaQueue({url, mode})
Verifikasi: gateway tsc PASS, FE tsc PASS, biome 0 error, next build PASS.
Nix build pending (dep native @lng2004/node-datachannel butuh pnpm rebuild).
This commit is contained in:
@@ -28,12 +28,12 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dank074/discord-video-stream": "6.0.0",
|
||||
"@discordjs/opus": "^0.10.0",
|
||||
"@discordjs/voice": "^0.19.2",
|
||||
"@snazzah/davey": "^0.1.11",
|
||||
"axios": "^1.16.1",
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"@dank074/discord-video-stream": "latest",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"imghash": "^1.1.4",
|
||||
|
||||
Generated
+5177
File diff suppressed because it is too large
Load Diff
@@ -82,7 +82,7 @@ export class CommandHandler {
|
||||
|
||||
// Create domain-specific handlers with their dependencies
|
||||
this.voiceHandler = new VoiceHandler(client, voiceController);
|
||||
this.mediaHandler = new MediaHandler();
|
||||
this.mediaHandler = new MediaHandler(client, () => voiceController.getStatus());
|
||||
this.guildHandler = new GuildHandler(client);
|
||||
this.moderationHandler = new ModerationHandler(client);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { StreamType } from "@discordjs/voice";
|
||||
@@ -11,6 +12,10 @@ import type {
|
||||
MediaQueueItem,
|
||||
} from "../voice-recording/mediaTypes.js";
|
||||
import { discordPlayer } from "../voice-recording/player.js";
|
||||
import {
|
||||
ScreenShareController,
|
||||
type ScreenShareVoiceStatus,
|
||||
} from "../voice-recording/screenShareController.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -27,6 +32,7 @@ interface MediaStatusItem {
|
||||
|
||||
export interface MediaStatusPayload {
|
||||
playing: boolean;
|
||||
activeMode: MediaMode | null;
|
||||
musicVolume: number;
|
||||
current: MediaStatusItem | null;
|
||||
queue: MediaStatusItem[];
|
||||
@@ -58,6 +64,7 @@ function buildStatusPayload(): MediaStatusPayload {
|
||||
return {
|
||||
playing:
|
||||
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
|
||||
activeMode: currentTrackItem?.mode ?? null,
|
||||
musicVolume: discordPlayer.getMusicVolume(),
|
||||
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
|
||||
queue: mediaQueue.map(mapToStatusItem),
|
||||
@@ -70,8 +77,17 @@ function buildStatusPayload(): MediaStatusPayload {
|
||||
|
||||
export class MediaHandler {
|
||||
private logger = createChildLogger("media-handler");
|
||||
private screenController: ScreenShareController | null = null;
|
||||
private screenPlayback: { stop(): void } | null = null;
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
private readonly client: Client | null = null,
|
||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus = () => ({
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
}),
|
||||
) {
|
||||
// Register auto-advance on natural track end
|
||||
discordPlayer.onIdle(() => {
|
||||
this.advanceQueue().catch((err) => {
|
||||
@@ -109,6 +125,50 @@ export class MediaHandler {
|
||||
};
|
||||
}
|
||||
|
||||
// Screen share (GoLive) path — bypasses the audio queue entirely.
|
||||
if (mode === "screen") {
|
||||
try {
|
||||
if (!this.client) {
|
||||
return {
|
||||
id: cmd.id,
|
||||
success: false,
|
||||
data: null,
|
||||
error: "Gateway client not initialized",
|
||||
};
|
||||
}
|
||||
if (!this.screenController) {
|
||||
this.screenController = new ScreenShareController(
|
||||
this.client,
|
||||
this.getVoiceStatus,
|
||||
);
|
||||
}
|
||||
const playback = await this.screenController.start(url);
|
||||
this.screenPlayback = playback;
|
||||
currentTrackItem = {
|
||||
id: randomUUID(),
|
||||
source: url,
|
||||
title: url,
|
||||
kind: "url",
|
||||
mode: "screen",
|
||||
requestedBy,
|
||||
addedAt: Date.now(),
|
||||
status: "playing",
|
||||
};
|
||||
playback.done.finally(() => {
|
||||
this.screenPlayback = null;
|
||||
if (currentTrackItem?.mode === "screen") {
|
||||
currentTrackItem = null;
|
||||
}
|
||||
});
|
||||
this.logger.info({ url }, "Screen share started");
|
||||
return { id: cmd.id, success: true, data: buildStatusPayload() };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error({ error: message }, "Screen share failed to start");
|
||||
return { id: cmd.id, success: false, data: null, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight metadata fetch for display — the full resolve happens in playNext
|
||||
let title: string = url;
|
||||
let duration: number | undefined;
|
||||
@@ -174,6 +234,12 @@ export class MediaHandler {
|
||||
}
|
||||
|
||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||
// Stop screen share if active — playback.done.finally clears the item.
|
||||
this.screenPlayback?.stop();
|
||||
this.screenPlayback = null;
|
||||
if (currentTrackItem?.mode === "screen") {
|
||||
currentTrackItem = null;
|
||||
}
|
||||
discordPlayer.stop("music");
|
||||
currentTrackItem = null;
|
||||
mediaQueue.length = 0; // Clear entire queue
|
||||
|
||||
@@ -282,6 +282,87 @@ export function resolveMediaUrl(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a media URL to a directly playable video URL (for screen share /
|
||||
* GoLive streaming). Uses yt-dlp `--get-url` with bestvideo+bestaudio.
|
||||
*
|
||||
* @throws If yt-dlp is not installed or the process exits with a non-zero code.
|
||||
*/
|
||||
export function getDirectVideoUrl(url: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const args = [
|
||||
url,
|
||||
"--get-url",
|
||||
"--format",
|
||||
"bestvideo[protocol^=http]+bestaudio[protocol^=http]/best[protocol^=http]/best",
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
"--quiet",
|
||||
];
|
||||
|
||||
logger.info({ url }, "Spawning yt-dlp for direct video URL");
|
||||
|
||||
const proc = spawn("yt-dlp", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
activeProcesses.add(proc);
|
||||
|
||||
let stdoutBuf = "";
|
||||
let stderrBuf = "";
|
||||
const MAX_STDERR = 4096;
|
||||
const MAX_STDOUT = 1_048_576;
|
||||
|
||||
if (proc.stdout) {
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
if (stdoutBuf.length < MAX_STDOUT) {
|
||||
stdoutBuf += chunk
|
||||
.toString("utf8")
|
||||
.slice(0, MAX_STDOUT - stdoutBuf.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (proc.stderr) {
|
||||
proc.stderr.on("data", (chunk: Buffer) => {
|
||||
if (stderrBuf.length < MAX_STDERR) {
|
||||
stderrBuf += chunk
|
||||
.toString("utf8")
|
||||
.slice(0, MAX_STDERR - stderrBuf.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
proc.on("error", (err: NodeJS.ErrnoException) => {
|
||||
activeProcesses.delete(proc);
|
||||
if (err.code === "ENOENT") {
|
||||
reject(buildNotInstalledError());
|
||||
} else {
|
||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
activeProcesses.delete(proc);
|
||||
|
||||
if (code !== 0) {
|
||||
const detail = stderrBuf.trim() ? `: ${stderrBuf.trim()}` : "";
|
||||
reject(
|
||||
new Error(`yt-dlp direct URL resolution exited with code ${code}${detail}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstLine = stdoutBuf.trim().split("\n")[0];
|
||||
if (!firstLine) {
|
||||
reject(new Error("yt-dlp returned no direct video URL"));
|
||||
return;
|
||||
}
|
||||
resolve(firstLine);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||
* without downloading the audio stream.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Encoders,
|
||||
prepareStream,
|
||||
playStream,
|
||||
Streamer,
|
||||
Utils,
|
||||
} from "@dank074/discord-video-stream";
|
||||
import type { Client } from "discord.js-selfbot-v13";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import type { ScreenSharePlayback } from "./mediaTypes.js";
|
||||
import { getDirectVideoUrl } from "./mediaSource.js";
|
||||
import { discordPlayer } from "./player.js";
|
||||
|
||||
const logger = createChildLogger("screen-share");
|
||||
|
||||
export interface ScreenShareVoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId: string | null;
|
||||
activeChannelId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord Go Live (screenshare) via @dank074/discord-video-stream.
|
||||
*
|
||||
* Pipeline:
|
||||
* URL (YouTube, dll.) → yt-dlp direct video URL → ffmpeg (H264 720p30) →
|
||||
* playStream({ type: "go-live" }) → Discord voice channel as Go Live.
|
||||
*
|
||||
* Restored from the pre-microservices implementation (commit d50ce86,
|
||||
* src/media/screenShareController.ts) — the interface survived in
|
||||
* mediaTypes.ts but the implementation was lost during the split.
|
||||
*/
|
||||
export class ScreenShareController {
|
||||
private logger = createChildLogger("screen-share");
|
||||
private streamer: Streamer | null = null;
|
||||
private active: ScreenSharePlayback | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly client: Client,
|
||||
private readonly getVoiceStatus: () => ScreenShareVoiceStatus,
|
||||
) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active !== null;
|
||||
}
|
||||
|
||||
async start(source: string): Promise<ScreenSharePlayback> {
|
||||
const status = this.getVoiceStatus();
|
||||
if (
|
||||
!status.connected ||
|
||||
!status.activeGuildId ||
|
||||
!status.activeChannelId
|
||||
) {
|
||||
throw new Error("Connect to a voice channel before sharing screen");
|
||||
}
|
||||
|
||||
if (this.active || discordPlayer.getOwner() !== "none") {
|
||||
throw new Error("Another media mode is active");
|
||||
}
|
||||
|
||||
try {
|
||||
const directUrl = await getDirectVideoUrl(source);
|
||||
if (!this.streamer) {
|
||||
this.streamer = new Streamer(this.client);
|
||||
}
|
||||
|
||||
const { command, output } = prepareStream(directUrl, {
|
||||
encoder: Encoders.software({ x264: { preset: "superfast" } }),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frameRate: 30,
|
||||
bitrateVideo: 2500,
|
||||
bitrateVideoMax: 4000,
|
||||
includeAudio: true,
|
||||
videoCodec: Utils.normalizeVideoCodec("H264"),
|
||||
});
|
||||
|
||||
let stopped = false;
|
||||
const done = playStream(output, this.streamer, {
|
||||
type: "go-live",
|
||||
}).finally(() => {
|
||||
this.active = null;
|
||||
});
|
||||
|
||||
const controller = this;
|
||||
this.active = {
|
||||
done,
|
||||
stop: () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
command.kill("SIGTERM");
|
||||
controller.active = null;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info({ source }, "Screen share started");
|
||||
return this.active;
|
||||
} catch (error) {
|
||||
this.active = null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error({ error: message, source }, "Screen stream failed");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,19 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
||||
const stopMut = useMediaStop();
|
||||
const volumeMut = useMediaVolume();
|
||||
const [queueUrl, setQueueUrl] = useState("");
|
||||
const [screenMode, setScreenMode] = useState(false);
|
||||
|
||||
// Sync WS media_state into the query cache
|
||||
useMediaWsSync(ws);
|
||||
|
||||
const handleQueue = useCallback(() => {
|
||||
if (!queueUrl.trim()) return;
|
||||
queueMut.mutate(queueUrl.trim());
|
||||
queueMut.mutate({
|
||||
url: queueUrl.trim(),
|
||||
mode: screenMode ? "screen" : "music",
|
||||
});
|
||||
setQueueUrl("");
|
||||
}, [queueUrl, queueMut]);
|
||||
}, [queueUrl, queueMut, screenMode]);
|
||||
|
||||
const handleVolume = useCallback(
|
||||
(value: number | readonly number[]) => {
|
||||
@@ -64,6 +68,15 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
||||
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
|
||||
className="flex-1 h-9"
|
||||
/>
|
||||
<Button
|
||||
variant={screenMode ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setScreenMode((v) => !v)}
|
||||
title="Queue as Discord GoLive screenshare instead of audio playback"
|
||||
className="h-9"
|
||||
>
|
||||
Screen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleQueue}
|
||||
disabled={!queueUrl.trim() || queueMut.isPending}
|
||||
@@ -73,6 +86,14 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mediaState?.activeMode && (
|
||||
<p className="text-[10px] font-mono text-primary/80 uppercase tracking-wider">
|
||||
{mediaState.activeMode === "screen"
|
||||
? "Screen share active"
|
||||
: "Music playing"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mediaState?.current ? (
|
||||
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4 space-y-2">
|
||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider flex items-center gap-1.5">
|
||||
|
||||
@@ -24,7 +24,10 @@ function useMediaAction<TArgs>(fn: (args: TArgs) => Promise<MediaState>) {
|
||||
}
|
||||
|
||||
export function useMediaQueue() {
|
||||
return useMediaAction((url: string) => mediaApi.queue(url, "music"));
|
||||
return useMediaAction(
|
||||
(input: { url: string; mode?: "music" | "screen" }) =>
|
||||
mediaApi.queue(input.url, input.mode ?? "music"),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaSkip() {
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface MediaItem {
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
activeMode?: MediaMode | null;
|
||||
musicVolume: number;
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
|
||||
Reference in New Issue
Block a user