3155 lines
110 KiB
JavaScript
3155 lines
110 KiB
JavaScript
// ==========================================
|
||
// 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 {
|
||
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;
|
||
|
||
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 {
|
||
const res = await fetch(`/browse_directories?path=${encodeURIComponent(path)}`);
|
||
const 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 {
|
||
const res = await fetch('/update_settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
library: { music_folder: path }
|
||
})
|
||
});
|
||
const result = await res.json();
|
||
if (result.success) {
|
||
alert("Music folder updated! Refreshing library...");
|
||
closeFolderPicker();
|
||
fetchLibrary();
|
||
} else {
|
||
alert("Error: " + result.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;
|
||
|
||
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.
|
||
// 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');
|
||
|
||
// Notify server that broadcast is active (listeners use MP3 stream)
|
||
if (!socket) initSocket();
|
||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
||
socket.emit('start_broadcast', { bitrate: bitrateValue });
|
||
socket.emit('get_listener_count');
|
||
|
||
console.log('[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) {
|
||
if (decks.A.crossfaderGain) {
|
||
try {
|
||
decks.A.crossfaderGain.disconnect(streamDestination);
|
||
decks.A.crossfaderGain.disconnect();
|
||
decks.A.crossfaderGain.connect(audioCtx.destination);
|
||
} catch (e) {
|
||
console.warn('Error restoring Deck A audio:', e);
|
||
}
|
||
}
|
||
if (decks.B.crossfaderGain) {
|
||
try {
|
||
decks.B.crossfaderGain.disconnect(streamDestination);
|
||
decks.B.crossfaderGain.disconnect();
|
||
decks.B.crossfaderGain.connect(audioCtx.destination);
|
||
} catch (e) {
|
||
console.warn('Error restoring Deck B audio:', 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
|
||
if (streamDestination) {
|
||
if (decks.A.crossfaderGain) {
|
||
try {
|
||
decks.A.crossfaderGain.disconnect(streamDestination);
|
||
decks.A.crossfaderGain.disconnect();
|
||
decks.A.crossfaderGain.connect(audioCtx.destination);
|
||
} catch (e) {
|
||
console.warn('Error cleaning up Deck A:', e);
|
||
}
|
||
}
|
||
if (decks.B.crossfaderGain) {
|
||
try {
|
||
decks.B.crossfaderGain.disconnect(streamDestination);
|
||
decks.B.crossfaderGain.disconnect();
|
||
decks.B.crossfaderGain.connect(audioCtx.destination);
|
||
} catch (e) {
|
||
console.warn('Error cleaning up Deck B:', e);
|
||
}
|
||
}
|
||
streamDestination = null;
|
||
}
|
||
|
||
// Preserve broadcasting state
|
||
const wasBroadcasting = isBroadcasting;
|
||
isBroadcasting = false; // Temporarily set to false so startBroadcast works
|
||
|
||
// Small delay to ensure cleanup completes
|
||
setTimeout(() => {
|
||
if (wasBroadcasting) {
|
||
startBroadcast();
|
||
console.log('[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.');
|