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:
parent
9513c11747
commit
44b36bf08d
|
|
@ -484,6 +484,8 @@
|
|||
onchange="handleFileUpload(event)">
|
||||
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
|
||||
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
528
script.js
528
script.js
|
|
@ -2,8 +2,6 @@
|
|||
// TechDJ Pro - Core DJ Logic
|
||||
// ==========================================
|
||||
|
||||
// Server-side audio mode (true = server processes audio, false = browser processes)
|
||||
const SERVER_SIDE_AUDIO = false;
|
||||
|
||||
let audioCtx;
|
||||
const decks = {
|
||||
|
|
@ -74,6 +72,42 @@ const queues = {
|
|||
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;
|
||||
|
|
@ -594,9 +628,21 @@ function generateWaveformData(buffer) {
|
|||
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; // Null check
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const data = decks[id].waveformData;
|
||||
if (!data) return;
|
||||
|
|
@ -781,19 +827,6 @@ function _notifyListenerDeckGlow() {
|
|||
|
||||
function playDeck(id) {
|
||||
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].playing) return;
|
||||
|
||||
|
|
@ -834,20 +867,6 @@ function playDeck(id) {
|
|||
|
||||
function pauseDeck(id) {
|
||||
vibrate(15);
|
||||
// Server-side audio mode
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_pause', { deck: id });
|
||||
decks[id].playing = false;
|
||||
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 (!audioCtx) {
|
||||
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
|
||||
decks[id].lastSeekTime = Date.now();
|
||||
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_seek', { deck: id, position: time });
|
||||
|
||||
// Update local state immediately for UI responsiveness
|
||||
decks[id].lastAnchorPosition = time;
|
||||
if (!decks[id].playing) {
|
||||
decks[id].pausedAt = time;
|
||||
}
|
||||
|
||||
// Update UI immediately (Optimistic UI)
|
||||
const progress = (time / decks[id].duration) * 100;
|
||||
const playhead = document.getElementById('playhead-' + id);
|
||||
if (playhead) playhead.style.left = progress + '%';
|
||||
|
||||
const timer = document.getElementById('time-current-' + id);
|
||||
if (timer) timer.textContent = formatTime(time);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!decks[id].localBuffer) {
|
||||
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
|
||||
return;
|
||||
|
|
@ -907,6 +905,8 @@ function seekTo(id, time) {
|
|||
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) { }
|
||||
|
|
@ -922,17 +922,27 @@ function seekTo(id, time) {
|
|||
}
|
||||
|
||||
src.connect(decks[id].filters.low);
|
||||
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
||||
const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
|
||||
// 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;
|
||||
|
||||
// Add error handler for the source
|
||||
// Wire onended so natural playback completion always triggers auto-play
|
||||
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);
|
||||
|
|
@ -960,14 +970,6 @@ function seekTo(id, time) {
|
|||
}
|
||||
|
||||
function changeSpeed(id, val) {
|
||||
// Server-side audio mode
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser-side audio mode
|
||||
if (!audioCtx || !decks[id].localSource) return;
|
||||
|
||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
||||
|
|
@ -978,28 +980,12 @@ function changeSpeed(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) {
|
||||
decks[id].volumeGain.gain.value = val / 100;
|
||||
}
|
||||
}
|
||||
|
||||
function changeEQ(id, band, val) {
|
||||
// Server-side audio mode
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser-side audio mode
|
||||
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
|
||||
}
|
||||
|
||||
|
|
@ -1269,14 +1255,6 @@ function syncDecks(id) {
|
|||
}
|
||||
|
||||
function updateCrossfader(val) {
|
||||
// Server-side audio mode
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_set_crossfader', { value: parseInt(val) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser-side audio mode
|
||||
const volA = (100 - val) / 100;
|
||||
const volB = val / 100;
|
||||
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
|
||||
|
|
@ -1411,19 +1389,6 @@ async function loadFromServer(id, url, title) {
|
|||
|
||||
console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
|
||||
|
||||
// Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
// Extract filename from URL and DECODE IT (for spaces etc)
|
||||
const filename = decodeURIComponent(url.split('/').pop());
|
||||
|
||||
if (!socket) initSocket();
|
||||
socket.emit('audio_load_track', { deck: id, filename: filename });
|
||||
console.log(`[Deck ${id}] Load command sent to server: ${filename}`);
|
||||
|
||||
// We DON'T return here anymore. We continue below to load for the UI.
|
||||
}
|
||||
|
||||
// Browser-side audio mode (original code)
|
||||
const wasPlaying = decks[id].playing;
|
||||
const wasBroadcasting = isBroadcasting;
|
||||
|
||||
|
|
@ -1524,6 +1489,7 @@ async function loadFromServer(id, url, title) {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1756,11 +1722,12 @@ function toggleRepeat(id, val) {
|
|||
|
||||
console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`);
|
||||
vibrate(10);
|
||||
saveSettings();
|
||||
}
|
||||
function toggleAutoMix(val) { settings.autoMix = val; }
|
||||
function toggleShuffle(val) { settings.shuffleMode = val; }
|
||||
function toggleQuantize(val) { settings.quantize = val; }
|
||||
function toggleAutoPlay(val) { settings.autoPlay = val; }
|
||||
function 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;
|
||||
|
|
@ -1769,6 +1736,7 @@ function updateManualGlow(id, val) {
|
|||
} else {
|
||||
document.body.classList.remove(`playing-${id}`);
|
||||
}
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function updateGlowIntensity(val) {
|
||||
|
|
@ -1776,15 +1744,16 @@ function updateGlowIntensity(val) {
|
|||
const opacity = settings.glowIntensity / 100;
|
||||
const spread = (settings.glowIntensity / 100) * 80;
|
||||
|
||||
// Dynamically update CSS variables for the glow
|
||||
document.documentElement.style.setProperty('--glow-opacity', opacity);
|
||||
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function updateListenerGlow(val) {
|
||||
settings.listenerGlowIntensity = parseInt(val);
|
||||
if (!socket) initSocket();
|
||||
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Dismiss landscape prompt
|
||||
|
|
@ -1805,33 +1774,38 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||
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);
|
||||
const glowAToggle = document.getElementById('glow-A');
|
||||
if (glowAToggle) glowAToggle.checked = settings.glowA;
|
||||
const glowBToggle = document.getElementById('glow-B');
|
||||
if (glowBToggle) glowBToggle.checked = settings.glowB;
|
||||
const intensitySlider = document.getElementById('glow-intensity');
|
||||
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
|
||||
|
||||
// Apply initial glow state
|
||||
updateManualGlow('A', settings.glowA);
|
||||
updateManualGlow('B', settings.glowB);
|
||||
|
||||
// Set stream URL in the streaming panel
|
||||
// Priority: server-configured listener_url > auto-detect > fallback
|
||||
const streamInput = document.getElementById('stream-url');
|
||||
if (streamInput) {
|
||||
const _autoDetectListenerUrl = () => {
|
||||
const host = window.location.hostname;
|
||||
// dj.techy.music → techy.music (strip leading "dj.")
|
||||
// dj.anything.com → anything.com
|
||||
if (host.startsWith('dj.')) {
|
||||
return `${window.location.protocol}//${host.slice(3)}`;
|
||||
}
|
||||
return `${window.location.protocol}//${host}:5001`;
|
||||
};
|
||||
// Try server-configured URL first
|
||||
fetch('/client_config')
|
||||
.then(r => r.json())
|
||||
.then(cfg => {
|
||||
|
|
@ -2031,24 +2005,7 @@ function startBroadcast() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Server-side audio mode
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
isBroadcasting = true;
|
||||
document.getElementById('broadcast-btn').classList.add('active');
|
||||
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
|
||||
document.getElementById('broadcast-status').textContent = 'LIVE';
|
||||
document.getElementById('broadcast-status').classList.add('live');
|
||||
|
||||
if (!socket) initSocket();
|
||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
||||
socket.emit('start_broadcast', { bitrate: bitrateValue });
|
||||
socket.emit('get_listener_count');
|
||||
|
||||
console.log('[OK] Server-side broadcast started');
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser-side audio mode (original code)
|
||||
// Browser-side audio mode
|
||||
// Check if any audio is playing
|
||||
const anyPlaying = decks.A.playing || decks.B.playing;
|
||||
if (!anyPlaying) {
|
||||
|
|
@ -2274,47 +2231,36 @@ function startBroadcast() {
|
|||
function stopBroadcast() {
|
||||
console.log('[BROADCAST] Stopping...');
|
||||
|
||||
if (SERVER_SIDE_AUDIO) {
|
||||
isBroadcasting = false;
|
||||
if (socket) {
|
||||
socket.emit('stop_broadcast');
|
||||
}
|
||||
} else {
|
||||
if (streamProcessor) {
|
||||
streamProcessor.stop();
|
||||
streamProcessor = null;
|
||||
}
|
||||
if (streamProcessor) {
|
||||
streamProcessor.stop();
|
||||
streamProcessor = null;
|
||||
}
|
||||
|
||||
if (streamDestination) {
|
||||
// Disconnect from stream destination and restore normal playback
|
||||
if (decks.A.crossfaderGain) {
|
||||
try {
|
||||
decks.A.crossfaderGain.disconnect(streamDestination);
|
||||
// Ensure connection to speakers is maintained
|
||||
decks.A.crossfaderGain.disconnect();
|
||||
decks.A.crossfaderGain.connect(audioCtx.destination);
|
||||
} catch (e) {
|
||||
console.warn('Error restoring Deck A audio:', e);
|
||||
}
|
||||
if (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);
|
||||
// Ensure connection to speakers is maintained
|
||||
decks.B.crossfaderGain.disconnect();
|
||||
decks.B.crossfaderGain.connect(audioCtx.destination);
|
||||
} catch (e) {
|
||||
console.warn('Error restoring Deck B audio:', e);
|
||||
}
|
||||
}
|
||||
streamDestination = null;
|
||||
}
|
||||
isBroadcasting = false;
|
||||
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;
|
||||
|
||||
// Notify server (browser-side mode also needs to tell server to stop relaying)
|
||||
if (socket) {
|
||||
socket.emit('stop_broadcast');
|
||||
}
|
||||
if (socket) {
|
||||
socket.emit('stop_broadcast');
|
||||
}
|
||||
|
||||
// Update UI
|
||||
|
|
@ -2422,91 +2368,155 @@ window.addEventListener('load', () => {
|
|||
}
|
||||
});
|
||||
|
||||
// Monitoring
|
||||
// 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; // Safety check
|
||||
if (!audioCtx) return;
|
||||
|
||||
|
||||
// In server-side mode, poll for status from server
|
||||
if (SERVER_SIDE_AUDIO && socket && socket.connected) {
|
||||
socket.emit('get_mixer_status');
|
||||
}
|
||||
|
||||
['A', 'B'].forEach(id => {
|
||||
if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) {
|
||||
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
||||
const current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
|
||||
const remaining = decks[id].duration - current;
|
||||
if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
|
||||
if (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) {
|
||||
console.log(`Track ending during broadcast on Deck ${id}`);
|
||||
if (settings[`repeat${id}`]) {
|
||||
console.log(`LOOP Repeating track on Deck ${id}`);
|
||||
seekTo(id, 0);
|
||||
return;
|
||||
}
|
||||
// Auto-play from queue during broadcast to maintain stream
|
||||
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
|
||||
decks[id].loading = true;
|
||||
console.log(`Auto-play (broadcast): 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;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No repeat, no queue - just let the stream continue silently
|
||||
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;
|
||||
|
||||
if (settings[`repeat${id}`]) {
|
||||
// Full song repeat
|
||||
console.log(`LOOP Repeating track on Deck ${id}`);
|
||||
seekTo(id, 0);
|
||||
} else if (settings.autoPlay) {
|
||||
// Prevent race condition
|
||||
decks[id].loading = true;
|
||||
pauseDeck(id);
|
||||
|
||||
// Check queue for auto-play
|
||||
if (queues[id] && queues[id].length > 0) {
|
||||
console.log(`Auto-play: Loading next from Queue ${id}...`);
|
||||
const next = queues[id].shift();
|
||||
renderQueue(id); // Update queue UI
|
||||
|
||||
loadFromServer(id, next.file, next.title).then(() => {
|
||||
decks[id].loading = false;
|
||||
playDeck(id);
|
||||
}).catch(() => {
|
||||
decks[id].loading = false;
|
||||
});
|
||||
} else {
|
||||
// No queue - just stop
|
||||
console.log(`Track ended, queue empty - stopping playback`);
|
||||
decks[id].loading = false;
|
||||
pauseDeck(id);
|
||||
decks[id].pausedAt = 0;
|
||||
}
|
||||
} else {
|
||||
// Just stop if no auto-play
|
||||
pauseDeck(id);
|
||||
decks[id].pausedAt = 0;
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
|
||||
|
|
@ -2602,11 +2612,6 @@ function addToQueue(deckId, file, title) {
|
|||
queues[deckId].push({ file, title });
|
||||
renderQueue(deckId);
|
||||
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
|
||||
|
||||
// Sync with server if in server-side mode
|
||||
if (SERVER_SIDE_AUDIO && socket) {
|
||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove track from queue
|
||||
|
|
@ -2614,11 +2619,6 @@ function removeFromQueue(deckId, index) {
|
|||
const removed = queues[deckId].splice(index, 1)[0];
|
||||
renderQueue(deckId);
|
||||
console.log(`Removed "${removed.title}" from Queue ${deckId}`);
|
||||
|
||||
// Sync with server if in server-side mode
|
||||
if (SERVER_SIDE_AUDIO && socket) {
|
||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entire queue
|
||||
|
|
@ -2627,11 +2627,6 @@ function clearQueue(deckId) {
|
|||
queues[deckId] = [];
|
||||
renderQueue(deckId);
|
||||
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
|
||||
|
||||
// Sync with server if in server-side mode
|
||||
if (SERVER_SIDE_AUDIO && socket) {
|
||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
||||
}
|
||||
}
|
||||
|
||||
// Load next track from queue
|
||||
|
|
@ -2646,11 +2641,6 @@ function loadNextFromQueue(deckId) {
|
|||
loadFromServer(deckId, next.file, next.title);
|
||||
renderQueue(deckId);
|
||||
|
||||
// Sync with server if in server-side mode (after shift)
|
||||
if (SERVER_SIDE_AUDIO && socket) {
|
||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2748,10 +2738,6 @@ function renderQueue(deckId) {
|
|||
const [movedItem] = queues[deckId].splice(fromIndex, 1);
|
||||
queues[deckId].splice(index, 0, movedItem);
|
||||
renderQueue(deckId);
|
||||
|
||||
if (SERVER_SIDE_AUDIO && socket) {
|
||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
54
style.css
54
style.css
|
|
@ -4782,3 +4782,57 @@ body.listening-active .landscape-prompt {
|
|||
background: rgba(188, 19, 254, 0.2);
|
||||
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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue