diff --git a/downloader.py b/downloader.py new file mode 100644 index 0000000..01ddb5f --- /dev/null +++ b/downloader.py @@ -0,0 +1,105 @@ +import requests +import os +import re + +def clean_filename(title): + # Remove quotes and illegal characters + title = title.strip("'").strip('"') + return re.sub(r'[\\/*?:"<>|]', "", title) + +def download_mp3(url, quality='320'): + print(f"\nšŸ” Processing: {url}") + + try: + # Use Cobalt v9 API to download + print("🌐 Requesting download from Cobalt API v9...") + + response = requests.post( + 'https://api.cobalt.tools/api/v9/process', + headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + json={ + 'url': url, + 'downloadMode': 'audio', + 'audioFormat': 'mp3' + }, + timeout=30 + ) + + print(f"šŸ“” API Response Status: {response.status_code}") + + if response.status_code != 200: + try: + error_data = response.json() + print(f"āŒ Cobalt API error: {error_data}") + except: + print(f"āŒ Cobalt API error: {response.text}") + return {"success": False, "error": f"API returned {response.status_code}"} + + data = response.json() + print(f"šŸ“¦ API Response: {data}") + + # Check for errors in response + if data.get('status') == 'error': + error_msg = data.get('text', 'Unknown error') + print(f"āŒ Cobalt error: {error_msg}") + return {"success": False, "error": error_msg} + + # Get download URL + download_url = data.get('url') + if not download_url: + print(f"āŒ No download URL in response: {data}") + return {"success": False, "error": "No download URL received"} + + print(f"šŸ“„ Downloading audio...") + + # Download the audio file + audio_response = requests.get(download_url, stream=True, timeout=60) + + if audio_response.status_code != 200: + print(f"āŒ Download failed: {audio_response.status_code}") + return {"success": False, "error": f"Download failed with status {audio_response.status_code}"} + + # Try to get filename from Content-Disposition header + content_disposition = audio_response.headers.get('Content-Disposition', '') + if 'filename=' in content_disposition: + filename = content_disposition.split('filename=')[1].strip('"') + filename = clean_filename(os.path.splitext(filename)[0]) + else: + # Fallback: extract video ID and use it + video_id = url.split('v=')[-1].split('&')[0] + filename = f"youtube_{video_id}" + + # Ensure .mp3 extension + output_path = f"music/{filename}.mp3" + + # Save the file + with open(output_path, 'wb') as f: + for chunk in audio_response.iter_content(chunk_size=8192): + f.write(chunk) + + print(f"āœ… Success! Saved as: {filename}.mp3") + print(" (Hit Refresh in the App)") + return {"success": True, "title": filename} + + except requests.exceptions.Timeout: + print("āŒ Request timed out") + return {"success": False, "error": "Request timed out"} + except requests.exceptions.RequestException as e: + print(f"āŒ Network error: {e}") + return {"success": False, "error": str(e)} + except Exception as e: + print(f"āŒ Error: {e}") + return {"success": False, "error": str(e)} + +if __name__ == "__main__": + if not os.path.exists("music"): + os.makedirs("music") + + print("--- TECHDJ DOWNLOADER (via Cobalt API) ---") + while True: + url = input("\nšŸ”— URL (q to quit): ").strip() + if url.lower() == 'q': break + if url: download_mp3(url) diff --git a/edge_glow_test.html b/edge_glow_test.html new file mode 100644 index 0000000..639cfa4 --- /dev/null +++ b/edge_glow_test.html @@ -0,0 +1,162 @@ + + + + + Edge Glow Test + + + + +
+

Edge Glow Test

+ + + + +
Status: No glow
+
+ + + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..0ca66f4 --- /dev/null +++ b/index.html @@ -0,0 +1,473 @@ + + + + + + + TechDJ Pro + + + + +
+

TECHDJ PROTOCOL

+ +

v2.0 // NEON CORE

+
+ + +
+
šŸ“±ā†’šŸ”„
+

OPTIMAL ORIENTATION

+

For the best DJ experience, please rotate your device to landscape mode (sideways).

+

Both decks and the crossfader will be visible simultaneously.

+ +
+ + +
+ + + + + +
+
+ + +
+ + +
+ +
+
+ +
+ +
+ +
+ + + +
+
+ DECK A + NO TRACK LOADED +
+ + +
+ +
+
+ 0:00 / 0:00 + +
+
+ +
+
+
A
+
+
+ + + + +
+ + + + +
+ + +
+ + + +
+ + +
+ +
+ + + + + + + +
+
+ +
+ +
+ + +
+
+
+ +
+ +
+ + +
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + +
+ + + + +
+ + +
+
+ šŸ“‹ QUEUE A + +
+
+
Drop tracks here or click "Queue to A" in library
+
+
+
+ + +
+
+ DECK B + NO TRACK LOADED +
+ + +
+ +
+
+ 0:00 / 0:00 + +
+
+ +
+
+
B
+
+
+ + + + +
+ + + + +
+ + +
+ + + +
+ + +
+ +
+ + + + + + + +
+
+ +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + +
+ + + + +
+ + +
+
+ šŸ“‹ QUEUE B + +
+
+
Drop tracks here or click "Queue to B" in library
+
+
+
+ + +
+ +
+ +
+ + +
+
+ šŸ“” LIVE STREAM + +
+
+
+ +
Offline
+
+ +
+
+ šŸ‘‚ + 0 + Listeners +
+
+ +
+ +
+ + +
+
+ +
+ + +
+ + + Lower = more stable on poor connections +
+
+
+
+ + + + + + + +
+
+ āš™ļø SETTINGS + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ + + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b16028e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +# TechDJ Requirements +flask +flask-socketio +yt-dlp +eventlet +python-dotenv + diff --git a/script.js b/script.js new file mode 100644 index 0000000..bfcbeca --- /dev/null +++ b/script.js @@ -0,0 +1,2940 @@ +// ========================================== +// TechDJ Pro - Core DJ Logic +// ========================================== + +// Server-side audio mode (true = server processes audio, false = browser processes) +const SERVER_SIDE_AUDIO = false; + +let audioCtx; +const decks = { + A: { + type: 'local', + playing: false, + pausedAt: 0, + duration: 0, + localBuffer: null, + localSource: null, + gainNode: null, + volumeGain: null, + crossfaderGain: null, + filters: {}, + cues: {}, + loopStart: null, + loopEnd: null, + loopActive: false, + activeAutoLoop: null, + waveformData: null, + lastAnchorTime: 0, + lastAnchorPosition: 0, + loading: false, + currentFile: null, + lastSeekTime: 0 + }, + B: { + type: 'local', + playing: false, + pausedAt: 0, + duration: 0, + localBuffer: null, + localSource: null, + gainNode: null, + volumeGain: null, + crossfaderGain: null, + filters: {}, + cues: {}, + loopStart: null, + loopEnd: null, + loopActive: false, + activeAutoLoop: null, + waveformData: null, + lastAnchorTime: 0, + lastAnchorPosition: 0, + loading: false, + currentFile: null, + lastSeekTime: 0 + } +}; + +let allSongs = []; +const settings = { + repeatA: false, + repeatB: false, + autoMix: false, + shuffleMode: false, + quantize: false, + autoPlay: true, + glowA: false, + glowB: false, + glowIntensity: 30 +}; + +// Queue system for both decks +const queues = { + A: [], + B: [] +}; + +// System Initialization +function initSystem() { + if (audioCtx) return; + audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + document.getElementById('start-overlay').style.display = 'none'; + + // Track dragging state per deck + const draggingState = { A: false, B: false }; + + // Setup audio path for both decks + ['A', 'B'].forEach(id => { + // Create separate volume and crossfader gain nodes + const volumeGain = audioCtx.createGain(); + const crossfaderGain = audioCtx.createGain(); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + + const low = audioCtx.createBiquadFilter(); + const mid = audioCtx.createBiquadFilter(); + const high = audioCtx.createBiquadFilter(); + const lp = audioCtx.createBiquadFilter(); + const hp = audioCtx.createBiquadFilter(); + + low.type = 'lowshelf'; low.frequency.value = 320; + mid.type = 'peaking'; mid.frequency.value = 1000; mid.Q.value = 1; + high.type = 'highshelf'; high.frequency.value = 3200; + lp.type = 'lowpass'; lp.frequency.value = 22050; // default fully open + hp.type = 'highpass'; hp.frequency.value = 0; // default fully open + + // Connect: Filters -> Volume -> Analyser -> Crossfader -> Destination + low.connect(mid); + mid.connect(high); + high.connect(lp); + lp.connect(hp); + hp.connect(volumeGain); + volumeGain.connect(analyser); + analyser.connect(crossfaderGain); + crossfaderGain.connect(audioCtx.destination); + + // Set default values + volumeGain.gain.value = 0.8; // 80% volume + // Crossfader: A=1.0 at left (val=0), B=1.0 at right (val=100), both 0.5 at center (val=50) + crossfaderGain.gain.value = id === 'A' ? 0.5 : 0.5; // Center position initially + + decks[id].volumeGain = volumeGain; + decks[id].crossfaderGain = crossfaderGain; + decks[id].gainNode = volumeGain; // Keep for compatibility + decks[id].analyser = analyser; + decks[id].filters = { low, mid, high, lp, hp }; + + // Set canvas dimensions properly with DPI scaling + const canvas = document.getElementById('waveform-' + id); + const dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = 80 * dpr; + + // Scale context to match DPI (reset transform first to prevent stacking) + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset to identity matrix + ctx.scale(dpr, dpr); + + // Waveform Scrubbing (Dragging to Seek) + const handleScrub = (e) => { + if (!decks[id].duration) return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX || (e.touches && e.touches[0].clientX)) - rect.left; + const percent = Math.max(0, Math.min(1, x / rect.width)); + seekTo(id, percent * decks[id].duration); + }; + + canvas.addEventListener('mousedown', (e) => { + draggingState[id] = true; + handleScrub(e); + }); + + canvas.addEventListener('mousemove', (e) => { + if (draggingState[id]) handleScrub(e); + }); + + canvas.addEventListener('mouseup', () => { + draggingState[id] = false; + }); + + canvas.addEventListener('mouseleave', () => { + draggingState[id] = false; + }); + + // Mobile Touch Support + canvas.addEventListener('touchstart', (e) => { + draggingState[id] = true; + handleScrub(e); + e.preventDefault(); + }, { passive: false }); + + canvas.addEventListener('touchmove', (e) => { + if (draggingState[id]) { + handleScrub(e); + e.preventDefault(); + } + }, { passive: false }); + + canvas.addEventListener('touchend', () => { + draggingState[id] = false; + }); + + // Disk Scrubbing (Dragging on the Disk) + const disk = document.querySelector(`#deck-${id} .dj-disk`); + let isDiskDragging = false; + + disk.addEventListener('mousedown', (e) => { + isDiskDragging = true; + e.stopPropagation(); // Don't trigger the click/toggleDeck if we're dragging + }); + + disk.addEventListener('mousemove', (e) => { + if (isDiskDragging && decks[id].playing) { + // Scrub based on vertical movement + const movement = e.movementY || 0; + const currentPos = getCurrentPosition(id); + const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1)); + seekTo(id, newPos); + } + }); + + disk.addEventListener('mouseup', () => { + isDiskDragging = false; + }); + + disk.addEventListener('mouseleave', () => { + isDiskDragging = false; + }); + + disk.addEventListener('touchstart', (e) => { + isDiskDragging = true; + e.stopPropagation(); + }, { passive: false }); + + disk.addEventListener('touchmove', (e) => { + if (isDiskDragging && decks[id].playing && e.touches[0]) { + const movement = e.touches[0].clientY - (disk._lastTouchY || e.touches[0].clientY); + disk._lastTouchY = e.touches[0].clientY; + const currentPos = getCurrentPosition(id); + const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1)); + seekTo(id, newPos); + } + }, { passive: false }); + + disk.addEventListener('touchend', () => { + isDiskDragging = false; + delete disk._lastTouchY; + }); + + // Handle click for toggle (only if not scrubbing) + disk.addEventListener('click', (e) => { + if (!isDiskDragging) { + toggleDeck(id); + } + }); + }); + + fetchLibrary(); + updateTimeDisplays(); + animateVUMeters(); // Start VU meter animation + + // Handle resize for DPI scaling + window.addEventListener('resize', () => { + ['A', 'B'].forEach(id => { + const canvas = document.getElementById('waveform-' + id); + if (!canvas) return; + const dpr = window.devicePixelRatio || 1; + canvas.width = canvas.offsetWidth * dpr; + canvas.height = 80 * dpr; + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + drawWaveform(id); + }); + }); + + // Initialize mobile view + if (window.innerWidth <= 1024) { + switchTab('library'); + } +} + +// VU Meter Animation with smoothing +const vuMeterState = { + A: { smoothedValues: [], peakValues: [] }, + B: { smoothedValues: [], peakValues: [] } +}; + +function animateVUMeters() { + requestAnimationFrame(animateVUMeters); + + const anyPlaying = decks.A.playing || decks.B.playing; + const isListener = window.location.port === '5001' || window.location.search.includes('listen=true'); + + // Skip rendering if nothing is happening to save battery + if (!anyPlaying && !isListener) return; + + ['A', 'B'].forEach(id => { + const canvas = document.getElementById('viz-' + id); + if (!canvas || !decks[id].analyser) return; + + const ctx = canvas.getContext('2d'); + const analyser = decks[id].analyser; + const bufferLength = analyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + analyser.getByteFrequencyData(dataArray); + + const width = canvas.width; + const height = canvas.height; + const barCount = 32; + const barWidth = width / barCount; + + // Initialize smoothed values if needed + if (!vuMeterState[id].smoothedValues.length) { + vuMeterState[id].smoothedValues = new Array(barCount).fill(0); + vuMeterState[id].peakValues = new Array(barCount).fill(0); + } + + ctx.fillStyle = '#0a0a12'; + ctx.fillRect(0, 0, width, height); + + for (let i = 0; i < barCount; i++) { + // Use logarithmic frequency distribution for better bass/mid/treble representation + const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength); + const rawValue = dataArray[freqIndex] / 255; + + // Smooth the values for less jittery animation + const smoothingFactor = 0.7; // Higher = smoother but less responsive + const targetValue = rawValue; + vuMeterState[id].smoothedValues[i] = + (vuMeterState[id].smoothedValues[i] * smoothingFactor) + + (targetValue * (1 - smoothingFactor)); + + const value = vuMeterState[id].smoothedValues[i]; + const barHeight = value * height; + + // Peak hold with decay + if (value > vuMeterState[id].peakValues[i]) { + vuMeterState[id].peakValues[i] = value; + } else { + vuMeterState[id].peakValues[i] *= 0.95; // Decay rate + } + + // Draw main bar with gradient + const hue = id === 'A' ? 180 : 280; // Cyan for A, Magenta for B + const saturation = 100; + const lightness = 30 + (value * 50); + + // Create gradient for each bar + const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight); + gradient.addColorStop(0, `hsl(${hue}, ${saturation}%, ${lightness}%)`); + gradient.addColorStop(1, `hsl(${hue}, ${saturation}%, ${Math.min(lightness + 20, 80)}%)`); + + ctx.fillStyle = gradient; + ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight); + + // Draw peak indicator + const peakY = height - (vuMeterState[id].peakValues[i] * height); + ctx.fillStyle = `hsl(${hue}, 100%, 70%)`; + ctx.fillRect(i * barWidth, peakY - 2, barWidth - 2, 2); + } + }); +} + +// Play/Pause Toggle for Disk +function toggleDeck(id) { + if (decks[id].playing) { + pauseDeck(id); + } else { + playDeck(id); + } +} + +// Mobile Tab Switching +function switchTab(tabId) { + const container = document.querySelector('.app-container'); + const buttons = document.querySelectorAll('.tab-btn'); + + // Remove all tab classes + container.classList.remove('show-library', 'show-deck-A', 'show-deck-B'); + buttons.forEach(btn => btn.classList.remove('active')); + + // Add active class and button state + container.classList.add('show-' + tabId); + + // Find the button and activate it + buttons.forEach(btn => { + if (btn.getAttribute('onclick').includes(tabId)) { + btn.classList.add('active'); + } + }); + + // Redraw waveforms if switching to a deck + if (tabId.startsWith('deck')) { + const id = tabId.split('-')[1]; + setTimeout(() => drawWaveform(id), 100); + } +} + +// Waveform Generation (Optimized for Speed) +function generateWaveformData(buffer) { + const rawData = buffer.getChannelData(0); + const samples = 1000; + const blockSize = Math.floor(rawData.length / samples); + // Only process a subset of samples for huge speed gain + const step = Math.max(1, Math.floor(blockSize / 20)); + const filteredData = []; + + for (let i = 0; i < samples; i++) { + let blockStart = blockSize * i; + let sum = 0; + let count = 0; + for (let j = 0; j < blockSize; j += step) { + const index = blockStart + j; + // Bounds check to prevent NaN values + if (index < rawData.length) { + sum += Math.abs(rawData[index]); + count++; + } + } + filteredData.push(sum / (count || 1)); + } + return filteredData; +} + +function drawWaveform(id) { + const canvas = document.getElementById('waveform-' + id); + if (!canvas) return; // Null check + const ctx = canvas.getContext('2d'); + const data = decks[id].waveformData; + if (!data) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + const width = canvas.width; + const height = canvas.height; + const barWidth = width / data.length; + + data.forEach((val, i) => { + const h = val * height * 5; + ctx.fillStyle = id === 'A' ? '#00f3ff' : '#bc13fe'; + ctx.fillRect(i * barWidth, (height - h) / 2, barWidth, h); + }); + + // Draw Cues + if (decks[id].duration > 0) { + Object.values(decks[id].cues).forEach(time => { + const x = (time / decks[id].duration) * width; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, height); + ctx.stroke(); + }); + } + + // Draw Loop Markers + if (decks[id].loopStart !== null && decks[id].duration > 0) { + const xIn = (decks[id].loopStart / decks[id].duration) * width; + ctx.strokeStyle = '#ffbb00'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 3]); + ctx.beginPath(); + ctx.moveTo(xIn, 0); + ctx.lineTo(xIn, height); + ctx.stroke(); + + if (decks[id].loopActive && decks[id].loopEnd !== null) { + const xOut = (decks[id].loopEnd / decks[id].duration) * width; + ctx.strokeStyle = '#ffaa33'; + ctx.beginPath(); + ctx.moveTo(xOut, 0); + ctx.lineTo(xOut, height); + ctx.stroke(); + + // Shade loop area + ctx.fillStyle = 'rgba(255, 187, 0, 0.15)'; + ctx.fillRect(xIn, 0, xOut - xIn, height); + } + ctx.setLineDash([]); + } +} + +// BPM Detection (Optimized: Only check middle 60 seconds for speed) +function detectBPM(buffer) { + const sampleRate = buffer.sampleRate; + const duration = buffer.duration; + + // Pick a 60s window in the middle + const startOffset = Math.max(0, Math.floor((duration / 2 - 30) * sampleRate)); + const endOffset = Math.min(buffer.length, Math.floor((duration / 2 + 30) * sampleRate)); + const data = buffer.getChannelData(0).slice(startOffset, endOffset); + + const bpmRange = [60, 180]; + const windowSize = Math.floor(sampleRate * 60 / bpmRange[1]); + + let peaks = 0; + let threshold = 0; + for (let i = 0; i < data.length; i += windowSize) { + const slice = data.slice(i, i + windowSize); + const avg = slice.reduce((a, b) => a + Math.abs(b), 0) / slice.length; + if (avg > threshold) { + peaks++; + threshold = avg * 0.8; + } + } + + const windowDuration = data.length / sampleRate; + const bpm = Math.round((peaks / windowDuration) * 60); + return bpm > bpmRange[0] && bpm < bpmRange[1] ? bpm : 0; +} + +// Time Display Updates +function updateTimeDisplays() { + requestAnimationFrame(updateTimeDisplays); + + // Only update if at least one deck is playing + const anyPlaying = decks.A.playing || decks.B.playing; + if (!anyPlaying) return; + + ['A', 'B'].forEach(id => { + if (decks[id].playing && decks[id].localBuffer) { + const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + + // Handle Looping wrapping for UI + if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { + const loopLen = decks[id].loopEnd - decks[id].loopStart; + if (current >= decks[id].loopEnd && loopLen > 0) { + current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart; + } + } else if (settings[`repeat${id}`]) { + // Full song repeat wrapping for UI + current = current % decks[id].duration; + } else { + current = Math.min(current, decks[id].duration); + } + + document.getElementById('time-current-' + id).textContent = formatTime(current); + + // Update playhead + const progress = (current / decks[id].duration) * 100; + const playhead = document.getElementById('playhead-' + id); + if (playhead) playhead.style.left = progress + '%'; + } + }); +} + +function getCurrentPosition(id) { + if (!decks[id].playing) return decks[id].pausedAt; + if (!audioCtx) return decks[id].pausedAt; + + const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + + // Handle wrapping for correct position return + if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { + const loopLen = decks[id].loopEnd - decks[id].loopStart; + if (pos >= decks[id].loopEnd && loopLen > 0) { + pos = ((pos - decks[id].loopStart) % loopLen) + decks[id].loopStart; + } + } else if (settings[`repeat${id}`] && decks[id].duration > 0) { + pos = pos % decks[id].duration; + } else { + pos = Math.min(decks[id].duration, pos); + } + + return pos; +} + +function formatTime(seconds) { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +// Playback Logic +function playDeck(id) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_play', { deck: id }); + decks[id].playing = true; + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.add('playing'); + console.log(`[Deck ${id}] Play command sent to server`); + return; + } + + // Browser-side audio mode (original code) + if (decks[id].type === 'local' && decks[id].localBuffer) { + if (decks[id].playing) return; + + try { + console.log(`[Deck ${id}] Starting playback from ${decks[id].pausedAt}s`); + decks[id].playing = true; + seekTo(id, decks[id].pausedAt); + + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.add('playing'); + + if (audioCtx.state === 'suspended') { + console.log(`[Deck ${id}] Resuming suspended AudioContext`); + audioCtx.resume(); + } + + // Auto-start broadcast if enabled and not already broadcasting + if (autoStartStream && !isBroadcasting && audioCtx) { + setTimeout(() => { + if (!socket) initSocket(); + setTimeout(() => startBroadcast(), 500); + }, 100); + } + } catch (error) { + console.error(`[Deck ${id}] Playback error:`, error); + decks[id].playing = false; + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.remove('playing'); + alert(`Playback error: ${error.message}`); + } + } else { + console.warn(`[Deck ${id}] Cannot play - no buffer loaded`); + } +} + +function pauseDeck(id) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_pause', { deck: id }); + decks[id].playing = false; + document.getElementById('deck-' + id).classList.remove('playing'); + console.log(`[Deck ${id}] Pause command sent to server`); + return; + } + + // Browser-side audio mode (original code) + if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) { + if (!audioCtx) { + console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialized`); + decks[id].playing = false; + } else { + const playbackRate = decks[id].localSource.playbackRate.value; + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + decks[id].pausedAt = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + + try { + decks[id].localSource.stop(); + decks[id].localSource.onended = null; + } catch (e) { } + + decks[id].localSource = null; + decks[id].playing = false; + } + } + document.getElementById('deck-' + id).classList.remove('playing'); +} + +function seekTo(id, time) { + // Update local state and timestamp for seek protection + decks[id].lastSeekTime = Date.now(); + + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_seek', { deck: id, position: time }); + + // Update local state immediately for UI responsiveness + decks[id].lastAnchorPosition = time; + if (!decks[id].playing) { + decks[id].pausedAt = time; + } + + // Update UI immediately (Optimistic UI) + const progress = (time / decks[id].duration) * 100; + const playhead = document.getElementById('playhead-' + id); + if (playhead) playhead.style.left = progress + '%'; + + const timer = document.getElementById('time-current-' + id); + if (timer) timer.textContent = formatTime(time); + + return; + } + + if (!decks[id].localBuffer) { + console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`); + return; + } + + try { + if (decks[id].playing) { + if (decks[id].localSource) { + try { + decks[id].localSource.stop(); + decks[id].localSource.onended = null; + } catch (e) { } + } + + const src = audioCtx.createBufferSource(); + src.buffer = decks[id].localBuffer; + + if (decks[id].loopActive) { + src.loop = true; + src.loopStart = decks[id].loopStart || 0; + src.loopEnd = decks[id].loopEnd || decks[id].duration; + } + + src.connect(decks[id].filters.low); + const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); + const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0; + src.playbackRate.value = speed; + + decks[id].localSource = src; + decks[id].lastAnchorTime = audioCtx.currentTime; + decks[id].lastAnchorPosition = time; + + // Add error handler for the source + src.onended = () => { + console.log(`[Deck ${id}] Playback ended naturally`); + }; + + src.start(0, time); + console.log(`[Deck ${id}] Playback started at ${time}s with speed ${speed}x`); + } else { + decks[id].pausedAt = time; + decks[id].lastAnchorPosition = time; + if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); + + // Update UI immediately (Manual Position) + const timer = document.getElementById('time-current-' + id); + if (timer) timer.textContent = formatTime(time); + const progress = (time / decks[id].duration) * 100; + const playhead = document.getElementById('playhead-' + id); + if (playhead) playhead.style.left = progress + '%'; + } + } catch (error) { + console.error(`[Deck ${id}] SeekTo error:`, error); + // Reset playing state on error + decks[id].playing = false; + const deckEl = document.getElementById('deck-' + id); + if (deckEl) deckEl.classList.remove('playing'); + document.body.classList.remove('playing-' + id); + } +} + +function changeSpeed(id, val) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) }); + return; + } + + // Browser-side audio mode + if (!audioCtx || !decks[id].localSource) return; + + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value; + decks[id].lastAnchorTime = audioCtx.currentTime; + + decks[id].localSource.playbackRate.value = parseFloat(val); +} + +function changeVolume(id, val) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_set_volume', { deck: id, volume: val / 100 }); + return; + } + + // Browser-side audio mode + if (decks[id].volumeGain) { + decks[id].volumeGain.gain.value = val / 100; + } +} + +function changeEQ(id, band, val) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) }); + return; + } + + // Browser-side audio mode + if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val); +} + +function changeFilter(id, type, val) { + if (!audioCtx || !decks[id].filters) return; + const filter = type === 'lowpass' ? decks[id].filters.lp : decks[id].filters.hp; + if (!filter) return; + + // Use exponential scaling for filter frequency (more musical) + if (type === 'lowpass') { + // Low-pass: 0 = low freq (muffled), 100 = high freq (open) + const freq = 20 * Math.pow(22050 / 20, val / 100); + filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05); + } else { + // High-pass: 0 = low freq (open), 100 = high freq (thin) + const freq = 20 * Math.pow(22050 / 20, val / 100); + filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05); + } +} + +// Hot Cue Functionality +function handleCue(id, cueNum) { + if (!decks[id].localBuffer) { + console.warn(`[Deck ${id}] No track loaded - cannot set cue`); + return; + } + + // If cue exists, jump to it and start playing + if (decks[id].cues[cueNum] !== undefined) { + const cueTime = decks[id].cues[cueNum]; + console.log(`[Deck ${id}] Jumping to cue ${cueNum} at ${cueTime.toFixed(2)}s`); + + // Always seek to cue point + seekTo(id, cueTime); + + // Auto-play when triggering cue (like real DJ equipment) + if (!decks[id].playing) { + playDeck(id); + } + + // Visual feedback + const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; + if (cueBtn) { + cueBtn.classList.add('cue-triggered'); + setTimeout(() => cueBtn.classList.remove('cue-triggered'), 200); + } + } else { + // Set new cue at current position + const currentTime = getCurrentPosition(id); + decks[id].cues[cueNum] = currentTime; + + console.log(`[Deck ${id}] Set cue ${cueNum} at ${currentTime.toFixed(2)}s`); + + const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; + if (cueBtn) { + cueBtn.classList.add('cue-set'); + // Flash to show it was set + cueBtn.style.animation = 'none'; + setTimeout(() => { + cueBtn.style.animation = 'flash 0.3s ease'; + }, 10); + } + + drawWaveform(id); + } +} + +function clearCue(id, cueNum) { + delete decks[id].cues[cueNum]; + const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; + if (cueBtn) cueBtn.classList.remove('cue-set'); + drawWaveform(id); +} + +function setLoop(id, action) { + if (!decks[id].localBuffer) { + console.warn(`[Deck ${id}] No track loaded - cannot set loop`); + return; + } + + const currentTime = getCurrentPosition(id); + + if (action === 'in') { + // Set loop start point + decks[id].loopStart = currentTime; + decks[id].loopEnd = null; // Clear loop end + decks[id].loopActive = false; // Not active until OUT is set + + console.log(`[Deck ${id}] Loop IN set at ${currentTime.toFixed(2)}s`); + + // Visual feedback + const loopInBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(1)`); + if (loopInBtn) { + loopInBtn.style.background = 'rgba(255, 187, 0, 0.3)'; + loopInBtn.style.borderColor = '#ffbb00'; + } + + } else if (action === 'out') { + // Set loop end point and activate + if (decks[id].loopStart === null) { + // If no loop start, set it to beginning + decks[id].loopStart = 0; + console.log(`[Deck ${id}] Auto-set loop IN at 0s`); + } + + if (currentTime > decks[id].loopStart) { + decks[id].loopEnd = currentTime; + decks[id].loopActive = true; + + const loopLength = decks[id].loopEnd - decks[id].loopStart; + console.log(`[Deck ${id}] Loop OUT set at ${currentTime.toFixed(2)}s (${loopLength.toFixed(2)}s loop)`); + + // Visual feedback + const loopOutBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(2)`); + if (loopOutBtn) { + loopOutBtn.style.background = 'rgba(255, 187, 0, 0.3)'; + loopOutBtn.style.borderColor = '#ffbb00'; + } + + // Restart playback to apply loop immediately + if (decks[id].playing) { + const currentPos = getCurrentPosition(id); + seekTo(id, currentPos); + } + } else { + console.warn(`[Deck ${id}] Loop OUT must be after Loop IN`); + alert('Loop OUT must be after Loop IN!'); + } + + } else if (action === 'exit') { + // Get current position BEFORE clearing loop state + const currentPos = getCurrentPosition(id); + + // Exit/clear loop + decks[id].loopActive = false; + decks[id].loopStart = null; + decks[id].loopEnd = null; + + console.log(`[Deck ${id}] Loop cleared at position ${currentPos.toFixed(2)}s`); + + // Clear visual feedback + const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`); + loopBtns.forEach(btn => { + btn.style.background = ''; + btn.style.borderColor = ''; + }); + + // Restart playback from current loop position to continue smoothly + if (decks[id].playing) { + seekTo(id, currentPos); + } else { + // If paused, just update the paused position + decks[id].pausedAt = currentPos; + } + } + + drawWaveform(id); +} + +// Auto-Loop with Beat Lengths (you.dj style) +function setAutoLoop(id, beats) { + if (!decks[id].localBuffer) { + console.warn(`[Deck ${id}] No track loaded - cannot set auto-loop`); + return; + } + + // Check if clicking the same active loop - if so, disable it + if (decks[id].activeAutoLoop === beats && decks[id].loopActive) { + // Get current position WITHIN the loop before disabling + const currentPos = getCurrentPosition(id); + + // Disable loop + decks[id].loopActive = false; + decks[id].loopStart = null; + decks[id].loopEnd = null; + decks[id].activeAutoLoop = null; + + console.log(`[Deck ${id}] Auto-loop ${beats} beats disabled at position ${currentPos.toFixed(2)}s`); + + // Update UI + updateAutoLoopButtons(id); + drawWaveform(id); + + // Restart playback from current loop position to continue smoothly + if (decks[id].playing) { + seekTo(id, currentPos); + } else { + // If paused, just update the paused position + decks[id].pausedAt = currentPos; + } + return; + } + + // Check if BPM is detected + if (!decks[id].bpm || decks[id].bpm === 0) { + alert(`Cannot set auto-loop: BPM not detected for Deck ${id}.\nPlease use manual loop controls (LOOP IN/OUT).`); + console.warn(`[Deck ${id}] BPM not detected - cannot calculate auto-loop`); + return; + } + + const currentTime = getCurrentPosition(id); + const bpm = decks[id].bpm; + + // Calculate loop length in seconds: (beats / BPM) * 60 + const loopLength = (beats / bpm) * 60; + + // Set loop points + decks[id].loopStart = currentTime; + decks[id].loopEnd = currentTime + loopLength; + decks[id].loopActive = true; + decks[id].activeAutoLoop = beats; + + console.log(`[Deck ${id}] Auto-loop set: ${beats} beats = ${loopLength.toFixed(3)}s at ${bpm} BPM`); + + // Update UI + updateAutoLoopButtons(id); + drawWaveform(id); + + // Restart playback to apply loop immediately + if (decks[id].playing) { + seekTo(id, currentTime); + } +} + +// Update auto-loop button visual states +function updateAutoLoopButtons(id) { + const buttons = document.querySelectorAll(`#deck-${id} .auto-loop-btn`); + buttons.forEach(btn => { + const btnBeats = parseFloat(btn.dataset.beats); + if (decks[id].activeAutoLoop === btnBeats && decks[id].loopActive) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); +} + +function pitchBend(id, amount) { + if (!audioCtx || !decks[id].localSource) return; + + const slider = document.querySelector(`#deck-${id} .speed-slider`); + const baseSpeed = parseFloat(slider.value); + + // Re-anchor + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value; + decks[id].lastAnchorTime = audioCtx.currentTime; + + if (amount !== 0) { + decks[id].localSource.playbackRate.value = baseSpeed + amount; + } else { + decks[id].localSource.playbackRate.value = baseSpeed; + } +} + +function syncDecks(id) { + const otherDeck = id === 'A' ? 'B' : 'A'; + if (!decks[id].bpm || !decks[otherDeck].bpm) return; + + // Calculate ratio to make other deck match this deck + const ratio = decks[id].bpm / decks[otherDeck].bpm; + const slider = document.querySelector(`#deck-${otherDeck} .speed-slider`); + if (slider) slider.value = ratio; + changeSpeed(otherDeck, ratio); +} + +function updateCrossfader(val) { + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + if (!socket) initSocket(); + socket.emit('audio_set_crossfader', { value: parseInt(val) }); + return; + } + + // Browser-side audio mode + const volA = (100 - val) / 100; + const volB = val / 100; + if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA; + if (decks.B.crossfaderGain) decks.B.crossfaderGain.gain.value = volB; +} + +// Library Functions +async function fetchLibrary() { + try { + const res = await fetch('library.json?t=' + new Date().getTime()); + allSongs = await res.json(); + renderLibrary(allSongs); + } catch (e) { + console.error("Library fetch failed", e); + } +} + +function renderLibrary(songs) { + const list = document.getElementById('library-list'); + list.innerHTML = ''; + if (songs.length === 0) { + list.innerHTML = '
Library empty. Download some music!
'; + return; + } + songs.forEach(t => { + const item = document.createElement('div'); + item.className = 'track-row'; + + const trackName = document.createElement('span'); + trackName.className = 'track-name'; + trackName.textContent = t.title; // Safe text assignment + + const loadActions = document.createElement('div'); + loadActions.className = 'load-actions'; + + // LOAD buttons + const btnA = document.createElement('button'); + btnA.className = 'load-btn btn-a'; + btnA.textContent = 'LOAD A'; + btnA.addEventListener('click', () => loadFromServer('A', t.file, t.title)); + + const btnB = document.createElement('button'); + btnB.className = 'load-btn btn-b'; + btnB.textContent = 'LOAD B'; + btnB.addEventListener('click', () => loadFromServer('B', t.file, t.title)); + + // QUEUE buttons + const queueA = document.createElement('button'); + queueA.className = 'load-btn queue-btn-a'; + queueA.textContent = 'šŸ“‹ Q-A'; + queueA.title = 'Add to Queue A'; + queueA.addEventListener('click', () => addToQueue('A', t.file, t.title)); + + const queueB = document.createElement('button'); + queueB.className = 'load-btn queue-btn-b'; + queueB.textContent = 'šŸ“‹ Q-B'; + queueB.title = 'Add to Queue B'; + queueB.addEventListener('click', () => addToQueue('B', t.file, t.title)); + + loadActions.appendChild(btnA); + loadActions.appendChild(queueA); + loadActions.appendChild(btnB); + loadActions.appendChild(queueB); + item.appendChild(trackName); + item.appendChild(loadActions); + + // Add data attribute for highlighting + item.dataset.file = t.file; + + list.appendChild(item); + }); + + // Update highlighting after rendering + updateLibraryHighlighting(); +} + +// Update library highlighting to show which tracks are loaded +function updateLibraryHighlighting() { + const trackRows = document.querySelectorAll('.track-row'); + + trackRows.forEach(row => { + const file = row.dataset.file; + + // Remove all deck classes + row.classList.remove('loaded-deck-a', 'loaded-deck-b', 'loaded-both'); + + const onDeckA = decks.A.currentFile && decks.A.currentFile.includes(file); + const onDeckB = decks.B.currentFile && decks.B.currentFile.includes(file); + + if (onDeckA && onDeckB) { + row.classList.add('loaded-both'); + } else if (onDeckA) { + row.classList.add('loaded-deck-a'); + } else if (onDeckB) { + row.classList.add('loaded-deck-b'); + } + }); +} + +// Utility function no longer needed but kept for future use +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function filterLibrary() { + const query = document.getElementById('lib-search').value.toLowerCase(); + const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query)); + renderLibrary(filtered); +} + +function refreshLibrary() { + fetchLibrary(); +} + +// YouTube Search Functions +let youtubeSearchTimeout = null; + +function handleYouTubeSearch(query) { + // Debounce search - wait 300ms after user stops typing + clearTimeout(youtubeSearchTimeout); + + if (!query || query.trim().length < 2) { + document.getElementById('youtube-results').innerHTML = ''; + return; + } + + youtubeSearchTimeout = setTimeout(() => { + searchYouTube(query.trim()); + }, 300); +} + +async function searchYouTube(query) { + const resultsDiv = document.getElementById('youtube-results'); + resultsDiv.innerHTML = '
šŸ” Searching YouTube...
'; + + try { + const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`); + const data = await response.json(); + + if (!data.success) { + resultsDiv.innerHTML = `
āŒ ${data.error}
`; + return; + } + + displayYouTubeResults(data.results); + } catch (error) { + console.error('YouTube search error:', error); + resultsDiv.innerHTML = '
āŒ Search failed. Check console.
'; + } +} + +function displayYouTubeResults(results) { + const resultsDiv = document.getElementById('youtube-results'); + + if (results.length === 0) { + resultsDiv.innerHTML = '
No results found
'; + return; + } + + resultsDiv.innerHTML = ''; + + results.forEach(result => { + const resultCard = document.createElement('div'); + resultCard.className = 'youtube-result-card'; + + resultCard.innerHTML = ` + ${result.title} +
+
${result.title}
+
${result.channel}
+
+ + `; + + resultsDiv.appendChild(resultCard); + }); +} + +async function downloadYouTubeResult(url, title) { + const resultsDiv = document.getElementById('youtube-results'); + const originalContent = resultsDiv.innerHTML; + + resultsDiv.innerHTML = '
ā³ Downloading...
'; + + try { + const response = await fetch('/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: url, quality: '320' }) + }); + + const result = await response.json(); + + if (result.success) { + resultsDiv.innerHTML = '
āœ… Downloaded! Refreshing library...
'; + await fetchLibrary(); + setTimeout(() => { + resultsDiv.innerHTML = originalContent; + }, 2000); + } else { + resultsDiv.innerHTML = `
āŒ Download failed: ${result.error}
`; + setTimeout(() => { + resultsDiv.innerHTML = originalContent; + }, 3000); + } + } catch (error) { + console.error('Download error:', error); + resultsDiv.innerHTML = '
āŒ Download failed
'; + setTimeout(() => { + resultsDiv.innerHTML = originalContent; + }, 3000); + } +} + +async function loadFromServer(id, url, title) { + const d = document.getElementById('display-' + id); + d.innerText = 'ā³ LOADING...'; + d.classList.add('blink'); + + console.log(`[Deck ${id}] Loading: ${title} from ${url}`); + + // Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform + if (SERVER_SIDE_AUDIO) { + // Extract filename from URL and DECODE IT (for spaces etc) + const filename = decodeURIComponent(url.split('/').pop()); + + if (!socket) initSocket(); + socket.emit('audio_load_track', { deck: id, filename: filename }); + console.log(`[Deck ${id}] šŸ“” Load command sent to server: ${filename}`); + + // We DON'T return here anymore. We continue below to load for the UI. + } + + // Browser-side audio mode (original code) + const wasPlaying = decks[id].playing; + const wasBroadcasting = isBroadcasting; + + if (wasPlaying && !wasBroadcasting) { + pauseDeck(id); + console.log(`[Deck ${id}] Paused for song load`); + } else if (wasPlaying && wasBroadcasting) { + console.log(`[Deck ${id}] ⚔ BROADCAST MODE: Keeping deck playing during load to maintain stream`); + } + + decks[id].waveformData = null; + decks[id].bpm = null; + decks[id].cues = {}; + decks[id].pausedAt = 0; + decks[id].currentFile = url; + + // Clear UI state + const bpmEl = document.getElementById('bpm-' + id); + if (bpmEl) bpmEl.textContent = ''; + const cueBtns = document.querySelectorAll(`#deck-${id} .cue-btn`); + cueBtns.forEach(btn => btn.classList.remove('cue-set')); + + try { + // Fetch the audio file + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); + + console.log(`[Deck ${id}] Fetch successful, decoding audio...`); + const arrayBuffer = await res.arrayBuffer(); + + // Decode audio data + let buffer; + try { + buffer = await audioCtx.decodeAudioData(arrayBuffer); + } catch (decodeError) { + console.error(`[Deck ${id}] Audio decode failed:`, decodeError); + throw new Error(`Cannot decode audio file`); + } + + console.log(`[Deck ${id}] Successfully decoded! Duration: ${buffer.duration}s`); + + // Track is now ready! + decks[id].localBuffer = buffer; + decks[id].duration = buffer.duration; + decks[id].lastAnchorPosition = 0; + + // Update UI - track is ready! + d.innerText = title.toUpperCase(); + d.classList.remove('blink'); + document.getElementById('time-total-' + id).textContent = formatTime(buffer.duration); + document.getElementById('time-current-' + id).textContent = '0:00'; + + console.log(`[Deck ${id}] Ready to play!`); + + // AUTO-RESUME for broadcast continuity + if (wasPlaying && wasBroadcasting) { + console.log(`[Deck ${id}] šŸŽµ Auto-resuming playback to maintain broadcast stream`); + // Small delay to ensure buffer is fully ready + setTimeout(() => { + playDeck(id); + }, 50); + } + + // Update library highlight + updateLibraryHighlighting(); + + // Process waveform and BPM in background + const processInBackground = () => { + try { + console.log(`[Deck ${id}] Starting background processing...`); + + // Generate waveform + const startWave = performance.now(); + decks[id].waveformData = generateWaveformData(buffer); + console.log(`[Deck ${id}] Waveform generated in ${(performance.now() - startWave).toFixed(0)}ms`); + drawWaveform(id); + + // Detect BPM + const startBPM = performance.now(); + decks[id].bpm = detectBPM(buffer); + console.log(`[Deck ${id}] BPM detected in ${(performance.now() - startBPM).toFixed(0)}ms`); + if (decks[id].bpm) { + const bpmEl = document.getElementById('bpm-' + id); + if (bpmEl) bpmEl.textContent = `${decks[id].bpm} BPM`; + } + } catch (bgError) { + console.warn(`[Deck ${id}] Background processing error:`, bgError); + } + }; + + if (window.requestIdleCallback) { + requestIdleCallback(processInBackground, { timeout: 2000 }); + } else { + setTimeout(processInBackground, 50); + } + + } catch (error) { + console.error(`[Deck ${id}] Load error:`, error); + d.innerText = 'LOAD ERROR'; + d.classList.remove('blink'); + setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000); + } +} + +// Download Functionality +async function downloadFromPanel(deckId) { + const input = document.getElementById('search-input-' + deckId); + const statusDiv = document.getElementById('download-status-' + deckId); + const qualitySelect = document.getElementById('quality-' + deckId); + const url = input.value.trim(); + if (!url) return; + + statusDiv.innerHTML = '
Downloading...
'; + try { + const res = await fetch('/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: url, quality: qualitySelect.value }) + }); + const result = await res.json(); + if (result.success) { + statusDiv.innerHTML = 'āœ… Complete!'; + fetchLibrary(); + setTimeout(() => statusDiv.innerHTML = '', 3000); + } else { + statusDiv.innerHTML = 'āŒ Failed'; + } + } catch (e) { + statusDiv.innerHTML = 'āŒ Error'; + } +} + +// Settings +function toggleSettings() { + const p = document.getElementById('settings-panel'); + p.classList.toggle('active'); +} + +// File Upload +async function handleFileUpload(event) { + const files = event.target.files; + if (!files || files.length === 0) return; + + console.log(`šŸ“ Uploading ${files.length} file(s)...`); + + for (let file of files) { + if (!file.type.match('audio/mpeg') && !file.name.endsWith('.mp3')) { + alert(`${file.name} is not an MP3 file`); + continue; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.success) { + console.log(`āœ… Uploaded: ${file.name}`); + } else { + console.error(`āŒ Upload failed: ${result.error}`); + alert(`Failed to upload ${file.name}: ${result.error}`); + } + } catch (error) { + console.error(`āŒ Upload error: ${error}`); + alert(`Error uploading ${file.name}`); + } + } + + // Refresh library + console.log('šŸ”„ Refreshing library...'); + await loadLibrary(); + alert(`āœ… ${files.length} file(s) uploaded successfully!`); + + // Clear the input so the same file can be uploaded again if needed + event.target.value = ''; +} + +function toggleRepeat(id, val) { settings[`repeat${id}`] = val; } +function toggleAutoMix(val) { settings.autoMix = val; } +function toggleShuffle(val) { settings.shuffleMode = val; } +function toggleQuantize(val) { settings.quantize = val; } +function toggleAutoPlay(val) { settings.autoPlay = val; } + +function updateManualGlow(id, val) { + settings[`glow${id}`] = val; + if (val) { + document.body.classList.add(`playing-${id}`); + } else { + document.body.classList.remove(`playing-${id}`); + } +} + +function updateGlowIntensity(val) { + settings.glowIntensity = parseInt(val); + const opacity = settings.glowIntensity / 100; + const spread = (settings.glowIntensity / 100) * 80; + + // Dynamically update CSS variables for the glow + document.documentElement.style.setProperty('--glow-opacity', opacity); + document.documentElement.style.setProperty('--glow-spread', `${spread}px`); +} + +// Dismiss landscape prompt +function dismissLandscapePrompt() { + const prompt = document.getElementById('landscape-prompt'); + if (prompt) { + prompt.classList.add('dismissed'); + // Store preference in localStorage + localStorage.setItem('landscapePromptDismissed', 'true'); + } +} + +// Check if prompt was previously dismissed +window.addEventListener('DOMContentLoaded', () => { + const wasDismissed = localStorage.getItem('landscapePromptDismissed'); + if (wasDismissed === 'true') { + const prompt = document.getElementById('landscape-prompt'); + if (prompt) prompt.classList.add('dismissed'); + } + + // Initialize glow intensity + updateGlowIntensity(settings.glowIntensity); + const glowAToggle = document.getElementById('glow-A'); + if (glowAToggle) glowAToggle.checked = settings.glowA; + const glowBToggle = document.getElementById('glow-B'); + if (glowBToggle) glowBToggle.checked = settings.glowB; + const intensitySlider = document.getElementById('glow-intensity'); + if (intensitySlider) intensitySlider.value = settings.glowIntensity; + + // Apply initial glow state + updateManualGlow('A', settings.glowA); + updateManualGlow('B', settings.glowB); + + // Dual-Port Logic: Port 5001 is for Listeners, 5000 is for DJs + const isListenerPort = window.location.port === '5001'; + const urlParams = new URLSearchParams(window.location.search); + + if (isListenerPort || urlParams.get('listen') === 'true') { + initListenerMode(); + } + + if (!isListenerPort) { + // Set stream URL to the dedicated listener port (5001) + const streamUrl = `${window.location.protocol}//${window.location.hostname}:5001`; + const streamInput = document.getElementById('stream-url'); + if (streamInput) streamInput.value = streamUrl; + } +}); + +// ========== LIVE STREAMING FUNCTIONALITY ========== + +let socket = null; +let streamDestination = null; +let streamProcessor = null; +let isBroadcasting = false; +let autoStartStream = false; +let listenerAudioContext = null; +let listenerGainNode = null; + +// Initialize SocketIO connection +function initSocket() { + if (socket) return socket; + + // Log connection details + const serverUrl = window.location.origin; + console.log(`šŸ”Œ Initializing Socket.IO connection to: ${serverUrl}`); + console.log(` Protocol: ${window.location.protocol}`); + console.log(` Host: ${window.location.host}`); + + socket = io({ + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 10 + }); + + socket.on('connect', () => { + console.log('āœ… Connected to streaming server'); + console.log(` Socket ID: ${socket.id}`); + console.log(` Transport: ${socket.io.engine.transport.name}`); + }); + + socket.on('connect_error', (error) => { + console.error('āŒ Connection error:', error.message); + console.error(' Make sure server is running on', serverUrl); + }); + + socket.on('disconnect', (reason) => { + console.log('āŒ Disconnected from streaming server'); + console.log(` Reason: ${reason}`); + }); + + socket.on('listener_count', (data) => { + const el = document.getElementById('listener-count'); + if (el) el.textContent = data.count; + }); + + socket.on('broadcast_started', () => { + console.log('šŸŽ™ļø Broadcast started notification received'); + }); + + socket.on('broadcast_stopped', () => { + console.log('šŸ›‘ Broadcast stopped notification received'); + }); + + socket.on('mixer_status', (data) => { + updateUIFromMixerStatus(data); + }); + + socket.on('deck_status', (data) => { + console.log(`šŸ“” Server: Deck ${data.deck_id} status update:`, data); + // This is handled by a single status update too, but helpful for immediate feedback + }); + + socket.on('error', (data) => { + console.error('šŸ“” Server error:', data.message); + alert(`SERVER ERROR: ${data.message}`); + }); + + return socket; +} + +// Update DJ UI from server status +function updateUIFromMixerStatus(status) { + if (!status) return; + + ['A', 'B'].forEach(id => { + const deckStatus = id === 'A' ? status.deck_a : status.deck_b; + if (!deckStatus) return; + + // Update position (only if not currently dragging the waveform) + const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0); + if (timeSinceSeek < 1500) { + // Seek Protection: Ignore server updates for 1.5s after manual seek + return; + } + + // Update playing state + decks[id].playing = deckStatus.playing; + + // Update loaded track if changed + if (deckStatus.filename && (!decks[id].currentFile || decks[id].currentFile !== deckStatus.filename)) { + console.log(`šŸ“” Server synced: Deck ${id} is playing ${deckStatus.filename}`); + decks[id].currentFile = deckStatus.filename; + decks[id].duration = deckStatus.duration; + + // Update UI elements for track title + const titleEl = document.getElementById(`display-${id}`); + if (titleEl) { + const name = deckStatus.filename.split('/').pop().replace(/\.[^/.]+$/, ""); + titleEl.textContent = name; + } + } + + // Sync playback rate for interpolation + const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); + if (speedSlider) speedSlider.value = deckStatus.pitch; + + // Update anchor for local interpolation + decks[id].lastAnchorPosition = deckStatus.position; + decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0; + + if (!decks[id].playing) { + decks[id].pausedAt = deckStatus.position; + } + + // Forced UI update to snap to server reality + const currentPos = getCurrentPosition(id); + const progress = (currentPos / decks[id].duration) * 100; + const playhead = document.getElementById('playhead-' + id); + if (playhead) playhead.style.left = progress + '%'; + const timer = document.getElementById('time-current-' + id); + if (timer) timer.textContent = formatTime(currentPos); + }); + + // Update crossfader if changed significantly + if (Math.abs(decks.crossfader - status.crossfader) > 1) { + // We'd update the UI slider here + } +} + +// Toggle streaming panel +function toggleStreamingPanel() { + const panel = document.getElementById('streaming-panel'); + panel.classList.toggle('active'); + + // Initialize socket when panel is opened + if (panel.classList.contains('active') && !socket) { + initSocket(); + } +} + +// Toggle broadcast +function toggleBroadcast() { + if (!audioCtx) { + alert('Please initialize the system first (click INITIALIZE SYSTEM)'); + return; + } + + if (!socket) initSocket(); + + if (isBroadcasting) { + stopBroadcast(); + } else { + startBroadcast(); + } +} + +// Start broadcasting +// Start broadcasting +function startBroadcast() { + try { + console.log('šŸŽ™ļø Starting broadcast...'); + + if (!audioCtx) { + alert('Please initialize the system first!'); + return; + } + + // Server-side audio mode + if (SERVER_SIDE_AUDIO) { + isBroadcasting = true; + document.getElementById('broadcast-btn').classList.add('active'); + document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; + document.getElementById('broadcast-status').textContent = 'šŸ”“ LIVE'; + document.getElementById('broadcast-status').classList.add('live'); + + if (!socket) initSocket(); + socket.emit('start_broadcast'); + socket.emit('get_listener_count'); + + console.log('āœ… Server-side broadcast started'); + return; + } + + // Browser-side audio mode (original code) + // Check if any audio is playing + const anyPlaying = decks.A.playing || decks.B.playing; + if (!anyPlaying) { + console.warn('āš ļø WARNING: No decks are currently playing! Start playing a track for audio to stream.'); + } + + // Create MediaStreamDestination to capture audio output + streamDestination = audioCtx.createMediaStreamDestination(); + + // IMPORTANT: Properly manage audio graph connections + // Disconnect from speakers first, then connect to stream AND speakers + // This prevents dual-connection instability + if (decks.A.crossfaderGain) { + try { + decks.A.crossfaderGain.disconnect(); + } catch (e) { + console.warn('Deck A crossfader already disconnected'); + } + decks.A.crossfaderGain.connect(streamDestination); + decks.A.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring + console.log('āœ… Deck A connected to stream + speakers'); + } + if (decks.B.crossfaderGain) { + try { + decks.B.crossfaderGain.disconnect(); + } catch (e) { + console.warn('Deck B crossfader already disconnected'); + } + decks.B.crossfaderGain.connect(streamDestination); + decks.B.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring + console.log('āœ… Deck B connected to stream + speakers'); + } + + // Verify stream has audio tracks + const stream = streamDestination.stream; + console.log(`šŸ“Š Stream tracks: ${stream.getAudioTracks().length} audio tracks`); + + if (stream.getAudioTracks().length === 0) { + throw new Error('No audio tracks in stream! Audio routing failed.'); + } + + // Get selected quality from dropdown + const qualitySelect = document.getElementById('stream-quality'); + const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps + console.log(`šŸŽšļø Starting broadcast at ${qualitySelect.value}kbps`); + + mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm;codecs=opus', + audioBitsPerSecond: selectedBitrate + }); + + let chunkCount = 0; + let lastLogTime = Date.now(); + let silenceWarningShown = false; + + // Send audio chunks via SocketIO + mediaRecorder.ondataavailable = async (event) => { + if (event.data.size > 0 && isBroadcasting && socket) { + chunkCount++; + + // Warn if chunks are too small (likely silence) + if (event.data.size < 100 && !silenceWarningShown) { + console.warn('āš ļø Audio chunks are very small - might be silence. Make sure audio is playing!'); + silenceWarningShown = true; + } + + // Convert blob to array buffer for transmission + const buffer = await event.data.arrayBuffer(); + socket.emit('audio_chunk', buffer); // Send raw ArrayBuffer directly + + // Log every second + const now = Date.now(); + if (now - lastLogTime > 1000) { + console.log(`šŸ“” Broadcasting: ${chunkCount} chunks sent (${(event.data.size / 1024).toFixed(1)} KB/chunk)`); + lastLogTime = now; + + // Reset silence warning + if (event.data.size > 100) { + silenceWarningShown = false; + } + } + } else { + // Debug why chunks aren't being sent + if (event.data.size === 0) { + console.warn('āš ļø Received empty audio chunk'); + } + if (!isBroadcasting) { + console.warn('āš ļø Broadcasting flag is false'); + } + if (!socket) { + console.warn('āš ļø Socket not connected'); + } + } + }; + + mediaRecorder.onerror = (error) => { + console.error('āŒ MediaRecorder error:', error); + // Try to recover from error + if (isBroadcasting) { + console.log('šŸ”„ Attempting to recover from MediaRecorder error...'); + setTimeout(() => { + if (isBroadcasting) { + restartBroadcast(); + } + }, 2000); + } + }; + + mediaRecorder.onstart = () => { + console.log('āœ… MediaRecorder started'); + }; + + mediaRecorder.onstop = (event) => { + console.warn('āš ļø MediaRecorder stopped!'); + console.log(` State: ${mediaRecorder.state}`); + console.log(` isBroadcasting flag: ${isBroadcasting}`); + + // If we're supposed to be broadcasting but MediaRecorder stopped, restart it + if (isBroadcasting) { + console.error('āŒ MediaRecorder stopped unexpectedly while broadcasting!'); + console.log('šŸ”„ Auto-recovery: Attempting to restart broadcast in 2 seconds...'); + + setTimeout(() => { + if (isBroadcasting) { + console.log('šŸ”„ Executing auto-recovery...'); + restartBroadcast(); + } + }, 2000); + } + }; + + mediaRecorder.onpause = (event) => { + console.warn('āš ļø MediaRecorder paused unexpectedly!'); + + // If we're broadcasting and MediaRecorder paused, resume it + if (isBroadcasting && mediaRecorder.state === 'paused') { + console.log('šŸ”„ Auto-resuming MediaRecorder...'); + try { + mediaRecorder.resume(); + console.log('āœ… MediaRecorder resumed'); + } catch (e) { + console.error('āŒ Failed to resume MediaRecorder:', e); + // If resume fails, try full restart + setTimeout(() => { + if (isBroadcasting) { + restartBroadcast(); + } + }, 1000); + } + } + }; + + // 1000ms chunks: Dramatically reduces CPU interrupts on low-RAM machines + // Validate state before starting + if (mediaRecorder.state === 'inactive') { + mediaRecorder.start(1000); + streamProcessor = mediaRecorder; + console.log('āœ… MediaRecorder started in state:', mediaRecorder.state); + } else { + console.error('āŒ Cannot start MediaRecorder - already in state:', mediaRecorder.state); + throw new Error(`MediaRecorder is already ${mediaRecorder.state}`); + } + + // Update UI + isBroadcasting = true; + document.getElementById('broadcast-btn').classList.add('active'); + document.getElementById('broadcast-text').textContent = 'STOP BROADCAST'; + document.getElementById('broadcast-status').textContent = 'šŸ”“ LIVE'; + document.getElementById('broadcast-status').classList.add('live'); + + // Notify server + if (!socket) initSocket(); + socket.emit('start_broadcast'); + socket.emit('get_listener_count'); + + console.log('āœ… Broadcasting started successfully!'); + console.log('šŸ’” TIP: Play a track on Deck A or B to stream audio'); + + // Monitor audio levels + setTimeout(() => { + if (chunkCount === 0) { + console.error('āŒ NO AUDIO CHUNKS after 2 seconds! Check:'); + console.error(' 1. Is audio playing on either deck?'); + console.error(' 2. Is volume turned up?'); + console.error(' 3. Is crossfader in the middle?'); + } + }, 2000); + + } catch (error) { + console.error('āŒ Failed to start broadcast:', error); + alert('Failed to start broadcast: ' + error.message); + isBroadcasting = false; + } +} + +// Stop broadcasting +function stopBroadcast() { + console.log('šŸ›‘ Stopping broadcast...'); + + if (SERVER_SIDE_AUDIO) { + isBroadcasting = false; + if (socket) { + socket.emit('stop_broadcast'); + } + } else { + if (streamProcessor) { + streamProcessor.stop(); + streamProcessor = null; + } + + if (streamDestination) { + // Disconnect from stream destination and restore normal playback + if (decks.A.crossfaderGain) { + try { + decks.A.crossfaderGain.disconnect(streamDestination); + // Ensure connection to speakers is maintained + decks.A.crossfaderGain.disconnect(); + decks.A.crossfaderGain.connect(audioCtx.destination); + } catch (e) { + console.warn('Error restoring Deck A audio:', e); + } + } + if (decks.B.crossfaderGain) { + try { + decks.B.crossfaderGain.disconnect(streamDestination); + // Ensure connection to speakers is maintained + decks.B.crossfaderGain.disconnect(); + decks.B.crossfaderGain.connect(audioCtx.destination); + } catch (e) { + console.warn('Error restoring Deck B audio:', e); + } + } + streamDestination = null; + } + isBroadcasting = false; + + // Notify server (browser-side mode also needs to tell server to stop relaying) + if (socket) { + socket.emit('stop_broadcast'); + } + } + + // Update UI + document.getElementById('broadcast-btn').classList.remove('active'); + document.getElementById('broadcast-text').textContent = 'START BROADCAST'; + document.getElementById('broadcast-status').textContent = 'Offline'; + document.getElementById('broadcast-status').classList.remove('live'); + + console.log('āœ… Broadcast stopped'); +} + + +// Restart broadcasting (for auto-recovery) +function restartBroadcast() { + console.log('šŸ”„ Restarting broadcast...'); + + // Clean up old MediaRecorder without changing UI state + if (streamProcessor) { + try { + streamProcessor.stop(); + } catch (e) { + console.warn('Could not stop old MediaRecorder:', e); + } + streamProcessor = null; + } + + // Clean up old stream destination + if (streamDestination) { + if (decks.A.crossfaderGain) { + try { + decks.A.crossfaderGain.disconnect(streamDestination); + decks.A.crossfaderGain.disconnect(); + decks.A.crossfaderGain.connect(audioCtx.destination); + } catch (e) { + console.warn('Error cleaning up Deck A:', e); + } + } + if (decks.B.crossfaderGain) { + try { + decks.B.crossfaderGain.disconnect(streamDestination); + decks.B.crossfaderGain.disconnect(); + decks.B.crossfaderGain.connect(audioCtx.destination); + } catch (e) { + console.warn('Error cleaning up Deck B:', e); + } + } + streamDestination = null; + } + + // Preserve broadcasting state + const wasBroadcasting = isBroadcasting; + isBroadcasting = false; // Temporarily set to false so startBroadcast works + + // Small delay to ensure cleanup completes + setTimeout(() => { + if (wasBroadcasting) { + startBroadcast(); + console.log('āœ… Broadcast restarted successfully'); + } + }, 100); +} + +// Copy stream URL to clipboard +function copyStreamUrl() { + const urlInput = document.getElementById('stream-url'); + urlInput.select(); + urlInput.setSelectionRange(0, 99999); // For mobile + + try { + document.execCommand('copy'); + const btn = event.target; + const originalText = btn.textContent; + btn.textContent = 'āœ“'; + setTimeout(() => { + btn.textContent = originalText; + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + } +} + +// Toggle auto-start stream +function toggleAutoStream(enabled) { + autoStartStream = enabled; + localStorage.setItem('autoStartStream', enabled); +} + +// ========== LISTENER MODE ========== + +function initListenerMode() { + console.log('šŸŽ§ Initializing listener mode (MediaSource Pipeline)...'); + + // UI Feedback for listener + const appContainer = document.querySelector('.app-container'); + const settingsBtn = document.querySelector('.settings-btn'); + const streamingBtn = document.querySelector('.streaming-btn'); + if (appContainer) appContainer.style.display = 'none'; + if (settingsBtn) settingsBtn.style.display = 'none'; + if (streamingBtn) streamingBtn.style.display = 'none'; + + const startOverlay = document.getElementById('start-overlay'); + if (startOverlay) startOverlay.style.display = 'none'; + + // Hide landscape prompt for listeners (not needed) + const landscapePrompt = document.getElementById('landscape-prompt'); + if (landscapePrompt) landscapePrompt.style.display = 'none'; + + const listenerMode = document.getElementById('listener-mode'); + if (listenerMode) listenerMode.style.display = 'flex'; + + // Add atmospheric glow for listeners + document.body.classList.add('listener-glow'); + document.body.classList.add('listening-active'); // For global CSS targeting + updateGlowIntensity(settings.glowIntensity || 30); + + // Setup Audio Context for volume/routing + listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); + listenerGainNode = listenerAudioContext.createGain(); + listenerGainNode.gain.value = 0.8; + listenerGainNode.connect(listenerAudioContext.destination); + + // Create a hidden audio element to handle the MediaSource + const audio = new Audio(); + audio.autoplay = true; + audio.hidden = true; + document.body.appendChild(audio); + + // Bridge Audio Element to AudioContext + const sourceNode = listenerAudioContext.createMediaElementSource(audio); + sourceNode.connect(listenerGainNode); + + // Initialize MediaSource for streaming binary chunks + const mediaSource = new MediaSource(); + audio.src = URL.createObjectURL(mediaSource); + + let sourceBuffer = null; + let audioQueue = []; + let chunksReceived = 0; + let lastStatusUpdate = 0; + + mediaSource.addEventListener('sourceopen', () => { + console.log('šŸ“¦ MediaSource opened'); + try { + sourceBuffer = mediaSource.addSourceBuffer('audio/webm;codecs=opus'); + sourceBuffer.mode = 'sequence'; + + // Request the startup header now that we are ready to receive it + console.log('šŸ“” Requesting stream header...'); + socket.emit('request_header'); + + // Kick off first append if data is already in queue + if (audioQueue.length > 0 && !sourceBuffer.updating && mediaSource.readyState === 'open') { + sourceBuffer.appendBuffer(audioQueue.shift()); + } + + sourceBuffer.addEventListener('updateend', () => { + if (audioQueue.length > 0 && !sourceBuffer.updating) { + sourceBuffer.appendBuffer(audioQueue.shift()); + } + }); + } catch (e) { + console.error('āŒ Failed to add SourceBuffer:', e); + } + }); + + // Show enable audio button instead of attempting autoplay + const enableAudioBtn = document.getElementById('enable-audio-btn'); + const statusEl = document.getElementById('connection-status'); + + if (enableAudioBtn) { + enableAudioBtn.style.display = 'flex'; + } + if (statusEl) { + statusEl.textContent = 'šŸ”µ Click "Enable Audio" to start listening'; + } + + // Store audio element and context for later activation + window.listenerAudio = audio; + window.listenerMediaSource = mediaSource; + + // Initialize socket and join + initSocket(); + socket.emit('join_listener'); + + let hasHeader = false; + + socket.on('audio_data', (data) => { + // We MUST have the header before we can do anything with broadcast chunks + const isHeaderDirect = data instanceof ArrayBuffer && data.byteLength > 1000; // Heuristic + + hasHeader = true; // No header request needed for WebM relay + + chunksReceived++; + audioQueue.push(data); + + // JITTER BUFFER: Reduced to 1 segments (buffered) for WebM/Opus + const isHeader = false; + + if (sourceBuffer && !sourceBuffer.updating && mediaSource.readyState === 'open') { + if (audioQueue.length >= 1) { + try { + const next = audioQueue.shift(); + sourceBuffer.appendBuffer(next); + + // Periodic cleanup of old buffer data to prevent memory bloat + // Keep the last 60 seconds of audio data + if (audio.buffered.length > 0 && !sourceBuffer.updating) { + const end = audio.buffered.end(audio.buffered.length - 1); + const start = audio.buffered.start(0); + if (end - start > 120) { // If buffer is > 2 mins + sourceBuffer.remove(0, end - 60); + } + } + + // Reset error counter on success + if (window.sourceBufferErrorCount) window.sourceBufferErrorCount = 0; + } catch (e) { + console.error('Buffer append error:', e); + window.sourceBufferErrorCount = (window.sourceBufferErrorCount || 0) + 1; + + if (window.sourceBufferErrorCount >= 5) { + console.error('āŒ Too many SourceBuffer errors - attempting recovery...'); + const statusEl = document.getElementById('connection-status'); + if (statusEl) statusEl.textContent = 'āš ļø Stream error - reconnecting...'; + audioQueue = []; + chunksReceived = 0; + window.sourceBufferErrorCount = 0; + setTimeout(() => { + if (socket) socket.emit('request_header'); + }, 1000); + } + } + } + } + + // UI Update + const now = Date.now(); + if (now - lastStatusUpdate > 1000) { + const statusEl = document.getElementById('connection-status'); + if (statusEl) { + statusEl.textContent = `🟢 Connected - ${chunksReceived} chunks (${audioQueue.length} buffered)`; + } + lastStatusUpdate = now; + } + }); + + socket.on('broadcast_started', () => { + const nowPlayingEl = document.getElementById('listener-now-playing'); + if (nowPlayingEl) nowPlayingEl.textContent = 'šŸŽµ Stream is live!'; + // Reset MediaSource for fresh stream if needed + }); + + 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...'; + } + }); + + socket.on('broadcast_stopped', () => { + const nowPlayingEl = document.getElementById('listener-now-playing'); + if (nowPlayingEl) nowPlayingEl.textContent = 'Stream ended'; + chunksReceived = 0; + audioQueue = []; + }); + + socket.on('connect', () => { + const statusEl = document.getElementById('connection-status'); + if (statusEl) statusEl.textContent = '🟢 Connected'; + }); + + socket.on('disconnect', () => { + const statusEl = document.getElementById('connection-status'); + if (statusEl) statusEl.textContent = 'šŸ”“ Disconnected'; + }); +} + +// Enable audio for listener mode (called when user clicks the button) +async function enableListenerAudio() { + console.log('šŸŽ§ Enabling audio via user gesture...'); + + const enableAudioBtn = document.getElementById('enable-audio-btn'); + const statusEl = document.getElementById('connection-status'); + const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null; + + if (audioText) audioText.textContent = 'INITIALIZING...'; + + try { + // 1. Create AudioContext if it somehow doesn't exist + if (!listenerAudioContext) { + listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + + // 2. Resume audio context (CRITICAL for Chrome/Safari) + if (listenerAudioContext.state === 'suspended') { + await listenerAudioContext.resume(); + console.log('āœ… Audio context resumed'); + } + + // 3. Kickstart the hidden audio element + if (window.listenerAudio) { + // Unmute just in case + window.listenerAudio.muted = false; + window.listenerAudio.volume = settings.volume || 0.8; + + // Attempt playback + await window.listenerAudio.play(); + console.log('āœ… Audio playback started successfully'); + } + + // 4. Hide the button and update status + if (enableAudioBtn) { + enableAudioBtn.style.opacity = '0'; + setTimeout(() => { + enableAudioBtn.style.display = 'none'; + }, 300); + } + + if (statusEl) { + statusEl.textContent = '🟢 Audio Active - Enjoy the stream'; + statusEl.classList.add('glow-text'); + } + + } catch (error) { + console.error('āŒ Failed to enable audio:', error); + const stashedStatus = document.getElementById('connection-status'); + const stashedBtn = document.getElementById('enable-audio-btn'); + const audioText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null; + + if (audioText) audioText.textContent = 'RETRY ENABLE'; + if (stashedStatus) { + stashedStatus.textContent = 'āš ļø Browser blocked audio. Please click again.'; + } + } +} + +function setListenerVolume(value) { + if (listenerGainNode) { + listenerGainNode.gain.value = value / 100; + } +} + +// Load auto-start preference +window.addEventListener('load', () => { + const autoStart = localStorage.getItem('autoStartStream'); + if (autoStart === 'true') { + document.getElementById('auto-start-stream').checked = true; + autoStartStream = true; + } +}); + +// Monitoring +function monitorTrackEnd() { + setInterval(() => { + if (!audioCtx) return; // Safety check + + // In server-side mode, poll for status from server + if (SERVER_SIDE_AUDIO && socket && socket.connected) { + socket.emit('get_mixer_status'); + } + + ['A', 'B'].forEach(id => { + if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) { + const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; + const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; + const current = decks[id].lastAnchorPosition + (realElapsed * playbackRate); + const remaining = decks[id].duration - current; + + // If end reached (with 0.5s buffer for safety) + if (remaining <= 0.5) { + // Don't pause during broadcast - let the track end naturally + if (isBroadcasting) { + console.log(`šŸŽ™ļø Track ending during broadcast on Deck ${id} - continuing stream`); + if (settings[`repeat${id}`]) { + console.log(`šŸ” Repeating track on Deck ${id}`); + seekTo(id, 0); + } + // Skip pause/stop during broadcast to maintain stream + return; + } + + if (settings[`repeat${id}`]) { + // Full song repeat + console.log(`šŸ” Repeating track on Deck ${id}`); + seekTo(id, 0); + } else if (settings.autoPlay) { + // Prevent race condition + decks[id].loading = true; + pauseDeck(id); + + // Check queue for auto-play + if (queues[id] && queues[id].length > 0) { + console.log(`šŸ“‹ Auto-play: Loading next from Queue ${id}...`); + const next = queues[id].shift(); + renderQueue(id); // Update queue UI + + loadFromServer(id, next.file, next.title).then(() => { + decks[id].loading = false; + playDeck(id); + }).catch(() => { + decks[id].loading = false; + }); + } else { + // No queue - just stop + console.log(`ā¹ļø Track ended, queue empty - stopping playback`); + decks[id].loading = false; + pauseDeck(id); + decks[id].pausedAt = 0; + } + } else { + // Just stop if no auto-play + pauseDeck(id); + decks[id].pausedAt = 0; + } + } + } + }); + }, 500); // Check every 0.5s +} +monitorTrackEnd(); +// Reset Deck to Default Settings +function resetDeck(id) { + console.log(`šŸ”„ Resetting Deck ${id} to defaults...`); + + if (!audioCtx) { + console.warn('AudioContext not initialized'); + return; + } + + // Reset EQ to neutral (0dB gain) + if (decks[id].filters) { + if (decks[id].filters.low) decks[id].filters.low.gain.value = 0; + if (decks[id].filters.mid) decks[id].filters.mid.gain.value = 0; + if (decks[id].filters.high) decks[id].filters.high.gain.value = 0; + + // Reset UI sliders + const eqSliders = document.querySelectorAll(`#deck-${id} .eq-band input`); + eqSliders.forEach(slider => slider.value = 0); // 0 = neutral for -20/20 range + } + + // Reset filters to fully open + if (decks[id].filters.lp) decks[id].filters.lp.frequency.value = 22050; + if (decks[id].filters.hp) decks[id].filters.hp.frequency.value = 0; + + // Reset UI filter sliders + const lpSlider = document.querySelector(`#deck-${id} .filter-lp`); + const hpSlider = document.querySelector(`#deck-${id} .filter-hp`); + if (lpSlider) lpSlider.value = 100; + if (hpSlider) hpSlider.value = 0; + + // Reset volume to 80% + if (decks[id].volumeGain) { + decks[id].volumeGain.gain.value = 0.8; + } + const volumeSlider = document.querySelector(`#deck-${id} .volume-fader`); + if (volumeSlider) volumeSlider.value = 80; + + // Reset pitch/speed to 1.0 (100%) + const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); + if (speedSlider) { + speedSlider.value = 1.0; + // Use changeSpeed function to properly update playback rate + changeSpeed(id, 1.0); + } + + // Clear all hot cues + decks[id].cues = {}; + const cueBtns = document.querySelectorAll(`#deck-${id} .cue-btn`); + cueBtns.forEach(btn => { + btn.classList.remove('cue-set'); + btn.style.animation = ''; + }); + + // Clear loops + decks[id].loopStart = null; + decks[id].loopEnd = null; + decks[id].loopActive = false; + + const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`); + loopBtns.forEach(btn => { + btn.style.background = ''; + btn.style.borderColor = ''; + }); + + // Redraw waveform to clear cue/loop markers + drawWaveform(id); + + console.log(`āœ… Deck ${id} reset complete!`); + + // Visual feedback + const resetBtn = document.querySelector(`#deck-${id} .reset-btn`); + if (resetBtn) { + resetBtn.style.transform = 'rotate(360deg)'; + resetBtn.style.transition = 'transform 0.5s ease'; + setTimeout(() => { + resetBtn.style.transform = ''; + }, 500); + } +} +// ========================================== +// QUEUE SYSTEM +// ========================================== + +// Add track to queue +function addToQueue(deckId, file, title) { + queues[deckId].push({ file, title }); + renderQueue(deckId); + console.log(`šŸ“‹ Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`); + + // Sync with server if in server-side mode + if (SERVER_SIDE_AUDIO && socket) { + socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] }); + } +} + +// Remove track from queue +function removeFromQueue(deckId, index) { + const removed = queues[deckId].splice(index, 1)[0]; + renderQueue(deckId); + console.log(`šŸ—‘ļø Removed "${removed.title}" from Queue ${deckId}`); + + // Sync with server if in server-side mode + if (SERVER_SIDE_AUDIO && socket) { + socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] }); + } +} + +// Clear entire queue +function clearQueue(deckId) { + const count = queues[deckId].length; + queues[deckId] = []; + renderQueue(deckId); + console.log(`šŸ—‘ļø Cleared Queue ${deckId} (${count} tracks removed)`); + + // Sync with server if in server-side mode + if (SERVER_SIDE_AUDIO && socket) { + socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] }); + } +} + +// Load next track from queue +function loadNextFromQueue(deckId) { + if (queues[deckId].length === 0) { + console.log(`šŸ“‹ Queue ${deckId} is empty`); + return false; + } + + const next = queues[deckId].shift(); + console.log(`šŸ“‹ Loading next from Queue ${deckId}: "${next.title}"`); + loadFromServer(deckId, next.file, next.title); + renderQueue(deckId); + + // Sync with server if in server-side mode (after shift) + if (SERVER_SIDE_AUDIO && socket) { + socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] }); + } + + return true; +} + +// Render queue UI +function renderQueue(deckId) { + const queueList = document.getElementById(`queue-list-${deckId}`); + if (!queueList) return; + + if (queues[deckId].length === 0) { + queueList.innerHTML = '
Drop tracks here or click "Queue to ' + deckId + '" in library
'; + return; + } + + queueList.innerHTML = ''; + queues[deckId].forEach((track, index) => { + const item = document.createElement('div'); + item.className = 'queue-item'; + item.draggable = true; + + const number = document.createElement('span'); + number.className = 'queue-number'; + number.textContent = (index + 1) + '.'; + + const title = document.createElement('span'); + title.className = 'queue-track-title'; + title.textContent = track.title; + + const actions = document.createElement('div'); + actions.className = 'queue-actions'; + + const loadBtn = document.createElement('button'); + loadBtn.className = 'queue-load-btn'; + loadBtn.textContent = 'ā–¶'; + loadBtn.title = 'Load now'; + loadBtn.onclick = () => { + loadFromServer(deckId, track.file, track.title); + removeFromQueue(deckId, index); + }; + + const removeBtn = document.createElement('button'); + removeBtn.className = 'queue-remove-btn'; + removeBtn.textContent = 'āœ•'; + removeBtn.title = 'Remove from queue'; + removeBtn.onclick = () => removeFromQueue(deckId, index); + + actions.appendChild(loadBtn); + actions.appendChild(removeBtn); + + item.appendChild(number); + item.appendChild(title); + item.appendChild(actions); + + // Drag and drop reordering + item.ondragstart = (e) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('queueIndex', index); + e.dataTransfer.setData('queueDeck', deckId); + item.classList.add('dragging'); + }; + + item.ondragend = () => { + item.classList.remove('dragging'); + }; + + item.ondragover = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + item.ondrop = (e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData('queueIndex')); + const fromDeck = e.dataTransfer.getData('queueDeck'); + + if (fromDeck === deckId && fromIndex !== index) { + const [moved] = queues[deckId].splice(fromIndex, 1); + queues[deckId].splice(index, 0, moved); + renderQueue(deckId); + console.log(`šŸ”„ Reordered Queue ${deckId}`); + } + }; + + queueList.appendChild(item); + }); +} + +// Auto-load next track when current track ends +function checkAndLoadNextFromQueue(deckId) { + if (settings.autoPlay && queues[deckId].length > 0) { + console.log(`šŸŽµ Auto-loading next track from Queue ${deckId}...`); + setTimeout(() => { + loadNextFromQueue(deckId); + }, 500); + } +} +// ========================================== +// KEYBOARD SHORTCUTS SYSTEM +// ========================================== + +// Default keyboard mappings (can be customized) +let keyboardMappings = { + // Deck A Controls + 'q': { action: 'playDeckA', label: 'Play Deck A' }, + 'a': { action: 'pauseDeckA', label: 'Pause Deck A' }, + '1': { action: 'cueA1', label: 'Deck A Cue 1' }, + '2': { action: 'cueA2', label: 'Deck A Cue 2' }, + '3': { action: 'cueA3', label: 'Deck A Cue 3' }, + '4': { action: 'cueA4', label: 'Deck A Cue 4' }, + 'z': { action: 'loopInA', label: 'Loop IN Deck A' }, + 'x': { action: 'loopOutA', label: 'Loop OUT Deck A' }, + 'c': { action: 'loopExitA', label: 'Loop EXIT Deck A' }, + 'r': { action: 'resetDeckA', label: 'Reset Deck A' }, + + // Deck B Controls + 'w': { action: 'playDeckB', label: 'Play Deck B' }, + 's': { action: 'pauseDeckB', label: 'Pause Deck B' }, + '7': { action: 'cueB1', label: 'Deck B Cue 1' }, + '8': { action: 'cueB2', label: 'Deck B Cue 2' }, + '9': { action: 'cueB3', label: 'Deck B Cue 3' }, + '0': { action: 'cueB4', label: 'Deck B Cue 4' }, + 'n': { action: 'loopInB', label: 'Loop IN Deck B' }, + 'm': { action: 'loopOutB', label: 'Loop OUT Deck B' }, + ',': { action: 'loopExitB', label: 'Loop EXIT Deck B' }, + 't': { action: 'resetDeckB', label: 'Reset Deck B' }, + + // Crossfader & Mixer + 'ArrowLeft': { action: 'crossfaderLeft', label: 'Crossfader Left' }, + 'ArrowRight': { action: 'crossfaderRight', label: 'Crossfader Right' }, + 'ArrowDown': { action: 'crossfaderCenter', label: 'Crossfader Center' }, + + // Pitch Bend + 'e': { action: 'pitchBendAUp', label: 'Pitch Bend A +' }, + 'd': { action: 'pitchBendADown', label: 'Pitch Bend A -' }, + 'i': { action: 'pitchBendBUp', label: 'Pitch Bend B +' }, + 'k': { action: 'pitchBendBDown', label: 'Pitch Bend B -' }, + + // Utility + 'f': { action: 'toggleLibrary', label: 'Toggle Library' }, + 'b': { action: 'toggleBroadcast', label: 'Toggle Broadcast' }, + 'h': { action: 'showKeyboardHelp', label: 'Show Keyboard Help' }, + 'Escape': { action: 'closeAllPanels', label: 'Close All Panels' } +}; + +// Load custom mappings from localStorage +function loadKeyboardMappings() { + const saved = localStorage.getItem('keyboardMappings'); + if (saved) { + try { + keyboardMappings = JSON.parse(saved); + console.log('āœ… Loaded custom keyboard mappings'); + } catch (e) { + console.error('Failed to load keyboard mappings:', e); + } + } +} + +// Save custom mappings to localStorage +function saveKeyboardMappings() { + localStorage.setItem('keyboardMappings', JSON.stringify(keyboardMappings)); + console.log('šŸ’¾ Saved keyboard mappings'); +} + +// Execute action based on key +function executeKeyboardAction(action) { + const actions = { + // Deck A + 'playDeckA': () => playDeck('A'), + 'pauseDeckA': () => pauseDeck('A'), + 'cueA1': () => handleCue('A', 1), + 'cueA2': () => handleCue('A', 2), + 'cueA3': () => handleCue('A', 3), + 'cueA4': () => handleCue('A', 4), + 'loopInA': () => setLoop('A', 'in'), + 'loopOutA': () => setLoop('A', 'out'), + 'loopExitA': () => setLoop('A', 'exit'), + 'resetDeckA': () => resetDeck('A'), + + // Deck B + 'playDeckB': () => playDeck('B'), + 'pauseDeckB': () => pauseDeck('B'), + 'cueB1': () => handleCue('B', 1), + 'cueB2': () => handleCue('B', 2), + 'cueB3': () => handleCue('B', 3), + 'cueB4': () => handleCue('B', 4), + 'loopInB': () => setLoop('B', 'in'), + 'loopOutB': () => setLoop('B', 'out'), + 'loopExitB': () => setLoop('B', 'exit'), + 'resetDeckB': () => resetDeck('B'), + + // Crossfader + 'crossfaderLeft': () => { + const cf = document.getElementById('crossfader'); + cf.value = Math.max(0, parseInt(cf.value) - 10); + updateCrossfader(cf.value); + }, + 'crossfaderRight': () => { + const cf = document.getElementById('crossfader'); + cf.value = Math.min(100, parseInt(cf.value) + 10); + updateCrossfader(cf.value); + }, + 'crossfaderCenter': () => { + const cf = document.getElementById('crossfader'); + cf.value = 50; + updateCrossfader(50); + }, + + // Pitch Bend + 'pitchBendAUp': () => pitchBend('A', 0.05), + 'pitchBendADown': () => pitchBend('A', -0.05), + 'pitchBendBUp': () => pitchBend('B', 0.05), + 'pitchBendBDown': () => pitchBend('B', -0.05), + + // Utility + 'toggleLibrary': () => switchTab('library'), + 'toggleBroadcast': () => toggleBroadcast(), + 'showKeyboardHelp': () => openKeyboardSettings(), + 'closeAllPanels': () => { + document.getElementById('settings-panel').classList.remove('active'); + document.getElementById('streaming-panel').classList.remove('active'); + } + }; + + if (actions[action]) { + actions[action](); + } else { + console.warn('Unknown action:', action); + } +} + +// Global keyboard event listener +document.addEventListener('keydown', (e) => { + // Ignore if typing in input field + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + + const key = e.key; + const mapping = keyboardMappings[key]; + + if (mapping) { + e.preventDefault(); + console.log(`āŒØļø Keyboard: ${key} → ${mapping.label}`); + executeKeyboardAction(mapping.action); + } +}); + +// Handle pitch bend release +document.addEventListener('keyup', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + const key = e.key; + const mapping = keyboardMappings[key]; + + // Release pitch bend on key up + if (mapping && mapping.action.includes('pitchBend')) { + const deck = mapping.action.includes('A') ? 'A' : 'B'; + pitchBend(deck, 0); + } +}); + +// Open keyboard settings panel +function openKeyboardSettings() { + const panel = document.getElementById('keyboard-settings-panel'); + if (panel) { + panel.classList.toggle('active'); + } else { + createKeyboardSettingsPanel(); + } +} + +// Create keyboard settings UI +function createKeyboardSettingsPanel() { + const panel = document.createElement('div'); + panel.id = 'keyboard-settings-panel'; + panel.className = 'settings-panel active'; + panel.innerHTML = ` +
+

āŒØļø Keyboard Shortcuts

+ +
+
+

Click on a key to reassign it. Press ESC to cancel.

+
+
+ + + +
+
+ `; + document.body.appendChild(panel); + renderKeyboardMappings(); +} + +function closeKeyboardSettings() { + const panel = document.getElementById('keyboard-settings-panel'); + if (panel) panel.classList.remove('active'); +} + +// Render keyboard mappings list +function renderKeyboardMappings() { + const list = document.getElementById('keyboard-mappings-list'); + if (!list) return; + + list.innerHTML = ''; + + Object.entries(keyboardMappings).forEach(([key, mapping]) => { + const item = document.createElement('div'); + item.className = 'keyboard-mapping-item'; + item.innerHTML = ` + ${formatKeyName(key)} + → + ${mapping.label} + + `; + list.appendChild(item); + }); +} + +// Format key name for display +function formatKeyName(key) { + const names = { + 'ArrowLeft': '← Left', + 'ArrowRight': '→ Right', + 'ArrowUp': '↑ Up', + 'ArrowDown': '↓ Down', + 'Escape': 'ESC', + ' ': 'Space' + }; + return names[key] || key.toUpperCase(); +} + +// Reassign a key +function reassignKey(oldKey) { + const item = event.target.closest('.keyboard-mapping-item'); + item.classList.add('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Press new key...'; + + const listener = (e) => { + e.preventDefault(); + + if (e.key === 'Escape') { + item.classList.remove('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Change'; + document.removeEventListener('keydown', listener); + return; + } + + const newKey = e.key; + + // Check if key already assigned + if (keyboardMappings[newKey] && newKey !== oldKey) { + if (!confirm(`Key "${formatKeyName(newKey)}" is already assigned to "${keyboardMappings[newKey].label}". Overwrite?`)) { + item.classList.remove('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Change'; + document.removeEventListener('keydown', listener); + return; + } + delete keyboardMappings[newKey]; + } + + // Move mapping to new key + keyboardMappings[newKey] = keyboardMappings[oldKey]; + delete keyboardMappings[oldKey]; + + saveKeyboardMappings(); + renderKeyboardMappings(); + document.removeEventListener('keydown', listener); + + console.log(`āœ… Remapped: ${formatKeyName(oldKey)} → ${formatKeyName(newKey)}`); + }; + + document.addEventListener('keydown', listener); +} + +// Reset to default mappings +function resetKeyboardMappings() { + if (confirm('Reset all keyboard shortcuts to defaults?')) { + localStorage.removeItem('keyboardMappings'); + location.reload(); + } +} + +// Export mappings +function exportKeyboardMappings() { + const json = JSON.stringify(keyboardMappings, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'techdj-keyboard-mappings.json'; + a.click(); + URL.revokeObjectURL(url); +} + +// Import mappings +function importKeyboardMappings() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = (e) => { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (event) => { + try { + const imported = JSON.parse(event.target.result); + keyboardMappings = imported; + saveKeyboardMappings(); + renderKeyboardMappings(); + alert('āœ… Keyboard mappings imported successfully!'); + } catch (err) { + alert('āŒ Failed to import: Invalid file format'); + } + }; + reader.readAsText(file); + }; + input.click(); +} + +// Initialize on load +loadKeyboardMappings(); +console.log('āŒØļø Keyboard shortcuts enabled. Press H for help.');