Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
493bca590d | ||
|
|
823b484497 | ||
|
|
891c1305f0 | ||
|
|
189ab1c1f6 | ||
|
|
9d60f00934 |
@@ -11,6 +11,15 @@
|
|||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
|
||||||
|
# OpenSSL headers (.dev output) + STATIC libs (pkgsStatic.openssl.out —
|
||||||
|
# node-datachannel's CMakeLists sets OPENSSL_USE_STATIC_LIBS=TRUE, and
|
||||||
|
# the default `pkgs.openssl` resolves to `bin` which has no lib/) merged
|
||||||
|
# into one tree so FindOpenSSL resolves both via OPENSSL_ROOT_DIR.
|
||||||
|
opensslDevEnv = pkgs.symlinkJoin {
|
||||||
|
name = "openssl-dev-env";
|
||||||
|
paths = [ pkgs.pkgsStatic.openssl.out pkgs.openssl.dev ];
|
||||||
|
};
|
||||||
|
|
||||||
# ---- Shared build tools ----
|
# ---- Shared build tools ----
|
||||||
nodejs = pkgs.nodejs_22;
|
nodejs = pkgs.nodejs_22;
|
||||||
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
|
pnpm = pkgs.pnpm.override { nodejs = nodejs; };
|
||||||
@@ -105,14 +114,49 @@ WRAPPER
|
|||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
nodejs pnpm
|
nodejs pnpm
|
||||||
pkgs.python3 pkgs.gnumake pkgs.gcc
|
pkgs.python3 pkgs.gnumake pkgs.gcc pkgs.cmake
|
||||||
pkgs.rustc pkgs.cargo
|
pkgs.rustc pkgs.cargo
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
|
pkgs.openssl.dev
|
||||||
|
pkgs.git # libdatachannel FetchContent clones from GitHub
|
||||||
pkgs.cacert
|
pkgs.cacert
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Runtime tools for the voice pipeline: ffmpeg (mic transmit encode,
|
||||||
|
# music stream decode, segment muxing) and yt-dlp (YouTube/Spotify/
|
||||||
|
# search media resolution). Must be on PATH inside the wrapper below.
|
||||||
|
buildInputs = [ pkgs.ffmpeg-headless pkgs.yt-dlp ];
|
||||||
|
|
||||||
|
# cmake is only needed for node-datachannel's postinstall build —
|
||||||
|
# do NOT let stdenv run its own cmake configure phase on the source.
|
||||||
|
dontUseCmakeConfigure = true;
|
||||||
|
|
||||||
buildPhase = pnpmInstall + ''
|
buildPhase = pnpmInstall + ''
|
||||||
|
echo "=== Building native voice deps ==="
|
||||||
|
# pnpm rebuild aborts on the first failing package and runs scripts
|
||||||
|
# from the wrong cwd — build each native dep explicitly with its own
|
||||||
|
# install script. Each failure is tolerated (|| true); the packages
|
||||||
|
# that matter (opus, datachannel, node-av) are verified at runtime.
|
||||||
|
for pkg in \
|
||||||
|
node_modules/.pnpm/@discordjs+opus@*/node_modules/@discordjs/opus \
|
||||||
|
node_modules/.pnpm/@lng2004+node-datachannel@*/node_modules/@lng2004/node-datachannel \
|
||||||
|
node_modules/.pnpm/zeromq@*/node_modules/zeromq
|
||||||
|
do
|
||||||
|
if [ -d "$pkg" ]; then
|
||||||
|
echo "--- native build: $pkg ---"
|
||||||
|
(cd "$pkg" && npm run install 2>&1 || true)
|
||||||
|
# node-datachannel's `prebuild -r napi` CLI is broken (TypeError:
|
||||||
|
# expected first argument to be an array) — the install fallback
|
||||||
|
# populates devDeps incl. cmake-js; build directly via cmake-js.
|
||||||
|
if [ "$(basename "$pkg")" = "node-datachannel" ]; then
|
||||||
|
echo "--- datachannel cmake-js compile ---"
|
||||||
|
# Nix splits OpenSSL headers/libs across outputs — merge them
|
||||||
|
# (opensslDevEnv) so FindOpenSSL finds both include + libcrypto.
|
||||||
|
(cd "$pkg" && OPENSSL_ROOT_DIR="${opensslDevEnv}" npm run compile 2>&1 || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
echo "=== Compiling TypeScript ==="
|
echo "=== Compiling TypeScript ==="
|
||||||
npx tsc 2>&1
|
npx tsc 2>&1
|
||||||
echo "=== Fixing @/ path aliases to relative paths ==="
|
echo "=== Fixing @/ path aliases to relative paths ==="
|
||||||
@@ -154,6 +198,7 @@ WRAPPER
|
|||||||
cat > $out/bin/gmw-discord-gateway << WRAPPER
|
cat > $out/bin/gmw-discord-gateway << WRAPPER
|
||||||
#!${pkgs.runtimeShell}
|
#!${pkgs.runtimeShell}
|
||||||
cd $out/lib/gmw-discord-gateway
|
cd $out/lib/gmw-discord-gateway
|
||||||
|
export PATH=${pkgs.ffmpeg-headless}/bin:${pkgs.yt-dlp}/bin:\$PATH
|
||||||
exec ${nodejs}/bin/node dist/index.js
|
exec ${nodejs}/bin/node dist/index.js
|
||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x $out/bin/gmw-discord-gateway
|
chmod +x $out/bin/gmw-discord-gateway
|
||||||
|
|||||||
@@ -10,13 +10,9 @@
|
|||||||
"@lng2004/node-datachannel",
|
"@lng2004/node-datachannel",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"node-av",
|
"node-av",
|
||||||
"node-crc",
|
|
||||||
"sharp",
|
"sharp",
|
||||||
"zeromq"
|
"zeromq"
|
||||||
],
|
]
|
||||||
"patchedDependencies": {
|
|
||||||
"node-crc@4.0.0": "./patches/node-crc@4.0.0.patch"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
@@ -28,19 +24,18 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dank074/discord-video-stream": "6.0.0",
|
||||||
"@discordjs/opus": "^0.10.0",
|
"@discordjs/opus": "^0.10.0",
|
||||||
"@discordjs/voice": "^0.19.2",
|
"@discordjs/voice": "^0.19.2",
|
||||||
"@snazzah/davey": "^0.1.11",
|
"@snazzah/davey": "^0.1.11",
|
||||||
"axios": "^1.16.1",
|
"axios": "^1.16.1",
|
||||||
"discord.js-selfbot-v13": "^3.7.1",
|
"discord.js-selfbot-v13": "^3.7.1",
|
||||||
"@dank074/discord-video-stream": "latest",
|
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"imghash": "^1.1.4",
|
"imghash": "^1.1.4",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lru-cache": "^11.5.1",
|
"lru-cache": "^11.5.1",
|
||||||
"node-crc": "^4.0.0",
|
|
||||||
"openai": "^6.38.0",
|
"openai": "^6.38.0",
|
||||||
"opusscript": "^0.0.8",
|
"opusscript": "^0.0.8",
|
||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
diff --git a/Cargo.toml b/Cargo.toml
|
|
||||||
index a967508960d8b6b686b23401ea14333b858a675c..d0282a30ee931563a65d774ed783c83d6d2fdd5e 100644
|
|
||||||
--- a/Cargo.toml
|
|
||||||
+++ b/Cargo.toml
|
|
||||||
@@ -3,7 +3,7 @@ name = "node-crc"
|
|
||||||
version = "4.0.0"
|
|
||||||
authors = ["Magic Len <len@magiclen.org>"]
|
|
||||||
edition = "2021"
|
|
||||||
-rust-version = "1.65"
|
|
||||||
+rust-version = "1.77.0"
|
|
||||||
repository = "https://github.com/magiclen/node-crc"
|
|
||||||
homepage = "https://magiclen.org/node-js-crc/"
|
|
||||||
keywords = ["nodejs", "crc8", "crc16", "crc32", "crc64"]
|
|
||||||
Generated
+5146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
allowBuilds:
|
||||||
|
"@discordjs/opus": true
|
||||||
|
"@lng2004/node-datachannel": true
|
||||||
|
esbuild: true
|
||||||
|
node-av: true
|
||||||
|
zeromq: true
|
||||||
|
# pnpm 11 requires build-script approvals here (the legacy `pnpm` field in
|
||||||
|
# package.json is ignored). Native voice deps need their postinstall build.
|
||||||
|
# NOTE: sharp sengaja TIDAK ada — binary-nya dari @img/sharp-linux-x64
|
||||||
|
# (prebuilt), install script-nya cuma validasi dan gagal di Nix sandbox.
|
||||||
|
# Kalau script sharp dijalankan pnpm rebuild abort sebelum opus/datachannel
|
||||||
|
# kebangun. node-crc dihapus dari deps (tidak pernah di-import).
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@discordjs/opus"
|
||||||
|
- "@lng2004/node-datachannel"
|
||||||
|
- esbuild
|
||||||
|
- node-av
|
||||||
|
- zeromq
|
||||||
@@ -82,7 +82,7 @@ export class CommandHandler {
|
|||||||
|
|
||||||
// Create domain-specific handlers with their dependencies
|
// Create domain-specific handlers with their dependencies
|
||||||
this.voiceHandler = new VoiceHandler(client, voiceController);
|
this.voiceHandler = new VoiceHandler(client, voiceController);
|
||||||
this.mediaHandler = new MediaHandler();
|
this.mediaHandler = new MediaHandler(client, () => voiceController.getStatus());
|
||||||
this.guildHandler = new GuildHandler(client);
|
this.guildHandler = new GuildHandler(client);
|
||||||
this.moderationHandler = new ModerationHandler(client);
|
this.moderationHandler = new ModerationHandler(client);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { Client } from "discord.js-selfbot-v13";
|
||||||
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
import type { CommandMessage, CommandReply } from "../../shared/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
import { StreamType } from "@discordjs/voice";
|
import { StreamType } from "@discordjs/voice";
|
||||||
@@ -11,6 +12,10 @@ import type {
|
|||||||
MediaQueueItem,
|
MediaQueueItem,
|
||||||
} from "../voice-recording/mediaTypes.js";
|
} from "../voice-recording/mediaTypes.js";
|
||||||
import { discordPlayer } from "../voice-recording/player.js";
|
import { discordPlayer } from "../voice-recording/player.js";
|
||||||
|
import {
|
||||||
|
ScreenShareController,
|
||||||
|
type ScreenShareVoiceStatus,
|
||||||
|
} from "../voice-recording/screenShareController.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -27,6 +32,7 @@ interface MediaStatusItem {
|
|||||||
|
|
||||||
export interface MediaStatusPayload {
|
export interface MediaStatusPayload {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
activeMode: MediaMode | null;
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
current: MediaStatusItem | null;
|
current: MediaStatusItem | null;
|
||||||
queue: MediaStatusItem[];
|
queue: MediaStatusItem[];
|
||||||
@@ -58,6 +64,7 @@ function buildStatusPayload(): MediaStatusPayload {
|
|||||||
return {
|
return {
|
||||||
playing:
|
playing:
|
||||||
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
|
currentTrackItem !== null && discordPlayer.getStatus() === "playing",
|
||||||
|
activeMode: currentTrackItem?.mode ?? null,
|
||||||
musicVolume: discordPlayer.getMusicVolume(),
|
musicVolume: discordPlayer.getMusicVolume(),
|
||||||
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
|
current: currentTrackItem ? mapToStatusItem(currentTrackItem) : null,
|
||||||
queue: mediaQueue.map(mapToStatusItem),
|
queue: mediaQueue.map(mapToStatusItem),
|
||||||
@@ -70,8 +77,17 @@ function buildStatusPayload(): MediaStatusPayload {
|
|||||||
|
|
||||||
export class MediaHandler {
|
export class MediaHandler {
|
||||||
private logger = createChildLogger("media-handler");
|
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
|
// Register auto-advance on natural track end
|
||||||
discordPlayer.onIdle(() => {
|
discordPlayer.onIdle(() => {
|
||||||
this.advanceQueue().catch((err) => {
|
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
|
// Lightweight metadata fetch for display — the full resolve happens in playNext
|
||||||
let title: string = url;
|
let title: string = url;
|
||||||
let duration: number | undefined;
|
let duration: number | undefined;
|
||||||
@@ -174,6 +234,12 @@ export class MediaHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleMediaStop(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
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");
|
discordPlayer.stop("music");
|
||||||
currentTrackItem = null;
|
currentTrackItem = null;
|
||||||
mediaQueue.length = 0; // Clear entire queue
|
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
|
* Extract metadata (title, duration, thumbnail) from a media URL
|
||||||
* without downloading the audio stream.
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export default function VoicePage() {
|
|||||||
const { speakers, subscribe } = useSpeakers();
|
const { speakers, subscribe } = useSpeakers();
|
||||||
const connectMut = useVoiceConnect();
|
const connectMut = useVoiceConnect();
|
||||||
const disconnectMut = useVoiceDisconnect();
|
const disconnectMut = useVoiceDisconnect();
|
||||||
const micMut = useMicTransmit();
|
const micMut = useMicTransmit(ws);
|
||||||
const [selectedChannel, setSelectedChannel] = useState("");
|
const [selectedChannel, setSelectedChannel] = useState("");
|
||||||
const [micActive, setMicActive] = useState(false);
|
const [micActive, setMicActive] = useState(false);
|
||||||
const [volume, setVolume] = useState(75);
|
const [volume, setVolume] = useState(75);
|
||||||
@@ -41,16 +41,33 @@ export default function VoicePage() {
|
|||||||
|
|
||||||
const handleMicToggle = useCallback(
|
const handleMicToggle = useCallback(
|
||||||
async (checked: boolean) => {
|
async (checked: boolean) => {
|
||||||
setMicActive(checked);
|
if (checked) {
|
||||||
try {
|
try {
|
||||||
await micMut.mutateAsync(checked);
|
await micMut.mutateAsync(true);
|
||||||
} catch {
|
setMicActive(true);
|
||||||
setMicActive(!checked);
|
} catch {
|
||||||
|
setMicActive(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMicActive(false);
|
||||||
|
try {
|
||||||
|
await micMut.mutateAsync(false);
|
||||||
|
} catch {
|
||||||
|
// Stop already tore down the local transmitter — ignore remote errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[micMut],
|
[micMut],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback(
|
||||||
|
(v: number) => {
|
||||||
|
setVolume(v);
|
||||||
|
micMut.setVolume(v);
|
||||||
|
},
|
||||||
|
[micMut],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGuildChange = useCallback((guildId: string | null) => {
|
const handleGuildChange = useCallback((guildId: string | null) => {
|
||||||
if (!guildId) {
|
if (!guildId) {
|
||||||
setSelectedGuild("");
|
setSelectedGuild("");
|
||||||
@@ -89,7 +106,13 @@ export default function VoicePage() {
|
|||||||
channelId: selectedChannel,
|
channelId: selectedChannel,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onDisconnect={() => disconnectMut.mutate(undefined)}
|
onDisconnect={() => {
|
||||||
|
if (micActive) {
|
||||||
|
setMicActive(false);
|
||||||
|
void micMut.mutateAsync(false).catch(() => {});
|
||||||
|
}
|
||||||
|
disconnectMut.mutate(undefined);
|
||||||
|
}}
|
||||||
connecting={connectMut.isPending}
|
connecting={connectMut.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -101,7 +124,7 @@ export default function VoicePage() {
|
|||||||
active={micActive}
|
active={micActive}
|
||||||
onToggle={handleMicToggle}
|
onToggle={handleMicToggle}
|
||||||
volume={volume}
|
volume={volume}
|
||||||
onVolumeChange={setVolume}
|
onVolumeChange={handleVolumeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,15 +29,19 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
|||||||
const stopMut = useMediaStop();
|
const stopMut = useMediaStop();
|
||||||
const volumeMut = useMediaVolume();
|
const volumeMut = useMediaVolume();
|
||||||
const [queueUrl, setQueueUrl] = useState("");
|
const [queueUrl, setQueueUrl] = useState("");
|
||||||
|
const [screenMode, setScreenMode] = useState(false);
|
||||||
|
|
||||||
// Sync WS media_state into the query cache
|
// Sync WS media_state into the query cache
|
||||||
useMediaWsSync(ws);
|
useMediaWsSync(ws);
|
||||||
|
|
||||||
const handleQueue = useCallback(() => {
|
const handleQueue = useCallback(() => {
|
||||||
if (!queueUrl.trim()) return;
|
if (!queueUrl.trim()) return;
|
||||||
queueMut.mutate(queueUrl.trim());
|
queueMut.mutate({
|
||||||
|
url: queueUrl.trim(),
|
||||||
|
mode: screenMode ? "screen" : "music",
|
||||||
|
});
|
||||||
setQueueUrl("");
|
setQueueUrl("");
|
||||||
}, [queueUrl, queueMut]);
|
}, [queueUrl, queueMut, screenMode]);
|
||||||
|
|
||||||
const handleVolume = useCallback(
|
const handleVolume = useCallback(
|
||||||
(value: number | readonly number[]) => {
|
(value: number | readonly number[]) => {
|
||||||
@@ -64,6 +68,15 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
|||||||
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
|
onKeyDown={(e) => e.key === "Enter" && handleQueue()}
|
||||||
className="flex-1 h-9"
|
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
|
<Button
|
||||||
onClick={handleQueue}
|
onClick={handleQueue}
|
||||||
disabled={!queueUrl.trim() || queueMut.isPending}
|
disabled={!queueUrl.trim() || queueMut.isPending}
|
||||||
@@ -73,6 +86,14 @@ export function MusicPlayer({ ws }: MusicPlayerProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{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">
|
<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">
|
<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() {
|
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() {
|
export function useMediaSkip() {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { useAction } from "@/hooks/use-action";
|
import { useAction } from "@/hooks/use-action";
|
||||||
import { voiceApi } from "@/lib/api";
|
import { voiceApi } from "@/lib/api";
|
||||||
|
import { MicTransmitter } from "@/lib/audio/mic-transmit";
|
||||||
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types";
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
@@ -65,10 +66,27 @@ export function useVoiceDisconnect() {
|
|||||||
return useAction(() => voiceApi.disconnect(), { onSuccess: invalidate });
|
return useAction(() => voiceApi.disconnect(), { onSuccess: invalidate });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMicTransmit() {
|
export function useMicTransmit(ws: {
|
||||||
return useAction((active: boolean) =>
|
sendBinary: (data: ArrayBufferLike) => void;
|
||||||
voiceApi.sendCommand(
|
}) {
|
||||||
active ? "voice:transmit:start" : "voice:transmit:stop",
|
const transmitterRef = useRef<MicTransmitter | null>(null);
|
||||||
),
|
|
||||||
);
|
const action = useAction(async (active: boolean) => {
|
||||||
|
if (active) {
|
||||||
|
const transmitter = new MicTransmitter((frame) => ws.sendBinary(frame));
|
||||||
|
transmitterRef.current = transmitter;
|
||||||
|
await transmitter.start();
|
||||||
|
await voiceApi.sendCommand("voice:transmit:start");
|
||||||
|
} else {
|
||||||
|
transmitterRef.current?.stop();
|
||||||
|
transmitterRef.current = null;
|
||||||
|
await voiceApi.sendCommand("voice:transmit:stop");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setVolume = useCallback((volume: number) => {
|
||||||
|
transmitterRef.current?.setVolume(volume / 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { ...action, setVolume };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Browser mic → Discord voice transmit.
|
||||||
|
*
|
||||||
|
* Pipeline: getUserMedia → AudioContext (48kHz) → AudioWorklet (downsample to
|
||||||
|
* 24kHz mono s16le, apply volume, chunk 20ms) → binary WS frames.
|
||||||
|
*
|
||||||
|
* The backend expects each binary frame to start with a 4-byte magic "PCM\0"
|
||||||
|
* followed by raw Int16LE PCM; it base64s the payload and publishes to Redis,
|
||||||
|
* where the gateway's VoiceTransmitter feeds it into FFmpeg (24kHz mono s16le
|
||||||
|
* → OggOpus) and plays it in the voice channel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PCM_MAGIC = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0"
|
||||||
|
const TARGET_RATE = 24000;
|
||||||
|
const CHUNK_MS = 20;
|
||||||
|
|
||||||
|
// Inline AudioWorklet processor (Blob URL — works with Next static export,
|
||||||
|
// no asset pipeline needed).
|
||||||
|
const WORKLET_SRC = `
|
||||||
|
class PcmDownsampler extends AudioWorkletProcessor {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
const opts = options.processorOptions || {};
|
||||||
|
this.targetRate = opts.targetRate || 24000;
|
||||||
|
this.ratio = sampleRate / this.targetRate;
|
||||||
|
this.chunkSamples = Math.floor((this.targetRate * (opts.chunkMs || 20)) / 1000);
|
||||||
|
this.phase = 0;
|
||||||
|
this.buffer = new Int16Array(this.chunkSamples);
|
||||||
|
this.bufferLen = 0;
|
||||||
|
this.volume = typeof opts.volume === 'number' ? opts.volume : 1;
|
||||||
|
this.port.onmessage = (e) => {
|
||||||
|
if (e.data && e.data.type === 'volume') this.volume = e.data.value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process(inputs) {
|
||||||
|
const input = inputs[0];
|
||||||
|
if (!input || input.length === 0) return true;
|
||||||
|
// Mixdown: average available channels
|
||||||
|
const chans = input.filter((c) => c && c.length > 0);
|
||||||
|
if (chans.length === 0) return true;
|
||||||
|
const len = chans[0].length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
let s = 0;
|
||||||
|
for (let c = 0; c < chans.length; c++) s += chans[c][i];
|
||||||
|
s /= chans.length;
|
||||||
|
this.phase += 1;
|
||||||
|
if (this.phase >= this.ratio) {
|
||||||
|
this.phase -= this.ratio;
|
||||||
|
const v = Math.max(-1, Math.min(1, s * this.volume));
|
||||||
|
this.buffer[this.bufferLen++] = (v * 32767) | 0;
|
||||||
|
if (this.bufferLen >= this.chunkSamples) {
|
||||||
|
const out = new Int16Array(this.buffer);
|
||||||
|
this.port.postMessage(out.buffer, [out.buffer]);
|
||||||
|
this.buffer = new Int16Array(this.chunkSamples);
|
||||||
|
this.bufferLen = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerProcessor('pcm-downsampler', PcmDownsampler);
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class MicTransmitter {
|
||||||
|
private ctx: AudioContext | null = null;
|
||||||
|
private stream: MediaStream | null = null;
|
||||||
|
private node: AudioWorkletNode | null = null;
|
||||||
|
private active = false;
|
||||||
|
private volume = 1;
|
||||||
|
|
||||||
|
constructor(private readonly onChunk: (frame: ArrayBuffer) => void) {}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(volume = 1): Promise<void> {
|
||||||
|
if (this.active) return;
|
||||||
|
this.volume = volume;
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
throw new Error("getUserMedia is not available (insecure context?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
autoGainControl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ctx = new AudioContext({ sampleRate: 48000 });
|
||||||
|
|
||||||
|
const blob = new Blob([WORKLET_SRC], { type: "application/javascript" });
|
||||||
|
const workletUrl = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
await this.ctx.audioWorklet.addModule(workletUrl);
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(workletUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = this.ctx.createMediaStreamSource(this.stream);
|
||||||
|
this.node = new AudioWorkletNode(this.ctx, "pcm-downsampler", {
|
||||||
|
processorOptions: {
|
||||||
|
targetRate: TARGET_RATE,
|
||||||
|
chunkMs: CHUNK_MS,
|
||||||
|
volume: this.volume,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.node.port.onmessage = (e: MessageEvent<ArrayBuffer>) => {
|
||||||
|
if (!this.active || !(e.data instanceof ArrayBuffer)) return;
|
||||||
|
const frame = new Uint8Array(PCM_MAGIC.length + e.data.byteLength);
|
||||||
|
frame.set(PCM_MAGIC, 0);
|
||||||
|
frame.set(new Uint8Array(e.data), PCM_MAGIC.length);
|
||||||
|
this.onChunk(frame.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(this.node);
|
||||||
|
// Keep the graph alive with an inaudible tail (silent gain) so the
|
||||||
|
// worklet keeps pulling mic data without audible feedback.
|
||||||
|
const silent = this.ctx.createGain();
|
||||||
|
silent.gain.value = 0;
|
||||||
|
this.node.connect(silent);
|
||||||
|
silent.connect(this.ctx.destination);
|
||||||
|
|
||||||
|
this.active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.volume = volume;
|
||||||
|
this.node?.port.postMessage({ type: "volume", value: volume });
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.active = false;
|
||||||
|
this.node?.port.postMessage({ type: "volume", value: 0 });
|
||||||
|
this.node?.disconnect();
|
||||||
|
this.node = null;
|
||||||
|
this.stream?.getTracks().forEach((t) => t.stop());
|
||||||
|
this.stream = null;
|
||||||
|
this.ctx?.close().catch(() => {});
|
||||||
|
this.ctx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export interface MediaItem {
|
|||||||
|
|
||||||
export interface MediaState {
|
export interface MediaState {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
activeMode?: MediaMode | null;
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
current: MediaItem | null;
|
current: MediaItem | null;
|
||||||
queue: MediaItem[];
|
queue: MediaItem[];
|
||||||
|
|||||||
Reference in New Issue
Block a user