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)">
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
<div class="toast-container" id="toast-container"></div>
</body>
</html>

528
script.js
View File

@ -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] });
}
}
};

View File

@ -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; }
}