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
@@ -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) =>
|
||||
|
||||
+23
-1
@@ -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(
|
||||
|
||||
@@ -269,6 +269,10 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
||||
(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> {
|
||||
(await this.require(serverId, userId)).setLoop(mode);
|
||||
}
|
||||
@@ -314,6 +318,7 @@ export class MusicManager extends EventEmitter<ManagerEvents> {
|
||||
volume: config.DEFAULT_VOLUME,
|
||||
loop: "off",
|
||||
shuffleUsed: false,
|
||||
videoEnabled: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
+12
-3
@@ -66,7 +66,9 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
|
||||
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<GuildPlayerEvents> {
|
||||
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<GuildPlayerEvents> {
|
||||
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<GuildPlayerEvents> {
|
||||
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();
|
||||
|
||||
+16
-4
@@ -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<PlaybackInput> {
|
||||
export async function openPlayback(
|
||||
track: Track,
|
||||
seekSeconds = 0,
|
||||
options: PlaybackOptions = {},
|
||||
): Promise<PlaybackInput> {
|
||||
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<Playb
|
||||
|
||||
// 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.
|
||||
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");
|
||||
const pipeline = openVideoPipeline(track.url, seekSeconds);
|
||||
return {
|
||||
|
||||
+2
-1
@@ -42,7 +42,8 @@ export interface PlayerSnapshot {
|
||||
history: Track[];
|
||||
volume: number;
|
||||
loop: LoopMode;
|
||||
shuffleUsed: boolean;
|
||||
shuffleUsed: boolean;
|
||||
videoEnabled: boolean;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user