- 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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user