2025-08-08 13:14:27 +02:00
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
2025-08-10 18:47:33 +02:00
|
|
|
import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api';
|
2025-08-09 17:21:01 +02:00
|
|
|
import type { VoiceChannelInfo, Sound, Category } from './types';
|
2025-08-08 03:21:01 +02:00
|
|
|
import { getCookie, setCookie } from './cookies';
|
2025-08-07 23:24:56 +02:00
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
const [sounds, setSounds] = useState<Sound[]>([]);
|
2025-08-08 01:40:49 +02:00
|
|
|
const [total, setTotal] = useState<number>(0);
|
2025-08-08 01:56:30 +02:00
|
|
|
const [folders, setFolders] = useState<Array<{ key: string; name: string; count: number }>>([]);
|
|
|
|
|
const [activeFolder, setActiveFolder] = useState<string>('__all__');
|
2025-08-09 17:21:01 +02:00
|
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
|
|
|
const [activeCategoryId, setActiveCategoryId] = useState<string>('');
|
2025-08-07 23:24:56 +02:00
|
|
|
const [channels, setChannels] = useState<VoiceChannelInfo[]>([]);
|
|
|
|
|
const [query, setQuery] = useState('');
|
2026-02-26 13:47:54 +01:00
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
const [selected, setSelected] = useState<string>('');
|
2025-08-10 18:54:48 +02:00
|
|
|
const selectedRef = useRef<string>('');
|
2025-08-07 23:24:56 +02:00
|
|
|
const [loading, setLoading] = useState(false);
|
2026-02-26 13:47:54 +01:00
|
|
|
const [notification, setNotification] = useState<{ msg: string, type: 'info' | 'error' } | null>(null);
|
|
|
|
|
|
2025-08-08 01:23:52 +02:00
|
|
|
const [volume, setVolume] = useState<number>(1);
|
2025-08-08 03:21:01 +02:00
|
|
|
const [favs, setFavs] = useState<Record<string, boolean>>({});
|
2025-08-08 13:17:29 +02:00
|
|
|
const [theme, setTheme] = useState<string>(() => localStorage.getItem('theme') || 'dark');
|
2025-08-08 14:23:18 +02:00
|
|
|
const [isAdmin, setIsAdmin] = useState<boolean>(false);
|
2025-08-09 22:00:57 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// Chaos Mode (Partymode)
|
2025-08-09 13:54:38 +02:00
|
|
|
const [chaosMode, setChaosMode] = useState<boolean>(false);
|
2026-02-26 13:47:54 +01:00
|
|
|
const [partyActiveGuilds, setPartyActiveGuilds] = useState<string[]>([]);
|
2025-08-09 14:58:55 +02:00
|
|
|
const chaosTimeoutRef = useRef<number | null>(null);
|
2025-08-09 15:28:32 +02:00
|
|
|
const chaosModeRef = useRef<boolean>(false);
|
2026-02-26 13:47:54 +01:00
|
|
|
|
|
|
|
|
// Scrolled State for Header blur
|
|
|
|
|
const [isScrolled, setIsScrolled] = useState(false);
|
|
|
|
|
|
2025-08-09 15:28:32 +02:00
|
|
|
useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]);
|
2025-08-10 18:54:48 +02:00
|
|
|
useEffect(() => { selectedRef.current = selected; }, [selected]);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
const showNotification = (msg: string, type: 'info' | 'error' = 'info') => {
|
|
|
|
|
setNotification({ msg, type });
|
|
|
|
|
setTimeout(() => setNotification(null), 3000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------- Init Load ----------------
|
2025-08-07 23:24:56 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
2025-08-10 18:47:33 +02:00
|
|
|
const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]);
|
2025-08-07 23:24:56 +02:00
|
|
|
setChannels(c);
|
2025-08-10 18:47:33 +02:00
|
|
|
let initial = '';
|
|
|
|
|
if (c.length > 0) {
|
|
|
|
|
const firstGuild = c[0].guildId;
|
|
|
|
|
const serverCid = selectedMap[firstGuild];
|
|
|
|
|
if (serverCid && c.find(x => x.guildId === firstGuild && x.channelId === serverCid)) {
|
|
|
|
|
initial = `${firstGuild}:${serverCid}`;
|
|
|
|
|
} else {
|
|
|
|
|
initial = `${c[0].guildId}:${c[0].channelId}`;
|
|
|
|
|
}
|
2025-08-08 02:37:53 +02:00
|
|
|
}
|
2025-08-10 18:47:33 +02:00
|
|
|
if (initial) setSelected(initial);
|
2025-08-07 23:24:56 +02:00
|
|
|
} catch (e: any) {
|
2026-02-26 13:47:54 +01:00
|
|
|
showNotification(e?.message || 'Fehler beim Laden der Channels', 'error');
|
2025-08-07 23:24:56 +02:00
|
|
|
}
|
2026-02-26 13:47:54 +01:00
|
|
|
try { setIsAdmin(await adminStatus()); } catch { }
|
|
|
|
|
try { const cats = await fetchCategories(); setCategories(cats.categories || []); } catch { }
|
2025-08-07 23:24:56 +02:00
|
|
|
})();
|
2025-08-08 03:17:38 +02:00
|
|
|
}, []);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// ---------------- Theme ----------------
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
document.body.setAttribute('data-theme', theme);
|
|
|
|
|
localStorage.setItem('theme', theme);
|
|
|
|
|
}, [theme]);
|
|
|
|
|
|
|
|
|
|
// ---------------- SSE Events ----------------
|
2025-08-10 00:11:38 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const unsub = subscribeEvents((msg) => {
|
|
|
|
|
if (msg?.type === 'party') {
|
|
|
|
|
setPartyActiveGuilds((prev) => {
|
|
|
|
|
const s = new Set(prev);
|
|
|
|
|
if (msg.active) s.add(msg.guildId); else s.delete(msg.guildId);
|
|
|
|
|
return Array.from(s);
|
|
|
|
|
});
|
|
|
|
|
} else if (msg?.type === 'snapshot') {
|
|
|
|
|
setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []);
|
2025-08-10 18:47:33 +02:00
|
|
|
try {
|
|
|
|
|
const sel = msg?.selected || {};
|
2025-08-10 18:54:48 +02:00
|
|
|
const currentSelected = selectedRef.current || '';
|
|
|
|
|
const gid = currentSelected ? currentSelected.split(':')[0] : '';
|
2025-08-10 18:47:33 +02:00
|
|
|
if (gid && sel[gid]) {
|
|
|
|
|
const newVal = `${gid}:${sel[gid]}`;
|
|
|
|
|
setSelected(newVal);
|
|
|
|
|
}
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch { }
|
2025-08-10 21:15:39 +02:00
|
|
|
try {
|
|
|
|
|
const vols = msg?.volumes || {};
|
|
|
|
|
const cur = selectedRef.current || '';
|
|
|
|
|
const gid = cur ? cur.split(':')[0] : '';
|
|
|
|
|
if (gid && typeof vols[gid] === 'number') {
|
2026-02-26 13:47:54 +01:00
|
|
|
setVolume(vols[gid]);
|
2025-08-10 21:15:39 +02:00
|
|
|
}
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch { }
|
2025-08-10 18:47:33 +02:00
|
|
|
} else if (msg?.type === 'channel') {
|
|
|
|
|
try {
|
|
|
|
|
const gid = msg.guildId;
|
|
|
|
|
const cid = msg.channelId;
|
|
|
|
|
if (gid && cid) {
|
2025-08-10 18:54:48 +02:00
|
|
|
const currentSelected = selectedRef.current || '';
|
|
|
|
|
const curGid = currentSelected ? currentSelected.split(':')[0] : '';
|
2025-08-10 18:47:33 +02:00
|
|
|
if (curGid === gid) setSelected(`${gid}:${cid}`);
|
|
|
|
|
}
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch { }
|
2025-08-10 21:15:39 +02:00
|
|
|
} else if (msg?.type === 'volume') {
|
|
|
|
|
try {
|
|
|
|
|
const gid = msg.guildId;
|
|
|
|
|
const v = msg.volume;
|
|
|
|
|
const cur = selectedRef.current || '';
|
|
|
|
|
const curGid = cur ? cur.split(':')[0] : '';
|
|
|
|
|
if (gid && curGid === gid && typeof v === 'number') {
|
|
|
|
|
setVolume(v);
|
|
|
|
|
}
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch { }
|
2025-08-10 00:11:38 +02:00
|
|
|
}
|
|
|
|
|
});
|
2026-02-26 13:47:54 +01:00
|
|
|
return () => { try { unsub(); } catch { } };
|
2025-08-10 00:11:38 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const gid = selected ? selected.split(':')[0] : '';
|
|
|
|
|
setChaosMode(gid ? partyActiveGuilds.includes(gid) : false);
|
|
|
|
|
}, [selected, partyActiveGuilds]);
|
|
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// ---------------- Data Fetching ----------------
|
2025-08-08 03:17:38 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
(async () => {
|
2025-08-07 23:24:56 +02:00
|
|
|
try {
|
2025-08-08 03:37:54 +02:00
|
|
|
const folderParam = activeFolder === '__favs__' ? '__all__' : activeFolder;
|
2026-02-26 13:47:54 +01:00
|
|
|
const s = await fetchSounds(query, folderParam, activeCategoryId || undefined, false); // Removed fuzzy param for cleaner UI
|
2025-08-08 01:40:49 +02:00
|
|
|
setSounds(s.items);
|
|
|
|
|
setTotal(s.total);
|
2025-08-08 01:56:30 +02:00
|
|
|
setFolders(s.folders);
|
2025-08-08 03:17:38 +02:00
|
|
|
} catch (e: any) {
|
2026-02-26 13:47:54 +01:00
|
|
|
showNotification(e?.message || 'Fehler beim Laden der Sounds', 'error');
|
2025-08-08 03:17:38 +02:00
|
|
|
}
|
|
|
|
|
})();
|
2026-02-26 13:47:54 +01:00
|
|
|
}, [activeFolder, query, activeCategoryId]);
|
2025-08-07 23:24:56 +02:00
|
|
|
|
2025-08-08 03:21:01 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const c = getCookie('favs');
|
2026-02-26 13:47:54 +01:00
|
|
|
if (c) { try { setFavs(JSON.parse(c)); } catch { } }
|
2025-08-08 03:21:01 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-02-26 13:47:54 +01:00
|
|
|
try { setCookie('favs', JSON.stringify(favs)); } catch { }
|
2025-08-08 03:21:01 +02:00
|
|
|
}, [favs]);
|
|
|
|
|
|
2025-08-08 02:37:53 +02:00
|
|
|
useEffect(() => {
|
2025-08-08 13:46:27 +02:00
|
|
|
(async () => {
|
|
|
|
|
if (selected) {
|
|
|
|
|
localStorage.setItem('selectedChannel', selected);
|
|
|
|
|
try {
|
|
|
|
|
const [guildId] = selected.split(':');
|
|
|
|
|
const v = await getVolume(guildId);
|
|
|
|
|
setVolume(v);
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch { }
|
2025-08-08 13:46:27 +02:00
|
|
|
}
|
|
|
|
|
})();
|
2025-08-08 02:37:53 +02:00
|
|
|
}, [selected]);
|
|
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// ---------------- Actions ----------------
|
2025-08-08 01:56:30 +02:00
|
|
|
async function handlePlay(name: string, rel?: string) {
|
2026-02-26 13:47:54 +01:00
|
|
|
if (!selected) return showNotification('Bitte einen Voice-Channel auswählen', 'error');
|
2025-08-07 23:24:56 +02:00
|
|
|
const [guildId, channelId] = selected.split(':');
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
2025-08-08 01:56:30 +02:00
|
|
|
await playSound(name, guildId, channelId, volume, rel);
|
2025-08-07 23:24:56 +02:00
|
|
|
} catch (e: any) {
|
2026-02-26 13:47:54 +01:00
|
|
|
showNotification(e?.message || 'Wiedergabe fehlgeschlagen', 'error');
|
2025-08-07 23:24:56 +02:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// Chaos Mode Logic
|
2025-08-09 13:54:38 +02:00
|
|
|
const startChaosMode = async () => {
|
|
|
|
|
if (!selected || !sounds.length) return;
|
|
|
|
|
const playRandomSound = async () => {
|
2025-08-09 14:58:55 +02:00
|
|
|
const pool = sounds;
|
|
|
|
|
if (!pool.length || !selected) return;
|
|
|
|
|
const randomSound = pool[Math.floor(Math.random() * pool.length)];
|
2025-08-09 13:54:38 +02:00
|
|
|
const [guildId, channelId] = selected.split(':');
|
|
|
|
|
try {
|
|
|
|
|
await playSound(randomSound.name, guildId, channelId, volume, randomSound.relativePath);
|
2026-02-26 13:47:54 +01:00
|
|
|
} catch (e: any) { console.error('Chaos sound play failed:', e); }
|
2025-08-09 13:54:38 +02:00
|
|
|
};
|
|
|
|
|
|
2025-08-09 14:58:55 +02:00
|
|
|
const scheduleNextPlay = async () => {
|
2025-08-09 15:28:32 +02:00
|
|
|
if (!chaosModeRef.current) return;
|
2025-08-09 14:58:55 +02:00
|
|
|
await playRandomSound();
|
2025-08-09 21:14:44 +02:00
|
|
|
const delay = 30_000 + Math.floor(Math.random() * 60_000); // 30-90 Sekunden
|
2025-08-09 14:58:55 +02:00
|
|
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, delay);
|
|
|
|
|
};
|
2025-08-09 13:54:38 +02:00
|
|
|
|
2025-08-09 15:28:32 +02:00
|
|
|
await playRandomSound();
|
2025-08-09 21:14:44 +02:00
|
|
|
const firstDelay = 30_000 + Math.floor(Math.random() * 60_000);
|
2025-08-09 14:58:55 +02:00
|
|
|
chaosTimeoutRef.current = window.setTimeout(scheduleNextPlay, firstDelay);
|
2025-08-09 13:54:38 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopChaosMode = async () => {
|
2025-08-09 14:58:55 +02:00
|
|
|
if (chaosTimeoutRef.current) {
|
|
|
|
|
clearTimeout(chaosTimeoutRef.current);
|
|
|
|
|
chaosTimeoutRef.current = null;
|
2025-08-09 13:54:38 +02:00
|
|
|
}
|
|
|
|
|
if (selected) {
|
|
|
|
|
const [guildId] = selected.split(':');
|
2026-02-26 13:47:54 +01:00
|
|
|
try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { }
|
2025-08-09 13:54:38 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleChaosMode = async () => {
|
|
|
|
|
if (chaosMode) {
|
|
|
|
|
setChaosMode(false);
|
|
|
|
|
await stopChaosMode();
|
2026-02-26 13:47:54 +01:00
|
|
|
if (selected) { const [guildId] = selected.split(':'); try { await partyStop(guildId); } catch { } }
|
2025-08-09 13:54:38 +02:00
|
|
|
} else {
|
|
|
|
|
setChaosMode(true);
|
|
|
|
|
await startChaosMode();
|
2026-02-26 13:47:54 +01:00
|
|
|
if (selected) { const [guildId, channelId] = selected.split(':'); try { await partyStart(guildId, channelId); } catch { } }
|
2025-08-09 13:54:38 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
// Filter Data
|
|
|
|
|
const filtered = sounds;
|
|
|
|
|
const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]);
|
|
|
|
|
|
|
|
|
|
// Scroll Handler for Top Bar Blur
|
|
|
|
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
|
|
|
setIsScrolled(e.currentTarget.scrollTop > 20);
|
|
|
|
|
};
|
2025-08-09 13:54:38 +02:00
|
|
|
|
2025-08-07 23:24:56 +02:00
|
|
|
return (
|
2026-02-26 13:47:54 +01:00
|
|
|
<div className="app-layout">
|
|
|
|
|
{/* ---------------- Sidebar ---------------- */}
|
|
|
|
|
<aside className="sidebar">
|
|
|
|
|
<h1 className="title-large" style={{ marginLeft: 12, marginBottom: 32, fontSize: 28, background: 'linear-gradient(45deg, var(--accent-blue), #5e5ce6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>
|
|
|
|
|
Jukebox
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
<div className="sidebar-title">Library</div>
|
|
|
|
|
<button className={`nav-item ${activeFolder === '__all__' ? 'active' : ''}`} onClick={() => setActiveFolder('__all__')}>
|
|
|
|
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>library_music</span>All Sounds</div>
|
|
|
|
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{total}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button className={`nav-item ${activeFolder === '__favs__' ? 'active' : ''}`} onClick={() => setActiveFolder('__favs__')}>
|
|
|
|
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>favorite</span>Favorites</div>
|
|
|
|
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{favCount}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<button className={`nav-item ${activeFolder === '__recent__' ? 'active' : ''}`} onClick={() => setActiveFolder('__recent__')}>
|
|
|
|
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>schedule</span>Recently Added</div>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{folders.length > 3 && ( // 3 = __all__, __recent__, __top3__
|
2025-08-09 01:22:59 +02:00
|
|
|
<>
|
2026-02-26 13:47:54 +01:00
|
|
|
<div className="sidebar-title" style={{ marginTop: 32 }}>Folders</div>
|
|
|
|
|
{folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)).map(f => {
|
|
|
|
|
const displayName = f.name.replace(/\s*\(\d+\)\s*$/, '');
|
|
|
|
|
return (
|
|
|
|
|
<button key={f.key} className={`nav-item ${activeFolder === f.key ? 'active' : ''}`} onClick={() => setActiveFolder(f.key)}>
|
|
|
|
|
<div className="flex items-center gap-2" style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}><span className="material-icons" style={{ fontSize: 20 }}>folder</span>{displayName}</div>
|
|
|
|
|
<span style={{ opacity: 0.5, fontSize: 12 }}>{f.count}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-08-09 01:22:59 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
2025-08-07 23:24:56 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
{categories.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="sidebar-title" style={{ marginTop: 32 }}>Categories</div>
|
|
|
|
|
{categories.map(cat => (
|
|
|
|
|
<button key={cat.id} className={`nav-item ${activeCategoryId === cat.id ? 'active' : ''}`} onClick={() => setActiveCategoryId(prev => prev === cat.id ? '' : cat.id)}>
|
|
|
|
|
<div className="flex items-center gap-2"><span className="material-icons" style={{ fontSize: 20 }}>label</span>{cat.name}</div>
|
2025-08-10 18:39:43 +02:00
|
|
|
</button>
|
2026-02-26 13:47:54 +01:00
|
|
|
))}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* ---------------- Main Content ---------------- */}
|
|
|
|
|
<main className="main-content" onScroll={handleScroll}>
|
|
|
|
|
<header className={`top-bar ${isScrolled ? 'scrolled' : ''}`}>
|
|
|
|
|
<h2 className="title-large" style={{ marginBottom: 0 }}>
|
|
|
|
|
{activeFolder === '__all__' ? 'All Sounds' :
|
|
|
|
|
activeFolder === '__favs__' ? 'Favorites' :
|
|
|
|
|
activeFolder === '__recent__' ? 'Recently Added' :
|
|
|
|
|
folders.find(f => f.key === activeFolder)?.name.replace(/\s*\(\d+\)\s*$/, '') || 'Library'}
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
<div className="header-actions">
|
|
|
|
|
<div className="search-box">
|
|
|
|
|
<span className="material-icons search-icon">search</span>
|
2025-08-08 23:40:26 +02:00
|
|
|
<input
|
2026-02-26 13:47:54 +01:00
|
|
|
type="text"
|
|
|
|
|
className="input-modern"
|
|
|
|
|
placeholder="Search..."
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
2025-08-08 23:40:26 +02:00
|
|
|
/>
|
2025-08-08 21:21:23 +02:00
|
|
|
</div>
|
2025-08-09 17:21:01 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
<select
|
|
|
|
|
className="select-modern"
|
|
|
|
|
value={theme}
|
|
|
|
|
onChange={(e) => setTheme(e.target.value)}
|
|
|
|
|
style={{ paddingRight: 20, paddingLeft: 12 }}
|
|
|
|
|
>
|
|
|
|
|
<option value="dark">Dark Theme</option>
|
|
|
|
|
<option value="light">Light Theme</option>
|
|
|
|
|
</select>
|
2025-08-08 21:21:23 +02:00
|
|
|
</div>
|
2026-02-26 13:47:54 +01:00
|
|
|
</header>
|
2025-08-09 15:46:12 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
<div className="track-container">
|
|
|
|
|
<div className="track-grid">
|
|
|
|
|
{(activeFolder === '__favs__' ? filtered.filter((s) => !!favs[s.relativePath ?? s.fileName]) : filtered).map((s) => {
|
|
|
|
|
const key = `${s.relativePath ?? s.fileName}`;
|
|
|
|
|
const isFav = !!favs[key];
|
2025-08-09 10:40:34 +02:00
|
|
|
return (
|
2026-02-26 13:47:54 +01:00
|
|
|
<div key={key} className="track-card" onClick={() => handlePlay(s.name, s.relativePath)}>
|
|
|
|
|
<button
|
|
|
|
|
className={`fav-btn ${isFav ? 'active' : ''}`}
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); setFavs(prev => ({ ...prev, [key]: !prev[key] })); }}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons">{isFav ? 'star' : 'star_border'}</span>
|
|
|
|
|
</button>
|
|
|
|
|
<div className="track-icon">
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 32 }}>music_note</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="track-name">{s.name}</div>
|
|
|
|
|
|
|
|
|
|
{Array.isArray((s as any).badges) && (s as any).badges!.includes('new') && (
|
|
|
|
|
<div className="badge-new">NEW</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-08-09 10:40:34 +02:00
|
|
|
);
|
|
|
|
|
})}
|
2025-08-08 21:21:23 +02:00
|
|
|
</div>
|
2026-02-26 13:47:54 +01:00
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
{/* ---------------- Bottom Control Bar ---------------- */}
|
|
|
|
|
<div className="bottom-player">
|
|
|
|
|
<div className="player-section">
|
|
|
|
|
{/* Target Channel */}
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
<span className="material-icons" style={{ color: 'var(--text-secondary)' }}>headset_mic</span>
|
|
|
|
|
<select
|
|
|
|
|
className="select-modern"
|
|
|
|
|
value={selected}
|
|
|
|
|
onChange={async (e) => {
|
|
|
|
|
const v = e.target.value;
|
|
|
|
|
setSelected(v);
|
|
|
|
|
try {
|
|
|
|
|
const [gid, cid] = v.split(':');
|
|
|
|
|
await setSelectedChannel(gid, cid);
|
|
|
|
|
} catch { }
|
|
|
|
|
}}
|
|
|
|
|
style={{ width: '240px', background: 'transparent', border: '1px solid var(--border-color)' }}
|
|
|
|
|
>
|
|
|
|
|
<option value="" disabled>Select Channel...</option>
|
|
|
|
|
{channels.map((c) => (
|
|
|
|
|
<option key={`${c.guildId}:${c.channelId}`} value={`${c.guildId}:${c.channelId}`}>
|
|
|
|
|
{c.guildName} • {c.channelName}
|
|
|
|
|
</option>
|
2025-08-09 19:52:45 +02:00
|
|
|
))}
|
2026-02-26 13:47:54 +01:00
|
|
|
</select>
|
|
|
|
|
</div>
|
2025-08-08 21:21:23 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
<div className="player-section center">
|
|
|
|
|
{/* Playback Controls */}
|
|
|
|
|
<button
|
|
|
|
|
className="btn-danger"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
setChaosMode(false); await stopChaosMode();
|
|
|
|
|
if (selected) { const [guildId] = selected.split(':'); try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } }
|
|
|
|
|
}}
|
|
|
|
|
title="Stop All"
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 20 }}>stop</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
className="btn-primary"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
const items = sounds;
|
|
|
|
|
if (!items.length || !selected) return;
|
|
|
|
|
const rnd = items[Math.floor(Math.random() * items.length)];
|
|
|
|
|
const [guildId, channelId] = selected.split(':');
|
|
|
|
|
await playSound(rnd.name, guildId, channelId, volume, rnd.relativePath);
|
|
|
|
|
} catch { }
|
|
|
|
|
}}
|
|
|
|
|
title="Play Random Sound"
|
|
|
|
|
style={{ padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 6 }}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 20 }}>shuffle</span> Shuffle
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
className={`btn-icon ${chaosMode ? 'btn-chaos' : ''}`}
|
|
|
|
|
onClick={toggleChaosMode}
|
|
|
|
|
title="Partymode (Auto-Play)"
|
|
|
|
|
style={chaosMode ? { width: 'auto', padding: '0 16px', borderRadius: 999, display: 'flex', gap: 6 } : {}}
|
|
|
|
|
>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 20 }}>{chaosMode ? 'celebration' : 'all_inclusive'}</span>
|
|
|
|
|
{chaosMode && <span>Party Active</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-08-08 13:14:27 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
<div className="player-section right">
|
|
|
|
|
{/* Volume */}
|
|
|
|
|
<div className="volume-container">
|
|
|
|
|
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_down</span>
|
|
|
|
|
<input
|
|
|
|
|
className="volume-slider"
|
|
|
|
|
type="range"
|
|
|
|
|
min={0} max={1} step={0.01}
|
|
|
|
|
value={volume}
|
|
|
|
|
onChange={async (e) => {
|
|
|
|
|
const v = parseFloat(e.target.value);
|
|
|
|
|
setVolume(v);
|
|
|
|
|
try { (e.target as HTMLInputElement).style.setProperty('--_fill', `${Math.round(v * 100)}%`); } catch { }
|
|
|
|
|
if (selected) { const [guildId] = selected.split(':'); try { await setVolumeLive(guildId, v); } catch { } }
|
|
|
|
|
}}
|
|
|
|
|
style={{ ['--_fill' as any]: `${Math.round(volume * 100)}%` }}
|
|
|
|
|
/>
|
|
|
|
|
<span className="material-icons" style={{ color: 'var(--text-secondary)', fontSize: 18 }}>volume_up</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-08-08 01:56:30 +02:00
|
|
|
|
2026-02-26 13:47:54 +01:00
|
|
|
{notification && (
|
|
|
|
|
<div className={`notification ${notification.type}`}>
|
|
|
|
|
<span className="material-icons" style={{ fontSize: 18 }}>
|
|
|
|
|
{notification.type === 'error' ? 'error_outline' : 'check_circle'}
|
|
|
|
|
</span>
|
|
|
|
|
{notification.msg}
|
2025-08-08 14:55:12 +02:00
|
|
|
</div>
|
2026-02-26 13:47:54 +01:00
|
|
|
)}
|
2025-08-08 14:55:12 +02:00
|
|
|
|
2025-08-08 15:01:53 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|