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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop tracks here or click "Queue to A" in library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Drop tracks here or click "Queue to B" in library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Offline
+
+
+
+
+ š
+ 0
+ Listeners
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Lower = more stable on poor connections
+
+
+
+
+
+
+
+
+
+
Waiting for stream...
+
+
+
+
+
+
+
+
+
Connecting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.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 = `
+
+
+
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.');