Comprehensive DJ Panel Improvements:

- Added toast notification system for visible feedback
- Implemented settings persistence via localStorage
- Added auto-crossfade logic (smooth 5s transition)
- Removed ~100 lines of dead SERVER_SIDE_AUDIO code
- Fixed seekTo speed bug by capturing current playbackRate
- Debounced drawWaveform with requestAnimationFrame
- Added 'SETTINGS' header and close button to settings panel
- Wired loadFromServer errors to toast notifications
- Stacked control buttons vertically to clear crossfader
This commit is contained in:
ComputerTech 2026-03-12 16:52:21 +00:00
parent 9513c11747
commit 44b36bf08d
3 changed files with 313 additions and 271 deletions

View File

@ -484,6 +484,8 @@
onchange="handleFileUpload(event)"> onchange="handleFileUpload(event)">
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button> <button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
<div class="toast-container" id="toast-container"></div>
</body> </body>
</html> </html>

434
script.js
View File

@ -2,8 +2,6 @@
// TechDJ Pro - Core DJ Logic // TechDJ Pro - Core DJ Logic
// ========================================== // ==========================================
// Server-side audio mode (true = server processes audio, false = browser processes)
const SERVER_SIDE_AUDIO = false;
let audioCtx; let audioCtx;
const decks = { const decks = {
@ -74,6 +72,42 @@ const queues = {
B: [] 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 // System Initialization
function initSystem() { function initSystem() {
if (audioCtx) return; if (audioCtx) return;
@ -594,9 +628,21 @@ function generateWaveformData(buffer) {
return filteredData; return filteredData;
} }
// Debounce guard: prevents redundant redraws within the same frame
const _waveformPending = { A: false, B: false };
function drawWaveform(id) { 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); const canvas = document.getElementById('waveform-' + id);
if (!canvas) return; // Null check if (!canvas) return;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const data = decks[id].waveformData; const data = decks[id].waveformData;
if (!data) return; if (!data) return;
@ -781,19 +827,6 @@ function _notifyListenerDeckGlow() {
function playDeck(id) { function playDeck(id) {
vibrate(15); vibrate(15);
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
socket.emit('audio_play', { deck: id });
decks[id].playing = true;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id);
_notifyListenerDeckGlow();
console.log(`[Deck ${id}] Play command sent to server`);
return;
}
// Browser-side audio mode (original code)
if (decks[id].type === 'local' && decks[id].localBuffer) { if (decks[id].type === 'local' && decks[id].localBuffer) {
if (decks[id].playing) return; if (decks[id].playing) return;
@ -834,20 +867,6 @@ function playDeck(id) {
function pauseDeck(id) { function pauseDeck(id) {
vibrate(15); vibrate(15);
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_pause', { deck: id });
decks[id].playing = false;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
_notifyListenerDeckGlow();
console.log(`[Deck ${id}] Pause command sent to server`);
return;
}
// Browser-side audio mode (original code)
if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) { if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
if (!audioCtx) { if (!audioCtx) {
console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`); console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`);
@ -877,27 +896,6 @@ function seekTo(id, time) {
// Update local state and timestamp for seek protection // Update local state and timestamp for seek protection
decks[id].lastSeekTime = Date.now(); decks[id].lastSeekTime = Date.now();
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_seek', { deck: id, position: time });
// Update local state immediately for UI responsiveness
decks[id].lastAnchorPosition = time;
if (!decks[id].playing) {
decks[id].pausedAt = time;
}
// Update UI immediately (Optimistic UI)
const progress = (time / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
const timer = document.getElementById('time-current-' + id);
if (timer) timer.textContent = formatTime(time);
return;
}
if (!decks[id].localBuffer) { if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`); console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
return; return;
@ -907,6 +905,8 @@ function seekTo(id, time) {
if (decks[id].playing) { if (decks[id].playing) {
if (decks[id].localSource) { if (decks[id].localSource) {
try { 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.stop();
decks[id].localSource.onended = null; decks[id].localSource.onended = null;
} catch (e) { } } catch (e) { }
@ -922,17 +922,27 @@ function seekTo(id, time) {
} }
src.connect(decks[id].filters.low); 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`); const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0; if (speedSlider) speed = parseFloat(speedSlider.value);
}
src.playbackRate.value = speed; src.playbackRate.value = speed;
decks[id].localSource = src; decks[id].localSource = src;
decks[id].lastAnchorTime = audioCtx.currentTime; decks[id].lastAnchorTime = audioCtx.currentTime;
decks[id].lastAnchorPosition = time; decks[id].lastAnchorPosition = time;
// Add error handler for the source // Wire onended so natural playback completion always triggers auto-play
src.onended = () => { src.onended = () => {
console.log(`[Deck ${id}] Playback ended naturally`); // 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); src.start(0, time);
@ -960,14 +970,6 @@ function seekTo(id, time) {
} }
function changeSpeed(id, val) { function changeSpeed(id, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) });
return;
}
// Browser-side audio mode
if (!audioCtx || !decks[id].localSource) return; if (!audioCtx || !decks[id].localSource) return;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
@ -978,28 +980,12 @@ function changeSpeed(id, val) {
} }
function changeVolume(id, val) { function changeVolume(id, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_volume', { deck: id, volume: val / 100 });
return;
}
// Browser-side audio mode
if (decks[id].volumeGain) { if (decks[id].volumeGain) {
decks[id].volumeGain.gain.value = val / 100; decks[id].volumeGain.gain.value = val / 100;
} }
} }
function changeEQ(id, band, val) { function changeEQ(id, band, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) });
return;
}
// Browser-side audio mode
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val); if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
} }
@ -1269,14 +1255,6 @@ function syncDecks(id) {
} }
function updateCrossfader(val) { function updateCrossfader(val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_crossfader', { value: parseInt(val) });
return;
}
// Browser-side audio mode
const volA = (100 - val) / 100; const volA = (100 - val) / 100;
const volB = val / 100; const volB = val / 100;
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA; if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
@ -1411,19 +1389,6 @@ async function loadFromServer(id, url, title) {
console.log(`[Deck ${id}] Loading: ${title} from ${url}`); console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
// Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform
if (SERVER_SIDE_AUDIO) {
// Extract filename from URL and DECODE IT (for spaces etc)
const filename = decodeURIComponent(url.split('/').pop());
if (!socket) initSocket();
socket.emit('audio_load_track', { deck: id, filename: filename });
console.log(`[Deck ${id}] Load command sent to server: ${filename}`);
// We DON'T return here anymore. We continue below to load for the UI.
}
// Browser-side audio mode (original code)
const wasPlaying = decks[id].playing; const wasPlaying = decks[id].playing;
const wasBroadcasting = isBroadcasting; const wasBroadcasting = isBroadcasting;
@ -1524,6 +1489,7 @@ async function loadFromServer(id, url, title) {
console.error(`[Deck ${id}] Load error:`, error); console.error(`[Deck ${id}] Load error:`, error);
d.innerText = 'LOAD ERROR'; d.innerText = 'LOAD ERROR';
d.classList.remove('blink'); d.classList.remove('blink');
showToast(`Deck ${id}: Failed to load track — ${error.message}`, 'error');
setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000); setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000);
} }
} }
@ -1756,11 +1722,12 @@ function toggleRepeat(id, val) {
console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`); console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`);
vibrate(10); vibrate(10);
saveSettings();
} }
function toggleAutoMix(val) { settings.autoMix = val; } function toggleAutoMix(val) { settings.autoMix = val; saveSettings(); }
function toggleShuffle(val) { settings.shuffleMode = val; } function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); }
function toggleQuantize(val) { settings.quantize = val; } function toggleQuantize(val) { settings.quantize = val; saveSettings(); }
function toggleAutoPlay(val) { settings.autoPlay = val; } function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); }
function updateManualGlow(id, val) { function updateManualGlow(id, val) {
settings[`glow${id}`] = val; settings[`glow${id}`] = val;
@ -1769,6 +1736,7 @@ function updateManualGlow(id, val) {
} else { } else {
document.body.classList.remove(`playing-${id}`); document.body.classList.remove(`playing-${id}`);
} }
saveSettings();
} }
function updateGlowIntensity(val) { function updateGlowIntensity(val) {
@ -1776,15 +1744,16 @@ function updateGlowIntensity(val) {
const opacity = settings.glowIntensity / 100; const opacity = settings.glowIntensity / 100;
const spread = (settings.glowIntensity / 100) * 80; const spread = (settings.glowIntensity / 100) * 80;
// Dynamically update CSS variables for the glow
document.documentElement.style.setProperty('--glow-opacity', opacity); document.documentElement.style.setProperty('--glow-opacity', opacity);
document.documentElement.style.setProperty('--glow-spread', `${spread}px`); document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
saveSettings();
} }
function updateListenerGlow(val) { function updateListenerGlow(val) {
settings.listenerGlowIntensity = parseInt(val); settings.listenerGlowIntensity = parseInt(val);
if (!socket) initSocket(); if (!socket) initSocket();
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity }); socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
saveSettings();
} }
// Dismiss landscape prompt // Dismiss landscape prompt
@ -1805,33 +1774,38 @@ window.addEventListener('DOMContentLoaded', () => {
if (prompt) prompt.classList.add('dismissed'); if (prompt) prompt.classList.add('dismissed');
} }
// Initialise glow intensity // 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); updateGlowIntensity(settings.glowIntensity);
const glowAToggle = document.getElementById('glow-A');
if (glowAToggle) glowAToggle.checked = settings.glowA;
const glowBToggle = document.getElementById('glow-B');
if (glowBToggle) glowBToggle.checked = settings.glowB;
const intensitySlider = document.getElementById('glow-intensity');
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
// Apply initial glow state // Apply initial glow state
updateManualGlow('A', settings.glowA); updateManualGlow('A', settings.glowA);
updateManualGlow('B', settings.glowB); updateManualGlow('B', settings.glowB);
// Set stream URL in the streaming panel // Set stream URL in the streaming panel
// Priority: server-configured listener_url > auto-detect > fallback
const streamInput = document.getElementById('stream-url'); const streamInput = document.getElementById('stream-url');
if (streamInput) { if (streamInput) {
const _autoDetectListenerUrl = () => { const _autoDetectListenerUrl = () => {
const host = window.location.hostname; const host = window.location.hostname;
// dj.techy.music → techy.music (strip leading "dj.")
// dj.anything.com → anything.com
if (host.startsWith('dj.')) { if (host.startsWith('dj.')) {
return `${window.location.protocol}//${host.slice(3)}`; return `${window.location.protocol}//${host.slice(3)}`;
} }
return `${window.location.protocol}//${host}:5001`; return `${window.location.protocol}//${host}:5001`;
}; };
// Try server-configured URL first
fetch('/client_config') fetch('/client_config')
.then(r => r.json()) .then(r => r.json())
.then(cfg => { .then(cfg => {
@ -2031,24 +2005,7 @@ function startBroadcast() {
return; return;
} }
// Server-side audio mode // Browser-side audio mode
if (SERVER_SIDE_AUDIO) {
isBroadcasting = true;
document.getElementById('broadcast-btn').classList.add('active');
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
document.getElementById('broadcast-status').textContent = 'LIVE';
document.getElementById('broadcast-status').classList.add('live');
if (!socket) initSocket();
const bitrateValue = document.getElementById('stream-quality').value + 'k';
socket.emit('start_broadcast', { bitrate: bitrateValue });
socket.emit('get_listener_count');
console.log('[OK] Server-side broadcast started');
return;
}
// Browser-side audio mode (original code)
// Check if any audio is playing // Check if any audio is playing
const anyPlaying = decks.A.playing || decks.B.playing; const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) { if (!anyPlaying) {
@ -2274,23 +2231,15 @@ function startBroadcast() {
function stopBroadcast() { function stopBroadcast() {
console.log('[BROADCAST] Stopping...'); console.log('[BROADCAST] Stopping...');
if (SERVER_SIDE_AUDIO) {
isBroadcasting = false;
if (socket) {
socket.emit('stop_broadcast');
}
} else {
if (streamProcessor) { if (streamProcessor) {
streamProcessor.stop(); streamProcessor.stop();
streamProcessor = null; streamProcessor = null;
} }
if (streamDestination) { if (streamDestination) {
// Disconnect from stream destination and restore normal playback
if (decks.A.crossfaderGain) { if (decks.A.crossfaderGain) {
try { try {
decks.A.crossfaderGain.disconnect(streamDestination); decks.A.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.A.crossfaderGain.disconnect(); decks.A.crossfaderGain.disconnect();
decks.A.crossfaderGain.connect(audioCtx.destination); decks.A.crossfaderGain.connect(audioCtx.destination);
} catch (e) { } catch (e) {
@ -2300,7 +2249,6 @@ function stopBroadcast() {
if (decks.B.crossfaderGain) { if (decks.B.crossfaderGain) {
try { try {
decks.B.crossfaderGain.disconnect(streamDestination); decks.B.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.B.crossfaderGain.disconnect(); decks.B.crossfaderGain.disconnect();
decks.B.crossfaderGain.connect(audioCtx.destination); decks.B.crossfaderGain.connect(audioCtx.destination);
} catch (e) { } catch (e) {
@ -2311,11 +2259,9 @@ function stopBroadcast() {
} }
isBroadcasting = false; isBroadcasting = false;
// Notify server (browser-side mode also needs to tell server to stop relaying)
if (socket) { if (socket) {
socket.emit('stop_broadcast'); socket.emit('stop_broadcast');
} }
}
// Update UI // Update UI
document.getElementById('broadcast-btn').classList.remove('active'); document.getElementById('broadcast-btn').classList.remove('active');
@ -2422,91 +2368,155 @@ window.addEventListener('load', () => {
} }
}); });
// Monitoring // Auto-crossfade: smoothly transitions from the ending deck to the other deck.
function monitorTrackEnd() { let _autoMixTimer = null;
setInterval(() => {
if (!audioCtx) return; // Safety check
// In server-side mode, poll for status from server function startAutoMixFade(endingDeckId) {
if (SERVER_SIDE_AUDIO && socket && socket.connected) { const otherDeck = endingDeckId === 'A' ? 'B' : 'A';
socket.emit('get_mixer_status');
// 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;
} }
['A', 'B'].forEach(id => { // If the other deck isn't playing, start it
if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) { if (!decks[otherDeck].playing) {
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0; playDeck(otherDeck);
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime; }
const current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
const remaining = decks[id].duration - current; // 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 end reached (with 0.5s buffer for safety)
// Skip if a loop is active — the Web Audio API handles looping natively
// and lastAnchorPosition is not updated on each native loop repeat, so
// the monotonically-growing `current` would incorrectly trigger end-of-track.
if (remaining <= 0.5 && !decks[id].loopActive) {
// During broadcast, still handle auto-play/queue to avoid dead air
if (isBroadcasting) { if (isBroadcasting) {
console.log(`Track ending during broadcast on Deck ${id}`); console.log(`Track ending during broadcast on Deck ${id}`);
if (settings[`repeat${id}`]) { if (settings[`repeat${id}`]) {
console.log(`LOOP Repeating track on Deck ${id}`); console.log(`Repeating track on Deck ${id} (broadcast)`);
seekTo(id, 0); seekTo(id, 0);
return; return;
} }
// Auto-play from queue during broadcast to maintain stream
if (settings.autoPlay && queues[id] && queues[id].length > 0) { if (settings.autoPlay && queues[id] && queues[id].length > 0) {
decks[id].loading = true; decks[id].loading = true;
console.log(`Auto-play (broadcast): Loading next from Queue ${id}...`);
const next = queues[id].shift(); const next = queues[id].shift();
renderQueue(id); renderQueue(id);
loadFromServer(id, next.file, next.title).then(() => { loadFromServer(id, next.file, next.title)
decks[id].loading = false; .then(() => { decks[id].loading = false; playDeck(id); })
playDeck(id); .catch(() => { decks[id].loading = false; });
}).catch(() => {
decks[id].loading = false;
});
return;
} }
// No repeat, no queue - just let the stream continue silently
return; return;
} }
if (settings[`repeat${id}`]) { if (settings[`repeat${id}`]) {
// Full song repeat console.log(`Repeating track on Deck ${id}`);
console.log(`LOOP Repeating track on Deck ${id}`);
seekTo(id, 0); 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) { } else if (settings.autoPlay) {
// Prevent race condition handleAutoPlay(id);
} else {
pauseDeck(id);
decks[id].pausedAt = 0;
}
}
function handleAutoPlay(id) {
decks[id].loading = true; decks[id].loading = true;
pauseDeck(id); pauseDeck(id);
// Check queue for auto-play
if (queues[id] && queues[id].length > 0) { if (queues[id] && queues[id].length > 0) {
console.log(`Auto-play: Loading next from Queue ${id}...`); console.log(`Auto-play: loading next from Queue ${id}`);
const next = queues[id].shift(); const next = queues[id].shift();
renderQueue(id); // Update queue UI 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;
}
}
loadFromServer(id, next.file, next.title).then(() => { // Monitoring — safety net for cases where onended doesn't fire
decks[id].loading = false; // (e.g. AudioContext suspended, very short buffer, scrub to near-end).
playDeck(id); function monitorTrackEnd() {
}).catch(() => { setInterval(() => {
decks[id].loading = false; if (!audioCtx) return;
});
} else {
// No queue - just stop
console.log(`Track ended, queue empty - stopping playback`); ['A', 'B'].forEach(id => {
decks[id].loading = false; if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
pauseDeck(id); if (decks[id].loopActive) return;
decks[id].pausedAt = 0;
} const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
} else { const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
// Just stop if no auto-play const current = decks[id].lastAnchorPosition + (elapsed * rate);
pauseDeck(id); const remaining = decks[id].duration - current;
decks[id].pausedAt = 0;
} // 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); // Check every 0.5s }, 500);
} }
monitorTrackEnd(); monitorTrackEnd();
@ -2602,11 +2612,6 @@ function addToQueue(deckId, file, title) {
queues[deckId].push({ file, title }); queues[deckId].push({ file, title });
renderQueue(deckId); renderQueue(deckId);
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`); console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
} }
// Remove track from queue // Remove track from queue
@ -2614,11 +2619,6 @@ function removeFromQueue(deckId, index) {
const removed = queues[deckId].splice(index, 1)[0]; const removed = queues[deckId].splice(index, 1)[0];
renderQueue(deckId); renderQueue(deckId);
console.log(`Removed "${removed.title}" from Queue ${deckId}`); console.log(`Removed "${removed.title}" from Queue ${deckId}`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
} }
// Clear entire queue // Clear entire queue
@ -2627,11 +2627,6 @@ function clearQueue(deckId) {
queues[deckId] = []; queues[deckId] = [];
renderQueue(deckId); renderQueue(deckId);
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`); console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
} }
// Load next track from queue // Load next track from queue
@ -2646,11 +2641,6 @@ function loadNextFromQueue(deckId) {
loadFromServer(deckId, next.file, next.title); loadFromServer(deckId, next.file, next.title);
renderQueue(deckId); renderQueue(deckId);
// Sync with server if in server-side mode (after shift)
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
return true; return true;
} }
@ -2748,10 +2738,6 @@ function renderQueue(deckId) {
const [movedItem] = queues[deckId].splice(fromIndex, 1); const [movedItem] = queues[deckId].splice(fromIndex, 1);
queues[deckId].splice(index, 0, movedItem); queues[deckId].splice(index, 0, movedItem);
renderQueue(deckId); renderQueue(deckId);
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
} }
}; };

View File

@ -4782,3 +4782,57 @@ body.listening-active .landscape-prompt {
background: rgba(188, 19, 254, 0.2); background: rgba(188, 19, 254, 0.2);
box-shadow: 0 0 10px rgba(188, 19, 254, 0.4); box-shadow: 0 0 10px rgba(188, 19, 254, 0.4);
} }
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 20000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 12px 20px;
border-radius: 6px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
font-weight: 500;
color: #fff;
pointer-events: auto;
animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 3.7s forwards;
max-width: 360px;
word-break: break-word;
border-left: 4px solid transparent;
}
.toast-error {
background: rgba(255, 40, 40, 0.9);
border-left-color: #ff0000;
box-shadow: 0 4px 20px rgba(255, 0, 0, 0.3);
}
.toast-success {
background: rgba(0, 200, 80, 0.9);
border-left-color: #00ff55;
box-shadow: 0 4px 20px rgba(0, 255, 85, 0.3);
}
.toast-info {
background: rgba(0, 120, 255, 0.9);
border-left-color: var(--primary-cyan);
box-shadow: 0 4px 20px rgba(0, 243, 255, 0.3);
}
@keyframes toast-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}