diff --git a/script.js b/script.js
deleted file mode 100644
index ae6d866..0000000
--- a/script.js
+++ /dev/null
@@ -1,2895 +0,0 @@
-// ==========================================
-// TechDJ Pro - Core DJ Logic
-// ==========================================
-
-// Server-side audio mode (true = server processes audio, false = browser processes)
-const SERVER_SIDE_AUDIO = false;
-
-let audioCtx;
-const decks = {
- A: {
- type: 'local',
- playing: false,
- pausedAt: 0,
- duration: 0,
- localBuffer: null,
- localSource: null,
- gainNode: null,
- volumeGain: null,
- crossfaderGain: null,
- filters: {},
- cues: {},
- loopStart: null,
- loopEnd: null,
- loopActive: false,
- activeAutoLoop: null,
- waveformData: null,
- lastAnchorTime: 0,
- lastAnchorPosition: 0,
- loading: false,
- currentFile: null,
- lastSeekTime: 0
- },
- B: {
- type: 'local',
- playing: false,
- pausedAt: 0,
- duration: 0,
- localBuffer: null,
- localSource: null,
- gainNode: null,
- volumeGain: null,
- crossfaderGain: null,
- filters: {},
- cues: {},
- loopStart: null,
- loopEnd: null,
- loopActive: false,
- activeAutoLoop: null,
- waveformData: null,
- lastAnchorTime: 0,
- lastAnchorPosition: 0,
- loading: false,
- currentFile: null,
- lastSeekTime: 0
- }
-};
-
-let allSongs = [];
-const settings = {
- repeatA: false,
- repeatB: false,
- autoMix: false,
- shuffleMode: false,
- quantize: false,
- autoPlay: true,
- glowA: false,
- glowB: false,
- glowIntensity: 30
-};
-
-// Queue system for both decks
-const queues = {
- A: [],
- B: []
-};
-
-// System Initialization
-function initSystem() {
- if (audioCtx) return;
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- document.getElementById('start-overlay').style.display = 'none';
-
- // Track dragging state per deck
- const draggingState = { A: false, B: false };
-
- // Setup audio path for both decks
- ['A', 'B'].forEach(id => {
- // Create separate volume and crossfader gain nodes
- const volumeGain = audioCtx.createGain();
- const crossfaderGain = audioCtx.createGain();
- const analyser = audioCtx.createAnalyser();
- analyser.fftSize = 256;
-
- const low = audioCtx.createBiquadFilter();
- const mid = audioCtx.createBiquadFilter();
- const high = audioCtx.createBiquadFilter();
- const lp = audioCtx.createBiquadFilter();
- const hp = audioCtx.createBiquadFilter();
-
- low.type = 'lowshelf'; low.frequency.value = 320;
- mid.type = 'peaking'; mid.frequency.value = 1000; mid.Q.value = 1;
- high.type = 'highshelf'; high.frequency.value = 3200;
- lp.type = 'lowpass'; lp.frequency.value = 22050; // default fully open
- hp.type = 'highpass'; hp.frequency.value = 0; // default fully open
-
- // Connect: Filters -> Volume -> Analyser -> Crossfader -> Destination
- low.connect(mid);
- mid.connect(high);
- high.connect(lp);
- lp.connect(hp);
- hp.connect(volumeGain);
- volumeGain.connect(analyser);
- analyser.connect(crossfaderGain);
- crossfaderGain.connect(audioCtx.destination);
-
- // Set default values
- volumeGain.gain.value = 0.8; // 80% volume
- // Crossfader: A=1.0 at left (val=0), B=1.0 at right (val=100), both 0.5 at center (val=50)
- crossfaderGain.gain.value = id === 'A' ? 0.5 : 0.5; // Center position initially
-
- decks[id].volumeGain = volumeGain;
- decks[id].crossfaderGain = crossfaderGain;
- decks[id].gainNode = volumeGain; // Keep for compatibility
- decks[id].analyser = analyser;
- decks[id].filters = { low, mid, high, lp, hp };
-
- // Set canvas dimensions properly with DPI scaling
- const canvas = document.getElementById('waveform-' + id);
- const dpr = window.devicePixelRatio || 1;
- canvas.width = canvas.offsetWidth * dpr;
- canvas.height = 80 * dpr;
-
- // Scale context to match DPI (reset transform first to prevent stacking)
- const ctx = canvas.getContext('2d');
- ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset to identity matrix
- ctx.scale(dpr, dpr);
-
- // Waveform Scrubbing (Dragging to Seek)
- const handleScrub = (e) => {
- if (!decks[id].duration) return;
- const rect = canvas.getBoundingClientRect();
- const x = (e.clientX || (e.touches && e.touches[0].clientX)) - rect.left;
- const percent = Math.max(0, Math.min(1, x / rect.width));
- seekTo(id, percent * decks[id].duration);
- };
-
- canvas.addEventListener('mousedown', (e) => {
- draggingState[id] = true;
- handleScrub(e);
- });
-
- canvas.addEventListener('mousemove', (e) => {
- if (draggingState[id]) handleScrub(e);
- });
-
- canvas.addEventListener('mouseup', () => {
- draggingState[id] = false;
- });
-
- canvas.addEventListener('mouseleave', () => {
- draggingState[id] = false;
- });
-
- // Mobile Touch Support
- canvas.addEventListener('touchstart', (e) => {
- draggingState[id] = true;
- handleScrub(e);
- e.preventDefault();
- }, { passive: false });
-
- canvas.addEventListener('touchmove', (e) => {
- if (draggingState[id]) {
- handleScrub(e);
- e.preventDefault();
- }
- }, { passive: false });
-
- canvas.addEventListener('touchend', () => {
- draggingState[id] = false;
- });
-
- // Disk Scrubbing (Dragging on the Disk)
- const disk = document.querySelector(`#deck-${id} .dj-disk`);
- let isDiskDragging = false;
-
- disk.addEventListener('mousedown', (e) => {
- isDiskDragging = true;
- e.stopPropagation(); // Don't trigger the click/toggleDeck if we're dragging
- });
-
- disk.addEventListener('mousemove', (e) => {
- if (isDiskDragging && decks[id].playing) {
- // Scrub based on vertical movement
- const movement = e.movementY || 0;
- const currentPos = getCurrentPosition(id);
- const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1));
- seekTo(id, newPos);
- }
- });
-
- disk.addEventListener('mouseup', () => {
- isDiskDragging = false;
- });
-
- disk.addEventListener('mouseleave', () => {
- isDiskDragging = false;
- });
-
- disk.addEventListener('touchstart', (e) => {
- isDiskDragging = true;
- e.stopPropagation();
- }, { passive: false });
-
- disk.addEventListener('touchmove', (e) => {
- if (isDiskDragging && decks[id].playing && e.touches[0]) {
- const movement = e.touches[0].clientY - (disk._lastTouchY || e.touches[0].clientY);
- disk._lastTouchY = e.touches[0].clientY;
- const currentPos = getCurrentPosition(id);
- const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1));
- seekTo(id, newPos);
- }
- }, { passive: false });
-
- disk.addEventListener('touchend', () => {
- isDiskDragging = false;
- delete disk._lastTouchY;
- });
-
- // Handle click for toggle (only if not scrubbing)
- disk.addEventListener('click', (e) => {
- if (!isDiskDragging) {
- toggleDeck(id);
- }
- });
- });
-
- fetchLibrary();
- updateTimeDisplays();
- animateVUMeters(); // Start VU meter animation
-
- // Handle resize for DPI scaling
- window.addEventListener('resize', () => {
- ['A', 'B'].forEach(id => {
- const canvas = document.getElementById('waveform-' + id);
- if (!canvas) return;
- const dpr = window.devicePixelRatio || 1;
- canvas.width = canvas.offsetWidth * dpr;
- canvas.height = 80 * dpr;
- const ctx = canvas.getContext('2d');
- ctx.setTransform(1, 0, 0, 1, 0, 0);
- ctx.scale(dpr, dpr);
- drawWaveform(id);
- });
- });
-
- // Initialize mobile view
- if (window.innerWidth <= 1024) {
- switchTab('library');
- }
-}
-
-// VU Meter Animation with smoothing
-const vuMeterState = {
- A: { smoothedValues: [], peakValues: [] },
- B: { smoothedValues: [], peakValues: [] }
-};
-
-function animateVUMeters() {
- requestAnimationFrame(animateVUMeters);
-
- const anyPlaying = decks.A.playing || decks.B.playing;
- const isListener = window.location.port === '5001' || window.location.search.includes('listen=true');
-
- // Skip rendering if nothing is happening to save battery
- if (!anyPlaying && !isListener) return;
-
- ['A', 'B'].forEach(id => {
- const canvas = document.getElementById('viz-' + id);
- if (!canvas || !decks[id].analyser) return;
-
- const ctx = canvas.getContext('2d');
- const analyser = decks[id].analyser;
- const bufferLength = analyser.frequencyBinCount;
- const dataArray = new Uint8Array(bufferLength);
-
- analyser.getByteFrequencyData(dataArray);
-
- const width = canvas.width;
- const height = canvas.height;
- const barCount = 32;
- const barWidth = width / barCount;
-
- // Initialize smoothed values if needed
- if (!vuMeterState[id].smoothedValues.length) {
- vuMeterState[id].smoothedValues = new Array(barCount).fill(0);
- vuMeterState[id].peakValues = new Array(barCount).fill(0);
- }
-
- ctx.fillStyle = '#0a0a12';
- ctx.fillRect(0, 0, width, height);
-
- for (let i = 0; i < barCount; i++) {
- // Use logarithmic frequency distribution for better bass/mid/treble representation
- const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength);
- const rawValue = dataArray[freqIndex] / 255;
-
- // Smooth the values for less jittery animation
- const smoothingFactor = 0.7; // Higher = smoother but less responsive
- const targetValue = rawValue;
- vuMeterState[id].smoothedValues[i] =
- (vuMeterState[id].smoothedValues[i] * smoothingFactor) +
- (targetValue * (1 - smoothingFactor));
-
- const value = vuMeterState[id].smoothedValues[i];
- const barHeight = value * height;
-
- // Peak hold with decay
- if (value > vuMeterState[id].peakValues[i]) {
- vuMeterState[id].peakValues[i] = value;
- } else {
- vuMeterState[id].peakValues[i] *= 0.95; // Decay rate
- }
-
- // Draw main bar with gradient
- const hue = id === 'A' ? 180 : 280; // Cyan for A, Magenta for B
- const saturation = 100;
- const lightness = 30 + (value * 50);
-
- // Create gradient for each bar
- const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight);
- gradient.addColorStop(0, `hsl(${hue}, ${saturation}%, ${lightness}%)`);
- gradient.addColorStop(1, `hsl(${hue}, ${saturation}%, ${Math.min(lightness + 20, 80)}%)`);
-
- ctx.fillStyle = gradient;
- ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight);
-
- // Draw peak indicator
- const peakY = height - (vuMeterState[id].peakValues[i] * height);
- ctx.fillStyle = `hsl(${hue}, 100%, 70%)`;
- ctx.fillRect(i * barWidth, peakY - 2, barWidth - 2, 2);
- }
- });
-}
-
-// Play/Pause Toggle for Disk
-function toggleDeck(id) {
- if (decks[id].playing) {
- pauseDeck(id);
- } else {
- playDeck(id);
- }
-}
-
-// Mobile Tab Switching
-function switchTab(tabId) {
- const container = document.querySelector('.app-container');
- const buttons = document.querySelectorAll('.tab-btn');
-
- // Remove all tab classes
- container.classList.remove('show-library', 'show-deck-A', 'show-deck-B');
- buttons.forEach(btn => btn.classList.remove('active'));
-
- // Add active class and button state
- container.classList.add('show-' + tabId);
-
- // Find the button and activate it
- buttons.forEach(btn => {
- if (btn.getAttribute('onclick').includes(tabId)) {
- btn.classList.add('active');
- }
- });
-
- // Redraw waveforms if switching to a deck
- if (tabId.startsWith('deck')) {
- const id = tabId.split('-')[1];
- setTimeout(() => drawWaveform(id), 100);
- }
-}
-
-// Waveform Generation (Optimized for Speed)
-function generateWaveformData(buffer) {
- const rawData = buffer.getChannelData(0);
- const samples = 1000;
- const blockSize = Math.floor(rawData.length / samples);
- // Only process a subset of samples for huge speed gain
- const step = Math.max(1, Math.floor(blockSize / 20));
- const filteredData = [];
-
- for (let i = 0; i < samples; i++) {
- let blockStart = blockSize * i;
- let sum = 0;
- let count = 0;
- for (let j = 0; j < blockSize; j += step) {
- const index = blockStart + j;
- // Bounds check to prevent NaN values
- if (index < rawData.length) {
- sum += Math.abs(rawData[index]);
- count++;
- }
- }
- filteredData.push(sum / (count || 1));
- }
- return filteredData;
-}
-
-function drawWaveform(id) {
- const canvas = document.getElementById('waveform-' + id);
- if (!canvas) return; // Null check
- const ctx = canvas.getContext('2d');
- const data = decks[id].waveformData;
- if (!data) return;
-
- ctx.clearRect(0, 0, canvas.width, canvas.height);
- const width = canvas.width;
- const height = canvas.height;
- const barWidth = width / data.length;
-
- data.forEach((val, i) => {
- const h = val * height * 5;
- ctx.fillStyle = id === 'A' ? '#00f3ff' : '#bc13fe';
- ctx.fillRect(i * barWidth, (height - h) / 2, barWidth, h);
- });
-
- // Draw Cues
- if (decks[id].duration > 0) {
- Object.values(decks[id].cues).forEach(time => {
- const x = (time / decks[id].duration) * width;
- ctx.strokeStyle = '#fff';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.moveTo(x, 0);
- ctx.lineTo(x, height);
- ctx.stroke();
- });
- }
-
- // Draw Loop Markers
- if (decks[id].loopStart !== null && decks[id].duration > 0) {
- const xIn = (decks[id].loopStart / decks[id].duration) * width;
- ctx.strokeStyle = '#ffbb00';
- ctx.lineWidth = 2;
- ctx.setLineDash([5, 3]);
- ctx.beginPath();
- ctx.moveTo(xIn, 0);
- ctx.lineTo(xIn, height);
- ctx.stroke();
-
- if (decks[id].loopActive && decks[id].loopEnd !== null) {
- const xOut = (decks[id].loopEnd / decks[id].duration) * width;
- ctx.strokeStyle = '#ffaa33';
- ctx.beginPath();
- ctx.moveTo(xOut, 0);
- ctx.lineTo(xOut, height);
- ctx.stroke();
-
- // Shade loop area
- ctx.fillStyle = 'rgba(255, 187, 0, 0.15)';
- ctx.fillRect(xIn, 0, xOut - xIn, height);
- }
- ctx.setLineDash([]);
- }
-}
-
-// BPM Detection (Optimized: Only check middle 60 seconds for speed)
-function detectBPM(buffer) {
- const sampleRate = buffer.sampleRate;
- const duration = buffer.duration;
-
- // Pick a 60s window in the middle
- const startOffset = Math.max(0, Math.floor((duration / 2 - 30) * sampleRate));
- const endOffset = Math.min(buffer.length, Math.floor((duration / 2 + 30) * sampleRate));
- const data = buffer.getChannelData(0).slice(startOffset, endOffset);
-
- const bpmRange = [60, 180];
- const windowSize = Math.floor(sampleRate * 60 / bpmRange[1]);
-
- let peaks = 0;
- let threshold = 0;
- for (let i = 0; i < data.length; i += windowSize) {
- const slice = data.slice(i, i + windowSize);
- const avg = slice.reduce((a, b) => a + Math.abs(b), 0) / slice.length;
- if (avg > threshold) {
- peaks++;
- threshold = avg * 0.8;
- }
- }
-
- const windowDuration = data.length / sampleRate;
- const bpm = Math.round((peaks / windowDuration) * 60);
- return bpm > bpmRange[0] && bpm < bpmRange[1] ? bpm : 0;
-}
-
-// Time Display Updates
-function updateTimeDisplays() {
- requestAnimationFrame(updateTimeDisplays);
-
- // Only update if at least one deck is playing
- const anyPlaying = decks.A.playing || decks.B.playing;
- if (!anyPlaying) return;
-
- ['A', 'B'].forEach(id => {
- if (decks[id].playing && decks[id].localBuffer) {
- const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
- const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
- let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
-
- // Handle Looping wrapping for UI
- if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
- const loopLen = decks[id].loopEnd - decks[id].loopStart;
- if (current >= decks[id].loopEnd && loopLen > 0) {
- current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart;
- }
- } else if (settings[`repeat${id}`]) {
- // Full song repeat wrapping for UI
- current = current % decks[id].duration;
- } else {
- current = Math.min(current, decks[id].duration);
- }
-
- document.getElementById('time-current-' + id).textContent = formatTime(current);
-
- // Update playhead
- const progress = (current / decks[id].duration) * 100;
- const playhead = document.getElementById('playhead-' + id);
- if (playhead) playhead.style.left = progress + '%';
- }
- });
-}
-
-function getCurrentPosition(id) {
- if (!decks[id].playing) return decks[id].pausedAt;
- if (!audioCtx) return decks[id].pausedAt;
-
- const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
- const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
- let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
-
- // Handle wrapping for correct position return
- if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
- const loopLen = decks[id].loopEnd - decks[id].loopStart;
- if (pos >= decks[id].loopEnd && loopLen > 0) {
- pos = ((pos - decks[id].loopStart) % loopLen) + decks[id].loopStart;
- }
- } else if (settings[`repeat${id}`] && decks[id].duration > 0) {
- pos = pos % decks[id].duration;
- } else {
- pos = Math.min(decks[id].duration, pos);
- }
-
- return pos;
-}
-
-function formatTime(seconds) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.floor(seconds % 60);
- return `${mins}:${secs.toString().padStart(2, '0')}`;
-}
-
-// Playback Logic
-function playDeck(id) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_play', { deck: id });
- decks[id].playing = true;
- const deckEl = document.getElementById('deck-' + id);
- if (deckEl) deckEl.classList.add('playing');
- console.log(`[Deck ${id}] Play command sent to server`);
- return;
- }
-
- // Browser-side audio mode (original code)
- if (decks[id].type === 'local' && decks[id].localBuffer) {
- if (decks[id].playing) return;
-
- try {
- console.log(`[Deck ${id}] Starting playback from ${decks[id].pausedAt}s`);
- decks[id].playing = true;
- seekTo(id, decks[id].pausedAt);
-
- const deckEl = document.getElementById('deck-' + id);
- if (deckEl) deckEl.classList.add('playing');
-
- if (audioCtx.state === 'suspended') {
- console.log(`[Deck ${id}] Resuming suspended AudioContext`);
- audioCtx.resume();
- }
-
- // Auto-start broadcast if enabled and not already broadcasting
- if (autoStartStream && !isBroadcasting && audioCtx) {
- setTimeout(() => {
- if (!socket) initSocket();
- setTimeout(() => startBroadcast(), 500);
- }, 100);
- }
- } catch (error) {
- console.error(`[Deck ${id}] Playback error:`, error);
- decks[id].playing = false;
- const deckEl = document.getElementById('deck-' + id);
- if (deckEl) deckEl.classList.remove('playing');
- alert(`Playback error: ${error.message}`);
- }
- } else {
- console.warn(`[Deck ${id}] Cannot play - no buffer loaded`);
- }
-}
-
-function pauseDeck(id) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_pause', { deck: id });
- decks[id].playing = false;
- document.getElementById('deck-' + id).classList.remove('playing');
- console.log(`[Deck ${id}] Pause command sent to server`);
- return;
- }
-
- // Browser-side audio mode (original code)
- if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
- if (!audioCtx) {
- console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialized`);
- decks[id].playing = false;
- } else {
- const playbackRate = decks[id].localSource.playbackRate.value;
- const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
- decks[id].pausedAt = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
-
- try {
- decks[id].localSource.stop();
- decks[id].localSource.onended = null;
- } catch (e) { }
-
- decks[id].localSource = null;
- decks[id].playing = false;
- }
- }
- document.getElementById('deck-' + id).classList.remove('playing');
-}
-
-function seekTo(id, time) {
- // Update local state and timestamp for seek protection
- decks[id].lastSeekTime = Date.now();
-
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_seek', { deck: id, position: time });
-
- // Update local state immediately for UI responsiveness
- decks[id].lastAnchorPosition = time;
- if (!decks[id].playing) {
- decks[id].pausedAt = time;
- }
-
- // Update UI immediately (Optimistic UI)
- const progress = (time / decks[id].duration) * 100;
- const playhead = document.getElementById('playhead-' + id);
- if (playhead) playhead.style.left = progress + '%';
-
- const timer = document.getElementById('time-current-' + id);
- if (timer) timer.textContent = formatTime(time);
-
- return;
- }
-
- if (!decks[id].localBuffer) {
- console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
- return;
- }
-
- try {
- if (decks[id].playing) {
- if (decks[id].localSource) {
- try {
- decks[id].localSource.stop();
- decks[id].localSource.onended = null;
- } catch (e) { }
- }
-
- const src = audioCtx.createBufferSource();
- src.buffer = decks[id].localBuffer;
-
- if (decks[id].loopActive) {
- src.loop = true;
- src.loopStart = decks[id].loopStart || 0;
- src.loopEnd = decks[id].loopEnd || decks[id].duration;
- }
-
- src.connect(decks[id].filters.low);
- const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
- const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
- src.playbackRate.value = speed;
-
- decks[id].localSource = src;
- decks[id].lastAnchorTime = audioCtx.currentTime;
- decks[id].lastAnchorPosition = time;
-
- // Add error handler for the source
- src.onended = () => {
- console.log(`[Deck ${id}] Playback ended naturally`);
- };
-
- src.start(0, time);
- console.log(`[Deck ${id}] Playback started at ${time}s with speed ${speed}x`);
- } else {
- decks[id].pausedAt = time;
- decks[id].lastAnchorPosition = time;
- if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
-
- // Update UI immediately (Manual Position)
- const timer = document.getElementById('time-current-' + id);
- if (timer) timer.textContent = formatTime(time);
- const progress = (time / decks[id].duration) * 100;
- const playhead = document.getElementById('playhead-' + id);
- if (playhead) playhead.style.left = progress + '%';
- }
- } catch (error) {
- console.error(`[Deck ${id}] SeekTo error:`, error);
- // Reset playing state on error
- decks[id].playing = false;
- const deckEl = document.getElementById('deck-' + id);
- if (deckEl) deckEl.classList.remove('playing');
- document.body.classList.remove('playing-' + id);
- }
-}
-
-function changeSpeed(id, val) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) });
- return;
- }
-
- // Browser-side audio mode
- if (!audioCtx || !decks[id].localSource) return;
-
- const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
- decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value;
- decks[id].lastAnchorTime = audioCtx.currentTime;
-
- decks[id].localSource.playbackRate.value = parseFloat(val);
-}
-
-function changeVolume(id, val) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_set_volume', { deck: id, volume: val / 100 });
- return;
- }
-
- // Browser-side audio mode
- if (decks[id].volumeGain) {
- decks[id].volumeGain.gain.value = val / 100;
- }
-}
-
-function changeEQ(id, band, val) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) });
- return;
- }
-
- // Browser-side audio mode
- if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
-}
-
-function changeFilter(id, type, val) {
- if (!audioCtx || !decks[id].filters) return;
- const filter = type === 'lowpass' ? decks[id].filters.lp : decks[id].filters.hp;
- if (!filter) return;
-
- // Use exponential scaling for filter frequency (more musical)
- if (type === 'lowpass') {
- // Low-pass: 0 = low freq (muffled), 100 = high freq (open)
- const freq = 20 * Math.pow(22050 / 20, val / 100);
- filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05);
- } else {
- // High-pass: 0 = low freq (open), 100 = high freq (thin)
- const freq = 20 * Math.pow(22050 / 20, val / 100);
- filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05);
- }
-}
-
-// Hot Cue Functionality
-function handleCue(id, cueNum) {
- if (!decks[id].localBuffer) {
- console.warn(`[Deck ${id}] No track loaded - cannot set cue`);
- return;
- }
-
- // If cue exists, jump to it and start playing
- if (decks[id].cues[cueNum] !== undefined) {
- const cueTime = decks[id].cues[cueNum];
- console.log(`[Deck ${id}] Jumping to cue ${cueNum} at ${cueTime.toFixed(2)}s`);
-
- // Always seek to cue point
- seekTo(id, cueTime);
-
- // Auto-play when triggering cue (like real DJ equipment)
- if (!decks[id].playing) {
- playDeck(id);
- }
-
- // Visual feedback
- const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
- if (cueBtn) {
- cueBtn.classList.add('cue-triggered');
- setTimeout(() => cueBtn.classList.remove('cue-triggered'), 200);
- }
- } else {
- // Set new cue at current position
- const currentTime = getCurrentPosition(id);
- decks[id].cues[cueNum] = currentTime;
-
- console.log(`[Deck ${id}] Set cue ${cueNum} at ${currentTime.toFixed(2)}s`);
-
- const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
- if (cueBtn) {
- cueBtn.classList.add('cue-set');
- // Flash to show it was set
- cueBtn.style.animation = 'none';
- setTimeout(() => {
- cueBtn.style.animation = 'flash 0.3s ease';
- }, 10);
- }
-
- drawWaveform(id);
- }
-}
-
-function clearCue(id, cueNum) {
- delete decks[id].cues[cueNum];
- const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
- if (cueBtn) cueBtn.classList.remove('cue-set');
- drawWaveform(id);
-}
-
-function setLoop(id, action) {
- if (!decks[id].localBuffer) {
- console.warn(`[Deck ${id}] No track loaded - cannot set loop`);
- return;
- }
-
- const currentTime = getCurrentPosition(id);
-
- if (action === 'in') {
- // Set loop start point
- decks[id].loopStart = currentTime;
- decks[id].loopEnd = null; // Clear loop end
- decks[id].loopActive = false; // Not active until OUT is set
-
- console.log(`[Deck ${id}] Loop IN set at ${currentTime.toFixed(2)}s`);
-
- // Visual feedback
- const loopInBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(1)`);
- if (loopInBtn) {
- loopInBtn.style.background = 'rgba(255, 187, 0, 0.3)';
- loopInBtn.style.borderColor = '#ffbb00';
- }
-
- } else if (action === 'out') {
- // Set loop end point and activate
- if (decks[id].loopStart === null) {
- // If no loop start, set it to beginning
- decks[id].loopStart = 0;
- console.log(`[Deck ${id}] Auto-set loop IN at 0s`);
- }
-
- if (currentTime > decks[id].loopStart) {
- decks[id].loopEnd = currentTime;
- decks[id].loopActive = true;
-
- const loopLength = decks[id].loopEnd - decks[id].loopStart;
- console.log(`[Deck ${id}] Loop OUT set at ${currentTime.toFixed(2)}s (${loopLength.toFixed(2)}s loop)`);
-
- // Visual feedback
- const loopOutBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(2)`);
- if (loopOutBtn) {
- loopOutBtn.style.background = 'rgba(255, 187, 0, 0.3)';
- loopOutBtn.style.borderColor = '#ffbb00';
- }
-
- // Restart playback to apply loop immediately
- if (decks[id].playing) {
- const currentPos = getCurrentPosition(id);
- seekTo(id, currentPos);
- }
- } else {
- console.warn(`[Deck ${id}] Loop OUT must be after Loop IN`);
- alert('Loop OUT must be after Loop IN!');
- }
-
- } else if (action === 'exit') {
- // Get current position BEFORE clearing loop state
- const currentPos = getCurrentPosition(id);
-
- // Exit/clear loop
- decks[id].loopActive = false;
- decks[id].loopStart = null;
- decks[id].loopEnd = null;
-
- console.log(`[Deck ${id}] Loop cleared at position ${currentPos.toFixed(2)}s`);
-
- // Clear visual feedback
- const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`);
- loopBtns.forEach(btn => {
- btn.style.background = '';
- btn.style.borderColor = '';
- });
-
- // Restart playback from current loop position to continue smoothly
- if (decks[id].playing) {
- seekTo(id, currentPos);
- } else {
- // If paused, just update the paused position
- decks[id].pausedAt = currentPos;
- }
- }
-
- drawWaveform(id);
-}
-
-// Auto-Loop with Beat Lengths (you.dj style)
-function setAutoLoop(id, beats) {
- if (!decks[id].localBuffer) {
- console.warn(`[Deck ${id}] No track loaded - cannot set auto-loop`);
- return;
- }
-
- // Check if clicking the same active loop - if so, disable it
- if (decks[id].activeAutoLoop === beats && decks[id].loopActive) {
- // Get current position WITHIN the loop before disabling
- const currentPos = getCurrentPosition(id);
-
- // Disable loop
- decks[id].loopActive = false;
- decks[id].loopStart = null;
- decks[id].loopEnd = null;
- decks[id].activeAutoLoop = null;
-
- console.log(`[Deck ${id}] Auto-loop ${beats} beats disabled at position ${currentPos.toFixed(2)}s`);
-
- // Update UI
- updateAutoLoopButtons(id);
- drawWaveform(id);
-
- // Restart playback from current loop position to continue smoothly
- if (decks[id].playing) {
- seekTo(id, currentPos);
- } else {
- // If paused, just update the paused position
- decks[id].pausedAt = currentPos;
- }
- return;
- }
-
- // Check if BPM is detected
- if (!decks[id].bpm || decks[id].bpm === 0) {
- alert(`Cannot set auto-loop: BPM not detected for Deck ${id}.\nPlease use manual loop controls (LOOP IN/OUT).`);
- console.warn(`[Deck ${id}] BPM not detected - cannot calculate auto-loop`);
- return;
- }
-
- const currentTime = getCurrentPosition(id);
- const bpm = decks[id].bpm;
-
- // Calculate loop length in seconds: (beats / BPM) * 60
- const loopLength = (beats / bpm) * 60;
-
- // Set loop points
- decks[id].loopStart = currentTime;
- decks[id].loopEnd = currentTime + loopLength;
- decks[id].loopActive = true;
- decks[id].activeAutoLoop = beats;
-
- console.log(`[Deck ${id}] Auto-loop set: ${beats} beats = ${loopLength.toFixed(3)}s at ${bpm} BPM`);
-
- // Update UI
- updateAutoLoopButtons(id);
- drawWaveform(id);
-
- // Restart playback to apply loop immediately
- if (decks[id].playing) {
- seekTo(id, currentTime);
- }
-}
-
-// Update auto-loop button visual states
-function updateAutoLoopButtons(id) {
- const buttons = document.querySelectorAll(`#deck-${id} .auto-loop-btn`);
- buttons.forEach(btn => {
- const btnBeats = parseFloat(btn.dataset.beats);
- if (decks[id].activeAutoLoop === btnBeats && decks[id].loopActive) {
- btn.classList.add('active');
- } else {
- btn.classList.remove('active');
- }
- });
-}
-
-function pitchBend(id, amount) {
- if (!audioCtx || !decks[id].localSource) return;
-
- const slider = document.querySelector(`#deck-${id} .speed-slider`);
- const baseSpeed = parseFloat(slider.value);
-
- // Re-anchor
- const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
- decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value;
- decks[id].lastAnchorTime = audioCtx.currentTime;
-
- if (amount !== 0) {
- decks[id].localSource.playbackRate.value = baseSpeed + amount;
- } else {
- decks[id].localSource.playbackRate.value = baseSpeed;
- }
-}
-
-function syncDecks(id) {
- const otherDeck = id === 'A' ? 'B' : 'A';
- if (!decks[id].bpm || !decks[otherDeck].bpm) return;
-
- // Calculate ratio to make other deck match this deck
- const ratio = decks[id].bpm / decks[otherDeck].bpm;
- const slider = document.querySelector(`#deck-${otherDeck} .speed-slider`);
- if (slider) slider.value = ratio;
- changeSpeed(otherDeck, ratio);
-}
-
-function updateCrossfader(val) {
- // Server-side audio mode
- if (SERVER_SIDE_AUDIO) {
- if (!socket) initSocket();
- socket.emit('audio_set_crossfader', { value: parseInt(val) });
- return;
- }
-
- // Browser-side audio mode
- const volA = (100 - val) / 100;
- const volB = val / 100;
- if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
- if (decks.B.crossfaderGain) decks.B.crossfaderGain.gain.value = volB;
-}
-
-// Library Functions
-async function fetchLibrary() {
- try {
- const res = await fetch('library.json?t=' + new Date().getTime());
- allSongs = await res.json();
- renderLibrary(allSongs);
- } catch (e) {
- console.error("Library fetch failed", e);
- }
-}
-
-function renderLibrary(songs) {
- const list = document.getElementById('library-list');
- list.innerHTML = '';
- if (songs.length === 0) {
- list.innerHTML = '
Library empty. Download some music!
';
- return;
- }
- songs.forEach(t => {
- const item = document.createElement('div');
- item.className = 'track-row';
-
- const trackName = document.createElement('span');
- trackName.className = 'track-name';
- trackName.textContent = t.title; // Safe text assignment
-
- const loadActions = document.createElement('div');
- loadActions.className = 'load-actions';
-
- // LOAD buttons
- const btnA = document.createElement('button');
- btnA.className = 'load-btn btn-a';
- btnA.textContent = 'LOAD A';
- btnA.addEventListener('click', () => loadFromServer('A', t.file, t.title));
-
- const btnB = document.createElement('button');
- btnB.className = 'load-btn btn-b';
- btnB.textContent = 'LOAD B';
- btnB.addEventListener('click', () => loadFromServer('B', t.file, t.title));
-
- // QUEUE buttons
- const queueA = document.createElement('button');
- queueA.className = 'load-btn queue-btn-a';
- queueA.textContent = 'đ Q-A';
- queueA.title = 'Add to Queue A';
- queueA.addEventListener('click', () => addToQueue('A', t.file, t.title));
-
- const queueB = document.createElement('button');
- queueB.className = 'load-btn queue-btn-b';
- queueB.textContent = 'đ Q-B';
- queueB.title = 'Add to Queue B';
- queueB.addEventListener('click', () => addToQueue('B', t.file, t.title));
-
- loadActions.appendChild(btnA);
- loadActions.appendChild(queueA);
- loadActions.appendChild(btnB);
- loadActions.appendChild(queueB);
- item.appendChild(trackName);
- item.appendChild(loadActions);
-
- // Add data attribute for highlighting
- item.dataset.file = t.file;
-
- list.appendChild(item);
- });
-
- // Update highlighting after rendering
- updateLibraryHighlighting();
-}
-
-// Update library highlighting to show which tracks are loaded
-function updateLibraryHighlighting() {
- const trackRows = document.querySelectorAll('.track-row');
-
- trackRows.forEach(row => {
- const file = row.dataset.file;
-
- // Remove all deck classes
- row.classList.remove('loaded-deck-a', 'loaded-deck-b', 'loaded-both');
-
- const onDeckA = decks.A.currentFile && decks.A.currentFile.includes(file);
- const onDeckB = decks.B.currentFile && decks.B.currentFile.includes(file);
-
- if (onDeckA && onDeckB) {
- row.classList.add('loaded-both');
- } else if (onDeckA) {
- row.classList.add('loaded-deck-a');
- } else if (onDeckB) {
- row.classList.add('loaded-deck-b');
- }
- });
-}
-
-// Utility function no longer needed but kept for future use
-function escapeHtml(text) {
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
-}
-
-function filterLibrary() {
- const query = document.getElementById('lib-search').value.toLowerCase();
- const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query));
- renderLibrary(filtered);
-}
-
-function refreshLibrary() {
- fetchLibrary();
-}
-
-// YouTube Search Functions
-let youtubeSearchTimeout = null;
-
-function handleYouTubeSearch(query) {
- // Debounce search - wait 300ms after user stops typing
- clearTimeout(youtubeSearchTimeout);
-
- if (!query || query.trim().length < 2) {
- document.getElementById('youtube-results').innerHTML = '';
- return;
- }
-
- youtubeSearchTimeout = setTimeout(() => {
- searchYouTube(query.trim());
- }, 300);
-}
-
-async function searchYouTube(query) {
- const resultsDiv = document.getElementById('youtube-results');
- resultsDiv.innerHTML = 'đ Searching YouTube...
';
-
- try {
- const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`);
- const data = await response.json();
-
- if (!data.success) {
- resultsDiv.innerHTML = `â ${data.error}
`;
- return;
- }
-
- displayYouTubeResults(data.results);
- } catch (error) {
- console.error('YouTube search error:', error);
- resultsDiv.innerHTML = 'â Search failed. Check console.
';
- }
-}
-
-function displayYouTubeResults(results) {
- const resultsDiv = document.getElementById('youtube-results');
-
- if (results.length === 0) {
- resultsDiv.innerHTML = 'No results found
';
- return;
- }
-
- resultsDiv.innerHTML = '';
-
- results.forEach(result => {
- const resultCard = document.createElement('div');
- resultCard.className = 'youtube-result-card';
-
- resultCard.innerHTML = `
-
-
-
${result.title}
-
${result.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 = `
-
-
-
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.');