// ========================================== // TechDJ Pro - Core DJ Logic // ========================================== 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: [] }; // Toast Notification System function showToast(message, type) { type = type || 'info'; const container = document.getElementById('toast-container'); if (!container) return; const toast = document.createElement('div'); toast.className = 'toast toast-' + type; toast.textContent = message; container.appendChild(toast); setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 4000); } // Settings Persistence function saveSettings() { try { localStorage.setItem('techdj_settings', JSON.stringify(settings)); } catch (e) { /* quota exceeded or private browsing */ } } function loadSettings() { try { var saved = localStorage.getItem('techdj_settings'); if (saved) { var parsed = JSON.parse(saved); Object.keys(parsed).forEach(function(key) { if (settings.hasOwnProperty(key)) settings[key] = parsed[key]; }); } } catch (e) { /* corrupt data, ignore */ } } // Restore saved settings on load loadSettings(); // 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); }); }); // Initialise mobile view if (window.innerWidth <= 1024) { switchTab('library'); } initDropZones(); } function initDropZones() { ['A', 'B'].forEach(id => { const deckEl = document.getElementById(`deck-${id}`); const queueEl = document.getElementById(`deck-queue-list-${id}`); // Deck Drop Zone (Load Track) if (deckEl) { deckEl.ondragover = (e) => { e.preventDefault(); deckEl.classList.add('drag-over'); }; deckEl.ondragleave = () => { deckEl.classList.remove('drag-over'); }; deckEl.ondrop = (e) => { e.preventDefault(); deckEl.classList.remove('drag-over'); const file = e.dataTransfer.getData('trackFile'); const title = e.dataTransfer.getData('trackTitle'); if (file && title) { console.log(`Dropped track onto Deck ${id}: ${title}`); loadFromServer(id, file, title); } }; } // Queue Drop Zone (Add to Queue) if (queueEl) { queueEl.ondragover = (e) => { e.preventDefault(); queueEl.classList.add('drag-over'); }; queueEl.ondragleave = () => { queueEl.classList.remove('drag-over'); }; queueEl.ondrop = (e) => { e.preventDefault(); queueEl.classList.remove('drag-over'); const file = e.dataTransfer.getData('trackFile'); const title = e.dataTransfer.getData('trackTitle'); const fromDeck = e.dataTransfer.getData('queueDeck'); const fromIndex = e.dataTransfer.getData('queueIndex'); if (fromDeck && fromDeck !== id && fromIndex !== "") { // Move from another queue const idx = parseInt(fromIndex); const [movedItem] = queues[fromDeck].splice(idx, 1); queues[id].push(movedItem); renderQueue(fromDeck); renderQueue(id); console.log(`Moved track from Queue ${fromDeck} to end of Queue ${id}: ${movedItem.title}`); } else if (file && title) { // Add from library (or re-append from same queue - which is essentially a no-op move to end) console.log(`Dropped track into Queue ${id}: ${title}`); addToQueue(id, file, title); } }; } }); } // 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'); // When nothing is playing, clear canvases to transparent so they don't show as dark blocks if (!anyPlaying && !isListener) { ['A', 'B'].forEach(id => { const canvas = document.getElementById('viz-' + id); if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); }); 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; // Initialise 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 let currentQueueTab = 'A'; // Track which queue is shown function switchTab(tabId) { const container = document.querySelector('.app-container'); const buttons = document.querySelectorAll('.tab-btn'); const sections = document.querySelectorAll('.library-section, .deck, .queue-section'); // Remove all tab and active classes container.classList.remove('show-library', 'show-deck-A', 'show-deck-B', 'show-queue-A', 'show-queue-B'); buttons.forEach(btn => btn.classList.remove('active')); sections.forEach(sec => sec.classList.remove('active')); // Add active class and button state container.classList.add('show-' + tabId); // Activate target section const targetSection = document.getElementById(tabId) || document.querySelector('.' + tabId + '-section'); if (targetSection) targetSection.classList.add('active'); // Find the button and activate it buttons.forEach(btn => { const onClickAttr = btn.getAttribute('onclick'); if (onClickAttr && (onClickAttr.includes(tabId) || (tabId.startsWith('queue') && onClickAttr.includes('switchQueueTab')))) { btn.classList.add('active'); } }); // Update queue tab label to reflect which queue is showing if (tabId.startsWith('queue')) { currentQueueTab = tabId.includes('A') ? 'A' : 'B'; const label = document.getElementById('queue-tab-label'); if (label) label.textContent = 'QUEUE ' + currentQueueTab; } // Redraw waveforms if switching to a deck if (tabId.startsWith('deck')) { const id = tabId.includes('-') ? tabId.split('-')[1] : (tabId.includes('A') ? 'A' : 'B'); setTimeout(() => drawWaveform(id), 100); } // Haptic feedback vibrate(10); } // Queue tab cycles between Queue A and Queue B function switchQueueTab() { const container = document.querySelector('.app-container'); const isQueueActive = container.classList.contains('show-queue-A') || container.classList.contains('show-queue-B'); if (!isQueueActive) { // First tap: show current queue switchTab('queue-' + currentQueueTab); } else { // Already on a queue tab: toggle between A and B const nextQueue = currentQueueTab === 'A' ? 'B' : 'A'; switchTab('queue-' + nextQueue); } } // Mobile Haptic Helper function vibrate(ms) { if (navigator.vibrate) { navigator.vibrate(ms); } } // Mobile FAB Menu Toggle function toggleFabMenu(e) { if (e) e.stopPropagation(); const menu = document.getElementById('fab-menu'); const fab = document.querySelector('.fab-main'); if (menu) menu.classList.toggle('active'); if (fab) fab.classList.toggle('active'); vibrate(10); } // Close menu when clicking outside document.addEventListener('click', () => { const menu = document.getElementById('fab-menu'); const fab = document.querySelector('.fab-main'); if (menu && menu.classList.contains('active')) { menu.classList.remove('active'); fab.classList.remove('active'); } }); // Update Clock setInterval(() => { const clock = document.getElementById('clock-display'); if (clock) { const now = new Date(); clock.textContent = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString().padStart(2, '0'); } }, 1000); // Fullscreen Toggle function toggleFullScreen() { if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(err => { console.error(`Error attempting to enable full-screen mode: ${err.message}`); }); document.getElementById('fullscreen-toggle').classList.add('active'); } else { if (document.exitFullscreen) { document.exitFullscreen(); } document.getElementById('fullscreen-toggle').classList.remove('active'); } vibrate(20); } // Touch Swiping Logic let touchStartX = 0; let touchEndX = 0; document.addEventListener('touchstart', e => { touchStartX = e.changedTouches[0].screenX; }, false); document.addEventListener('touchend', e => { touchEndX = e.changedTouches[0].screenX; handleSwipe(); }, false); function handleSwipe() { const threshold = 100; const swipeDistance = touchEndX - touchStartX; // Get current tab const activeBtn = document.querySelector('.tab-btn.active'); if (!activeBtn) return; const tabs = ['library', 'deck-A', 'deck-B', 'queue-A', 'queue-B']; let currentIndex = -1; const onClickAttr = activeBtn.getAttribute('onclick') || ''; if (onClickAttr.includes('library')) currentIndex = 0; else if (onClickAttr.includes('deck-A')) currentIndex = 1; else if (onClickAttr.includes('deck-B')) currentIndex = 2; else if (onClickAttr.includes('switchQueueTab')) { // Determine which queue is active const container = document.querySelector('.app-container'); currentIndex = container.classList.contains('show-queue-B') ? 4 : 3; } if (currentIndex === -1) return; if (swipeDistance > threshold) { // Swipe Right (Go Left) if (currentIndex > 0) switchTab(tabs[currentIndex - 1]); } else if (swipeDistance < -threshold) { // Swipe Left (Go Right) if (currentIndex < tabs.length - 1) switchTab(tabs[currentIndex + 1]); } } // Waveform Generation (Optimised 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; } // Debounce guard: prevents redundant redraws within the same frame const _waveformPending = { A: false, B: false }; function drawWaveform(id) { if (_waveformPending[id]) return; _waveformPending[id] = true; requestAnimationFrame(() => { _waveformPending[id] = false; _drawWaveformImmediate(id); }); } function _drawWaveformImmediate(id) { const canvas = document.getElementById('waveform-' + id); if (!canvas) return; const ctx = canvas.getContext('2d'); const data = decks[id].waveformData; if (!data) return; // Use logical (CSS) dimensions — the canvas context has ctx.scale(dpr) // applied in initSystem, so coordinates must be in logical pixels. const dpr = window.devicePixelRatio || 1; const width = canvas.width / dpr; const height = canvas.height / dpr; ctx.clearRect(0, 0, width, 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([]); } // Apply manual glow if enabled if (settings[`glow${id}`] && !decks[id].playing) { applyGlow(id, settings.glowIntensity); } else { removeGlow(id); } } function applyGlow(id, intensity) { const deckEl = document.getElementById('deck-' + id); if (!deckEl) return; const color = id === 'A' ? 'var(--primary-cyan)' : 'var(--secondary-magenta)'; const blur = Math.max(10, intensity); const spread = Math.max(2, intensity / 5); deckEl.style.boxShadow = `inset 0 0 ${blur}px ${spread}px ${color}`; } function removeGlow(id) { const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.style.boxShadow = ''; } // BPM Detection (Optimised: 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 _notifyListenerDeckGlow() { // Emit the current playing state of both decks to the listener page if (!socket) return; socket.emit('deck_glow', { A: !!decks.A.playing, B: !!decks.B.playing }); } function playDeck(id) { vibrate(15); 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'); document.body.classList.add('playing-' + id); _notifyListenerDeckGlow(); 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'); document.body.classList.remove('playing-' + id); alert(`Playback error: ${error.message}`); } } else { console.warn(`[Deck ${id}] Cannot play - no buffer loaded`); } } function pauseDeck(id) { vibrate(15); if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) { if (!audioCtx) { console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`); 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; } } const deckEl = document.getElementById('deck-' + id); if (deckEl) deckEl.classList.remove('playing'); document.body.classList.remove('playing-' + id); _notifyListenerDeckGlow(); } function seekTo(id, time) { // Update local state and timestamp for seek protection decks[id].lastSeekTime = Date.now(); if (!decks[id].localBuffer) { console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`); return; } try { if (decks[id].playing) { if (decks[id].localSource) { try { // Capture playback rate before stopping for the new source decks[id]._lastPlaybackRate = decks[id].localSource.playbackRate.value; 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); // Read current speed from old source before it was stopped, fall back to DOM slider let speed = 1.0; if (decks[id]._lastPlaybackRate != null) { speed = decks[id]._lastPlaybackRate; } else { const speedSlider = document.querySelector(`#deck-${id} .speed-slider`); if (speedSlider) speed = parseFloat(speedSlider.value); } src.playbackRate.value = speed; decks[id].localSource = src; decks[id].lastAnchorTime = audioCtx.currentTime; decks[id].lastAnchorPosition = time; // Wire onended so natural playback completion always triggers auto-play src.onended = () => { // Guard: only act if we didn't stop it intentionally if (decks[id].playing && !decks[id].loading) { console.log(`[Deck ${id}] Playback ended naturally (onended)`); handleTrackEnd(id); } }; 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) { 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) { if (decks[id].volumeGain) { decks[id].volumeGain.gain.value = val / 100; } } function changeEQ(id, band, val) { 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) { vibrate(15); 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) { vibrate(15); 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) { 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 // --------------------------------------------------------------------------- // Tauri v2 asset-protocol helper // When running inside Tauri (window.__TAURI__ is injected via withGlobalTauri) // and the server has provided an absolutePath for the track, we convert it to // an asset:// URL so the WebView reads the file directly from disk — no Flask // round-trip, works with any folder under $HOME. // Falls back to the ordinary server URL when not in Tauri or no absolutePath. // --------------------------------------------------------------------------- function tauriResolve(track) { const cvt = window.__TAURI__?.core?.convertFileSrc; if (cvt && track.absolutePath) { return cvt(track.absolutePath); } return track.file; } async function fetchLibrary() { try { // In Tauri: scan disk directly via native Rust commands — no Flask needed. if (window.__TAURI__?.core?.invoke) { const musicDir = await window.__TAURI__.core.invoke('get_music_folder'); allSongs = await window.__TAURI__.core.invoke('scan_library', { musicDir }); renderLibrary(allSongs); return; } // Browser / Flask fallback 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) { if (!songs) songs = allSongs; const list = document.getElementById('library-list'); list.innerHTML = ''; const filteredSongs = songs; if (filteredSongs.length === 0) { list.innerHTML = `
No tracks found.
`; return; } filteredSongs.forEach(t => { const item = document.createElement('div'); item.className = 'track-row'; item.draggable = true; // Drag data item.ondragstart = (e) => { e.dataTransfer.setData('trackFile', tauriResolve(t)); e.dataTransfer.setData('trackTitle', t.title); e.dataTransfer.setData('source', 'library'); item.classList.add('dragging'); }; item.ondragend = () => { item.classList.remove('dragging'); }; 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', tauriResolve(t), t.title)); const btnB = document.createElement('button'); btnB.className = 'load-btn btn-b'; btnB.textContent = 'LOAD B'; btnB.addEventListener('click', () => loadFromServer('B', tauriResolve(t), 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', tauriResolve(t), 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', tauriResolve(t), 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 — store the resolved URL so // updateLibraryHighlighting() matches decks.X.currentFile correctly. item.dataset.file = tauriResolve(t); 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'); } }); } 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(); } async function loadFromServer(id, url, title) { const d = document.getElementById('display-' + id); d.innerText = '[WAIT] LOADING...'; d.classList.add('blink'); console.log(`[Deck ${id}] Loading: ${title} from ${url}`); 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}] [LIVE] 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'); showToast(`Deck ${id}: Failed to load track — ${error.message}`, 'error'); setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000); } } // Settings function toggleSettings() { const p = document.getElementById('settings-panel'); p.classList.toggle('active'); } // File Upload // File Upload with Progress and Parallelism async function handleFileUpload(event) { const files = Array.from(event.target.files); if (!files || files.length === 0) return; // In Tauri: files are already local — no server upload needed. // Create session blob URLs and add directly to the in-memory library. if (window.__TAURI__?.core?.invoke) { const allowed = ['.mp3', '.m4a', '.wav', '.flac', '.ogg']; let added = 0; files.forEach(file => { const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!allowed.includes(ext)) return; const blobUrl = URL.createObjectURL(file); const title = file.name.replace(/\.[^.]+$/, ''); // absolutePath is null — tauriResolve will fall back to the blob URL. allSongs.push({ title, file: blobUrl, absolutePath: null }); added++; }); if (added > 0) { renderLibrary(allSongs); showToast(`${added} track(s) loaded into library`, 'success'); } return; } console.log(`Uploading ${files.length} file(s)...`); // Create/Show progress container let progressContainer = document.getElementById('upload-progress-container'); if (!progressContainer) { progressContainer = document.createElement('div'); progressContainer.id = 'upload-progress-container'; progressContainer.className = 'upload-progress-container'; document.body.appendChild(progressContainer); } progressContainer.innerHTML = '

UPLOADING TRACKS...

'; progressContainer.classList.add('active'); const existingFilenames = allSongs.map(s => s.file.split('/').pop().toLowerCase()); const uploadPromises = files.map(async (file) => { const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg']; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!allowedExts.includes(ext)) { console.warn(`${file.name} is not a supported audio file`); return; } // Check for duplicates if (existingFilenames.includes(file.name.toLowerCase())) { console.log(`[UPLOAD] Skipping duplicate: ${file.name}`); showToast(`Skipped duplicate: ${file.name}`, 'info'); return; } const formData = new FormData(); formData.append('file', file); const progressRow = document.createElement('div'); progressRow.className = 'upload-progress-row'; const nameSpan = document.createElement('span'); nameSpan.textContent = file.name.substring(0, 20) + (file.name.length > 20 ? '...' : ''); const barWrap = document.createElement('div'); barWrap.className = 'progress-bar-wrap'; const barInner = document.createElement('div'); barInner.className = 'progress-bar-inner'; barInner.style.width = '0%'; barWrap.appendChild(barInner); progressRow.appendChild(nameSpan); progressRow.appendChild(barWrap); progressContainer.appendChild(progressRow); return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload', true); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const percent = (e.loaded / e.total) * 100; barInner.style.width = percent + '%'; } }; xhr.onload = () => { if (xhr.status === 200) { try { const result = JSON.parse(xhr.responseText); if (result.success) { barInner.style.background = '#00ff88'; } else { barInner.style.background = '#ff4444'; nameSpan.title = result.error || 'Upload failed'; console.error(`[UPLOAD] ${file.name}: ${result.error}`); } } catch (e) { barInner.style.background = '#ff4444'; console.error(`[UPLOAD] Bad response for ${file.name}`); } } else { barInner.style.background = '#ff4444'; nameSpan.title = `HTTP ${xhr.status}`; console.error(`[UPLOAD] ${file.name}: HTTP ${xhr.status}${xhr.status === 413 ? ' — file too large (nginx limit)' : ''}`); } resolve(); // Always resolve so other uploads continue }; xhr.onerror = () => { barInner.style.background = '#ff4444'; console.error(`[UPLOAD] ${file.name}: Network error`); resolve(); }; xhr.send(formData); }); }); // Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all) try { await Promise.all(uploadPromises); console.log('All uploads finished.'); } catch (e) { console.error('Some uploads failed', e); } // Refresh library setTimeout(() => { fetchLibrary(); setTimeout(() => { progressContainer.classList.remove('active'); vibrate(30); }, 2000); }, 500); // Clear the input event.target.value = ''; } // Folder Selection Logic async function openFolderPicker() { let picker = document.getElementById('folder-picker-modal'); if (!picker) { picker = document.createElement('div'); picker.id = 'folder-picker-modal'; picker.className = 'modal-overlay'; picker.innerHTML = ` `; document.body.appendChild(picker); } picker.classList.add('active'); // Initial browse to home or current browseToPath(''); } function closeFolderPicker() { document.getElementById('folder-picker-modal').classList.remove('active'); } async function browseToPath(targetPath) { const currentInput = document.getElementById('current-folder-path'); let path = currentInput.value; if (targetPath === '..') { // Handled by server if we send '..' but let's be explicit const parts = path.split('/'); parts.pop(); path = parts.join('/') || '/'; } else if (targetPath) { path = targetPath; } try { let data; if (window.__TAURI__?.core?.invoke) { data = await window.__TAURI__.core.invoke('list_dirs', { path }); } else { const res = await fetch(`/browse_directories?path=${encodeURIComponent(path)}`); data = await res.json(); } if (data.success) { currentInput.value = data.path; const list = document.getElementById('dir-list'); list.innerHTML = ''; data.entries.forEach(entry => { const div = document.createElement('div'); div.className = 'dir-entry'; div.innerHTML = `[DIR] ${entry.name}`; div.onclick = () => browseToPath(entry.path); list.appendChild(div); }); } } catch (e) { console.error("Browse failed", e); } } async function confirmFolderSelection() { const path = document.getElementById('current-folder-path').value; try { let result; if (window.__TAURI__?.core?.invoke) { result = await window.__TAURI__.core.invoke('save_music_folder', { path }); } else { const res = await fetch('/update_settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ library: { music_folder: path } }) }); result = await res.json(); } if (result.success) { alert('Music folder updated! Refreshing library...'); closeFolderPicker(); fetchLibrary(); } else { alert('Error: ' + (result.error || 'Unknown error')); } } catch (e) { alert('Failed to update settings'); } } function toggleRepeat(id, val) { if (val === undefined) { settings[`repeat${id}`] = !settings[`repeat${id}`]; } else { settings[`repeat${id}`] = val; } // Update UI const btn = document.getElementById(`repeat-btn-${id}`); const checkbox = document.getElementById(`repeat-${id}`); if (btn) { btn.classList.toggle('active', settings[`repeat${id}`]); btn.textContent = settings[`repeat${id}`] ? 'LOOP ON' : 'LOOP'; } if (checkbox) checkbox.checked = settings[`repeat${id}`]; console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`); vibrate(10); saveSettings(); } function toggleAutoMix(val) { settings.autoMix = val; saveSettings(); } function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); } function toggleQuantize(val) { settings.quantize = val; saveSettings(); } function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); } function updateManualGlow(id, val) { settings[`glow${id}`] = val; if (val) { document.body.classList.add(`playing-${id}`); } else { document.body.classList.remove(`playing-${id}`); } saveSettings(); } function updateGlowIntensity(val) { settings.glowIntensity = parseInt(val); const opacity = settings.glowIntensity / 100; const spread = (settings.glowIntensity / 100) * 80; document.documentElement.style.setProperty('--glow-opacity', opacity); document.documentElement.style.setProperty('--glow-spread', `${spread}px`); saveSettings(); } function updateListenerGlow(val) { settings.listenerGlowIntensity = parseInt(val); if (!socket) initSocket(); socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity }); saveSettings(); } // 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'); } // Sync all restored settings to UI controls const syncCheckbox = (elId, val) => { const el = document.getElementById(elId); if (el) el.checked = !!val; }; const syncRange = (elId, val) => { const el = document.getElementById(elId); if (el && val != null) el.value = val; }; syncCheckbox('repeat-A', settings.repeatA); syncCheckbox('repeat-B', settings.repeatB); syncCheckbox('auto-mix', settings.autoMix); syncCheckbox('shuffle-mode', settings.shuffleMode); syncCheckbox('quantize', settings.quantize); syncCheckbox('auto-play', settings.autoPlay); syncCheckbox('glow-A', settings.glowA); syncCheckbox('glow-B', settings.glowB); syncRange('glow-intensity', settings.glowIntensity); syncRange('listener-glow-intensity', settings.listenerGlowIntensity); // Initialise glow intensity CSS variables updateGlowIntensity(settings.glowIntensity); // Apply initial glow state updateManualGlow('A', settings.glowA); updateManualGlow('B', settings.glowB); // Set stream URL in the streaming panel const streamInput = document.getElementById('stream-url'); if (streamInput) { const _autoDetectListenerUrl = () => { const host = window.location.hostname; if (host.startsWith('dj.')) { return `${window.location.protocol}//${host.slice(3)}`; } return `${window.location.protocol}//${host}:5001`; }; fetch('/client_config') .then(r => r.json()) .then(cfg => { streamInput.value = cfg.listener_url || _autoDetectListenerUrl(); }) .catch(() => { streamInput.value = _autoDetectListenerUrl(); }); } }); // ========== LIVE STREAMING FUNCTIONALITY ========== let socket = null; let streamDestination = null; let streamProcessor = null; let mediaRecorder = null; let isBroadcasting = false; let autoStartStream = false; let currentStreamMimeType = null; // Initialise SocketIO connection function initSocket() { if (socket) return socket; // Socket.IO is loaded from a CDN; in Tauri (offline) it may not be available. if (typeof io === 'undefined') { console.warn('[SOCKET] Socket.IO not loaded — live streaming is unavailable'); return null; } const serverUrl = window.location.origin; console.log(`[SOCKET] Connecting to ${serverUrl}`); socket = io(serverUrl, { transports: ['websocket', 'polling'], reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 10000 }); socket.on('connect', () => { console.log('[OK] Connected to streaming server'); socket.emit('get_listener_count'); }); socket.on('connect_error', (error) => { console.error('[ERROR] Connection error:', error.message); }); socket.on('disconnect', (reason) => { console.warn(`[WARN] Disconnected: ${reason}`); }); socket.on('listener_count', (data) => { const el = document.getElementById('listener-count'); if (el) el.textContent = data.count; }); socket.on('broadcast_started', () => { console.log('[EVENT] broadcast_started'); // Update relay UI if it's a relay const relayStatus = document.getElementById('relay-status'); if (relayStatus && relayStatus.textContent.includes('Connecting')) { relayStatus.textContent = 'Relay active - streaming to listeners'; relayStatus.style.color = '#00ff00'; } }); socket.on('broadcast_stopped', () => { console.log('[EVENT] broadcast_stopped'); // Reset relay UI if it was active const startRelayBtn = document.getElementById('start-relay-btn'); const stopRelayBtn = document.getElementById('stop-relay-btn'); const relayStatus = document.getElementById('relay-status'); if (startRelayBtn) startRelayBtn.style.display = 'inline-block'; if (stopRelayBtn) stopRelayBtn.style.display = 'none'; if (relayStatus) relayStatus.textContent = ''; }); 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}`); // Reset relay UI on error const startRelayBtn = document.getElementById('start-relay-btn'); const stopRelayBtn = document.getElementById('stop-relay-btn'); const relayStatus = document.getElementById('relay-status'); if (startRelayBtn) startRelayBtn.style.display = 'inline-block'; if (stopRelayBtn) stopRelayBtn.style.display = 'none'; if (relayStatus) relayStatus.textContent = ''; }); 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 (status.crossfader !== undefined) { const cf = document.getElementById('crossfader'); if (cf && Math.abs(parseInt(cf.value) - status.crossfader) > 1) { cf.value = status.crossfader; updateCrossfader(status.crossfader); } } } // Toggle streaming panel function toggleStreamingPanel() { const panel = document.getElementById('streaming-panel'); panel.classList.toggle('active'); // Initialise socket when panel is opened if (panel.classList.contains('active') && !socket) { initSocket(); } } // Toggle broadcast function toggleBroadcast() { if (!audioCtx) { alert('Please initialise the system first (click INITIALIZE SYSTEM)'); return; } if (!socket) initSocket(); if (isBroadcasting) { stopBroadcast(); } else { startBroadcast(); } } // Start broadcasting function startBroadcast() { try { console.log('Starting broadcast...'); if (!audioCtx) { alert('Please initialise the system first!'); return; } // Browser-side audio mode // 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('[OK] 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('[OK] Deck B connected to stream + speakers'); } // Verify stream has audio tracks const stream = streamDestination.stream; console.log(`[DATA] 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 = [ // Prefer MP4/AAC when available (broad device support) 'audio/mp4;codecs=mp4a.40.2', 'audio/mp4', // Fallbacks 'audio/webm', 'audio/ogg', ]; 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('[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('[OK] 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('[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('[OK] MediaRecorder resumed'); } catch (e) { console.error('[ERROR] Failed to resume MediaRecorder:', e); // If resume fails, try full restart setTimeout(() => { if (isBroadcasting) { restartBroadcast(); } }, 1000); } } }; // 250ms chunks: More frequent smaller chunks reduces stall gaps on weak connections. // A 1-second chunk creates a 1-second starvation gap if the network hiccups; // 250ms chunks keep the server fed 4x more often. // Notify server FIRST so broadcast_state is active on the server before // the first audio_chunk arrives — prevents the first ~250 ms of audio // being silently dropped by the server's isinstance() guard. if (!socket) initSocket(); const bitrateValue = document.getElementById('stream-quality').value + 'k'; socket.emit('start_broadcast', { bitrate: bitrateValue }); socket.emit('get_listener_count'); // Validate state before starting if (mediaRecorder.state === 'inactive') { mediaRecorder.start(250); streamProcessor = mediaRecorder; console.log('[OK] MediaRecorder started in state:', mediaRecorder.state); } else { console.error('[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'); console.log('[OK] Broadcasting started successfully!'); console.log('TIP TIP: Play a track on Deck A or B to stream audio'); // Monitor audio levels setTimeout(() => { if (chunkCount === 0) { console.error('[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('[ERROR] Failed to start broadcast:', error); alert('Failed to start broadcast: ' + error.message); isBroadcasting = false; } } // Stop broadcasting function stopBroadcast() { console.log('[BROADCAST] Stopping...'); if (streamProcessor) { streamProcessor.stop(); streamProcessor = null; } if (streamDestination) { // Only disconnect the specific stream connection — do NOT call .disconnect() // with no args as that also removes the audioCtx.destination connection and // causes an audible pop / silence gap for locally-monitored decks. if (decks.A.crossfaderGain) { try { decks.A.crossfaderGain.disconnect(streamDestination); } catch (e) { console.warn('Error disconnecting Deck A from stream:', e); } } if (decks.B.crossfaderGain) { try { decks.B.crossfaderGain.disconnect(streamDestination); } catch (e) { console.warn('Error disconnecting Deck B from stream:', e); } } streamDestination = null; } isBroadcasting = false; 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('[OK] 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 — only disconnect the stream leg, // not the audioCtx.destination leg, to avoid an audible glitch. if (streamDestination) { if (decks.A.crossfaderGain) { try { decks.A.crossfaderGain.disconnect(streamDestination); } catch (e) { console.warn('Error cleaning up Deck A:', e); } } if (decks.B.crossfaderGain) { try { decks.B.crossfaderGain.disconnect(streamDestination); } 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('[OK] Broadcast restarted successfully'); } }, 100); } // Copy stream URL to clipboard function copyStreamUrl(evt) { const urlInput = document.getElementById('stream-url'); const text = urlInput.value; const btn = evt?.target; const showFeedback = (success) => { if (!btn) return; const originalText = btn.textContent; btn.textContent = success ? 'OK' : 'FAIL'; setTimeout(() => { btn.textContent = originalText; }, 2000); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text) .then(() => showFeedback(true)) .catch(() => showFeedback(false)); } else { // Fallback for older browsers urlInput.select(); urlInput.setSelectionRange(0, 99999); try { document.execCommand('copy'); showFeedback(true); } catch (err) { console.error('Failed to copy:', err); showFeedback(false); } } } // Toggle auto-start stream function toggleAutoStream(enabled) { autoStartStream = enabled; localStorage.setItem('autoStartStream', enabled); } // Load auto-start preference window.addEventListener('load', () => { const autoStart = localStorage.getItem('autoStartStream'); if (autoStart === 'true') { document.getElementById('auto-start-stream').checked = true; autoStartStream = true; } }); // Auto-crossfade: smoothly transitions from the ending deck to the other deck. let _autoMixTimer = null; function startAutoMixFade(endingDeckId) { const otherDeck = endingDeckId === 'A' ? 'B' : 'A'; // The other deck must have a track loaded and be playing (or about to play) if (!decks[otherDeck].localBuffer) { console.log(`[AutoMix] Other deck ${otherDeck} has no track loaded, skipping crossfade`); return false; } // If the other deck isn't playing, start it if (!decks[otherDeck].playing) { playDeck(otherDeck); } // Cancel any existing auto-mix animation if (_autoMixTimer) { clearInterval(_autoMixTimer); _autoMixTimer = null; } const slider = document.getElementById('crossfader'); if (!slider) return false; const target = endingDeckId === 'A' ? 100 : 0; // Fade toward the OTHER deck const duration = 5000; // 5 seconds const steps = 50; const interval = duration / steps; const start = parseInt(slider.value); const delta = (target - start) / steps; let step = 0; console.log(`[AutoMix] Crossfading from Deck ${endingDeckId} → Deck ${otherDeck} over ${duration / 1000}s`); showToast(`Auto-crossfading to Deck ${otherDeck}`, 'info'); _autoMixTimer = setInterval(() => { step++; const val = Math.round(start + delta * step); slider.value = val; updateCrossfader(val); if (step >= steps) { clearInterval(_autoMixTimer); _autoMixTimer = null; console.log(`[AutoMix] Crossfade complete. Now on Deck ${otherDeck}`); // Load next track on the ending deck so it's ready for the next crossfade if (queues[endingDeckId] && queues[endingDeckId].length > 0) { const next = queues[endingDeckId].shift(); renderQueue(endingDeckId); loadFromServer(endingDeckId, next.file, next.title); } } }, interval); return true; } // Shared handler called both by onended and the monitor poll. // Handles repeat, auto-crossfade, auto-play-from-queue, or stop. function handleTrackEnd(id) { // Already being handled or loop is active — skip if (decks[id].loading || decks[id].loopActive) return; if (isBroadcasting) { console.log(`Track ending during broadcast on Deck ${id}`); if (settings[`repeat${id}`]) { console.log(`Repeating track on Deck ${id} (broadcast)`); seekTo(id, 0); return; } if (settings.autoPlay && queues[id] && queues[id].length > 0) { decks[id].loading = true; const next = queues[id].shift(); renderQueue(id); loadFromServer(id, next.file, next.title) .then(() => { decks[id].loading = false; playDeck(id); }) .catch(() => { decks[id].loading = false; }); } return; } if (settings[`repeat${id}`]) { console.log(`Repeating track on Deck ${id}`); seekTo(id, 0); } else if (settings.autoMix) { // Auto-crossfade takes priority over simple auto-play decks[id].loading = true; if (!startAutoMixFade(id)) { // Crossfade not possible (other deck empty) — fall through to normal auto-play decks[id].loading = false; handleAutoPlay(id); } else { decks[id].loading = false; } } else if (settings.autoPlay) { handleAutoPlay(id); } else { pauseDeck(id); decks[id].pausedAt = 0; } } function handleAutoPlay(id) { decks[id].loading = true; pauseDeck(id); if (queues[id] && queues[id].length > 0) { console.log(`Auto-play: loading next from Queue ${id}`); const next = queues[id].shift(); renderQueue(id); loadFromServer(id, next.file, next.title) .then(() => { decks[id].loading = false; playDeck(id); }) .catch(() => { decks[id].loading = false; }); } else { console.log(`Auto-play: queue empty on Deck ${id}, stopping`); decks[id].loading = false; decks[id].pausedAt = 0; } } // Monitoring — safety net for cases where onended doesn't fire // (e.g. AudioContext suspended, very short buffer, scrub to near-end). function monitorTrackEnd() { setInterval(() => { if (!audioCtx) return; ['A', 'B'].forEach(id => { if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return; if (decks[id].loopActive) return; const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime; const current = decks[id].lastAnchorPosition + (elapsed * rate); const remaining = decks[id].duration - current; // Threshold scales with playback rate so we don't miss fast playback. // Use 1.5× the poll interval (0.75s) as a safe window. const threshold = Math.max(0.75, 0.75 * rate); if (remaining <= threshold) { console.log(`[Monitor] Deck ${id} near end (${remaining.toFixed(2)}s left) — triggering handleTrackEnd`); handleTrackEnd(id); } }); }, 500); } monitorTrackEnd(); // Reset Deck to Default Settings function resetDeck(id) { vibrate(20); console.log(`Resetting Deck ${id} to defaults...`); if (!audioCtx) { console.warn('AudioContext not initialised'); 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); // Clear neon glow document.body.classList.remove('playing-' + id); console.log(`[OK] 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)`); } // 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}`); } // Clear entire queue function clearQueue(deckId) { const count = queues[deckId].length; queues[deckId] = []; renderQueue(deckId); console.log(`Cleared Queue ${deckId} (${count} tracks removed)`); } // 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); return true; } // Render queue UI function renderQueue(deckId) { const mainQueue = document.getElementById(`queue-list-${deckId}`); const deckQueue = document.getElementById(`deck-queue-list-${deckId}`); const targets = [mainQueue, deckQueue].filter(t => t !== null); if (targets.length === 0) return; const generateHTML = (isEmpty) => { if (isEmpty) { return '
Queue is empty
'; } return ''; }; targets.forEach(container => { if (queues[deckId].length === 0) { container.innerHTML = generateHTML(true); } else { container.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 = 'PLAY'; loadBtn.title = 'Load now'; loadBtn.onclick = (e) => { e.stopPropagation(); loadFromServer(deckId, track.file, track.title); removeFromQueue(deckId, index); }; const removeBtn = document.createElement('button'); removeBtn.className = 'queue-remove-btn'; removeBtn.textContent = 'X'; removeBtn.title = 'Remove from queue'; removeBtn.onclick = (e) => { e.stopPropagation(); removeFromQueue(deckId, index); }; actions.appendChild(loadBtn); actions.appendChild(removeBtn); item.appendChild(number); item.appendChild(title); item.appendChild(actions); // Click to load also item.onclick = () => { loadFromServer(deckId, track.file, track.title); removeFromQueue(deckId, index); }; // Drag and drop reordering / moving item.ondragstart = (e) => { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('queueIndex', index); e.dataTransfer.setData('queueDeck', deckId); // Also set track data so it can be dropped as a generic track e.dataTransfer.setData('trackFile', track.file); e.dataTransfer.setData('trackTitle', track.title); 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 = e.dataTransfer.getData('queueIndex'); const fromDeck = e.dataTransfer.getData('queueDeck'); const trackFile = e.dataTransfer.getData('trackFile'); const trackTitle = e.dataTransfer.getData('trackTitle'); if (fromDeck !== "" && fromIndex !== "") { // Move from a queue (same or different) const srcIdx = parseInt(fromIndex); const [movedItem] = queues[fromDeck].splice(srcIdx, 1); // If same deck and after removal index changes, adjust target index if needed? // Actually splice(index, 0, item) works fine if we handle same deck carefully. let targetIdx = index; queues[deckId].splice(targetIdx, 0, movedItem); renderQueue(deckId); if (fromDeck !== deckId) renderQueue(fromDeck); console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`); } else if (trackFile && trackTitle) { // Drop from library into middle of queue queues[deckId].splice(index, 0, { file: trackFile, title: trackTitle }); renderQueue(deckId); console.log(`Inserted library track into Queue ${deckId} at index ${index}: ${trackTitle}`); } }; container.appendChild(item); }); } }); } // ========================================== // KEYBOARD SHORTCUTS SYSTEM // ========================================== // Default keyboard mappings (can be customized) const DEFAULT_KEYBOARD_MAPPINGS = { // 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' } }; let keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS }; // Load custom mappings from server async function loadKeyboardMappings() { try { const response = await fetch('/load_keymaps'); const data = await response.json(); if (data.success && data.keymaps) { keyboardMappings = data.keymaps; console.log('[OK] Loaded custom keyboard mappings from server'); } else { console.log('Using default keyboard mappings'); } } catch (e) { console.error('Failed to load keyboard mappings from server:', e); } } // Save custom mappings to server async function saveKeyboardMappings() { try { await fetch('/save_keymaps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(keyboardMappings) }); console.log('[SAVED] Saved keyboard mappings to server'); } catch (e) { console.error('Failed to save keyboard mappings to server:', e); } } // 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} RIGHT ${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)} RIGHT ${mapping.label} `; const changeBtn = document.createElement('button'); changeBtn.className = 'key-reassign-btn'; changeBtn.textContent = 'Change'; changeBtn.addEventListener('click', (e) => reassignKey(key, e)); item.appendChild(changeBtn); list.appendChild(item); }); } // Format key name for display function formatKeyName(key) { const names = { 'ArrowLeft': 'LEFT Left', 'ArrowRight': 'RIGHT Right', 'ArrowUp': 'UP Up', 'ArrowDown': 'DOWN Down', 'Escape': 'ESC', ' ': 'Space' }; return names[key] || key.toUpperCase(); } // Reassign a key function reassignKey(oldKey, evt) { const item = evt.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(`[OK] Remapped: ${formatKeyName(oldKey)} RIGHT ${formatKeyName(newKey)}`); }; document.addEventListener('keydown', listener); } // Reset to default mappings async function resetKeyboardMappings() { if (confirm('Reset all keyboard shortcuts to defaults?')) { keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS }; await saveKeyboardMappings(); renderKeyboardMappings(); } } // 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('[OK] Keyboard mappings imported successfully!'); } catch (err) { alert('[ERROR] Failed to import: Invalid file format'); } }; reader.readAsText(file); }; input.click(); } // Initialise on load loadKeyboardMappings(); console.log('Keyboard shortcuts enabled. Press H for help.');