// ========================================== // 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; let listenerChunksReceived = 0; let currentStreamMimeType = null; function getMp3FallbackUrl() { return `${window.location.protocol}//${window.location.hostname}:5001/stream.mp3`; } // 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`); const preferredTypes = [ 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', ]; const chosenType = preferredTypes.find((t) => { try { return MediaRecorder.isTypeSupported(t); } catch { return false; } }); if (!chosenType) { throw new Error('No supported MediaRecorder mimeType found on this browser'); } currentStreamMimeType = chosenType; console.log(`đŸŽ›ī¸ Using broadcast mimeType: ${currentStreamMimeType}`); mediaRecorder = new MediaRecorder(stream, { mimeType: currentStreamMimeType, 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 (include codec/container so listeners can configure SourceBuffer) if (!socket) initSocket(); socket.emit('start_broadcast', { mimeType: currentStreamMimeType }); 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 media element. // Note: MSE (MediaSource) support is often more reliable on