Take the picture down when the queue runs out

The clip stayed frozen on screen after the music ended. Advancing the
queue cleaned up the decoder but never unpublished the video track, and a
published track keeps showing its last frame, so an empty queue left the
bot displaying a still. Every other teardown path (stop, skip, leaving
the channel) already dropped it; the natural end of the queue did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-09 05:27:02 +03:00
co-authored by Claude Opus 5
parent a1596cd654
commit 7cca374e66
+32 -29
View File
@@ -66,8 +66,8 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
current: Track | null = null; current: Track | null = null;
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. */ /** Per-server switch for publishing the clip; the env flag gates it too. */
videoEnabled = false; videoEnabled = false;
private status: PlayerStatus = "idle"; private status: PlayerStatus = "idle";
@@ -115,7 +115,7 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
history: this.history.slice(0, 10), history: this.history.slice(0, 10),
volume: this.volume, volume: this.volume,
loop: this.loop, loop: this.loop,
shuffleUsed: this.shuffleUsed, shuffleUsed: this.shuffleUsed,
videoEnabled: this.videoEnabled, videoEnabled: this.videoEnabled,
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
@@ -262,16 +262,16 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
* participant name, so nobody is ever dropped and the channel never looks * participant name, so nobody is ever dropped and the channel never looks
* empty — the bot would sit in it forever. * empty — the bot would sit in it forever.
*/ */
/** Whether this user is in the channel the bot is sitting in, per LiveKit. */ /** Whether this user is in the channel the bot is sitting in, per LiveKit. */
hasParticipant(userId: string): boolean { hasParticipant(userId: string): boolean {
const participants = this.connection?.room?.remoteParticipants; const participants = this.connection?.room?.remoteParticipants;
if (!participants) return false; if (!participants) return false;
for (const participant of participants.values()) { for (const participant of participants.values()) {
if (participant.identity === userId) return true; if (participant.identity === userId) return true;
} }
return false; return false;
} }
private listenersInChannel(): number { private listenersInChannel(): number {
return this.connection?.room?.remoteParticipants?.size ?? 0; return this.connection?.room?.remoteParticipants?.size ?? 0;
} }
@@ -379,14 +379,14 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private async startScreenShare(video: NonNullable<PlaybackInput["video"]>): Promise<void> { private async startScreenShare(video: NonNullable<PlaybackInput["video"]>): Promise<void> {
const room = this.connection?.room; const room = this.connection?.room;
if (!room) return; if (!room) return;
const publisher = new VideoPublisher({ const publisher = new VideoPublisher({
width: video.width, width: video.width,
height: video.height, height: video.height,
fps: video.fps, fps: video.fps,
// Frames are released against the sound people have actually heard, not // Frames are released against the sound people have actually heard, not
// the sound handed to LiveKit: its audio source buffers up to a second, // the sound handed to LiveKit: its audio source buffers up to a second,
// and gating on the queued position showed every frame that much early. // and gating on the queued position showed every frame that much early.
audioClock: () => this.heardSeconds(), audioClock: () => this.heardSeconds(),
}); });
try { try {
await publisher.start(room, video.stream, (reason) => { await publisher.start(room, video.stream, (reason) => {
@@ -402,14 +402,14 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
} }
} }
/** Playback position as heard, i.e. minus whatever is still queued in LiveKit. */ /** Playback position as heard, i.e. minus whatever is still queued in LiveKit. */
private heardSeconds(): number { private heardSeconds(): number {
const media = this.media; const media = this.media;
if (!media) return 0; if (!media) return 0;
const queuedMs = media.source?.queuedDuration ?? 0; const queuedMs = media.source?.queuedDuration ?? 0;
return Math.max(0, media.seconds - queuedMs / 1000); return Math.max(0, media.seconds - queuedMs / 1000);
} }
private stopScreenShare(): void { private stopScreenShare(): void {
const publisher = this.videoPublisher; const publisher = this.videoPublisher;
this.videoPublisher = null; this.videoPublisher = null;
@@ -428,6 +428,9 @@ export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
private async advance(skipLoop: boolean): Promise<void> { private async advance(skipLoop: boolean): Promise<void> {
const finished = this.current; const finished = this.current;
this.current = null; this.current = null;
// The track has to go with the sound: a published video track keeps showing
// its last frame, so the clip would hang on screen after the music stopped.
this.stopScreenShare();
this.currentInput?.cleanup(); this.currentInput?.cleanup();
this.currentInput = null; this.currentInput = null;
this.seekOffset = 0; this.seekOffset = 0;