diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml deleted file mode 100644 index 0c9415a..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Build Docker Image - -on: - push: - branches: [main, nightly, feature/nightly] - -env: - REGISTRY: forgejo.adriahub.de - IMAGE: root/jukebox-vibe - -jobs: - build: - runs-on: ubuntu-latest - container: - image: docker:latest - volumes: - - /var/run/docker.sock:/var/run/docker.sock - steps: - - name: Checkout - run: | - apk add --no-cache git - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "http://root:${{ secrets.REGISTRY_PASSWORD }}@192.168.1.100:3000/${GITHUB_REPOSITORY}.git" . - - - name: Determine version and tag - id: vars - run: | - BRANCH="${GITHUB_REF_NAME}" - if [ "$BRANCH" = "main" ]; then - TAG="main"; VERSION="2.0.0"; CHANNEL="stable" - elif [ "$BRANCH" = "nightly" ] || [ "$BRANCH" = "feature/nightly" ]; then - TAG="nightly"; VERSION="2.0.0-nightly"; CHANNEL="nightly" - else - TAG=$(echo "$BRANCH" | sed 's/\//-/g'); VERSION="2.0.0-dev"; CHANNEL="dev" - fi - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" - - - name: Build and push - run: | - docker build \ - --build-arg "VITE_BUILD_CHANNEL=${{ steps.vars.outputs.channel }}" \ - --build-arg "VITE_APP_VERSION=${{ steps.vars.outputs.version }}" \ - -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ - . - if [ "${GITHUB_REF_NAME}" = "main" ]; then - docker tag ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} \ - ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest - fi - echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u root --password-stdin - docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} - if [ "${GITHUB_REF_NAME}" = "main" ]; then - docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest - fi - - - name: Cleanup build artifacts - if: always() - run: | - TAG="${{ steps.vars.outputs.tag }}" - docker rmi "${{ env.REGISTRY }}/${{ env.IMAGE }}:${TAG}" 2>/dev/null || true - docker rmi "${{ env.REGISTRY }}/${{ env.IMAGE }}:latest" 2>/dev/null || true - docker image prune -f 2>/dev/null || true - docker builder prune -f --keep-storage=4GB 2>/dev/null || true diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c5f06d3..7e25852 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,9 +2,10 @@ stages: - build variables: - INTERNAL_REGISTRY: "192.168.1.100:9080" - IMAGE_NAME: "$INTERNAL_REGISTRY/$CI_PROJECT_PATH" - CI_SERVER_URL: "http://192.168.1.100:9080" + REGISTRY_HOST: "10.10.10.10:5050" + IMAGE_NAME: "$REGISTRY_HOST/$CI_PROJECT_PATH" + # Force clone via IP instead of hostname to bypass Unraid Docker DNS issues + CI_SERVER_URL: "http://10.10.10.10:9080" GITLAB_FEATURES: "" docker-build: @@ -20,7 +21,7 @@ docker-build: cat > /kaniko/.docker/config.json < | remove` – set or remove your entrance sound +- `?exit | remove` – set or remove your exit sound + +### **Upload via DM** +- Send MP3/WAV files directly to the bot +- Files are stored under `/data/sounds` +- Immediately available in the frontend + +## 🐳 Docker deployment + +### **Docker Compose (Empfohlen)** +```yaml +# docker-compose.yml +services: + app: + build: . + container_name: discord-soundboard + ports: + - "8199:8080" + env_file: + - .env + volumes: + - ./data/sounds:/data/sounds + restart: unless-stopped +``` + +### **Docker Run** ```bash docker run -d \ - --name jukebox \ + --name jukebox-420 \ -p 8199:8080 \ - -e DISCORD_TOKEN=dein_token \ - -e ADMIN_PWD=dein_passwort \ - -v $(pwd)/sounds:/data/sounds \ - --restart unless-stopped \ - git.daddelolymp.de/root/jukebox-vibe:latest + --env-file .env \ + -v $(pwd)/data/sounds:/data/sounds \ + flex420/jukebox-vibe:latest ``` -## Konfiguration +### **Docker Hub** +```bash +# Image pullen +docker pull flex420/jukebox-vibe:latest -| Variable | Default | Beschreibung | -|---|---|---| -| `DISCORD_TOKEN` | *erforderlich* | Discord Bot Token | -| `ADMIN_PWD` | `''` | Admin-Passwort fuer Web-UI | -| `PORT` | `8080` | Server Port | -| `SOUNDS_DIR` | `/data/sounds` | Sound-Verzeichnis (Docker Volume) | -| `ALLOWED_GUILD_IDS` | `''` | Komma-getrennte Guild-IDs (leer = alle) | -| `NORMALIZE_ENABLE` | `true` | Loudness Normalization an/aus | -| `NORMALIZE_I` | `-16` | Ziel-Lautstaerke in LUFS | -| `NORMALIZE_LRA` | `11` | Loudness Range | -| `NORMALIZE_TP` | `-1.5` | True Peak in dBTP | -| `NORM_CONCURRENCY` | `2` | Parallele ffmpeg-Prozesse | - -## Bot Commands - -Alle Commands per DM an den Bot oder im Server-Chat: - -| Command | Beschreibung | -|---|---| -| `?help` | Hilfe anzeigen | -| `?list` | Alle Sounds auflisten | -| `?entrance ` | Entrance-Sound setzen | -| `?entrance remove` | Entrance-Sound entfernen | -| `?exit ` | Exit-Sound setzen | -| `?exit remove` | Exit-Sound entfernen | - -**Upload via DM:** MP3/WAV als Anhang an den Bot senden – wird automatisch gespeichert und normalisiert. - -## API - -### Public - -``` -GET /api/health Healthcheck + Stats -GET /api/sounds Sound-Liste (query: q, folder, categoryId, fuzzy) -GET /api/analytics Top 10 Most Played, Gesamt-Plays -GET /api/channels Verfuegbare Voice Channels -GET /api/selected-channels Gespeicherte Channel-Auswahl -POST /api/selected-channel Channel-Auswahl setzen -POST /api/play Sound abspielen (body: soundName, guildId, channelId) -POST /api/play-url MP3-URL downloaden und abspielen -POST /api/stop Playback stoppen -GET /api/volume Volume abfragen -POST /api/volume Volume setzen (0-1) -POST /api/party/start Party Mode starten -POST /api/party/stop Party Mode stoppen -GET /api/events SSE Stream (Real-Time Updates) +# Container starten +docker run -d --name jukebox-420 -p 8199:8080 --env-file .env -v $(pwd)/data/sounds:/data/sounds flex420/jukebox-vibe:latest ``` -### Admin (Cookie-Auth) +## 🔒 SSL/HTTPS Hinweis (wichtig für Discord) -``` -POST /api/admin/login Login (body: password) -POST /api/admin/logout Logout -GET /api/admin/status Auth-Status pruefen -POST /api/upload Dateien hochladen (multipart, max 20x50MB) -POST /api/admin/sounds/delete Sounds loeschen (body: paths[]) -POST /api/admin/sounds/rename Sound umbenennen -GET /api/categories Kategorien auflisten -POST /api/categories Kategorie erstellen -PATCH /api/categories/:id Kategorie bearbeiten -DELETE /api/categories/:id Kategorie loeschen -POST /api/categories/assign Kategorien zuweisen -POST /api/badges/assign Badges zuweisen -POST /api/badges/clear Badges entfernen -``` +- Das Web-Frontend MUSS hinter HTTPS (SSL) ausgeliefert werden. Empfohlen ist ein Domain‑Mapping (Reverse Proxy) mit gültigem Zertifikat (z. B. Traefik, Nginx, Caddy, Cloudflare). +- Hintergrund: Ohne TLS kann es zu Verschlüsselungs-/Encrypt‑Fehlern kommen, und Audio wird in Discord nicht korrekt wiedergegeben. +- Praxis: Richte eine Domain wie `https://soundboard.deinedomain.tld` auf das Frontend ein und aktiviere SSL (Let’s Encrypt). Danach sollten Uploads/Playback stabil funktionieren. -## Sound-Dateien - -``` -/data/sounds/ - state.json # Persistierter State (Volumes, Plays, Kategorien, etc.) - .norm-cache/ # Normalisierte PCM-Caches - airhorn.mp3 # Sounds im Root - memes/ # Ordner-Struktur (1 Ebene) - bruh.mp3 - oof.wav -``` - -- Formate: `.mp3`, `.wav` -- Ordner werden als Filter-Chips im Frontend angezeigt -- `.norm-cache/` wird automatisch verwaltet (Cache-Invalidierung bei Datei-Aenderung) - -## Loudness Normalization - -Alle Sounds werden per ffmpeg `loudnorm` (EBU R128) normalisiert: - -1. **Startup:** Hintergrund-Sync normalisiert alle uncached Sounds -2. **Cache Hit:** Gecachte PCM-Datei wird direkt gestreamt (kein ffmpeg, instant) -3. **Cache Miss:** ffmpeg streamt live zum Player UND schreibt gleichzeitig in Cache -4. **Upload:** Neue Dateien werden sofort im Hintergrund normalisiert - -## Voice Connection - -3-stufiges Recovery bei Verbindungsproblemen: - -1. **Wait for Ready** (15s Timeout) -2. **Rejoin** im selben Channel (15s Timeout) -3. **Destroy + Fresh Join** (15s Timeout) - -Lifecycle-Handler mit: -- Max 3 Reconnect-Versuche mit Exponential Backoff -- Anti-Reentrancy Guard gegen Endlosschleifen -- Automatisches State-Cleanup bei totalem Verbindungsverlust - -## Projektstruktur +## 📁 Project structure ``` jukebox-vibe/ - server/ - src/index.ts # Server + Discord Bot (Hauptdatei) - package.json - tsconfig.json - web/ - src/ - App.tsx # React SPA - styles.css # Themes + Layout - types.ts # TypeScript Types - package.json - vite.config.ts - Dockerfile # Multi-Stage Build (web + server + runtime) - docker-compose.yml - .gitlab-ci.yml # CI/CD Pipeline (Kaniko) - .env.example +├── server/ # Backend (Node.js/Express) +│ ├── src/ +│ │ ├── index.ts # Main Server + Discord Bot +│ │ └── types/ # TypeScript Definitions +│ ├── package.json +│ └── tsconfig.json +├── web/ # Frontend (React/Vite) +│ ├── src/ +│ │ ├── App.tsx # Main React Component +│ │ ├── api.ts # API Client +│ │ ├── styles.css # Theme Styles +│ │ └── types.ts # TypeScript Types +│ ├── package.json +│ └── index.html +├── docker-compose.yml # Docker Compose Config +├── Dockerfile # Multi-Stage Build +├── .env.example # Environment Template +└── README.md # Diese Datei ``` -## CI/CD +## 🔧 Development + +### **Lokale Entwicklung** +```bash +# Backend +cd server +npm install +npm run dev + +# Frontend (neues Terminal) +cd web +npm install +npm run dev +``` + +### **Build** +```bash +# Production Build +docker build -t jukebox-vibe . + +# Development Build +docker build --target development -t jukebox-vibe:dev . +``` + +## 📈 Stats + +### **Persistent data** +- Sounds: `/data/sounds/` (volume mount) +- State: `/data/sounds/state.json` (volume, channel, plays) +- Favorites: browser cookies +- Theme: browser localStorage + +### **Monitoring** +- Health check: `/api/health` +- Docker logs: `docker compose logs -f` +- Container status: `docker compose ps` + +## 🛠️ Troubleshooting + +### **Häufige Probleme** + +**Bot does not join the voice channel:** +- Check bot permissions (Connect, Speak, Request to Speak for Stage) +- Verify gateway intents in the Discord Developer Portal (GuildVoiceStates, DirectMessages, MessageContent) +- Check network/firewall + +**Sounds do not show up:** +- Verify volume mount `/data/sounds` +- Check file permissions +- Confirm uploads via DM + +**Admin login fails:** +- Check browser cookies +- Confirm admin password +- Inspect server logs + +### **Logs anzeigen** +```bash +# Docker Compose Logs +docker compose logs -f + +# Container-spezifische Logs +docker compose logs -f app + +# Letzte 100 Zeilen +docker compose logs --tail=100 app +``` + +## 🤝 Contributing + +1. **Fork** das Repository +2. **Feature Branch** erstellen (`git checkout -b feature/AmazingFeature`) +3. **Commit** Änderungen (`git commit -m 'Add AmazingFeature'`) +4. **Push** zum Branch (`git push origin feature/AmazingFeature`) +5. **Pull Request** erstellen + +## 📄 Lizenz + +Dieses Projekt ist unter der MIT Lizenz lizenziert - siehe [LICENSE](LICENSE) Datei für Details. + +## 🙏 Credits + +- **Discord.js** für Bot-Funktionalität +- **React** für das Frontend +- **Vite** für Build-Tooling +- **Docker** für Containerisierung +- **Tailwind CSS** für Styling + +--- + +**🎵 Jukebox 420** - Dein ultimatives Discord Soundboard! 🚀 -GitLab CI baut automatisch bei jedem Push: -| Branch | Image Tag | -|---|---| -| `main` | `:main`, `:latest`, `:sha` | -| `nightly` | `:nightly`, `:sha` | -| andere | `:`, `:sha` | -Registry: `git.daddelolymp.de/root/jukebox-vibe` -## Lizenz -MIT diff --git a/server/package.json b/server/package.json index b932289..e02373e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "discord-soundboard-server", - "version": "2.0.0", + "version": "1.1.1", "private": true, "type": "module", "main": "dist/index.js", @@ -10,24 +10,23 @@ "start": "node dist/index.js" }, "dependencies": { - "@discordjs/opus": "^0.10.0", - "@snazzah/davey": "^0.1.10", - "@discordjs/voice": "^0.19.0", + "@discordjs/opus": "^0.9.0", + "@discordjs/voice": "^0.18.0", "cors": "^2.8.5", - "discord.js": "^14.25.1", - "express": "^5.2.1", + "discord.js": "^14.16.3", + "express": "^4.19.2", "libsodium-wrappers": "^0.8.2", "multer": "^2.0.0", - "sodium-native": "^5.0.10", + "sodium-native": "^4.0.8", "tweetnacl": "^1.0.3", "ws": "^8.18.0" }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/express": "^5.0.6", - "@types/multer": "^2.0.0", - "@types/node": "^24.0.0", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", + "@types/node": "^20.12.12", "ts-node": "^10.9.2", - "typescript": "^5.9.3" + "typescript": "^5.5.4" } } diff --git a/server/src/index.ts b/server/src/index.ts index 6bc11b5..2016f8a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,7 +5,7 @@ import express, { Request, Response } from 'express'; import multer from 'multer'; import cors from 'cors'; import crypto from 'node:crypto'; -import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, type VoiceBasedChannel, VoiceState } from 'discord.js'; +import { Client, GatewayIntentBits, Partials, ChannelType, Events, type Message, VoiceState } from 'discord.js'; import { joinVoiceChannel, createAudioPlayer, @@ -22,8 +22,9 @@ import { } from '@discordjs/voice'; import sodium from 'libsodium-wrappers'; import nacl from 'tweetnacl'; +// Streaming externer Plattformen entfernt – nur MP3-URLs werden noch unterstützt import child_process from 'node:child_process'; -import { PassThrough, Readable } from 'node:stream'; +import { PassThrough } from 'node:stream'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -45,187 +46,6 @@ if (!DISCORD_TOKEN) { fs.mkdirSync(SOUNDS_DIR, { recursive: true }); -// ── yt-dlp URL detection ── -const YTDLP_HOSTS = [ - 'youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', - 'music.youtube.com', - 'instagram.com', 'www.instagram.com', -]; - -function isYtDlpUrl(url: string): boolean { - try { - const host = new URL(url).hostname.toLowerCase(); - return YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h)); - } catch { return false; } -} - -function isDirectMp3Url(url: string): boolean { - try { - return new URL(url).pathname.toLowerCase().endsWith('.mp3'); - } catch { return false; } -} - -function isSupportedUrl(url: string): boolean { - return isYtDlpUrl(url) || isDirectMp3Url(url); -} - -/** Download audio via yt-dlp → MP3 file in SOUNDS_DIR */ -function downloadWithYtDlp(url: string): Promise<{ filename: string; filepath: string }> { - return new Promise((resolve, reject) => { - const outputTemplate = path.join(SOUNDS_DIR, '%(title)s.%(ext)s'); - const args = [ - '-x', // extract audio only - '--audio-format', 'mp3', // convert to MP3 - '--audio-quality', '0', // best quality - '-o', outputTemplate, // output path template - '--no-playlist', // single video only - '--no-overwrites', // don't overwrite existing - '--restrict-filenames', // safe filenames (ASCII, no spaces) - '--max-filesize', '50m', // same limit as file upload - '--socket-timeout', '30', // timeout for slow connections - '--verbose', // verbose output for logging - url, - ]; - - const startTime = Date.now(); - console.log(`[Jukebox] [yt-dlp] ▶ START url=${url}`); - console.log(`[Jukebox] [yt-dlp] args: yt-dlp ${args.join(' ')}`); - - const proc = child_process.spawn('yt-dlp', args, { stdio: ['ignore', 'pipe', 'pipe'] }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (d: Buffer) => { - const line = d.toString(); - stdout += line; - for (const l of line.split('\n').filter((s: string) => s.trim())) { - console.log(`[Jukebox] [yt-dlp:out] ${l.trim()}`); - } - }); - proc.stderr?.on('data', (d: Buffer) => { - const line = d.toString(); - stderr += line; - for (const l of line.split('\n').filter((s: string) => s.trim())) { - console.error(`[Jukebox] [yt-dlp:err] ${l.trim()}`); - } - }); - - proc.on('error', (err) => { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [yt-dlp] ✗ SPAWN ERROR after ${elapsed}s: ${err.message}`); - reject(new Error('yt-dlp nicht verfügbar')); - }); - - proc.on('close', (code) => { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - if (code !== 0) { - console.error(`[Jukebox] [yt-dlp] ✗ FAILED exit=${code} after ${elapsed}s`); - console.error(`[Jukebox] [yt-dlp] stderr (last 1000 chars): ${stderr.slice(-1000)}`); - console.error(`[Jukebox] [yt-dlp] stdout (last 500 chars): ${stdout.slice(-500)}`); - - if (stderr.includes('Video unavailable') || stderr.includes('is not available')) - reject(new Error('Video nicht verfügbar')); - else if (stderr.includes('Private video')) - reject(new Error('Privates Video')); - else if (stderr.includes('Sign in') || stderr.includes('login')) - reject(new Error('Login erforderlich')); - else if (stderr.includes('exceeds maximum')) - reject(new Error('Datei zu groß (max 50 MB)')); - else if (stderr.includes('Unsupported URL')) - reject(new Error('URL nicht unterstützt')); - else if (stderr.includes('HTTP Error 404')) - reject(new Error('Video nicht gefunden (404)')); - else if (stderr.includes('HTTP Error 403')) - reject(new Error('Zugriff verweigert (403)')); - else - reject(new Error(`yt-dlp Fehler (exit ${code})`)); - return; - } - - console.log(`[Jukebox] [yt-dlp] ✓ DONE exit=0 after ${elapsed}s`); - - const destMatch = stdout.match(/\[ExtractAudio\] Destination: (.+\.mp3)/i) - ?? stdout.match(/\[download\] (.+\.mp3) has already been downloaded/i) - ?? stdout.match(/Destination: (.+\.mp3)/i); - - if (destMatch) { - const filepath = destMatch[1].trim(); - const filename = path.basename(filepath); - console.log(`[Jukebox] [yt-dlp] saved: ${filename} (regex match)`); - resolve({ filename, filepath }); - return; - } - - // Fallback: scan SOUNDS_DIR for newest MP3 (within last 60s) - const now = Date.now(); - const mp3s = fs.readdirSync(SOUNDS_DIR) - .filter(f => f.endsWith('.mp3')) - .map(f => ({ name: f, mtime: fs.statSync(path.join(SOUNDS_DIR, f)).mtimeMs })) - .filter(f => now - f.mtime < 60000) - .sort((a, b) => b.mtime - a.mtime); - - if (mp3s.length > 0) { - const filename = mp3s[0].name; - console.log(`[Jukebox] [yt-dlp] saved: ${filename} (fallback scan, ${mp3s.length} recent files)`); - resolve({ filename, filepath: path.join(SOUNDS_DIR, filename) }); - return; - } - - console.error(`[Jukebox] [yt-dlp] ✗ OUTPUT FILE NOT FOUND`); - console.error(`[Jukebox] [yt-dlp] full stdout:\n${stdout}`); - console.error(`[Jukebox] [yt-dlp] full stderr:\n${stderr}`); - reject(new Error('Download abgeschlossen, aber Datei nicht gefunden')); - }); - }); -} - -/** Shared download logic for play-url and download-url */ -async function handleUrlDownload(url: string, customFilename?: string): Promise<{ savedFile: string; savedPath: string }> { - let savedFile: string; - let savedPath: string; - - if (isYtDlpUrl(url)) { - console.log(`[Jukebox] [url-dl] → yt-dlp...`); - const result = await downloadWithYtDlp(url); - savedFile = result.filename; - savedPath = result.filepath; - } else { - const parsed = new URL(url); - savedFile = path.basename(parsed.pathname); - savedPath = path.join(SOUNDS_DIR, savedFile); - console.log(`[Jukebox] [url-dl] → direct MP3: ${savedFile}`); - const r = await fetch(url); - if (!r.ok) throw new Error(`Download fehlgeschlagen (HTTP ${r.status})`); - const buf = Buffer.from(await r.arrayBuffer()); - fs.writeFileSync(savedPath, buf); - console.log(`[Jukebox] [url-dl] saved ${(buf.length / 1024).toFixed(0)} KB → ${savedFile}`); - } - - // Rename if custom filename provided - if (customFilename) { - const safeName = customFilename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim(); - if (safeName) { - const ext = path.extname(savedFile).toLowerCase() || '.mp3'; - const newName = safeName.endsWith(ext) ? safeName : safeName + ext; - const newPath = path.join(SOUNDS_DIR, newName); - if (newPath !== savedPath && !fs.existsSync(newPath)) { - fs.renameSync(savedPath, newPath); - console.log(`[Jukebox] [url-dl] renamed: ${savedFile} → ${newName}`); - savedFile = newName; - savedPath = newPath; - } - } - } - - if (NORMALIZE_ENABLE) { - try { await normalizeToCache(savedPath); console.log(`[Jukebox] [url-dl] normalized`); } - catch (e: any) { console.error(`[Jukebox] [url-dl] normalize failed: ${e?.message}`); } - } - - return { savedFile, savedPath }; -} - // Persistenter Zustand: Lautstärke/Plays + Kategorien type Category = { id: string; name: string; color?: string; sort?: number }; type PersistedState = { @@ -338,36 +158,6 @@ const NORMALIZE_TP = String(process.env.NORMALIZE_TP ?? '-1.5'); const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache'); fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); -// In-Memory PCM Cache: gecachte PCM-Dateien werden beim ersten Abspielen in den RAM geladen. -// Danach kein Disk-I/O mehr bei Cache-Hits -> nahezu instant playback. -const pcmMemoryCache = new Map(); -const PCM_MEMORY_CACHE_MAX_MB = Number(process.env.PCM_CACHE_MAX_MB ?? '512'); -const PCM_PER_FILE_MAX_MB = 50; // Max 50 MB pro Datei (~4.5 min PCM) - groessere werden gestreamt -let pcmMemoryCacheBytes = 0; - -function getPcmFromMemory(cachedPath: string): Buffer | null { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) return buf; - try { - // Dateigroesse VORHER pruefen - keine grossen Files in den RAM laden - const stat = fs.statSync(cachedPath); - if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null; - if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null; - const data = fs.readFileSync(cachedPath); - pcmMemoryCache.set(cachedPath, data); - pcmMemoryCacheBytes += data.byteLength; - return data; - } catch { return null; } -} - -function invalidatePcmMemory(cachedPath: string): void { - const buf = pcmMemoryCache.get(cachedPath); - if (buf) { - pcmMemoryCacheBytes -= buf.byteLength; - pcmMemoryCache.delete(cachedPath); - } -} - /** Erzeugt einen Cache-Key aus dem relativen Pfad (Slash-normalisiert, Sonderzeichen escaped) */ function normCacheKey(filePath: string): string { const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/'); @@ -382,7 +172,7 @@ function getNormCachePath(filePath: string): string | null { try { const srcMtime = fs.statSync(filePath).mtimeMs; const cacheMtime = fs.statSync(cacheFile).mtimeMs; - if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; } + if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} return null; } } catch { return null; } return cacheFile; } @@ -501,8 +291,6 @@ const partyTimers = new Map(); const partyActive = new Set(); // Now-Playing: aktuell gespielter Sound pro Guild const nowPlaying = new Map(); -// Verbindungszeitpunkt pro Guild (fuer Uptime-Anzeige im Frontend) -const connectedSince = new Map(); // SSE-Klienten für Broadcasts (z.B. Partymode Status) const sseClients = new Set(); function sseBroadcast(payload: any) { @@ -593,19 +381,9 @@ async function playFilePath(guildId: string, channelId: string, filePath: string if (NORMALIZE_ENABLE) { const cachedPath = getNormCachePath(filePath); if (cachedPath) { - // Cache-Hit: PCM aus RAM lesen (kein Disk-I/O, instant) - const pcmBuf = getPcmFromMemory(cachedPath); - if (pcmBuf) { - const useInline = useVolume !== 1; - resource = createAudioResource(Readable.from(pcmBuf), { - inlineVolume: useInline, - inputType: StreamType.Raw - }); - } else { - // Fallback: Stream von Disk - const pcmStream = fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }); - resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw }); - } + // Cache-Hit: gecachte PCM-Datei als Stream lesen (kein ffmpeg, instant) + const pcmStream = fs.createReadStream(cachedPath); + resource = createAudioResource(pcmStream, { inlineVolume: true, inputType: StreamType.Raw }); } else { // Cache-Miss: ffmpeg streamen (sofortige Wiedergabe) UND gleichzeitig in Cache schreiben const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); @@ -624,14 +402,6 @@ async function playFilePath(guildId: string, channelId: string, filePath: string playerStream.end(); cacheWrite.end(); console.log(`${new Date().toISOString()} | Loudnorm cached: ${path.basename(filePath)}`); - // In Memory-Cache laden fuer naechsten Aufruf - try { - const buf = fs.readFileSync(cacheFile); - if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { - pcmMemoryCache.set(cacheFile, buf); - pcmMemoryCacheBytes += buf.byteLength; - } - } catch {} }); ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} }); ff.on('close', (code) => { @@ -748,29 +518,26 @@ async function handleCommand(message: Message, content: string) { } async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { - // Versuch 1: Warten ob bestehende Connection ready wird try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${new Date().toISOString()} | VoiceConnection ready`); return connection; } catch (e) { - console.warn(`${new Date().toISOString()} | VoiceConnection not ready, trying rejoin...`); + console.warn(`${new Date().toISOString()} | VoiceConnection not ready, trying rejoin...`, e); } - // Versuch 2: Rejoin try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); console.log(`${new Date().toISOString()} | VoiceConnection ready after rejoin`); return connection; } catch (e2) { - console.warn(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`); + console.error(`${new Date().toISOString()} | VoiceConnection still not ready after rejoin`, e2); } - // Versuch 3: Komplett neu verbinden - try { connection.destroy(); } catch {} - guildAudioState.delete(guildId); - + try { + connection.destroy(); + } catch {} const newConn = joinVoiceChannel({ channelId, guildId, @@ -778,16 +545,10 @@ async function ensureConnectionReady(connection: VoiceConnection, channelId: str selfMute: false, selfDeaf: false }); - try { - await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); - console.log(`${new Date().toISOString()} | VoiceConnection ready after fresh join`); - return newConn; - } catch (e3) { - console.error(`${new Date().toISOString()} | VoiceConnection failed after all attempts, cleaning up`); - try { newConn.destroy(); } catch {} - guildAudioState.delete(guildId); - throw new Error('Voice connection failed after 3 attempts'); - } + await entersState(newConn, VoiceConnectionStatus.Ready, 15_000).catch((e3) => { + console.error(`${new Date().toISOString()} | VoiceConnection not ready after fresh join`, e3); + }); + return newConn; } function attachVoiceLifecycle(state: GuildAudioState, guild: any) { @@ -795,26 +556,8 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { // Mehrfach-Registrierung verhindern if ((connection as any).__lifecycleAttached) return; try { (connection as any).setMaxListeners?.(0); } catch {} - - // Retry-Tracking um Endlosschleife zu verhindern - let reconnectAttempts = 0; - const MAX_RECONNECT_ATTEMPTS = 3; - let isReconnecting = false; - connection.on('stateChange', async (oldS: any, newS: any) => { console.log(`${new Date().toISOString()} | VoiceConnection: ${oldS.status} -> ${newS.status}`); - - // Ready zurückgesetzt -> Retry-Counter reset - if (newS.status === VoiceConnectionStatus.Ready) { - reconnectAttempts = 0; - isReconnecting = false; - if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); - return; - } - - // Re-Entranz verhindern: wenn schon ein Reconnect läuft, nicht nochmal starten - if (isReconnecting) return; - try { if (newS.status === VoiceConnectionStatus.Disconnected) { // Versuche, die Verbindung kurzfristig neu auszuhandeln, sonst rejoin @@ -824,28 +567,9 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { entersState(connection, VoiceConnectionStatus.Connecting, 5_000) ]); } catch { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - reconnectAttempts++; - console.warn(`${new Date().toISOString()} | Disconnected rejoin attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached from Disconnected, destroying and rejoining fresh`); - reconnectAttempts = 0; - try { connection.destroy(); } catch {} - const newConn = joinVoiceChannel({ - channelId: state.channelId, - guildId: state.guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - state.connection = newConn; - newConn.subscribe(state.player); - attachVoiceLifecycle(state, guild); - } + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } } else if (newS.status === VoiceConnectionStatus.Destroyed) { - connectedSince.delete(state.guildId); // Komplett neu beitreten const newConn = joinVoiceChannel({ channelId: state.channelId, @@ -858,38 +582,14 @@ function attachVoiceLifecycle(state: GuildAudioState, guild: any) { newConn.subscribe(state.player); attachVoiceLifecycle(state, guild); } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) { - isReconnecting = true; try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); - // Ready wird oben im Handler behandelt } catch (e) { - reconnectAttempts++; - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { - const backoffMs = reconnectAttempts * 2_000; - console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, backoff ${backoffMs}ms`); - await new Promise(r => setTimeout(r, backoffMs)); - isReconnecting = false; - connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); - } else { - console.error(`${new Date().toISOString()} | Max reconnect attempts reached, destroying and rejoining fresh`); - reconnectAttempts = 0; - isReconnecting = false; - try { connection.destroy(); } catch {} - const newConn = joinVoiceChannel({ - channelId: state.channelId, - guildId: state.guildId, - adapterCreator: guild.voiceAdapterCreator as any, - selfMute: false, - selfDeaf: false - }); - state.connection = newConn; - newConn.subscribe(state.player); - attachVoiceLifecycle(state, guild); - } + console.warn(`${new Date().toISOString()} | Voice not ready from ${newS.status}, rejoin`, e); + connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } } } catch (e) { - isReconnecting = false; console.error(`${new Date().toISOString()} | Voice lifecycle handler error`, e); } }); @@ -1471,17 +1171,14 @@ app.get('/api/channels', (_req: Request, res: Response) => { if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); const allowed = new Set(ALLOWED_GUILD_IDS); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; members: number; selected?: boolean }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; for (const [, guild] of client.guilds.cache) { if (allowed.size > 0 && !allowed.has(guild.id)) continue; const channels = guild.channels.cache; for (const [, ch] of channels) { if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { const sel = getSelectedChannelForGuild(guild.id); - const members = ('members' in ch) - ? (ch as VoiceBasedChannel).members.filter(m => !m.user.bot).size - : 0; - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, members, selected: sel === ch.id }); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); } } } @@ -1708,22 +1405,7 @@ app.get('/api/events', (req: Request, res: Response) => { // Snapshot senden try { - const statsSnap: Record = {}; - for (const [gId, st] of guildAudioState) { - const status = st.connection.state?.status ?? 'unknown'; - if (status === 'ready' && !connectedSince.has(gId)) { - connectedSince.set(gId, new Date().toISOString()); - } - const ch = client.channels.cache.get(st.channelId); - statsSnap[gId] = { - voicePing: (st.connection.ping as any)?.ws ?? null, - gatewayPing: client.ws.ping, - status, - channelName: ch && 'name' in ch ? (ch as any).name : null, - connectedSince: connectedSince.get(gId) ?? null, - }; - } - res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying), voicestats: statsSnap })}\n\n`); + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`); } catch {} // Ping, damit Proxies die Verbindung offen halten @@ -1737,105 +1419,59 @@ app.get('/api/events', (req: Request, res: Response) => { }); }); -// --- Medien-URL abspielen (YouTube / Instagram / MP3) --- +// --- Medien-URL abspielen --- +// Unterstützt: direkte MP3-URL (Download und Ablage) app.post('/api/play-url', async (req: Request, res: Response) => { - const startTime = Date.now(); try { - const { url, guildId, channelId, volume, filename } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number; filename?: string }; - const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`[Jukebox] [play-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'} guild=${guildId}`); - + const { url, guildId, channelId, volume } = req.body as { url?: string; guildId?: string; channelId?: string; volume?: number }; if (!url || !guildId || !channelId) return res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); - try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } - if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); - const { savedFile, savedPath } = await handleUrlDownload(url, filename); - - try { await playFilePath(guildId, channelId, savedPath, volume, savedFile); console.log(`[Jukebox] [play-url] playing`); } - catch (e: any) { console.error(`[Jukebox] [play-url] play failed (file saved): ${e?.message}`); } - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`[Jukebox] [play-url] ✓ DONE in ${elapsed}s → ${savedFile}`); - return res.json({ ok: true, saved: savedFile }); + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: 'Ungültige URL' }); + } + const pathname = parsed.pathname.toLowerCase(); + if (!pathname.endsWith('.mp3')) { + return res.status(400).json({ error: 'Nur direkte MP3-Links werden unterstützt.' }); + } + const fileName = path.basename(parsed.pathname); + const dest = path.join(SOUNDS_DIR, fileName); + const r = await fetch(url); + if (!r.ok) return res.status(400).json({ error: 'Download fehlgeschlagen' }); + const buf = Buffer.from(await r.arrayBuffer()); + fs.writeFileSync(dest, buf); + // Vor dem Abspielen normalisieren → sofort aus Cache + if (NORMALIZE_ENABLE) { + try { await normalizeToCache(dest); } catch (e) { console.warn('Norm after URL import failed:', e); } + } + try { + await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); + } catch { + return res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); + } + return res.json({ ok: true, saved: path.basename(dest) }); } catch (e: any) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [play-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); + console.error('play-url error:', e); return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); } }); -// --- URL nur herunterladen (ohne Abspielen) --- -app.post('/api/download-url', requireAdmin, async (req: Request, res: Response) => { - const startTime = Date.now(); - try { - const { url, filename } = req.body as { url?: string; filename?: string }; - const urlType = isYtDlpUrl(url || '') ? 'yt-dlp' : isDirectMp3Url(url || '') ? 'mp3' : 'unknown'; - console.log(`[Jukebox] [download-url] ▶ type=${urlType} url=${url} filename=${filename || '(auto)'}`); - - if (!url) return res.status(400).json({ error: 'URL erforderlich' }); - try { new URL(url); } catch { return res.status(400).json({ error: 'Ungültige URL' }); } - if (!isSupportedUrl(url)) return res.status(400).json({ error: 'Nur YouTube, Instagram oder direkte MP3-Links' }); - - const { savedFile } = await handleUrlDownload(url, filename); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log(`[Jukebox] [download-url] ✓ DONE in ${elapsed}s → ${savedFile}`); - return res.json({ ok: true, saved: savedFile }); - } catch (e: any) { - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`[Jukebox] [download-url] ✗ ERROR after ${elapsed}s: ${e?.message ?? e}`); - return res.status(500).json({ error: e?.message ?? 'Fehler' }); - } -}); - // Static Frontend ausliefern (Vite build) const webDistPath = path.resolve(__dirname, '../../web/dist'); if (fs.existsSync(webDistPath)) { app.use(express.static(webDistPath)); - app.get('/{*splat}', (_req, res) => { + app.get('*', (_req, res) => { res.sendFile(path.join(webDistPath, 'index.html')); }); } -// Node 24 warnt bei negativen Timeout-Werten (aus @discordjs/voice intern) - harmlos unterdruecken -process.on('warning', (warning) => { - if (warning.name === 'TimeoutNegativeWarning') return; - console.warn(warning.name + ': ' + warning.message); -}); -process.on('uncaughtException', (err) => { - console.error(`FATAL uncaughtException:`, err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error(`FATAL unhandledRejection:`, reason); -}); - app.listen(PORT, () => { console.log(`Server läuft auf http://0.0.0.0:${PORT}`); // Vollständige Cache-Synchronisation beim Start (Hintergrund) syncNormCache(); - - // Voice-Stats alle 5 Sekunden an alle SSE-Clients broadcasten - setInterval(() => { - if (sseClients.size === 0 || guildAudioState.size === 0) return; - for (const [gId, st] of guildAudioState) { - const status = st.connection.state?.status ?? 'unknown'; - if (status === 'ready' && !connectedSince.has(gId)) { - connectedSince.set(gId, new Date().toISOString()); - } - const ch = client.channels.cache.get(st.channelId); - sseBroadcast({ - type: 'voicestats', - guildId: gId, - voicePing: (st.connection.ping as any)?.ws ?? null, - gatewayPing: client.ws.ping, - status, - channelName: ch && 'name' in ch ? (ch as any).name : null, - connectedSince: connectedSince.get(gId) ?? null, - }); - } - }, 5_000); }); diff --git a/web/index.html b/web/index.html index 309b1b6..34f6580 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ - Jukebox420 + Jukebox diff --git a/web/package.json b/web/package.json index 175d661..14fba7c 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "discord-soundboard-web", "private": true, - "version": "2.0.0", + "version": "1.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/web/src/App.tsx b/web/src/App.tsx index f67f287..fb48183 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,1421 +1,1167 @@ -import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { - fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, downloadUrl, setVolumeLive, getVolume, - adminStatus, adminLogin, adminLogout, adminDelete, adminRename, - fetchCategories, partyStart, partyStop, subscribeEvents, - getSelectedChannels, setSelectedChannel, uploadFile, -} from './api'; -import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; -import { getCookie, setCookie } from './cookies'; - -const THEMES = [ - { id: 'default', color: '#5865f2', label: 'Discord' }, - { id: 'purple', color: '#9b59b6', label: 'Midnight' }, - { id: 'forest', color: '#2ecc71', label: 'Forest' }, - { id: 'sunset', color: '#e67e22', label: 'Sunset' }, - { id: 'ocean', color: '#3498db', label: 'Ocean' }, -]; - -const CAT_PALETTE = [ - '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', - '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', - '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', -]; - -type Tab = 'all' | 'favorites' | 'recent'; - -type UploadItem = { - id: string; - file: File; - status: 'waiting' | 'uploading' | 'done' | 'error'; - progress: number; - savedName?: string; - error?: string; -}; - -export default function App() { - /* ── Data ── */ - const [sounds, setSounds] = useState([]); - const [total, setTotal] = useState(0); - const [folders, setFolders] = useState>([]); - const [categories, setCategories] = useState([]); - const [analytics, setAnalytics] = useState({ - totalSounds: 0, - totalPlays: 0, - mostPlayed: [], - }); - - /* ── Navigation ── */ - const [activeTab, setActiveTab] = useState('all'); - const [activeFolder, setActiveFolder] = useState(''); - const [query, setQuery] = useState(''); - const [importUrl, setImportUrl] = useState(''); - const [importBusy, setImportBusy] = useState(false); - - // Download modal state - const [dlModal, setDlModal] = useState<{ - url: string; type: 'youtube' | 'instagram' | 'mp3' | null; - filename: string; phase: 'input' | 'downloading' | 'done' | 'error'; - savedName?: string; error?: string; - } | null>(null); - - /* ── Channels ── */ - const [channels, setChannels] = useState([]); - const [selected, setSelected] = useState(''); - const selectedRef = useRef(''); - const [channelOpen, setChannelOpen] = useState(false); - - /* ── Playback ── */ - const [volume, setVolume] = useState(1); - const [lastPlayed, setLastPlayed] = useState(''); - - /* ── Preferences ── */ - const [favs, setFavs] = useState>({}); - const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); - const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); - - /* ── Party ── */ - const [chaosMode, setChaosMode] = useState(false); - const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); - const chaosModeRef = useRef(false); - const volDebounceRef = useRef>(); - - /* ── Admin ── */ - const [isAdmin, setIsAdmin] = useState(false); - const [showAdmin, setShowAdmin] = useState(false); - const [adminPwd, setAdminPwd] = useState(''); - const [adminSounds, setAdminSounds] = useState([]); - const [adminLoading, setAdminLoading] = useState(false); - const [adminQuery, setAdminQuery] = useState(''); - const [adminSelection, setAdminSelection] = useState>({}); - const [renameTarget, setRenameTarget] = useState(''); - const [renameValue, setRenameValue] = useState(''); - - /* ── Drag & Drop Upload ── */ - const [isDragging, setIsDragging] = useState(false); - const [uploads, setUploads] = useState([]); - const [showUploads, setShowUploads] = useState(false); - const dragCounterRef = useRef(0); - const uploadDismissRef = useRef>(); - - /* ── Voice Stats ── */ - interface VoiceStats { - voicePing: number | null; - gatewayPing: number; - status: string; - channelName: string | null; - connectedSince: string | null; - } - const [voiceStats, setVoiceStats] = useState(null); - const [showConnModal, setShowConnModal] = useState(false); - - /* ── UI ── */ - const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); - const [clock, setClock] = useState(''); - const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); - const [refreshKey, setRefreshKey] = useState(0); - - /* ── Refs ── */ - useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); - useEffect(() => { selectedRef.current = selected; }, [selected]); - - /* ── Drag & Drop: globale Window-Listener ── */ - useEffect(() => { - const onDragEnter = (e: DragEvent) => { - if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { - dragCounterRef.current++; - setIsDragging(true); - } - }; - const onDragLeave = () => { - dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); - if (dragCounterRef.current === 0) setIsDragging(false); - }; - const onDragOver = (e: DragEvent) => e.preventDefault(); - const onDrop = (e: DragEvent) => { - e.preventDefault(); - dragCounterRef.current = 0; - setIsDragging(false); - const files = Array.from(e.dataTransfer?.files ?? []).filter(f => - /\.(mp3|wav)$/i.test(f.name) - ); - if (files.length) handleFileDrop(files); - }; - window.addEventListener('dragenter', onDragEnter); - window.addEventListener('dragleave', onDragLeave); - window.addEventListener('dragover', onDragOver); - window.addEventListener('drop', onDrop); - return () => { - window.removeEventListener('dragenter', onDragEnter); - window.removeEventListener('dragleave', onDragLeave); - window.removeEventListener('dragover', onDragOver); - window.removeEventListener('drop', onDrop); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAdmin]); - - /* ── Helpers ── */ - const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { - setNotification({ msg, type }); - setTimeout(() => setNotification(null), 3000); - }, []); - const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); - const YTDLP_HOSTS = ['youtube.com', 'www.youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com', 'instagram.com', 'www.instagram.com']; - /** Auto-prepend https:// if missing */ - const normalizeUrl = useCallback((value: string): string => { - const v = value.trim(); - if (!v) return v; - if (/^https?:\/\//i.test(v)) return v; - return 'https://' + v; - }, []); - const isSupportedUrl = useCallback((value: string) => { - try { - const parsed = new URL(normalizeUrl(value)); - const host = parsed.hostname.toLowerCase(); - if (parsed.pathname.toLowerCase().endsWith('.mp3')) return true; - if (YTDLP_HOSTS.some(h => host === h || host.endsWith('.' + h))) return true; - return false; - } catch { - return false; - } - }, [normalizeUrl]); - const getUrlType = useCallback((value: string): 'youtube' | 'instagram' | 'mp3' | null => { - try { - const parsed = new URL(normalizeUrl(value)); - const host = parsed.hostname.toLowerCase(); - if (host.includes('youtube') || host === 'youtu.be') return 'youtube'; - if (host.includes('instagram')) return 'instagram'; - if (parsed.pathname.toLowerCase().endsWith('.mp3')) return 'mp3'; - return null; - } catch { return null; } - }, [normalizeUrl]); - - const guildId = selected ? selected.split(':')[0] : ''; - const channelId = selected ? selected.split(':')[1] : ''; - - const selectedChannel = useMemo(() => - channels.find(c => `${c.guildId}:${c.channelId}` === selected), - [channels, selected]); - - /* ── Clock ── */ - useEffect(() => { - const update = () => { - const now = new Date(); - const h = String(now.getHours()).padStart(2, '0'); - const m = String(now.getMinutes()).padStart(2, '0'); - const s = String(now.getSeconds()).padStart(2, '0'); - setClock(`${h}:${m}:${s}`); - }; - update(); - const id = setInterval(update, 1000); - return () => clearInterval(id); - }, []); - - /* ── Init ── */ - useEffect(() => { - (async () => { - try { - const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); - setChannels(ch); - if (ch.length) { - const g = ch[0].guildId; - const serverCid = selMap[g]; - const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); - setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); - } - } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } - try { setIsAdmin(await adminStatus()); } catch { } - try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } - })(); - }, []); - - /* ── Theme ── */ - useEffect(() => { - if (theme === 'default') document.body.removeAttribute('data-theme'); - else document.body.setAttribute('data-theme', theme); - localStorage.setItem('jb-theme', theme); - }, [theme]); - - /* ── Card size ── */ - useEffect(() => { - const r = document.documentElement; - r.style.setProperty('--card-size', cardSize + 'px'); - const ratio = cardSize / 110; - r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); - r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); - localStorage.setItem('jb-card-size', String(cardSize)); - }, [cardSize]); - - /* ── SSE ── */ - 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 : []); - try { - const sel = msg?.selected || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && sel[g]) setSelected(`${g}:${sel[g]}`); - } catch { } - try { - const vols = msg?.volumes || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && typeof vols[g] === 'number') setVolume(vols[g]); - } catch { } - try { - const np = msg?.nowplaying || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && typeof np[g] === 'string') setLastPlayed(np[g]); - } catch { } - try { - const vs = msg?.voicestats || {}; - const g = selectedRef.current?.split(':')[0]; - if (g && vs[g]) setVoiceStats(vs[g]); - } catch { } - } else if (msg?.type === 'channel') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); - } else if (msg?.type === 'volume') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); - } else if (msg?.type === 'nowplaying') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g) setLastPlayed(msg.name || ''); - } else if (msg?.type === 'voicestats') { - const g = selectedRef.current?.split(':')[0]; - if (msg.guildId === g) { - setVoiceStats({ - voicePing: msg.voicePing, - gatewayPing: msg.gatewayPing, - status: msg.status, - channelName: msg.channelName, - connectedSince: msg.connectedSince, - }); - } - } - }); - return () => { try { unsub(); } catch { } }; - }, []); - - useEffect(() => { - setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); - }, [selected, partyActiveGuilds]); - - /* ── Data Fetch ── */ - useEffect(() => { - (async () => { - try { - let folderParam = '__all__'; - if (activeTab === 'recent') folderParam = '__recent__'; - else if (activeFolder) folderParam = activeFolder; - const s = await fetchSounds(query, folderParam, undefined, false); - setSounds(s.items); - setTotal(s.total); - setFolders(s.folders); - } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } - })(); - }, [activeTab, activeFolder, query, refreshKey]); - - useEffect(() => { - void loadAnalytics(); - }, [refreshKey]); - - /* ── Favs persistence ── */ - useEffect(() => { - const c = getCookie('favs'); - if (c) try { setFavs(JSON.parse(c)); } catch { } - }, []); - - useEffect(() => { - try { setCookie('favs', JSON.stringify(favs)); } catch { } - }, [favs]); - - /* ── Volume sync ── */ - useEffect(() => { - if (selected) { - (async () => { - try { const v = await getVolume(guildId); setVolume(v); } catch { } - })(); - } - }, [selected]); - - /* ── Close dropdowns on outside click ── */ - useEffect(() => { - const handler = () => { setChannelOpen(false); setCtxMenu(null); }; - document.addEventListener('click', handler); - return () => document.removeEventListener('click', handler); - }, []); - - useEffect(() => { - if (showAdmin && isAdmin) { - void loadAdminSounds(); - } - }, [showAdmin, isAdmin]); - - /* ── Actions ── */ - async function loadAnalytics() { - try { - const data = await fetchAnalytics(); - setAnalytics(data); - } catch { } - } - - async function handlePlay(s: Sound) { - if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); - try { - await playSound(s.name, guildId, channelId, volume, s.relativePath); - setLastPlayed(s.name); - void loadAnalytics(); - } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } - } - - // Open download modal instead of downloading directly - function handleUrlImport() { - const trimmed = normalizeUrl(importUrl); - if (!trimmed) return notify('Bitte einen Link eingeben', 'error'); - if (!isSupportedUrl(trimmed)) return notify('Nur YouTube, Instagram oder direkte MP3-Links', 'error'); - const urlType = getUrlType(trimmed); - // Pre-fill filename for MP3 links (basename without .mp3), empty for YT/IG - let defaultName = ''; - if (urlType === 'mp3') { - try { defaultName = new URL(trimmed).pathname.split('/').pop()?.replace(/\.mp3$/i, '') ?? ''; } catch {} - } - setDlModal({ url: trimmed, type: urlType, filename: defaultName, phase: 'input' }); - } - - // Actual download triggered from modal - async function handleModalDownload() { - if (!dlModal) return; - setDlModal(prev => prev ? { ...prev, phase: 'downloading' } : null); - try { - let savedName: string | undefined; - const fn = dlModal.filename.trim() || undefined; - if (selected && guildId && channelId) { - const result = await playUrl(dlModal.url, guildId, channelId, volume, fn); - savedName = result.saved; - } else { - const result = await downloadUrl(dlModal.url, fn); - savedName = result.saved; - } - setDlModal(prev => prev ? { ...prev, phase: 'done', savedName } : null); - setImportUrl(''); - setRefreshKey(k => k + 1); - void loadAnalytics(); - // Auto-close after 2.5s - setTimeout(() => setDlModal(null), 2500); - } catch (e: any) { - setDlModal(prev => prev ? { ...prev, phase: 'error', error: e?.message || 'Fehler' } : null); - } - } - - async function handleFileDrop(files: File[]) { - if (!isAdmin) { - notify('Admin-Login erforderlich zum Hochladen', 'error'); - return; - } - if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); - - const items: UploadItem[] = files.map(f => ({ - id: Math.random().toString(36).slice(2), - file: f, - status: 'waiting', - progress: 0, - })); - setUploads(items); - setShowUploads(true); - - const updated = [...items]; - for (let i = 0; i < updated.length; i++) { - updated[i] = { ...updated[i], status: 'uploading' }; - setUploads([...updated]); - try { - const savedName = await uploadFile( - updated[i].file, - pct => { - updated[i] = { ...updated[i], progress: pct }; - setUploads([...updated]); - }, - ); - updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; - } catch (e: any) { - updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; - } - setUploads([...updated]); - } - - // Sound-Liste aktualisieren - setRefreshKey(k => k + 1); - void loadAnalytics(); - - // Auto-Dismiss nach 3s - uploadDismissRef.current = setTimeout(() => { - setShowUploads(false); - setUploads([]); - }, 3500); - } - - async function handleStop() { - if (!selected) return; - setLastPlayed(''); - try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } - } - - async function handleRandom() { - if (!displaySounds.length || !selected) return; - const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; - handlePlay(rnd); - } - - async function toggleParty() { - if (chaosMode) { - await handleStop(); - try { await partyStop(guildId); } catch { } - } else { - if (!selected) return notify('Bitte einen Channel auswählen', 'error'); - try { await partyStart(guildId, channelId); } catch { } - } - } - - async function handleChannelSelect(ch: VoiceChannelInfo) { - const v = `${ch.guildId}:${ch.channelId}`; - setSelected(v); - setChannelOpen(false); - try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { } - } - - function toggleFav(key: string) { - setFavs(prev => ({ ...prev, [key]: !prev[key] })); - } - - async function loadAdminSounds() { - setAdminLoading(true); - try { - const data = await fetchSounds('', '__all__', undefined, false); - setAdminSounds(data.items || []); - } catch (e: any) { - notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); - } finally { - setAdminLoading(false); - } - } - - function toggleAdminSelection(path: string) { - setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); - } - - function startRename(sound: Sound) { - setRenameTarget(soundKey(sound)); - setRenameValue(sound.name); - } - - function cancelRename() { - setRenameTarget(''); - setRenameValue(''); - } - - async function submitRename() { - if (!renameTarget) return; - const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); - if (!baseName) { - notify('Bitte einen gültigen Namen eingeben', 'error'); - return; - } - try { - await adminRename(renameTarget, baseName); - notify('Sound umbenannt'); - cancelRename(); - setRefreshKey(k => k + 1); - if (showAdmin) await loadAdminSounds(); - } catch (e: any) { - notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); - } - } - - async function deleteAdminPaths(paths: string[]) { - if (paths.length === 0) return; - try { - await adminDelete(paths); - notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); - setAdminSelection({}); - cancelRename(); - setRefreshKey(k => k + 1); - if (showAdmin) await loadAdminSounds(); - } catch (e: any) { - notify(e?.message || 'Löschen fehlgeschlagen', 'error'); - } - } - - async function handleAdminLogin() { - try { - const ok = await adminLogin(adminPwd); - if (ok) { - setIsAdmin(true); - setAdminPwd(''); - notify('Admin eingeloggt'); - } - else notify('Falsches Passwort', 'error'); - } catch { notify('Login fehlgeschlagen', 'error'); } - } - - async function handleAdminLogout() { - try { - await adminLogout(); - setIsAdmin(false); - setAdminSelection({}); - cancelRename(); - notify('Ausgeloggt'); - } catch { } - } - - /* ── Computed ── */ - const displaySounds = useMemo(() => { - if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); - return sounds; - }, [sounds, activeTab, favs]); - - const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); - - const visibleFolders = useMemo(() => - folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), - [folders]); - - const folderColorMap = useMemo(() => { - const m: Record = {}; - visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); - return m; - }, [visibleFolders]); - - const firstOfInitial = useMemo(() => { - const seen = new Set(); - const result = new Set(); - displaySounds.forEach((s, idx) => { - const ch = s.name.charAt(0).toUpperCase(); - if (!seen.has(ch)) { seen.add(ch); result.add(idx); } - }); - return result; - }, [displaySounds]); - - const channelsByGuild = useMemo(() => { - const groups: Record = {}; - channels.forEach(c => { - if (!groups[c.guildName]) groups[c.guildName] = []; - groups[c.guildName].push(c); - }); - return groups; - }, [channels]); - - const adminFilteredSounds = useMemo(() => { - const q = adminQuery.trim().toLowerCase(); - if (!q) return adminSounds; - return adminSounds.filter(s => { - const key = soundKey(s).toLowerCase(); - return s.name.toLowerCase().includes(q) - || (s.folder || '').toLowerCase().includes(q) - || key.includes(q); - }); - }, [adminQuery, adminSounds, soundKey]); - - const selectedAdminPaths = useMemo(() => - Object.keys(adminSelection).filter(k => adminSelection[k]), - [adminSelection]); - - const selectedVisibleCount = useMemo(() => - adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, - [adminFilteredSounds, adminSelection, soundKey]); - - const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; - const analyticsTop = analytics.mostPlayed.slice(0, 10); - const totalSoundsDisplay = analytics.totalSounds || total; - - const clockMain = clock.slice(0, 5); - const clockSec = clock.slice(5); - - /* ════════════════════════════════════════════ - RENDER - ════════════════════════════════════════════ */ - return ( -
- {chaosMode &&
} - - {/* ═══ TOPBAR ═══ */} -
-
-
- music_note -
- Jukebox420 - - {/* Channel Dropdown */} -
e.stopPropagation()}> - - {channelOpen && ( -
- {Object.entries(channelsByGuild).map(([guild, chs]) => ( - -
{guild}
- {chs.map(ch => ( -
handleChannelSelect(ch)} - > - volume_up - {ch.channelName}{ch.members ? ` (${ch.members})` : ''} -
- ))} -
- ))} - {channels.length === 0 && ( -
- Keine Channels verfügbar -
- )} -
- )} -
-
- -
-
{clockMain}{clockSec}
-
- -
- {lastPlayed && ( -
-
-
-
-
- Last Played: {lastPlayed} -
- )} - {selected && ( -
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> - - Verbunden - {voiceStats?.voicePing != null && ( - {voiceStats.voicePing}ms - )} -
- )} - -
-
- - {/* ═══ TOOLBAR ═══ */} -
-
- - - -
- -
- search - setQuery(e.target.value)} - /> - {query && ( - - )} -
- -
- - {getUrlType(importUrl) === 'youtube' ? 'smart_display' - : getUrlType(importUrl) === 'instagram' ? 'photo_camera' - : 'link'} - - setImportUrl(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} - /> - {importUrl && ( - - {getUrlType(importUrl) === 'youtube' ? 'YT' - : getUrlType(importUrl) === 'instagram' ? 'IG' - : getUrlType(importUrl) === 'mp3' ? 'MP3' - : '?'} - - )} - -
- -
- -
- { - const newVol = volume > 0 ? 0 : 0.5; - setVolume(newVol); - if (guildId) setVolumeLive(guildId, newVol).catch(() => {}); - }} - > - {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} - - { - const v = parseFloat(e.target.value); - setVolume(v); - if (guildId) { - if (volDebounceRef.current) clearTimeout(volDebounceRef.current); - volDebounceRef.current = setTimeout(() => { - setVolumeLive(guildId, v).catch(() => {}); - }, 120); - } - }} - style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} - /> - {Math.round(volume * 100)}% -
- - - - - - - -
- grid_view - setCardSize(parseInt(e.target.value))} - /> -
- -
- {THEMES.map(t => ( -
setTheme(t.id)} - /> - ))} -
-
- -
-
- library_music -
- Sounds gesamt - {totalSoundsDisplay} -
-
- -
- leaderboard -
- Most Played -
- {analyticsTop.length === 0 ? ( - Noch keine Plays - ) : ( - analyticsTop.map((item, idx) => ( - - {idx + 1}. {item.name} ({item.count}) - - )) - )} -
-
-
-
- - {/* ═══ FOLDER CHIPS ═══ */} - {activeTab === 'all' && visibleFolders.length > 0 && ( -
- {visibleFolders.map(f => { - const color = folderColorMap[f.key] || '#888'; - const isActive = activeFolder === f.key; - return ( - - ); - })} -
- )} - - {/* ═══ MAIN ═══ */} -
- {displaySounds.length === 0 ? ( -
-
{activeTab === 'favorites' ? '⭐' : '🔇'}
-
- {activeTab === 'favorites' - ? 'Noch keine Favoriten' - : query - ? `Kein Sound für "${query}" gefunden` - : 'Keine Sounds vorhanden'} -
-
- {activeTab === 'favorites' - ? 'Klick den Stern auf einem Sound!' - : 'Hier gibt\'s noch nichts zu hören.'} -
-
- ) : ( -
- {displaySounds.map((s, idx) => { - const key = s.relativePath ?? s.fileName; - const isFav = !!favs[key]; - const isPlaying = lastPlayed === s.name; - const isNew = s.isRecent || s.badges?.includes('new'); - const initial = s.name.charAt(0).toUpperCase(); - const showInitial = firstOfInitial.has(idx); - const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; - - return ( -
{ - const card = e.currentTarget; - const rect = card.getBoundingClientRect(); - const ripple = document.createElement('div'); - ripple.className = 'ripple'; - const sz = Math.max(rect.width, rect.height); - ripple.style.width = ripple.style.height = sz + 'px'; - ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; - ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; - card.appendChild(ripple); - setTimeout(() => ripple.remove(), 500); - handlePlay(s); - }} - onContextMenu={e => { - e.preventDefault(); - e.stopPropagation(); - setCtxMenu({ - x: Math.min(e.clientX, window.innerWidth - 170), - y: Math.min(e.clientY, window.innerHeight - 140), - sound: s, - }); - }} - title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} - > - {isNew && NEU} - { e.stopPropagation(); toggleFav(key); }} - > - {isFav ? 'star' : 'star_border'} - - {showInitial && {initial}} - {s.name} - {s.folder && {s.folder}} -
-
-
-
-
- ); - })} -
- )} -
- - {/* ═══ CONTEXT MENU ═══ */} - {ctxMenu && ( -
e.stopPropagation()} - > -
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> - play_arrow - Abspielen -
-
{ - toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); - setCtxMenu(null); - }}> - - {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} - - Favorit -
- {isAdmin && ( - <> -
-
{ - const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; - await deleteAdminPaths([path]); - setCtxMenu(null); - }}> - delete - Löschen -
- - )} -
- )} - - {/* ═══ CONNECTION MODAL ═══ */} - {showConnModal && voiceStats && (() => { - const uptimeSec = voiceStats.connectedSince - ? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000) - : 0; - const h = Math.floor(uptimeSec / 3600); - const m = Math.floor((uptimeSec % 3600) / 60); - const s = uptimeSec % 60; - const uptimeStr = h > 0 - ? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s` - : m > 0 - ? `${m}m ${String(s).padStart(2,'0')}s` - : `${s}s`; - const pingColor = (ms: number | null) => - ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040'; - return ( -
setShowConnModal(false)}> -
e.stopPropagation()}> -
- cell_tower - Verbindungsdetails - -
-
-
- Voice Ping - - - {voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'} - -
-
- Gateway Ping - - - {voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'} - -
-
- Status - - {voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status} - -
-
- Kanal - {voiceStats.channelName || '---'} -
-
- Verbunden seit - {uptimeStr} -
-
-
-
- ); - })()} - - {/* ═══ TOAST ═══ */} - {notification && ( -
- - {notification.type === 'error' ? 'error_outline' : 'check_circle'} - - {notification.msg} -
- )} - - {/* ═══ ADMIN PANEL ═══ */} - {showAdmin && ( -
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> -
-

- Admin - -

- {!isAdmin ? ( -
-
- - setAdminPwd(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} - placeholder="Admin-Passwort..." - /> -
- -
- ) : ( -
-
-

Eingeloggt als Admin

-
- - -
-
- -
- - setAdminQuery(e.target.value)} - placeholder="Nach Name, Ordner oder Pfad filtern..." - /> -
- -
- - - -
- -
- {adminLoading ? ( -
Lade Sounds...
- ) : adminFilteredSounds.length === 0 ? ( -
Keine Sounds gefunden.
- ) : ( -
- {adminFilteredSounds.map(sound => { - const key = soundKey(sound); - const editing = renameTarget === key; - return ( -
- - -
-
{sound.name}
-
- {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} - {' · '} - {key} -
- {editing && ( -
- setRenameValue(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void submitRename(); - if (e.key === 'Escape') cancelRename(); - }} - placeholder="Neuer Name..." - /> - - -
- )} -
- - {!editing && ( -
- - -
- )} -
- ); - })} -
- )} -
-
- )} -
-
- )} - - {/* ── Drag & Drop Overlay ── */} - {isDragging && ( -
-
- cloud_upload -
MP3 & WAV hier ablegen
-
Mehrere Dateien gleichzeitig möglich
-
-
- )} - - {/* ── Upload-Queue ── */} - {showUploads && uploads.length > 0 && ( -
-
- upload - - {uploads.every(u => u.status === 'done' || u.status === 'error') - ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` - : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} - - -
-
- {uploads.map(u => ( -
- audio_file -
-
- {u.savedName ?? u.file.name} -
-
{(u.file.size / 1024).toFixed(0)} KB
-
- {(u.status === 'waiting' || u.status === 'uploading') && ( -
-
-
- )} - - {u.status === 'done' ? 'check_circle' : - u.status === 'error' ? 'error' : - u.status === 'uploading' ? 'sync' : 'schedule'} - - {u.status === 'error' &&
{u.error}
} -
- ))} -
-
- )} - - {/* ── Download Modal ── */} - {dlModal && ( -
dlModal.phase !== 'downloading' && setDlModal(null)}> -
e.stopPropagation()}> -
- - {dlModal.type === 'youtube' ? 'smart_display' : dlModal.type === 'instagram' ? 'photo_camera' : 'audio_file'} - - - {dlModal.phase === 'input' ? 'Sound herunterladen' - : dlModal.phase === 'downloading' ? 'Wird heruntergeladen...' - : dlModal.phase === 'done' ? 'Fertig!' - : 'Fehler'} - - {dlModal.phase !== 'downloading' && ( - - )} -
- -
- {/* URL badge */} -
- - {dlModal.type === 'youtube' ? 'YouTube' : dlModal.type === 'instagram' ? 'Instagram' : 'MP3'} - - - {dlModal.url.length > 60 ? dlModal.url.slice(0, 57) + '...' : dlModal.url} - -
- - {/* Filename input (input phase only) */} - {dlModal.phase === 'input' && ( -
- -
- setDlModal(prev => prev ? { ...prev, filename: e.target.value } : null)} - onKeyDown={e => { if (e.key === 'Enter') void handleModalDownload(); }} - autoFocus - /> - .mp3 -
- Leer lassen = automatischer Name -
- )} - - {/* Progress (downloading phase) */} - {dlModal.phase === 'downloading' && ( -
-
- - {dlModal.type === 'youtube' || dlModal.type === 'instagram' - ? 'Audio wird extrahiert...' - : 'MP3 wird heruntergeladen...'} - -
- )} - - {/* Success */} - {dlModal.phase === 'done' && ( -
- check_circle - Gespeichert als {dlModal.savedName} -
- )} - - {/* Error */} - {dlModal.phase === 'error' && ( -
- error - {dlModal.error} -
- )} -
- - {/* Actions */} - {dlModal.phase === 'input' && ( -
- - -
- )} - {dlModal.phase === 'error' && ( -
- - -
- )} -
-
- )} -
- ); -} +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { + fetchChannels, fetchSounds, fetchAnalytics, playSound, playUrl, setVolumeLive, getVolume, + adminStatus, adminLogin, adminLogout, adminDelete, adminRename, + fetchCategories, partyStart, partyStop, subscribeEvents, + getSelectedChannels, setSelectedChannel, uploadFile, +} from './api'; +import type { VoiceChannelInfo, Sound, Category, AnalyticsResponse } from './types'; +import { getCookie, setCookie } from './cookies'; + +const THEMES = [ + { id: 'default', color: '#5865f2', label: 'Discord' }, + { id: 'purple', color: '#9b59b6', label: 'Midnight' }, + { id: 'forest', color: '#2ecc71', label: 'Forest' }, + { id: 'sunset', color: '#e67e22', label: 'Sunset' }, + { id: 'ocean', color: '#3498db', label: 'Ocean' }, +]; + +const CAT_PALETTE = [ + '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', + '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', + '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', +]; + +type Tab = 'all' | 'favorites' | 'recent'; + +type UploadItem = { + id: string; + file: File; + status: 'waiting' | 'uploading' | 'done' | 'error'; + progress: number; + savedName?: string; + error?: string; +}; + +export default function App() { + /* ── Data ── */ + const [sounds, setSounds] = useState([]); + const [total, setTotal] = useState(0); + const [folders, setFolders] = useState>([]); + const [categories, setCategories] = useState([]); + const [analytics, setAnalytics] = useState({ + totalSounds: 0, + totalPlays: 0, + mostPlayed: [], + }); + + /* ── Navigation ── */ + const [activeTab, setActiveTab] = useState('all'); + const [activeFolder, setActiveFolder] = useState(''); + const [query, setQuery] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [importBusy, setImportBusy] = useState(false); + + /* ── Channels ── */ + const [channels, setChannels] = useState([]); + const [selected, setSelected] = useState(''); + const selectedRef = useRef(''); + const [channelOpen, setChannelOpen] = useState(false); + + /* ── Playback ── */ + const [volume, setVolume] = useState(1); + const [lastPlayed, setLastPlayed] = useState(''); + + /* ── Preferences ── */ + const [favs, setFavs] = useState>({}); + const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); + const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); + + /* ── Party ── */ + const [chaosMode, setChaosMode] = useState(false); + const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); + const chaosModeRef = useRef(false); + const volDebounceRef = useRef>(); + + /* ── Admin ── */ + const [isAdmin, setIsAdmin] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); + const [adminPwd, setAdminPwd] = useState(''); + const [adminSounds, setAdminSounds] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminQuery, setAdminQuery] = useState(''); + const [adminSelection, setAdminSelection] = useState>({}); + const [renameTarget, setRenameTarget] = useState(''); + const [renameValue, setRenameValue] = useState(''); + + /* ── Drag & Drop Upload ── */ + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState([]); + const [showUploads, setShowUploads] = useState(false); + const dragCounterRef = useRef(0); + const uploadDismissRef = useRef>(); + + /* ── UI ── */ + const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); + const [clock, setClock] = useState(''); + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); + const [refreshKey, setRefreshKey] = useState(0); + + /* ── Refs ── */ + useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); + useEffect(() => { selectedRef.current = selected; }, [selected]); + + /* ── Drag & Drop: globale Window-Listener ── */ + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { + dragCounterRef.current++; + setIsDragging(true); + } + }; + const onDragLeave = () => { + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }; + const onDragOver = (e: DragEvent) => e.preventDefault(); + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => + /\.(mp3|wav)$/i.test(f.name) + ); + if (files.length) handleFileDrop(files); + }; + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + + /* ── Helpers ── */ + const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { + setNotification({ msg, type }); + setTimeout(() => setNotification(null), 3000); + }, []); + const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); + const isMp3Url = useCallback((value: string) => { + try { + const parsed = new URL(value.trim()); + return parsed.pathname.toLowerCase().endsWith('.mp3'); + } catch { + return false; + } + }, []); + + const guildId = selected ? selected.split(':')[0] : ''; + const channelId = selected ? selected.split(':')[1] : ''; + + const selectedChannel = useMemo(() => + channels.find(c => `${c.guildId}:${c.channelId}` === selected), + [channels, selected]); + + /* ── Clock ── */ + useEffect(() => { + const update = () => { + const now = new Date(); + const h = String(now.getHours()).padStart(2, '0'); + const m = String(now.getMinutes()).padStart(2, '0'); + const s = String(now.getSeconds()).padStart(2, '0'); + setClock(`${h}:${m}:${s}`); + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, []); + + /* ── Init ── */ + useEffect(() => { + (async () => { + try { + const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); + setChannels(ch); + if (ch.length) { + const g = ch[0].guildId; + const serverCid = selMap[g]; + const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); + setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); + } + } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } + try { setIsAdmin(await adminStatus()); } catch { } + try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } + })(); + }, []); + + /* ── Theme ── */ + useEffect(() => { + if (theme === 'default') document.body.removeAttribute('data-theme'); + else document.body.setAttribute('data-theme', theme); + localStorage.setItem('jb-theme', theme); + }, [theme]); + + /* ── Card size ── */ + useEffect(() => { + const r = document.documentElement; + r.style.setProperty('--card-size', cardSize + 'px'); + const ratio = cardSize / 110; + r.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); + r.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); + localStorage.setItem('jb-card-size', String(cardSize)); + }, [cardSize]); + + /* ── SSE ── */ + 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 : []); + try { + const sel = msg?.selected || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && sel[g]) setSelected(`${g}:${sel[g]}`); + } catch { } + try { + const vols = msg?.volumes || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof vols[g] === 'number') setVolume(vols[g]); + } catch { } + try { + const np = msg?.nowplaying || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof np[g] === 'string') setLastPlayed(np[g]); + } catch { } + } else if (msg?.type === 'channel') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); + } else if (msg?.type === 'volume') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); + } else if (msg?.type === 'nowplaying') { + const g = selectedRef.current?.split(':')[0]; + if (msg.guildId === g) setLastPlayed(msg.name || ''); + } + }); + return () => { try { unsub(); } catch { } }; + }, []); + + useEffect(() => { + setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); + }, [selected, partyActiveGuilds]); + + /* ── Data Fetch ── */ + useEffect(() => { + (async () => { + try { + let folderParam = '__all__'; + if (activeTab === 'recent') folderParam = '__recent__'; + else if (activeFolder) folderParam = activeFolder; + const s = await fetchSounds(query, folderParam, undefined, false); + setSounds(s.items); + setTotal(s.total); + setFolders(s.folders); + } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } + })(); + }, [activeTab, activeFolder, query, refreshKey]); + + useEffect(() => { + void loadAnalytics(); + }, [refreshKey]); + + /* ── Favs persistence ── */ + useEffect(() => { + const c = getCookie('favs'); + if (c) try { setFavs(JSON.parse(c)); } catch { } + }, []); + + useEffect(() => { + try { setCookie('favs', JSON.stringify(favs)); } catch { } + }, [favs]); + + /* ── Volume sync ── */ + useEffect(() => { + if (selected) { + (async () => { + try { const v = await getVolume(guildId); setVolume(v); } catch { } + })(); + } + }, [selected]); + + /* ── Close dropdowns on outside click ── */ + useEffect(() => { + const handler = () => { setChannelOpen(false); setCtxMenu(null); }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, []); + + useEffect(() => { + if (showAdmin && isAdmin) { + void loadAdminSounds(); + } + }, [showAdmin, isAdmin]); + + /* ── Actions ── */ + async function loadAnalytics() { + try { + const data = await fetchAnalytics(); + setAnalytics(data); + } catch { } + } + + async function handlePlay(s: Sound) { + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + try { + await playSound(s.name, guildId, channelId, volume, s.relativePath); + setLastPlayed(s.name); + void loadAnalytics(); + } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } + } + + async function handleUrlImport() { + const trimmed = importUrl.trim(); + if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); + if (!selected) return notify('Bitte einen Voice-Channel auswählen', 'error'); + if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + setImportBusy(true); + try { + await playUrl(trimmed, guildId, channelId, volume); + setImportUrl(''); + notify('MP3 importiert und abgespielt'); + setRefreshKey(k => k + 1); + await loadAnalytics(); + } catch (e: any) { + notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + } finally { + setImportBusy(false); + } + } + + async function handleFileDrop(files: File[]) { + if (!isAdmin) { + notify('Admin-Login erforderlich zum Hochladen', 'error'); + return; + } + if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); + + const items: UploadItem[] = files.map(f => ({ + id: Math.random().toString(36).slice(2), + file: f, + status: 'waiting', + progress: 0, + })); + setUploads(items); + setShowUploads(true); + + const updated = [...items]; + for (let i = 0; i < updated.length; i++) { + updated[i] = { ...updated[i], status: 'uploading' }; + setUploads([...updated]); + try { + const savedName = await uploadFile( + updated[i].file, + pct => { + updated[i] = { ...updated[i], progress: pct }; + setUploads([...updated]); + }, + ); + updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; + } catch (e: any) { + updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; + } + setUploads([...updated]); + } + + // Sound-Liste aktualisieren + setRefreshKey(k => k + 1); + void loadAnalytics(); + + // Auto-Dismiss nach 3s + uploadDismissRef.current = setTimeout(() => { + setShowUploads(false); + setUploads([]); + }, 3500); + } + + async function handleStop() { + if (!selected) return; + setLastPlayed(''); + try { await fetch(`/api/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } + } + + async function handleRandom() { + if (!displaySounds.length || !selected) return; + const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; + handlePlay(rnd); + } + + async function toggleParty() { + if (chaosMode) { + await handleStop(); + try { await partyStop(guildId); } catch { } + } else { + if (!selected) return notify('Bitte einen Channel auswählen', 'error'); + try { await partyStart(guildId, channelId); } catch { } + } + } + + async function handleChannelSelect(ch: VoiceChannelInfo) { + const v = `${ch.guildId}:${ch.channelId}`; + setSelected(v); + setChannelOpen(false); + try { await setSelectedChannel(ch.guildId, ch.channelId); } catch { } + } + + function toggleFav(key: string) { + setFavs(prev => ({ ...prev, [key]: !prev[key] })); + } + + async function loadAdminSounds() { + setAdminLoading(true); + try { + const data = await fetchSounds('', '__all__', undefined, false); + setAdminSounds(data.items || []); + } catch (e: any) { + notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); + } finally { + setAdminLoading(false); + } + } + + function toggleAdminSelection(path: string) { + setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); + } + + function startRename(sound: Sound) { + setRenameTarget(soundKey(sound)); + setRenameValue(sound.name); + } + + function cancelRename() { + setRenameTarget(''); + setRenameValue(''); + } + + async function submitRename() { + if (!renameTarget) return; + const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); + if (!baseName) { + notify('Bitte einen gültigen Namen eingeben', 'error'); + return; + } + try { + await adminRename(renameTarget, baseName); + notify('Sound umbenannt'); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); + } + } + + async function deleteAdminPaths(paths: string[]) { + if (paths.length === 0) return; + try { + await adminDelete(paths); + notify(paths.length === 1 ? 'Sound gelöscht' : `${paths.length} Sounds gelöscht`); + setAdminSelection({}); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Löschen fehlgeschlagen', 'error'); + } + } + + async function handleAdminLogin() { + try { + const ok = await adminLogin(adminPwd); + if (ok) { + setIsAdmin(true); + setAdminPwd(''); + notify('Admin eingeloggt'); + } + else notify('Falsches Passwort', 'error'); + } catch { notify('Login fehlgeschlagen', 'error'); } + } + + async function handleAdminLogout() { + try { + await adminLogout(); + setIsAdmin(false); + setAdminSelection({}); + cancelRename(); + notify('Ausgeloggt'); + } catch { } + } + + /* ── Computed ── */ + const displaySounds = useMemo(() => { + if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); + return sounds; + }, [sounds, activeTab, favs]); + + const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); + + const visibleFolders = useMemo(() => + folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), + [folders]); + + const folderColorMap = useMemo(() => { + const m: Record = {}; + visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); + return m; + }, [visibleFolders]); + + const firstOfInitial = useMemo(() => { + const seen = new Set(); + const result = new Set(); + displaySounds.forEach((s, idx) => { + const ch = s.name.charAt(0).toUpperCase(); + if (!seen.has(ch)) { seen.add(ch); result.add(idx); } + }); + return result; + }, [displaySounds]); + + const channelsByGuild = useMemo(() => { + const groups: Record = {}; + channels.forEach(c => { + if (!groups[c.guildName]) groups[c.guildName] = []; + groups[c.guildName].push(c); + }); + return groups; + }, [channels]); + + const adminFilteredSounds = useMemo(() => { + const q = adminQuery.trim().toLowerCase(); + if (!q) return adminSounds; + return adminSounds.filter(s => { + const key = soundKey(s).toLowerCase(); + return s.name.toLowerCase().includes(q) + || (s.folder || '').toLowerCase().includes(q) + || key.includes(q); + }); + }, [adminQuery, adminSounds, soundKey]); + + const selectedAdminPaths = useMemo(() => + Object.keys(adminSelection).filter(k => adminSelection[k]), + [adminSelection]); + + const selectedVisibleCount = useMemo(() => + adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, + [adminFilteredSounds, adminSelection, soundKey]); + + const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; + const analyticsTop = analytics.mostPlayed.slice(0, 10); + const totalSoundsDisplay = analytics.totalSounds || total; + + const clockMain = clock.slice(0, 5); + const clockSec = clock.slice(5); + + /* ════════════════════════════════════════════ + RENDER + ════════════════════════════════════════════ */ + return ( +
+ {chaosMode &&
} + + {/* ═══ TOPBAR ═══ */} +
+
+
+ music_note +
+ Soundboard + + {/* Channel Dropdown */} +
e.stopPropagation()}> + + {channelOpen && ( +
+ {Object.entries(channelsByGuild).map(([guild, chs]) => ( + +
{guild}
+ {chs.map(ch => ( +
handleChannelSelect(ch)} + > + volume_up + {ch.channelName} +
+ ))} +
+ ))} + {channels.length === 0 && ( +
+ Keine Channels verfügbar +
+ )} +
+ )} +
+
+ +
+
{clockMain}{clockSec}
+
+ +
+ {lastPlayed && ( +
+
+
+
+
+ {lastPlayed} +
+ )} + {selected && ( +
+ + Verbunden +
+ )} + +
+
+ + {/* ═══ TOOLBAR ═══ */} +
+
+ + + +
+ +
+ search + setQuery(e.target.value)} + /> + {query && ( + + )} +
+ +
+ link + setImportUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} + /> + +
+ +
+ +
+ { + const newVol = volume > 0 ? 0 : 0.5; + setVolume(newVol); + if (guildId) setVolumeLive(guildId, newVol).catch(() => {}); + }} + > + {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} + + { + const v = parseFloat(e.target.value); + setVolume(v); + if (guildId) { + if (volDebounceRef.current) clearTimeout(volDebounceRef.current); + volDebounceRef.current = setTimeout(() => { + setVolumeLive(guildId, v).catch(() => {}); + }, 120); + } + }} + style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} + /> + {Math.round(volume * 100)}% +
+ + + + + + + +
+ grid_view + setCardSize(parseInt(e.target.value))} + /> +
+ +
+ {THEMES.map(t => ( +
setTheme(t.id)} + /> + ))} +
+
+ +
+
+ library_music +
+ Sounds gesamt + {totalSoundsDisplay} +
+
+ +
+ leaderboard +
+ Most Played +
+ {analyticsTop.length === 0 ? ( + Noch keine Plays + ) : ( + analyticsTop.map((item, idx) => ( + + {idx + 1}. {item.name} ({item.count}) + + )) + )} +
+
+
+
+ + {/* ═══ FOLDER CHIPS ═══ */} + {activeTab === 'all' && visibleFolders.length > 0 && ( +
+ {visibleFolders.map(f => { + const color = folderColorMap[f.key] || '#888'; + const isActive = activeFolder === f.key; + return ( + + ); + })} +
+ )} + + {/* ═══ MAIN ═══ */} +
+ {displaySounds.length === 0 ? ( +
+
{activeTab === 'favorites' ? '⭐' : '🔇'}
+
+ {activeTab === 'favorites' + ? 'Noch keine Favoriten' + : query + ? `Kein Sound für "${query}" gefunden` + : 'Keine Sounds vorhanden'} +
+
+ {activeTab === 'favorites' + ? 'Klick den Stern auf einem Sound!' + : 'Hier gibt\'s noch nichts zu hören.'} +
+
+ ) : ( +
+ {displaySounds.map((s, idx) => { + const key = s.relativePath ?? s.fileName; + const isFav = !!favs[key]; + const isPlaying = lastPlayed === s.name; + const isNew = s.isRecent || s.badges?.includes('new'); + const initial = s.name.charAt(0).toUpperCase(); + const showInitial = firstOfInitial.has(idx); + const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; + + return ( +
{ + const card = e.currentTarget; + const rect = card.getBoundingClientRect(); + const ripple = document.createElement('div'); + ripple.className = 'ripple'; + const sz = Math.max(rect.width, rect.height); + ripple.style.width = ripple.style.height = sz + 'px'; + ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; + ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; + card.appendChild(ripple); + setTimeout(() => ripple.remove(), 500); + handlePlay(s); + }} + onContextMenu={e => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: Math.min(e.clientX, window.innerWidth - 170), + y: Math.min(e.clientY, window.innerHeight - 140), + sound: s, + }); + }} + title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} + > + {isNew && NEU} + { e.stopPropagation(); toggleFav(key); }} + > + {isFav ? 'star' : 'star_border'} + + {showInitial && {initial}} + {s.name} + {s.folder && {s.folder}} +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* ═══ CONTEXT MENU ═══ */} + {ctxMenu && ( +
e.stopPropagation()} + > +
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> + play_arrow + Abspielen +
+
{ + toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); + setCtxMenu(null); + }}> + + {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} + + Favorit +
+ {isAdmin && ( + <> +
+
{ + const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; + await deleteAdminPaths([path]); + setCtxMenu(null); + }}> + delete + Löschen +
+ + )} +
+ )} + + {/* ═══ TOAST ═══ */} + {notification && ( +
+ + {notification.type === 'error' ? 'error_outline' : 'check_circle'} + + {notification.msg} +
+ )} + + {/* ═══ ADMIN PANEL ═══ */} + {showAdmin && ( +
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> +
+

+ Admin + +

+ {!isAdmin ? ( +
+
+ + setAdminPwd(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} + placeholder="Admin-Passwort..." + /> +
+ +
+ ) : ( +
+
+

Eingeloggt als Admin

+
+ + +
+
+ +
+ + setAdminQuery(e.target.value)} + placeholder="Nach Name, Ordner oder Pfad filtern..." + /> +
+ +
+ + + +
+ +
+ {adminLoading ? ( +
Lade Sounds...
+ ) : adminFilteredSounds.length === 0 ? ( +
Keine Sounds gefunden.
+ ) : ( +
+ {adminFilteredSounds.map(sound => { + const key = soundKey(sound); + const editing = renameTarget === key; + return ( +
+ + +
+
{sound.name}
+
+ {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} + {' · '} + {key} +
+ {editing && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void submitRename(); + if (e.key === 'Escape') cancelRename(); + }} + placeholder="Neuer Name..." + /> + + +
+ )} +
+ + {!editing && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
+
+ )} +
+
+ )} + + {/* ── Drag & Drop Overlay ── */} + {isDragging && ( +
+
+ cloud_upload +
MP3 & WAV hier ablegen
+
Mehrere Dateien gleichzeitig möglich
+
+
+ )} + + {/* ── Upload-Queue ── */} + {showUploads && uploads.length > 0 && ( +
+
+ upload + + {uploads.every(u => u.status === 'done' || u.status === 'error') + ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` + : `Lade hoch… (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} + + +
+
+ {uploads.map(u => ( +
+ audio_file +
+
+ {u.savedName ?? u.file.name} +
+
{(u.file.size / 1024).toFixed(0)} KB
+
+ {(u.status === 'waiting' || u.status === 'uploading') && ( +
+
+
+ )} + + {u.status === 'done' ? 'check_circle' : + u.status === 'error' ? 'error' : + u.status === 'uploading' ? 'sync' : 'schedule'} + + {u.status === 'error' &&
{u.error}
} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/api.ts b/web/src/api.ts index 74f7523..4cf8736 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -195,24 +195,15 @@ export async function adminRename(from: string, to: string): Promise { return data?.to as string; } -export async function playUrl(url: string, guildId: string, channelId: string, volume: number, filename?: string): Promise<{ saved?: string }> { +export async function playUrl(url: string, guildId: string, channelId: string, volume: number): Promise { const res = await fetch(`${API_BASE}/play-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, guildId, channelId, volume, filename }) + body: JSON.stringify({ url, guildId, channelId, volume }) }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || 'Play-URL fehlgeschlagen'); - return data; -} - -export async function downloadUrl(url: string, filename?: string): Promise<{ saved?: string }> { - const res = await fetch(`${API_BASE}/download-url`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, filename }) - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || 'Download fehlgeschlagen'); - return data; + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Play-URL fehlgeschlagen'); + } } /** Lädt eine einzelne MP3/WAV-Datei hoch. Gibt den gespeicherten Dateinamen zurück. */ diff --git a/web/src/styles.css b/web/src/styles.css index e0efd56..4fa09d6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -353,90 +353,6 @@ input, select { 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } } -.conn-ping { - font-size: 10px; - opacity: .7; - margin-left: 2px; -} - -/* ── Connection Details Modal ── */ -.conn-modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .55); - z-index: 9000; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(4px); - animation: fadeIn .15s ease; -} -.conn-modal { - background: var(--bg-primary); - border: 1px solid var(--border); - border-radius: 16px; - width: 340px; - box-shadow: 0 20px 60px rgba(0,0,0,.4); - overflow: hidden; - animation: slideUp .2s ease; -} -@keyframes slideUp { - from { transform: translateY(20px); opacity: 0; } - to { transform: translateY(0); opacity: 1; } -} -.conn-modal-header { - display: flex; - align-items: center; - gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid var(--border); - font-weight: 700; - font-size: 14px; -} -.conn-modal-close { - margin-left: auto; - background: none; - border: none; - color: var(--muted); - cursor: pointer; - padding: 4px; - border-radius: 6px; - display: flex; - transition: all .15s; -} -.conn-modal-close:hover { - background: rgba(255,255,255,.08); - color: var(--fg); -} -.conn-modal-body { - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; -} -.conn-stat { - display: flex; - justify-content: space-between; - align-items: center; -} -.conn-stat-label { - color: var(--muted); - font-size: 13px; -} -.conn-stat-value { - font-weight: 600; - font-size: 13px; - display: flex; - align-items: center; - gap: 6px; -} -.conn-ping-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - /* ── Admin Icon Button ── */ .admin-btn-icon { width: 32px; @@ -641,24 +557,6 @@ input, select { pointer-events: none; } -.url-import-tag { - flex-shrink: 0; - padding: 1px 6px; - border-radius: 8px; - font-size: 10px; - font-weight: 800; - letter-spacing: .5px; - text-transform: uppercase; -} -.url-import-tag.valid { - background: rgba(46, 204, 113, .18); - color: #2ecc71; -} -.url-import-tag.invalid { - background: rgba(231, 76, 60, .18); - color: #e74c3c; -} - /* ── Toolbar Buttons ── */ .tb-btn { display: flex; @@ -1224,7 +1122,7 @@ input, select { border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); font-size: 12px; color: var(--text-muted); - max-width: none; + max-width: 200px; min-width: 0; animation: np-fade-in 300ms ease; } @@ -1237,6 +1135,8 @@ input, select { .np-name { color: var(--accent); font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } @@ -1866,7 +1766,7 @@ input, select { } .now-playing { - max-width: none; + max-width: 120px; } .toolbar .tb-btn { @@ -1954,6 +1854,7 @@ input, select { border-radius: 14px; box-shadow: 0 8px 40px rgba(0, 0, 0, .45); z-index: 200; + overflow: hidden; animation: slide-up 200ms cubic-bezier(.16,1,.3,1); } @@ -2027,6 +1928,7 @@ input, select { font-weight: 500; color: var(--text-normal); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } @@ -2041,6 +1943,7 @@ input, select { height: 3px; background: rgba(255, 255, 255, .07); border-radius: 2px; + overflow: hidden; margin-top: 4px; } @@ -2081,141 +1984,6 @@ input, select { margin-top: 2px; } -/* ──────────────────────────────────────────── - Download Modal - ──────────────────────────────────────────── */ -.dl-modal-overlay { - position: fixed; inset: 0; - background: rgba(0, 0, 0, .55); - display: flex; align-items: center; justify-content: center; - z-index: 300; - animation: fade-in 150ms ease; -} -@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } - -.dl-modal { - width: 420px; max-width: 92vw; - background: var(--bg-secondary); - border: 1px solid rgba(255, 255, 255, .1); - border-radius: 16px; - box-shadow: 0 12px 60px rgba(0, 0, 0, .5); - animation: scale-in 200ms cubic-bezier(.16, 1, .3, 1); -} -@keyframes scale-in { from { opacity: 0; transform: scale(.95); } to { opacity: 1; transform: scale(1); } } - -.dl-modal-header { - display: flex; align-items: center; gap: 8px; - padding: 14px 16px; - border-bottom: 1px solid rgba(255, 255, 255, .06); - font-size: 14px; font-weight: 700; - color: var(--text-normal); -} -.dl-modal-header .material-icons { color: var(--accent); } - -.dl-modal-close { - margin-left: auto; - display: flex; align-items: center; justify-content: center; - width: 26px; height: 26px; border-radius: 50%; - border: none; background: rgba(255,255,255,.06); - color: var(--text-muted); cursor: pointer; - transition: background var(--transition); -} -.dl-modal-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } - -.dl-modal-body { padding: 16px; display: flex; flex-direction: column; gap: 14px; } - -/* URL display */ -.dl-modal-url { - display: flex; align-items: center; gap: 8px; - padding: 8px 10px; border-radius: 8px; - background: rgba(0, 0, 0, .2); - overflow: hidden; -} -.dl-modal-tag { - flex-shrink: 0; padding: 2px 8px; border-radius: 6px; - font-size: 10px; font-weight: 800; letter-spacing: .5px; text-transform: uppercase; -} -.dl-modal-tag.youtube { background: rgba(255, 0, 0, .18); color: #ff4444; } -.dl-modal-tag.instagram { background: rgba(225, 48, 108, .18); color: #e1306c; } -.dl-modal-tag.mp3 { background: rgba(46, 204, 113, .18); color: #2ecc71; } -.dl-modal-url-text { - font-size: 11px; color: var(--text-faint); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -/* Filename field */ -.dl-modal-field { display: flex; flex-direction: column; gap: 5px; } -.dl-modal-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px; } -.dl-modal-input-wrap { - display: flex; align-items: center; - border: 1px solid rgba(255, 255, 255, .1); border-radius: 8px; - background: rgba(0, 0, 0, .15); - overflow: hidden; - transition: border-color var(--transition); -} -.dl-modal-input-wrap:focus-within { border-color: var(--accent); } -.dl-modal-input { - flex: 1; border: none; background: transparent; - padding: 8px 10px; color: var(--text-normal); - font-size: 13px; font-family: var(--font); outline: none; -} -.dl-modal-input::placeholder { color: var(--text-faint); } -.dl-modal-ext { - padding: 0 10px; font-size: 12px; font-weight: 600; - color: var(--text-faint); background: rgba(255, 255, 255, .04); - align-self: stretch; display: flex; align-items: center; -} -.dl-modal-hint { font-size: 10px; color: var(--text-faint); } - -/* Progress spinner */ -.dl-modal-progress { - display: flex; align-items: center; gap: 12px; - padding: 20px 0; justify-content: center; - font-size: 13px; color: var(--text-muted); -} -.dl-modal-spinner { - width: 24px; height: 24px; border-radius: 50%; - border: 3px solid rgba(var(--accent-rgb), .2); - border-top-color: var(--accent); - animation: spin 800ms linear infinite; -} - -/* Success */ -.dl-modal-success { - display: flex; align-items: center; gap: 10px; - padding: 16px 0; justify-content: center; - font-size: 13px; color: var(--text-normal); -} -.dl-modal-check { color: #2ecc71; font-size: 28px; } - -/* Error */ -.dl-modal-error { - display: flex; align-items: center; gap: 10px; - padding: 12px 0; justify-content: center; - font-size: 13px; color: #e74c3c; -} - -/* Actions */ -.dl-modal-actions { - display: flex; justify-content: flex-end; gap: 8px; - padding: 0 16px 14px; -} -.dl-modal-cancel { - padding: 7px 14px; border-radius: 8px; - border: 1px solid rgba(255, 255, 255, .1); background: transparent; - color: var(--text-muted); font-size: 12px; font-weight: 600; - cursor: pointer; transition: all var(--transition); -} -.dl-modal-cancel:hover { background: rgba(255,255,255,.06); color: var(--text-normal); } -.dl-modal-submit { - display: flex; align-items: center; gap: 5px; - padding: 7px 16px; border-radius: 8px; - border: none; background: var(--accent); - color: #fff; font-size: 12px; font-weight: 700; - cursor: pointer; transition: filter var(--transition); -} -.dl-modal-submit:hover { filter: brightness(1.15); } - /* ──────────────────────────────────────────── Utility ──────────────────────────────────────────── */ @@ -2225,6 +1993,7 @@ input, select { height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border-width: 0; diff --git a/web/src/types.ts b/web/src/types.ts index 6222add..919e8fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,7 +20,6 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; - members?: number; selected?: boolean; };