Make video a per-server toggle instead of an env-wide setting
VIDEO_ENABLED is now permission rather than behaviour: it decides whether the feature exists at all, while turning it on for a server is a toggle in the panel or !video in chat, off by default. Video costs real CPU for every playing channel, so that should be a deliberate choice rather than something a config flag switches on everywhere. With the env flag off the panel renders no toggle at all and !video says so, and the switch applies from the next track — swapping tracks mid-play would cut the current one. The loop button no longer reads "выкл" either: it sat next to the video button showing the same word, so the two states were indistinguishable. Both now name what they do and rely on highlighting for state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e06c42f60c
commit
a9b680c418
@@ -79,6 +79,8 @@ YTDLP_JS_RUNTIME=node
|
|||||||
# Требует прав Video у бота и включённого видео в конфигурации инстанса.
|
# Требует прав Video у бота и включённого видео в конфигурации инстанса.
|
||||||
# YouTube отдаёт одним файлом только 360p, а стримить можно лишь такой формат —
|
# YouTube отдаёт одним файлом только 360p, а стримить можно лишь такой формат —
|
||||||
# отсюда размер по умолчанию. Кодирование видео заметно грузит CPU сервера.
|
# отсюда размер по умолчанию. Кодирование видео заметно грузит CPU сервера.
|
||||||
|
# Это лишь разрешение: сам показ включается тумблером в панели или командой
|
||||||
|
# !video, и по умолчанию выключен. При false тумблер в панели не показывается.
|
||||||
VIDEO_ENABLED=false
|
VIDEO_ENABLED=false
|
||||||
VIDEO_WIDTH=640
|
VIDEO_WIDTH=640
|
||||||
VIDEO_HEIGHT=360
|
VIDEO_HEIGHT=360
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ cd /opt/stoat-mbot && docker compose up -d --build && docker compose logs -f
|
|||||||
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
| `!queue [страница]`, `!nowplaying` | очередь и текущий трек |
|
||||||
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
| `!volume [0-200]`, `!loop [off\|track\|queue]`, `!shuffle` | звук и порядок |
|
||||||
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
| `!remove <n>`, `!clear`, `!seek 1:23` | правка очереди и перемотка |
|
||||||
|
| `!video [on\|off]` | показывать клип как демонстрацию экрана |
|
||||||
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
| `!join`, `!leave` | зайти в ваш голосовой канал / выйти |
|
||||||
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
| `!panel` | личная ссылка на веб-панель (действует 10 минут) |
|
||||||
| `!help` | список команд |
|
| `!help` | список команд |
|
||||||
@@ -227,8 +228,12 @@ docker compose build --build-arg YTDLP_VERSION=$(date +%Y.%m.%d) && docker compo
|
|||||||
|
|
||||||
## Видео: клип как демонстрация экрана
|
## Видео: клип как демонстрация экрана
|
||||||
|
|
||||||
`VIDEO_ENABLED=true` — и вместе со звуком бот публикует картинку клипа отдельной дорожкой
|
`VIDEO_ENABLED=true` разрешает боту публиковать картинку клипа отдельной дорожкой (screen
|
||||||
(screen share), которую видно в голосовом канале.
|
share), которую видно в голосовом канале. Само включение — тумблер **📺 видео** в панели или
|
||||||
|
команда `!video on|off`, и по умолчанию оно выключено: видео стоит дороже звука, поэтому платить
|
||||||
|
за него нужно осознанно. Переключение применяется со следующего трека — менять дорожки на
|
||||||
|
лету значило бы прервать текущий. При `VIDEO_ENABLED=false` тумблер в панели не показывается
|
||||||
|
вовсе, а `!video` отвечает, что видео выключено в настройках.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
VIDEO_ENABLED=true
|
VIDEO_ENABLED=true
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ export async function startApiServer({ manager, context }: ApiServerOptions) {
|
|||||||
features: {
|
features: {
|
||||||
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
|
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
|
||||||
requireListener: config.REQUIRE_LISTENER,
|
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),
|
leave: (serverId, userId) => manager.leave(serverId, userId),
|
||||||
volume: (serverId, userId, body) =>
|
volume: (serverId, userId, body) =>
|
||||||
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
|
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) =>
|
loop: (serverId, userId, body) =>
|
||||||
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
||||||
seek: (serverId, userId, body) =>
|
seek: (serverId, userId, body) =>
|
||||||
|
|||||||
+23
-1
@@ -260,6 +260,26 @@ export const commands: Command[] = [
|
|||||||
await ctx.reply(`🔁 Повтор: ${loopLabel(nextMode)}`);
|
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",
|
name: "shuffle",
|
||||||
aliases: ["sh", "перемешай"],
|
aliases: ["sh", "перемешай"],
|
||||||
@@ -348,7 +368,9 @@ export const commands: Command[] = [
|
|||||||
usage: "help",
|
usage: "help",
|
||||||
description: "Показать список команд",
|
description: "Показать список команд",
|
||||||
async run(ctx) {
|
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}`,
|
(command) => `\`${config.COMMAND_PREFIX}${command.usage}\` — ${command.description}`,
|
||||||
);
|
);
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
|||||||
(await this.require(serverId, userId)).setVolume(volume);
|
(await this.require(serverId, userId)).setVolume(volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setVideo(serverId: string, userId: string, enabled: boolean): Promise<void> {
|
||||||
|
(await this.require(serverId, userId)).setVideo(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
async setLoop(serverId: string, userId: string, mode: LoopMode): Promise<void> {
|
||||||
(await this.require(serverId, userId)).setLoop(mode);
|
(await this.require(serverId, userId)).setLoop(mode);
|
||||||
}
|
}
|
||||||
@@ -314,6 +318,7 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
|||||||
volume: config.DEFAULT_VOLUME,
|
volume: config.DEFAULT_VOLUME,
|
||||||
loop: "off",
|
loop: "off",
|
||||||
shuffleUsed: false,
|
shuffleUsed: false,
|
||||||
|
videoEnabled: false,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -67,6 +67,8 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
volume = config.DEFAULT_VOLUME;
|
volume = config.DEFAULT_VOLUME;
|
||||||
loop: LoopMode = "off";
|
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 status: PlayerStatus = "idle";
|
||||||
private readonly revoice: RevoiceLike;
|
private readonly revoice: RevoiceLike;
|
||||||
@@ -114,6 +116,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
volume: this.volume,
|
volume: this.volume,
|
||||||
loop: this.loop,
|
loop: this.loop,
|
||||||
shuffleUsed: this.shuffleUsed,
|
shuffleUsed: this.shuffleUsed,
|
||||||
|
videoEnabled: this.videoEnabled,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -307,7 +310,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.publish();
|
this.publish();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const input = await openPlayback(track, seekSeconds);
|
const input = await openPlayback(track, seekSeconds, { video: this.videoEnabled });
|
||||||
this.currentInput = input;
|
this.currentInput = input;
|
||||||
this.expectingStop = false;
|
this.expectingStop = false;
|
||||||
await media.playStream(input.input, input.inputOptions);
|
await media.playStream(input.input, input.inputOptions);
|
||||||
@@ -464,6 +467,12 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
|||||||
this.publish();
|
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 {
|
setLoop(mode: LoopMode): void {
|
||||||
this.loop = mode;
|
this.loop = mode;
|
||||||
this.publish();
|
this.publish();
|
||||||
|
|||||||
+16
-4
@@ -173,9 +173,14 @@ export interface PlaybackInput {
|
|||||||
video?: { stream: Readable; width: number; height: number };
|
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. */
|
/** Only YouTube reliably carries a picture worth showing next to the audio. */
|
||||||
function canShowVideo(track: Track): boolean {
|
function canShowVideo(track: Track, wanted: boolean): boolean {
|
||||||
return config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive;
|
return wanted && config.VIDEO_ENABLED && track.source === "youtube" && !track.isLive;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HTTP_RESILIENCE = [
|
const HTTP_RESILIENCE = [
|
||||||
@@ -196,7 +201,11 @@ function ffmpegProxyOptions(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
|
/** Opens an ffmpeg-compatible input for a track, optionally starting at an offset. */
|
||||||
export async function openPlayback(track: Track, seekSeconds = 0): Promise<PlaybackInput> {
|
export async function openPlayback(
|
||||||
|
track: Track,
|
||||||
|
seekSeconds = 0,
|
||||||
|
options: PlaybackOptions = {},
|
||||||
|
): Promise<PlaybackInput> {
|
||||||
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
|
const seekOptions = seekSeconds > 0 ? ["-ss", seekSeconds.toFixed(2)] : [];
|
||||||
|
|
||||||
if (track.source === "local") {
|
if (track.source === "local") {
|
||||||
@@ -214,7 +223,10 @@ export async function openPlayback(track: Track, seekSeconds = 0): Promise<Playb
|
|||||||
|
|
||||||
// The format is checked before committing to the video pipeline: audio comes
|
// The format is checked before committing to the video pipeline: audio comes
|
||||||
// out of that same pipeline, so falling back afterwards would kill the sound.
|
// out of that same pipeline, so falling back afterwards would kill the sound.
|
||||||
if (canShowVideo(track) && (await ytdlp.hasProgressiveVideo(track.url, config.VIDEO_HEIGHT))) {
|
if (
|
||||||
|
canShowVideo(track, options.video ?? false) &&
|
||||||
|
(await ytdlp.hasProgressiveVideo(track.url, config.VIDEO_HEIGHT))
|
||||||
|
) {
|
||||||
log.info({ title: track.title }, "playing with video");
|
log.info({ title: track.title }, "playing with video");
|
||||||
const pipeline = openVideoPipeline(track.url, seekSeconds);
|
const pipeline = openVideoPipeline(track.url, seekSeconds);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface PlayerSnapshot {
|
|||||||
volume: number;
|
volume: number;
|
||||||
loop: LoopMode;
|
loop: LoopMode;
|
||||||
shuffleUsed: boolean;
|
shuffleUsed: boolean;
|
||||||
|
videoEnabled: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export function App() {
|
|||||||
voiceChannels={voiceChannels}
|
voiceChannels={voiceChannels}
|
||||||
yourVoiceChannel={yourVoiceChannel}
|
yourVoiceChannel={yourVoiceChannel}
|
||||||
requireListener={me.features.requireListener}
|
requireListener={me.features.requireListener}
|
||||||
|
videoAvailable={me.features.video}
|
||||||
onAction={(action, payload) => void runAction(action, payload)}
|
onAction={(action, payload) => void runAction(action, payload)}
|
||||||
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
onSeek={(seconds) => void runAction("seek", { position: seconds })}
|
||||||
onJoin={(channelId) => {
|
onJoin={(channelId) => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface Props {
|
|||||||
voiceChannels: VoiceChannel[];
|
voiceChannels: VoiceChannel[];
|
||||||
yourVoiceChannel: VoiceChannel | null;
|
yourVoiceChannel: VoiceChannel | null;
|
||||||
requireListener: boolean;
|
requireListener: boolean;
|
||||||
|
/** Hidden entirely when the bot was built without video support. */
|
||||||
|
videoAvailable: boolean;
|
||||||
onAction(action: string, payload?: Record<string, unknown>): void;
|
onAction(action: string, payload?: Record<string, unknown>): void;
|
||||||
onSeek(seconds: number): void;
|
onSeek(seconds: number): void;
|
||||||
onJoin(channelId: string | null): void;
|
onJoin(channelId: string | null): void;
|
||||||
@@ -23,7 +25,7 @@ const STATUS_LABEL: Record<PlayerState["status"], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOOP_LABEL: Record<LoopMode, string> = {
|
const LOOP_LABEL: Record<LoopMode, string> = {
|
||||||
off: "🔁 выкл",
|
off: "🔁 повтор",
|
||||||
track: "🔂 трек",
|
track: "🔂 трек",
|
||||||
queue: "🔁 очередь",
|
queue: "🔁 очередь",
|
||||||
};
|
};
|
||||||
@@ -35,6 +37,7 @@ export function NowPlaying({
|
|||||||
voiceChannels,
|
voiceChannels,
|
||||||
yourVoiceChannel,
|
yourVoiceChannel,
|
||||||
requireListener,
|
requireListener,
|
||||||
|
videoAvailable,
|
||||||
onAction,
|
onAction,
|
||||||
onSeek,
|
onSeek,
|
||||||
onJoin,
|
onJoin,
|
||||||
@@ -141,6 +144,21 @@ export function NowPlaying({
|
|||||||
{LOOP_LABEL[state.loop]}
|
{LOOP_LABEL[state.loop]}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{videoAvailable && (
|
||||||
|
<button
|
||||||
|
className={state.videoEnabled ? "active" : ""}
|
||||||
|
onClick={() => onAction("video", { enabled: !state.videoEnabled })}
|
||||||
|
disabled={!canControl}
|
||||||
|
title={
|
||||||
|
state.videoEnabled
|
||||||
|
? "Клип показывается как демонстрация экрана — нажмите, чтобы выключить"
|
||||||
|
: "Показывать клип как демонстрацию экрана (со следующего трека)"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
📺 видео
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="volume">
|
<div className="volume">
|
||||||
<span title="Громкость">🔊</span>
|
<span title="Громкость">🔊</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ export interface PlayerState {
|
|||||||
history: Track[];
|
history: Track[];
|
||||||
volume: number;
|
volume: number;
|
||||||
loop: LoopMode;
|
loop: LoopMode;
|
||||||
|
videoEnabled: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export interface ServerRef {
|
|||||||
export interface Me {
|
export interface Me {
|
||||||
user: { id: string; username: string };
|
user: { id: string; username: string };
|
||||||
servers: ServerRef[];
|
servers: ServerRef[];
|
||||||
features: { localLibrary: boolean; requireListener: boolean };
|
features: { localLibrary: boolean; requireListener: boolean; video: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStateResponse {
|
export interface ServerStateResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user