techdj/script.js

3074 lines
108 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==========================================
// 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');
const sections = document.querySelectorAll('.library-section, .deck');
// Remove all tab and active classes
container.classList.remove('show-library', 'show-deck-A', 'show-deck-B');
buttons.forEach(btn => btn.classList.remove('active'));
sections.forEach(sec => sec.classList.remove('active'));
// Normalize IDs (deck-A -> deckA for class)
const normalizedId = tabId.replace('-', '');
// Add active class and button state
container.classList.add('show-' + tabId);
// Activate target section
const targetSection = document.getElementById(tabId) || document.querySelector('.' + tabId + '-section');
if (targetSection) targetSection.classList.add('active');
// Find the button and activate it
buttons.forEach(btn => {
const onClickAttr = btn.getAttribute('onclick');
if (onClickAttr && onClickAttr.includes(tabId)) {
btn.classList.add('active');
}
});
// Redraw waveforms if switching to a deck
if (tabId.startsWith('deck')) {
const id = tabId.includes('-') ? tabId.split('-')[1] : (tabId.includes('A') ? 'A' : 'B');
setTimeout(() => drawWaveform(id), 100);
}
// Haptic feedback
vibrate(10);
}
function dismissLandscapePrompt() {
const prompt = document.getElementById('landscape-prompt');
if (prompt) prompt.classList.add('dismissed');
vibrate(10);
}
// Mobile Haptic Helper
function vibrate(ms) {
if (navigator.vibrate) {
navigator.vibrate(ms);
}
}
// Fullscreen Toggle
function toggleFullScreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
});
document.getElementById('fullscreen-toggle').classList.add('active');
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
document.getElementById('fullscreen-toggle').classList.remove('active');
}
vibrate(20);
}
// Touch Swiping Logic
let touchStartX = 0;
let touchEndX = 0;
document.addEventListener('touchstart', e => {
touchStartX = e.changedTouches[0].screenX;
}, false);
document.addEventListener('touchend', e => {
touchEndX = e.changedTouches[0].screenX;
handleSwipe();
}, false);
function handleSwipe() {
const threshold = 100;
const swipeDistance = touchEndX - touchStartX;
// Get current tab
const activeBtn = document.querySelector('.tab-btn.active');
if (!activeBtn) return;
const tabs = ['library', 'deck-A', 'deck-B'];
let currentIndex = -1;
if (activeBtn.getAttribute('onclick').includes('library')) currentIndex = 0;
else if (activeBtn.getAttribute('onclick').includes('deck-A')) currentIndex = 1;
else if (activeBtn.getAttribute('onclick').includes('deck-B')) currentIndex = 2;
if (currentIndex === -1) return;
if (swipeDistance > threshold) {
// Swipe Right (Go Left)
if (currentIndex > 0) switchTab(tabs[currentIndex - 1]);
} else if (swipeDistance < -threshold) {
// Swipe Left (Go Right)
if (currentIndex < tabs.length - 1) switchTab(tabs[currentIndex + 1]);
}
}
// Waveform Generation (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) {
vibrate(15);
// 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) {
vibrate(15);
// 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) {
vibrate(15);
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set cue`);
return;
}
// If cue exists, jump to it and start playing
if (decks[id].cues[cueNum] !== undefined) {
const cueTime = decks[id].cues[cueNum];
console.log(`[Deck ${id}] Jumping to cue ${cueNum} at ${cueTime.toFixed(2)}s`);
// Always seek to cue point
seekTo(id, cueTime);
// Auto-play when triggering cue (like real DJ equipment)
if (!decks[id].playing) {
playDeck(id);
}
// Visual feedback
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) {
cueBtn.classList.add('cue-triggered');
setTimeout(() => cueBtn.classList.remove('cue-triggered'), 200);
}
} else {
// Set new cue at current position
const currentTime = getCurrentPosition(id);
decks[id].cues[cueNum] = currentTime;
console.log(`[Deck ${id}] Set cue ${cueNum} at ${currentTime.toFixed(2)}s`);
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) {
cueBtn.classList.add('cue-set');
// Flash to show it was set
cueBtn.style.animation = 'none';
setTimeout(() => {
cueBtn.style.animation = 'flash 0.3s ease';
}, 10);
}
drawWaveform(id);
}
}
function clearCue(id, cueNum) {
delete decks[id].cues[cueNum];
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) cueBtn.classList.remove('cue-set');
drawWaveform(id);
}
function setLoop(id, action) {
vibrate(15);
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set loop`);
return;
}
const currentTime = getCurrentPosition(id);
if (action === 'in') {
// Set loop start point
decks[id].loopStart = currentTime;
decks[id].loopEnd = null; // Clear loop end
decks[id].loopActive = false; // Not active until OUT is set
console.log(`[Deck ${id}] Loop IN set at ${currentTime.toFixed(2)}s`);
// Visual feedback
const loopInBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(1)`);
if (loopInBtn) {
loopInBtn.style.background = 'rgba(255, 187, 0, 0.3)';
loopInBtn.style.borderColor = '#ffbb00';
}
} else if (action === 'out') {
// Set loop end point and activate
if (decks[id].loopStart === null) {
// If no loop start, set it to beginning
decks[id].loopStart = 0;
console.log(`[Deck ${id}] Auto-set loop IN at 0s`);
}
if (currentTime > decks[id].loopStart) {
decks[id].loopEnd = currentTime;
decks[id].loopActive = true;
const loopLength = decks[id].loopEnd - decks[id].loopStart;
console.log(`[Deck ${id}] Loop OUT set at ${currentTime.toFixed(2)}s (${loopLength.toFixed(2)}s loop)`);
// Visual feedback
const loopOutBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(2)`);
if (loopOutBtn) {
loopOutBtn.style.background = 'rgba(255, 187, 0, 0.3)';
loopOutBtn.style.borderColor = '#ffbb00';
}
// Restart playback to apply loop immediately
if (decks[id].playing) {
const currentPos = getCurrentPosition(id);
seekTo(id, currentPos);
}
} else {
console.warn(`[Deck ${id}] Loop OUT must be after Loop IN`);
alert('Loop OUT must be after Loop IN!');
}
} else if (action === 'exit') {
// Get current position BEFORE clearing loop state
const currentPos = getCurrentPosition(id);
// Exit/clear loop
decks[id].loopActive = false;
decks[id].loopStart = null;
decks[id].loopEnd = null;
console.log(`[Deck ${id}] Loop cleared at position ${currentPos.toFixed(2)}s`);
// Clear visual feedback
const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`);
loopBtns.forEach(btn => {
btn.style.background = '';
btn.style.borderColor = '';
});
// Restart playback from current loop position to continue smoothly
if (decks[id].playing) {
seekTo(id, currentPos);
} else {
// If paused, just update the paused position
decks[id].pausedAt = currentPos;
}
}
drawWaveform(id);
}
// Auto-Loop with Beat Lengths (you.dj style)
function setAutoLoop(id, beats) {
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set auto-loop`);
return;
}
// Check if clicking the same active loop - if so, disable it
if (decks[id].activeAutoLoop === beats && decks[id].loopActive) {
// Get current position WITHIN the loop before disabling
const currentPos = getCurrentPosition(id);
// Disable loop
decks[id].loopActive = false;
decks[id].loopStart = null;
decks[id].loopEnd = null;
decks[id].activeAutoLoop = null;
console.log(`[Deck ${id}] Auto-loop ${beats} beats disabled at position ${currentPos.toFixed(2)}s`);
// Update UI
updateAutoLoopButtons(id);
drawWaveform(id);
// Restart playback from current loop position to continue smoothly
if (decks[id].playing) {
seekTo(id, currentPos);
} else {
// If paused, just update the paused position
decks[id].pausedAt = currentPos;
}
return;
}
// Check if BPM is detected
if (!decks[id].bpm || decks[id].bpm === 0) {
alert(`Cannot set auto-loop: BPM not detected for Deck ${id}.\nPlease use manual loop controls (LOOP IN/OUT).`);
console.warn(`[Deck ${id}] BPM not detected - cannot calculate auto-loop`);
return;
}
const currentTime = getCurrentPosition(id);
const bpm = decks[id].bpm;
// Calculate loop length in seconds: (beats / BPM) * 60
const loopLength = (beats / bpm) * 60;
// Set loop points
decks[id].loopStart = currentTime;
decks[id].loopEnd = currentTime + loopLength;
decks[id].loopActive = true;
decks[id].activeAutoLoop = beats;
console.log(`[Deck ${id}] Auto-loop set: ${beats} beats = ${loopLength.toFixed(3)}s at ${bpm} BPM`);
// Update UI
updateAutoLoopButtons(id);
drawWaveform(id);
// Restart playback to apply loop immediately
if (decks[id].playing) {
seekTo(id, currentTime);
}
}
// Update auto-loop button visual states
function updateAutoLoopButtons(id) {
const buttons = document.querySelectorAll(`#deck-${id} .auto-loop-btn`);
buttons.forEach(btn => {
const btnBeats = parseFloat(btn.dataset.beats);
if (decks[id].activeAutoLoop === btnBeats && decks[id].loopActive) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function pitchBend(id, amount) {
if (!audioCtx || !decks[id].localSource) return;
const slider = document.querySelector(`#deck-${id} .speed-slider`);
const baseSpeed = parseFloat(slider.value);
// Re-anchor
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value;
decks[id].lastAnchorTime = audioCtx.currentTime;
if (amount !== 0) {
decks[id].localSource.playbackRate.value = baseSpeed + amount;
} else {
decks[id].localSource.playbackRate.value = baseSpeed;
}
}
function syncDecks(id) {
const otherDeck = id === 'A' ? 'B' : 'A';
if (!decks[id].bpm || !decks[otherDeck].bpm) return;
// Calculate ratio to make other deck match this deck
const ratio = decks[id].bpm / decks[otherDeck].bpm;
const slider = document.querySelector(`#deck-${otherDeck} .speed-slider`);
if (slider) slider.value = ratio;
changeSpeed(otherDeck, ratio);
}
function updateCrossfader(val) {
// 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 = '<div style="padding:10px; opacity:0.5;">Library empty. Download some music!</div>';
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();
}
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);
}
}
// Settings
function toggleSettings() {
const p = document.getElementById('settings-panel');
p.classList.toggle('active');
}
// File Upload
async function handleFileUpload(event) {
const files = event.target.files;
if (!files || files.length === 0) return;
console.log(`📁 Uploading ${files.length} file(s)...`);
for (let file of files) {
if (!file.type.match('audio/mpeg') && !file.name.endsWith('.mp3')) {
alert(`${file.name} is not an MP3 file`);
continue;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
console.log(`✅ Uploaded: ${file.name}`);
} else {
console.error(`❌ Upload failed: ${result.error}`);
alert(`Failed to upload ${file.name}: ${result.error}`);
}
} catch (error) {
console.error(`❌ Upload error: ${error}`);
alert(`Error uploading ${file.name}`);
}
}
// Refresh library
console.log('🔄 Refreshing library...');
await loadLibrary();
alert(`${files.length} file(s) uploaded successfully!`);
// Clear the input so the same file can be uploaded again if needed
event.target.value = '';
}
function toggleRepeat(id, val) { settings[`repeat${id}`] = val; }
function toggleAutoMix(val) { settings.autoMix = val; }
function toggleShuffle(val) { settings.shuffleMode = val; }
function toggleQuantize(val) { settings.quantize = val; }
function toggleAutoPlay(val) { settings.autoPlay = val; }
function updateManualGlow(id, val) {
settings[`glow${id}`] = val;
if (val) {
document.body.classList.add(`playing-${id}`);
} else {
document.body.classList.remove(`playing-${id}`);
}
}
function updateGlowIntensity(val) {
settings.glowIntensity = parseInt(val);
const opacity = settings.glowIntensity / 100;
const spread = (settings.glowIntensity / 100) * 80;
// Dynamically update CSS variables for the glow
document.documentElement.style.setProperty('--glow-opacity', opacity);
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
}
// Dismiss landscape prompt
function dismissLandscapePrompt() {
const prompt = document.getElementById('landscape-prompt');
if (prompt) {
prompt.classList.add('dismissed');
// Store preference in localStorage
localStorage.setItem('landscapePromptDismissed', 'true');
}
}
// Check if prompt was previously dismissed
window.addEventListener('DOMContentLoaded', () => {
const wasDismissed = localStorage.getItem('landscapePromptDismissed');
if (wasDismissed === 'true') {
const prompt = document.getElementById('landscape-prompt');
if (prompt) prompt.classList.add('dismissed');
}
// Initialize glow intensity
updateGlowIntensity(settings.glowIntensity);
const glowAToggle = document.getElementById('glow-A');
if (glowAToggle) glowAToggle.checked = settings.glowA;
const glowBToggle = document.getElementById('glow-B');
if (glowBToggle) glowBToggle.checked = settings.glowB;
const intensitySlider = document.getElementById('glow-intensity');
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
// Apply initial glow state
updateManualGlow('A', settings.glowA);
updateManualGlow('B', settings.glowB);
// Check if this is the listener page based on hostname or port
const isListenerPort = window.location.port === '5001';
const isListenerHostname = window.location.hostname.startsWith('music.') || window.location.hostname.startsWith('listen.');
const urlParams = new URLSearchParams(window.location.search);
if (isListenerPort || isListenerHostname || urlParams.get('listen') === 'true') {
initListenerMode();
}
if (!isListenerPort && !isListenerHostname) {
// Set stream URL to the listener domain
const streamUrl = window.location.hostname.startsWith('dj.')
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
: `${window.location.protocol}//${window.location.hostname}:5001`;
const streamInput = document.getElementById('stream-url');
if (streamInput) streamInput.value = streamUrl;
}
});
// ========== LIVE STREAMING FUNCTIONALITY ==========
let socket = null;
let streamDestination = null;
let streamProcessor = null;
let isBroadcasting = false;
let autoStartStream = false;
let listenerAudioContext = null;
let listenerGainNode = null;
let listenerAnalyserNode = null;
let listenerMediaElementSourceNode = null;
let listenerVuMeterRunning = false;
let listenerChunksReceived = 0;
function startListenerVUMeter() {
if (listenerVuMeterRunning) return;
listenerVuMeterRunning = true;
const draw = () => {
if (!listenerVuMeterRunning) return;
requestAnimationFrame(draw);
const canvas = document.getElementById('viz-listener');
if (!canvas || !listenerAnalyserNode) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Keep canvas sized correctly for DPI
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const targetW = Math.max(1, Math.floor(rect.width * dpr));
const targetH = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width !== targetW || canvas.height !== targetH) {
canvas.width = targetW;
canvas.height = targetH;
}
const analyser = listenerAnalyserNode;
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;
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, width, height);
// Listener uses the magenta hue (matches Deck B styling)
const hue = 280;
for (let i = 0; i < barCount; i++) {
const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength);
const value = (dataArray[freqIndex] || 0) / 255;
const barHeight = value * height;
const lightness = 30 + (value * 50);
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight);
gradient.addColorStop(0, `hsl(${hue}, 100%, ${lightness}%)`);
gradient.addColorStop(1, `hsl(${hue}, 100%, ${Math.min(lightness + 20, 80)}%)`);
ctx.fillStyle = gradient;
ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight);
}
};
draw();
}
let currentStreamMimeType = null;
function getMp3FallbackUrl() {
// Use same-origin so this works behind reverse proxies (e.g., Cloudflare) where :5001 may not be reachable.
return `${window.location.origin}/stream.mp3`;
}
// Initialize SocketIO connection
function initSocket() {
if (socket) return socket;
// Log connection details
const urlParams = new URLSearchParams(window.location.search);
const isListenerMode =
window.location.port === '5001' ||
window.location.hostname.startsWith('music.') ||
window.location.hostname.startsWith('listen.') ||
urlParams.get('listen') === 'true';
// If someone opens listener mode on the DJ dev port (:5000?listen=true),
// use the listener backend (:5001). For proxied deployments (Cloudflare),
// do NOT force a port (it may be blocked); stick to same-origin.
const serverUrl = (isListenerMode && window.location.port === '5000')
? `${window.location.protocol}//${window.location.hostname}:5001`
: window.location.origin;
console.log(`🔌 Initializing Socket.IO connection to: ${serverUrl}`);
console.log(` Protocol: ${window.location.protocol}`);
console.log(` Host: ${window.location.host}`);
socket = io(serverUrl, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 10
});
socket.on('connect', () => {
console.log('✅ Connected to streaming server');
console.log(` Socket ID: ${socket.id}`);
console.log(` Transport: ${socket.io.engine.transport.name}`);
// Get initial listener count soon as we connect
socket.emit('get_listener_count');
});
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');
// Update relay UI if it's a relay
const relayStatus = document.getElementById('relay-status');
if (relayStatus && relayStatus.textContent.includes('Connecting')) {
relayStatus.textContent = 'Relay active - streaming to listeners';
relayStatus.style.color = '#00ff00';
}
});
socket.on('broadcast_stopped', () => {
console.log('🛑 Broadcast stopped notification received');
// Reset relay UI if it was active
document.getElementById('start-relay-btn').style.display = 'inline-block';
document.getElementById('stop-relay-btn').style.display = 'none';
document.getElementById('relay-status').textContent = '';
});
socket.on('mixer_status', (data) => {
updateUIFromMixerStatus(data);
});
socket.on('deck_status', (data) => {
console.log(`📡 Server: Deck ${data.deck_id} status update:`, data);
// This is handled by a single status update too, but helpful for immediate feedback
});
socket.on('error', (data) => {
console.error('📡 Server error:', data.message);
alert(`SERVER ERROR: ${data.message}`);
// Reset relay UI on error
document.getElementById('start-relay-btn').style.display = 'inline-block';
document.getElementById('stop-relay-btn').style.display = 'none';
document.getElementById('relay-status').textContent = '';
});
return socket;
}
// Update DJ UI from server status
function updateUIFromMixerStatus(status) {
if (!status) return;
['A', 'B'].forEach(id => {
const deckStatus = id === 'A' ? status.deck_a : status.deck_b;
if (!deckStatus) return;
// Update position (only if not currently dragging the waveform)
const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0);
if (timeSinceSeek < 1500) {
// Seek Protection: Ignore server updates for 1.5s after manual seek
return;
}
// Update playing state
decks[id].playing = deckStatus.playing;
// Update loaded track if changed
if (deckStatus.filename && (!decks[id].currentFile || decks[id].currentFile !== deckStatus.filename)) {
console.log(`📡 Server synced: Deck ${id} is playing ${deckStatus.filename}`);
decks[id].currentFile = deckStatus.filename;
decks[id].duration = deckStatus.duration;
// Update UI elements for track title
const titleEl = document.getElementById(`display-${id}`);
if (titleEl) {
const name = deckStatus.filename.split('/').pop().replace(/\.[^/.]+$/, "");
titleEl.textContent = name;
}
}
// Sync playback rate for interpolation
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
if (speedSlider) speedSlider.value = deckStatus.pitch;
// Update anchor for local interpolation
decks[id].lastAnchorPosition = deckStatus.position;
decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0;
if (!decks[id].playing) {
decks[id].pausedAt = deckStatus.position;
}
// Forced UI update to snap to server reality
const currentPos = getCurrentPosition(id);
const progress = (currentPos / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
const timer = document.getElementById('time-current-' + id);
if (timer) timer.textContent = formatTime(currentPos);
});
// Update crossfader if changed significantly
if (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();
const bitrateValue = document.getElementById('stream-quality').value + 'k';
socket.emit('start_broadcast', { bitrate: bitrateValue });
socket.emit('get_listener_count');
console.log('✅ Server-side broadcast started');
return;
}
// Browser-side audio mode (original code)
// Check if any audio is playing
const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) {
console.warn('⚠️ WARNING: No decks are currently playing! Start playing a track for audio to stream.');
}
// Create MediaStreamDestination to capture audio output
streamDestination = audioCtx.createMediaStreamDestination();
// IMPORTANT: Properly manage audio graph connections
// Disconnect from speakers first, then connect to stream AND speakers
// This prevents dual-connection instability
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck A crossfader already disconnected');
}
decks.A.crossfaderGain.connect(streamDestination);
decks.A.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('✅ Deck A connected to stream + speakers');
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck B crossfader already disconnected');
}
decks.B.crossfaderGain.connect(streamDestination);
decks.B.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('✅ Deck B connected to stream + speakers');
}
// Verify stream has audio tracks
const stream = streamDestination.stream;
console.log(`📊 Stream tracks: ${stream.getAudioTracks().length} audio tracks`);
if (stream.getAudioTracks().length === 0) {
throw new Error('No audio tracks in stream! Audio routing failed.');
}
// Get selected quality from dropdown
const qualitySelect = document.getElementById('stream-quality');
const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps
console.log(`🎚️ Starting broadcast at ${qualitySelect.value}kbps`);
const preferredTypes = [
// Prefer MP4/AAC when available (broad device support)
'audio/mp4;codecs=mp4a.40.2',
'audio/mp4',
// Fallbacks
'audio/webm',
'audio/ogg',
];
const chosenType = preferredTypes.find((t) => {
try {
return MediaRecorder.isTypeSupported(t);
} catch {
return false;
}
});
if (!chosenType) {
throw new Error('No supported MediaRecorder mimeType found on this browser');
}
currentStreamMimeType = chosenType;
console.log(`🎛️ Using broadcast mimeType: ${currentStreamMimeType}`);
mediaRecorder = new MediaRecorder(stream, {
mimeType: currentStreamMimeType,
audioBitsPerSecond: selectedBitrate
});
let chunkCount = 0;
let lastLogTime = Date.now();
let silenceWarningShown = false;
// Send audio chunks via SocketIO
mediaRecorder.ondataavailable = async (event) => {
if (event.data.size > 0 && isBroadcasting && socket) {
chunkCount++;
// Warn if chunks are too small (likely silence)
if (event.data.size < 100 && !silenceWarningShown) {
console.warn('⚠️ Audio chunks are very small - might be silence. Make sure audio is playing!');
silenceWarningShown = true;
}
// Convert blob to array buffer for transmission
const buffer = await event.data.arrayBuffer();
socket.emit('audio_chunk', buffer); // Send raw ArrayBuffer directly
// Log every second
const now = Date.now();
if (now - lastLogTime > 1000) {
console.log(`📡 Broadcasting: ${chunkCount} chunks sent (${(event.data.size / 1024).toFixed(1)} KB/chunk)`);
lastLogTime = now;
// Reset silence warning
if (event.data.size > 100) {
silenceWarningShown = false;
}
}
} else {
// Debug why chunks aren't being sent
if (event.data.size === 0) {
console.warn('⚠️ Received empty audio chunk');
}
if (!isBroadcasting) {
console.warn('⚠️ Broadcasting flag is false');
}
if (!socket) {
console.warn('⚠️ Socket not connected');
}
}
};
mediaRecorder.onerror = (error) => {
console.error('❌ 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 that broadcast is active (listeners use MP3 stream)
if (!socket) initSocket();
const bitrateValue = document.getElementById('stream-quality').value + 'k';
socket.emit('start_broadcast', { bitrate: bitrateValue });
socket.emit('get_listener_count');
console.log('✅ Broadcasting started successfully!');
console.log('💡 TIP: Play a track on Deck A or B to stream audio');
// Monitor audio levels
setTimeout(() => {
if (chunkCount === 0) {
console.error('❌ NO AUDIO CHUNKS after 2 seconds! Check:');
console.error(' 1. Is audio playing on either deck?');
console.error(' 2. Is volume turned up?');
console.error(' 3. Is crossfader in the middle?');
}
}, 2000);
} catch (error) {
console.error('❌ Failed to start broadcast:', error);
alert('Failed to start broadcast: ' + error.message);
isBroadcasting = false;
}
}
// Stop broadcasting
function stopBroadcast() {
console.log('🛑 Stopping broadcast...');
if (SERVER_SIDE_AUDIO) {
isBroadcasting = false;
if (socket) {
socket.emit('stop_broadcast');
}
} else {
if (streamProcessor) {
streamProcessor.stop();
streamProcessor = null;
}
if (streamDestination) {
// Disconnect from stream destination and restore normal playback
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.A.crossfaderGain.disconnect();
decks.A.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck A audio:', e);
}
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.B.crossfaderGain.disconnect();
decks.B.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck B audio:', e);
}
}
streamDestination = null;
}
isBroadcasting = false;
// Notify server (browser-side mode also needs to tell server to stop relaying)
if (socket) {
socket.emit('stop_broadcast');
}
}
// Update UI
document.getElementById('broadcast-btn').classList.remove('active');
document.getElementById('broadcast-text').textContent = 'START BROADCAST';
document.getElementById('broadcast-status').textContent = 'Offline';
document.getElementById('broadcast-status').classList.remove('live');
console.log('✅ Broadcast stopped');
}
// Restart broadcasting (for auto-recovery)
function restartBroadcast() {
console.log('🔄 Restarting broadcast...');
// Clean up old MediaRecorder without changing UI state
if (streamProcessor) {
try {
streamProcessor.stop();
} catch (e) {
console.warn('Could not stop old MediaRecorder:', e);
}
streamProcessor = null;
}
// Clean up old stream destination
if (streamDestination) {
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect(streamDestination);
decks.A.crossfaderGain.disconnect();
decks.A.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error cleaning up Deck A:', e);
}
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect(streamDestination);
decks.B.crossfaderGain.disconnect();
decks.B.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error cleaning up Deck B:', e);
}
}
streamDestination = null;
}
// Preserve broadcasting state
const wasBroadcasting = isBroadcasting;
isBroadcasting = false; // Temporarily set to false so startBroadcast works
// Small delay to ensure cleanup completes
setTimeout(() => {
if (wasBroadcasting) {
startBroadcast();
console.log('✅ Broadcast restarted successfully');
}
}, 100);
}
// Copy stream URL to clipboard
function copyStreamUrl(evt) {
const urlInput = document.getElementById('stream-url');
urlInput.select();
urlInput.setSelectionRange(0, 99999); // For mobile
try {
document.execCommand('copy');
const btn = evt?.target;
const originalText = btn.textContent;
btn.textContent = '✓';
setTimeout(() => {
btn.textContent = originalText;
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
}
// Toggle auto-start stream
function toggleAutoStream(enabled) {
autoStartStream = enabled;
localStorage.setItem('autoStartStream', enabled);
}
// ========== LISTENER MODE ==========
function initListenerMode() {
console.log('🎧 Initializing listener mode (MP3 stream)...');
// UI Feedback for listener
const appContainer = document.querySelector('.app-container');
const settingsBtn = document.querySelector('.settings-btn');
const streamingBtn = document.querySelector('.streaming-btn');
if (appContainer) appContainer.style.display = 'none';
if (settingsBtn) settingsBtn.style.display = 'none';
if (streamingBtn) streamingBtn.style.display = 'none';
const startOverlay = document.getElementById('start-overlay');
if (startOverlay) startOverlay.style.display = 'none';
// Hide landscape prompt for listeners (not needed)
const landscapePrompt = document.getElementById('landscape-prompt');
if (landscapePrompt) landscapePrompt.style.display = 'none';
const listenerMode = document.getElementById('listener-mode');
if (listenerMode) listenerMode.style.display = 'flex';
// Add atmospheric glow for listeners
document.body.classList.add('listener-glow');
document.body.classList.add('listening-active'); // For global CSS targeting
updateGlowIntensity(settings.glowIntensity || 30);
// AudioContext will be created when user enables audio to avoid suspension
// ALWAYS create a fresh audio element to avoid MediaSource/MediaElementSource conflicts
// This is critical for page refreshes - you can only create MediaElementSource once per element
let audio;
// Clean up old audio element if it exists
if (window.listenerAudio) {
console.log('🧹 Cleaning up old audio element and AudioContext nodes');
try {
window.listenerAudio.pause();
if (window.listenerAudio.src) {
URL.revokeObjectURL(window.listenerAudio.src);
}
window.listenerAudio.removeAttribute('src');
window.listenerAudio.remove(); // Remove from DOM
} catch (e) {
console.warn('Error cleaning up old audio:', e);
}
// Reset all AudioContext-related nodes
if (listenerMediaElementSourceNode) {
try {
listenerMediaElementSourceNode.disconnect();
} catch (e) { }
listenerMediaElementSourceNode = null;
}
if (listenerAnalyserNode) {
try {
listenerAnalyserNode.disconnect();
} catch (e) { }
listenerAnalyserNode = null;
}
if (listenerGainNode) {
try {
listenerGainNode.disconnect();
} catch (e) { }
listenerGainNode = null;
}
window.listenerAudio = null;
window.listenerMediaSource = null;
window.listenerAudioEnabled = false;
}
// Create a new hidden media element.
// For MP3 we can use a plain <audio> element.
audio = document.createElement('audio');
audio.autoplay = false; // Don't autoplay - we use the Enable Audio button
audio.muted = false;
audio.controls = false;
audio.playsInline = true;
audio.setAttribute('playsinline', '');
audio.style.display = 'none';
document.body.appendChild(audio);
console.log('🆕 Created fresh media element (audio) for listener');
// MP3 stream (server-side) — requires ffmpeg on the server.
audio.src = getMp3FallbackUrl();
audio.load();
console.log(`🎧 Listener source set to MP3 stream: ${audio.src}`);
// 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 (MP3)';
}
// Store audio element and context for later activation
window.listenerAudio = audio;
window.listenerMediaSource = null;
window.listenerAudioEnabled = false; // Track if user has enabled audio
// Initialize socket and join
initSocket();
socket.emit('join_listener');
// No socket audio chunks needed in MP3-only mode.
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) {
if (data.active) {
const status = data.remote_relay ? '🔗 Remote stream is live!' : '🎵 DJ stream is live!';
nowPlayingEl.textContent = status;
} else {
nowPlayingEl.textContent = 'Stream offline - waiting for DJ...';
}
}
});
socket.on('broadcast_stopped', () => {
const nowPlayingEl = document.getElementById('listener-now-playing');
if (nowPlayingEl) nowPlayingEl.textContent = 'Stream ended';
});
socket.on('connect', () => {
const statusEl = document.getElementById('connection-status');
// Only update if audio is enabled, otherwise keep the "Click Enable Audio" message
if (statusEl && window.listenerAudioEnabled) {
statusEl.textContent = '🟢 Connected';
}
});
socket.on('disconnect', () => {
const statusEl = document.getElementById('connection-status');
// Always show disconnect status as it's critical
if (statusEl) statusEl.textContent = '🔴 Disconnected';
});
}
// Enable audio for listener mode (called when user clicks the button)
async function enableListenerAudio() {
console.log('🎧 Enabling audio via user gesture...');
const enableAudioBtn = document.getElementById('enable-audio-btn');
const statusEl = document.getElementById('connection-status');
const audioText = enableAudioBtn ? enableAudioBtn.querySelector('.audio-text') : null;
if (audioText) audioText.textContent = 'INITIALIZING...';
try {
// 1. Create AudioContext if it somehow doesn't exist
if (!listenerAudioContext) {
listenerAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
// 2. Resume audio context (CRITICAL for Chrome/Safari)
if (listenerAudioContext.state === 'suspended') {
await listenerAudioContext.resume();
console.log('✅ Audio context resumed');
}
// 3. Bridge Audio Element to AudioContext if not already connected
if (window.listenerAudio) {
try {
if (!listenerGainNode) {
listenerGainNode = listenerAudioContext.createGain();
listenerGainNode.gain.value = 0.8;
listenerGainNode.connect(listenerAudioContext.destination);
}
if (!listenerAnalyserNode) {
listenerAnalyserNode = listenerAudioContext.createAnalyser();
listenerAnalyserNode.fftSize = 256;
}
if (!listenerMediaElementSourceNode) {
listenerMediaElementSourceNode = listenerAudioContext.createMediaElementSource(window.listenerAudio);
}
// Ensure a clean, single connection chain:
// media element -> analyser -> gain -> destination
try { listenerMediaElementSourceNode.disconnect(); } catch (_) { }
try { listenerAnalyserNode.disconnect(); } catch (_) { }
listenerMediaElementSourceNode.connect(listenerAnalyserNode);
listenerAnalyserNode.connect(listenerGainNode);
window.listenerAudio._connectedToContext = true;
console.log('🔗 Connected audio element to AudioContext (with analyser)');
// Start visualizer after the graph exists
startListenerVUMeter();
} catch (e) {
console.warn('⚠️ Could not connect to AudioContext:', e.message);
}
}
// 4. Prepare and start audio playback
if (window.listenerAudio) {
console.log('📊 Audio element state:', {
readyState: window.listenerAudio.readyState,
networkState: window.listenerAudio.networkState,
src: window.listenerAudio.src ? 'set' : 'not set',
buffered: window.listenerAudio.buffered.length,
paused: window.listenerAudio.paused
});
// Unmute just in case
window.listenerAudio.muted = false;
// Volume is controlled via listenerGainNode; keep element volume sane.
window.listenerAudio.volume = 1.0;
const volEl = document.getElementById('listener-volume');
const volValue = volEl ? parseInt(volEl.value, 10) : 80;
setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
const hasBufferedData = () => {
return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0;
};
// MP3 stream: call play() immediately to capture the user gesture.
if (audioText) audioText.textContent = 'STARTING...';
console.log('▶️ Attempting to play audio...');
const playPromise = window.listenerAudio.play();
// If not buffered yet, show buffering but don't block.
if (!hasBufferedData() && audioText) {
audioText.textContent = 'BUFFERING...';
}
await playPromise;
console.log('✅ Audio playback started successfully');
// Mark audio as enabled so status updates can now display
window.listenerAudioEnabled = true;
}
// 4. Hide the button and update status
if (enableAudioBtn) {
enableAudioBtn.style.opacity = '0';
setTimeout(() => {
enableAudioBtn.style.display = 'none';
}, 300);
}
if (statusEl) {
statusEl.textContent = '🟢 Audio Active - Enjoy the stream';
statusEl.classList.add('glow-text');
}
} catch (error) {
console.error('❌ Failed to enable audio:', error);
const stashedStatus = document.getElementById('connection-status');
const stashedBtn = document.getElementById('enable-audio-btn');
const audioText = stashedBtn ? stashedBtn.querySelector('.audio-text') : null;
if (audioText) audioText.textContent = 'RETRY ENABLE';
if (stashedStatus) {
// Show the actual error message to help debugging
let errorMsg = error.name + ': ' + error.message;
if (error.name === 'NotAllowedError') {
errorMsg = 'Browser blocked audio (NotAllowedError). Check permissions.';
} else if (error.name === 'NotSupportedError') {
errorMsg = 'MP3 stream not supported or unavailable (NotSupportedError).';
}
stashedStatus.textContent = '⚠️ ' + errorMsg;
if (error.name === 'NotSupportedError') {
stashedStatus.textContent = '⚠️ MP3 stream failed. Is ffmpeg installed on the server?';
}
}
}
}
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) {
vibrate(20);
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 = '<div class="queue-empty">Drop tracks here or click "Queue to ' + deckId + '" in library</div>';
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)
const DEFAULT_KEYBOARD_MAPPINGS = {
// Deck A Controls
'q': { action: 'playDeckA', label: 'Play Deck A' },
'a': { action: 'pauseDeckA', label: 'Pause Deck A' },
'1': { action: 'cueA1', label: 'Deck A Cue 1' },
'2': { action: 'cueA2', label: 'Deck A Cue 2' },
'3': { action: 'cueA3', label: 'Deck A Cue 3' },
'4': { action: 'cueA4', label: 'Deck A Cue 4' },
'z': { action: 'loopInA', label: 'Loop IN Deck A' },
'x': { action: 'loopOutA', label: 'Loop OUT Deck A' },
'c': { action: 'loopExitA', label: 'Loop EXIT Deck A' },
'r': { action: 'resetDeckA', label: 'Reset Deck A' },
// Deck B Controls
'w': { action: 'playDeckB', label: 'Play Deck B' },
's': { action: 'pauseDeckB', label: 'Pause Deck B' },
'7': { action: 'cueB1', label: 'Deck B Cue 1' },
'8': { action: 'cueB2', label: 'Deck B Cue 2' },
'9': { action: 'cueB3', label: 'Deck B Cue 3' },
'0': { action: 'cueB4', label: 'Deck B Cue 4' },
'n': { action: 'loopInB', label: 'Loop IN Deck B' },
'm': { action: 'loopOutB', label: 'Loop OUT Deck B' },
',': { action: 'loopExitB', label: 'Loop EXIT Deck B' },
't': { action: 'resetDeckB', label: 'Reset Deck B' },
// Crossfader & Mixer
'ArrowLeft': { action: 'crossfaderLeft', label: 'Crossfader Left' },
'ArrowRight': { action: 'crossfaderRight', label: 'Crossfader Right' },
'ArrowDown': { action: 'crossfaderCenter', label: 'Crossfader Center' },
// Pitch Bend
'e': { action: 'pitchBendAUp', label: 'Pitch Bend A +' },
'd': { action: 'pitchBendADown', label: 'Pitch Bend A -' },
'i': { action: 'pitchBendBUp', label: 'Pitch Bend B +' },
'k': { action: 'pitchBendBDown', label: 'Pitch Bend B -' },
// Utility
'f': { action: 'toggleLibrary', label: 'Toggle Library' },
'b': { action: 'toggleBroadcast', label: 'Toggle Broadcast' },
'h': { action: 'showKeyboardHelp', label: 'Show Keyboard Help' },
'Escape': { action: 'closeAllPanels', label: 'Close All Panels' }
};
let keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
// Load custom mappings from server
async function loadKeyboardMappings() {
try {
const response = await fetch('/load_keymaps');
const data = await response.json();
if (data.success && data.keymaps) {
keyboardMappings = data.keymaps;
console.log('✅ Loaded custom keyboard mappings from server');
} else {
console.log(' Using default keyboard mappings');
}
} catch (e) {
console.error('Failed to load keyboard mappings from server:', e);
}
}
// Save custom mappings to server
async function saveKeyboardMappings() {
try {
await fetch('/save_keymaps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(keyboardMappings)
});
console.log('💾 Saved keyboard mappings to server');
} catch (e) {
console.error('Failed to save keyboard mappings to server:', e);
}
}
// Execute action based on key
function executeKeyboardAction(action) {
const actions = {
// Deck A
'playDeckA': () => playDeck('A'),
'pauseDeckA': () => pauseDeck('A'),
'cueA1': () => handleCue('A', 1),
'cueA2': () => handleCue('A', 2),
'cueA3': () => handleCue('A', 3),
'cueA4': () => handleCue('A', 4),
'loopInA': () => setLoop('A', 'in'),
'loopOutA': () => setLoop('A', 'out'),
'loopExitA': () => setLoop('A', 'exit'),
'resetDeckA': () => resetDeck('A'),
// Deck B
'playDeckB': () => playDeck('B'),
'pauseDeckB': () => pauseDeck('B'),
'cueB1': () => handleCue('B', 1),
'cueB2': () => handleCue('B', 2),
'cueB3': () => handleCue('B', 3),
'cueB4': () => handleCue('B', 4),
'loopInB': () => setLoop('B', 'in'),
'loopOutB': () => setLoop('B', 'out'),
'loopExitB': () => setLoop('B', 'exit'),
'resetDeckB': () => resetDeck('B'),
// Crossfader
'crossfaderLeft': () => {
const cf = document.getElementById('crossfader');
cf.value = Math.max(0, parseInt(cf.value) - 10);
updateCrossfader(cf.value);
},
'crossfaderRight': () => {
const cf = document.getElementById('crossfader');
cf.value = Math.min(100, parseInt(cf.value) + 10);
updateCrossfader(cf.value);
},
'crossfaderCenter': () => {
const cf = document.getElementById('crossfader');
cf.value = 50;
updateCrossfader(50);
},
// Pitch Bend
'pitchBendAUp': () => pitchBend('A', 0.05),
'pitchBendADown': () => pitchBend('A', -0.05),
'pitchBendBUp': () => pitchBend('B', 0.05),
'pitchBendBDown': () => pitchBend('B', -0.05),
// Utility
'toggleLibrary': () => switchTab('library'),
'toggleBroadcast': () => toggleBroadcast(),
'showKeyboardHelp': () => openKeyboardSettings(),
'closeAllPanels': () => {
document.getElementById('settings-panel').classList.remove('active');
document.getElementById('streaming-panel').classList.remove('active');
}
};
if (actions[action]) {
actions[action]();
} else {
console.warn('Unknown action:', action);
}
}
// Global keyboard event listener
document.addEventListener('keydown', (e) => {
// Ignore if typing in input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
const key = e.key;
const mapping = keyboardMappings[key];
if (mapping) {
e.preventDefault();
console.log(`⌨️ Keyboard: ${key}${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 = `
<div class="panel-header">
<h2>⌨️ Keyboard Shortcuts</h2>
<button onclick="closeKeyboardSettings()">✕</button>
</div>
<div class="panel-content">
<p style="margin-bottom: 15px; color: #888;">Click on a key to reassign it. Press ESC to cancel.</p>
<div id="keyboard-mappings-list"></div>
<div style="margin-top: 20px; display: flex; gap: 10px;">
<button onclick="resetKeyboardMappings()" class="btn-secondary">Reset to Defaults</button>
<button onclick="exportKeyboardMappings()" class="btn-secondary">Export</button>
<button onclick="importKeyboardMappings()" class="btn-secondary">Import</button>
</div>
</div>
`;
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 = `
<span class="key-display">${formatKeyName(key)}</span>
<span class="key-arrow">→</span>
<span class="action-label">${mapping.label}</span>
<button class="key-reassign-btn" onclick="reassignKey('${key}')">Change</button>
`;
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
async function resetKeyboardMappings() {
if (confirm('Reset all keyboard shortcuts to defaults?')) {
keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
await saveKeyboardMappings();
renderKeyboardMappings();
}
}
// Export mappings
function exportKeyboardMappings() {
const json = JSON.stringify(keyboardMappings, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'techdj-keyboard-mappings.json';
a.click();
URL.revokeObjectURL(url);
}
// Import mappings
function importKeyboardMappings() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
try {
const imported = JSON.parse(event.target.result);
keyboardMappings = imported;
saveKeyboardMappings();
renderKeyboardMappings();
alert('✅ 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.');