techdj/script.js

3185 lines
112 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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
// ==========================================
let audioCtx;
const decks = {
A: {
type: 'local',
playing: false,
pausedAt: 0,
duration: 0,
localBuffer: null,
localSource: null,
gainNode: null,
volumeGain: null,
crossfaderGain: null,
filters: {},
cues: {},
loopStart: null,
loopEnd: null,
loopActive: false,
activeAutoLoop: null,
waveformData: null,
lastAnchorTime: 0,
lastAnchorPosition: 0,
loading: false,
currentFile: null,
lastSeekTime: 0
},
B: {
type: 'local',
playing: false,
pausedAt: 0,
duration: 0,
localBuffer: null,
localSource: null,
gainNode: null,
volumeGain: null,
crossfaderGain: null,
filters: {},
cues: {},
loopStart: null,
loopEnd: null,
loopActive: false,
activeAutoLoop: null,
waveformData: null,
lastAnchorTime: 0,
lastAnchorPosition: 0,
loading: false,
currentFile: null,
lastSeekTime: 0
}
};
let allSongs = [];
const settings = {
repeatA: false,
repeatB: false,
autoMix: false,
shuffleMode: false,
quantize: false,
autoPlay: true,
glowA: false,
glowB: false,
glowIntensity: 30
};
// Queue system for both decks
const queues = {
A: [],
B: []
};
// Toast Notification System
function showToast(message, type) {
type = type || 'info';
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 4000);
}
// Settings Persistence
function saveSettings() {
try {
localStorage.setItem('techdj_settings', JSON.stringify(settings));
} catch (e) { /* quota exceeded or private browsing */ }
}
function loadSettings() {
try {
var saved = localStorage.getItem('techdj_settings');
if (saved) {
var parsed = JSON.parse(saved);
Object.keys(parsed).forEach(function(key) {
if (settings.hasOwnProperty(key)) settings[key] = parsed[key];
});
}
} catch (e) { /* corrupt data, ignore */ }
}
// Restore saved settings on load
loadSettings();
// System Initialization
function initSystem() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
document.getElementById('start-overlay').style.display = 'none';
// Track dragging state per deck
const draggingState = { A: false, B: false };
// Setup audio path for both decks
['A', 'B'].forEach(id => {
// Create separate volume and crossfader gain nodes
const volumeGain = audioCtx.createGain();
const crossfaderGain = audioCtx.createGain();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const low = audioCtx.createBiquadFilter();
const mid = audioCtx.createBiquadFilter();
const high = audioCtx.createBiquadFilter();
const lp = audioCtx.createBiquadFilter();
const hp = audioCtx.createBiquadFilter();
low.type = 'lowshelf'; low.frequency.value = 320;
mid.type = 'peaking'; mid.frequency.value = 1000; mid.Q.value = 1;
high.type = 'highshelf'; high.frequency.value = 3200;
lp.type = 'lowpass'; lp.frequency.value = 22050; // default fully open
hp.type = 'highpass'; hp.frequency.value = 0; // default fully open
// Connect: Filters -> Volume -> Analyser -> Crossfader -> Destination
low.connect(mid);
mid.connect(high);
high.connect(lp);
lp.connect(hp);
hp.connect(volumeGain);
volumeGain.connect(analyser);
analyser.connect(crossfaderGain);
crossfaderGain.connect(audioCtx.destination);
// Set default values
volumeGain.gain.value = 0.8; // 80% volume
// Crossfader: A=1.0 at left (val=0), B=1.0 at right (val=100), both 0.5 at center (val=50)
crossfaderGain.gain.value = id === 'A' ? 0.5 : 0.5; // Center position initially
decks[id].volumeGain = volumeGain;
decks[id].crossfaderGain = crossfaderGain;
decks[id].gainNode = volumeGain; // Keep for compatibility
decks[id].analyser = analyser;
decks[id].filters = { low, mid, high, lp, hp };
// Set canvas dimensions properly with DPI scaling
const canvas = document.getElementById('waveform-' + id);
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = 80 * dpr;
// Scale context to match DPI (reset transform first to prevent stacking)
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset to identity matrix
ctx.scale(dpr, dpr);
// Waveform Scrubbing (Dragging to Seek)
const handleScrub = (e) => {
if (!decks[id].duration) return;
const rect = canvas.getBoundingClientRect();
const x = (e.clientX || (e.touches && e.touches[0].clientX)) - rect.left;
const percent = Math.max(0, Math.min(1, x / rect.width));
seekTo(id, percent * decks[id].duration);
};
canvas.addEventListener('mousedown', (e) => {
draggingState[id] = true;
handleScrub(e);
});
canvas.addEventListener('mousemove', (e) => {
if (draggingState[id]) handleScrub(e);
});
canvas.addEventListener('mouseup', () => {
draggingState[id] = false;
});
canvas.addEventListener('mouseleave', () => {
draggingState[id] = false;
});
// Mobile Touch Support
canvas.addEventListener('touchstart', (e) => {
draggingState[id] = true;
handleScrub(e);
e.preventDefault();
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
if (draggingState[id]) {
handleScrub(e);
e.preventDefault();
}
}, { passive: false });
canvas.addEventListener('touchend', () => {
draggingState[id] = false;
});
// Disk Scrubbing (Dragging on the Disk)
const disk = document.querySelector(`#deck-${id} .dj-disk`);
let isDiskDragging = false;
disk.addEventListener('mousedown', (e) => {
isDiskDragging = true;
e.stopPropagation(); // Don't trigger the click/toggleDeck if we're dragging
});
disk.addEventListener('mousemove', (e) => {
if (isDiskDragging && decks[id].playing) {
// Scrub based on vertical movement
const movement = e.movementY || 0;
const currentPos = getCurrentPosition(id);
const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1));
seekTo(id, newPos);
}
});
disk.addEventListener('mouseup', () => {
isDiskDragging = false;
});
disk.addEventListener('mouseleave', () => {
isDiskDragging = false;
});
disk.addEventListener('touchstart', (e) => {
isDiskDragging = true;
e.stopPropagation();
}, { passive: false });
disk.addEventListener('touchmove', (e) => {
if (isDiskDragging && decks[id].playing && e.touches[0]) {
const movement = e.touches[0].clientY - (disk._lastTouchY || e.touches[0].clientY);
disk._lastTouchY = e.touches[0].clientY;
const currentPos = getCurrentPosition(id);
const newPos = Math.max(0, Math.min(decks[id].duration, currentPos + movement * 0.1));
seekTo(id, newPos);
}
}, { passive: false });
disk.addEventListener('touchend', () => {
isDiskDragging = false;
delete disk._lastTouchY;
});
// Handle click for toggle (only if not scrubbing)
disk.addEventListener('click', (e) => {
if (!isDiskDragging) {
toggleDeck(id);
}
});
});
fetchLibrary();
updateTimeDisplays();
animateVUMeters(); // Start VU meter animation
// Handle resize for DPI scaling
window.addEventListener('resize', () => {
['A', 'B'].forEach(id => {
const canvas = document.getElementById('waveform-' + id);
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = 80 * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
drawWaveform(id);
});
});
// Initialise mobile view
if (window.innerWidth <= 1024) {
switchTab('library');
}
initDropZones();
}
function initDropZones() {
['A', 'B'].forEach(id => {
const deckEl = document.getElementById(`deck-${id}`);
const queueEl = document.getElementById(`deck-queue-list-${id}`);
// Deck Drop Zone (Load Track)
if (deckEl) {
deckEl.ondragover = (e) => {
e.preventDefault();
deckEl.classList.add('drag-over');
};
deckEl.ondragleave = () => {
deckEl.classList.remove('drag-over');
};
deckEl.ondrop = (e) => {
e.preventDefault();
deckEl.classList.remove('drag-over');
const file = e.dataTransfer.getData('trackFile');
const title = e.dataTransfer.getData('trackTitle');
if (file && title) {
console.log(`Dropped track onto Deck ${id}: ${title}`);
loadFromServer(id, file, title);
}
};
}
// Queue Drop Zone (Add to Queue)
if (queueEl) {
queueEl.ondragover = (e) => {
e.preventDefault();
queueEl.classList.add('drag-over');
};
queueEl.ondragleave = () => {
queueEl.classList.remove('drag-over');
};
queueEl.ondrop = (e) => {
e.preventDefault();
queueEl.classList.remove('drag-over');
const file = e.dataTransfer.getData('trackFile');
const title = e.dataTransfer.getData('trackTitle');
const fromDeck = e.dataTransfer.getData('queueDeck');
const fromIndex = e.dataTransfer.getData('queueIndex');
if (fromDeck && fromDeck !== id && fromIndex !== "") {
// Move from another queue
const idx = parseInt(fromIndex);
const [movedItem] = queues[fromDeck].splice(idx, 1);
queues[id].push(movedItem);
renderQueue(fromDeck);
renderQueue(id);
console.log(`Moved track from Queue ${fromDeck} to end of Queue ${id}: ${movedItem.title}`);
} else if (file && title) {
// Add from library (or re-append from same queue - which is essentially a no-op move to end)
console.log(`Dropped track into Queue ${id}: ${title}`);
addToQueue(id, file, title);
}
};
}
});
}
// VU Meter Animation with smoothing
const vuMeterState = {
A: { smoothedValues: [], peakValues: [] },
B: { smoothedValues: [], peakValues: [] }
};
function animateVUMeters() {
requestAnimationFrame(animateVUMeters);
const anyPlaying = decks.A.playing || decks.B.playing;
const isListener = window.location.port === '5001' || window.location.search.includes('listen=true');
// When nothing is playing, clear canvases to transparent so they don't show as dark blocks
if (!anyPlaying && !isListener) {
['A', 'B'].forEach(id => {
const canvas = document.getElementById('viz-' + id);
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
});
return;
}
['A', 'B'].forEach(id => {
const canvas = document.getElementById('viz-' + id);
if (!canvas || !decks[id].analyser) return;
const ctx = canvas.getContext('2d');
const analyser = decks[id].analyser;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
analyser.getByteFrequencyData(dataArray);
const width = canvas.width;
const height = canvas.height;
const barCount = 32;
const barWidth = width / barCount;
// Initialise smoothed values if needed
if (!vuMeterState[id].smoothedValues.length) {
vuMeterState[id].smoothedValues = new Array(barCount).fill(0);
vuMeterState[id].peakValues = new Array(barCount).fill(0);
}
ctx.fillStyle = '#0a0a12';
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < barCount; i++) {
// Use logarithmic frequency distribution for better bass/mid/treble representation
const freqIndex = Math.floor(Math.pow(i / barCount, 1.5) * bufferLength);
const rawValue = dataArray[freqIndex] / 255;
// Smooth the values for less jittery animation
const smoothingFactor = 0.7; // Higher = smoother but less responsive
const targetValue = rawValue;
vuMeterState[id].smoothedValues[i] =
(vuMeterState[id].smoothedValues[i] * smoothingFactor) +
(targetValue * (1 - smoothingFactor));
const value = vuMeterState[id].smoothedValues[i];
const barHeight = value * height;
// Peak hold with decay
if (value > vuMeterState[id].peakValues[i]) {
vuMeterState[id].peakValues[i] = value;
} else {
vuMeterState[id].peakValues[i] *= 0.95; // Decay rate
}
// Draw main bar with gradient
const hue = id === 'A' ? 180 : 280; // Cyan for A, Magenta for B
const saturation = 100;
const lightness = 30 + (value * 50);
// Create gradient for each bar
const gradient = ctx.createLinearGradient(0, height, 0, height - barHeight);
gradient.addColorStop(0, `hsl(${hue}, ${saturation}%, ${lightness}%)`);
gradient.addColorStop(1, `hsl(${hue}, ${saturation}%, ${Math.min(lightness + 20, 80)}%)`);
ctx.fillStyle = gradient;
ctx.fillRect(i * barWidth, height - barHeight, barWidth - 2, barHeight);
// Draw peak indicator
const peakY = height - (vuMeterState[id].peakValues[i] * height);
ctx.fillStyle = `hsl(${hue}, 100%, 70%)`;
ctx.fillRect(i * barWidth, peakY - 2, barWidth - 2, 2);
}
});
}
// Play/Pause Toggle for Disk
function toggleDeck(id) {
if (decks[id].playing) {
pauseDeck(id);
} else {
playDeck(id);
}
}
// Mobile Tab Switching
let currentQueueTab = 'A'; // Track which queue is shown
function switchTab(tabId) {
const container = document.querySelector('.app-container');
const buttons = document.querySelectorAll('.tab-btn');
const sections = document.querySelectorAll('.library-section, .deck, .queue-section');
// Remove all tab and active classes
container.classList.remove('show-library', 'show-deck-A', 'show-deck-B', 'show-queue-A', 'show-queue-B');
buttons.forEach(btn => btn.classList.remove('active'));
sections.forEach(sec => sec.classList.remove('active'));
// Add active class and button state
container.classList.add('show-' + tabId);
// Activate target section
const targetSection = document.getElementById(tabId) || document.querySelector('.' + tabId + '-section');
if (targetSection) targetSection.classList.add('active');
// Find the button and activate it
buttons.forEach(btn => {
const onClickAttr = btn.getAttribute('onclick');
if (onClickAttr && (onClickAttr.includes(tabId) || (tabId.startsWith('queue') && onClickAttr.includes('switchQueueTab')))) {
btn.classList.add('active');
}
});
// Update queue tab label to reflect which queue is showing
if (tabId.startsWith('queue')) {
currentQueueTab = tabId.includes('A') ? 'A' : 'B';
const label = document.getElementById('queue-tab-label');
if (label) label.textContent = 'QUEUE ' + currentQueueTab;
}
// Redraw waveforms if switching to a deck
if (tabId.startsWith('deck')) {
const id = tabId.includes('-') ? tabId.split('-')[1] : (tabId.includes('A') ? 'A' : 'B');
setTimeout(() => drawWaveform(id), 100);
}
// Haptic feedback
vibrate(10);
}
// Queue tab cycles between Queue A and Queue B
function switchQueueTab() {
const container = document.querySelector('.app-container');
const isQueueActive = container.classList.contains('show-queue-A') || container.classList.contains('show-queue-B');
if (!isQueueActive) {
// First tap: show current queue
switchTab('queue-' + currentQueueTab);
} else {
// Already on a queue tab: toggle between A and B
const nextQueue = currentQueueTab === 'A' ? 'B' : 'A';
switchTab('queue-' + nextQueue);
}
}
// Mobile Haptic Helper
function vibrate(ms) {
if (navigator.vibrate) {
navigator.vibrate(ms);
}
}
// Mobile FAB Menu Toggle
function toggleFabMenu(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('fab-menu');
const fab = document.querySelector('.fab-main');
if (menu) menu.classList.toggle('active');
if (fab) fab.classList.toggle('active');
vibrate(10);
}
// Close menu when clicking outside
document.addEventListener('click', () => {
const menu = document.getElementById('fab-menu');
const fab = document.querySelector('.fab-main');
if (menu && menu.classList.contains('active')) {
menu.classList.remove('active');
fab.classList.remove('active');
}
});
// Update Clock
setInterval(() => {
const clock = document.getElementById('clock-display');
if (clock) {
const now = new Date();
clock.textContent = now.getHours().toString().padStart(2, '0') + ':' +
now.getMinutes().toString().padStart(2, '0');
}
}, 1000);
// Fullscreen Toggle
function toggleFullScreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
});
document.getElementById('fullscreen-toggle').classList.add('active');
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
document.getElementById('fullscreen-toggle').classList.remove('active');
}
vibrate(20);
}
// Touch Swiping Logic
let touchStartX = 0;
let touchEndX = 0;
document.addEventListener('touchstart', e => {
touchStartX = e.changedTouches[0].screenX;
}, false);
document.addEventListener('touchend', e => {
touchEndX = e.changedTouches[0].screenX;
handleSwipe();
}, false);
function handleSwipe() {
const threshold = 100;
const swipeDistance = touchEndX - touchStartX;
// Get current tab
const activeBtn = document.querySelector('.tab-btn.active');
if (!activeBtn) return;
const tabs = ['library', 'deck-A', 'deck-B', 'queue-A', 'queue-B'];
let currentIndex = -1;
const onClickAttr = activeBtn.getAttribute('onclick') || '';
if (onClickAttr.includes('library')) currentIndex = 0;
else if (onClickAttr.includes('deck-A')) currentIndex = 1;
else if (onClickAttr.includes('deck-B')) currentIndex = 2;
else if (onClickAttr.includes('switchQueueTab')) {
// Determine which queue is active
const container = document.querySelector('.app-container');
currentIndex = container.classList.contains('show-queue-B') ? 4 : 3;
}
if (currentIndex === -1) return;
if (swipeDistance > threshold) {
// Swipe Right (Go Left)
if (currentIndex > 0) switchTab(tabs[currentIndex - 1]);
} else if (swipeDistance < -threshold) {
// Swipe Left (Go Right)
if (currentIndex < tabs.length - 1) switchTab(tabs[currentIndex + 1]);
}
}
// Waveform Generation (Optimised for Speed)
function generateWaveformData(buffer) {
const rawData = buffer.getChannelData(0);
const samples = 1000;
const blockSize = Math.floor(rawData.length / samples);
// Only process a subset of samples for huge speed gain
const step = Math.max(1, Math.floor(blockSize / 20));
const filteredData = [];
for (let i = 0; i < samples; i++) {
let blockStart = blockSize * i;
let sum = 0;
let count = 0;
for (let j = 0; j < blockSize; j += step) {
const index = blockStart + j;
// Bounds check to prevent NaN values
if (index < rawData.length) {
sum += Math.abs(rawData[index]);
count++;
}
}
filteredData.push(sum / (count || 1));
}
return filteredData;
}
// Debounce guard: prevents redundant redraws within the same frame
const _waveformPending = { A: false, B: false };
function drawWaveform(id) {
if (_waveformPending[id]) return;
_waveformPending[id] = true;
requestAnimationFrame(() => {
_waveformPending[id] = false;
_drawWaveformImmediate(id);
});
}
function _drawWaveformImmediate(id) {
const canvas = document.getElementById('waveform-' + id);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const data = decks[id].waveformData;
if (!data) return;
// Use logical (CSS) dimensions — the canvas context has ctx.scale(dpr)
// applied in initSystem, so coordinates must be in logical pixels.
const dpr = window.devicePixelRatio || 1;
const width = canvas.width / dpr;
const height = canvas.height / dpr;
ctx.clearRect(0, 0, width, height);
const barWidth = width / data.length;
data.forEach((val, i) => {
const h = val * height * 5;
ctx.fillStyle = id === 'A' ? '#00f3ff' : '#bc13fe';
ctx.fillRect(i * barWidth, (height - h) / 2, barWidth, h);
});
// Draw Cues
if (decks[id].duration > 0) {
Object.values(decks[id].cues).forEach(time => {
const x = (time / decks[id].duration) * width;
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
});
}
// Draw Loop Markers
if (decks[id].loopStart !== null && decks[id].duration > 0) {
const xIn = (decks[id].loopStart / decks[id].duration) * width;
ctx.strokeStyle = '#ffbb00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
ctx.moveTo(xIn, 0);
ctx.lineTo(xIn, height);
ctx.stroke();
if (decks[id].loopActive && decks[id].loopEnd !== null) {
const xOut = (decks[id].loopEnd / decks[id].duration) * width;
ctx.strokeStyle = '#ffaa33';
ctx.beginPath();
ctx.moveTo(xOut, 0);
ctx.lineTo(xOut, height);
ctx.stroke();
// Shade loop area
ctx.fillStyle = 'rgba(255, 187, 0, 0.15)';
ctx.fillRect(xIn, 0, xOut - xIn, height);
}
ctx.setLineDash([]);
}
// Apply manual glow if enabled
if (settings[`glow${id}`] && !decks[id].playing) {
applyGlow(id, settings.glowIntensity);
} else {
removeGlow(id);
}
}
function applyGlow(id, intensity) {
const deckEl = document.getElementById('deck-' + id);
if (!deckEl) return;
const color = id === 'A' ? 'var(--primary-cyan)' : 'var(--secondary-magenta)';
const blur = Math.max(10, intensity);
const spread = Math.max(2, intensity / 5);
deckEl.style.boxShadow = `inset 0 0 ${blur}px ${spread}px ${color}`;
}
function removeGlow(id) {
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.style.boxShadow = '';
}
// BPM Detection (Optimised: Only check middle 60 seconds for speed)
function detectBPM(buffer) {
const sampleRate = buffer.sampleRate;
const duration = buffer.duration;
// Pick a 60s window in the middle
const startOffset = Math.max(0, Math.floor((duration / 2 - 30) * sampleRate));
const endOffset = Math.min(buffer.length, Math.floor((duration / 2 + 30) * sampleRate));
const data = buffer.getChannelData(0).slice(startOffset, endOffset);
const bpmRange = [60, 180];
const windowSize = Math.floor(sampleRate * 60 / bpmRange[1]);
let peaks = 0;
let threshold = 0;
for (let i = 0; i < data.length; i += windowSize) {
const slice = data.slice(i, i + windowSize);
const avg = slice.reduce((a, b) => a + Math.abs(b), 0) / slice.length;
if (avg > threshold) {
peaks++;
threshold = avg * 0.8;
}
}
const windowDuration = data.length / sampleRate;
const bpm = Math.round((peaks / windowDuration) * 60);
return bpm > bpmRange[0] && bpm < bpmRange[1] ? bpm : 0;
}
// Time Display Updates
function updateTimeDisplays() {
requestAnimationFrame(updateTimeDisplays);
// Only update if at least one deck is playing
const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) return;
['A', 'B'].forEach(id => {
if (decks[id].playing && decks[id].localBuffer) {
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
// Handle Looping wrapping for UI
if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
const loopLen = decks[id].loopEnd - decks[id].loopStart;
if (current >= decks[id].loopEnd && loopLen > 0) {
current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart;
}
} else if (settings[`repeat${id}`]) {
// Full song repeat wrapping for UI
current = current % decks[id].duration;
} else {
current = Math.min(current, decks[id].duration);
}
document.getElementById('time-current-' + id).textContent = formatTime(current);
// Update playhead
const progress = (current / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
}
});
}
function getCurrentPosition(id) {
if (!decks[id].playing) return decks[id].pausedAt;
if (!audioCtx) return decks[id].pausedAt;
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
// Handle wrapping for correct position return
if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
const loopLen = decks[id].loopEnd - decks[id].loopStart;
if (pos >= decks[id].loopEnd && loopLen > 0) {
pos = ((pos - decks[id].loopStart) % loopLen) + decks[id].loopStart;
}
} else if (settings[`repeat${id}`] && decks[id].duration > 0) {
pos = pos % decks[id].duration;
} else {
pos = Math.min(decks[id].duration, pos);
}
return pos;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
// Playback Logic
function _notifyListenerDeckGlow() {
// Emit the current playing state of both decks to the listener page
if (!socket) return;
socket.emit('deck_glow', { A: !!decks.A.playing, B: !!decks.B.playing });
}
function playDeck(id) {
vibrate(15);
if (decks[id].type === 'local' && decks[id].localBuffer) {
if (decks[id].playing) return;
try {
console.log(`[Deck ${id}] Starting playback from ${decks[id].pausedAt}s`);
decks[id].playing = true;
seekTo(id, decks[id].pausedAt);
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id);
_notifyListenerDeckGlow();
if (audioCtx.state === 'suspended') {
console.log(`[Deck ${id}] Resuming suspended AudioContext`);
audioCtx.resume();
}
// Auto-start broadcast if enabled and not already broadcasting
if (autoStartStream && !isBroadcasting && audioCtx) {
setTimeout(() => {
if (!socket) initSocket();
setTimeout(() => startBroadcast(), 500);
}, 100);
}
} catch (error) {
console.error(`[Deck ${id}] Playback error:`, error);
decks[id].playing = false;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
alert(`Playback error: ${error.message}`);
}
} else {
console.warn(`[Deck ${id}] Cannot play - no buffer loaded`);
}
}
function pauseDeck(id) {
vibrate(15);
if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
if (!audioCtx) {
console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`);
decks[id].playing = false;
} else {
const playbackRate = decks[id].localSource.playbackRate.value;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
decks[id].pausedAt = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
try {
decks[id].localSource.stop();
decks[id].localSource.onended = null;
} catch (e) { }
decks[id].localSource = null;
decks[id].playing = false;
}
}
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
_notifyListenerDeckGlow();
}
function seekTo(id, time) {
// Update local state and timestamp for seek protection
decks[id].lastSeekTime = Date.now();
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
return;
}
try {
if (decks[id].playing) {
if (decks[id].localSource) {
try {
// Capture playback rate before stopping for the new source
decks[id]._lastPlaybackRate = decks[id].localSource.playbackRate.value;
decks[id].localSource.stop();
decks[id].localSource.onended = null;
} catch (e) { }
}
const src = audioCtx.createBufferSource();
src.buffer = decks[id].localBuffer;
if (decks[id].loopActive) {
src.loop = true;
src.loopStart = decks[id].loopStart || 0;
src.loopEnd = decks[id].loopEnd || decks[id].duration;
}
src.connect(decks[id].filters.low);
// Read current speed from old source before it was stopped, fall back to DOM slider
let speed = 1.0;
if (decks[id]._lastPlaybackRate != null) {
speed = decks[id]._lastPlaybackRate;
} else {
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
if (speedSlider) speed = parseFloat(speedSlider.value);
}
src.playbackRate.value = speed;
decks[id].localSource = src;
decks[id].lastAnchorTime = audioCtx.currentTime;
decks[id].lastAnchorPosition = time;
// Wire onended so natural playback completion always triggers auto-play
src.onended = () => {
// Guard: only act if we didn't stop it intentionally
if (decks[id].playing && !decks[id].loading) {
console.log(`[Deck ${id}] Playback ended naturally (onended)`);
handleTrackEnd(id);
}
};
src.start(0, time);
console.log(`[Deck ${id}] Playback started at ${time}s with speed ${speed}x`);
} else {
decks[id].pausedAt = time;
decks[id].lastAnchorPosition = time;
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
// Update UI immediately (Manual Position)
const timer = document.getElementById('time-current-' + id);
if (timer) timer.textContent = formatTime(time);
const progress = (time / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
}
} catch (error) {
console.error(`[Deck ${id}] SeekTo error:`, error);
// Reset playing state on error
decks[id].playing = false;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
}
}
function changeSpeed(id, val) {
if (!audioCtx || !decks[id].localSource) return;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value;
decks[id].lastAnchorTime = audioCtx.currentTime;
decks[id].localSource.playbackRate.value = parseFloat(val);
}
function changeVolume(id, val) {
if (decks[id].volumeGain) {
decks[id].volumeGain.gain.value = val / 100;
}
}
function changeEQ(id, band, val) {
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
}
function changeFilter(id, type, val) {
if (!audioCtx || !decks[id].filters) return;
const filter = type === 'lowpass' ? decks[id].filters.lp : decks[id].filters.hp;
if (!filter) return;
// Use exponential scaling for filter frequency (more musical)
if (type === 'lowpass') {
// Low-pass: 0 = low freq (muffled), 100 = high freq (open)
const freq = 20 * Math.pow(22050 / 20, val / 100);
filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05);
} else {
// High-pass: 0 = low freq (open), 100 = high freq (thin)
const freq = 20 * Math.pow(22050 / 20, val / 100);
filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.05);
}
}
// Hot Cue Functionality
function handleCue(id, cueNum) {
vibrate(15);
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set cue`);
return;
}
// If cue exists, jump to it and start playing
if (decks[id].cues[cueNum] !== undefined) {
const cueTime = decks[id].cues[cueNum];
console.log(`[Deck ${id}] Jumping to cue ${cueNum} at ${cueTime.toFixed(2)}s`);
// Always seek to cue point
seekTo(id, cueTime);
// Auto-play when triggering cue (like real DJ equipment)
if (!decks[id].playing) {
playDeck(id);
}
// Visual feedback
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) {
cueBtn.classList.add('cue-triggered');
setTimeout(() => cueBtn.classList.remove('cue-triggered'), 200);
}
} else {
// Set new cue at current position
const currentTime = getCurrentPosition(id);
decks[id].cues[cueNum] = currentTime;
console.log(`[Deck ${id}] Set cue ${cueNum} at ${currentTime.toFixed(2)}s`);
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) {
cueBtn.classList.add('cue-set');
// Flash to show it was set
cueBtn.style.animation = 'none';
setTimeout(() => {
cueBtn.style.animation = 'flash 0.3s ease';
}, 10);
}
drawWaveform(id);
}
}
function clearCue(id, cueNum) {
delete decks[id].cues[cueNum];
const cueBtn = document.querySelectorAll(`#deck-${id} .cue-btn`)[cueNum - 1];
if (cueBtn) cueBtn.classList.remove('cue-set');
drawWaveform(id);
}
function setLoop(id, action) {
vibrate(15);
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set loop`);
return;
}
const currentTime = getCurrentPosition(id);
if (action === 'in') {
// Set loop start point
decks[id].loopStart = currentTime;
decks[id].loopEnd = null; // Clear loop end
decks[id].loopActive = false; // Not active until OUT is set
console.log(`[Deck ${id}] Loop IN set at ${currentTime.toFixed(2)}s`);
// Visual feedback
const loopInBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(1)`);
if (loopInBtn) {
loopInBtn.style.background = 'rgba(255, 187, 0, 0.3)';
loopInBtn.style.borderColor = '#ffbb00';
}
} else if (action === 'out') {
// Set loop end point and activate
if (decks[id].loopStart === null) {
// If no loop start, set it to beginning
decks[id].loopStart = 0;
console.log(`[Deck ${id}] Auto-set loop IN at 0s`);
}
if (currentTime > decks[id].loopStart) {
decks[id].loopEnd = currentTime;
decks[id].loopActive = true;
const loopLength = decks[id].loopEnd - decks[id].loopStart;
console.log(`[Deck ${id}] Loop OUT set at ${currentTime.toFixed(2)}s (${loopLength.toFixed(2)}s loop)`);
// Visual feedback
const loopOutBtn = document.querySelector(`#deck-${id} .loop-controls button:nth-child(2)`);
if (loopOutBtn) {
loopOutBtn.style.background = 'rgba(255, 187, 0, 0.3)';
loopOutBtn.style.borderColor = '#ffbb00';
}
// Restart playback to apply loop immediately
if (decks[id].playing) {
const currentPos = getCurrentPosition(id);
seekTo(id, currentPos);
}
} else {
console.warn(`[Deck ${id}] Loop OUT must be after Loop IN`);
alert('Loop OUT must be after Loop IN!');
}
} else if (action === 'exit') {
// Get current position BEFORE clearing loop state
const currentPos = getCurrentPosition(id);
// Exit/clear loop
decks[id].loopActive = false;
decks[id].loopStart = null;
decks[id].loopEnd = null;
console.log(`[Deck ${id}] Loop cleared at position ${currentPos.toFixed(2)}s`);
// Clear visual feedback
const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`);
loopBtns.forEach(btn => {
btn.style.background = '';
btn.style.borderColor = '';
});
// Restart playback from current loop position to continue smoothly
if (decks[id].playing) {
seekTo(id, currentPos);
} else {
// If paused, just update the paused position
decks[id].pausedAt = currentPos;
}
}
drawWaveform(id);
}
// Auto-Loop with Beat Lengths (you.dj style)
function setAutoLoop(id, beats) {
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] No track loaded - cannot set auto-loop`);
return;
}
// Check if clicking the same active loop - if so, disable it
if (decks[id].activeAutoLoop === beats && decks[id].loopActive) {
// Get current position WITHIN the loop before disabling
const currentPos = getCurrentPosition(id);
// Disable loop
decks[id].loopActive = false;
decks[id].loopStart = null;
decks[id].loopEnd = null;
decks[id].activeAutoLoop = null;
console.log(`[Deck ${id}] Auto-loop ${beats} beats disabled at position ${currentPos.toFixed(2)}s`);
// Update UI
updateAutoLoopButtons(id);
drawWaveform(id);
// Restart playback from current loop position to continue smoothly
if (decks[id].playing) {
seekTo(id, currentPos);
} else {
// If paused, just update the paused position
decks[id].pausedAt = currentPos;
}
return;
}
// Check if BPM is detected
if (!decks[id].bpm || decks[id].bpm === 0) {
alert(`Cannot set auto-loop: BPM not detected for Deck ${id}.\nPlease use manual loop controls (LOOP IN/OUT).`);
console.warn(`[Deck ${id}] BPM not detected - cannot calculate auto-loop`);
return;
}
const currentTime = getCurrentPosition(id);
const bpm = decks[id].bpm;
// Calculate loop length in seconds: (beats / BPM) * 60
const loopLength = (beats / bpm) * 60;
// Set loop points
decks[id].loopStart = currentTime;
decks[id].loopEnd = currentTime + loopLength;
decks[id].loopActive = true;
decks[id].activeAutoLoop = beats;
console.log(`[Deck ${id}] Auto-loop set: ${beats} beats = ${loopLength.toFixed(3)}s at ${bpm} BPM`);
// Update UI
updateAutoLoopButtons(id);
drawWaveform(id);
// Restart playback to apply loop immediately
if (decks[id].playing) {
seekTo(id, currentTime);
}
}
// Update auto-loop button visual states
function updateAutoLoopButtons(id) {
const buttons = document.querySelectorAll(`#deck-${id} .auto-loop-btn`);
buttons.forEach(btn => {
const btnBeats = parseFloat(btn.dataset.beats);
if (decks[id].activeAutoLoop === btnBeats && decks[id].loopActive) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function pitchBend(id, amount) {
if (!audioCtx || !decks[id].localSource) return;
const slider = document.querySelector(`#deck-${id} .speed-slider`);
const baseSpeed = parseFloat(slider.value);
// Re-anchor
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
decks[id].lastAnchorPosition += realElapsed * decks[id].localSource.playbackRate.value;
decks[id].lastAnchorTime = audioCtx.currentTime;
if (amount !== 0) {
decks[id].localSource.playbackRate.value = baseSpeed + amount;
} else {
decks[id].localSource.playbackRate.value = baseSpeed;
}
}
function syncDecks(id) {
const otherDeck = id === 'A' ? 'B' : 'A';
if (!decks[id].bpm || !decks[otherDeck].bpm) return;
// Calculate ratio to make other deck match this deck
const ratio = decks[id].bpm / decks[otherDeck].bpm;
const slider = document.querySelector(`#deck-${otherDeck} .speed-slider`);
if (slider) slider.value = ratio;
changeSpeed(otherDeck, ratio);
}
function updateCrossfader(val) {
const volA = (100 - val) / 100;
const volB = val / 100;
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
if (decks.B.crossfaderGain) decks.B.crossfaderGain.gain.value = volB;
}
// Library Functions
// ---------------------------------------------------------------------------
// Tauri v2 asset-protocol helper
// When running inside Tauri (window.__TAURI__ is injected via withGlobalTauri)
// and the server has provided an absolutePath for the track, we convert it to
// an asset:// URL so the WebView reads the file directly from disk — no Flask
// round-trip, works with any folder under $HOME.
// Falls back to the ordinary server URL when not in Tauri or no absolutePath.
// ---------------------------------------------------------------------------
function tauriResolve(track) {
const cvt = window.__TAURI__?.core?.convertFileSrc;
if (cvt && track.absolutePath) {
return cvt(track.absolutePath);
}
return track.file;
}
async function fetchLibrary() {
try {
// In Tauri: scan disk directly via native Rust commands — no Flask needed.
if (window.__TAURI__?.core?.invoke) {
const musicDir = await window.__TAURI__.core.invoke('get_music_folder');
allSongs = await window.__TAURI__.core.invoke('scan_library', { musicDir });
renderLibrary(allSongs);
return;
}
// Browser / Flask fallback
const res = await fetch('library.json?t=' + new Date().getTime());
allSongs = await res.json();
renderLibrary(allSongs);
} catch (e) {
console.error("Library fetch failed", e);
}
}
function renderLibrary(songs) {
if (!songs) songs = allSongs;
const list = document.getElementById('library-list');
list.innerHTML = '';
const filteredSongs = songs;
if (filteredSongs.length === 0) {
list.innerHTML = `<div style="padding:20px; text-align:center; opacity:0.5;">No tracks found.</div>`;
return;
}
filteredSongs.forEach(t => {
const item = document.createElement('div');
item.className = 'track-row';
item.draggable = true;
// Drag data
item.ondragstart = (e) => {
e.dataTransfer.setData('trackFile', tauriResolve(t));
e.dataTransfer.setData('trackTitle', t.title);
e.dataTransfer.setData('source', 'library');
item.classList.add('dragging');
};
item.ondragend = () => {
item.classList.remove('dragging');
};
const trackName = document.createElement('span');
trackName.className = 'track-name';
trackName.textContent = t.title; // Safe text assignment
const loadActions = document.createElement('div');
loadActions.className = 'load-actions';
// LOAD buttons
const btnA = document.createElement('button');
btnA.className = 'load-btn btn-a';
btnA.textContent = 'LOAD A';
btnA.addEventListener('click', () => loadFromServer('A', tauriResolve(t), t.title));
const btnB = document.createElement('button');
btnB.className = 'load-btn btn-b';
btnB.textContent = 'LOAD B';
btnB.addEventListener('click', () => loadFromServer('B', tauriResolve(t), t.title));
// QUEUE buttons
const queueA = document.createElement('button');
queueA.className = 'load-btn queue-btn-a';
queueA.textContent = 'Q-A';
queueA.title = 'Add to Queue A';
queueA.addEventListener('click', () => addToQueue('A', tauriResolve(t), t.title));
const queueB = document.createElement('button');
queueB.className = 'load-btn queue-btn-b';
queueB.textContent = 'Q-B';
queueB.title = 'Add to Queue B';
queueB.addEventListener('click', () => addToQueue('B', tauriResolve(t), t.title));
loadActions.appendChild(btnA);
loadActions.appendChild(queueA);
loadActions.appendChild(btnB);
loadActions.appendChild(queueB);
item.appendChild(trackName);
item.appendChild(loadActions);
// Add data attribute for highlighting — store the resolved URL so
// updateLibraryHighlighting() matches decks.X.currentFile correctly.
item.dataset.file = tauriResolve(t);
list.appendChild(item);
});
// Update highlighting after rendering
updateLibraryHighlighting();
}
// Update library highlighting to show which tracks are loaded
function updateLibraryHighlighting() {
const trackRows = document.querySelectorAll('.track-row');
trackRows.forEach(row => {
const file = row.dataset.file;
// Remove all deck classes
row.classList.remove('loaded-deck-a', 'loaded-deck-b', 'loaded-both');
const onDeckA = decks.A.currentFile && decks.A.currentFile.includes(file);
const onDeckB = decks.B.currentFile && decks.B.currentFile.includes(file);
if (onDeckA && onDeckB) {
row.classList.add('loaded-both');
} else if (onDeckA) {
row.classList.add('loaded-deck-a');
} else if (onDeckB) {
row.classList.add('loaded-deck-b');
}
});
}
function filterLibrary() {
const query = document.getElementById('lib-search').value.toLowerCase();
const filtered = allSongs.filter(s => s.title.toLowerCase().includes(query));
renderLibrary(filtered);
}
function refreshLibrary() {
fetchLibrary();
}
async function loadFromServer(id, url, title) {
const d = document.getElementById('display-' + id);
d.innerText = '[WAIT] LOADING...';
d.classList.add('blink');
console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
const wasPlaying = decks[id].playing;
const wasBroadcasting = isBroadcasting;
if (wasPlaying && !wasBroadcasting) {
pauseDeck(id);
console.log(`[Deck ${id}] Paused for song load`);
} else if (wasPlaying && wasBroadcasting) {
console.log(`[Deck ${id}] [LIVE] BROADCAST MODE: Keeping deck playing during load to maintain stream`);
}
decks[id].waveformData = null;
decks[id].bpm = null;
decks[id].cues = {};
decks[id].pausedAt = 0;
decks[id].currentFile = url;
// Clear UI state
const bpmEl = document.getElementById('bpm-' + id);
if (bpmEl) bpmEl.textContent = '';
const cueBtns = document.querySelectorAll(`#deck-${id} .cue-btn`);
cueBtns.forEach(btn => btn.classList.remove('cue-set'));
try {
// Fetch the audio file
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
console.log(`[Deck ${id}] Fetch successful, decoding audio...`);
const arrayBuffer = await res.arrayBuffer();
// Decode audio data
let buffer;
try {
buffer = await audioCtx.decodeAudioData(arrayBuffer);
} catch (decodeError) {
console.error(`[Deck ${id}] Audio decode failed:`, decodeError);
throw new Error(`Cannot decode audio file`);
}
console.log(`[Deck ${id}] Successfully decoded! Duration: ${buffer.duration}s`);
// Track is now ready!
decks[id].localBuffer = buffer;
decks[id].duration = buffer.duration;
decks[id].lastAnchorPosition = 0;
// Update UI - track is ready!
d.innerText = title.toUpperCase();
d.classList.remove('blink');
document.getElementById('time-total-' + id).textContent = formatTime(buffer.duration);
document.getElementById('time-current-' + id).textContent = '0:00';
console.log(`[Deck ${id}] Ready to play!`);
// AUTO-RESUME for broadcast continuity
if (wasPlaying && wasBroadcasting) {
console.log(`[Deck ${id}] Auto-resuming playback to maintain broadcast stream`);
// Small delay to ensure buffer is fully ready
setTimeout(() => {
playDeck(id);
}, 50);
}
// Update library highlight
updateLibraryHighlighting();
// Process waveform and BPM in background
const processInBackground = () => {
try {
console.log(`[Deck ${id}] Starting background processing...`);
// Generate waveform
const startWave = performance.now();
decks[id].waveformData = generateWaveformData(buffer);
console.log(`[Deck ${id}] Waveform generated in ${(performance.now() - startWave).toFixed(0)}ms`);
drawWaveform(id);
// Detect BPM
const startBPM = performance.now();
decks[id].bpm = detectBPM(buffer);
console.log(`[Deck ${id}] BPM detected in ${(performance.now() - startBPM).toFixed(0)}ms`);
if (decks[id].bpm) {
const bpmEl = document.getElementById('bpm-' + id);
if (bpmEl) bpmEl.textContent = `${decks[id].bpm} BPM`;
}
} catch (bgError) {
console.warn(`[Deck ${id}] Background processing error:`, bgError);
}
};
if (window.requestIdleCallback) {
requestIdleCallback(processInBackground, { timeout: 2000 });
} else {
setTimeout(processInBackground, 50);
}
} catch (error) {
console.error(`[Deck ${id}] Load error:`, error);
d.innerText = 'LOAD ERROR';
d.classList.remove('blink');
showToast(`Deck ${id}: Failed to load track — ${error.message}`, 'error');
setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000);
}
}
// Settings
function toggleSettings() {
const p = document.getElementById('settings-panel');
p.classList.toggle('active');
}
// File Upload
// File Upload with Progress and Parallelism
async function handleFileUpload(event) {
const files = Array.from(event.target.files);
if (!files || files.length === 0) return;
// In Tauri: files are already local — no server upload needed.
// Create session blob URLs and add directly to the in-memory library.
if (window.__TAURI__?.core?.invoke) {
const allowed = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
let added = 0;
files.forEach(file => {
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowed.includes(ext)) return;
const blobUrl = URL.createObjectURL(file);
const title = file.name.replace(/\.[^.]+$/, '');
// absolutePath is null — tauriResolve will fall back to the blob URL.
allSongs.push({ title, file: blobUrl, absolutePath: null });
added++;
});
if (added > 0) {
renderLibrary(allSongs);
showToast(`${added} track(s) loaded into library`, 'success');
}
return;
}
console.log(`Uploading ${files.length} file(s)...`);
// Create/Show progress container
let progressContainer = document.getElementById('upload-progress-container');
if (!progressContainer) {
progressContainer = document.createElement('div');
progressContainer.id = 'upload-progress-container';
progressContainer.className = 'upload-progress-container';
document.body.appendChild(progressContainer);
}
progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>';
progressContainer.classList.add('active');
const existingFilenames = allSongs.map(s => s.file.split('/').pop().toLowerCase());
const uploadPromises = files.map(async (file) => {
const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowedExts.includes(ext)) {
console.warn(`${file.name} is not a supported audio file`);
return;
}
// Check for duplicates
if (existingFilenames.includes(file.name.toLowerCase())) {
console.log(`[UPLOAD] Skipping duplicate: ${file.name}`);
showToast(`Skipped duplicate: ${file.name}`, 'info');
return;
}
const formData = new FormData();
formData.append('file', file);
const progressRow = document.createElement('div');
progressRow.className = 'upload-progress-row';
const nameSpan = document.createElement('span');
nameSpan.textContent = file.name.substring(0, 20) + (file.name.length > 20 ? '...' : '');
const barWrap = document.createElement('div');
barWrap.className = 'progress-bar-wrap';
const barInner = document.createElement('div');
barInner.className = 'progress-bar-inner';
barInner.style.width = '0%';
barWrap.appendChild(barInner);
progressRow.appendChild(nameSpan);
progressRow.appendChild(barWrap);
progressContainer.appendChild(progressRow);
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
barInner.style.width = percent + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText);
if (result.success) {
barInner.style.background = '#00ff88';
} else {
barInner.style.background = '#ff4444';
nameSpan.title = result.error || 'Upload failed';
console.error(`[UPLOAD] ${file.name}: ${result.error}`);
}
} catch (e) {
barInner.style.background = '#ff4444';
console.error(`[UPLOAD] Bad response for ${file.name}`);
}
} else {
barInner.style.background = '#ff4444';
nameSpan.title = `HTTP ${xhr.status}`;
console.error(`[UPLOAD] ${file.name}: HTTP ${xhr.status}${xhr.status === 413 ? ' — file too large (nginx limit)' : ''}`);
}
resolve(); // Always resolve so other uploads continue
};
xhr.onerror = () => {
barInner.style.background = '#ff4444';
console.error(`[UPLOAD] ${file.name}: Network error`);
resolve();
};
xhr.send(formData);
});
});
// Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all)
try {
await Promise.all(uploadPromises);
console.log('All uploads finished.');
} catch (e) {
console.error('Some uploads failed', e);
}
// Refresh library
setTimeout(() => {
fetchLibrary();
setTimeout(() => {
progressContainer.classList.remove('active');
vibrate(30);
}, 2000);
}, 500);
// Clear the input
event.target.value = '';
}
// Folder Selection Logic
async function openFolderPicker() {
let picker = document.getElementById('folder-picker-modal');
if (!picker) {
picker = document.createElement('div');
picker.id = 'folder-picker-modal';
picker.className = 'modal-overlay';
picker.innerHTML = `
<div class="modal-card folder-picker-card">
<div class="modal-header">
<span>SELECT MUSIC FOLDER</span>
<button onclick="closeFolderPicker()">X</button>
</div>
<div class="modal-body">
<div class="path-nav">
<button onclick="browseToPath('..')">UP</button>
<input type="text" id="current-folder-path" readonly>
</div>
<div id="dir-list" class="dir-list"></div>
</div>
<div class="modal-footer">
<button class="btn-primary" onclick="confirmFolderSelection()">USE THIS FOLDER</button>
</div>
</div>
`;
document.body.appendChild(picker);
}
picker.classList.add('active');
// Initial browse to home or current
browseToPath('');
}
function closeFolderPicker() {
document.getElementById('folder-picker-modal').classList.remove('active');
}
async function browseToPath(targetPath) {
const currentInput = document.getElementById('current-folder-path');
let path = currentInput.value;
if (targetPath === '..') {
// Handled by server if we send '..' but let's be explicit
const parts = path.split('/');
parts.pop();
path = parts.join('/') || '/';
} else if (targetPath) {
path = targetPath;
}
try {
let data;
if (window.__TAURI__?.core?.invoke) {
data = await window.__TAURI__.core.invoke('list_dirs', { path });
} else {
const res = await fetch(`/browse_directories?path=${encodeURIComponent(path)}`);
data = await res.json();
}
if (data.success) {
currentInput.value = data.path;
const list = document.getElementById('dir-list');
list.innerHTML = '';
data.entries.forEach(entry => {
const div = document.createElement('div');
div.className = 'dir-entry';
div.innerHTML = `<span class="dir-icon">[DIR]</span> ${entry.name}`;
div.onclick = () => browseToPath(entry.path);
list.appendChild(div);
});
}
} catch (e) {
console.error("Browse failed", e);
}
}
async function confirmFolderSelection() {
const path = document.getElementById('current-folder-path').value;
try {
let result;
if (window.__TAURI__?.core?.invoke) {
result = await window.__TAURI__.core.invoke('save_music_folder', { path });
} else {
const res = await fetch('/update_settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ library: { music_folder: path } })
});
result = await res.json();
}
if (result.success) {
alert('Music folder updated! Refreshing library...');
closeFolderPicker();
fetchLibrary();
} else {
alert('Error: ' + (result.error || 'Unknown error'));
}
} catch (e) {
alert('Failed to update settings');
}
}
function toggleRepeat(id, val) {
if (val === undefined) {
settings[`repeat${id}`] = !settings[`repeat${id}`];
} else {
settings[`repeat${id}`] = val;
}
// Update UI
const btn = document.getElementById(`repeat-btn-${id}`);
const checkbox = document.getElementById(`repeat-${id}`);
if (btn) {
btn.classList.toggle('active', settings[`repeat${id}`]);
btn.textContent = settings[`repeat${id}`] ? 'LOOP ON' : 'LOOP';
}
if (checkbox) checkbox.checked = settings[`repeat${id}`];
console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`);
vibrate(10);
saveSettings();
}
function toggleAutoMix(val) { settings.autoMix = val; saveSettings(); }
function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); }
function toggleQuantize(val) { settings.quantize = val; saveSettings(); }
function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); }
function updateManualGlow(id, val) {
settings[`glow${id}`] = val;
if (val) {
document.body.classList.add(`playing-${id}`);
} else {
document.body.classList.remove(`playing-${id}`);
}
saveSettings();
}
function updateGlowIntensity(val) {
settings.glowIntensity = parseInt(val);
const opacity = settings.glowIntensity / 100;
const spread = (settings.glowIntensity / 100) * 80;
document.documentElement.style.setProperty('--glow-opacity', opacity);
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
saveSettings();
}
function updateListenerGlow(val) {
settings.listenerGlowIntensity = parseInt(val);
if (!socket) initSocket();
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
saveSettings();
}
// Dismiss landscape prompt
function dismissLandscapePrompt() {
const prompt = document.getElementById('landscape-prompt');
if (prompt) {
prompt.classList.add('dismissed');
// Store preference in localStorage
localStorage.setItem('landscapePromptDismissed', 'true');
}
}
// Check if prompt was previously dismissed
window.addEventListener('DOMContentLoaded', () => {
const wasDismissed = localStorage.getItem('landscapePromptDismissed');
if (wasDismissed === 'true') {
const prompt = document.getElementById('landscape-prompt');
if (prompt) prompt.classList.add('dismissed');
}
// Sync all restored settings to UI controls
const syncCheckbox = (elId, val) => { const el = document.getElementById(elId); if (el) el.checked = !!val; };
const syncRange = (elId, val) => { const el = document.getElementById(elId); if (el && val != null) el.value = val; };
syncCheckbox('repeat-A', settings.repeatA);
syncCheckbox('repeat-B', settings.repeatB);
syncCheckbox('auto-mix', settings.autoMix);
syncCheckbox('shuffle-mode', settings.shuffleMode);
syncCheckbox('quantize', settings.quantize);
syncCheckbox('auto-play', settings.autoPlay);
syncCheckbox('glow-A', settings.glowA);
syncCheckbox('glow-B', settings.glowB);
syncRange('glow-intensity', settings.glowIntensity);
syncRange('listener-glow-intensity', settings.listenerGlowIntensity);
// Initialise glow intensity CSS variables
updateGlowIntensity(settings.glowIntensity);
// Apply initial glow state
updateManualGlow('A', settings.glowA);
updateManualGlow('B', settings.glowB);
// Set stream URL in the streaming panel
const streamInput = document.getElementById('stream-url');
if (streamInput) {
const _autoDetectListenerUrl = () => {
const host = window.location.hostname;
if (host.startsWith('dj.')) {
return `${window.location.protocol}//${host.slice(3)}`;
}
return `${window.location.protocol}//${host}:5001`;
};
fetch('/client_config')
.then(r => r.json())
.then(cfg => {
streamInput.value = cfg.listener_url || _autoDetectListenerUrl();
})
.catch(() => {
streamInput.value = _autoDetectListenerUrl();
});
}
});
// ========== LIVE STREAMING FUNCTIONALITY ==========
let socket = null;
let streamDestination = null;
let streamProcessor = null;
let mediaRecorder = null;
let isBroadcasting = false;
let autoStartStream = false;
let currentStreamMimeType = null;
// Initialise SocketIO connection
function initSocket() {
if (socket) return socket;
// Socket.IO is loaded from a CDN; in Tauri (offline) it may not be available.
if (typeof io === 'undefined') {
console.warn('[SOCKET] Socket.IO not loaded — live streaming is unavailable');
return null;
}
const serverUrl = window.location.origin;
console.log(`[SOCKET] Connecting to ${serverUrl}`);
socket = io(serverUrl, {
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000
});
socket.on('connect', () => {
console.log('[OK] Connected to streaming server');
socket.emit('get_listener_count');
});
socket.on('connect_error', (error) => {
console.error('[ERROR] Connection error:', error.message);
});
socket.on('disconnect', (reason) => {
console.warn(`[WARN] Disconnected: ${reason}`);
});
socket.on('listener_count', (data) => {
const el = document.getElementById('listener-count');
if (el) el.textContent = data.count;
});
socket.on('broadcast_started', () => {
console.log('[EVENT] broadcast_started');
// Update relay UI if it's a relay
const relayStatus = document.getElementById('relay-status');
if (relayStatus && relayStatus.textContent.includes('Connecting')) {
relayStatus.textContent = 'Relay active - streaming to listeners';
relayStatus.style.color = '#00ff00';
}
});
socket.on('broadcast_stopped', () => {
console.log('[EVENT] broadcast_stopped');
// Reset relay UI if it was active
const startRelayBtn = document.getElementById('start-relay-btn');
const stopRelayBtn = document.getElementById('stop-relay-btn');
const relayStatus = document.getElementById('relay-status');
if (startRelayBtn) startRelayBtn.style.display = 'inline-block';
if (stopRelayBtn) stopRelayBtn.style.display = 'none';
if (relayStatus) relayStatus.textContent = '';
});
socket.on('mixer_status', (data) => {
updateUIFromMixerStatus(data);
});
socket.on('deck_status', (data) => {
console.log(`Server: Deck ${data.deck_id} status update:`, data);
// This is handled by a single status update too, but helpful for immediate feedback
});
socket.on('error', (data) => {
console.error('Server error:', data.message);
alert(`SERVER ERROR: ${data.message}`);
// Reset relay UI on error
const startRelayBtn = document.getElementById('start-relay-btn');
const stopRelayBtn = document.getElementById('stop-relay-btn');
const relayStatus = document.getElementById('relay-status');
if (startRelayBtn) startRelayBtn.style.display = 'inline-block';
if (stopRelayBtn) stopRelayBtn.style.display = 'none';
if (relayStatus) relayStatus.textContent = '';
});
return socket;
}
// Update DJ UI from server status
function updateUIFromMixerStatus(status) {
if (!status) return;
['A', 'B'].forEach(id => {
const deckStatus = id === 'A' ? status.deck_a : status.deck_b;
if (!deckStatus) return;
// Update position (only if not currently dragging the waveform)
const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0);
if (timeSinceSeek < 1500) {
// Seek Protection: Ignore server updates for 1.5s after manual seek
return;
}
// Update playing state
decks[id].playing = deckStatus.playing;
// Update loaded track if changed
if (deckStatus.filename && (!decks[id].currentFile || decks[id].currentFile !== deckStatus.filename)) {
console.log(`Server synced: Deck ${id} is playing ${deckStatus.filename}`);
decks[id].currentFile = deckStatus.filename;
decks[id].duration = deckStatus.duration;
// Update UI elements for track title
const titleEl = document.getElementById(`display-${id}`);
if (titleEl) {
const name = deckStatus.filename.split('/').pop().replace(/\.[^/.]+$/, "");
titleEl.textContent = name;
}
}
// Sync playback rate for interpolation
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
if (speedSlider) speedSlider.value = deckStatus.pitch;
// Update anchor for local interpolation
decks[id].lastAnchorPosition = deckStatus.position;
decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0;
if (!decks[id].playing) {
decks[id].pausedAt = deckStatus.position;
}
// Forced UI update to snap to server reality
const currentPos = getCurrentPosition(id);
const progress = (currentPos / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
const timer = document.getElementById('time-current-' + id);
if (timer) timer.textContent = formatTime(currentPos);
});
// Update crossfader if changed significantly
if (status.crossfader !== undefined) {
const cf = document.getElementById('crossfader');
if (cf && Math.abs(parseInt(cf.value) - status.crossfader) > 1) {
cf.value = status.crossfader;
updateCrossfader(status.crossfader);
}
}
}
// Toggle streaming panel
function toggleStreamingPanel() {
const panel = document.getElementById('streaming-panel');
panel.classList.toggle('active');
// Initialise socket when panel is opened
if (panel.classList.contains('active') && !socket) {
initSocket();
}
}
// Toggle broadcast
function toggleBroadcast() {
if (!audioCtx) {
alert('Please initialise the system first (click INITIALIZE SYSTEM)');
return;
}
if (!socket) initSocket();
if (isBroadcasting) {
stopBroadcast();
} else {
startBroadcast();
}
}
// Start broadcasting
function startBroadcast() {
try {
console.log('Starting broadcast...');
if (!audioCtx) {
alert('Please initialise the system first!');
return;
}
// Browser-side audio mode
// Check if any audio is playing
const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) {
console.warn('WARNING: No decks are currently playing! Start playing a track for audio to stream.');
}
// Create MediaStreamDestination to capture audio output
streamDestination = audioCtx.createMediaStreamDestination();
// IMPORTANT: Properly manage audio graph connections
// Disconnect from speakers first, then connect to stream AND speakers
// This prevents dual-connection instability
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck A crossfader already disconnected');
}
decks.A.crossfaderGain.connect(streamDestination);
decks.A.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('[OK] Deck A connected to stream + speakers');
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect();
} catch (e) {
console.warn('Deck B crossfader already disconnected');
}
decks.B.crossfaderGain.connect(streamDestination);
decks.B.crossfaderGain.connect(audioCtx.destination); // Re-add for local monitoring
console.log('[OK] Deck B connected to stream + speakers');
}
// Verify stream has audio tracks
const stream = streamDestination.stream;
console.log(`[DATA] Stream tracks: ${stream.getAudioTracks().length} audio tracks`);
if (stream.getAudioTracks().length === 0) {
throw new Error('No audio tracks in stream! Audio routing failed.');
}
// Get selected quality from dropdown
const qualitySelect = document.getElementById('stream-quality');
const selectedBitrate = parseInt(qualitySelect.value) * 1000; // Convert kbps to bps
console.log(`Starting broadcast at ${qualitySelect.value}kbps`);
const preferredTypes = [
// Prefer MP4/AAC when available (broad device support)
'audio/mp4;codecs=mp4a.40.2',
'audio/mp4',
// Fallbacks
'audio/webm',
'audio/ogg',
];
const chosenType = preferredTypes.find((t) => {
try {
return MediaRecorder.isTypeSupported(t);
} catch {
return false;
}
});
if (!chosenType) {
throw new Error('No supported MediaRecorder mimeType found on this browser');
}
currentStreamMimeType = chosenType;
console.log(`Using broadcast mimeType: ${currentStreamMimeType}`);
mediaRecorder = new MediaRecorder(stream, {
mimeType: currentStreamMimeType,
audioBitsPerSecond: selectedBitrate
});
let chunkCount = 0;
let lastLogTime = Date.now();
let silenceWarningShown = false;
// Send audio chunks via SocketIO
mediaRecorder.ondataavailable = async (event) => {
if (event.data.size > 0 && isBroadcasting && socket) {
chunkCount++;
// Warn if chunks are too small (likely silence)
if (event.data.size < 100 && !silenceWarningShown) {
console.warn('Audio chunks are very small - might be silence. Make sure audio is playing!');
silenceWarningShown = true;
}
// Convert blob to array buffer for transmission
const buffer = await event.data.arrayBuffer();
socket.emit('audio_chunk', buffer); // Send raw ArrayBuffer directly
// Log every second
const now = Date.now();
if (now - lastLogTime > 1000) {
console.log(`Broadcasting: ${chunkCount} chunks sent (${(event.data.size / 1024).toFixed(1)} KB/chunk)`);
lastLogTime = now;
// Reset silence warning
if (event.data.size > 100) {
silenceWarningShown = false;
}
}
} else {
// Debug why chunks aren't being sent
if (event.data.size === 0) {
console.warn('Received empty audio chunk');
}
if (!isBroadcasting) {
console.warn('Broadcasting flag is false');
}
if (!socket) {
console.warn('Socket not connected');
}
}
};
mediaRecorder.onerror = (error) => {
console.error('[ERROR] MediaRecorder error:', error);
// Try to recover from error
if (isBroadcasting) {
console.log('Attempting to recover from MediaRecorder error...');
setTimeout(() => {
if (isBroadcasting) {
restartBroadcast();
}
}, 2000);
}
};
mediaRecorder.onstart = () => {
console.log('[OK] MediaRecorder started');
};
mediaRecorder.onstop = (event) => {
console.warn('MediaRecorder stopped!');
console.log(` State: ${mediaRecorder.state}`);
console.log(` isBroadcasting flag: ${isBroadcasting}`);
// If we're supposed to be broadcasting but MediaRecorder stopped, restart it
if (isBroadcasting) {
console.error('[ERROR] MediaRecorder stopped unexpectedly while broadcasting!');
console.log('Auto-recovery: Attempting to restart broadcast in 2 seconds...');
setTimeout(() => {
if (isBroadcasting) {
console.log('Executing auto-recovery...');
restartBroadcast();
}
}, 2000);
}
};
mediaRecorder.onpause = (event) => {
console.warn('MediaRecorder paused unexpectedly!');
// If we're broadcasting and MediaRecorder paused, resume it
if (isBroadcasting && mediaRecorder.state === 'paused') {
console.log('Auto-resuming MediaRecorder...');
try {
mediaRecorder.resume();
console.log('[OK] MediaRecorder resumed');
} catch (e) {
console.error('[ERROR] Failed to resume MediaRecorder:', e);
// If resume fails, try full restart
setTimeout(() => {
if (isBroadcasting) {
restartBroadcast();
}
}, 1000);
}
}
};
// 250ms chunks: More frequent smaller chunks reduces stall gaps on weak connections.
// A 1-second chunk creates a 1-second starvation gap if the network hiccups;
// 250ms chunks keep the server fed 4x more often.
// Notify server FIRST so broadcast_state is active on the server before
// the first audio_chunk arrives — prevents the first ~250 ms of audio
// being silently dropped by the server's isinstance() guard.
if (!socket) initSocket();
const bitrateValue = document.getElementById('stream-quality').value + 'k';
socket.emit('start_broadcast', { bitrate: bitrateValue });
socket.emit('get_listener_count');
// Validate state before starting
if (mediaRecorder.state === 'inactive') {
mediaRecorder.start(250);
streamProcessor = mediaRecorder;
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
} else {
console.error('[ERROR] Cannot start MediaRecorder - already in state:', mediaRecorder.state);
throw new Error(`MediaRecorder is already ${mediaRecorder.state}`);
}
// Update UI
isBroadcasting = true;
document.getElementById('broadcast-btn').classList.add('active');
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
document.getElementById('broadcast-status').textContent = 'LIVE';
document.getElementById('broadcast-status').classList.add('live');
console.log('[OK] Broadcasting started successfully!');
console.log('TIP TIP: Play a track on Deck A or B to stream audio');
// Monitor audio levels
setTimeout(() => {
if (chunkCount === 0) {
console.error('[ERROR] NO AUDIO CHUNKS after 2 seconds! Check:');
console.error(' 1. Is audio playing on either deck?');
console.error(' 2. Is volume turned up?');
console.error(' 3. Is crossfader in the middle?');
}
}, 2000);
} catch (error) {
console.error('[ERROR] Failed to start broadcast:', error);
alert('Failed to start broadcast: ' + error.message);
isBroadcasting = false;
}
}
// Stop broadcasting
function stopBroadcast() {
console.log('[BROADCAST] Stopping...');
if (streamProcessor) {
streamProcessor.stop();
streamProcessor = null;
}
if (streamDestination) {
// Only disconnect the specific stream connection — do NOT call .disconnect()
// with no args as that also removes the audioCtx.destination connection and
// causes an audible pop / silence gap for locally-monitored decks.
if (decks.A.crossfaderGain) {
try { decks.A.crossfaderGain.disconnect(streamDestination); }
catch (e) { console.warn('Error disconnecting Deck A from stream:', e); }
}
if (decks.B.crossfaderGain) {
try { decks.B.crossfaderGain.disconnect(streamDestination); }
catch (e) { console.warn('Error disconnecting Deck B from stream:', e); }
}
streamDestination = null;
}
isBroadcasting = false;
if (socket) {
socket.emit('stop_broadcast');
}
// Update UI
document.getElementById('broadcast-btn').classList.remove('active');
document.getElementById('broadcast-text').textContent = 'START BROADCAST';
document.getElementById('broadcast-status').textContent = 'Offline';
document.getElementById('broadcast-status').classList.remove('live');
console.log('[OK] Broadcast stopped');
}
// Restart broadcasting (for auto-recovery)
function restartBroadcast() {
console.log('Restarting broadcast...');
// Clean up old MediaRecorder without changing UI state
if (streamProcessor) {
try {
streamProcessor.stop();
} catch (e) {
console.warn('Could not stop old MediaRecorder:', e);
}
streamProcessor = null;
}
// Clean up old stream destination — only disconnect the stream leg,
// not the audioCtx.destination leg, to avoid an audible glitch.
if (streamDestination) {
if (decks.A.crossfaderGain) {
try { decks.A.crossfaderGain.disconnect(streamDestination); }
catch (e) { console.warn('Error cleaning up Deck A:', e); }
}
if (decks.B.crossfaderGain) {
try { decks.B.crossfaderGain.disconnect(streamDestination); }
catch (e) { console.warn('Error cleaning up Deck B:', e); }
}
streamDestination = null;
}
// Preserve broadcasting state
const wasBroadcasting = isBroadcasting;
isBroadcasting = false; // Temporarily set to false so startBroadcast works
// Small delay to ensure cleanup completes
setTimeout(() => {
if (wasBroadcasting) {
startBroadcast();
console.log('[OK] Broadcast restarted successfully');
}
}, 100);
}
// Copy stream URL to clipboard
function copyStreamUrl(evt) {
const urlInput = document.getElementById('stream-url');
const text = urlInput.value;
const btn = evt?.target;
const showFeedback = (success) => {
if (!btn) return;
const originalText = btn.textContent;
btn.textContent = success ? 'OK' : 'FAIL';
setTimeout(() => { btn.textContent = originalText; }, 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text)
.then(() => showFeedback(true))
.catch(() => showFeedback(false));
} else {
// Fallback for older browsers
urlInput.select();
urlInput.setSelectionRange(0, 99999);
try {
document.execCommand('copy');
showFeedback(true);
} catch (err) {
console.error('Failed to copy:', err);
showFeedback(false);
}
}
}
// Toggle auto-start stream
function toggleAutoStream(enabled) {
autoStartStream = enabled;
localStorage.setItem('autoStartStream', enabled);
}
// Load auto-start preference
window.addEventListener('load', () => {
const autoStart = localStorage.getItem('autoStartStream');
if (autoStart === 'true') {
document.getElementById('auto-start-stream').checked = true;
autoStartStream = true;
}
});
// Auto-crossfade: smoothly transitions from the ending deck to the other deck.
let _autoMixTimer = null;
function startAutoMixFade(endingDeckId) {
const otherDeck = endingDeckId === 'A' ? 'B' : 'A';
// The other deck must have a track loaded and be playing (or about to play)
if (!decks[otherDeck].localBuffer) {
console.log(`[AutoMix] Other deck ${otherDeck} has no track loaded, skipping crossfade`);
return false;
}
// If the other deck isn't playing, start it
if (!decks[otherDeck].playing) {
playDeck(otherDeck);
}
// Cancel any existing auto-mix animation
if (_autoMixTimer) {
clearInterval(_autoMixTimer);
_autoMixTimer = null;
}
const slider = document.getElementById('crossfader');
if (!slider) return false;
const target = endingDeckId === 'A' ? 100 : 0; // Fade toward the OTHER deck
const duration = 5000; // 5 seconds
const steps = 50;
const interval = duration / steps;
const start = parseInt(slider.value);
const delta = (target - start) / steps;
let step = 0;
console.log(`[AutoMix] Crossfading from Deck ${endingDeckId} → Deck ${otherDeck} over ${duration / 1000}s`);
showToast(`Auto-crossfading to Deck ${otherDeck}`, 'info');
_autoMixTimer = setInterval(() => {
step++;
const val = Math.round(start + delta * step);
slider.value = val;
updateCrossfader(val);
if (step >= steps) {
clearInterval(_autoMixTimer);
_autoMixTimer = null;
console.log(`[AutoMix] Crossfade complete. Now on Deck ${otherDeck}`);
// Load next track on the ending deck so it's ready for the next crossfade
if (queues[endingDeckId] && queues[endingDeckId].length > 0) {
const next = queues[endingDeckId].shift();
renderQueue(endingDeckId);
loadFromServer(endingDeckId, next.file, next.title);
}
}
}, interval);
return true;
}
// Shared handler called both by onended and the monitor poll.
// Handles repeat, auto-crossfade, auto-play-from-queue, or stop.
function handleTrackEnd(id) {
// Already being handled or loop is active — skip
if (decks[id].loading || decks[id].loopActive) return;
if (isBroadcasting) {
console.log(`Track ending during broadcast on Deck ${id}`);
if (settings[`repeat${id}`]) {
console.log(`Repeating track on Deck ${id} (broadcast)`);
seekTo(id, 0);
return;
}
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
decks[id].loading = true;
const next = queues[id].shift();
renderQueue(id);
loadFromServer(id, next.file, next.title)
.then(() => { decks[id].loading = false; playDeck(id); })
.catch(() => { decks[id].loading = false; });
}
return;
}
if (settings[`repeat${id}`]) {
console.log(`Repeating track on Deck ${id}`);
seekTo(id, 0);
} else if (settings.autoMix) {
// Auto-crossfade takes priority over simple auto-play
decks[id].loading = true;
if (!startAutoMixFade(id)) {
// Crossfade not possible (other deck empty) — fall through to normal auto-play
decks[id].loading = false;
handleAutoPlay(id);
} else {
decks[id].loading = false;
}
} else if (settings.autoPlay) {
handleAutoPlay(id);
} else {
pauseDeck(id);
decks[id].pausedAt = 0;
}
}
function handleAutoPlay(id) {
decks[id].loading = true;
pauseDeck(id);
if (queues[id] && queues[id].length > 0) {
console.log(`Auto-play: loading next from Queue ${id}`);
const next = queues[id].shift();
renderQueue(id);
loadFromServer(id, next.file, next.title)
.then(() => { decks[id].loading = false; playDeck(id); })
.catch(() => { decks[id].loading = false; });
} else {
console.log(`Auto-play: queue empty on Deck ${id}, stopping`);
decks[id].loading = false;
decks[id].pausedAt = 0;
}
}
// Monitoring — safety net for cases where onended doesn't fire
// (e.g. AudioContext suspended, very short buffer, scrub to near-end).
function monitorTrackEnd() {
setInterval(() => {
if (!audioCtx) return;
['A', 'B'].forEach(id => {
if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
if (decks[id].loopActive) return;
const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
const current = decks[id].lastAnchorPosition + (elapsed * rate);
const remaining = decks[id].duration - current;
// Threshold scales with playback rate so we don't miss fast playback.
// Use 1.5× the poll interval (0.75s) as a safe window.
const threshold = Math.max(0.75, 0.75 * rate);
if (remaining <= threshold) {
console.log(`[Monitor] Deck ${id} near end (${remaining.toFixed(2)}s left) — triggering handleTrackEnd`);
handleTrackEnd(id);
}
});
}, 500);
}
monitorTrackEnd();
// Reset Deck to Default Settings
function resetDeck(id) {
vibrate(20);
console.log(`Resetting Deck ${id} to defaults...`);
if (!audioCtx) {
console.warn('AudioContext not initialised');
return;
}
// Reset EQ to neutral (0dB gain)
if (decks[id].filters) {
if (decks[id].filters.low) decks[id].filters.low.gain.value = 0;
if (decks[id].filters.mid) decks[id].filters.mid.gain.value = 0;
if (decks[id].filters.high) decks[id].filters.high.gain.value = 0;
// Reset UI sliders
const eqSliders = document.querySelectorAll(`#deck-${id} .eq-band input`);
eqSliders.forEach(slider => slider.value = 0); // 0 = neutral for -20/20 range
}
// Reset filters to fully open
if (decks[id].filters.lp) decks[id].filters.lp.frequency.value = 22050;
if (decks[id].filters.hp) decks[id].filters.hp.frequency.value = 0;
// Reset UI filter sliders
const lpSlider = document.querySelector(`#deck-${id} .filter-lp`);
const hpSlider = document.querySelector(`#deck-${id} .filter-hp`);
if (lpSlider) lpSlider.value = 100;
if (hpSlider) hpSlider.value = 0;
// Reset volume to 80%
if (decks[id].volumeGain) {
decks[id].volumeGain.gain.value = 0.8;
}
const volumeSlider = document.querySelector(`#deck-${id} .volume-fader`);
if (volumeSlider) volumeSlider.value = 80;
// Reset pitch/speed to 1.0 (100%)
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
if (speedSlider) {
speedSlider.value = 1.0;
// Use changeSpeed function to properly update playback rate
changeSpeed(id, 1.0);
}
// Clear all hot cues
decks[id].cues = {};
const cueBtns = document.querySelectorAll(`#deck-${id} .cue-btn`);
cueBtns.forEach(btn => {
btn.classList.remove('cue-set');
btn.style.animation = '';
});
// Clear loops
decks[id].loopStart = null;
decks[id].loopEnd = null;
decks[id].loopActive = false;
const loopBtns = document.querySelectorAll(`#deck-${id} .loop-controls button`);
loopBtns.forEach(btn => {
btn.style.background = '';
btn.style.borderColor = '';
});
// Redraw waveform to clear cue/loop markers
drawWaveform(id);
// Clear neon glow
document.body.classList.remove('playing-' + id);
console.log(`[OK] Deck ${id} reset complete!`);
// Visual feedback
const resetBtn = document.querySelector(`#deck-${id} .reset-btn`);
if (resetBtn) {
resetBtn.style.transform = 'rotate(360deg)';
resetBtn.style.transition = 'transform 0.5s ease';
setTimeout(() => {
resetBtn.style.transform = '';
}, 500);
}
}
// ==========================================
// QUEUE SYSTEM
// ==========================================
// Add track to queue
function addToQueue(deckId, file, title) {
queues[deckId].push({ file, title });
renderQueue(deckId);
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
}
// Remove track from queue
function removeFromQueue(deckId, index) {
const removed = queues[deckId].splice(index, 1)[0];
renderQueue(deckId);
console.log(`Removed "${removed.title}" from Queue ${deckId}`);
}
// Clear entire queue
function clearQueue(deckId) {
const count = queues[deckId].length;
queues[deckId] = [];
renderQueue(deckId);
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
}
// Load next track from queue
function loadNextFromQueue(deckId) {
if (queues[deckId].length === 0) {
console.log(`Queue ${deckId} is empty`);
return false;
}
const next = queues[deckId].shift();
console.log(`Loading next from Queue ${deckId}: "${next.title}"`);
loadFromServer(deckId, next.file, next.title);
renderQueue(deckId);
return true;
}
// Render queue UI
function renderQueue(deckId) {
const mainQueue = document.getElementById(`queue-list-${deckId}`);
const deckQueue = document.getElementById(`deck-queue-list-${deckId}`);
const targets = [mainQueue, deckQueue].filter(t => t !== null);
if (targets.length === 0) return;
const generateHTML = (isEmpty) => {
if (isEmpty) {
return '<div class="queue-empty">Queue is empty</div>';
}
return '';
};
targets.forEach(container => {
if (queues[deckId].length === 0) {
container.innerHTML = generateHTML(true);
} else {
container.innerHTML = '';
queues[deckId].forEach((track, index) => {
const item = document.createElement('div');
item.className = 'queue-item';
item.draggable = true;
const number = document.createElement('span');
number.className = 'queue-number';
number.textContent = (index + 1) + '.';
const title = document.createElement('span');
title.className = 'queue-track-title';
title.textContent = track.title;
const actions = document.createElement('div');
actions.className = 'queue-actions';
const loadBtn = document.createElement('button');
loadBtn.className = 'queue-load-btn';
loadBtn.textContent = 'PLAY';
loadBtn.title = 'Load now';
loadBtn.onclick = (e) => {
e.stopPropagation();
loadFromServer(deckId, track.file, track.title);
removeFromQueue(deckId, index);
};
const removeBtn = document.createElement('button');
removeBtn.className = 'queue-remove-btn';
removeBtn.textContent = 'X';
removeBtn.title = 'Remove from queue';
removeBtn.onclick = (e) => {
e.stopPropagation();
removeFromQueue(deckId, index);
};
actions.appendChild(loadBtn);
actions.appendChild(removeBtn);
item.appendChild(number);
item.appendChild(title);
item.appendChild(actions);
// Click to load also
item.onclick = () => {
loadFromServer(deckId, track.file, track.title);
removeFromQueue(deckId, index);
};
// Drag and drop reordering / moving
item.ondragstart = (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('queueIndex', index);
e.dataTransfer.setData('queueDeck', deckId);
// Also set track data so it can be dropped as a generic track
e.dataTransfer.setData('trackFile', track.file);
e.dataTransfer.setData('trackTitle', track.title);
item.classList.add('dragging');
};
item.ondragend = () => {
item.classList.remove('dragging');
};
item.ondragover = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
item.ondrop = (e) => {
e.preventDefault();
const fromIndex = e.dataTransfer.getData('queueIndex');
const fromDeck = e.dataTransfer.getData('queueDeck');
const trackFile = e.dataTransfer.getData('trackFile');
const trackTitle = e.dataTransfer.getData('trackTitle');
if (fromDeck !== "" && fromIndex !== "") {
// Move from a queue (same or different)
const srcIdx = parseInt(fromIndex);
const [movedItem] = queues[fromDeck].splice(srcIdx, 1);
// If same deck and after removal index changes, adjust target index if needed?
// Actually splice(index, 0, item) works fine if we handle same deck carefully.
let targetIdx = index;
queues[deckId].splice(targetIdx, 0, movedItem);
renderQueue(deckId);
if (fromDeck !== deckId) renderQueue(fromDeck);
console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`);
} else if (trackFile && trackTitle) {
// Drop from library into middle of queue
queues[deckId].splice(index, 0, { file: trackFile, title: trackTitle });
renderQueue(deckId);
console.log(`Inserted library track into Queue ${deckId} at index ${index}: ${trackTitle}`);
}
};
container.appendChild(item);
});
}
});
}
// ==========================================
// KEYBOARD SHORTCUTS SYSTEM
// ==========================================
// Default keyboard mappings (can be customized)
const DEFAULT_KEYBOARD_MAPPINGS = {
// Deck A Controls
'q': { action: 'playDeckA', label: 'Play Deck A' },
'a': { action: 'pauseDeckA', label: 'Pause Deck A' },
'1': { action: 'cueA1', label: 'Deck A Cue 1' },
'2': { action: 'cueA2', label: 'Deck A Cue 2' },
'3': { action: 'cueA3', label: 'Deck A Cue 3' },
'4': { action: 'cueA4', label: 'Deck A Cue 4' },
'z': { action: 'loopInA', label: 'Loop IN Deck A' },
'x': { action: 'loopOutA', label: 'Loop OUT Deck A' },
'c': { action: 'loopExitA', label: 'Loop EXIT Deck A' },
'r': { action: 'resetDeckA', label: 'Reset Deck A' },
// Deck B Controls
'w': { action: 'playDeckB', label: 'Play Deck B' },
's': { action: 'pauseDeckB', label: 'Pause Deck B' },
'7': { action: 'cueB1', label: 'Deck B Cue 1' },
'8': { action: 'cueB2', label: 'Deck B Cue 2' },
'9': { action: 'cueB3', label: 'Deck B Cue 3' },
'0': { action: 'cueB4', label: 'Deck B Cue 4' },
'n': { action: 'loopInB', label: 'Loop IN Deck B' },
'm': { action: 'loopOutB', label: 'Loop OUT Deck B' },
',': { action: 'loopExitB', label: 'Loop EXIT Deck B' },
't': { action: 'resetDeckB', label: 'Reset Deck B' },
// Crossfader & Mixer
'ArrowLeft': { action: 'crossfaderLeft', label: 'Crossfader Left' },
'ArrowRight': { action: 'crossfaderRight', label: 'Crossfader Right' },
'ArrowDown': { action: 'crossfaderCenter', label: 'Crossfader Center' },
// Pitch Bend
'e': { action: 'pitchBendAUp', label: 'Pitch Bend A +' },
'd': { action: 'pitchBendADown', label: 'Pitch Bend A -' },
'i': { action: 'pitchBendBUp', label: 'Pitch Bend B +' },
'k': { action: 'pitchBendBDown', label: 'Pitch Bend B -' },
// Utility
'f': { action: 'toggleLibrary', label: 'Toggle Library' },
'b': { action: 'toggleBroadcast', label: 'Toggle Broadcast' },
'h': { action: 'showKeyboardHelp', label: 'Show Keyboard Help' },
'Escape': { action: 'closeAllPanels', label: 'Close All Panels' }
};
let keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
// Load custom mappings from server
async function loadKeyboardMappings() {
try {
const response = await fetch('/load_keymaps');
const data = await response.json();
if (data.success && data.keymaps) {
keyboardMappings = data.keymaps;
console.log('[OK] Loaded custom keyboard mappings from server');
} else {
console.log('Using default keyboard mappings');
}
} catch (e) {
console.error('Failed to load keyboard mappings from server:', e);
}
}
// Save custom mappings to server
async function saveKeyboardMappings() {
try {
await fetch('/save_keymaps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(keyboardMappings)
});
console.log('[SAVED] Saved keyboard mappings to server');
} catch (e) {
console.error('Failed to save keyboard mappings to server:', e);
}
}
// Execute action based on key
function executeKeyboardAction(action) {
const actions = {
// Deck A
'playDeckA': () => playDeck('A'),
'pauseDeckA': () => pauseDeck('A'),
'cueA1': () => handleCue('A', 1),
'cueA2': () => handleCue('A', 2),
'cueA3': () => handleCue('A', 3),
'cueA4': () => handleCue('A', 4),
'loopInA': () => setLoop('A', 'in'),
'loopOutA': () => setLoop('A', 'out'),
'loopExitA': () => setLoop('A', 'exit'),
'resetDeckA': () => resetDeck('A'),
// Deck B
'playDeckB': () => playDeck('B'),
'pauseDeckB': () => pauseDeck('B'),
'cueB1': () => handleCue('B', 1),
'cueB2': () => handleCue('B', 2),
'cueB3': () => handleCue('B', 3),
'cueB4': () => handleCue('B', 4),
'loopInB': () => setLoop('B', 'in'),
'loopOutB': () => setLoop('B', 'out'),
'loopExitB': () => setLoop('B', 'exit'),
'resetDeckB': () => resetDeck('B'),
// Crossfader
'crossfaderLeft': () => {
const cf = document.getElementById('crossfader');
cf.value = Math.max(0, parseInt(cf.value) - 10);
updateCrossfader(cf.value);
},
'crossfaderRight': () => {
const cf = document.getElementById('crossfader');
cf.value = Math.min(100, parseInt(cf.value) + 10);
updateCrossfader(cf.value);
},
'crossfaderCenter': () => {
const cf = document.getElementById('crossfader');
cf.value = 50;
updateCrossfader(50);
},
// Pitch Bend
'pitchBendAUp': () => pitchBend('A', 0.05),
'pitchBendADown': () => pitchBend('A', -0.05),
'pitchBendBUp': () => pitchBend('B', 0.05),
'pitchBendBDown': () => pitchBend('B', -0.05),
// Utility
'toggleLibrary': () => switchTab('library'),
'toggleBroadcast': () => toggleBroadcast(),
'showKeyboardHelp': () => openKeyboardSettings(),
'closeAllPanels': () => {
document.getElementById('settings-panel').classList.remove('active');
document.getElementById('streaming-panel').classList.remove('active');
}
};
if (actions[action]) {
actions[action]();
} else {
console.warn('Unknown action:', action);
}
}
// Global keyboard event listener
document.addEventListener('keydown', (e) => {
// Ignore if typing in input field
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
const key = e.key;
const mapping = keyboardMappings[key];
if (mapping) {
e.preventDefault();
console.log(`Keyboard: ${key} RIGHT ${mapping.label}`);
executeKeyboardAction(mapping.action);
}
});
// Handle pitch bend release
document.addEventListener('keyup', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const key = e.key;
const mapping = keyboardMappings[key];
// Release pitch bend on key up
if (mapping && mapping.action.includes('pitchBend')) {
const deck = mapping.action.includes('A') ? 'A' : 'B';
pitchBend(deck, 0);
}
});
// Open keyboard settings panel
function openKeyboardSettings() {
const panel = document.getElementById('keyboard-settings-panel');
if (panel) {
panel.classList.toggle('active');
} else {
createKeyboardSettingsPanel();
}
}
// Create keyboard settings UI
function createKeyboardSettingsPanel() {
const panel = document.createElement('div');
panel.id = 'keyboard-settings-panel';
panel.className = 'settings-panel active';
panel.innerHTML = `
<div class="panel-header">
<h2>Keyboard Shortcuts</h2>
<button onclick="closeKeyboardSettings()">X</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">RIGHT</span>
<span class="action-label">${mapping.label}</span>
`;
const changeBtn = document.createElement('button');
changeBtn.className = 'key-reassign-btn';
changeBtn.textContent = 'Change';
changeBtn.addEventListener('click', (e) => reassignKey(key, e));
item.appendChild(changeBtn);
list.appendChild(item);
});
}
// Format key name for display
function formatKeyName(key) {
const names = {
'ArrowLeft': 'LEFT Left',
'ArrowRight': 'RIGHT Right',
'ArrowUp': 'UP Up',
'ArrowDown': 'DOWN Down',
'Escape': 'ESC',
' ': 'Space'
};
return names[key] || key.toUpperCase();
}
// Reassign a key
function reassignKey(oldKey, evt) {
const item = evt.target.closest('.keyboard-mapping-item');
item.classList.add('listening');
item.querySelector('.key-reassign-btn').textContent = 'Press new key...';
const listener = (e) => {
e.preventDefault();
if (e.key === 'Escape') {
item.classList.remove('listening');
item.querySelector('.key-reassign-btn').textContent = 'Change';
document.removeEventListener('keydown', listener);
return;
}
const newKey = e.key;
// Check if key already assigned
if (keyboardMappings[newKey] && newKey !== oldKey) {
if (!confirm(`Key "${formatKeyName(newKey)}" is already assigned to "${keyboardMappings[newKey].label}". Overwrite?`)) {
item.classList.remove('listening');
item.querySelector('.key-reassign-btn').textContent = 'Change';
document.removeEventListener('keydown', listener);
return;
}
delete keyboardMappings[newKey];
}
// Move mapping to new key
keyboardMappings[newKey] = keyboardMappings[oldKey];
delete keyboardMappings[oldKey];
saveKeyboardMappings();
renderKeyboardMappings();
document.removeEventListener('keydown', listener);
console.log(`[OK] Remapped: ${formatKeyName(oldKey)} RIGHT ${formatKeyName(newKey)}`);
};
document.addEventListener('keydown', listener);
}
// Reset to default mappings
async function resetKeyboardMappings() {
if (confirm('Reset all keyboard shortcuts to defaults?')) {
keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
await saveKeyboardMappings();
renderKeyboardMappings();
}
}
// Export mappings
function exportKeyboardMappings() {
const json = JSON.stringify(keyboardMappings, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'techdj-keyboard-mappings.json';
a.click();
URL.revokeObjectURL(url);
}
// Import mappings
function importKeyboardMappings() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
try {
const imported = JSON.parse(event.target.result);
keyboardMappings = imported;
saveKeyboardMappings();
renderKeyboardMappings();
alert('[OK] Keyboard mappings imported successfully!');
} catch (err) {
alert('[ERROR] Failed to import: Invalid file format');
}
};
reader.readAsText(file);
};
input.click();
}
// Initialise on load
loadKeyboardMappings();
console.log('Keyboard shortcuts enabled. Press H for help.');