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

âŒ¨ī¸ Keyboard Shortcuts

- -
-
-

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

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