diff --git a/.env.example b/.env.example index 88587c8..8cea5bb 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,8 @@ YTDLP_JS_RUNTIME=node # Требует прав Video у бота и включённого видео в конфигурации инстанса. # YouTube отдаёт одним файлом только 360p, а стримить можно лишь такой формат — # отсюда размер по умолчанию. Кодирование видео заметно грузит CPU сервера. +# Это лишь разрешение: сам показ включается тумблером в панели или командой +# !video, и по умолчанию выключен. При false тумблер в панели не показывается. VIDEO_ENABLED=false VIDEO_WIDTH=640 VIDEO_HEIGHT=360 diff --git a/README.md b/README.md index 227d615..97b1c33 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f | `!queue [страница]`, `!nowplaying` | очередь и текущий трек | | `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок | | `!remove `, `!clear`, `!seek 1:23` | правка очереди и перемотка | +| `!video [on\|off]` | показывать клип как демонстрацию экрана | | `!join`, `!leave` | зайти в ваш голосовой канал / выйти | | `!panel` | личная ссылка на веб-панель (действует 10 минут) | | `!help` | список команд | @@ -227,8 +228,12 @@ docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compo ## Видео: клип как демонстрация экрана -`VIDEO_ENABLED=true` — и вместе со звуком бот публикует картинку клипа отдельной дорожкой -(screen share), которую видно в голосовом канале. +`VIDEO_ENABLED=true` разрешает боту публиковать картинку клипа отдельной дорожкой (screen +share), которую видно в голосовом канале. Само включение — тумблер **📺 видео** в панели или +команда `!video on|off`, и по умолчанию оно выключено: видео стоит дороже звука, поэтому платить +за него нужно осознанно. Переключение применяется со следующего трека — менять дорожки на +лету значило бы прервать текущий. При `VIDEO_ENABLED=false` тумблер в панели не показывается +вовсе, а `!video` отвечает, что видео выключено в настройках. ```dotenv VIDEO_ENABLED=true diff --git a/src/api/server.ts b/src/api/server.ts index f4d5c84..c120a2a 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -186,6 +186,7 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { features: { localLibrary: Boolean(config.LOCAL_MEDIA_DIR), requireListener: config.REQUIRE_LISTENER, + video: config.VIDEO_ENABLED, }, }); }); @@ -265,6 +266,8 @@ export async function startApiServer({ manager, context }: ApiServerOptions) { leave: (serverId, userId) => manager.leave(serverId, userId), volume: (serverId, userId, body) => manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume), + video: (serverId, userId, body) => + manager.setVideo(serverId, userId, z.object({ enabled: z.boolean() }).parse(body).enabled), loop: (serverId, userId, body) => manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode), seek: (serverId, userId, body) => diff --git a/src/bot/commands.ts b/src/bot/commands.ts index 8a75be2..ff6213f 100644 --- a/src/bot/commands.ts +++ b/src/bot/commands.ts @@ -260,6 +260,26 @@ export const commands: Command[] = [ await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`); }, }, + { + name: "video", + aliases: ["видео"], + usage: "video [on|off]", + description: "Показывать клип как демонстрацию экрана", + async run(ctx) { + if (!config.VIDEO_ENABLED) { + throw new UserFacingError("Видео выключено в настройках бота (VIDEO_ENABLED)"); + } + const raw = (ctx.args[0] ?? "").toLowerCase(); + const current = ctx.manager.snapshot(ctx.serverId).videoEnabled; + const next = raw ? ["on", "вкл", "true", "1", "да"].includes(raw) : !current; + await ctx.manager.setVideo(ctx.serverId, ctx.actor.id, next); + await ctx.reply( + next + ? "📺 Клип будет показан как демонстрация экрана — со следующего трека." + : "🔇 Видео выключено, играю только звук.", + ); + }, + }, { name: "shuffle", aliases: ["sh", "перемешай"], @@ -348,7 +368,9 @@ export const commands: Command[] = [ usage: "help", description: "Показать список команд", async run(ctx) { - const lines = commands.map( + const lines = commands + .filter((command) => command.name !== "video" || config.VIDEO_ENABLED) + .map( (command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`, ); await ctx.reply( diff --git a/src/core/manager.ts b/src/core/manager.ts index 2d0d02f..c75dac3 100644 --- a/src/core/manager.ts +++ b/src/core/manager.ts @@ -269,6 +269,10 @@ export class MusicManager extends EventEmitter { (await this.require(serverId, userId)).setVolume(volume); } + async setVideo(serverId: string, userId: string, enabled: boolean): Promise { + (await this.require(serverId, userId)).setVideo(enabled); + } + async setLoop(serverId: string, userId: string, mode: LoopMode): Promise { (await this.require(serverId, userId)).setLoop(mode); } @@ -314,6 +318,7 @@ export class MusicManager extends EventEmitter { volume: config.DEFAULT_VOLUME, loop: "off", shuffleUsed: false, + videoEnabled: false, updatedAt: Date.now(), }; } diff --git a/src/core/player.ts b/src/core/player.ts index fd56086..781eb65 100644 --- a/src/core/player.ts +++ b/src/core/player.ts @@ -66,7 +66,9 @@ export class GuildPlayer extends EventEmitter { current: Track | null = null; volume = config.DEFAULT_VOLUME; loop: LoopMode = "off"; - shuffleUsed = false; + shuffleUsed = false; + /** Per-server switch for publishing the clip; the env flag gates it too. */ + videoEnabled = false; private status: PlayerStatus = "idle"; private readonly revoice: RevoiceLike; @@ -113,7 +115,8 @@ export class GuildPlayer extends EventEmitter { history: this.history.slice(0, 10), volume: this.volume, loop: this.loop, - shuffleUsed: this.shuffleUsed, + shuffleUsed: this.shuffleUsed, + videoEnabled: this.videoEnabled, updatedAt: Date.now(), }; } @@ -307,7 +310,7 @@ export class GuildPlayer extends EventEmitter { this.publish(); try { - const input = await openPlayback(track, seekSeconds); + const input = await openPlayback(track, seekSeconds, { video: this.videoEnabled }); this.currentInput = input; this.expectingStop = false; await media.playStream(input.input, input.inputOptions); @@ -464,6 +467,12 @@ export class GuildPlayer extends EventEmitter { this.publish(); } + /** Takes effect on the next track: switching mid-stream would cut playback. */ + setVideo(enabled: boolean): void { + this.videoEnabled = enabled; + this.publish(); + } + setLoop(mode: LoopMode): void { this.loop = mode; this.publish(); diff --git a/src/sources/index.ts b/src/sources/index.ts index 04f20c1..7855745 100644 --- a/src/sources/index.ts +++ b/src/sources/index.ts @@ -173,9 +173,14 @@ export interface PlaybackInput { video?: { stream: Readable; width: number; height: number }; } +export interface PlaybackOptions { + /** Publish the picture too; the server-wide switch still has to allow it. */ + video?: boolean; +} + /** Only YouTube reliably carries a picture worth showing next to the audio. */ -function canShowVideo(track: Track): boolean { - return config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive; +function canShowVideo(track: Track, wanted: boolean): boolean { + return wanted && config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive; } const HTTP_RESILIENCE = [ @@ -196,7 +201,11 @@ function ffmpegProxyOptions(): string[] { } /** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */ -export async function openPlayback(track: Track, seekSeconds = 0): Promise { +export async function openPlayback( + track: Track, + seekSeconds = 0, + options: PlaybackOptions = {}, +): Promise { const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : []; if (track.source === "local") { @@ -214,7 +223,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise void runAction(action, payload)} onSeek={(seconds) => void runAction("seek", { position: seconds })} onJoin={(channelId) => { diff --git a/web/src/components/NowPlaying.tsx b/web/src/components/NowPlaying.tsx index f81290e..f54e1d1 100644 --- a/web/src/components/NowPlaying.tsx +++ b/web/src/components/NowPlaying.tsx @@ -9,6 +9,8 @@ interface Props { voiceChannels: VoiceChannel[]; yourVoiceChannel: VoiceChannel | null; requireListener: boolean; + /** Hidden entirely when the bot was built without video support. */ + videoAvailable: boolean; onAction(action: string, payload?: Record): void; onSeek(seconds: number): void; onJoin(channelId: string | null): void; @@ -23,7 +25,7 @@ const STATUS_LABEL: Record = { }; const LOOP_LABEL: Record = { - off: "🔁 выкл", + off: "🔁 повтор", track: "🔂 трек", queue: "🔁 очередь", }; @@ -35,6 +37,7 @@ export function NowPlaying({ voiceChannels, yourVoiceChannel, requireListener, + videoAvailable, onAction, onSeek, onJoin, @@ -141,6 +144,21 @@ export function NowPlaying({ {LOOP_LABEL[state.loop]} + {videoAvailable && ( + + )} +
🔊