Files
techdj/script.js
3nd3r 81120ac7ea Add remote stream relay feature: relay remote DJ streams to listeners
- Server-side: Added remote URL support in ffmpeg transcoder
- UI: Added relay controls in streaming panel with URL input
- Client: Added start/stop relay functions with socket communication
- Listener: Shows remote relay status in stream indicator
2026-01-03 10:29:10 -06:00

3139 lines
110 KiB
JavaScript

// ==========================================
// 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 = '<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();
}
// 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 = '<div class="search-loading">🔍 Searching YouTube...</div>';
try {
const response = await fetch(`/search_youtube?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (!data.success) {
resultsDiv.innerHTML = `<div class="search-error">❌ ${data.error}</div>`;
return;
}
displayYouTubeResults(data.results);
} catch (error) {
console.error('YouTube search error:', error);
resultsDiv.innerHTML = '<div class="search-error">❌ Search failed. Check console.</div>';
}
}
function displayYouTubeResults(results) {
const resultsDiv = document.getElementById('youtube-results');
if (results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty">No results found</div>';
return;
}
resultsDiv.innerHTML = '';
results.forEach(result => {
const resultCard = document.createElement('div');
resultCard.className = 'youtube-result-card';
resultCard.innerHTML = `
<img src="${result.thumbnail}" alt="${result.title}" class="result-thumbnail">
<div class="result-info">
<div class="result-title">${result.title}</div>
<div class="result-channel">${result.channel}</div>
</div>
<button class="result-download-btn" onclick="downloadYouTubeResult('${result.url}', '${result.title.replace(/'/g, "\\'")}')">
⬇️
</button>
`;
resultsDiv.appendChild(resultCard);
});
}
async function downloadYouTubeResult(url, title) {
const resultsDiv = document.getElementById('youtube-results');
const originalContent = resultsDiv.innerHTML;
resultsDiv.innerHTML = '<div class="search-loading">⏳ Downloading...</div>';
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 = '<div class="search-success">✅ Downloaded! Refreshing library...</div>';
await fetchLibrary();
setTimeout(() => {
resultsDiv.innerHTML = originalContent;
}, 2000);
} else {
resultsDiv.innerHTML = `<div class="search-error">❌ Download failed: ${result.error}</div>`;
setTimeout(() => {
resultsDiv.innerHTML = originalContent;
}, 3000);
}
} catch (error) {
console.error('Download error:', error);
resultsDiv.innerHTML = '<div class="search-error">❌ Download failed</div>';
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 = '<div class="progress-text">Downloading...</div>';
try {
const res = await fetch('/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url, quality: qualitySelect.value })
});
const result = await res.json();
if (result.success) {
statusDiv.innerHTML = '✅ Complete!';
fetchLibrary();
setTimeout(() => statusDiv.innerHTML = '', 3000);
} else {
statusDiv.innerHTML = '❌ Failed';
}
} catch (e) {
statusDiv.innerHTML = '❌ Error';
}
}
// Settings
function toggleSettings() {
const p = document.getElementById('settings-panel');
p.classList.toggle('active');
}
// File Upload
async function handleFileUpload(event) {
const files = event.target.files;
if (!files || files.length === 0) return;
console.log(`📁 Uploading ${files.length} file(s)...`);
for (let file of files) {
if (!file.type.match('audio/mpeg') && !file.name.endsWith('.mp3')) {
alert(`${file.name} is not an MP3 file`);
continue;
}
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
console.log(`✅ Uploaded: ${file.name}`);
} else {
console.error(`❌ Upload failed: ${result.error}`);
alert(`Failed to upload ${file.name}: ${result.error}`);
}
} catch (error) {
console.error(`❌ Upload error: ${error}`);
alert(`Error uploading ${file.name}`);
}
}
// Refresh library
console.log('🔄 Refreshing library...');
await loadLibrary();
alert(`${files.length} file(s) uploaded successfully!`);
// Clear the input so the same file can be uploaded again if needed
event.target.value = '';
}
function toggleRepeat(id, val) { settings[`repeat${id}`] = val; }
function toggleAutoMix(val) { settings.autoMix = val; }
function toggleShuffle(val) { settings.shuffleMode = val; }
function toggleQuantize(val) { settings.quantize = val; }
function toggleAutoPlay(val) { settings.autoPlay = val; }
function updateManualGlow(id, val) {
settings[`glow${id}`] = val;
if (val) {
document.body.classList.add(`playing-${id}`);
} else {
document.body.classList.remove(`playing-${id}`);
}
}
function updateGlowIntensity(val) {
settings.glowIntensity = parseInt(val);
const opacity = settings.glowIntensity / 100;
const spread = (settings.glowIntensity / 100) * 80;
// Dynamically update CSS variables for the glow
document.documentElement.style.setProperty('--glow-opacity', opacity);
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
}
// Dismiss landscape prompt
function dismissLandscapePrompt() {
const prompt = document.getElementById('landscape-prompt');
if (prompt) {
prompt.classList.add('dismissed');
// Store preference in localStorage
localStorage.setItem('landscapePromptDismissed', 'true');
}
}
// Check if prompt was previously dismissed
window.addEventListener('DOMContentLoaded', () => {
const wasDismissed = localStorage.getItem('landscapePromptDismissed');
if (wasDismissed === 'true') {
const prompt = document.getElementById('landscape-prompt');
if (prompt) prompt.classList.add('dismissed');
}
// Initialize glow intensity
updateGlowIntensity(settings.glowIntensity);
const glowAToggle = document.getElementById('glow-A');
if (glowAToggle) glowAToggle.checked = settings.glowA;
const glowBToggle = document.getElementById('glow-B');
if (glowBToggle) glowBToggle.checked = settings.glowB;
const intensitySlider = document.getElementById('glow-intensity');
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
// Apply initial glow state
updateManualGlow('A', settings.glowA);
updateManualGlow('B', settings.glowB);
// Check if this is the listener page based on hostname or port
const isListenerPort = window.location.port === '5001';
const isListenerHostname = window.location.hostname.startsWith('music.') || window.location.hostname.startsWith('listen.');
const urlParams = new URLSearchParams(window.location.search);
if (isListenerPort || isListenerHostname || urlParams.get('listen') === 'true') {
initListenerMode();
}
if (!isListenerPort && !isListenerHostname) {
// Set stream URL to the listener domain
const streamUrl = window.location.hostname.startsWith('dj.')
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
: `${window.location.protocol}//${window.location.hostname}:5001`;
const streamInput = document.getElementById('stream-url');
if (streamInput) streamInput.value = streamUrl;
}
});
// ========== LIVE STREAMING FUNCTIONALITY ==========
let socket = null;
let streamDestination = null;
let streamProcessor = null;
let isBroadcasting = false;
let autoStartStream = false;
let listenerAudioContext = null;
let listenerGainNode = null;
let 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}`);
});
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();
socket.emit('start_broadcast');
socket.emit('get_listener_count');
console.log('✅ Server-side broadcast started');
return;
}
// Browser-side audio mode (original code)
// Check if any audio is playing
const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) {
console.warn('⚠️ WARNING: No decks are currently playing! Start playing a track for audio to stream.');
}
// Create MediaStreamDestination to capture audio output
streamDestination = audioCtx.createMediaStreamDestination();
// IMPORTANT: Properly manage audio graph connections
// Disconnect from speakers first, then connect to stream AND speakers
// This prevents dual-connection instability
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck A crossfader already disconnected');
}
decks.A.crossfaderGain.connect(streamDestination);
decks.A.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('✅ Deck A connected to stream + speakers');
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck B crossfader already disconnected');
}
decks.B.crossfaderGain.connect(streamDestination);
decks.B.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('✅ Deck B connected to stream + speakers');
}
// Verify stream has audio tracks
const stream = streamDestination.stream;
console.log(`📊 Stream tracks: ${stream.getAudioTracks().length} audio tracks`);
if (stream.getAudioTracks().length === 0) {
throw new Error('No audio tracks in stream! Audio routing failed.');
}
// Get selected quality from dropdown
const qualitySelect = document.getElementById('stream-quality');
const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps
console.log(`🎚️ Starting broadcast at ${qualitySelect.value}kbps`);
const preferredTypes = [
// 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();
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(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);
}
// ========== REMOTE RELAY FUNCTIONS ==========
function startRemoteRelay() {
const urlInput = document.getElementById('remote-stream-url');
const url = urlInput.value.trim();
if (!url) {
alert('Please enter a remote stream URL');
return;
}
if (!socket) initSocket();
// Stop any existing broadcast first
if (isBroadcasting) {
stopBroadcast();
}
console.log('🔗 Starting remote relay for:', url);
// Update UI
document.getElementById('start-relay-btn').style.display = 'none';
document.getElementById('stop-relay-btn').style.display = 'inline-block';
document.getElementById('relay-status').textContent = 'Connecting to remote stream...';
document.getElementById('relay-status').style.color = '#00f3ff';
socket.emit('start_remote_relay', { url: url });
}
function stopRemoteRelay() {
if (!socket) return;
console.log('🛑 Stopping remote relay');
socket.emit('stop_remote_relay');
// Update UI
document.getElementById('start-relay-btn').style.display = 'inline-block';
document.getElementById('stop-relay-btn').style.display = 'none';
document.getElementById('relay-status').textContent = '';
}
// ========== 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) {
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)
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 = `
<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
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.');