From 81120ac7ea958d9e7d68bcde629d55a356eda904 Mon Sep 17 00:00:00 2001 From: 3nd3r Date: Sat, 3 Jan 2026 10:29:10 -0600 Subject: [PATCH] Add remote stream relay feature: relay remote DJ streams to listeners - Server-side: Added remote URL support in ffmpeg transcoder - UI: Added relay controls in streaming panel with URL input - Client: Added start/stop relay functions with socket communication - Listener: Shows remote relay status in stream indicator --- index.html | 10 ++++++ script.js | 63 ++++++++++++++++++++++++++++++++- server.py | 101 ++++++++++++++++++++++++++++++++++++++++++----------- style.css | 69 ++++++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 38945ae..505270a 100644 --- a/index.html +++ b/index.html @@ -398,6 +398,16 @@ Lower = more stable on poor connections + +
+

🔗 Remote Stream Relay

+
+ + + +
+
+
diff --git a/script.js b/script.js index d87db82..15a2ed8 100644 --- a/script.js +++ b/script.js @@ -1664,10 +1664,20 @@ function initSocket() { socket.on('broadcast_started', () => { console.log('🎙️ Broadcast started notification received'); + // Update relay UI if it's a relay + const relayStatus = document.getElementById('relay-status'); + if (relayStatus && relayStatus.textContent.includes('Connecting')) { + relayStatus.textContent = 'Relay active - streaming to listeners'; + relayStatus.style.color = '#00ff00'; + } }); socket.on('broadcast_stopped', () => { console.log('🛑 Broadcast stopped notification received'); + // Reset relay UI if it was active + document.getElementById('start-relay-btn').style.display = 'inline-block'; + document.getElementById('stop-relay-btn').style.display = 'none'; + document.getElementById('relay-status').textContent = ''; }); socket.on('mixer_status', (data) => { @@ -1682,6 +1692,10 @@ function initSocket() { socket.on('error', (data) => { console.error('📡 Server error:', data.message); alert(`SERVER ERROR: ${data.message}`); + // Reset relay UI on error + document.getElementById('start-relay-btn').style.display = 'inline-block'; + document.getElementById('stop-relay-btn').style.display = 'none'; + document.getElementById('relay-status').textContent = ''; }); return socket; @@ -2151,6 +2165,48 @@ function toggleAutoStream(enabled) { localStorage.setItem('autoStartStream', enabled); } +// ========== REMOTE RELAY FUNCTIONS ========== + +function startRemoteRelay() { + const urlInput = document.getElementById('remote-stream-url'); + const url = urlInput.value.trim(); + + if (!url) { + alert('Please enter a remote stream URL'); + return; + } + + if (!socket) initSocket(); + + // Stop any existing broadcast first + if (isBroadcasting) { + stopBroadcast(); + } + + console.log('🔗 Starting remote relay for:', url); + + // Update UI + document.getElementById('start-relay-btn').style.display = 'none'; + document.getElementById('stop-relay-btn').style.display = 'inline-block'; + document.getElementById('relay-status').textContent = 'Connecting to remote stream...'; + document.getElementById('relay-status').style.color = '#00f3ff'; + + socket.emit('start_remote_relay', { url: url }); +} + +function stopRemoteRelay() { + if (!socket) return; + + console.log('🛑 Stopping remote relay'); + + socket.emit('stop_remote_relay'); + + // Update UI + document.getElementById('start-relay-btn').style.display = 'inline-block'; + document.getElementById('stop-relay-btn').style.display = 'none'; + document.getElementById('relay-status').textContent = ''; +} + // ========== LISTENER MODE ========== function initListenerMode() { @@ -2272,7 +2328,12 @@ function initListenerMode() { socket.on('stream_status', (data) => { const nowPlayingEl = document.getElementById('listener-now-playing'); if (nowPlayingEl) { - nowPlayingEl.textContent = data.active ? '🎵 Stream is live!' : 'Stream offline - waiting for DJ...'; + if (data.active) { + const status = data.remote_relay ? '🔗 Remote stream is live!' : '🎵 DJ stream is live!'; + nowPlayingEl.textContent = status; + } else { + nowPlayingEl.textContent = 'Stream offline - waiting for DJ...'; + } } }); diff --git a/server.py b/server.py index 142104d..bb1c009 100644 --- a/server.py +++ b/server.py @@ -53,6 +53,7 @@ _transcode_threads_started = False _transcoder_bytes_out = 0 _transcoder_last_error = None _last_audio_chunk_ts = 0.0 +_remote_stream_url = None # For relaying remote streams def _start_transcoder_if_needed(): @@ -61,32 +62,55 @@ def _start_transcoder_if_needed(): if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None: return - cmd = [ - 'ffmpeg', - '-hide_banner', - '-loglevel', 'error', - '-i', 'pipe:0', - '-vn', - '-acodec', 'libmp3lame', - '-b:a', '192k', - '-f', 'mp3', - 'pipe:1', - ] + if _remote_stream_url: + # Remote relay mode: input from URL + cmd = [ + 'ffmpeg', + '-hide_banner', + '-loglevel', 'error', + '-i', _remote_stream_url, + '-vn', + '-acodec', 'libmp3lame', + '-b:a', '192k', + '-f', 'mp3', + 'pipe:1', + ] + else: + # Local broadcast mode: input from pipe + cmd = [ + 'ffmpeg', + '-hide_banner', + '-loglevel', 'error', + '-i', 'pipe:0', + '-vn', + '-acodec', 'libmp3lame', + '-b:a', '192k', + '-f', 'mp3', + 'pipe:1', + ] try: - _ffmpeg_proc = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) + if _remote_stream_url: + _ffmpeg_proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + else: + _ffmpeg_proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) except FileNotFoundError: _ffmpeg_proc = None print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled') return - print('🎛️ ffmpeg transcoder started for /stream.mp3') + print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({ "remote relay" if _remote_stream_url else "local broadcast" })') def _writer(): global _transcoder_last_error @@ -152,7 +176,7 @@ def _stop_transcoder(): def _feed_transcoder(data: bytes): global _last_audio_chunk_ts - if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: + if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None or _remote_stream_url: return _last_audio_chunk_ts = time.time() try: @@ -480,6 +504,43 @@ def dj_stop(): listener_socketio.emit('broadcast_stopped', namespace='/') listener_socketio.emit('stream_status', {'active': False}, namespace='/') +@dj_socketio.on('start_remote_relay') +def dj_start_remote_relay(data): + global _remote_stream_url + url = data.get('url', '').strip() + if not url: + dj_socketio.emit('error', {'message': 'No URL provided for remote relay'}) + return + + # Stop any existing broadcast/relay + if broadcast_state['active']: + dj_stop() + + _remote_stream_url = url + broadcast_state['active'] = True + broadcast_state['remote_relay'] = True + session['is_dj'] = True + print(f"🔗 Starting remote relay from: {url}") + + _start_transcoder_if_needed() + + listener_socketio.emit('broadcast_started', namespace='/') + listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True}, namespace='/') + +@dj_socketio.on('stop_remote_relay') +def dj_stop_remote_relay(): + global _remote_stream_url + _remote_stream_url = None + broadcast_state['active'] = False + broadcast_state['remote_relay'] = False + session['is_dj'] = False + print("🛑 Remote relay stopped") + + _stop_transcoder() + + listener_socketio.emit('broadcast_stopped', namespace='/') + listener_socketio.emit('stream_status', {'active': False}, namespace='/') + @dj_socketio.on('audio_chunk') def dj_audio(data): # MP3-only mode: do not relay raw chunks to listeners; feed transcoder only. diff --git a/style.css b/style.css index b3463a3..6c68af9 100644 --- a/style.css +++ b/style.css @@ -2320,6 +2320,75 @@ input[type=range] { opacity: 0.7; } +/* Remote Relay Section */ +.remote-relay-section { + padding: 15px; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + border: 1px solid rgba(0, 243, 255, 0.3); +} + +.remote-relay-section h4 { + margin: 0 0 15px 0; + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; + font-size: 1rem; +} + +.relay-controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.relay-url-input { + padding: 10px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--primary-cyan); + color: var(--text-main); + border-radius: 5px; + font-family: 'Rajdhani', monospace; + font-size: 0.85rem; +} + +.relay-btn { + padding: 12px; + background: linear-gradient(145deg, #1a1a1a, #0a0a0a); + border: 2px solid var(--primary-cyan); + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; + font-weight: bold; + cursor: pointer; + border-radius: 5px; + transition: all 0.3s; + box-shadow: 0 0 15px rgba(0, 243, 255, 0.2); +} + +.relay-btn:hover { + background: linear-gradient(145deg, #2a2a2a, #1a1a1a); + box-shadow: 0 0 25px rgba(0, 243, 255, 0.4); + transform: translateY(-1px); +} + +.relay-btn.stop { + border-color: #ff4444; + color: #ff4444; + box-shadow: 0 0 15px rgba(255, 68, 68, 0.2); +} + +.relay-btn.stop:hover { + background: rgba(255, 68, 68, 0.1); + box-shadow: 0 0 25px rgba(255, 68, 68, 0.4); +} + +.relay-status { + margin-top: 10px; + font-size: 0.85rem; + color: var(--text-dim); + min-height: 20px; +} + /* ========== LISTENER MODE ========== */ .listener-mode {