From ec1735b9e318956e6b63ec7e47aee5661fd87d75 Mon Sep 17 00:00:00 2001 From: Colby Lipsett Date: Fri, 2 Jan 2026 18:31:55 +0000 Subject: [PATCH] Upload files to "/" --- index.html | 470 ++++++ requirements.txt | 7 + script.js | 2895 +++++++++++++++++++++++++++++++++++ server.py | 244 +++ style.css | 3763 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 7379 insertions(+) create mode 100644 index.html create mode 100644 requirements.txt create mode 100644 script.js create mode 100644 server.py create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..0b379c9 --- /dev/null +++ b/index.html @@ -0,0 +1,470 @@ + + + + + + + TechDJ Pro + + + + +
+

TECHDJ PROTOCOL

+ +

v2.0 // NEON CORE

+
+ + +
+
📱→🔄
+

OPTIMAL ORIENTATION

+

For the best DJ experience, please rotate your device to landscape mode (sideways).

+

Both decks and the crossfader will be visible simultaneously.

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

⌨️ Keyboard Shortcuts

+ +
+
+

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

+
+
+ + + +
+
+ `; + document.body.appendChild(panel); + renderKeyboardMappings(); +} + +function closeKeyboardSettings() { + const panel = document.getElementById('keyboard-settings-panel'); + if (panel) panel.classList.remove('active'); +} + +// Render keyboard mappings list +function renderKeyboardMappings() { + const list = document.getElementById('keyboard-mappings-list'); + if (!list) return; + + list.innerHTML = ''; + + Object.entries(keyboardMappings).forEach(([key, mapping]) => { + const item = document.createElement('div'); + item.className = 'keyboard-mapping-item'; + item.innerHTML = ` + ${formatKeyName(key)} + + ${mapping.label} + + `; + list.appendChild(item); + }); +} + +// Format key name for display +function formatKeyName(key) { + const names = { + 'ArrowLeft': '← Left', + 'ArrowRight': '→ Right', + 'ArrowUp': '↑ Up', + 'ArrowDown': '↓ Down', + 'Escape': 'ESC', + ' ': 'Space' + }; + return names[key] || key.toUpperCase(); +} + +// Reassign a key +function reassignKey(oldKey) { + const item = event.target.closest('.keyboard-mapping-item'); + item.classList.add('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Press new key...'; + + const listener = (e) => { + e.preventDefault(); + + if (e.key === 'Escape') { + item.classList.remove('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Change'; + document.removeEventListener('keydown', listener); + return; + } + + const newKey = e.key; + + // Check if key already assigned + if (keyboardMappings[newKey] && newKey !== oldKey) { + if (!confirm(`Key "${formatKeyName(newKey)}" is already assigned to "${keyboardMappings[newKey].label}". Overwrite?`)) { + item.classList.remove('listening'); + item.querySelector('.key-reassign-btn').textContent = 'Change'; + document.removeEventListener('keydown', listener); + return; + } + delete keyboardMappings[newKey]; + } + + // Move mapping to new key + keyboardMappings[newKey] = keyboardMappings[oldKey]; + delete keyboardMappings[oldKey]; + + saveKeyboardMappings(); + renderKeyboardMappings(); + document.removeEventListener('keydown', listener); + + console.log(`✅ Remapped: ${formatKeyName(oldKey)} → ${formatKeyName(newKey)}`); + }; + + document.addEventListener('keydown', listener); +} + +// Reset to default mappings +function resetKeyboardMappings() { + if (confirm('Reset all keyboard shortcuts to defaults?')) { + localStorage.removeItem('keyboardMappings'); + location.reload(); + } +} + +// Export mappings +function exportKeyboardMappings() { + const json = JSON.stringify(keyboardMappings, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'techdj-keyboard-mappings.json'; + a.click(); + URL.revokeObjectURL(url); +} + +// Import mappings +function importKeyboardMappings() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json'; + input.onchange = (e) => { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (event) => { + try { + const imported = JSON.parse(event.target.result); + keyboardMappings = imported; + saveKeyboardMappings(); + renderKeyboardMappings(); + alert('✅ Keyboard mappings imported successfully!'); + } catch (err) { + alert('❌ Failed to import: Invalid file format'); + } + }; + reader.readAsText(file); + }; + input.click(); +} + +// Initialize on load +loadKeyboardMappings(); +console.log('⌨️ Keyboard shortcuts enabled. Press H for help.'); diff --git a/server.py b/server.py new file mode 100644 index 0000000..4de2674 --- /dev/null +++ b/server.py @@ -0,0 +1,244 @@ +# Monkey patch MUST be first - before any other imports! +import eventlet +eventlet.monkey_patch() + +import os +from flask import Flask, send_from_directory, jsonify, request, session +from flask_socketio import SocketIO, emit +from dotenv import load_dotenv +# Load environment variables from .env file +load_dotenv() +import downloader + +# Relay State +broadcast_state = { + 'active': False +} +listener_count = 0 +MUSIC_FOLDER = "music" +# Ensure music folder exists +if not os.path.exists(MUSIC_FOLDER): + os.makedirs(MUSIC_FOLDER) + +# Helper for shared routes +def setup_shared_routes(app): + @app.route('/library.json') + def get_library(): + library = [] + if os.path.exists(MUSIC_FOLDER): + for filename in sorted(os.listdir(MUSIC_FOLDER)): + if filename.lower().endswith(('.mp3', '.m4a', '.wav', '.flac', '.ogg')): + library.append({ + "title": os.path.splitext(filename)[0], + "file": f"music/{filename}" + }) + return jsonify(library) + + @app.route('/download', methods=['POST']) + def download(): + data = request.json + url = data.get('url') + quality = data.get('quality', '320') + if not url: + return jsonify({"success": False, "error": "No URL provided"}), 400 + result = downloader.download_mp3(url, quality) + return jsonify(result) + + @app.route('/search_youtube', methods=['GET']) + def search_youtube(): + query = request.args.get('q', '') + if not query: + return jsonify({"success": False, "error": "No query provided"}), 400 + + # Get API key from environment variable + api_key = os.environ.get('YOUTUBE_API_KEY', '') + if not api_key: + return jsonify({ + "success": False, + "error": "YouTube API key not configured. Set YOUTUBE_API_KEY environment variable." + }), 500 + + try: + import requests + # Search YouTube using Data API v3 + url = 'https://www.googleapis.com/youtube/v3/search' + params = { + 'part': 'snippet', + 'q': query, + 'type': 'video', + 'videoCategoryId': '10', # Music category + 'maxResults': 20, + 'key': api_key + } + + response = requests.get(url, params=params) + data = response.json() + + if 'error' in data: + return jsonify({ + "success": False, + "error": data['error'].get('message', 'YouTube API error') + }), 400 + + # Format results + results = [] + for item in data.get('items', []): + results.append({ + 'videoId': item['id']['videoId'], + 'title': item['snippet']['title'], + 'channel': item['snippet']['channelTitle'], + 'thumbnail': item['snippet']['thumbnails']['medium']['url'], + 'url': f"https://www.youtube.com/watch?v={item['id']['videoId']}" + }) + + return jsonify({"success": True, "results": results}) + + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + @app.route('/') + def serve_static(filename): + response = send_from_directory('.', filename) + if filename.endswith(('.css', '.js', '.html')): + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + return response + + @app.route('/') + def index(): + return send_from_directory('.', 'index.html') + +# === DJ SERVER (Port 5000) === +dj_app = Flask(__name__, static_folder='.', static_url_path='') +dj_app.config['SECRET_KEY'] = 'dj_panel_secret' +setup_shared_routes(dj_app) +dj_socketio = SocketIO( + dj_app, + cors_allowed_origins="*", + async_mode='eventlet', + max_http_buffer_size=1e8, # 100MB buffer + ping_timeout=10, + ping_interval=5, + logger=False, + engineio_logger=False +) + +@dj_socketio.on('connect') +def dj_connect(): + print(f"🎧 DJ connected: {request.sid}") + session['is_dj'] = True + +@dj_socketio.on('disconnect') +def dj_disconnect(): + if session.get('is_dj'): + print("⚠️ DJ disconnected - broadcast will continue until manually stopped") + session['is_dj'] = False + # Don't stop streaming_active - let it continue + # Broadcast will resume when DJ reconnects + +def stop_broadcast_after_timeout(): + """No longer used - broadcasts don't auto-stop""" + pass + +@dj_socketio.on('start_broadcast') +def dj_start(): + broadcast_state['active'] = True + session['is_dj'] = True + print("🎙️ Broadcast -> ACTIVE") + + listener_socketio.emit('broadcast_started', namespace='/') + listener_socketio.emit('stream_status', {'active': True}, namespace='/') + +@dj_socketio.on('stop_broadcast') +def dj_stop(): + broadcast_state['active'] = False + session['is_dj'] = False + print("🛑 DJ stopped broadcasting") + + listener_socketio.emit('broadcast_stopped', namespace='/') + listener_socketio.emit('stream_status', {'active': False}, namespace='/') + +@dj_socketio.on('audio_chunk') +def dj_audio(data): + # Relay audio chunk to all listeners immediately + if broadcast_state['active']: + listener_socketio.emit('audio_data', data, namespace='/') + +# === LISTENER SERVER (Port 6000) === +listener_app = Flask(__name__, static_folder='.', static_url_path='') +listener_app.config['SECRET_KEY'] = 'listener_secret' +setup_shared_routes(listener_app) +listener_socketio = SocketIO( + listener_app, + cors_allowed_origins="*", + async_mode='eventlet', + max_http_buffer_size=1e8, # 100MB buffer + ping_timeout=10, + ping_interval=5, + logger=False, + engineio_logger=False +) + +@listener_socketio.on('connect') +def listener_connect(): + print(f"👂 Listener Socket Connected: {request.sid}") + +@listener_socketio.on('disconnect') +def listener_disconnect(): + global listener_count + if session.get('is_listener'): + # Clear session flag FIRST to prevent re-entry issues + session['is_listener'] = False + listener_count = max(0, listener_count - 1) + print(f"❌ Listener left. Total: {listener_count}") + # Broadcast to all listeners + listener_socketio.emit('listener_count', {'count': listener_count}, namespace='/') + # Broadcast to all DJs + dj_socketio.emit('listener_count', {'count': listener_count}, namespace='/') + +@listener_socketio.on('join_listener') +def listener_join(): + global listener_count + if not session.get('is_listener'): + session['is_listener'] = True + listener_count += 1 + print(f"👂 New listener joined. Total: {listener_count}") + # Broadcast to all listeners + listener_socketio.emit('listener_count', {'count': listener_count}, namespace='/') + # Broadcast to all DJs + dj_socketio.emit('listener_count', {'count': listener_count}, namespace='/') + + emit('stream_status', {'active': broadcast_state['active']}) + +@listener_socketio.on('request_header') +def handle_request_header(): + # Header logic removed for local relay mode + pass + +@listener_socketio.on('get_listener_count') +def listener_get_count(): + emit('listener_count', {'count': listener_count}) + +# DJ Panel Routes (No engine commands needed in local mode) +@dj_socketio.on('get_mixer_status') +def get_mixer_status(): + pass + +@dj_socketio.on('audio_sync_queue') +def audio_sync_queue(data): + pass + + +if __name__ == '__main__': + print("=" * 50) + print("🎧 TECHDJ PRO - DUAL PORT ARCHITECTURE") + print("=" * 50) + print("👉 DJ PANEL: http://localhost:5000") + print("👉 LISTEN PAGE: http://localhost:5001") + print("=" * 50) + + # Audio engine DISABLED + print("✅ Local Radio server ready") + + # Run both servers using eventlet's spawn + eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=5000, debug=False) + listener_socketio.run(listener_app, host='0.0.0.0', port=5001, debug=False) diff --git a/style.css b/style.css new file mode 100644 index 0000000..0724535 --- /dev/null +++ b/style.css @@ -0,0 +1,3763 @@ +@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Rajdhani:wght@300;500;700&display=swap'); + +:root { + --bg-dark: #0a0a12; + --panel-bg: rgba(20, 20, 30, 0.8); + --primary-cyan: #00f3ff; + --secondary-magenta: #bc13fe; + --text-main: #e0e0e0; + --text-dim: #888; + --glass-border: 1px solid rgba(255, 255, 255, 0.1); + --glow-opacity: 0.3; + --glow-spread: 30px; + --glow-border: 5px; +} + +body { + margin: 0; + background-color: var(--bg-dark); + color: var(--text-main); + font-family: 'Rajdhani', sans-serif; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + background-image: + radial-gradient(circle at 10% 20%, rgba(0, 243, 255, 0.15) 0%, transparent 25%), + radial-gradient(circle at 90% 80%, rgba(188, 19, 254, 0.15) 0%, transparent 25%), + radial-gradient(circle at 50% 50%, rgba(0, 243, 255, 0.05) 0%, transparent 50%); +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 99999; + border: 1px solid rgba(80, 80, 80, 0.1); + box-sizing: border-box; +} + +body.playing-A::before { + border: none; + box-shadow: + 0 0 var(--glow-spread) rgba(0, 243, 255, var(--glow-opacity)), + inset 0 0 var(--glow-spread) rgba(0, 243, 255, calc(var(--glow-opacity) * 0.8)); + animation: pulse-cyan 3s ease-in-out infinite; +} + +body.playing-B::before { + border: none; + box-shadow: + 0 0 var(--glow-spread) rgba(188, 19, 254, var(--glow-opacity)), + inset 0 0 var(--glow-spread) rgba(188, 19, 254, calc(var(--glow-opacity) * 0.8)); + animation: pulse-magenta 3s ease-in-out infinite; +} + +body.playing-A.playing-B::before { + border: none; + box-shadow: + 0 0 var(--glow-spread) rgba(0, 243, 255, var(--glow-opacity)), + 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, var(--glow-opacity)), + inset 0 0 var(--glow-spread) rgba(0, 243, 255, calc(var(--glow-opacity) * 0.6)), + inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 0.6)); + animation: pulse-both 3s ease-in-out infinite; +} + +body.listener-glow::before { + border: none; + box-shadow: + 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)), + 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)), + 0 0 calc(var(--glow-spread) * 2.5) rgba(0, 243, 255, calc(var(--glow-opacity) * 0.5)), + inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)), + inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1)); + animation: pulse-listener 4s ease-in-out infinite; +} + +@keyframes pulse-listener { + + 0%, + 100% { + filter: hue-rotate(0deg) brightness(1.2); + box-shadow: + 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)), + 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)), + inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)), + inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1)); + } + + 50% { + filter: hue-rotate(15deg) brightness(1.8); + box-shadow: + 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)), + 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 2.2)), + 0 0 calc(var(--glow-spread) * 4) rgba(0, 243, 255, calc(var(--glow-opacity) * 1)), + inset 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 1.5)), + inset 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 1.5)); + } +} + + + + +#start-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.95); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + z-index: 1000; + backdrop-filter: blur(10px); +} + +.overlay-title { + font-family: 'Orbitron', sans-serif; + font-size: 3rem; + color: var(--primary-cyan); + text-shadow: 0 0 20px var(--primary-cyan); + margin-bottom: 2rem; + letter-spacing: 5px; +} + +#start-btn { + padding: 15px 40px; + font-family: 'Orbitron', sans-serif; + font-size: 1.2rem; + background: transparent; + color: var(--primary-cyan); + border: 2px solid var(--primary-cyan); + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 0 15px rgba(0, 243, 255, 0.2); +} + +#start-btn:hover { + background: var(--primary-cyan); + color: #000; + box-shadow: 0 0 30px var(--primary-cyan); +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 20px; + height: 60px; + box-sizing: border-box; + background: rgba(0, 0, 0, 0.5); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +header h1 { + font-family: 'Orbitron', sans-serif; + color: #fff; + margin: 0; + font-size: 1.5rem; + letter-spacing: 2px; +} + +.status-bar { + font-family: 'Orbitron', sans-serif; + font-size: 0.8rem; + color: #00ff00; + text-shadow: 0 0 5px #00ff00; +} + +/* LAYOUT GRID */ +.app-container { + display: grid; + grid-template-columns: 320px 1fr 1fr; + grid-template-rows: 1fr 80px; + gap: 10px; + padding: 10px; + height: calc(100vh - 60px); + /* Adjust based on header height */ + min-height: 0; + overflow: hidden; +} + +/* LIBRARY SECTION */ +.library-section { + grid-row: 1 / -1; + background: var(--panel-bg); + border: 2px solid var(--primary-cyan); + box-shadow: 0 0 20px rgba(0, 243, 255, 0.1); + border-radius: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.lib-header { + padding: 15px; + background: rgba(255, 255, 255, 0.02); + border-bottom: var(--glass-border); + display: flex; + gap: 10px; + align-items: center; +} + +.lib-header input { + flex: 1; + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid #333; + color: #fff; + border-radius: 4px; + font-family: 'Rajdhani', sans-serif; +} + +.refresh-btn { + background: rgba(0, 243, 255, 0.1); + border: 1px solid var(--primary-cyan); + color: var(--primary-cyan); + border-radius: 4px; + padding: 8px 12px; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.3s; +} + +.refresh-btn:hover { + background: rgba(0, 243, 255, 0.2); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); +} + +.refresh-btn.spinning { + animation: spin 0.5s linear; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* YouTube Search */ +.youtube-search-container { + padding: 10px 15px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.youtube-search-container input { + width: 100%; + padding: 10px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--primary-cyan); + color: #fff; + border-radius: 4px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + box-shadow: 0 0 10px rgba(0, 243, 255, 0.2); + transition: all 0.3s; +} + +.youtube-search-container input:focus { + outline: none; + border-color: var(--primary-cyan); + box-shadow: 0 0 15px rgba(0, 243, 255, 0.4); +} + +.youtube-results { + max-height: 300px; + overflow-y: auto; + margin-top: 10px; +} + +.youtube-result-card { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 4px; + margin-bottom: 6px; + transition: all 0.2s; + cursor: pointer; +} + +.youtube-result-card:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--primary-cyan); + transform: translateX(2px); +} + +.result-thumbnail { + width: 80px; + height: 60px; + object-fit: cover; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.result-info { + flex: 1; + min-width: 0; +} + +.result-title { + font-size: 0.85rem; + font-weight: bold; + color: #fff; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-channel { + font-size: 0.75rem; + color: #888; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.result-download-btn { + padding: 8px 12px; + background: rgba(0, 243, 255, 0.2); + border: 1px solid var(--primary-cyan); + color: var(--primary-cyan); + border-radius: 4px; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.2s; + flex-shrink: 0; +} + +.result-download-btn:hover { + background: rgba(0, 243, 255, 0.3); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.4); + transform: scale(1.1); +} + +.result-download-btn:active { + transform: scale(0.95); +} + +.search-loading, +.search-error, +.search-success, +.search-empty { + padding: 15px; + text-align: center; + border-radius: 4px; + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; +} + +.search-loading { + background: rgba(255, 187, 0, 0.1); + color: #ffbb00; + border: 1px solid rgba(255, 187, 0, 0.3); +} + +.search-error { + background: rgba(255, 68, 68, 0.1); + color: #ff4444; + border: 1px solid rgba(255, 68, 68, 0.3); +} + +.search-success { + background: rgba(0, 255, 0, 0.1); + color: #00ff00; + border: 1px solid rgba(0, 255, 0, 0.3); +} + +.search-empty { + background: rgba(255, 255, 255, 0.03); + color: #666; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.library-list { + flex: 1; + overflow-y: auto; + padding: 10px; +} + +.track-row { + background: rgba(255, 255, 255, 0.03); + margin-bottom: 8px; + padding: 10px; + border-radius: 4px; + transition: 0.2s; + border-left: 3px solid transparent; +} + +.track-row:hover { + background: rgba(255, 255, 255, 0.08); + border-left: 3px solid var(--primary-cyan); +} + +.track-name { + display: block; + font-weight: bold; + margin-bottom: 5px; + color: #fff; +} + +.load-actions { + display: flex; + gap: 5px; +} + +.load-btn { + flex: 1; + border: none; + padding: 4px; + font-size: 0.7rem; + cursor: pointer; + font-family: 'Orbitron', sans-serif; + opacity: 0.7; + transition: 0.2s; +} + +.btn-a { + background: rgba(0, 243, 255, 0.2); + color: var(--primary-cyan); + border: 1px solid var(--primary-cyan); +} + +.btn-b { + background: rgba(188, 19, 254, 0.2); + color: var(--secondary-magenta); + border: 1px solid var(--secondary-magenta); +} + +.load-btn:hover { + opacity: 1; + box-shadow: 0 0 10px currentColor; +} + +/* DECKS */ +.deck { + background: var(--panel-bg); + border: var(--glass-border); + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + position: relative; + transition: box-shadow 0.3s; + min-height: 0; + overflow-y: auto; +} + +#deck-A { + border: 2px solid var(--primary-cyan); + box-shadow: 0 0 25px rgba(0, 243, 255, 0.3); +} + +#deck-B { + border: 2px solid var(--secondary-magenta); + box-shadow: 0 0 25px rgba(188, 19, 254, 0.3); +} + +.deck.playing { + box-shadow: 0 0 45px rgba(0, 243, 255, 0.5); +} + +#deck-B.playing { + box-shadow: 0 0 45px rgba(188, 19, 254, 0.5); +} + +.deck-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + background: #000; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid #333; +} + +.deck-title { + font-family: 'Orbitron', sans-serif; + font-size: 1rem; + font-weight: bold; +} + +.title-a { + color: var(--primary-cyan); +} + +.title-b { + color: var(--secondary-magenta); +} + +.track-display { + font-family: 'Rajdhani', monospace; + color: #fff; + font-size: 0.9rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 200px; +} + +.blink { + animation: blinker 1s linear infinite; +} + +@keyframes blinker { + 50% { + opacity: 0; + } +} + +/* DISK */ +.disk-container { + display: flex; + justify-content: center; + margin-bottom: 6px; +} + +.dj-disk { + width: 90px; + height: 90px; + background: radial-gradient(circle, #222 10%, #111 11%, #000 100%); + border-radius: 50%; + border: 2px solid #333; + display: flex; + justify-content: center; + align-items: center; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + cursor: pointer; + position: relative; + transition: transform 0.2s; +} + +.dj-disk::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: 50%; + background: repeating-radial-gradient(#111 0, + #111 2px, + #181818 3px, + #181818 4px); + opacity: 0.3; + pointer-events: none; +} + +.dj-disk:active { + transform: scale(0.95); +} + +.disk-label { + width: 38px; + height: 38px; + background: #fff; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + font-size: 0.9rem; + color: #000; + font-family: 'Orbitron', sans-serif; + z-index: 2; + border: 2px solid #ccc; +} + +#deck-A .disk-label { + background: var(--primary-cyan); + box-shadow: 0 0 10px var(--primary-cyan); +} + +#deck-B .disk-label { + background: var(--secondary-magenta); + box-shadow: 0 0 10px var(--secondary-magenta); +} + +@keyframes rotate { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.deck.playing .dj-disk { + animation: rotate 2s linear infinite; + box-shadow: 0 0 20px rgba(0, 243, 255, 0.3); +} + +#deck-B.playing .dj-disk { + box-shadow: 0 0 20px rgba(188, 19, 254, 0.3); +} + +canvas { + width: 100%; + height: 60px; + background: #000; + border-radius: 4px; + margin-bottom: 6px; + border: 1px solid #333; +} + +/* SEARCH & DOWNLOAD */ +.start-group { + position: relative; + margin-bottom: 4px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.search-input { + width: 100%; + padding: 6px 8px; + background: #111; + border: 1px solid #444; + color: #fff; + font-family: 'Rajdhani', sans-serif; + font-size: 0.85rem; + border-radius: 4px; + box-sizing: border-box; +} + +.download-btn { + width: 100%; + padding: 6px; + background: var(--primary-cyan); + border: 1px solid var(--primary-cyan); + font-weight: bold; + cursor: pointer; + font-size: 0.8rem; + font-family: 'Orbitron', sans-serif; + color: #000; + border-radius: 4px; + transition: all 0.3s; + box-shadow: 0 0 10px rgba(0, 243, 255, 0.2); +} + +.download-btn:hover { + box-shadow: 0 0 20px rgba(0, 243, 255, 0.5); + transform: translateY(-1px); +} + +.download-btn:active { + transform: translateY(0); +} + +#deck-B .download-btn { + background: var(--secondary-magenta); + border-color: var(--secondary-magenta); + color: #fff; + box-shadow: 0 0 10px rgba(188, 19, 254, 0.2); +} + +#deck-B .download-btn:hover { + box-shadow: 0 0 20px rgba(188, 19, 254, 0.5); +} + + +.quality-selector { + background: rgba(10, 10, 12, 0.8); + color: var(--text-color); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 4px 6px; + border-radius: 4px; + font-family: 'Rajdhani', sans-serif; + font-size: 0.8rem; + outline: none; + cursor: pointer; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.download-status .loading { + color: #ffa500; + animation: pulse 1.5s ease-in-out infinite; +} + +.download-status .success { + color: #00ff00; + font-weight: bold; +} + +.download-status .error { + color: #ff4444; + font-weight: bold; +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +/* CONTROLS */ +.controls-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + margin-top: auto; +} + +.fader-group { + display: flex; + flex-direction: column; + align-items: center; +} + +.fader-group label { + font-size: 0.65rem; + color: #888; + margin-bottom: 3px; +} + +input[type=range] { + width: 100%; + cursor: pointer; +} + +/* EQ */ +.eq-container { + display: flex; + justify-content: space-between; + margin: 4px 0; +} + +.eq-band { + display: flex; + flex-direction: column; + align-items: center; + width: 30%; +} + +.eq-band input { + writing-mode: bt-lr; + /* IE */ + -webkit-appearance: slider-vertical; + appearance: slider-vertical; + width: 8px; + height: 60px; +} + +/* TRANSPORT */ +.transport { + display: flex; + gap: 4px; + margin-top: 3px; +} + +.big-btn { + flex: 1; + padding: 6px; + border: none; + font-family: 'Orbitron', sans-serif; + font-weight: bold; + cursor: pointer; + font-size: 0.85rem; + transition: 0.1s; + background: #222; + color: #666; + border-bottom: 2px solid #000; +} + +.big-btn:active { + transform: translateY(2px); + border-bottom: 1px solid #000; +} + +.play-btn:hover { + background: #333; + color: #fff; +} + +.deck.playing .play-btn { + background: var(--primary-cyan); + color: #000; + box-shadow: 0 0 15px var(--primary-cyan); +} + +#deck-B.playing .play-btn { + background: var(--secondary-magenta); + color: #fff; + box-shadow: 0 0 15px var(--secondary-magenta); +} + +/* CROSSFADER */ +.mixer-section { + grid-column: 2 / 4; + grid-row: 2; + background: linear-gradient(to bottom, #1a1a1a, #0a0a0a); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + padding: 15px 40px; + border: 2px solid #444; + position: relative; + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.8), inset 0 2px 10px rgba(255, 255, 255, 0.05); +} + +.mixer-section::before { + content: 'A'; + position: absolute; + left: 20px; + font-family: 'Orbitron', sans-serif; + font-size: 1.5rem; + font-weight: bold; + color: var(--primary-cyan); + text-shadow: 0 0 10px var(--primary-cyan); +} + +.mixer-section::after { + content: 'B'; + position: absolute; + right: 20px; + font-family: 'Orbitron', sans-serif; + font-size: 1.5rem; + font-weight: bold; + color: var(--secondary-magenta); + text-shadow: 0 0 10px var(--secondary-magenta); +} + +.xfader { + width: 100%; + appearance: none; + height: 12px; + background: linear-gradient(to right, + var(--primary-cyan) 0%, + #333 50%, + var(--secondary-magenta) 100%); + border-radius: 6px; + outline: none; + border: 2px solid #555; + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 255, 255, 0.1); +} + +.xfader::-webkit-slider-thumb { + -webkit-appearance: none; + width: 60px; + height: 40px; + background: linear-gradient(145deg, #aaa, #666); + border: 3px solid #ccc; + border-radius: 8px; + cursor: grab; + box-shadow: 0 0 25px rgba(255, 255, 255, 0.4), + inset 0 -3px 8px rgba(0, 0, 0, 0.6); + transition: all 0.1s; +} + +.xfader::-webkit-slider-thumb:hover { + background: linear-gradient(145deg, #ccc, #888); + box-shadow: 0 0 35px rgba(255, 255, 255, 0.6); +} + +.xfader::-webkit-slider-thumb:active { + cursor: grabbing; + box-shadow: 0 0 40px rgba(255, 255, 255, 0.8); + transform: scale(1.05); +} + +.xfader::-moz-range-thumb { + width: 60px; + height: 40px; + background: linear-gradient(145deg, #aaa, #666); + border: 3px solid #ccc; + border-radius: 8px; + cursor: grab; + box-shadow: 0 0 25px rgba(255, 255, 255, 0.4); +} + +/* ====== NEW PROFESSIONAL DJ FEATURES ====== */ + +/* Waveform Display */ +.waveform-container { + position: relative; + background: #000; + border: 1px solid #333; + border-radius: 4px; + margin-bottom: 4px; + padding: 3px; +} + +.waveform-canvas { + width: 100%; + height: 40px; + display: block; +} + +.playhead { + position: absolute; + top: 10px; + bottom: 10px; + width: 2px; + background: #ff0; + box-shadow: 0 0 10px #ff0; + pointer-events: none; + left: 0%; + transform: translateX(-1px); + transition: left 0.1s linear; +} + +.time-display { + margin-top: 3px; + display: flex; + justify-content: space-between; + font-family: 'Orbitron', monospace; + font-size: 0.75rem; + color: #0ff; +} + +.bpm-display { + color: #f0f; + font-weight: bold; +} + +/* Hot Cues */ +.hot-cues { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3px; + margin-bottom: 4px; +} + +.cue-btn { + padding: 6px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid #444; + color: #888; + font-family: 'Orbitron', sans-serif; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; +} + +.cue-btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: #666; +} + +.cue-btn.cue-set { + background: rgba(0, 243, 255, 0.2); + border-color: var(--primary-cyan); + color: var(--primary-cyan); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); +} + +#deck-B .cue-btn.cue-set { + background: rgba(188, 19, 254, 0.2); + border-color: var(--secondary-magenta); + color: var(--secondary-magenta); + box-shadow: 0 0 10px rgba(188, 19, 254, 0.3); +} + +.cue-btn.cue-triggered { + transform: scale(0.95); + box-shadow: 0 0 20px rgba(0, 243, 255, 0.8) !important; +} + +#deck-B .cue-btn.cue-triggered { + box-shadow: 0 0 20px rgba(188, 19, 254, 0.8) !important; +} + +@keyframes flash { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + transform: scale(1.05); + } +} + +/* Loop Controls */ +.loop-controls { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; + margin-bottom: 4px; +} + +.loop-btn { + padding: 5px; + background: rgba(255, 100, 0, 0.1); + border: 1px solid #883300; + color: #ff6600; + font-family: 'Orbitron', sans-serif; + font-size: 0.7rem; + cursor: pointer; + transition: all 0.2s; + border-radius: 4px; +} + +.loop-btn:hover { + background: rgba(255, 100, 0, 0.2); + border-color: #ff6600; + box-shadow: 0 0 10px rgba(255, 100, 0, 0.3); +} + +.loop-btn:active { + transform: scale(0.95); +} + +/* Auto-Loop Beat Controls */ +.auto-loop-controls { + margin-bottom: 4px; + padding: 6px; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.auto-loop-label { + display: block; + font-size: 0.6rem; + color: #666; + font-family: 'Orbitron', sans-serif; + margin-bottom: 4px; + text-align: center; + letter-spacing: 1px; +} + +.auto-loop-buttons { + display: flex; + justify-content: space-between; + gap: 3px; +} + +.auto-loop-btn { + flex: 1; + aspect-ratio: 1; + min-width: 0; + padding: 0; + background: rgba(255, 255, 255, 0.03); + border: 1.5px solid #333; + border-radius: 50%; + color: #666; + font-family: 'Orbitron', sans-serif; + font-size: 0.65rem; + font-weight: bold; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.auto-loop-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: #555; + color: #aaa; + transform: scale(1.1); +} + +/* Active state for Deck A */ +#deck-A .auto-loop-btn.active { + background: rgba(0, 243, 255, 0.2); + border-color: var(--primary-cyan); + color: var(--primary-cyan); + box-shadow: 0 0 15px rgba(0, 243, 255, 0.5), + inset 0 0 10px rgba(0, 243, 255, 0.2); + transform: scale(1.05); +} + +#deck-A .auto-loop-btn:hover { + border-color: var(--primary-cyan); + color: var(--primary-cyan); +} + +/* Active state for Deck B */ +#deck-B .auto-loop-btn.active { + background: rgba(188, 19, 254, 0.2); + border-color: var(--secondary-magenta); + color: var(--secondary-magenta); + box-shadow: 0 0 15px rgba(188, 19, 254, 0.5), + inset 0 0 10px rgba(188, 19, 254, 0.2); + transform: scale(1.05); +} + +#deck-B .auto-loop-btn:hover { + border-color: var(--secondary-magenta); + color: var(--secondary-magenta); +} + +.auto-loop-btn:active { + transform: scale(0.95); +} + +/* Pitch Bend Buttons */ +.pitch-bend-buttons { + display: flex; + gap: 4px; + margin-top: 3px; + justify-content: center; +} + +.pitch-bend { + padding: 4px 12px; + background: #222; + border: 1px solid #444; + color: #fff; + font-family: 'Orbitron', sans-serif; + font-size: 0.85rem; + font-weight: bold; + cursor: pointer; + border-radius: 4px; + transition: all 0.1s; +} + +.pitch-bend:hover { + background: #333; + border-color: #666; +} + +.pitch-bend:active { + background: var(--primary-cyan); + color: #000; + box-shadow: 0 0 15px rgba(0, 243, 255, 0.5); +} + +#deck-B .pitch-bend:active { + background: var(--secondary-magenta); + color: #fff; + box-shadow: 0 0 15px rgba(188, 19, 254, 0.5); +} + +/* Sync Button */ +.sync-btn { + background: rgba(0, 255, 0, 0.1) !important; + border: 1px solid #0a0 !important; + color: #0f0 !important; +} + +.sync-btn:hover { + background: rgba(0, 255, 0, 0.2) !important; + box-shadow: 0 0 15px rgba(0, 255, 0, 0.3) !important; +} + +.reset-btn { + background: rgba(255, 140, 0, 0.1) !important; + border: 1px solid #ff8c00 !important; + color: #ffa500 !important; +} + +.reset-btn:hover { + background: rgba(255, 140, 0, 0.2) !important; + box-shadow: 0 0 15px rgba(255, 140, 0, 0.4) !important; + transform: rotate(15deg); +} + +.reset-btn:active { + transform: rotate(360deg) scale(0.95); +} + +/* Transport Buttons - Make room for 4 buttons */ +.transport { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + gap: 8px; + margin-top: 10px; +} + +/* Spinning Animation for Refresh */ +@keyframes rotate360 { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.spinning { + animation: rotate360 0.5s linear; +} + +/* Quality Selector and Download Controls */ +.download-controls { + display: flex; + gap: 8px; +} + +.quality-selector { + background: #222; + border: 1px solid #444; + color: #0ff; + padding: 8px; + border-radius: 4px; + font-family: 'Orbitron', sans-serif; + font-size: 0.85rem; + cursor: pointer; +} + +.quality-selector option { + background: #111; + color: #0ff; +} + +#deck-B .quality-selector { + color: #f0f; +} + +/* Enhanced Crossfader Visibility */ +.mixer-section { + background: linear-gradient(to bottom, #1a1a1a, #0a0a0a) !important; + border: 2px solid #333 !important; + padding: 20px 40px !important; + box-shadow: 0 -5px 20px rgba(0, 0, 0, 0.5) !important; +} + +.xfader { + height: 8px !important; + background: linear-gradient(to right, + var(--primary-cyan) 0%, + #555 50%, + var(--secondary-magenta) 100%) !important; + border-radius: 4px !important; +} + +.xfader::-webkit-slider-thumb { + width: 60px !important; + height: 40px !important; + background: linear-gradient(145deg, #888, #555) !important; + border: 3px solid #aaa !important; + border-radius: 6px !important; + box-shadow: 0 0 20px rgba(255, 255, 255, 0.3), + inset 0 -2px 5px rgba(0, 0, 0, 0.5) !important; + cursor: grab !important; +} + +.xfader::-webkit-slider-thumb:active { + cursor: grabbing !important; + box-shadow: 0 0 30px rgba(255, 255, 255, 0.5) !important; +} + +.xfader::-moz-range-thumb { + width: 60px !important; + height: 40px !important; + background: linear-gradient(145deg, #888, #555) !important; + border: 3px solid #aaa !important; + border-radius: 6px !important; + box-shadow: 0 0 20px rgba(255, 255, 255, 0.3) !important; + cursor: grab !important; +} + +/* Waveform drag cursor */ +.waveform-canvas:active { + cursor: grabbing !important; +} + +/* COMPACT MODE - Make everything fit at 100% zoom */ +.deck { + padding: 15px !important; + gap: 10px !important; +} + +.waveform-container { + margin-bottom: 10px !important; + padding: 5px !important; +} + +.waveform-canvas { + height: 60px !important; +} + +.hot-cues { + gap: 5px !important; + margin-bottom: 10px !important; +} + +.cue-btn { + padding: 6px !important; + font-size: 0.7rem !important; +} + +.loop-controls { + gap: 5px !important; + margin-bottom: 10px !important; +} + +.loop-btn { + padding: 6px !important; + font-size: 0.7rem !important; +} + +.start-group { + margin-bottom: 10px !important; +} + +.search-input { + padding: 8px !important; + font-size: 0.85rem !important; +} + +.download-btn { + padding: 8px 15px !important; + font-size: 0.85rem !important; +} + +.controls-grid { + gap: 10px !important; + margin-bottom: 10px !important; +} + +.eq-container { + gap: 8px !important; +} + +.eq-band input { + height: 80px !important; +} + +.fader-group label { + font-size: 0.75rem !important; + margin-bottom: 5px !important; +} + +.transport { + gap: 8px !important; + margin-top: 8px !important; +} + +.big-btn { + padding: 10px !important; + font-size: 0.9rem !important; +} + +.disk-container { + margin: 10px 0 !important; +} + +.dj-disk { + width: 120px !important; + height: 120px !important; +} + +.mixer-section { + padding: 15px 40px !important; +} + +.library-section { + padding: 15px !important; +} + +.track-row { + padding: 8px !important; + margin-bottom: 6px !important; +} + +.track-name { + font-size: 0.85rem !important; +} + +.load-btn { + padding: 6px 12px !important; + font-size: 0.75rem !important; +} + +#viz-A, +#viz-B { + height: 80px !important; +} + +.pitch-bend-buttons { + margin-top: 3px !important; +} + +.pitch-bend { + padding: 4px 12px !important; + font-size: 0.8rem !important; +} + +.quality-selector { + padding: 6px !important; + font-size: 0.75rem !important; +} + +.download-controls { + gap: 5px !important; +} + +/* === VOLUME FADERS === */ +.volume-fader { + writing-mode: bt-lr; + -webkit-appearance: slider-vertical; + appearance: slider-vertical; + width: 30px !important; + height: 100px !important; + background: linear-gradient(to top, #ff0000, #ffff00, #00ff00); + border-radius: 5px; +} + +/* === FILTER KNOBS === */ +.filter-knobs { + display: flex; + flex-direction: column; + gap: 10px; +} + +.filter-knob { + display: flex; + flex-direction: column; + align-items: center; +} + +.filter-knob label { + font-size: 0.7rem; + margin-bottom: 5px; + color: #ff8800; + font-family: 'Orbitron', sans-serif; +} + +.filter-slider { + width: 100px; + height: 6px; + background: linear-gradient(to right, #ff8800, #ffcc00); + border-radius: 3px; + outline: none; +} + +.filter-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + background: #ff8800; + border: 2px solid #ffaa00; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 0 10px rgba(255, 136, 0, 0.5); +} + +.filter-slider::-moz-range-thumb { + width: 20px; + height: 20px; + background: #ff8800; + border: 2px solid #ffaa00; + border-radius: 50%; + cursor: pointer; +} + +/* === SAMPLE PADS === */ +.sample-pads { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + margin: 10px 0; +} + +.sample-pad { + padding: 10px 5px; + background: linear-gradient(145deg, #333, #1a1a1a); + border: 2px solid #444; + color: #fff; + font-family: 'Orbitron', sans-serif; + font-size: 0.75rem; + cursor: pointer; + border-radius: 6px; + transition: all 0.1s; + box-shadow: inset 0 -2px 5px rgba(0, 0, 0, 0.3); +} + +.sample-pad:hover { + background: linear-gradient(145deg, #444, #2a2a2a); + border-color: #666; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.2); +} + +.sample-pad:active { + transform: scale(0.9); + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 255, 255, 0.4); +} + +#deck-A .sample-pad:active { + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 20px var(--primary-cyan); +} + +#deck-B .sample-pad:active { + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.5), 0 0 20px var(--secondary-magenta); +} + +/* Adjust controls grid for new elements */ +.controls-grid { + display: grid; + grid-template-columns: auto repeat(3, 1fr) auto; + gap: 15px; + align-items: center; +} + +@keyframes glow-pulse { + + 0%, + 100% { + box-shadow: + inset 0 0 30px rgba(0, 243, 255, 0.3), + inset 0 0 60px rgba(188, 19, 254, 0.2), + 0 0 30px rgba(0, 243, 255, 0.4), + 0 0 60px rgba(188, 19, 254, 0.3); + } + + 50% { + box-shadow: + inset 0 0 40px rgba(0, 243, 255, 0.5), + inset 0 0 80px rgba(188, 19, 254, 0.4), + 0 0 40px rgba(0, 243, 255, 0.6), + 0 0 80px rgba(188, 19, 254, 0.5); + } +} + + +/* === DOWNLOAD PROGRESS BAR === */ +.download-status { + min-height: 30px; + margin-top: 8px; + position: relative; + text-align: center; + font-family: 'Orbitron', sans-serif; + color: #0ff; +} + +.progress-container { + width: 100%; + height: 20px; + background: rgba(0, 0, 0, 0.5); + border-radius: 10px; + overflow: hidden; + border: 1px solid #333; + position: relative; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, + var(--primary-cyan) 0%, + var(--secondary-magenta) 100%); + border-radius: 10px; + transition: width 0.3s ease; + box-shadow: 0 0 10px var(--primary-cyan); + position: relative; + overflow: hidden; +} + +.progress-bar::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100%); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +.progress-text { + font-size: 0.8rem; + font-family: 'Orbitron', sans-serif; + color: #fff; + padding: 5px; +} + +#deck-B .progress-bar { + background: linear-gradient(90deg, + var(--secondary-magenta) 0%, + var(--primary-cyan) 100%); + box-shadow: 0 0 10px var(--secondary-magenta); +} + +/* Remove static border, replace with dynamic one */ +/* === ULTRA COMPACT MODE - Ensure crossfader visible at 100% zoom === */ +.deck-header { + padding: 8px !important; + margin-bottom: 8px !important; +} + +.deck-title { + font-size: 1rem !important; +} + +.track-display { + font-size: 0.75rem !important; +} + +.waveform-container { + margin-bottom: 6px !important; + padding: 3px !important; +} + +.waveform-canvas { + height: 50px !important; +} + +.time-display { + font-size: 0.75rem !important; + margin-top: 3px !important; +} + +.disk-container { + margin: 6px 0 !important; +} + +.dj-disk { + width: 100px !important; + height: 100px !important; +} + +.disk-label { + font-size: 1.5rem !important; +} + +#viz-A, +#viz-B { + height: 60px !important; + margin-bottom: 6px !important; +} + +.hot-cues { + gap: 4px !important; + margin-bottom: 6px !important; +} + +.cue-btn { + padding: 4px !important; + font-size: 0.65rem !important; +} + +.loop-controls { + gap: 4px !important; + margin-bottom: 6px !important; +} + +.loop-btn { + padding: 4px !important; + font-size: 0.65rem !important; +} + +.start-group { + margin-bottom: 6px !important; +} + +.search-input { + padding: 6px !important; + font-size: 0.8rem !important; + margin-bottom: 4px !important; +} + +.download-btn { + padding: 6px 12px !important; + font-size: 0.8rem !important; +} + +.quality-selector { + padding: 5px !important; + font-size: 0.7rem !important; +} + +.controls-grid { + gap: 8px !important; + margin-bottom: 6px !important; +} + +.eq-band input { + height: 70px !important; +} + +.eq-band label { + font-size: 0.65rem !important; +} + +.volume-fader { + height: 70px !important; +} + +.fader-group label { + font-size: 0.65rem !important; + margin-bottom: 3px !important; +} + +.filter-knob label { + font-size: 0.6rem !important; + margin-bottom: 3px !important; +} + +.filter-slider { + width: 80px !important; +} + +.sample-pads { + gap: 4px !important; + margin: 6px 0 !important; +} + +.sample-pad { + padding: 6px 3px !important; + font-size: 0.65rem !important; +} + +.transport { + gap: 6px !important; + margin-top: 6px !important; +} + +.big-btn { + padding: 8px !important; + font-size: 0.85rem !important; +} + +.pitch-bend { + padding: 3px 10px !important; + font-size: 0.75rem !important; +} + +.mixer-section { + padding: 12px 40px !important; + min-height: auto !important; +} + +.mixer-section::before, +.mixer-section::after { + font-size: 1.2rem !important; +} + +.library-section { + padding: 10px !important; +} + +.library-header { + padding: 8px !important; + margin-bottom: 8px !important; +} + +.track-row { + padding: 6px !important; + margin-bottom: 4px !important; +} + +.track-name { + font-size: 0.8rem !important; +} + +.load-btn { + padding: 5px 10px !important; + font-size: 0.7rem !important; +} + +/* Make main container fit better */ +#app-container { + padding: 10px !important; + gap: 10px !important; +} + +/* === SETTINGS PANEL === */ +/* === MOBILE RESPONSIVE & TABS === */ +.mobile-tabs { + display: none; + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: 90%; + max-width: 400px; + background: rgba(15, 15, 25, 0.85); + border: 1px solid rgba(0, 243, 255, 0.3); + border-radius: 50px; + z-index: 10003; + justify-content: space-around; + padding: 8px; + backdrop-filter: blur(15px); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 243, 255, 0.1); +} + +.tab-btn { + background: none; + border: none; + color: var(--text-dim); + font-family: 'Orbitron', sans-serif; + font-size: 0.65rem; + padding: 10px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + border-radius: 40px; +} + +.tab-icon { + font-size: 1.2rem; + margin-bottom: 2px; +} + +.tab-btn.active { + color: var(--primary-cyan); + background: rgba(0, 243, 255, 0.1); + text-shadow: 0 0 10px var(--primary-cyan); + box-shadow: inset 0 0 10px rgba(0, 243, 255, 0.2); +} + +@media (max-width: 1024px) { + body { + height: 100vh; + overflow: hidden; + /* Prevent body scroll, use container scroll */ + } + + body::before { + border-width: 2px; + } + + .app-container { + grid-template-columns: 1fr; + grid-template-rows: 1fr; + gap: 0; + padding: 5px; + padding-bottom: 90px; + height: 100vh; + overflow-y: auto; + } + + .mobile-tabs { + display: flex; + } + + .library-section, + #deck-A, + #deck-B, + .mixer-section { + display: none; + width: 100%; + height: 100%; + } + + .app-container.show-library .library-section { + display: flex; + } + + .app-container.show-deck-A #deck-A { + display: flex; + } + + .app-container.show-deck-B #deck-B { + display: flex; + } + + /* Mixer integration: Show mixer combined with active deck in a scrollable view */ + .app-container.show-deck-A .mixer-section, + .app-container.show-deck-B .mixer-section { + display: flex; + padding: 20px !important; + margin-top: 20px; + border-radius: 15px; + background: rgba(0, 0, 0, 0.3); + } + + .library-section { + height: calc(100vh - 120px); + } + + .deck { + padding: 15px; + min-height: min-content; + } + + .dj-disk { + width: 180px; + height: 180px; + margin: 20px auto; + } + + .waveform-container { + height: 100px; + } + + .controls-grid { + grid-template-columns: 1fr 1fr; + gap: 15px; + } + + .big-btn { + padding: 15px !important; + font-size: 1rem !important; + } + + .settings-btn { + bottom: 100px; + right: 20px; + width: 50px; + height: 50px; + } + + .volume-fader { + height: 220px !important; + } + + /* Library specific mobile fixes */ + .track-row { + flex-direction: row; + justify-content: space-between; + padding: 12px !important; + background: rgba(255, 255, 255, 0.03); + margin-bottom: 8px; + border-radius: 8px; + } + + .load-actions { + gap: 5px; + } + + .load-btn { + font-size: 0.6rem !important; + padding: 5px 8px !important; + } + + .track-name { + font-size: 0.9rem !important; + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mixer-controls { + gap: 20px !important; + } + + .settings-btn { + bottom: 120px !important; + } + + .lib-header { + padding: 10px !important; + flex-direction: column; + gap: 8px !important; + } + + .lib-header input { + width: 100%; + background: rgba(0, 0, 0, 0.5) !important; + border: 1px solid var(--primary-cyan) !important; + box-shadow: 0 0 10px rgba(0, 243, 255, 0.1) inset; + } +} + +/* ========== LIVE STREAMING CONTROLS ========== */ + +/* Streaming Button */ +.streaming-btn { + position: fixed; + bottom: 25px; + right: 100px; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(145deg, #222, #111); + border: 2px solid var(--secondary-magenta); + color: var(--secondary-magenta); + font-size: 1.8rem; + cursor: pointer; + z-index: 10000; + box-shadow: 0 0 20px rgba(188, 19, 254, 0.4); + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; +} + +.streaming-btn:hover { + transform: scale(1.1); + box-shadow: 0 0 30px rgba(188, 19, 254, 0.6); +} + +.streaming-btn:active { + transform: scale(0.95); +} + +/* Streaming Panel */ +.streaming-panel { + position: fixed; + top: 0; + right: -400px; + height: 100vh; + width: 380px; + background: rgba(10, 10, 20, 0.98); + border-left: 2px solid var(--secondary-magenta); + padding: 30px; + z-index: 10001; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(15px); + overflow-y: auto; +} + +.streaming-panel.active { + right: 0 !important; +} + +.streaming-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + border-bottom: 1px solid rgba(188, 19, 254, 0.3); + padding-bottom: 15px; + font-family: 'Orbitron', sans-serif; + font-size: 1.2rem; + color: var(--secondary-magenta); +} + +.close-streaming { + background: none; + border: none; + color: var(--text-dim); + font-size: 1.5rem; + cursor: pointer; + transition: all 0.3s; +} + +.close-streaming:hover { + color: var(--secondary-magenta); + transform: rotate(90deg); +} + +.streaming-content { + display: flex; + flex-direction: column; + gap: 25px; +} + +/* Broadcast Controls */ +.broadcast-controls { + display: flex; + flex-direction: column; + gap: 15px; +} + +.broadcast-btn { + padding: 20px; + background: linear-gradient(145deg, #1a1a1a, #0a0a0a); + border: 2px solid var(--secondary-magenta); + color: var(--secondary-magenta); + font-family: 'Orbitron', sans-serif; + font-size: 1.1rem; + font-weight: bold; + cursor: pointer; + border-radius: 10px; + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + box-shadow: 0 0 20px rgba(188, 19, 254, 0.2); +} + +.broadcast-btn:hover { + background: linear-gradient(145deg, #2a2a2a, #1a1a1a); + box-shadow: 0 0 30px rgba(188, 19, 254, 0.4); + transform: translateY(-2px); +} + +.broadcast-btn.active { + background: var(--secondary-magenta); + color: #000; + box-shadow: 0 0 40px rgba(188, 19, 254, 0.6); + animation: pulse-broadcast 2s ease-in-out infinite; +} + +@keyframes pulse-broadcast { + + 0%, + 100% { + box-shadow: 0 0 40px rgba(188, 19, 254, 0.6); + } + + 50% { + box-shadow: 0 0 60px rgba(188, 19, 254, 0.9); + } +} + +.broadcast-icon { + font-size: 1.3rem; +} + +.broadcast-btn.active .broadcast-icon { + animation: blink-red 1.5s ease-in-out infinite; +} + +@keyframes blink-red { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} + +.broadcast-status { + text-align: center; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + color: var(--text-dim); + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 5px; +} + +.broadcast-status.live { + color: var(--secondary-magenta); + font-weight: bold; + text-shadow: 0 0 10px var(--secondary-magenta); +} + +/* Listener Info */ +.listener-info { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(188, 19, 254, 0.3); + border-radius: 10px; + padding: 20px; +} + +.listener-count { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-family: 'Orbitron', sans-serif; + font-size: 2rem; + color: var(--secondary-magenta); +} + +.count-icon { + font-size: 2.5rem; +} + +.count-label { + font-size: 0.9rem; + color: var(--text-dim); + font-family: 'Rajdhani', sans-serif; +} + +/* Stream URL Section */ +.stream-url-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.stream-url-section label { + font-size: 0.9rem; + color: var(--text-dim); +} + +.url-copy-group { + display: flex; + gap: 5px; +} + +.url-copy-group input { + flex: 1; + padding: 10px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(188, 19, 254, 0.3); + color: var(--text-main); + border-radius: 5px; + font-family: 'Rajdhani', monospace; + font-size: 0.85rem; +} + +.copy-btn { + padding: 10px 15px; + background: rgba(188, 19, 254, 0.2); + border: 1px solid var(--secondary-magenta); + color: var(--secondary-magenta); + border-radius: 5px; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.3s; +} + +.copy-btn:hover { + background: rgba(188, 19, 254, 0.3); + box-shadow: 0 0 15px rgba(188, 19, 254, 0.4); +} + +.copy-btn:active { + transform: scale(0.95); +} + +/* Stream Settings */ +.stream-settings { + padding: 15px; + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; +} + +.stream-settings label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 1rem; +} + +.stream-settings input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; +} + +/* Quality Selector */ +.quality-selector-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid rgba(188, 19, 254, 0.2); +} + +.quality-selector-group label { + font-size: 0.9rem; + color: var(--text-dim); + margin-bottom: 5px; +} + +.stream-quality-select { + padding: 10px; + background: rgba(0, 0, 0, 0.5); + border: 1px solid var(--secondary-magenta); + color: var(--text-main); + border-radius: 5px; + font-family: 'Rajdhani', sans-serif; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s; +} + +.stream-quality-select:hover { + background: rgba(0, 0, 0, 0.7); + border-color: #ff00ff; + box-shadow: 0 0 10px rgba(188, 19, 254, 0.3); +} + +.stream-quality-select:focus { + outline: none; + box-shadow: 0 0 15px rgba(188, 19, 254, 0.5); +} + +.quality-hint { + font-size: 0.75rem; + color: var(--text-dim); + font-style: italic; + opacity: 0.7; +} + +/* ========== LISTENER MODE ========== */ + +.listener-mode { + position: fixed; + inset: 0; + background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%); + z-index: 10010; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px; +} + +.listener-header { + text-align: center; + margin-bottom: 40px; +} + +.listener-header h1 { + font-family: 'Orbitron', sans-serif; + font-size: 3rem; + color: var(--secondary-magenta); + text-shadow: 0 0 30px var(--secondary-magenta); + margin: 0 0 20px 0; +} + +.glow-text { + color: #fff; + text-shadow: 0 0 10px var(--secondary-magenta), 0 0 20px var(--secondary-magenta); + animation: text-glow-pulse 2s infinite ease-in-out; +} + +@keyframes text-glow-pulse { + + 0%, + 100% { + opacity: 0.8; + text-shadow: 0 0 10px var(--secondary-magenta); + } + + 50% { + opacity: 1; + text-shadow: 0 0 15px var(--secondary-magenta), 0 0 30px var(--secondary-magenta); + } +} + +.live-indicator { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: rgba(188, 19, 254, 0.2); + border: 2px solid var(--secondary-magenta); + border-radius: 25px; + font-family: 'Orbitron', sans-serif; + font-size: 1.2rem; + color: var(--secondary-magenta); + box-shadow: 0 0 20px rgba(188, 19, 254, 0.4); +} + +.pulse-dot { + width: 12px; + height: 12px; + background: var(--secondary-magenta); + border-radius: 50%; + animation: pulse-dot 1.5s ease-in-out infinite; +} + +@keyframes pulse-dot { + + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.3); + opacity: 0.7; + } +} + +.listener-content { + max-width: 600px; + width: 100%; + background: rgba(10, 10, 20, 0.8); + border: 2px solid var(--secondary-magenta); + border-radius: 20px; + padding: 40px; + box-shadow: 0 0 40px rgba(188, 19, 254, 0.3); + backdrop-filter: blur(10px); +} + +.now-playing { + text-align: center; + font-family: 'Orbitron', sans-serif; + font-size: 1.5rem; + color: var(--text-main); + margin-bottom: 30px; + padding: 20px; + background: rgba(0, 0, 0, 0.3); + border-radius: 10px; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; +} + +.volume-control { + margin-bottom: 20px; +} + +.volume-control label { + display: block; + margin-bottom: 10px; + font-size: 1.1rem; + color: var(--text-dim); +} + +.volume-control input[type="range"] { + width: 100%; + height: 8px; +} + +.connection-status { + text-align: center; + font-family: 'Rajdhani', sans-serif; + font-size: 0.9rem; + color: var(--text-dim); + padding: 10px; + background: rgba(0, 0, 0, 0.3); + border-radius: 5px; +} + +.connection-status.connected { + color: #00ff00; + text-shadow: 0 0 10px #00ff00; +} + +.connection-status.disconnected { + color: #ff4444; +} + +/* Enable Audio Button */ +.enable-audio-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 30px 40px; + margin: 30px 0; + background: linear-gradient(145deg, #1a1a1a, #0a0a0a); + border: 3px solid var(--secondary-magenta); + border-radius: 15px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 0 30px rgba(188, 19, 254, 0.3); + font-family: 'Orbitron', sans-serif; +} + +.enable-audio-btn:hover { + background: linear-gradient(145deg, #2a2a2a, #1a1a1a); + box-shadow: 0 0 50px rgba(188, 19, 254, 0.6); + transform: translateY(-3px); + border-color: #ff00ff; +} + +.enable-audio-btn:active { + transform: translateY(-1px); + box-shadow: 0 0 40px rgba(188, 19, 254, 0.5); +} + +.enable-audio-btn .audio-icon { + font-size: 3rem; + animation: pulse-icon 2s ease-in-out infinite; +} + +@keyframes pulse-icon { + + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + + 50% { + transform: scale(1.1); + opacity: 0.8; + } +} + +.enable-audio-btn .audio-text { + font-size: 1.5rem; + font-weight: bold; + color: var(--secondary-magenta); + text-shadow: 0 0 10px var(--secondary-magenta); +} + +.enable-audio-btn .audio-subtitle { + font-size: 0.9rem; + color: var(--text-dim); + font-family: 'Rajdhani', sans-serif; +} + +/* Mobile Responsiveness for Listener Mode */ +@media (max-width: 768px) { + .listener-mode { + padding: 20px; + justify-content: flex-start; + padding-top: 60px; + } + + .listener-header h1 { + font-size: 2.2rem; + margin-bottom: 10px; + } + + .live-indicator { + font-size: 0.9rem; + padding: 6px 15px; + } + + .listener-content { + padding: 25px; + margin-top: 10px; + border-radius: 15px; + } + + .now-playing { + font-size: 1.1rem; + min-height: 80px; + line-height: 1.4; + margin-bottom: 20px; + } + + .volume-control label { + font-size: 0.9rem; + } +} + +/* Hide landscape prompt globally when listening-active class is present */ +body.listening-active .landscape-prompt { + display: none !important; +} + +/* Base Settings Button Fix */ +.keyboard-btn { + position: fixed; + bottom: 25px; + right: 100px; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(145deg, #222, #111); + border: 2px solid #ffbb00; + color: #ffbb00; + font-size: 1.8rem; + cursor: pointer; + z-index: 10000; + box-shadow: 0 0 20px rgba(255, 187, 0, 0.4); + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; +} + +.keyboard-btn:hover { + transform: scale(1.1) rotate(5deg); + box-shadow: 0 0 30px rgba(255, 187, 0, 0.6); +} + +.settings-btn { + position: fixed; + bottom: 25px; + right: 25px; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(145deg, #222, #111); + border: 2px solid var(--primary-cyan); + color: var(--primary-cyan); + font-size: 1.8rem; + cursor: pointer; + z-index: 10000; + box-shadow: 0 0 20px rgba(0, 243, 255, 0.4); + transition: all 0.3s; + display: flex; + align-items: center; + justify-content: center; +} + +.settings-panel { + position: fixed; + top: 0; + right: -350px; + height: 100vh; + width: 320px; + background: rgba(10, 10, 20, 0.98); + border-left: 1px solid var(--primary-cyan); + padding: 30px; + z-index: 10001; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(15px); +} + +.settings-panel.active { + right: 0 !important; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + border-bottom: 1px solid rgba(0, 243, 255, 0.3); + padding-bottom: 15px; +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 20px; +} + +.setting-item label { + display: flex; + align-items: center; + gap: 15px; + font-size: 1.1rem; + cursor: pointer; +} + +/* Pulsing animations for edge border */ +@keyframes pulse-cyan { + + 0%, + 100% { + box-shadow: + inset 0 0 0 10px var(--primary-cyan), + inset 0 0 80px rgba(0, 243, 255, 1), + inset 0 0 150px rgba(0, 243, 255, 0.7); + } + + 50% { + box-shadow: + inset 0 0 0 12px var(--primary-cyan), + inset 0 0 120px rgba(0, 243, 255, 1), + inset 0 0 200px rgba(0, 243, 255, 0.9); + } +} + +@keyframes pulse-magenta { + + 0%, + 100% { + box-shadow: + inset 0 0 0 10px var(--secondary-magenta), + inset 0 0 80px rgba(188, 19, 254, 1), + inset 0 0 150px rgba(188, 19, 254, 0.7); + } + + 50% { + box-shadow: + inset 0 0 0 12px var(--secondary-magenta), + inset 0 0 120px rgba(188, 19, 254, 1), + inset 0 0 200px rgba(188, 19, 254, 0.9); + } +} + +@keyframes pulse-both { + + 0%, + 100% { + box-shadow: + inset 0 0 0 10px var(--primary-cyan), + inset 0 0 80px rgba(0, 243, 255, 0.9), + inset 0 0 120px rgba(188, 19, 254, 0.9); + } + + 50% { + box-shadow: + inset 0 0 0 12px var(--primary-cyan), + inset 0 0 120px rgba(0, 243, 255, 1), + inset 0 0 180px rgba(188, 19, 254, 1); + } +} + +/* Enhanced Mobile Improvements */ +@media (max-width: 1024px) { + + /* Larger tap targets */ + button, + .cue-btn, + .loop-btn { + min-height: 44px; + min-width: 44px; + font-size: 0.9rem; + } + + /* Better spacing */ + .hot-cues, + .loop-controls { + gap: 8px; + } + + /* Larger sliders */ + input[type="range"] { + height: 40px; + } + + /* Bigger disk for easier touch */ + .dj-disk { + width: 180px; + height: 180px; + } + + .disk-label { + width: 60px; + height: 60px; + font-size: 1.5rem; + } + + /* Better waveform touch area */ + .waveform-canvas { + min-height: 100px; + } + + /* Larger text */ + .track-display { + font-size: 1rem; + } + + .time-display { + font-size: 1rem; + } + + /* Better tab buttons */ + .mobile-tabs { + padding: 12px 0; + } + + .tab-btn { + padding: 12px 20px; + font-size: 1rem; + } + + /* Settings panel improvements */ + .setting-item { + padding: 12px; + font-size: 1rem; + } + + /* Better library items */ + .track-row { + padding: 12px; + min-height: 60px; + } + + .track-name { + font-size: 1rem; + } + + .load-btn { + padding: 10px 16px; + font-size: 0.9rem; + } +} + +@media (max-width: 768px) { + + /* Even larger for phones */ + .dj-disk { + width: 200px; + height: 200px; + } + + .disk-label { + width: 70px; + height: 70px; + font-size: 1.8rem; + } + + /* Stack controls vertically */ + .controls { + flex-direction: column; + gap: 15px; + } + + /* Larger crossfader */ + .crossfader-section { + padding: 20px; + } + + #crossfader { + height: 50px; + } + + /* Better EQ controls */ + .eq-controls, + .filter-controls { + gap: 15px; + } + + .eq-knob, + .filter-knob { + min-width: 80px; + } + + /* Larger speed control */ + .speed-control { + padding: 15px; + } + + .speed-slider { + height: 50px; + } + + /* Better pitch bend buttons */ + .pitch-bend-controls button { + min-width: 60px; + min-height: 50px; + font-size: 1.2rem; + } +} + +@media (max-width: 480px) { + + /* Extra small phones */ + body { + font-size: 14px; + } + + .deck-title { + font-size: 1rem; + } + + /* Compact hot cues */ + .hot-cues { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + + .cue-btn { + padding: 12px; + } + + /* Compact loop controls */ + .loop-controls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; + } + + /* Smaller VU meters */ + canvas#viz-A, + canvas#viz-B { + height: 60px; + } + + /* Compact library */ + .lib-header input { + font-size: 0.9rem; + } + + .track-row { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .load-actions { + width: 100%; + justify-content: space-between; + } +} + +/* ========== LANDSCAPE MODE OPTIMIZATIONS (Phone Sideways) ========== */ +@media (max-width: 1024px) and (orientation: landscape) { + + /* Override the portrait tab system - show both decks */ + .mobile-tabs { + display: none !important; + } + + .app-container { + display: grid !important; + grid-template-columns: 1fr 1fr !important; + grid-template-rows: 1fr auto !important; + gap: 8px !important; + padding: 8px !important; + padding-bottom: 8px !important; + height: 100vh; + overflow: hidden; + } + + /* Hide library in landscape - focus on DJing */ + .library-section { + display: none !important; + } + + /* Show both decks side by side */ + #deck-A, + #deck-B { + display: flex !important; + flex-direction: column; + padding: 8px !important; + overflow-y: auto; + height: 100%; + border-radius: 8px; + } + + /* Show crossfader at bottom spanning both decks */ + .mixer-section { + display: flex !important; + grid-column: 1 / 3 !important; + grid-row: 2 !important; + padding: 8px 30px !important; + min-height: 60px !important; + height: 60px !important; + } + + /* Compact deck headers */ + .deck-header { + padding: 6px 8px !important; + margin-bottom: 6px !important; + flex-shrink: 0; + } + + .deck-title { + font-size: 0.9rem !important; + } + + .track-display { + font-size: 0.7rem !important; + max-width: 150px !important; + } + + /* Compact waveform */ + .waveform-container { + margin-bottom: 6px !important; + padding: 6px !important; + min-height: auto !important; + } + + .waveform-canvas { + height: 50px !important; + min-height: 50px !important; + } + + .time-display { + font-size: 0.7rem !important; + margin-top: 3px !important; + } + + /* Smaller disk */ + .disk-container { + margin-bottom: 6px !important; + } + + .dj-disk { + width: 80px !important; + height: 80px !important; + } + + .disk-label { + width: 30px !important; + height: 30px !important; + font-size: 0.9rem !important; + } + + /* Hide VU meters to save space */ + canvas#viz-A, + canvas#viz-B { + display: none !important; + } + + /* Compact hot cues */ + .hot-cues { + grid-template-columns: repeat(4, 1fr) !important; + gap: 4px !important; + margin-bottom: 6px !important; + } + + .cue-btn { + padding: 6px 4px !important; + font-size: 0.65rem !important; + min-height: 32px !important; + } + + /* Compact loop controls */ + .loop-controls { + gap: 4px !important; + margin-bottom: 6px !important; + } + + .loop-btn { + padding: 6px 4px !important; + font-size: 0.65rem !important; + min-height: 32px !important; + } + + /* Hide download section to save space */ + .start-group { + display: none !important; + } + + /* Compact controls grid */ + .controls-grid { + grid-template-columns: auto auto auto auto !important; + gap: 6px !important; + margin-top: 6px !important; + } + + /* Compact faders */ + .fader-group { + min-width: 50px; + } + + .fader-group label { + font-size: 0.6rem !important; + margin-bottom: 2px !important; + } + + .volume-fader { + height: 80px !important; + width: 8px !important; + } + + /* Compact EQ */ + .eq-container { + margin: 0 !important; + gap: 4px; + } + + .eq-band { + width: auto !important; + } + + .eq-band input { + height: 80px !important; + width: 8px !important; + } + + .eq-band label { + font-size: 0.6rem !important; + } + + /* Compact filters */ + .filter-knobs { + display: flex; + flex-direction: column; + gap: 4px; + } + + .filter-knob { + min-width: auto !important; + } + + .filter-knob label { + font-size: 0.55rem !important; + margin-bottom: 2px !important; + } + + .filter-slider { + width: 60px !important; + height: 6px !important; + } + + /* Compact pitch control */ + .speed-slider { + height: 6px !important; + } + + .pitch-bend-buttons { + gap: 4px; + margin-top: 4px; + } + + .pitch-bend { + padding: 4px 8px !important; + font-size: 0.7rem !important; + min-height: 28px !important; + } + + /* Compact transport buttons */ + .transport { + gap: 4px !important; + margin-top: 6px !important; + } + + .big-btn { + padding: 8px 6px !important; + font-size: 0.75rem !important; + min-height: 36px !important; + } + + /* Prominent crossfader */ + .xfader { + height: 16px !important; + } + + .xfader::-webkit-slider-thumb { + width: 50px !important; + height: 40px !important; + } + + .xfader::-moz-range-thumb { + width: 50px !important; + height: 40px !important; + } + + .mixer-section::before, + .mixer-section::after { + font-size: 1.3rem !important; + top: 50%; + transform: translateY(-50%); + } + + .mixer-section::before { + left: 10px !important; + } + + .mixer-section::after { + right: 10px !important; + } + + /* Settings button adjustment */ + .settings-btn { + bottom: 70px !important; + right: 10px !important; + width: 40px !important; + height: 40px !important; + font-size: 1.2rem !important; + } + + /* Reduce edge border effect intensity */ + body::before { + border: 2px solid rgba(80, 80, 80, 0.3) !important; + } + + body.playing-A::before { + border: 10px solid var(--primary-cyan) !important; + box-shadow: + 0 0 60px rgba(0, 243, 255, 1), + inset 0 0 60px rgba(0, 243, 255, 0.7) !important; + } + + body.playing-B::before { + border: 10px solid var(--secondary-magenta) !important; + box-shadow: + 0 0 60px rgba(188, 19, 254, 1), + inset 0 0 60px rgba(188, 19, 254, 0.7) !important; + } + + body.playing-A.playing-B::before { + border: 10px solid var(--primary-cyan) !important; + box-shadow: + 0 0 60px rgba(0, 243, 255, 1), + 0 0 80px rgba(188, 19, 254, 1), + inset 0 0 60px rgba(0, 243, 255, 0.6), + inset 0 0 80px rgba(188, 19, 254, 0.6) !important; + } +} + +/* Extra compact for very small landscape screens (phones) */ +@media (max-width: 768px) and (orientation: landscape) { + .deck-header { + flex-direction: column; + align-items: flex-start; + gap: 2px; + } + + .track-display { + max-width: 100% !important; + } + + /* Even more compact controls */ + .hot-cues { + grid-template-columns: repeat(2, 1fr) !important; + } + + .controls-grid { + grid-template-columns: auto auto !important; + } + + .filter-knobs { + display: none !important; + } +} + +/* Tiny landscape screens */ +@media (max-width: 640px) and (orientation: landscape) { + .app-container { + gap: 4px !important; + padding: 4px !important; + } + + #deck-A, + #deck-B { + padding: 4px !important; + } + + .waveform-canvas { + height: 40px !important; + } + + .dj-disk { + width: 60px !important; + height: 60px !important; + } + + .disk-label { + width: 24px !important; + height: 24px !important; + font-size: 0.7rem !important; + } + + .hot-cues, + .loop-controls { + gap: 2px !important; + } + + .cue-btn, + .loop-btn { + font-size: 0.55rem !important; + padding: 4px 2px !important; + min-height: 28px !important; + } +} + +/* Touch-friendly hover states */ +@media (hover: none) { + + button:hover, + .cue-btn:hover, + .loop-btn:hover { + transform: none; + } + + button:active, + .cue-btn:active, + .loop-btn:active { + transform: scale(0.95); + opacity: 0.8; + } +} + +/* Landscape orientation prompt */ +.landscape-prompt { + display: none; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.95); + border: 2px solid var(--primary-cyan); + border-radius: 15px; + padding: 30px; + z-index: 10005; + text-align: center; + max-width: 80%; + box-shadow: 0 0 40px rgba(0, 243, 255, 0.5); + backdrop-filter: blur(20px); +} + +.landscape-prompt h2 { + font-family: 'Orbitron', sans-serif; + color: var(--primary-cyan); + margin: 0 0 15px 0; + font-size: 1.3rem; + text-shadow: 0 0 10px var(--primary-cyan); +} + +.landscape-prompt p { + color: var(--text-main); + margin: 0 0 20px 0; + font-size: 1rem; + line-height: 1.5; +} + +.landscape-prompt .rotate-icon { + font-size: 3rem; + margin-bottom: 15px; + animation: rotate-hint 2s ease-in-out infinite; +} + +@keyframes rotate-hint { + + 0%, + 100% { + transform: rotate(0deg); + } + + 50% { + transform: rotate(90deg); + } +} + +.landscape-prompt button { + background: var(--primary-cyan); + color: #000; + border: none; + padding: 12px 30px; + font-family: 'Orbitron', sans-serif; + font-size: 1rem; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 0 20px rgba(0, 243, 255, 0.4); + transition: all 0.3s; +} + +.landscape-prompt button:hover { + box-shadow: 0 0 30px rgba(0, 243, 255, 0.6); + transform: translateY(-2px); +} + +/* Show prompt on mobile portrait */ +@media (max-width: 1024px) and (orientation: portrait) { + .landscape-prompt { + display: block; + } + + .landscape-prompt.dismissed { + display: none; + } +} + +/* ========================================== + QUEUE SYSTEM STYLING + ========================================== */ + +.queue-panel { + background: rgba(10, 10, 20, 0.9); + border: 2px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + margin-top: 15px; + padding: 10px; + max-height: 200px; + display: flex; + flex-direction: column; +} + +.queue-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.queue-title { + font-family: 'Orbitron', sans-serif; + font-size: 0.9rem; + font-weight: bold; + color: #0ff; +} + +#queue-panel-B .queue-title { + color: #f0f; +} + +.queue-clear-btn { + background: rgba(255, 0, 0, 0.1); + border: 1px solid #f00; + color: #f00; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.queue-clear-btn:hover { + background: rgba(255, 0, 0, 0.2); + box-shadow: 0 0 10px rgba(255, 0, 0, 0.3); +} + +.queue-list { + flex: 1; + overflow-y: auto; + min-height: 50px; +} + +.queue-empty { + text-align: center; + padding: 20px; + color: #666; + font-size: 0.85rem; + font-style: italic; +} + +.queue-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + margin-bottom: 5px; + background: rgba(255, 255, 255, 0.03); + border-radius: 4px; + border-left: 3px solid #0ff; + cursor: move; + transition: all 0.2s; +} + +#queue-panel-B .queue-item { + border-left-color: #f0f; +} + +.queue-item:hover { + background: rgba(255, 255, 255, 0.08); + transform: translateX(3px); +} + +.queue-item.dragging { + opacity: 0.5; +} + +.queue-number { + font-family: 'Orbitron', sans-serif; + font-size: 0.8rem; + color: #0ff; + min-width: 20px; +} + +#queue-panel-B .queue-number { + color: #f0f; +} + +.queue-track-title { + flex: 1; + font-size: 0.85rem; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-actions { + display: flex; + gap: 4px; +} + +.queue-load-btn, +.queue-remove-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 4px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; +} + +.queue-load-btn:hover { + background: rgba(0, 255, 0, 0.2); + border-color: #0f0; + color: #0f0; +} + +.queue-remove-btn:hover { + background: rgba(255, 0, 0, 0.2); + border-color: #f00; + color: #f00; +} + +/* Queue buttons in library */ +.queue-btn-a, +.queue-btn-b { + font-size: 0.65rem !important; + padding: 4px 6px !important; +} + +.queue-btn-a { + background: rgba(0, 243, 255, 0.15) !important; + border-color: rgba(0, 243, 255, 0.4) !important; +} + +.queue-btn-b { + background: rgba(188, 19, 254, 0.15) !important; + border-color: rgba(188, 19, 254, 0.4) !important; +} + +.queue-btn-a:hover, +.queue-btn-b:hover { + opacity: 1 !important; + transform: scale(1.05); +} + +/* Update load-actions to fit 4 buttons */ +.load-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} + +/* ========================================== + KEYBOARD SETTINGS PANEL + ========================================== */ + +.keyboard-mapping-item { + display: flex; + align-items: center; + gap: 15px; + padding: 12px; + margin-bottom: 8px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + border-left: 3px solid transparent; + transition: all 0.2s; +} + +.keyboard-mapping-item:hover { + background: rgba(255, 255, 255, 0.06); + border-left-color: #0ff; +} + +.keyboard-mapping-item.listening { + background: rgba(255, 255, 0, 0.1); + border-left-color: #ff0; + animation: pulse 1s infinite; +} + +.key-display { + font-family: 'Orbitron', monospace; + font-size: 0.9rem; + font-weight: bold; + color: #0ff; + background: rgba(0, 243, 255, 0.1); + padding: 6px 12px; + border-radius: 4px; + border: 1px solid rgba(0, 243, 255, 0.3); + min-width: 60px; + text-align: center; +} + +.key-arrow { + color: #666; + font-size: 1.2rem; +} + +.action-label { + flex: 1; + color: #ccc; + font-size: 0.9rem; +} + +.key-reassign-btn { + background: rgba(0, 243, 255, 0.1); + border: 1px solid #0ff; + color: #0ff; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; + font-family: 'Orbitron', sans-serif; + transition: all 0.2s; +} + +.key-reassign-btn:hover { + background: rgba(0, 243, 255, 0.2); + box-shadow: 0 0 10px rgba(0, 243, 255, 0.3); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-family: 'Orbitron', sans-serif; + font-size: 0.85rem; + transition: all 0.2s; +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.1); + border-color: #0ff; + color: #0ff; +} + +#keyboard-settings-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.9); + width: 90%; + max-width: 600px; + max-height: 80vh; + background: rgba(10, 10, 20, 0.98); + border: 2px solid #0ff; + border-radius: 10px; + box-shadow: 0 0 50px rgba(0, 243, 255, 0.3); + z-index: 10000; + opacity: 0; + pointer-events: none; + transition: all 0.3s ease; + display: flex; + flex-direction: column; +} + +#keyboard-settings-panel.active { + opacity: 1; + pointer-events: all; + transform: translate(-50%, -50%) scale(1); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.panel-header h2 { + margin: 0; + font-family: 'Orbitron', sans-serif; + color: #0ff; + font-size: 1.3rem; +} + +.panel-header button { + background: rgba(255, 0, 0, 0.1); + border: 1px solid #f00; + color: #f00; + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.2s; +} + +.panel-header button:hover { + background: rgba(255, 0, 0, 0.2); + transform: rotate(90deg); +} + +.panel-content { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +#keyboard-mappings-list { + max-height: 400px; + overflow-y: auto; +} + +/* Scrollbar styling */ +#keyboard-mappings-list::-webkit-scrollbar { + width: 8px; +} + +#keyboard-mappings-list::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; +} + +#keyboard-mappings-list::-webkit-scrollbar-thumb { + background: rgba(0, 243, 255, 0.3); + border-radius: 4px; +} + +#keyboard-mappings-list::-webkit-scrollbar-thumb:hover { + background: rgba(0, 243, 255, 0.5); +} + +/* ========================================== + LIBRARY TRACK HIGHLIGHTING + ========================================== */ + +/* Track loaded on Deck A (Cyan) */ +.track-row.loaded-deck-a { + border-left: 4px solid var(--primary-cyan); + background: linear-gradient(90deg, rgba(0, 243, 255, 0.15) 0%, rgba(0, 243, 255, 0.03) 100%); +} + +.track-row.loaded-deck-a .track-name { + color: var(--primary-cyan); + font-weight: bold; +} + +.track-row.loaded-deck-a::before { + content: '▶ A'; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.7rem; + font-weight: bold; + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; +} + +/* Track loaded on Deck B (Magenta) */ +.track-row.loaded-deck-b { + border-left: 4px solid var(--secondary-magenta); + background: linear-gradient(90deg, rgba(188, 19, 254, 0.15) 0%, rgba(188, 19, 254, 0.03) 100%); +} + +.track-row.loaded-deck-b .track-name { + color: var(--secondary-magenta); + font-weight: bold; +} + +.track-row.loaded-deck-b::before { + content: '▶ B'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.7rem; + font-weight: bold; + color: var(--secondary-magenta); + font-family: 'Orbitron', sans-serif; +} + +/* Track loaded on BOTH decks */ +.track-row.loaded-both { + border-left: 4px solid var(--primary-cyan); + border-right: 4px solid var(--secondary-magenta); + background: linear-gradient(90deg, + rgba(0, 243, 255, 0.15) 0%, + rgba(188, 19, 254, 0.15) 100%); +} + +.track-row.loaded-both .track-name { + background: linear-gradient(90deg, var(--primary-cyan), var(--secondary-magenta)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: bold; +} + +.track-row.loaded-both::before { + content: '▶ A'; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.7rem; + font-weight: bold; + color: var(--primary-cyan); + font-family: 'Orbitron', sans-serif; +} + +.track-row.loaded-both::after { + content: '▶ B'; + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + font-size: 0.7rem; + font-weight: bold; + color: var(--secondary-magenta); + font-family: 'Orbitron', sans-serif; +} + +/* Ensure track-row is positioned for absolute children */ +.track-row { + position: relative; + padding-left: 40px; + padding-right: 40px; +} \ No newline at end of file