// ========================================== // TechDJ Pro - Core DJ Logic // ========================================== // Server-side audio mode (true = server processes audio, false = browser processes) const SERVER_SIDE_AUDIO = false; let audioCtx; const decks = { A: { type: 'local', playing: false, pausedAt: 0, duration: 0, localBuffer: null, localSource: null, gainNode: null, volumeGain: null, crossfaderGain: null, filters: {}, cues: {}, loopStart: null, loopEnd: null, loopActive: false, activeAutoLoop: null, waveformData: null, lastAnchorTime: 0, lastAnchorPosition: 0, loading: false, currentFile: null, lastSeekTime: 0 }, B: { type: 'local', playing: false, pausedAt: 0, duration: 0, localBuffer: null, localSource: null, gainNode: null, volumeGain: null, crossfaderGain: null, filters: {}, cues: {}, loopStart: null, loopEnd: null, loopActive: false, activeAutoLoop: null, waveformData: null, lastAnchorTime: 0, lastAnchorPosition: 0, loading: false, currentFile: null, lastSeekTime: 0 } }; let allSongs = []; const settings = { repeatA: false, repeatB: false, autoMix: false, shuffleMode: false, quantize: false, autoPlay: true, glowA: false, glowB: false, glowIntensity: 30 }; // Queue system for both decks const queues = { A: [], B: [] }; // System Initialization function initSystem() { if (audioCtx) return; audioCtx = new (window.AudioContext || window.webkitAudioContext)(); document.getElementById('start-overlay').style.display = 'none'; // Track dragging state per deck const draggingState = { A: false, B: false }; // Setup audio path for both decks ['A', 'B'].forEach(id => { // Create separate volume and crossfader gain nodes const volumeGain = audioCtx.createGain(); const crossfaderGain = audioCtx.createGain(); const analyser = audioCtx.createAnalyser(); analyser.fftSize = 256; const low = audioCtx.createBiquadFilter(); const mid = audioCtx.createBiquadFilter(); const high = audioCtx.createBiquadFilter(); const lp = audioCtx.createBiquadFilter(); const hp = audioCtx.createBiquadFilter(); low.type = 'lowshelf'; low.frequency.value = 320; mid.type = 'peaking'; mid.frequency.value = 1000; mid.Q.value = 1; high.type = 'highshelf'; high.frequency.value = 3200; lp.type = 'lowpass'; lp.frequency.value = 22050; // default fully open hp.type = 'highpass'; hp.frequency.value = 0; // default fully open // Connect: Filters -> Volume -> Analyser -> Crossfader -> Destination low.connect(mid); mid.connect(high); high.connect(lp); lp.connect(hp); hp.connect(volumeGain); volumeGain.connect(analyser); analyser.connect(crossfaderGain); crossfaderGain.connect(audioCtx.destination); // Set default values volumeGain.gain.value = 0.8; // 80% volume // Crossfader: A=1.0 at left (val=0), B=1.0 at right (val=100), both 0.5 at center (val=50) crossfaderGain.gain.value = id === 'A' ? 0.5 : 0.5; // Center position initially decks[id].volumeGain = volumeGain; decks[id].crossfaderGain = crossfaderGain; decks[id].gainNode = volumeGain; // Keep for compatibility decks[id].analyser = analyser; decks[id].filters = { low, mid, high, lp, hp }; // Set canvas dimensions properly with DPI scaling const canvas = document.getElementById('waveform-' + id); const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = 80 * dpr; // Scale context to match DPI (reset transform first to prevent stacking) const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset to identity matrix ctx.scale(dpr, dpr); // Waveform Scrubbing (Dragging to Seek) const handleScrub = (e) => { if (!decks[id].duration) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX || (e.touches && e.touches[0].clientX)) - rect.left; const percent = Math.max(0, Math.min(1, x / rect.width)); seekTo(id, percent * decks[id].duration); }; canvas.addEventListener('mousedown', (e) => { draggingState[id] = true; handleScrub(e); }); canvas.addEventListener('mousemove', (e) => { if (draggingState[id]) handleScrub(e); }); canvas.addEventListener('mouseup', () => { draggingState[id] = false; }); canvas.addEventListener('mouseleave', () => { draggingState[id] = false; }); // Mobile Touch Support canvas.addEventListener('touchstart', (e) => { draggingState[id] = true; handleScrub(e); e.preventDefault(); }, { passive: false }); canvas.addEventListener('touchmove', (e) => { if (draggingState[id]) { handleScrub(e); e.preventDefault(); } }, { passive: false }); canvas.addEventListener('touchend', () => { draggingState[id] = false; }); // Disk Scrubbing (Dragging on the Disk) const disk = document.querySelector(`#deck-${id} .dj-disk`); let isDiskDragging = false; disk.addEventListener('mousedown', (e) => { isDiskDragging = true; e.stopPropagation(); // Don't trigger the click/toggleDeck if we're dragging }); disk.addEventListener('mousemove', (e) => { if (isDiskDragging && decks[id].playing) { // Scrub based on vertical movement const movement = e.movementY || 0; const currentPos = getCurrentPosition(id); const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1)); seekTo(id, newPos); } }); disk.addEventListener('mouseup', () => { isDiskDragging = false; }); disk.addEventListener('mouseleave', () => { isDiskDragging = false; }); disk.addEventListener('touchstart', (e) => { isDiskDragging = true; e.stopPropagation(); }, { passive: false }); disk.addEventListener('touchmove', (e) => { if (isDiskDragging && decks[id].playing && e.touches[0]) { const movement = e.touches[0].clientY - (disk._lastTouchY || e.touches[0].clientY); disk._lastTouchY = e.touches[0].clientY; const currentPos = getCurrentPosition(id); const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1)); seekTo(id, newPos); } }, { passive: false }); disk.addEventListener('touchend', () => { isDiskDragging = false; delete disk._lastTouchY; }); // Handle click for toggle (only if not scrubbing) disk.addEventListener('click', (e) => { if (!isDiskDragging) { toggleDeck(id); } }); }); fetchLibrary(); updateTimeDisplays(); animateVUMeters(); // Start VU meter animation // Handle resize for DPI scaling window.addEventListener('resize', () => { ['A', 'B'].forEach(id => { const canvas = document.getElementById('waveform-' + id); if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = canvas.offsetWidth * dpr; canvas.height = 80 * dpr; const ctx = canvas.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.scale(dpr, dpr); drawWaveform(id); }); }); // Initialize mobile view if (window.innerWidth <= 1024) { switchTab('library'); } } // VU Meter Animation with smoothing const vuMeterState = { A: { smoothedValues: [], peakValues: [] }, B: { smoothedValues: [], peakValues: [] } }; function animateVUMeters() { requestAnimationFrame(animateVUMeters); const anyPlaying = decks.A.playing || decks.B.playing; const isListener = window.location.port === '5001' || window.location.search.includes('listen=true'); // Skip rendering if nothing is happening to save battery if (!anyPlaying && !isListener) return; ['A', 'B'].forEach(id => { const canvas = document.getElementById('viz-' + id); if (!canvas || !decks[id].analyser) return; const ctx = canvas.getContext('2d'); const analyser = decks[id].analyser; const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); analyser.getByteFrequencyData(dataArray); const width = canvas.width; const height = canvas.height; const barCount = 32; const barWidth = width / barCount; // Initialize smoothed values if needed if (!vuMeterState[id].smoothedValues.length) { vuMeterState[id].smoothedValues = new Array(barCount).fill(0); vuMeterState[id].peakValues = new Array(barCount).fill(0); } ctx.fillStyle = '#0a0a12'; ctx.fillRect(0, 0, width, height); for (let i = 0; i < barCount; i++) { // Use logarithmic frequency distribution for better bass/mid/treble representation const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength); const rawValue = dataArray[freqIndex] / 255; // Smooth the values for less jittery animation const smoothingFactor = 0.7; // Higher = smoother but less responsive const targetValue = rawValue; vuMeterState[id].smoothedValues[i] = (vuMeterState[id].smoothedValues[i] * smoothingFactor) + (targetValue * (1 - smoothingFactor)); const value = vuMeterState[id].smoothedValues[i]; const barHeight = value * height; // Peak hold with decay if (value > vuMeterState[id].peakValues[i]) { vuMeterState[id].peakValues[i] = value; } else { vuMeterState[id].peakValues[i] *= 0.95; // Decay rate } // Draw main bar with gradient const hue = id === 'A' ? 180 : 280; // Cyan for A, Magenta for B const saturation = 100; const lightness = 30 + (value * 50); // Create gradient for each bar const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight); gradient.addColorStop(0, `hsl(${hue}, ${saturation}%, ${lightness}%)`); gradient.addColorStop(1, `hsl(${hue}, ${saturation}%, ${Math.min(lightness + 20, 80)}%)`); ctx.fillStyle = gradient; ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight); // Draw peak indicator const peakY = height - (vuMeterState[id].peakValues[i] * height); ctx.fillStyle = `hsl(${hue}, 100%, 70%)`; ctx.fillRect(i * barWidth, peakY - 2, barWidth - 2, 2); } }); } // Play/Pause Toggle for Disk function toggleDeck(id) { if (decks[id].playing) { pauseDeck(id); } else { playDeck(id); } } // Mobile Tab Switching function switchTab(tabId) { const container = document.querySelector('.app-container'); const buttons = document.querySelectorAll('.tab-btn'); // Remove all tab classes container.classList.remove('show-library', 'show-deck-A', 'show-deck-B'); buttons.forEach(btn => btn.classList.remove('active')); // Add active class and button state container.classList.add('show-' + tabId); // Find the button and activate it buttons.forEach(btn => { if (btn.getAttribute('onclick').includes(tabId)) { btn.classList.add('active'); } }); // Redraw waveforms if switching to a deck if (tabId.startsWith('deck')) { const id = tabId.split('-')[1]; setTimeout(() => drawWaveform(id), 100); } } // Waveform Generation (Optimized for Speed) function generateWaveformData(buffer) { const rawData = buffer.getChannelData(0); const samples = 1000; const blockSize = Math.floor(rawData.length / samples); // Only process a subset of samples for huge speed gain const step = Math.max(1, Math.floor(blockSize / 20)); const filteredData = []; for (let i = 0; i < samples; i++) { let blockStart = blockSize * i; let sum = 0; let count = 0; for (let j = 0; j < blockSize; j += step) { const index = blockStart + j; // Bounds check to prevent NaN values if (index < rawData.length) { sum += Math.abs(rawData[index]); count++; } } filteredData.push(sum / (count || 1)); } return filteredData; } function drawWaveform(id) { const canvas = document.getElementById('waveform-' + id); if (!canvas) return; // Null check const ctx = canvas.getContext('2d'); const data = decks[id].waveformData; if (!data) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const width = canvas.width; const height = canvas.height; const barWidth = width / data.length; data.forEach((val, i) => { const h = val * height * 5; ctx.fillStyle = id === 'A' ? '#00f3ff' : '#bc13fe'; ctx.fillRect(i * barWidth, (height - h) / 2, barWidth, h); }); // Draw Cues if (decks[id].duration > 0) { Object.values(decks[id].cues).forEach(time => { const x = (time / decks[id].duration) * width; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); }); } // Draw Loop Markers if (decks[id].loopStart !== null && decks[id].duration > 0) { const xIn = (decks[id].loopStart / decks[id].duration) * width; ctx.strokeStyle = '#ffbb00'; ctx.lineWidth = 2; ctx.setLineDash([5, 3]); ctx.beginPath(); ctx.moveTo(xIn, 0); ctx.lineTo(xIn, height); ctx.stroke(); if (decks[id].loopActive && decks[id].loopEnd !== null) { const xOut = (decks[id].loopEnd / decks[id].duration) * width; ctx.strokeStyle = '#ffaa33'; ctx.beginPath(); ctx.moveTo(xOut, 0); ctx.lineTo(xOut, height); ctx.stroke(); // Shade loop area ctx.fillStyle = 'rgba(255, 187, 0, 0.15)'; ctx.fillRect(xIn, 0, xOut - xIn, height); } ctx.setLineDash([]); } } // BPM Detection (Optimized: Only check middle 60 seconds for speed) function detectBPM(buffer) { const sampleRate = buffer.sampleRate; const duration = buffer.duration; // Pick a 60s window in the middle const startOffset = Math.max(0, Math.floor((duration / 2 - 30) * sampleRate)); const endOffset = Math.min(buffer.length, Math.floor((duration / 2 + 30) * sampleRate)); const data = buffer.getChannelData(0).slice(startOffset, endOffset); const bpmRange = [60, 180]; const windowSize = Math.floor(sampleRate * 60 / bpmRange[1]); let peaks = 0; let threshold = 0; for (let i = 0; i < data.length; i += windowSize) { const slice = data.slice(i, i + windowSize); const avg = slice.reduce((a, b) => a + Math.abs(b), 0) / slice.length; if (avg > threshold) { peaks++; threshold = avg * 0.8; } } const windowDuration = data.length / sampleRate; const bpm = Math.round((peaks / windowDuration) * 60); return bpm > bpmRange[0] && bpm < bpmRange[1] ? bpm : 0; } // Time Display Updates function updateTimeDisplays() { requestAnimationFrame(updateTimeDisplays); // Only update if at least one deck is playing const anyPlaying = decks.A.playing || decks.B.playing; if (!anyPlaying) return; ['A', 'B'].forEach(id => { if (decks[id].playing && decks[id].localBuffer) { const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate); // Handle Looping wrapping for UI if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { const loopLen = decks[id].loopEnd - decks[id].loopStart; if (current >= decks[id].loopEnd && loopLen > 0) { current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart; } } else if (settings[`repeat${id}`]) { // Full song repeat wrapping for UI current = current % decks[id].duration; } else { current = Math.min(current, decks[id].duration); } document.getElementById('time-current-' + id).textContent = formatTime(current); // Update playhead const progress = (current / decks[id].duration) * 100; const playhead = document.getElementById('playhead-' + id); if (playhead) playhead.style.left = progress + '%'; } }); } function getCurrentPosition(id) { if (!decks[id].playing) return decks[id].pausedAt; if (!audioCtx) return decks[id].pausedAt; const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate); // Handle wrapping for correct position return if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) { const loopLen = decks[id].loopEnd - decks[id].loopStart; if (pos >= decks[id].loopEnd && loopLen > 0) { pos = ((pos - decks[id].loopStart) % loopLen) + decks[id].loopStart; } } else if (settings[`repeat${id}`] && decks[id].duration > 0) { pos = pos % decks[id].duration; } else { pos = Math.min(decks[id].duration, pos); } return pos; } function formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } // Playback Logic function playDeck(id) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_play', { deck: id }); decks[id].playing = true; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.add('playing'); console.log(`[Deck ${id}] Play command sent to server`); return; } // Browser-side audio mode (original code) if (decks[id].type === 'local' && decks[id].localBuffer) { if (decks[id].playing) return; try { console.log(`[Deck ${id}] Starting playback from ${decks[id].pausedAt}s`); decks[id].playing = true; seekTo(id, decks[id].pausedAt); const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.add('playing'); if (audioCtx.state === 'suspended') { console.log(`[Deck ${id}] Resuming suspended AudioContext`); audioCtx.resume(); } // Auto-start broadcast if enabled and not already broadcasting if (autoStartStream && !isBroadcasting && audioCtx) { setTimeout(() => { if (!socket) initSocket(); setTimeout(() => startBroadcast(), 500); }, 100); } } catch (error) { console.error(`[Deck ${id}] Playback error:`, error); decks[id].playing = false; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.remove('playing'); alert(`Playback error: ${error.message}`); } } else { console.warn(`[Deck ${id}] Cannot play - no buffer loaded`); } } function pauseDeck(id) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_pause', { deck: id }); decks[id].playing = false; document.getElementById('deck-' + id).classList.remove('playing'); console.log(`[Deck ${id}] Pause command sent to server`); return; } // Browser-side audio mode (original code) if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) { if (!audioCtx) { console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialized`); decks[id].playing = false; } else { const playbackRate = decks[id].localSource.playbackRate.value; const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; decks[id].pausedAt = decks[id].lastAnchorPosition + (realElapsed * playbackRate); try { decks[id].localSource.stop(); decks[id].localSource.onended = null; } catch (e) { } decks[id].localSource = null; decks[id].playing = false; } } document.getElementById('deck-' + id).classList.remove('playing'); } function seekTo(id, time) { // Update local state and timestamp for seek protection decks[id].lastSeekTime = Date.now(); if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_seek', { deck: id, position: time }); // Update local state immediately for UI responsiveness decks[id].lastAnchorPosition = time; if (!decks[id].playing) { decks[id].pausedAt = time; } // Update UI immediately (Optimistic UI) const progress = (time / decks[id].duration) * 100; const playhead = document.getElementById('playhead-' + id); if (playhead) playhead.style.left = progress + '%'; const timer = document.getElementById('time-current-' + id); if (timer) timer.textContent = formatTime(time); return; } if (!decks[id].localBuffer) { console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`); return; } try { if (decks[id].playing) { if (decks[id].localSource) { try { decks[id].localSource.stop(); decks[id].localSource.onended = null; } catch (e) { } } const src = audioCtx.createBufferSource(); src.buffer = decks[id].localBuffer; if (decks[id].loopActive) { src.loop = true; src.loopStart = decks[id].loopStart || 0; src.loopEnd = decks[id].loopEnd || decks[id].duration; } src.connect(decks[id].filters.low); const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0; src.playbackRate.value = speed; decks[id].localSource = src; decks[id].lastAnchorTime = audioCtx.currentTime; decks[id].lastAnchorPosition = time; // Add error handler for the source src.onended = () => { console.log(`[Deck ${id}] Playback ended naturally`); }; src.start(0, time); console.log(`[Deck ${id}] Playback started at ${time}s with speed ${speed}x`); } else { decks[id].pausedAt = time; decks[id].lastAnchorPosition = time; if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume(); // Update UI immediately (Manual Position) const timer = document.getElementById('time-current-' + id); if (timer) timer.textContent = formatTime(time); const progress = (time / decks[id].duration) * 100; const playhead = document.getElementById('playhead-' + id); if (playhead) playhead.style.left = progress + '%'; } } catch (error) { console.error(`[Deck ${id}] SeekTo error:`, error); // Reset playing state on error decks[id].playing = false; const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.remove('playing'); document.body.classList.remove('playing-' + id); } } function changeSpeed(id, val) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) }); return; } // Browser-side audio mode if (!audioCtx || !decks[id].localSource) return; const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value; decks[id].lastAnchorTime = audioCtx.currentTime; decks[id].localSource.playbackRate.value = parseFloat(val); } function changeVolume(id, val) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_set_volume', { deck: id, volume: val / 100 }); return; } // Browser-side audio mode if (decks[id].volumeGain) { decks[id].volumeGain.gain.value = val / 100; } } function changeEQ(id, band, val) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) }); return; } // Browser-side audio mode if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val); } function changeFilter(id, type, val) { if (!audioCtx || !decks[id].filters) return; const filter = type === 'lowpass' ? decks[id].filters.lp : decks[id].filters.hp; if (!filter) return; // Use exponential scaling for filter frequency (more musical) if (type === 'lowpass') { // Low-pass: 0 = low freq (muffled), 100 = high freq (open) const freq = 20 * Math.pow(22050 / 20, val / 100); filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05); } else { // High-pass: 0 = low freq (open), 100 = high freq (thin) const freq = 20 * Math.pow(22050 / 20, val / 100); filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05); } } // Hot Cue Functionality function handleCue(id, cueNum) { if (!decks[id].localBuffer) { console.warn(`[Deck ${id}] No track loaded - cannot set cue`); return; } // If cue exists, jump to it and start playing if (decks[id].cues[cueNum] !== undefined) { const cueTime = decks[id].cues[cueNum]; console.log(`[Deck ${id}] Jumping to cue ${cueNum} at ${cueTime.toFixed(2)}s`); // Always seek to cue point seekTo(id, cueTime); // Auto-play when triggering cue (like real DJ equipment) if (!decks[id].playing) { playDeck(id); } // Visual feedback const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; if (cueBtn) { cueBtn.classList.add('cue-triggered'); setTimeout(() => cueBtn.classList.remove('cue-triggered'), 200); } } else { // Set new cue at current position const currentTime = getCurrentPosition(id); decks[id].cues[cueNum] = currentTime; console.log(`[Deck ${id}] Set cue ${cueNum} at ${currentTime.toFixed(2)}s`); const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; if (cueBtn) { cueBtn.classList.add('cue-set'); // Flash to show it was set cueBtn.style.animation = 'none'; setTimeout(() => { cueBtn.style.animation = 'flash 0.3s ease'; }, 10); } drawWaveform(id); } } function clearCue(id, cueNum) { delete decks[id].cues[cueNum]; const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1]; if (cueBtn) cueBtn.classList.remove('cue-set'); drawWaveform(id); } function setLoop(id, action) { if (!decks[id].localBuffer) { console.warn(`[Deck ${id}] No track loaded - cannot set loop`); return; } const currentTime = getCurrentPosition(id); if (action === 'in') { // Set loop start point decks[id].loopStart = currentTime; decks[id].loopEnd = null; // Clear loop end decks[id].loopActive = false; // Not active until OUT is set console.log(`[Deck ${id}] Loop IN set at ${currentTime.toFixed(2)}s`); // Visual feedback const loopInBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(1)`); if (loopInBtn) { loopInBtn.style.background = 'rgba(255, 187, 0, 0.3)'; loopInBtn.style.borderColor = '#ffbb00'; } } else if (action === 'out') { // Set loop end point and activate if (decks[id].loopStart === null) { // If no loop start, set it to beginning decks[id].loopStart = 0; console.log(`[Deck ${id}] Auto-set loop IN at 0s`); } if (currentTime > decks[id].loopStart) { decks[id].loopEnd = currentTime; decks[id].loopActive = true; const loopLength = decks[id].loopEnd - decks[id].loopStart; console.log(`[Deck ${id}] Loop OUT set at ${currentTime.toFixed(2)}s (${loopLength.toFixed(2)}s loop)`); // Visual feedback const loopOutBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(2)`); if (loopOutBtn) { loopOutBtn.style.background = 'rgba(255, 187, 0, 0.3)'; loopOutBtn.style.borderColor = '#ffbb00'; } // Restart playback to apply loop immediately if (decks[id].playing) { const currentPos = getCurrentPosition(id); seekTo(id, currentPos); } } else { console.warn(`[Deck ${id}] Loop OUT must be after Loop IN`); alert('Loop OUT must be after Loop IN!'); } } else if (action === 'exit') { // Get current position BEFORE clearing loop state const currentPos = getCurrentPosition(id); // Exit/clear loop decks[id].loopActive = false; decks[id].loopStart = null; decks[id].loopEnd = null; console.log(`[Deck ${id}] Loop cleared at position ${currentPos.toFixed(2)}s`); // Clear visual feedback const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`); loopBtns.forEach(btn => { btn.style.background = ''; btn.style.borderColor = ''; }); // Restart playback from current loop position to continue smoothly if (decks[id].playing) { seekTo(id, currentPos); } else { // If paused, just update the paused position decks[id].pausedAt = currentPos; } } drawWaveform(id); } // Auto-Loop with Beat Lengths (you.dj style) function setAutoLoop(id, beats) { if (!decks[id].localBuffer) { console.warn(`[Deck ${id}] No track loaded - cannot set auto-loop`); return; } // Check if clicking the same active loop - if so, disable it if (decks[id].activeAutoLoop === beats && decks[id].loopActive) { // Get current position WITHIN the loop before disabling const currentPos = getCurrentPosition(id); // Disable loop decks[id].loopActive = false; decks[id].loopStart = null; decks[id].loopEnd = null; decks[id].activeAutoLoop = null; console.log(`[Deck ${id}] Auto-loop ${beats} beats disabled at position ${currentPos.toFixed(2)}s`); // Update UI updateAutoLoopButtons(id); drawWaveform(id); // Restart playback from current loop position to continue smoothly if (decks[id].playing) { seekTo(id, currentPos); } else { // If paused, just update the paused position decks[id].pausedAt = currentPos; } return; } // Check if BPM is detected if (!decks[id].bpm || decks[id].bpm === 0) { alert(`Cannot set auto-loop: BPM not detected for Deck ${id}.\nPlease use manual loop controls (LOOP IN/OUT).`); console.warn(`[Deck ${id}] BPM not detected - cannot calculate auto-loop`); return; } const currentTime = getCurrentPosition(id); const bpm = decks[id].bpm; // Calculate loop length in seconds: (beats / BPM) * 60 const loopLength = (beats / bpm) * 60; // Set loop points decks[id].loopStart = currentTime; decks[id].loopEnd = currentTime + loopLength; decks[id].loopActive = true; decks[id].activeAutoLoop = beats; console.log(`[Deck ${id}] Auto-loop set: ${beats} beats = ${loopLength.toFixed(3)}s at ${bpm} BPM`); // Update UI updateAutoLoopButtons(id); drawWaveform(id); // Restart playback to apply loop immediately if (decks[id].playing) { seekTo(id, currentTime); } } // Update auto-loop button visual states function updateAutoLoopButtons(id) { const buttons = document.querySelectorAll(`#deck-${id} .auto-loop-btn`); buttons.forEach(btn => { const btnBeats = parseFloat(btn.dataset.beats); if (decks[id].activeAutoLoop === btnBeats && decks[id].loopActive) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); } function pitchBend(id, amount) { if (!audioCtx || !decks[id].localSource) return; const slider = document.querySelector(`#deck-${id} .speed-slider`); const baseSpeed = parseFloat(slider.value); // Re-anchor const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value; decks[id].lastAnchorTime = audioCtx.currentTime; if (amount !== 0) { decks[id].localSource.playbackRate.value = baseSpeed + amount; } else { decks[id].localSource.playbackRate.value = baseSpeed; } } function syncDecks(id) { const otherDeck = id === 'A' ? 'B' : 'A'; if (!decks[id].bpm || !decks[otherDeck].bpm) return; // Calculate ratio to make other deck match this deck const ratio = decks[id].bpm / decks[otherDeck].bpm; const slider = document.querySelector(`#deck-${otherDeck} .speed-slider`); if (slider) slider.value = ratio; changeSpeed(otherDeck, ratio); } function updateCrossfader(val) { // Server-side audio mode if (SERVER_SIDE_AUDIO) { if (!socket) initSocket(); socket.emit('audio_set_crossfader', { value: parseInt(val) }); return; } // Browser-side audio mode const volA = (100 - val) / 100; const volB = val / 100; if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA; if (decks.B.crossfaderGain) decks.B.crossfaderGain.gain.value = volB; } // Library Functions async function fetchLibrary() { try { const res = await fetch('library.json?t=' + new Date().getTime()); allSongs = await res.json(); renderLibrary(allSongs); } catch (e) { console.error("Library fetch failed", e); } } function renderLibrary(songs) { const list = document.getElementById('library-list'); list.innerHTML = ''; if (songs.length === 0) { list.innerHTML = '
Library empty. Download some music!
'; return; } songs.forEach(t => { const item = document.createElement('div'); item.className = 'track-row'; const trackName = document.createElement('span'); trackName.className = 'track-name'; trackName.textContent = t.title; // Safe text assignment const loadActions = document.createElement('div'); loadActions.className = 'load-actions'; // LOAD buttons const btnA = document.createElement('button'); btnA.className = 'load-btn btn-a'; btnA.textContent = 'LOAD A'; btnA.addEventListener('click', () => loadFromServer('A', t.file, t.title)); const btnB = document.createElement('button'); btnB.className = 'load-btn btn-b'; btnB.textContent = 'LOAD B'; btnB.addEventListener('click', () => loadFromServer('B', t.file, t.title)); // QUEUE buttons const queueA = document.createElement('button'); queueA.className = 'load-btn queue-btn-a'; queueA.textContent = '📋 Q-A'; queueA.title = 'Add to Queue A'; queueA.addEventListener('click', () => addToQueue('A', t.file, t.title)); const queueB = document.createElement('button'); queueB.className = 'load-btn queue-btn-b'; queueB.textContent = '📋 Q-B'; queueB.title = 'Add to Queue B'; queueB.addEventListener('click', () => addToQueue('B', t.file, t.title)); loadActions.appendChild(btnA); loadActions.appendChild(queueA); loadActions.appendChild(btnB); loadActions.appendChild(queueB); item.appendChild(trackName); item.appendChild(loadActions); // Add data attribute for highlighting item.dataset.file = t.file; list.appendChild(item); }); // Update highlighting after rendering updateLibraryHighlighting(); } // Update library highlighting to show which tracks are loaded function updateLibraryHighlighting() { const trackRows = document.querySelectorAll('.track-row'); trackRows.forEach(row => { const file = row.dataset.file; // Remove all deck classes row.classList.remove('loaded-deck-a', 'loaded-deck-b', 'loaded-both'); const onDeckA = decks.A.currentFile && decks.A.currentFile.includes(file); const onDeckB = decks.B.currentFile && decks.B.currentFile.includes(file); if (onDeckA && onDeckB) { row.classList.add('loaded-both'); } else if (onDeckA) { row.classList.add('loaded-deck-a'); } else if (onDeckB) { row.classList.add('loaded-deck-b'); } }); } // Utility function no longer needed but kept for future use function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function filterLibrary() { const query = document.getElementById('lib-search').value.toLowerCase(); const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query)); renderLibrary(filtered); } function refreshLibrary() { fetchLibrary(); } // YouTube Search Functions let youtubeSearchTimeout = null; function handleYouTubeSearch(query) { // Debounce search - wait 300ms after user stops typing clearTimeout(youtubeSearchTimeout); if (!query || query.trim().length < 2) { document.getElementById('youtube-results').innerHTML = ''; return; } youtubeSearchTimeout = setTimeout(() => { searchYouTube(query.trim()); }, 300); } async function searchYouTube(query) { const resultsDiv = document.getElementById('youtube-results'); resultsDiv.innerHTML = '
🔍 Searching YouTube...
'; try { const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`); const data = await response.json(); if (!data.success) { resultsDiv.innerHTML = `
❌ ${data.error}
`; return; } displayYouTubeResults(data.results); } catch (error) { console.error('YouTube search error:', error); resultsDiv.innerHTML = '
❌ Search failed. Check console.
'; } } function displayYouTubeResults(results) { const resultsDiv = document.getElementById('youtube-results'); if (results.length === 0) { resultsDiv.innerHTML = '
No results found
'; return; } resultsDiv.innerHTML = ''; results.forEach(result => { const resultCard = document.createElement('div'); resultCard.className = 'youtube-result-card'; resultCard.innerHTML = ` ${result.title}
${result.title}
${result.channel}
`; resultsDiv.appendChild(resultCard); }); } async function downloadYouTubeResult(url, title) { const resultsDiv = document.getElementById('youtube-results'); const originalContent = resultsDiv.innerHTML; resultsDiv.innerHTML = '
âŗ Downloading...
'; try { const response = await fetch('/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url, quality: '320' }) }); const result = await response.json(); if (result.success) { resultsDiv.innerHTML = '
✅ Downloaded! Refreshing library...
'; await fetchLibrary(); setTimeout(() => { resultsDiv.innerHTML = originalContent; }, 2000); } else { resultsDiv.innerHTML = `
❌ Download failed: ${result.error}
`; setTimeout(() => { resultsDiv.innerHTML = originalContent; }, 3000); } } catch (error) { console.error('Download error:', error); resultsDiv.innerHTML = '
❌ Download failed
'; setTimeout(() => { resultsDiv.innerHTML = originalContent; }, 3000); } } async function loadFromServer(id, url, title) { const d = document.getElementById('display-' + id); d.innerText = 'âŗ LOADING...'; d.classList.add('blink'); console.log(`[Deck ${id}] Loading: ${title} from ${url}`); // Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform if (SERVER_SIDE_AUDIO) { // Extract filename from URL and DECODE IT (for spaces etc) const filename = decodeURIComponent(url.split('/').pop()); if (!socket) initSocket(); socket.emit('audio_load_track', { deck: id, filename: filename }); console.log(`[Deck ${id}] 📡 Load command sent to server: ${filename}`); // We DON'T return here anymore. We continue below to load for the UI. } // Browser-side audio mode (original code) const wasPlaying = decks[id].playing; const wasBroadcasting = isBroadcasting; if (wasPlaying && !wasBroadcasting) { pauseDeck(id); console.log(`[Deck ${id}] Paused for song load`); } else if (wasPlaying && wasBroadcasting) { console.log(`[Deck ${id}] ⚡ BROADCAST MODE: Keeping deck playing during load to maintain stream`); } decks[id].waveformData = null; decks[id].bpm = null; decks[id].cues = {}; decks[id].pausedAt = 0; decks[id].currentFile = url; // Clear UI state const bpmEl = document.getElementById('bpm-' + id); if (bpmEl) bpmEl.textContent = ''; const cueBtns = document.querySelectorAll(`#deck-${id} .cue-btn`); cueBtns.forEach(btn => btn.classList.remove('cue-set')); try { // Fetch the audio file const res = await fetch(url); if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`); console.log(`[Deck ${id}] Fetch successful, decoding audio...`); const arrayBuffer = await res.arrayBuffer(); // Decode audio data let buffer; try { buffer = await audioCtx.decodeAudioData(arrayBuffer); } catch (decodeError) { console.error(`[Deck ${id}] Audio decode failed:`, decodeError); throw new Error(`Cannot decode audio file`); } console.log(`[Deck ${id}] Successfully decoded! Duration: ${buffer.duration}s`); // Track is now ready! decks[id].localBuffer = buffer; decks[id].duration = buffer.duration; decks[id].lastAnchorPosition = 0; // Update UI - track is ready! d.innerText = title.toUpperCase(); d.classList.remove('blink'); document.getElementById('time-total-' + id).textContent = formatTime(buffer.duration); document.getElementById('time-current-' + id).textContent = '0:00'; console.log(`[Deck ${id}] Ready to play!`); // AUTO-RESUME for broadcast continuity if (wasPlaying && wasBroadcasting) { console.log(`[Deck ${id}] đŸŽĩ Auto-resuming playback to maintain broadcast stream`); // Small delay to ensure buffer is fully ready setTimeout(() => { playDeck(id); }, 50); } // Update library highlight updateLibraryHighlighting(); // Process waveform and BPM in background const processInBackground = () => { try { console.log(`[Deck ${id}] Starting background processing...`); // Generate waveform const startWave = performance.now(); decks[id].waveformData = generateWaveformData(buffer); console.log(`[Deck ${id}] Waveform generated in ${(performance.now() - startWave).toFixed(0)}ms`); drawWaveform(id); // Detect BPM const startBPM = performance.now(); decks[id].bpm = detectBPM(buffer); console.log(`[Deck ${id}] BPM detected in ${(performance.now() - startBPM).toFixed(0)}ms`); if (decks[id].bpm) { const bpmEl = document.getElementById('bpm-' + id); if (bpmEl) bpmEl.textContent = `${decks[id].bpm} BPM`; } } catch (bgError) { console.warn(`[Deck ${id}] Background processing error:`, bgError); } }; if (window.requestIdleCallback) { requestIdleCallback(processInBackground, { timeout: 2000 }); } else { setTimeout(processInBackground, 50); } } catch (error) { console.error(`[Deck ${id}] Load error:`, error); d.innerText = 'LOAD ERROR'; d.classList.remove('blink'); setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000); } } // Download Functionality async function downloadFromPanel(deckId) { const input = document.getElementById('search-input-' + deckId); const statusDiv = document.getElementById('download-status-' + deckId); const qualitySelect = document.getElementById('quality-' + deckId); const url = input.value.trim(); if (!url) return; statusDiv.innerHTML = '
Downloading...
'; try { const res = await fetch('/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url, quality: qualitySelect.value }) }); const result = await res.json(); if (result.success) { statusDiv.innerHTML = '✅ Complete!'; fetchLibrary(); setTimeout(() => statusDiv.innerHTML = '', 3000); } else { statusDiv.innerHTML = '❌ Failed'; } } catch (e) { statusDiv.innerHTML = '❌ Error'; } } // Settings function toggleSettings() { const p = document.getElementById('settings-panel'); p.classList.toggle('active'); } // File Upload async function handleFileUpload(event) { const files = event.target.files; if (!files || files.length === 0) return; console.log(`📁 Uploading ${files.length} file(s)...`); for (let file of files) { if (!file.type.match('audio/mpeg') && !file.name.endsWith('.mp3')) { alert(`${file.name} is not an MP3 file`); continue; } const formData = new FormData(); formData.append('file', file); try { const response = await fetch('/upload', { method: 'POST', body: formData }); const result = await response.json(); if (result.success) { console.log(`✅ Uploaded: ${file.name}`); } else { console.error(`❌ Upload failed: ${result.error}`); alert(`Failed to upload ${file.name}: ${result.error}`); } } catch (error) { console.error(`❌ Upload error: ${error}`); alert(`Error uploading ${file.name}`); } } // Refresh library console.log('🔄 Refreshing library...'); await loadLibrary(); alert(`✅ ${files.length} file(s) uploaded successfully!`); // Clear the input so the same file can be uploaded again if needed event.target.value = ''; } function toggleRepeat(id, val) { settings[`repeat${id}`] = val; } function toggleAutoMix(val) { settings.autoMix = val; } function toggleShuffle(val) { settings.shuffleMode = val; } function toggleQuantize(val) { settings.quantize = val; } function toggleAutoPlay(val) { settings.autoPlay = val; } function updateManualGlow(id, val) { settings[`glow${id}`] = val; if (val) { document.body.classList.add(`playing-${id}`); } else { document.body.classList.remove(`playing-${id}`); } } function updateGlowIntensity(val) { settings.glowIntensity = parseInt(val); const opacity = settings.glowIntensity / 100; const spread = (settings.glowIntensity / 100) * 80; // Dynamically update CSS variables for the glow document.documentElement.style.setProperty('--glow-opacity', opacity); document.documentElement.style.setProperty('--glow-spread', `${spread}px`); } // Dismiss landscape prompt function dismissLandscapePrompt() { const prompt = document.getElementById('landscape-prompt'); if (prompt) { prompt.classList.add('dismissed'); // Store preference in localStorage localStorage.setItem('landscapePromptDismissed', 'true'); } } // Check if prompt was previously dismissed window.addEventListener('DOMContentLoaded', () => { const wasDismissed = localStorage.getItem('landscapePromptDismissed'); if (wasDismissed === 'true') { const prompt = document.getElementById('landscape-prompt'); if (prompt) prompt.classList.add('dismissed'); } // Initialize glow intensity updateGlowIntensity(settings.glowIntensity); const glowAToggle = document.getElementById('glow-A'); if (glowAToggle) glowAToggle.checked = settings.glowA; const glowBToggle = document.getElementById('glow-B'); if (glowBToggle) glowBToggle.checked = settings.glowB; const intensitySlider = document.getElementById('glow-intensity'); if (intensitySlider) intensitySlider.value = settings.glowIntensity; // Apply initial glow state updateManualGlow('A', settings.glowA); updateManualGlow('B', settings.glowB); // Check if this is the listener page based on hostname or port const isListenerPort = window.location.port === '5001'; const isListenerHostname = window.location.hostname.startsWith('music.') || window.location.hostname.startsWith('listen.'); const urlParams = new URLSearchParams(window.location.search); if (isListenerPort || isListenerHostname || urlParams.get('listen') === 'true') { initListenerMode(); } if (!isListenerPort && !isListenerHostname) { // Set stream URL to the listener domain const streamUrl = window.location.hostname.startsWith('dj.') ? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}` : `${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 urlParams = new URLSearchParams(window.location.search); const isListenerMode = window.location.port === '5001' || window.location.hostname.startsWith('music.') || window.location.hostname.startsWith('listen.') || urlParams.get('listen') === 'true'; // If someone opens listener mode on the DJ port (e.g. :5000?listen=true), // force the Socket.IO connection to the listener backend (:5001). const serverUrl = (isListenerMode && window.location.port !== '5001' && !window.location.hostname.startsWith('music.') && !window.location.hostname.startsWith('listen.')) ? `${window.location.protocol}//${window.location.hostname}:5001` : 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(serverUrl, { 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(evt) { const urlInput = document.getElementById('stream-url'); urlInput.select(); urlInput.setSelectionRange(0, 99999); // For mobile try { document.execCommand('copy'); const btn = evt?.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); // AudioContext will be created when user enables audio to avoid suspension // Create or reuse audio element to handle the MediaSource let audio; if (window.listenerAudio) { // Reuse existing audio element from previous initialization audio = window.listenerAudio; console.log('â™ģī¸ Reusing existing audio element'); // Clean up old MediaSource if it exists if (audio.src) { URL.revokeObjectURL(audio.src); audio.removeAttribute('src'); audio.load(); // Reset the element } } else { // Create a new hidden audio element audio = new Audio(); audio.autoplay = false; // Don't autoplay - we use the Enable Audio button audio.hidden = true; document.body.appendChild(audio); console.log('🆕 Created new audio element'); // AudioContext will be created later on user interaction } // Initialize MediaSource for streaming binary chunks const mediaSource = new MediaSource(); audio.src = URL.createObjectURL(mediaSource); // CRITICAL: Call load() to initialize the MediaSource // Without this, the audio element won't load the MediaSource until play() is called, // which will fail with "no supported sources" if no data is buffered yet audio.load(); console.log('đŸŽŦ Audio element loading 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'; // 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', () => { // Process next chunk in queue if (audioQueue.length > 0 && !sourceBuffer.updating) { sourceBuffer.appendBuffer(audioQueue.shift()); } // 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 && mediaSource.readyState === 'open') { const end = audio.buffered.end(audio.buffered.length - 1); const start = audio.buffered.start(0); if (end - start > 120) { // If buffer is > 2 mins try { sourceBuffer.remove(0, end - 60); } catch (e) { console.warn('Buffer cleanup skipped:', e.message); } } } }); } 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; window.listenerAudioEnabled = false; // Track if user has enabled audio // 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); // 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; } } } } // UI Update (only if audio is already enabled, don't overwrite the enable prompt) const now = Date.now(); if (now - lastStatusUpdate > 1000 && window.listenerAudioEnabled) { 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'); // Only update if audio is enabled, otherwise keep the "Click Enable Audio" message if (statusEl && window.listenerAudioEnabled) { statusEl.textContent = 'đŸŸĸ Connected'; } }); socket.on('disconnect', () => { const statusEl = document.getElementById('connection-status'); // Always show disconnect status as it's critical 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. Bridge Audio Element to AudioContext if not already connected if (window.listenerAudio && !window.listenerAudio._connectedToContext) { try { const sourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio); if (!listenerGainNode) { listenerGainNode = listenerAudioContext.createGain(); listenerGainNode.gain.value = 0.8; listenerGainNode.connect(listenerAudioContext.destination); } sourceNode.connect(listenerGainNode); window.listenerAudio._connectedToContext = true; console.log('🔗 Connected audio element to AudioContext'); } catch (e) { console.warn('âš ī¸ Could not connect to AudioContext:', e.message); } } // 4. Prepare and start audio playback if (window.listenerAudio) { console.log('📊 Audio element state:', { readyState: window.listenerAudio.readyState, networkState: window.listenerAudio.networkState, src: window.listenerAudio.src ? 'set' : 'not set', buffered: window.listenerAudio.buffered.length, paused: window.listenerAudio.paused }); // Unmute just in case window.listenerAudio.muted = false; // Volume is controlled via listenerGainNode; keep element volume sane. window.listenerAudio.volume = 1.0; const volEl = document.getElementById('listener-volume'); const volValue = volEl ? parseInt(volEl.value, 10) : 80; setListenerVolume(Number.isFinite(volValue) ? volValue : 80); // Check if we have buffered data const hasBufferedData = () => { return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0; }; // If no buffered data yet, wait a bit for it to arrive if (!hasBufferedData()) { console.log('âŗ Waiting for audio data to buffer...'); if (audioText) audioText.textContent = 'WAITING FOR STREAM...'; // Wait up to 10 seconds for data to arrive (increased from 5) const waitForData = new Promise((resolve) => { let attempts = 0; const maxAttempts = 100; // 10 seconds (100 * 100ms) const checkInterval = setInterval(() => { attempts++; if (attempts % 10 === 0) { // Log every second console.log(`âąī¸ Attempt ${attempts}/${maxAttempts} - Buffered: ${window.listenerAudio.buffered.length}, ReadyState: ${window.listenerAudio.readyState}`); } if (hasBufferedData()) { clearInterval(checkInterval); console.log('✅ Audio data buffered, ready to play'); resolve(); } else if (attempts >= maxAttempts) { clearInterval(checkInterval); // Don't reject - try to play anyway, MediaSource might handle it console.warn('âš ī¸ Timeout waiting for buffered data, attempting playback anyway...'); resolve(); } }, 100); }); await waitForData; } else { console.log('✅ Audio already has buffered data'); } // Attempt playback console.log('â–ļī¸ Attempting to play audio...'); await window.listenerAudio.play(); console.log('✅ Audio playback started successfully'); // Mark audio as enabled so status updates can now display window.listenerAudioEnabled = true; } // 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 = 'âš ī¸ ' + (error.message === 'Timeout waiting for audio data' ? 'No stream data yet. Is the DJ broadcasting?' : '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.');