App.tsx wechselt automatisch zum Streaming-Tab wenn ?viewStream= in der URL steht.
StreamingTab nutzt ref-basierten Ansatz: Stream-ID wird beim Mount gespeichert,
Passwort-Modal oeffnet sich sobald die Stream-Liste vom Server geladen ist.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Server:
- Dual-Role: Client kann gleichzeitig broadcasten UND zuschauen
(broadcastStreamId + viewingStreamId statt single role)
- POST /api/streaming/disconnect Beacon-Endpoint fuer
zuverlaessigen Cleanup bei Page-Unload
- Heartbeat auf 5s reduziert (schnellere Erkennung)
Frontend:
- pagehide + sendBeacon: Streams werden sofort aufgeraeumt wenn
Browser geschlossen/neugeladen wird
- ICE Routing: Broadcaster-Map wird zuerst geprueft, dann Viewer-PC
→ Broadcast + View im selben Tab moeglich
- 3-Punkt-Menü mit Stream-Details, "In neuem Fenster oeffnen" und
"Link teilen" (Clipboard)
- Auto-Join via ?viewStream=... Query-Parameter (fuer geteilte Links)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- beforeunload Event verhindert versehentliches Verlassen/Reload
waehrend Broadcasting oder Viewing aktiv ist
- Vollbild-Button im Viewer-Header (Fullscreen API)
- Fullscreen-State wird korrekt getrackt und Icon wechselt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Doppelter Offer entfernt (onnegotiationneeded + explizit createOffer)
- ICE Candidate Queuing: Candidates werden gepuffert bis setRemoteDescription
fertig ist, statt sie zu verwerfen
- Cleanup bei Re-Offer: vorherige PeerConnection wird sauber geschlossen
- Pending Candidates werden bei Disconnect/Leave aufgeraeumt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Disconnect:
- Server-side heartbeat ping/pong every 10s with 25s timeout
- Detects and cleans up dead connections (browser closed, network lost)
- ws.terminate() on heartbeat timeout triggers handleDisconnect
Password:
- Stream password is mandatory (server rejects start_broadcast without)
- Password stored server-side, never sent to clients
- Viewers must enter password via modal before joining
- Lock icon on tiles, WRONG_PASSWORD error shown in modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The WebSocket onmessage handler captured isBroadcasting=false at creation
time. When ICE candidates arrived from remote viewers, the handler looked
up viewerPcRef instead of peerConnectionsRef, dropping all candidates.
Fix: use refs (isBroadcastingRef, viewingRef, handleWsMessageRef) so the
WS handler always reads current state. connectWs() now has [] deps and
delegates to handleWsMessageRef.current for every message.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New plugin: browser-based screen sharing via Chrome Screen Capture API.
Multi-stream grid layout (Rustdesk-style tiles) with live previews.
- Server: WebSocket signaling at /ws/streaming (SDP/ICE relay)
- Server: http.createServer for WebSocket attachment
- Frontend: StreamingTab with broadcaster/viewer modes
- Frontend: tile grid, fullscreen viewer, LIVE badges
- Supports multiple concurrent streams
- Peer-to-peer video via WebRTC (no video through server)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every step now logged with timestamps, exit codes, stdout/stderr
streaming in real-time. yt-dlp runs with --verbose flag. Routes log
URL type detection, download progress, normalization, play status.
Error messages include HTTP status codes and specific yt-dlp errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
URLs pasted without protocol (e.g. instagram.com/reel/...) now work.
normalizeUrl() adds https:// if missing. Input type changed from
"url" to "text" to prevent browser validation blocking paste.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yt-dlp extracts audio as MP3 from YouTube and Instagram links.
Direct MP3 links continue to work as before. URL input field now shows
a type indicator (YT/IG/MP3) and validates all three formats.
Backend: downloadWithYtDlp() spawns yt-dlp with --extract-audio,
saves to SOUNDS_DIR, normalizes if enabled. New /download-url route
for save-only without auto-play. play-url route extended for all types.
Frontend: isSupportedUrl() validates YouTube/Instagram/MP3, dynamic
icon changes per URL type, disabled state when URL is unsupported.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Buffer 384 KB of PCM data (~2 seconds at 48kHz stereo) via PassThrough
stream before starting Discord playback. Falls back to immediate start
after 3s timeout for slow streams. Cleanup integrated into stopAudio.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Globe.gl needs non-zero container dimensions for initialization and click
handling. With the tab persistence fix (display:none for hidden tabs), the
globe container starts at 0×0 when radio isn't the first tab. Added a
separate ResizeObserver that detects when the container becomes visible
and triggers globe initialization via containerVisible state dependency.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All tab components are now always mounted and hidden via CSS
(display: none/contents) instead of conditionally rendered.
This prevents React state from being destroyed on tab switch,
so nowPlaying, SSE handlers, and stop buttons keep working.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
op.gg REST API doesn't track featured game modes (URF, ARAM Mayhem/Brawl).
Now uses Riot API for match history when RIOT_API_KEY env var is set,
falling back to op.gg REST for profile/ranked stats (no key needed).
- Add Riot API match fetcher with region routing (europe/americas/asia/sea)
- Add DDragon champion ID→name mapping for Riot API matches
- Add queue ID→name mapping (420=Ranked, 450=ARAM, 900=URF, etc.)
- Transform Riot match data to existing MatchEntry interface
- Batch match detail requests (5 at a time) for rate limit safety
- Keep op.gg REST as fallback when no API key is configured
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Profile now fetched via REST API (summoner lookup + summary endpoint)
- Match history via REST API games endpoint (proper JSON, no parser)
- All 10 players per game returned directly (no separate detail fetch)
- DDragon champion ID→name mapping loaded at startup
- Fixed summoner_id lookup to use # separator (was using - which failed)
- MCP kept as fallback for match detail and edge cases
- Frontend: find "me" by summoner name instead of assuming index 0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Uses op.gg REST API to trigger summoner data renewal before fetching
stats via MCP. Adds Update button in profile header for manual refresh.
Flow: lookup summoner_id → POST renewal → poll until finish → re-fetch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New plugin for League of Legends stats tracking, similar to op.gg:
- Search summoners by Riot ID (Name#Tag) + region
- Profile overview: rank, tier, LP, win rate, ladder position
- Top champions with KDA and win rates
- Match history with KDA, CS, items, game duration
- Expandable match details showing all 10 players
- Recent searches persisted across restarts
Uses op.gg MCP server (no API key needed, no 24h expiration).
Backend: server/src/plugins/lolstats/ (3 files)
Frontend: web/src/plugins/lolstats/ (2 files)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Root cause: only /data/sounds/ survives container recreation (it's the
volume-mounted directory). /data/hub-state.json was written to the
container's ephemeral layer and lost on every redeploy.
- State file now saved to /data/sounds/hub-state.json
- Auto-migrates from legacy /data/hub-state.json if found
- Favorites and radio volumes will now persist across deploys
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- loadState: logs DATA_DIR path, writability check, lists files, shows
radio_favorites count on load
- saveState: read-back verification after atomic write, fallback to
direct write if rename fails
- /api/health: shows state diagnostics (file exists, file size, keys,
favorites count in memory vs disk, lastSaveOk)
- Helps diagnose why favorites are not persisting across deploys
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Original was 21600x10800 (full NASA resolution) - way too large
for a WebGL texture. Resized to 4096x2048 (max useful for globe.gl)
at JPEG quality 85 with progressive loading.
28 MB → 1.1 MB = 96% smaller, dramatically faster initial load.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add voice channel member count (non-bot users) to soundboard
channel API response and display it in the dropdown, matching
the radio plugin's existing behavior.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Atomic save: write to .tmp file then rename (prevents corruption
if container is killed mid-write)
- Backup: .bak copy created on successful load, used as fallback
if main file is corrupted
- Startup log shows loaded keys (verifies favorites survived)
Ensures radio_favorites and radio_volumes survive container
updates, crashes, and forced restarts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. FAB button (favorites star) now hidden when any panel is open.
Previously the FAB (z-index:20) covered the panel close button
(z-index:15), making it impossible to close the station sidebar.
2. Added margin-left:auto to radio-topbar-right so theme dots
always stay right-aligned. Previously they sat left when no
station was playing (missing flex:1 now-playing spacer).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Auto-rotation now stops completely when a station list or
favorites sidebar is visible. Resumes only when all panels
are closed. Interaction-based pause (drag/scroll) also
respects open panels - won't resume after 5s timeout if
a sidebar is still showing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Restore d.size-dependent point radius so larger cities (more
stations) show bigger dots like Radio Garden. Formula:
radius = clamp(0.12, 0.45, 0.06 + size * 0.005)
Zoom scaling also respects per-station size.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace sprite-based markers (objectsData + SpriteMaterial with
soft 64x64 gradient texture → blurry at zoom) with optimized
point meshes:
- pointResolution(24): smooth 24-sided circles (no hexagons)
- pointAltitude(0.001): nearly flat on surface (no cylinder effect)
- sqrt-based zoom scaling: dots shrink when zooming in, grow when
zooming out → visually consistent at all zoom levels
- Removed three.js Sprite/SpriteMaterial/CanvasTexture imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Radio: Move controls from bottom bar to topbar above globe
(guild/channel selectors, now playing, volume, connection, stop)
- Radio: Theme selector moved inline into topbar-right
- Radio: Globe now fills remaining space below topbar
- Soundboard: Fix connection modal not opening when voiceStats is null
(modal now renders with '---' values when no voice data available)
- Both: Modal condition changed from `showConnModal && voiceStats` to
just `showConnModal`, handling null voiceStats gracefully
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of destroying and recreating the voice connection on every
station change, now checks if the bot is already in the target channel.
If same channel: only stops ffmpeg/player, spawns new stream, reuses
the existing connection (no reconnect flicker).
If different channel: full disconnect + reconnect as before.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Globe was black because Radio Garden CDN (rg-tiles.b-cdn.net) returns
403 without Referer: radio.garden header. Added server-side tile proxy
/api/radio/tile/:z/:x/:y with in-memory cache (max 500 tiles).
- Added radio_voicestats SSE broadcast (every 5s) with voice ping,
gateway ping, status, channel name, and connected-since timestamp.
- Added clickable "Verbunden" connection indicator in RadioTab bottom
bar with live ping display and connection details modal (matching
soundboard's existing modal pattern).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each plugin now uses its own @discordjs/voice group:
- Radio: group='radio'
- Soundboard: group='soundboard'
This prevents joinVoiceChannel from one bot overwriting the
other bot's connection. Both bots can now play simultaneously
in the same voice channel. Removed claimVoice system (not needed
with separate bots).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each plugin gets its own Discord client and token:
- DISCORD_TOKEN_JUKEBOX (fallback: DISCORD_TOKEN) → Soundboard
- DISCORD_TOKEN_RADIO → Radio
discord.ts: factory createClient() instead of singleton
plugin.ts: per-plugin context storage via registerPlugin(p, ctx)
index.ts: creates/logins/shutdowns multiple bots independently
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace static earth texture with tile-based rendering from CDN.
Tiles load progressively (zoom 1→2→3 base), then dynamically
load zoom 4-6 as user zooms in. Mercator→equirectangular
reprojection via strip-based canvas compositing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Plugins now claim voice per guild via claimVoice(). When soundboard
plays a sound, radio's cleanup runs automatically (kills ffmpeg,
broadcasts SSE stop event). Fixes stale "now playing" UI on tab switch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Search API: read title/subtitle/url from _source.page (nested)
- Channel click: extract correct ID from URL (last segment)
- Replace earth texture with higher-res 4096x2048 original
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
NASA's image server blocks cross-origin requests. Downloaded the
3600x1800 Black Marble texture and serve it from web/public/.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Set renderer pixel ratio to window.devicePixelRatio for crisp
rendering on Retina/HiDPI displays (was defaulting to 1)
- Upgraded Earth texture from low-res three-globe example (~2K)
to NASA Black Marble 3600x1800 for sharp detail when zooming
- Added pointResolution(6) for cleaner point geometry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>