Fix multiple GitHub issues (#5, #6, #7, #8, #10, #13, #16, #18)

- Fix #16: ScrollArea scroll bug — global CSS override for Radix display:table
- Fix #8: Music bot voice port no longer hardcoded to 9987, configurable per bot
- Fix #7: Long song titles no longer hide action buttons (CSS grid minmax fix)
- Fix #10: Self-signed certificate support via TS_ALLOW_SELF_SIGNED env var
- Fix #5: YouTube cookie file support via YT_COOKIE_FILE env var for yt-dlp
- Fix #18: Widget improvements — spacer channel rendering (SVG + HTML),
  hide empty channels option, click-to-join ts3server:// links
- Fix #13: SSH reconnect hardened — TCP keepalive via ssh2, application-level
  keepalive with failure detection, forceDisconnect, double-reconnect guard
- Fix #6: Add !queue/!add chat commands to enqueue songs without interrupting
  current playback; !play now queues when already playing
This commit is contained in:
Clusterzx
2026-03-07 19:31:11 +01:00
parent 33623336d5
commit 07a8fc3d6d
22 changed files with 358 additions and 46 deletions
+1
View File
@@ -11,6 +11,7 @@ services:
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in Coolify environment variables}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- TS_ALLOW_SELF_SIGNED=${TS_ALLOW_SELF_SIGNED:-false}
- JWT_ACCESS_EXPIRY=15m
- JWT_REFRESH_EXPIRY=7d
- FRONTEND_URL=${FRONTEND_URL:-https://ts6.example.com}
+1
View File
@@ -13,6 +13,7 @@ services:
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-me-in-production}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- TS_ALLOW_SELF_SIGNED=${TS_ALLOW_SELF_SIGNED:-false}
- JWT_ACCESS_EXPIRY=15m
- JWT_REFRESH_EXPIRY=7d
- FRONTEND_URL=http://localhost:3000
+4
View File
@@ -11,6 +11,8 @@ services:
- DATABASE_URL=file:/app/packages/backend/data/ts6webui.db
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env or environment}
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-}
- TS_ALLOW_SELF_SIGNED=${TS_ALLOW_SELF_SIGNED:-false}
- YT_COOKIE_FILE=${YT_COOKIE_FILE:-}
- JWT_ACCESS_EXPIRY=15m
- JWT_REFRESH_EXPIRY=7d
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
@@ -18,6 +20,8 @@ services:
volumes:
- backend-data:/app/packages/backend/data
- music-data:/data/music
# Optional: mount YouTube cookies file for yt-dlp
# - ./cookies.txt:/app/cookies.txt
ports:
- "3001:3001"
networks:
+2
View File
@@ -144,6 +144,7 @@ model MusicBot {
serverPassword String?
defaultChannel String?
channelPassword String?
voicePort Int @default(9987)
volume Int @default(50)
identityData String?
autoStart Boolean @default(false)
@@ -213,6 +214,7 @@ model Widget {
theme String @default("dark")
showChannelTree Boolean @default(true)
showClients Boolean @default(true)
hideEmptyChannels Boolean @default(false)
maxChannelDepth Int @default(5)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -42,6 +42,7 @@ export class SshQueryClient extends EventEmitter {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
private fatalError: boolean = false;
private reconnecting: boolean = false;
constructor(private options: SshQueryClientOptions) {
super();
@@ -84,6 +85,7 @@ export class SshQueryClient extends EventEmitter {
if (!this.connected && this.bannerReceived) {
this.connected = true;
this.reconnectAttempt = 0;
this.reconnecting = false;
this.startKeepalive();
this.emit('ready');
if (!settled) { settled = true; clearTimeout(connectTimeout); resolve(); }
@@ -137,7 +139,8 @@ export class SshQueryClient extends EventEmitter {
port: this.options.port,
username: this.options.username,
password: this.options.password,
keepaliveInterval: 0, // We handle keepalive ourselves
keepaliveInterval: 30000, // TCP-level keepalive every 30s
keepaliveCountMax: 3, // Disconnect after 3 missed keepalives (~90s)
readyTimeout: 10000,
});
});
@@ -230,6 +233,25 @@ export class SshQueryClient extends EventEmitter {
this.connected = false;
}
private forceDisconnect(): void {
this.connected = false;
this.bannerReceived = false;
this.stopKeepalive();
this.rejectAllPending('Keepalive timeout');
if (this.shell) {
try { this.shell.close(); } catch {}
this.shell = null;
}
if (this.ssh) {
try { this.ssh.end(); } catch {}
this.ssh = null;
}
if (!this.destroyed) {
this.emit('close');
this.scheduleReconnect();
}
}
// --- Internals ---
private onShellData(data: Buffer): void {
@@ -335,13 +357,21 @@ export class SshQueryClient extends EventEmitter {
private startKeepalive(): void {
this.stopKeepalive();
let consecutiveFailures = 0;
this.keepaliveTimer = setInterval(() => {
if (this.connected) {
this.executeCommand('whoami', 5000).catch(() => {
// Keepalive failure will trigger disconnect detection
});
this.executeCommand('whoami', 5000)
.then(() => { consecutiveFailures = 0; })
.catch((err) => {
consecutiveFailures++;
console.warn(`[SshQueryClient] Keepalive failed for ${this.options.host}:${this.options.port} (${consecutiveFailures}/3): ${err.message}`);
if (consecutiveFailures >= 3) {
console.error(`[SshQueryClient] Keepalive failed 3 times, forcing disconnect for ${this.options.host}:${this.options.port}`);
this.forceDisconnect();
}
});
}
}, 60000);
}, 30000);
}
private stopKeepalive(): void {
@@ -352,13 +382,20 @@ export class SshQueryClient extends EventEmitter {
}
private scheduleReconnect(): void {
if (this.destroyed || this.fatalError) return;
if (this.destroyed || this.fatalError || this.reconnecting) return;
this.reconnecting = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000);
console.log(`[SshQueryClient] Reconnecting to ${this.options.host}:${this.options.port} in ${delay}ms (attempt ${this.reconnectAttempt + 1})`);
this.reconnectTimer = setTimeout(async () => {
this.reconnectAttempt++;
this.reconnecting = false;
try {
await this.connect();
} catch (err: any) {
+1
View File
@@ -13,4 +13,5 @@ export const config = {
jwtAccessExpiry: process.env.JWT_ACCESS_EXPIRY || '15m',
jwtRefreshExpiry: process.env.JWT_REFRESH_EXPIRY || '7d',
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
tsAllowSelfSigned: process.env.TS_ALLOW_SELF_SIGNED === 'true' || process.env.TS_ALLOW_SELF_SIGNED === '1',
};
+13
View File
@@ -7,7 +7,9 @@ import { BotEngine } from './bot-engine/engine.js';
import { VoiceBotManager } from './voice/voice-bot-manager.js';
import { MusicCommandHandler } from './voice/music-command-handler.js';
import { config } from './config.js';
import { setYtCookieFile } from './voice/audio/youtube.js';
import jwt from 'jsonwebtoken';
import fs from 'fs';
async function main() {
// C1: JWT secret startup guard
@@ -19,6 +21,17 @@ async function main() {
console.warn('[WARN] JWT_SECRET is using the default development value. Set JWT_SECRET in production!');
}
// Configure yt-dlp cookie file if provided
const cookiePath = process.env.YT_COOKIE_FILE;
if (cookiePath) {
if (fs.existsSync(cookiePath)) {
setYtCookieFile(cookiePath);
console.log(`[yt-dlp] Using cookie file: ${cookiePath}`);
} else {
console.warn(`[yt-dlp] Cookie file not found: ${cookiePath}`);
}
}
const prisma = new PrismaClient();
const app = createApp();
const server = createServer(app);
@@ -30,6 +30,7 @@ musicBotRoutes.get('/', async (req: Request, res: Response, next) => {
serverConfig: b.serverConfig,
nickname: b.nickname,
defaultChannel: b.defaultChannel,
voicePort: b.voicePort,
volume: b.volume,
autoStart: b.autoStart,
status: runtime?.status ?? 'stopped',
@@ -67,7 +68,7 @@ musicBotRoutes.get('/:id', async (req: Request, res: Response, next) => {
musicBotRoutes.post('/', async (req: Request, res: Response, next) => {
try {
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
const { name, serverConfigId, nickname, serverPassword, defaultChannel, channelPassword, volume, autoStart } = req.body;
const { name, serverConfigId, nickname, serverPassword, defaultChannel, channelPassword, voicePort, volume, autoStart } = req.body;
if (!name || !serverConfigId) throw new AppError(400, 'name and serverConfigId are required');
const result = await manager.createBot({
@@ -77,6 +78,7 @@ musicBotRoutes.post('/', async (req: Request, res: Response, next) => {
serverPassword,
defaultChannel,
channelPassword,
voicePort: voicePort != null ? parseInt(voicePort) : undefined,
volume: volume != null ? parseInt(volume) : undefined,
autoStart: autoStart ?? false,
});
@@ -91,7 +93,7 @@ musicBotRoutes.put('/:id', async (req: Request, res: Response, next) => {
const prisma = req.app.locals.prisma;
const manager: VoiceBotManager = req.app.locals.voiceBotManager;
const id = parseInt(req.params.id as string);
const { name, nickname, serverPassword, defaultChannel, channelPassword, volume, autoStart } = req.body;
const { name, nickname, serverPassword, defaultChannel, channelPassword, voicePort, volume, autoStart } = req.body;
const dbBot = await prisma.musicBot.update({
where: { id },
@@ -101,6 +103,7 @@ musicBotRoutes.put('/:id', async (req: Request, res: Response, next) => {
...(serverPassword !== undefined && { serverPassword }),
...(defaultChannel !== undefined && { defaultChannel }),
...(channelPassword !== undefined && { channelPassword }),
...(voicePort != null && { voicePort: parseInt(voicePort) }),
...(volume != null && { volume: parseInt(volume) }),
...(autoStart != null && { autoStart }),
},
@@ -115,6 +118,7 @@ musicBotRoutes.put('/:id', async (req: Request, res: Response, next) => {
...(serverPassword !== undefined && { serverPassword: serverPassword || undefined }),
...(defaultChannel !== undefined && { defaultChannel: defaultChannel || undefined }),
...(channelPassword !== undefined && { channelPassword: channelPassword || undefined }),
...(voicePort != null && { serverPort: parseInt(voicePort) }),
...(volume != null && { volume: parseInt(volume) }),
});
}
@@ -23,7 +23,10 @@ async function getWidgetData(token: string, req: Request): Promise<WidgetData |
if (cached && cached.expiresAt > Date.now()) return cached.data;
const prisma = req.app.locals.prisma;
const widget = await prisma.widget.findUnique({ where: { token } });
const widget = await prisma.widget.findUnique({
where: { token },
include: { serverConfig: { select: { host: true } } },
});
if (!widget) return null;
const pool: ConnectionPool = req.app.locals.connectionPool;
@@ -49,6 +52,8 @@ async function getWidgetData(token: string, req: Request): Promise<WidgetData |
const data: WidgetData = {
serverName: info.virtualserver_name || 'TeamSpeak Server',
serverHost: widget.serverConfig.host,
serverPort: Number(info.virtualserver_port) || 9987,
onlineUsers: onlineClients.length,
maxClients: Number(info.virtualserver_maxclients) || 0,
uptime: Number(info.virtualserver_uptime) || 0,
@@ -59,7 +64,7 @@ async function getWidgetData(token: string, req: Request): Promise<WidgetData |
showChannelTree: widget.showChannelTree,
showClients: widget.showClients,
channelTree: widget.showChannelTree
? buildWidgetTree(channels, clients, widget.maxChannelDepth, widget.showClients)
? buildWidgetTree(channels, clients, widget.maxChannelDepth, widget.showClients, widget.hideEmptyChannels ?? false)
: [],
fetchedAt: new Date().toISOString(),
};
+4 -2
View File
@@ -22,7 +22,7 @@ widgetRoutes.get('/', async (req: Request, res: Response, next) => {
widgetRoutes.post('/', requireRole('admin'), async (req: Request, res: Response, next) => {
try {
const prisma = req.app.locals.prisma;
const { name, serverConfigId, virtualServerId, theme, showChannelTree, showClients, maxChannelDepth } = req.body;
const { name, serverConfigId, virtualServerId, theme, showChannelTree, showClients, hideEmptyChannels, maxChannelDepth } = req.body;
if (!name || !serverConfigId) throw new AppError(400, 'name and serverConfigId are required');
@@ -38,6 +38,7 @@ widgetRoutes.post('/', requireRole('admin'), async (req: Request, res: Response,
theme: theme ?? 'dark',
showChannelTree: showChannelTree ?? true,
showClients: showClients ?? true,
hideEmptyChannels: hideEmptyChannels ?? false,
maxChannelDepth: maxChannelDepth ?? 5,
},
include: { serverConfig: { select: { id: true, name: true, host: true } } },
@@ -52,7 +53,7 @@ widgetRoutes.patch('/:id', requireRole('admin'), async (req: Request, res: Respo
try {
const prisma = req.app.locals.prisma;
const id = parseInt(req.params.id as string);
const { name, theme, showChannelTree, showClients, maxChannelDepth } = req.body;
const { name, theme, showChannelTree, showClients, hideEmptyChannels, maxChannelDepth } = req.body;
// Invalidate cache for this widget's token
const existing = await prisma.widget.findUnique({ where: { id } });
@@ -66,6 +67,7 @@ widgetRoutes.patch('/:id', requireRole('admin'), async (req: Request, res: Respo
...(theme !== undefined && { theme }),
...(showChannelTree !== undefined && { showChannelTree }),
...(showClients !== undefined && { showClients }),
...(hideEmptyChannels !== undefined && { hideEmptyChannels }),
...(maxChannelDepth !== undefined && { maxChannelDepth }),
},
include: { serverConfig: { select: { id: true, name: true, host: true } } },
@@ -2,6 +2,7 @@ import axios, { AxiosInstance } from 'axios';
import http from 'http';
import https from 'https';
import { TSApiError } from '../middleware/error-handler.js';
import { config } from '../config.js';
export class WebQueryClient {
private http: AxiosInstance;
@@ -20,7 +21,7 @@ export class WebQueryClient {
// TS server registers each one as a separate "serveradmin" query client
// (serveradmin, serveradmin1, serveradmin2, ...).
this.agent = useHttps
? new https.Agent({ keepAlive: true, maxSockets: 1 })
? new https.Agent({ keepAlive: true, maxSockets: 1, rejectUnauthorized: !config.tsAllowSelfSigned })
: new http.Agent({ keepAlive: true, maxSockets: 1 });
this.http = axios.create({
+18 -3
View File
@@ -19,6 +19,17 @@ export interface YouTubeSearchResult {
thumbnail: string;
}
// Shared cookie file path (set from settings)
let ytCookieFile: string | null = null;
export function setYtCookieFile(filePath: string | null): void {
ytCookieFile = filePath;
}
function getCookieArgs(): string[] {
return ytCookieFile ? ["--cookies", ytCookieFile] : [];
}
/**
* Download audio from a YouTube URL using yt-dlp
*/
@@ -28,7 +39,8 @@ export function downloadYouTube(url: string, outputDir: string): Promise<{ fileP
// First get info
const infoProc = spawn("yt-dlp", [
"--dump-json",
...getCookieArgs(),
"--dump-json",
"--no-playlist",
url,
], { shell: false });
@@ -72,6 +84,7 @@ export function downloadYouTube(url: string, outputDir: string): Promise<{ fileP
// Download audio only
const dlProc = spawn("yt-dlp", [
...getCookieArgs(),
"-x", // extract audio
"--audio-format", "opus", // opus format (native for TS3)
"--audio-quality", "0", // best quality
@@ -118,7 +131,8 @@ export function downloadYouTube(url: string, outputDir: string): Promise<{ fileP
export function getYouTubeUrlInfo(url: string): Promise<{ type: 'video' | 'playlist'; items: YouTubeSearchResult[] }> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", [
"--dump-json",
...getCookieArgs(),
"--dump-json",
"--flat-playlist",
"--no-download",
url,
@@ -170,7 +184,8 @@ export function getYouTubeUrlInfo(url: string): Promise<{ type: 'video' | 'playl
export function searchYouTube(query: string, maxResults: number = 10): Promise<YouTubeSearchResult[]> {
return new Promise((resolve, reject) => {
const proc = spawn("yt-dlp", [
`ytsearch${maxResults}:${query}`,
...getCookieArgs(),
`ytsearch${maxResults}:${query}`,
"--dump-json",
"--flat-playlist",
"--no-download",
@@ -9,7 +9,7 @@ const CMD_PREFIX = '!';
const MUSIC_COMMANDS = new Set([
'radio', 'play', 'stop', 'pause', 'skip', 'next', 'prev',
'vol', 'volume', 'np', 'nowplaying',
'vol', 'volume', 'np', 'nowplaying', 'queue', 'add',
]);
/**
@@ -94,6 +94,10 @@ export class MusicCommandHandler {
case 'nowplaying':
this.handleNowPlaying(bot, userClid);
break;
case 'queue':
case 'add':
await this.handleQueue(bot, userClid, args);
break;
}
} catch (err: any) {
console.error(`[MusicCmd] Error handling !${command}: ${err.message}`);
@@ -194,15 +198,76 @@ export class MusicCommandHandler {
};
bot.queue.add(queueItem);
bot.queue.playAt(bot.queue.length - 1);
await bot.play(queueItem);
this.reply(bot, userClid, `Now playing: ${info.artist} - ${info.title}`);
// If something is already playing, queue it instead of interrupting
if (bot.status === 'playing' || bot.status === 'paused') {
this.reply(bot, userClid, `Queued: ${info.artist} - ${info.title} (position #${bot.queue.length})`);
} else {
bot.queue.playAt(bot.queue.length - 1);
await bot.play(queueItem);
this.reply(bot, userClid, `Now playing: ${info.artist} - ${info.title}`);
}
} catch (err: any) {
this.reply(bot, userClid, `Failed to play: ${err.message}`);
}
}
private async handleQueue(bot: VoiceBot, userClid: number, args: string): Promise<void> {
// No args — show current queue
if (!args) {
const items = bot.queue.getAll();
if (items.length === 0) {
this.reply(bot, userClid, 'Queue is empty.');
return;
}
const np = bot.nowPlaying;
const lines = items.slice(0, 15).map((item, i) => {
const marker = np && item.id === np.id ? '▶ ' : ' ';
const artist = item.artist ? `${item.artist} - ` : '';
return `${marker}${i + 1}. ${artist}${item.title}`;
});
if (items.length > 15) lines.push(` ... and ${items.length - 15} more`);
this.reply(bot, userClid, `Queue (${items.length} tracks):\n${lines.join('\n')}`);
return;
}
// URL provided — add to queue without interrupting
if (!args.startsWith('http://') && !args.startsWith('https://')) {
this.reply(bot, userClid, 'Usage: !queue [url] — Show queue or add a song.');
return;
}
this.reply(bot, userClid, 'Loading...');
try {
const { filePath, info } = await downloadYouTube(args, MUSIC_DIR);
const queueItem: QueueItem = {
id: `yt_${info.id}`,
title: info.title,
artist: info.artist,
duration: info.duration,
filePath,
source: 'youtube',
sourceUrl: args,
};
bot.queue.add(queueItem);
// If nothing is playing, start playing the queued item
if (bot.status !== 'playing' && bot.status !== 'paused') {
bot.queue.playAt(bot.queue.length - 1);
await bot.play(queueItem);
this.reply(bot, userClid, `Now playing: ${info.artist} - ${info.title}`);
} else {
this.reply(bot, userClid, `Queued: ${info.artist} - ${info.title} (position #${bot.queue.length})`);
}
} catch (err: any) {
this.reply(bot, userClid, `Failed to queue: ${err.message}`);
}
}
private handleStop(bot: VoiceBot, userClid: number): void {
bot.stopAudio();
this.reply(bot, userClid, 'Playback stopped.');
@@ -56,7 +56,7 @@ export class VoiceBotManager extends EventEmitter {
id: dbBot.id,
name: dbBot.name,
serverHost: dbBot.serverConfig.host,
serverPort: 9987, // Default TS3 voice port
serverPort: dbBot.voicePort ?? 9987,
nickname: dbBot.nickname,
serverPassword: dbBot.serverPassword ?? undefined,
defaultChannel: dbBot.defaultChannel ?? undefined,
@@ -146,6 +146,7 @@ export class VoiceBotManager extends EventEmitter {
serverPassword?: string;
defaultChannel?: string;
channelPassword?: string;
voicePort?: number;
volume?: number;
autoStart?: boolean;
}): Promise<{ id: number }> {
@@ -177,6 +178,7 @@ export class VoiceBotManager extends EventEmitter {
serverPassword: data.serverPassword,
defaultChannel: data.defaultChannel,
channelPassword: data.channelPassword,
voicePort: data.voicePort ?? 9987,
volume: data.volume ?? 50,
autoStart: data.autoStart ?? false,
identityData,
@@ -187,7 +189,7 @@ export class VoiceBotManager extends EventEmitter {
id: dbBot.id,
name: dbBot.name,
serverHost: serverConfig.host,
serverPort: 9987,
serverPort: dbBot.voicePort ?? 9987,
nickname: dbBot.nickname,
serverPassword: dbBot.serverPassword ?? undefined,
defaultChannel: dbBot.defaultChannel ?? undefined,
@@ -1,10 +1,38 @@
import type { WidgetChannelNode, WidgetClient } from '@ts6/common';
// Parse TS spacer channel names: [*spacer0]=, [cspacer0]Center Text, [rspacer]Right, etc.
function parseSpacerInfo(name: string): { isSpacer: boolean; spacerType: WidgetChannelNode['spacerType']; spacerText: string } {
const match = name.match(/^\[([lcr]?\*?)spacer\d*\](.*)$/i);
if (!match) return { isSpacer: false, spacerType: 'none', spacerText: '' };
const prefix = match[1].toLowerCase();
const text = match[2];
let spacerType: WidgetChannelNode['spacerType'] = 'left';
if (text === '---') spacerType = 'dashline';
else if (text === '...') spacerType = 'dotline';
else if (text === '___' || text === '===' || text === '---' || text === '___') spacerType = 'line';
else if (prefix === 'c') spacerType = 'center';
else if (prefix === 'r') spacerType = 'right';
else if (prefix === '*' || prefix === 'l*' || prefix === '') {
if (!text || text === '=' || text.match(/^[=\-_.]+$/)) spacerType = 'line';
else spacerType = 'left';
}
return { isSpacer: true, spacerType, spacerText: text };
}
function hasClients(node: WidgetChannelNode): boolean {
if (node.clients.length > 0) return true;
return node.children.some(hasClients);
}
export function buildWidgetTree(
channels: any[],
clients: any[],
maxDepth: number,
showClients: boolean,
hideEmptyChannels: boolean = false,
): WidgetChannelNode[] {
// Group human clients by channel
const clientsByChannel = new Map<number, WidgetClient[]>();
@@ -28,10 +56,16 @@ export function buildWidgetTree(
for (const ch of channels) {
const cid = Number(ch.cid);
const name = String(ch.channel_name || '');
const spacer = parseSpacerInfo(name);
map.set(cid, {
cid,
name: String(ch.channel_name || ''),
name,
hasPassword: Number(ch.channel_flag_password) === 1,
isspacer: spacer.isSpacer,
spacerType: spacer.spacerType,
spacerText: spacer.spacerText,
clients: clientsByChannel.get(cid) ?? [],
children: [],
});
@@ -55,5 +89,17 @@ export function buildWidgetTree(
return nodes.map(n => ({ ...n, children: pruneDepth(n.children, depth + 1) }));
}
return pruneDepth(roots, 0);
let result = pruneDepth(roots, 0);
// Filter empty channels (keep spacers always visible)
if (hideEmptyChannels) {
function filterEmpty(nodes: WidgetChannelNode[]): WidgetChannelNode[] {
return nodes
.map(n => ({ ...n, children: filterEmpty(n.children) }))
.filter(n => n.isspacer || hasClients(n));
}
result = filterEmpty(result);
}
return result;
}
+18 -2
View File
@@ -30,8 +30,8 @@ function formatUptime(seconds: number): string {
function countTreeRows(nodes: WidgetChannelNode[], showClients: boolean): number {
let count = 0;
for (const node of nodes) {
count += 1; // channel row
if (showClients) count += node.clients.length;
count += 1; // channel or spacer row
if (!node.isspacer && showClients) count += node.clients.length;
count += countTreeRows(node.children, showClients);
}
return count;
@@ -108,6 +108,22 @@ function renderTreeNodes(
const indent = PADDING + depth * 16;
for (const node of nodes) {
// Spacer channels
if (node.isspacer) {
const lineY = y + 11;
if (node.spacerType === 'line' || node.spacerType === 'dashline' || node.spacerType === 'dotline') {
const dashArray = node.spacerType === 'dotline' ? '2,4' : node.spacerType === 'dashline' ? '6,4' : 'none';
lines.push(`<line x1="${PADDING}" y1="${lineY}" x2="${WIDTH - PADDING}" y2="${lineY}" stroke="${theme.border}" stroke-width="1"${dashArray !== 'none' ? ` stroke-dasharray="${dashArray}"` : ''}/>`);
} else {
const text = escapeXml(node.spacerText || '');
const anchor = node.spacerType === 'center' ? 'middle' : node.spacerType === 'right' ? 'end' : 'start';
const tx = node.spacerType === 'center' ? WIDTH / 2 : node.spacerType === 'right' ? WIDTH - PADDING : PADDING;
lines.push(`<text x="${tx}" y="${y + 14}" fill="${theme.textSecondary}" font-family="${FONT}" font-size="11" font-weight="600" text-anchor="${anchor}" letter-spacing="0.5">${text}</text>`);
}
y += CHANNEL_ROW;
continue;
}
// Channel row
const channelName = escapeXml(truncate(node.name, 36 - depth * 2));
const hashX = indent;
+9 -1
View File
@@ -142,7 +142,7 @@ export interface CreateBanRequest {
// Token
export interface CreateTokenRequest {
tokentype: 0 | 1; // 0=server group, 1=channel group
tokentype: 0 | 1; // 0=server group, 1=channel group tokens)
tokenid1: number; // group id
tokenid2: number; // channel id (for channel group tokens)
tokendescription?: string;
@@ -214,6 +214,7 @@ export interface WidgetConfig {
theme: WidgetTheme;
showChannelTree: boolean;
showClients: boolean;
hideEmptyChannels: boolean;
maxChannelDepth: number;
createdAt: string;
updatedAt: string;
@@ -226,6 +227,7 @@ export interface CreateWidgetRequest {
theme?: WidgetTheme;
showChannelTree?: boolean;
showClients?: boolean;
hideEmptyChannels?: boolean;
maxChannelDepth?: number;
}
@@ -234,11 +236,14 @@ export interface UpdateWidgetRequest {
theme?: WidgetTheme;
showChannelTree?: boolean;
showClients?: boolean;
hideEmptyChannels?: boolean;
maxChannelDepth?: number;
}
export interface WidgetData {
serverName: string;
serverHost: string;
serverPort: number;
onlineUsers: number;
maxClients: number;
uptime: number;
@@ -255,6 +260,9 @@ export interface WidgetChannelNode {
cid: number;
name: string;
hasPassword: boolean;
isspacer: boolean;
spacerType: 'line' | 'dotline' | 'dashline' | 'center' | 'left' | 'right' | 'none';
spacerText: string;
clients: WidgetClient[];
children: WidgetChannelNode[];
}
+3
View File
@@ -11,6 +11,7 @@ export interface MusicBotSummary {
serverPassword: string | null;
defaultChannel: string | null;
channelPassword: string | null;
voicePort: number;
volume: number;
autoStart: boolean;
status: VoiceBotStatus;
@@ -30,6 +31,7 @@ export interface CreateMusicBotRequest {
serverPassword?: string;
defaultChannel?: string;
channelPassword?: string;
voicePort?: number;
volume?: number;
autoStart?: boolean;
}
@@ -40,6 +42,7 @@ export interface UpdateMusicBotRequest {
serverPassword?: string;
defaultChannel?: string;
channelPassword?: string;
voicePort?: number;
volume?: number;
autoStart?: boolean;
}
@@ -49,6 +49,7 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
const [theme, setTheme] = useState<WidgetTheme>('dark');
const [showChannelTree, setShowChannelTree] = useState(true);
const [showClients, setShowClients] = useState(true);
const [hideEmptyChannels, setHideEmptyChannels] = useState(false);
const [maxChannelDepth, setMaxChannelDepth] = useState(5);
const resetForm = () => {
@@ -58,6 +59,7 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
setTheme('dark');
setShowChannelTree(true);
setShowClients(true);
setHideEmptyChannels(false);
setMaxChannelDepth(5);
};
@@ -73,6 +75,7 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
setTheme(w.theme);
setShowChannelTree(w.showChannelTree);
setShowClients(w.showClients);
setHideEmptyChannels(w.hideEmptyChannels ?? false);
setMaxChannelDepth(w.maxChannelDepth);
setView('edit');
};
@@ -91,6 +94,7 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
theme,
showChannelTree,
showClients,
hideEmptyChannels,
maxChannelDepth,
});
setView('list');
@@ -100,7 +104,7 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
if (!editTarget || !name.trim()) return;
await updateWidget.mutateAsync({
id: editTarget.id,
data: { name: name.trim(), theme, showChannelTree, showClients, maxChannelDepth },
data: { name: name.trim(), theme, showChannelTree, showClients, hideEmptyChannels, maxChannelDepth },
});
setView('list');
};
@@ -241,6 +245,11 @@ export function WidgetManagerModal({ open, onOpenChange }: Props) {
<Switch checked={showClients} onCheckedChange={setShowClients} />
</div>
<div className="flex items-center justify-between">
<Label>Hide Empty Channels</Label>
<Switch checked={hideEmptyChannels} onCheckedChange={setHideEmptyChannels} />
</div>
<div className="space-y-2">
<Label>Max Channel Depth: {maxChannelDepth}</Label>
<Slider min={1} max={10} step={1} value={[maxChannelDepth]} onValueChange={([v]) => setMaxChannelDepth(v)} />
@@ -10,6 +10,43 @@ function formatUptime(seconds: number): string {
return `${m}m`;
}
function SpacerChannel({ node, theme }: { node: WidgetChannelNodeType; theme: WidgetTheme }) {
const t = WIDGET_THEMES[theme];
if (node.spacerType === 'line' || node.spacerType === 'dashline' || node.spacerType === 'dotline') {
const borderStyle = node.spacerType === 'dotline' ? 'dotted' : node.spacerType === 'dashline' ? 'dashed' : 'solid';
return (
<div style={{
padding: '4px 4px',
display: 'flex',
alignItems: 'center',
}}>
<div style={{
flex: 1,
borderBottom: `1px ${borderStyle} ${t.border}`,
}} />
</div>
);
}
const textAlign = node.spacerType === 'center' ? 'center' as const
: node.spacerType === 'right' ? 'right' as const
: 'left' as const;
return (
<div style={{
padding: '2px 4px',
fontSize: '11px',
color: t.textSecondary,
textAlign,
fontWeight: 600,
letterSpacing: '0.5px',
}}>
{node.spacerText || ''}
</div>
);
}
function ChannelNode({ node, depth, showClients, theme }: {
node: WidgetChannelNodeType;
depth: number;
@@ -19,6 +56,10 @@ function ChannelNode({ node, depth, showClients, theme }: {
const t = WIDGET_THEMES[theme];
const indent = depth * 16;
if (node.isspacer) {
return <SpacerChannel node={node} theme={theme} />;
}
return (
<div>
{/* Channel row */}
@@ -78,6 +119,10 @@ function ChannelNode({ node, depth, showClients, theme }: {
export function WidgetRenderer({ data }: { data: WidgetData }) {
const t = WIDGET_THEMES[data.theme] || WIDGET_THEMES.dark;
const joinUrl = data.serverHost
? `ts3server://${data.serverHost}${data.serverPort !== 9987 ? `?port=${data.serverPort}` : ''}`
: null;
return (
<div style={{
background: t.background,
@@ -95,18 +140,38 @@ export function WidgetRenderer({ data }: { data: WidgetData }) {
marginBottom: '10px',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontWeight: 700,
color: t.accent,
fontSize: '15px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
marginRight: '8px',
}}>
{data.serverName}
</span>
{joinUrl ? (
<a
href={joinUrl}
style={{
fontWeight: 700,
color: t.accent,
fontSize: '15px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
marginRight: '8px',
textDecoration: 'none',
}}
title="Click to join server"
>
{data.serverName}
</a>
) : (
<span style={{
fontWeight: 700,
color: t.accent,
fontSize: '15px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
marginRight: '8px',
}}>
{data.serverName}
</span>
)}
<span style={{
background: t.clientColor,
color: '#fff',
+13 -6
View File
@@ -422,7 +422,7 @@ function BotsTab() {
// Create form
const [form, setForm] = useState({
name: '', serverConfigId: '', nickname: 'MusicBot', serverPassword: '', defaultChannel: '', channelPassword: '', volume: 50, autoStart: false,
name: '', serverConfigId: '', nickname: 'MusicBot', serverPassword: '', defaultChannel: '', channelPassword: '', voicePort: 9987, volume: 50, autoStart: false,
});
const bots = Array.isArray(data) ? data : [];
@@ -440,6 +440,7 @@ function BotsTab() {
serverPassword: form.serverPassword || undefined,
defaultChannel: form.defaultChannel || undefined,
channelPassword: form.channelPassword || undefined,
voicePort: form.voicePort,
volume: form.volume,
autoStart: form.autoStart,
}, {
@@ -456,6 +457,7 @@ function BotsTab() {
serverPassword: form.serverPassword || undefined,
defaultChannel: form.defaultChannel || undefined,
channelPassword: form.channelPassword || undefined,
voicePort: form.voicePort,
volume: form.volume,
autoStart: form.autoStart,
}}, {
@@ -464,7 +466,7 @@ function BotsTab() {
});
};
const resetForm = () => setForm({ name: '', serverConfigId: '', nickname: 'MusicBot', serverPassword: '', defaultChannel: '', channelPassword: '', volume: 50, autoStart: false });
const resetForm = () => setForm({ name: '', serverConfigId: '', nickname: 'MusicBot', serverPassword: '', defaultChannel: '', channelPassword: '', voicePort: 9987, volume: 50, autoStart: false });
return (
<div className="space-y-4">
@@ -491,6 +493,7 @@ function BotsTab() {
serverPassword: bot.serverPassword || '',
defaultChannel: bot.defaultChannel || '',
channelPassword: bot.channelPassword || '',
voicePort: bot.voicePort ?? 9987,
volume: bot.volume,
autoStart: bot.autoStart,
});
@@ -525,6 +528,10 @@ function BotsTab() {
</Select>
</div>
)}
<div>
<Label className="text-xs">Voice Port</Label>
<Input type="number" value={form.voicePort} onChange={(e) => setForm({ ...form, voicePort: parseInt(e.target.value) || 9987 })} placeholder="9987" />
</div>
<div>
<Label className="text-xs">Nickname</Label>
<Input value={form.nickname} onChange={(e) => setForm({ ...form, nickname: e.target.value })} placeholder="MusicBot" />
@@ -903,7 +910,7 @@ function LibraryTab() {
<EmptyState icon={Music} title="No songs yet" description="Upload audio files or download from YouTube to build your library." />
) : (
<div className="border rounded-lg overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-2 px-3 py-2 bg-muted/50 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
<div className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto_auto] gap-2 px-3 py-2 bg-muted/50 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
<span>Title</span>
<span className="w-20 text-right">Duration</span>
<span className="w-16 text-center">Source</span>
@@ -912,7 +919,7 @@ function LibraryTab() {
</div>
<ScrollArea className="max-h-[400px]">
{filtered.map((song) => (
<div key={song.id} className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-2 px-3 py-2 hover:bg-muted/30 transition-colors items-center border-t border-border/50">
<div key={song.id} className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto_auto] gap-2 px-3 py-2 hover:bg-muted/30 transition-colors items-center border-t border-border/50">
<div className="min-w-0">
<p className="text-xs font-medium truncate">{song.title}</p>
{song.artist && <p className="text-[10px] text-muted-foreground truncate">{song.artist}</p>}
@@ -1338,7 +1345,7 @@ function RadioTab() {
<DialogTitle>Radio Presets</DialogTitle>
<DialogDescription>Add popular radio stations with one click.</DialogDescription>
</DialogHeader>
<div className="flex-1 max-h-[400px] overflow-y-auto">
<ScrollArea className="flex-1 max-h-[400px]">
{presetList.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">No presets available.</p>
) : presetList.map((preset, i) => (
@@ -1356,7 +1363,7 @@ function RadioTab() {
</Button>
</div>
))}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPresets(false)}>Done</Button>
</DialogFooter>
+5
View File
@@ -80,6 +80,11 @@
font-family: 'Outfit', system-ui, sans-serif;
}
/* Fix Radix ScrollArea display:table bug preventing scrolling */
[data-radix-scroll-area-viewport] > div {
display: block !important;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;