Compare commits

..

10 Commits

Author SHA1 Message Date
ComputerTech d2e6e2a7d7 Update script.js, settings.json; remove MOBILE_IMPROVEMENTS.md 2026-03-28 11:24:46 +00:00
ComputerTech eb3e66ba61 Implement Duplicate Upload Prevention
- Server: /upload rejects existing files with 409 Conflict
- Web: handleFileUpload skips duplicates with toast feedback
- Qt: upload_track blocks duplicate imports and uploads with dialog alert
2026-03-12 19:12:07 +00:00
ComputerTech 44b36bf08d 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
2026-03-12 16:52:21 +00:00
ComputerTech 9513c11747 Restore settings panel header with close button 2026-03-11 19:41:20 +00:00
ComputerTech 2e64870daa UI improvements: glow effects, deck colors, listener count, remove black bar, mobile header fix 2026-03-11 19:34:32 +00:00
ComputerTech 6027f2e973 Fix streaming: bypass ffmpeg for MP3 input, fix PyQt latency, fix routing bugs
- server.py: Add _distribute_mp3() to route MP3 chunks directly to listener
  queues without a second ffmpeg passthrough (halves pipeline latency, removes
  the eventlet/subprocess blocking read that caused the Qt client to fail)
- server.py: dj_start no longer starts ffmpeg for is_mp3_input=True
- server.py: dj_audio routes to _distribute_mp3 vs _feed_transcoder based on format
- server.py: _transcoder_watchdog skips MP3-direct mode
- server.py: stream_mp3 endpoint no longer waits for ffmpeg proc when MP3 direct
- techdj_qt.py: Add -fflags nobuffer + -flush_packets 1 to reduce source latency
- techdj_qt.py: bufsize=0 and read(4096) instead of read(8192) for ~260ms chunks
- listener.js: Reduce broadcast_started connect delay 800ms -> 300ms
2026-03-10 19:54:06 +00:00
ComputerTech 514f9899a3 Remove listener count from listening page 2026-03-10 19:33:56 +00:00
ComputerTech af109381c1 Reduce buffering: 250ms DJ chunks, 1024-chunk preroll, X-Accel-Buffering header 2026-03-10 19:32:24 +00:00
ComputerTech 43a3e692fc Fix upload error handling; log 413 nginx size limit clearly 2026-03-10 19:13:39 +00:00
ComputerTech 1fa6887efd Fix stream URL detection for custom domains; add listener_url config 2026-03-10 19:07:04 +00:00
11 changed files with 636 additions and 539 deletions

View File

@ -1,126 +0,0 @@
# TechDJ Mobile Improvements Summary
## Overview
Comprehensive mobile optimization to make the TechDJ interface more compact, efficient, and user-friendly on mobile devices.
## Key Improvements
### 1. **Redesigned Mobile Tab Navigation**
- **Full-width bottom bar**: Changed from floating pill design to full-width bottom bar
- **More compact**: Reduced height from ~90px to 65px
- **Better visual feedback**: Active tabs now have enhanced glow and slight elevation
- **Improved spacing**: Tighter padding (6px 4px) for more tab buttons in same space
- **Better touch targets**: Minimum 50px width per tab
### 2. **Optimized Deck Layout**
- **Reduced padding**: Deck padding reduced from 15px to 10px
- **Compact disk**: Vinyl disk reduced from 180px to 140px (saves vertical space)
- **Smaller disk label**: 50px instead of 60px
- **Compact waveform**: Height reduced from 100px to 70px
- **Better scrolling**: Decks now properly scroll within viewport
### 3. **Improved Controls**
- **Compact buttons**: All buttons optimized to 40px min-height (still touchable)
- **Better spacing**: Reduced gaps from 8px to 6px throughout
- **Optimized sliders**: Height reduced to 36px for better space usage
- **Compact EQ/Volume faders**: Reduced from 220px to 120px height
- **Better filter controls**: Full-width sliders with 8px height
- **Compact pitch controls**: Smaller bend buttons (36px min-height)
### 4. **Enhanced Library**
- **Compact track rows**: Reduced padding from 12px to 10px
- **Better text sizing**: Track names at 0.85rem (readable but compact)
- **Optimized buttons**: Load buttons at 0.65rem with 36px min-height
- **Horizontal header**: Search and refresh button side-by-side
- **Better scrolling**: Custom thin scrollbars (6px width)
### 5. **Improved Queue Sections**
- **Compact layout**: Reduced padding from 20px to 12px
- **Full height**: Queue sections now use calc(100vh - 65px)
- **Smaller title**: 1.3rem instead of 1.5rem
- **Compact items**: Queue items at 10px padding with 50px min-height
- **Better buttons**: Queue action buttons at 36px min-height
### 6. **Optimized Floating Buttons**
- **Smaller size**: Reduced from 50px to 45px
- **Better positioning**: Moved to 75px from bottom (above tabs)
- **Tighter spacing**: Buttons closer together for easier reach
- **Compact icons**: Font size reduced to 0.8rem
### 7. **Better Visual Polish**
- **Smooth scrolling**: Added webkit smooth scrolling for touch devices
- **Custom scrollbars**: Thin (6px) cyan-themed scrollbars
- **Better transitions**: Smoother tab switching with 0.2s transitions
- **Enhanced active states**: Better visual feedback on active tabs
### 8. **Space Savings**
- **Reduced VU meters**: From 80px to 50px height on mobile
- **Compact text**: Reduced font sizes across the board (0.6-0.95rem)
- **Tighter margins**: Reduced margins from 8-10px to 4-6px
- **Better use of space**: Overall ~30% more compact while maintaining usability
## Mobile-Specific Features
### Portrait Mode
- Full-screen sections with bottom tab navigation
- Optimized for one-handed use
- Floating buttons positioned for thumb reach
- Crossfader integrated with deck views
### Landscape Mode
- Side-by-side deck layout (unchanged, already optimized)
- Full crossfader at bottom
- Compact controls for maximum deck visibility
## Technical Improvements
### Performance
- Reduced repaints with better CSS organization
- Optimized transitions (0.2s instead of 0.3s)
- Better scroll performance with webkit-overflow-scrolling
### Touch Optimization
- Minimum 40px touch targets (WCAG AA compliant)
- Better tap feedback with transform animations
- Optimized slider thumb sizes (28px)
### Visual Consistency
- Consistent spacing (6px, 10px, 12px scale)
- Unified font sizing (0.6rem to 0.95rem range)
- Better color contrast for readability
## Before vs After Metrics
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| Tab bar height | ~90px | 65px | 28% smaller |
| Deck padding | 15px | 10px | 33% reduction |
| Disk size | 180px | 140px | 22% smaller |
| Waveform height | 100px | 70px | 30% smaller |
| Button min-height | 44px | 40px | 9% smaller |
| Control gaps | 8px | 6px | 25% tighter |
| Overall vertical space | ~100% | ~70% | 30% more compact |
## User Experience Benefits
1. **More content visible**: ~30% more content fits on screen
2. **Less scrolling needed**: Compact layout reduces need to scroll
3. **Faster navigation**: Tighter spacing means less finger movement
4. **Better one-handed use**: Optimized button positions
5. **Cleaner interface**: Less wasted space, more focused design
6. **Maintained usability**: Still meets accessibility guidelines
## Browser Compatibility
- ✅ iOS Safari (webkit optimizations)
- ✅ Android Chrome (webkit optimizations)
- ✅ Mobile Firefox (fallback scrollbars)
- ✅ All modern mobile browsers
## Notes
- All changes maintain minimum 40px touch targets for accessibility
- Font sizes remain readable (minimum 0.6rem = ~9.6px on most devices)
- Scrollbars are thin but still visible and usable
- Active states provide clear visual feedback
- Smooth animations enhance perceived performance

View File

@ -12,5 +12,8 @@
"stream_bitrate": "192k", "stream_bitrate": "192k",
"max_upload_mb": 500, "max_upload_mb": 500,
"cors_origins": "*", "cors_origins": "*",
"debug": false "debug": false,
"_comment_listener_url": "Public URL of the listener page. Shown in DJ panel as the shareable stream link. Leave empty to auto-detect.",
"listener_url": ""
} }

View File

@ -71,7 +71,6 @@
<div class="lib-header"> <div class="lib-header">
<input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()"> <input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()">
<button class="folder-btn" onclick="openFolderPicker()" title="Choose Folder">OPEN</button>
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button> <button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button>
</div> </div>
@ -426,8 +425,8 @@
<!-- Settings Panel --> <!-- Settings Panel -->
<div class="settings-panel" id="settings-panel"> <div class="settings-panel" id="settings-panel">
<div class="settings-header"> <div class="settings-header">
<span>SETTINGS</span> <span style="font-family:'Orbitron',sans-serif; font-size:1rem; letter-spacing:3px; color:var(--primary-cyan);">SETTINGS</span>
<button class="close-settings" onclick="toggleSettings()">X</button> <button onclick="toggleSettings()" style="background:transparent; border:none; color:#aaa; font-size:1.4rem; cursor:pointer; line-height:1; padding:4px 8px;">&times;</button>
</div> </div>
<div class="settings-content"> <div class="settings-content">
<div class="setting-item"><label><input type="checkbox" id="repeat-A" <div class="setting-item"><label><input type="checkbox" id="repeat-A"
@ -447,10 +446,15 @@
<div class="setting-item"><label><input type="checkbox" id="glow-B" <div class="setting-item"><label><input type="checkbox" id="glow-B"
onchange="updateManualGlow('B', this.checked)">Glow Deck B (Magenta)</label></div> onchange="updateManualGlow('B', this.checked)">Glow Deck B (Magenta)</label></div>
<div class="setting-item" style="flex-direction: column; align-items: flex-start;"> <div class="setting-item" style="flex-direction: column; align-items: flex-start;">
<label>Glow Intensity</label> <label>Glow Intensity (DJ Panel)</label>
<input type="range" id="glow-intensity" min="1" max="100" value="30" style="width: 100%;" <input type="range" id="glow-intensity" min="1" max="100" value="30" style="width: 100%;"
oninput="updateGlowIntensity(this.value)"> oninput="updateGlowIntensity(this.value)">
</div> </div>
<div class="setting-item" style="flex-direction: column; align-items: flex-start;">
<label>Listener Page Glow</label>
<input type="range" id="listener-glow-intensity" min="0" max="100" value="30" style="width: 100%;"
oninput="updateListenerGlow(this.value)">
</div>
<div class="setting-item"> <div class="setting-item">
<button class="btn-primary" onclick="openKeyboardSettings()" <button class="btn-primary" onclick="openKeyboardSettings()"
style="width: 100%; padding: 12px; margin-top: 10px;"> style="width: 100%; padding: 12px; margin-top: 10px;">
@ -480,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>

View File

@ -45,34 +45,33 @@ body::before {
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 999;
opacity: var(--glow-opacity, 0.3); opacity: var(--glow-opacity, 0.3);
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1); transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* Listener atmospheric glow */ /* Listener atmospheric glow — static, intensity driven by --glow-opacity/--glow-spread */
body.listener-glow::before { body.listener-glow::before {
animation: pulse-listener 4s ease-in-out infinite;
}
@keyframes pulse-listener {
0%, 100% {
filter: hue-rotate(0deg) brightness(1.2);
box-shadow: box-shadow:
0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)), 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)),
0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)), 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)),
inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)), inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)),
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1)); inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1));
} }
50% {
filter: hue-rotate(15deg) brightness(1.8); /* Deck-driven glow — mirrors the DJ panel colours */
box-shadow: body.listener-glow.playing-A::before {
0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)), box-shadow: inset 0 0 var(--glow-spread) var(--primary-cyan);
0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 2.2)),
0 0 calc(var(--glow-spread) * 4) rgba(0, 243, 255, calc(var(--glow-opacity) * 1)),
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 1.5)),
inset 0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 1.5));
} }
body.listener-glow.playing-B::before {
box-shadow: inset 0 0 var(--glow-spread) var(--secondary-magenta);
}
body.listener-glow.playing-A.playing-B::before {
box-shadow:
inset 0 0 var(--glow-spread) var(--primary-cyan),
inset 0 0 calc(var(--glow-spread) * 1.5) var(--secondary-magenta);
} }
/* ========== Listener Mode Layout ========== */ /* ========== Listener Mode Layout ========== */
@ -80,7 +79,7 @@ body.listener-glow::before {
.listener-mode { .listener-mode {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%); background: transparent;
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -12,11 +12,10 @@
<!-- Listener UI --> <!-- Listener UI -->
<div class="listener-mode" id="listener-mode"> <div class="listener-mode" id="listener-mode">
<div class="listener-header"> <div class="listener-header">
<h1>TECHDJ LIVE</h1> <h1>TECHY.MUSIC</h1>
<div class="live-indicator"> <div class="live-indicator">
<span class="pulse-dot"></span> <span class="pulse-dot"></span>
<span>LIVE</span> <span>LIVE</span>
<span class="listener-count-badge"><span id="listener-count">0</span> listening</span>
</div> </div>
</div> </div>
<div class="listener-content"> <div class="listener-content">

View File

@ -56,11 +56,6 @@ function updateNowPlaying(text) {
if (el) el.textContent = text; if (el) el.textContent = text;
} }
function updateListenerCount(count) {
const el = document.getElementById('listener-count');
if (el) el.textContent = count;
}
// --- Reconnection with Exponential Backoff --- // --- Reconnection with Exponential Backoff ---
function scheduleReconnect() { function scheduleReconnect() {
@ -258,7 +253,6 @@ function initSocket() {
socket.on('connect', () => { socket.on('connect', () => {
console.log('[SOCKET] Connected'); console.log('[SOCKET] Connected');
socket.emit('join_listener'); socket.emit('join_listener');
socket.emit('get_listener_count');
if (window.listenerAudioEnabled) { if (window.listenerAudioEnabled) {
updateStatus('Connected', true); updateStatus('Connected', true);
} }
@ -275,10 +269,6 @@ function initSocket() {
} }
}); });
socket.on('listener_count', (data) => {
updateListenerCount(data.count);
});
// --- Broadcast lifecycle events --- // --- Broadcast lifecycle events ---
socket.on('stream_status', (data) => { socket.on('stream_status', (data) => {
@ -304,9 +294,10 @@ function initSocket() {
updateNowPlaying('Stream is live!'); updateNowPlaying('Stream is live!');
if (window.listenerAudioEnabled) { if (window.listenerAudioEnabled) {
// Small delay to let the transcoder produce initial data // Brief delay: 300ms is enough for ffmpeg to produce its first output
// (was 800ms — reduced to cut perceived startup lag)
resetReconnectBackoff(); resetReconnectBackoff();
setTimeout(() => connectStream(), 800); setTimeout(() => connectStream(), 300);
} }
}); });
@ -317,6 +308,15 @@ function initSocket() {
handleBroadcastOffline(); handleBroadcastOffline();
}); });
socket.on('listener_glow', (data) => {
updateGlowIntensity(data.intensity ?? 30);
});
socket.on('deck_glow', (data) => {
document.body.classList.toggle('playing-A', !!data.A);
document.body.classList.toggle('playing-B', !!data.B);
});
return socket; return socket;
} }

548
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;
@ -301,7 +335,19 @@ function initDropZones() {
queueEl.classList.remove('drag-over'); queueEl.classList.remove('drag-over');
const file = e.dataTransfer.getData('trackFile'); const file = e.dataTransfer.getData('trackFile');
const title = e.dataTransfer.getData('trackTitle'); const title = e.dataTransfer.getData('trackTitle');
if (file && title) { 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}`); console.log(`Dropped track into Queue ${id}: ${title}`);
addToQueue(id, file, title); addToQueue(id, file, title);
} }
@ -594,9 +640,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;
@ -773,22 +831,14 @@ function formatTime(seconds) {
} }
// Playback Logic // Playback Logic
function playDeck(id) { function _notifyListenerDeckGlow() {
vibrate(15); // Emit the current playing state of both decks to the listener page
// Server-side audio mode if (!socket) return;
if (SERVER_SIDE_AUDIO) { socket.emit('deck_glow', { A: !!decks.A.playing, B: !!decks.B.playing });
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);
console.log(`[Deck ${id}] Play command sent to server`);
return;
} }
// Browser-side audio mode (original code) function playDeck(id) {
vibrate(15);
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;
@ -800,6 +850,7 @@ function playDeck(id) {
const deckEl = document.getElementById('deck-' + id); const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.add('playing'); if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id); document.body.classList.add('playing-' + id);
_notifyListenerDeckGlow();
if (audioCtx.state === 'suspended') { if (audioCtx.state === 'suspended') {
console.log(`[Deck ${id}] Resuming suspended AudioContext`); console.log(`[Deck ${id}] Resuming suspended AudioContext`);
@ -828,20 +879,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);
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`);
@ -863,6 +900,7 @@ function pauseDeck(id) {
const deckEl = document.getElementById('deck-' + id); const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing'); if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id); document.body.classList.remove('playing-' + id);
_notifyListenerDeckGlow();
} }
@ -870,27 +908,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;
@ -900,6 +917,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) { }
@ -915,17 +934,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);
@ -953,14 +982,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;
@ -971,28 +992,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);
} }
@ -1262,14 +1267,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;
@ -1404,19 +1401,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;
@ -1517,6 +1501,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);
} }
} }
@ -1547,14 +1532,24 @@ async function handleFileUpload(event) {
progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>'; progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>';
progressContainer.classList.add('active'); progressContainer.classList.add('active');
const existingFilenames = allSongs.map(s => s.file.split('/').pop().toLowerCase());
const uploadPromises = files.map(async (file) => { const uploadPromises = files.map(async (file) => {
const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg']; const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowedExts.includes(ext)) { if (!allowedExts.includes(ext)) {
console.warn(`${file.name} is not a supported audio file`); console.warn(`${file.name} is not a supported audio file`);
return; 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(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@ -1572,8 +1567,7 @@ async function handleFileUpload(event) {
progressRow.appendChild(barWrap); progressRow.appendChild(barWrap);
progressContainer.appendChild(progressRow); progressContainer.appendChild(progressRow);
try { return new Promise((resolve) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true); xhr.open('POST', '/upload', true);
@ -1586,30 +1580,35 @@ async function handleFileUpload(event) {
xhr.onload = () => { xhr.onload = () => {
if (xhr.status === 200) { if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText); const result = JSON.parse(xhr.responseText);
if (result.success) { if (result.success) {
barInner.style.background = '#00ff88'; barInner.style.background = '#00ff88';
resolve();
} else { } else {
barInner.style.background = '#ff4444'; barInner.style.background = '#ff4444';
reject(new Error(result.error)); 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 { } else {
barInner.style.background = '#ff4444'; barInner.style.background = '#ff4444';
reject(new Error(`HTTP ${xhr.status}`)); 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 = () => { xhr.onerror = () => {
barInner.style.background = '#ff4444'; barInner.style.background = '#ff4444';
reject(new Error('Network error')); console.error(`[UPLOAD] ${file.name}: Network error`);
resolve();
}; };
xhr.send(formData); xhr.send(formData);
}); });
} catch (error) {
console.error(`[ERROR] Upload error: ${error}`);
}
}); });
// Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all) // Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all)
@ -1745,11 +1744,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;
@ -1758,6 +1758,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) {
@ -1765,9 +1766,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) {
settings.listenerGlowIntensity = parseInt(val);
if (!socket) initSocket();
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
saveSettings();
} }
// Dismiss landscape prompt // Dismiss landscape prompt
@ -1788,25 +1796,47 @@ 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
const streamUrl = window.location.hostname.startsWith('dj.')
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
: `${window.location.protocol}//${window.location.hostname}:5001`;
const streamInput = document.getElementById('stream-url'); const streamInput = document.getElementById('stream-url');
if (streamInput) streamInput.value = streamUrl; 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 ========== // ========== LIVE STREAMING FUNCTIONALITY ==========
@ -1997,24 +2027,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) {
@ -2190,10 +2203,12 @@ function startBroadcast() {
} }
}; };
// 1000ms chunks: Dramatically reduces CPU interrupts on low-RAM machines // 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 // Validate state before starting
if (mediaRecorder.state === 'inactive') { if (mediaRecorder.state === 'inactive') {
mediaRecorder.start(1000); mediaRecorder.start(250);
streamProcessor = mediaRecorder; streamProcessor = mediaRecorder;
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state); console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
} else { } else {
@ -2238,23 +2253,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) {
@ -2264,7 +2271,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) {
@ -2275,11 +2281,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');
@ -2386,91 +2390,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();
@ -2566,11 +2634,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
@ -2578,11 +2641,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
@ -2591,11 +2649,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
@ -2610,11 +2663,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;
} }
@ -2686,11 +2734,14 @@ function renderQueue(deckId) {
removeFromQueue(deckId, index); removeFromQueue(deckId, index);
}; };
// Drag and drop reordering // Drag and drop reordering / moving
item.ondragstart = (e) => { item.ondragstart = (e) => {
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('queueIndex', index); e.dataTransfer.setData('queueIndex', index);
e.dataTransfer.setData('queueDeck', deckId); 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.classList.add('dragging');
}; };
@ -2705,17 +2756,30 @@ function renderQueue(deckId) {
item.ondrop = (e) => { item.ondrop = (e) => {
e.preventDefault(); e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('queueIndex')); const fromIndex = e.dataTransfer.getData('queueIndex');
const fromDeck = e.dataTransfer.getData('queueDeck'); 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);
if (fromDeck === deckId && fromIndex !== index) {
const [movedItem] = queues[deckId].splice(fromIndex, 1);
queues[deckId].splice(index, 0, movedItem);
renderQueue(deckId); renderQueue(deckId);
if (fromDeck !== deckId) renderQueue(fromDeck);
if (SERVER_SIDE_AUDIO && socket) { console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`);
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] }); } 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}`);
} }
}; };

151
server.py
View File

@ -42,6 +42,7 @@ CONFIG_SECRET = (CONFIG.get('secret_key') or '').strip() or 'dj_panel_secret'
CONFIG_CORS = CONFIG.get('cors_origins', '*') CONFIG_CORS = CONFIG.get('cors_origins', '*')
CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500) CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
CONFIG_DEBUG = bool(CONFIG.get('debug', False)) CONFIG_DEBUG = bool(CONFIG.get('debug', False))
CONFIG_LISTENER_URL = (CONFIG.get('listener_url') or '').strip()
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip() DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD) DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
@ -66,7 +67,7 @@ _mp3_lock = threading.Lock()
_transcoder_bytes_out = 0 _transcoder_bytes_out = 0
_transcoder_last_error = None _transcoder_last_error = None
_last_audio_chunk_ts = 0.0 _last_audio_chunk_ts = 0.0
_mp3_preroll = collections.deque(maxlen=512) _mp3_preroll = collections.deque(maxlen=1024) # ~83s at 96kbps for fast reconnect buffer fill
def _start_transcoder_if_needed(is_mp3_input=False): def _start_transcoder_if_needed(is_mp3_input=False):
@ -234,9 +235,9 @@ def _stop_transcoder():
def _feed_transcoder(data: bytes): def _feed_transcoder(data: bytes):
global _last_audio_chunk_ts global _last_audio_chunk_ts
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# If active but dead, restart it automatically # If active but dead, restart it automatically (non-MP3 mode only)
if broadcast_state.get('active'): if broadcast_state.get('active'):
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False)) _start_transcoder_if_needed(is_mp3_input=False)
else: else:
return return
@ -246,6 +247,28 @@ def _feed_transcoder(data: bytes):
except queue.Full: except queue.Full:
# Drop chunk if overflow to prevent memory bloat # Drop chunk if overflow to prevent memory bloat
pass pass
def _distribute_mp3(data: bytes):
"""Distribute MP3 bytes directly to preroll buffer and all connected listener
clients, bypassing the ffmpeg transcoder entirely.
Used when the DJ is already sending valid MP3 (e.g. the Qt desktop client)
to eliminate the unnecessary encode/decode round-trip and cut pipeline latency
roughly in half.
"""
global _transcoder_bytes_out, _last_audio_chunk_ts
_last_audio_chunk_ts = time.time()
_transcoder_bytes_out += len(data)
with _mp3_lock:
_mp3_preroll.append(data)
for q in list(_mp3_clients):
try:
q.put_nowait(data)
except queue.Full:
pass
# Load settings to get MUSIC_FOLDER # Load settings to get MUSIC_FOLDER
def _load_settings(): def _load_settings():
try: try:
@ -386,6 +409,9 @@ def setup_shared_routes(app, index_file='index.html'):
filepath = os.path.join(MUSIC_FOLDER, filename) filepath = os.path.join(MUSIC_FOLDER, filename)
if os.path.exists(filepath):
return jsonify({"success": False, "error": "File already exists in library"}), 409
try: try:
file.save(filepath) file.save(filepath)
print(f"UPLOADED: {filename}") print(f"UPLOADED: {filename}")
@ -431,7 +457,10 @@ def setup_shared_routes(app, index_file='index.html'):
'Cache-Control': 'no-cache, no-store', 'Cache-Control': 'no-cache, no-store',
}) })
# If the transcoder isn't ready yet, wait briefly (it may still be starting) # For non-MP3 input the server runs an ffmpeg transcoder; wait for it to start.
# For MP3 input (e.g. Qt client) chunks are distributed directly — no ffmpeg needed.
is_mp3_direct = broadcast_state.get('is_mp3_input', False)
if not is_mp3_direct:
waited = 0.0 waited = 0.0
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0: while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
eventlet.sleep(0.5) eventlet.sleep(0.5)
@ -485,6 +514,7 @@ def setup_shared_routes(app, index_file='index.html'):
'X-Content-Type-Options': 'nosniff', 'X-Content-Type-Options': 'nosniff',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Icy-Name': 'TechDJ Live', 'Icy-Name': 'TechDJ Live',
'X-Accel-Buffering': 'no', # Tell nginx/Cloudflare not to buffer this stream
}) })
@app.route('/stream_debug') @app.route('/stream_debug')
@ -588,7 +618,6 @@ def dj_login():
<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus /> <input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus />
<button type=\"submit\">Unlock DJ Panel</button> <button type=\"submit\">Unlock DJ Panel</button>
{f"<div class='err'>{error}</div>" if error else ""} {f"<div class='err'>{error}</div>" if error else ""}
<div class=\"hint\">Set/disable this in config.json (dj_panel_password).</div>
</form> </form>
</div> </div>
</div> </div>
@ -605,6 +634,12 @@ def dj_logout():
302, 302,
{'Location': '/login'} {'Location': '/login'}
) )
@dj_app.route('/client_config')
def client_config():
"""Expose server-side config values needed by the DJ panel client."""
return jsonify({'listener_url': CONFIG_LISTENER_URL})
dj_socketio = SocketIO( dj_socketio = SocketIO(
dj_app, dj_app,
cors_allowed_origins=CONFIG_CORS, cors_allowed_origins=CONFIG_CORS,
@ -680,17 +715,21 @@ def dj_start(data=None):
broadcast_state['is_mp3_input'] = is_mp3_input broadcast_state['is_mp3_input'] = is_mp3_input
if not was_already_active: if not was_already_active:
# Fresh broadcast start - clear pre-roll and start transcoder cleanly # Fresh broadcast start — clear pre-roll.
# For non-MP3 input start the ffmpeg transcoder; for MP3 input chunks are
# distributed directly via _distribute_mp3(), no transcoder required.
with _mp3_lock: with _mp3_lock:
_mp3_preroll.clear() _mp3_preroll.clear()
_start_transcoder_if_needed(is_mp3_input=is_mp3_input) if not is_mp3_input:
_start_transcoder_if_needed(is_mp3_input=False)
# Tell listeners a new broadcast has begun (triggers audio player reload) # Tell listeners a new broadcast has begun (triggers audio player reload)
listener_socketio.emit('broadcast_started', namespace='/') listener_socketio.emit('broadcast_started', namespace='/')
else: else:
# DJ reconnected mid-broadcast - just ensure transcoder is alive # DJ reconnected mid-broadcast - just ensure transcoder is alive (non-MP3 only)
# Do NOT clear pre-roll or trigger listener reload # Do NOT clear pre-roll or trigger listener reload
print("BROADCAST: DJ reconnected - resuming existing broadcast") print("BROADCAST: DJ reconnected - resuming existing broadcast")
_start_transcoder_if_needed(is_mp3_input=is_mp3_input) if not is_mp3_input:
_start_transcoder_if_needed(is_mp3_input=False)
# Always send current status so any waiting listeners get unblocked # Always send current status so any waiting listeners get unblocked
listener_socketio.emit('stream_status', {'active': True}, namespace='/') listener_socketio.emit('stream_status', {'active': True}, namespace='/')
@ -699,6 +738,23 @@ def dj_start(data=None):
def dj_get_listener_count(): def dj_get_listener_count():
emit('listener_count', {'count': len(listener_sids)}) emit('listener_count', {'count': len(listener_sids)})
@dj_socketio.on('listener_glow')
def dj_listener_glow(data):
"""DJ sets the glow intensity on the listener page."""
intensity = int(data.get('intensity', 30)) if isinstance(data, dict) else 30
intensity = max(0, min(100, intensity))
listener_socketio.emit('listener_glow', {'intensity': intensity}, namespace='/')
@dj_socketio.on('deck_glow')
def dj_deck_glow(data):
"""Relay which decks are playing so the listener page can mirror the glow colour."""
if not isinstance(data, dict):
return
listener_socketio.emit('deck_glow', {
'A': bool(data.get('A', False)),
'B': bool(data.get('B', False)),
}, namespace='/')
@dj_socketio.on('stop_broadcast') @dj_socketio.on('stop_broadcast')
def dj_stop(): def dj_stop():
broadcast_state['active'] = False broadcast_state['active'] = False
@ -712,15 +768,14 @@ def dj_stop():
@dj_socketio.on('audio_chunk') @dj_socketio.on('audio_chunk')
def dj_audio(data): def dj_audio(data):
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only. if broadcast_state['active'] and isinstance(data, (bytes, bytearray)):
if broadcast_state['active']: if broadcast_state.get('is_mp3_input', False):
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed) # MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners
_distribute_mp3(bytes(data))
else:
# Other formats (e.g. webm/opus from browser): route through ffmpeg transcoder
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# If we don't know the format, default to transcode, _start_transcoder_if_needed(is_mp3_input=False)
# but usually start_broadcast handles this
_start_transcoder_if_needed()
if isinstance(data, (bytes, bytearray)):
_feed_transcoder(bytes(data)) _feed_transcoder(bytes(data))
# === LISTENER SERVER === # === LISTENER SERVER ===
@ -748,34 +803,49 @@ listener_socketio = SocketIO(
cors_allowed_origins=CONFIG_CORS, cors_allowed_origins=CONFIG_CORS,
async_mode='eventlet', async_mode='eventlet',
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024, max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
ping_timeout=60, # Lower timeouts: stale connections detected in ~25s instead of ~85s
ping_interval=25, # ping_interval: how often to probe (seconds)
# ping_timeout: how long to wait for pong before declaring dead
ping_timeout=15,
ping_interval=10,
logger=CONFIG_DEBUG, logger=CONFIG_DEBUG,
engineio_logger=CONFIG_DEBUG engineio_logger=CONFIG_DEBUG
) )
def _broadcast_listener_count():
"""Compute the most accurate listener count and broadcast to both panels.
Uses the larger of:
- listener_sids: Socket.IO connections (people with the page open)
- _mp3_clients: active /stream.mp3 HTTP connections (people actually hearing audio)
Taking the max avoids undercounting when someone hasn't clicked Enable Audio
yet, and also avoids undercounting direct stream URL listeners (e.g. VLC).
"""
with _mp3_lock:
stream_count = len(_mp3_clients)
count = max(len(listener_sids), stream_count)
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
return count
@listener_socketio.on('connect') @listener_socketio.on('connect')
def listener_connect(): def listener_connect():
print(f"LISTENER: Listener Socket Connected: {request.sid}") # Count immediately on connect — don't wait for join_listener
listener_sids.add(request.sid)
count = _broadcast_listener_count()
print(f"LISTENER: Connected {request.sid}. Total: {count}")
@listener_socketio.on('disconnect') @listener_socketio.on('disconnect')
def listener_disconnect(): def listener_disconnect():
listener_sids.discard(request.sid) listener_sids.discard(request.sid)
count = len(listener_sids) count = _broadcast_listener_count()
print(f"REMOVED: Listener left. Total: {count}") print(f"REMOVED: Listener left {request.sid}. Total: {count}")
# Notify BOTH namespaces
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
@listener_socketio.on('join_listener') @listener_socketio.on('join_listener')
def listener_join(): def listener_join():
if request.sid not in listener_sids: # SID already added in listener_connect(); just send stream status back
listener_sids.add(request.sid)
count = len(listener_sids)
print(f"LISTENER: New listener joined. Total: {count}")
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
emit('stream_status', {'active': broadcast_state['active']}) emit('stream_status', {'active': broadcast_state['active']})
@listener_socketio.on('get_listener_count') @listener_socketio.on('get_listener_count')
@ -784,23 +854,24 @@ def listener_get_count():
# DJ Panel Routes (No engine commands needed in local mode) # DJ Panel Routes (No engine commands needed in local mode)
def _transcoder_watchdog(): def _transcoder_watchdog():
"""Periodic check to ensure the transcoder stays alive during active broadcasts.""" """Periodic check to ensure the ffmpeg transcoder stays alive.
Only applies to non-MP3 input; MP3 input (Qt client) is distributed directly.
"""
while True: while True:
if broadcast_state.get('active'): is_mp3_direct = broadcast_state.get('is_mp3_input', False)
if broadcast_state.get('active') and not is_mp3_direct:
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None: if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# Only log if it's actually dead and supposed to be alive
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...") print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False)) _start_transcoder_if_needed(is_mp3_input=False)
eventlet.sleep(5) eventlet.sleep(5)
def _listener_count_sync_loop(): def _listener_count_sync_loop():
"""Periodic background sync to ensure listener count is always accurate.""" """Periodic reconciliation — catches any edge cases where connect/disconnect
events were missed (e.g. server under load, eventlet greenlet delays)."""
while True: while True:
count = len(listener_sids)
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
eventlet.sleep(5) eventlet.sleep(5)
_broadcast_listener_count()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -10,7 +10,7 @@
"audio": { "audio": {
"recording_sample_rate": 48000, "recording_sample_rate": 48000,
"recording_format": "wav", "recording_format": "wav",
"stream_server_url": "http://54.37.246.24:5000" "stream_server_url": "http://54.37.246.24:5001/"
}, },
"ui": { "ui": {
"neon_mode": 2 "neon_mode": 2

View File

@ -24,6 +24,12 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
overflow: hidden;
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar {
display: none; /* Chrome / Edge / Safari */
} }
body { body {
@ -218,7 +224,7 @@ header h1 {
grid-template-rows: 1fr 80px; grid-template-rows: 1fr 80px;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
height: calc(100vh - 60px); height: 100vh;
/* Adjust based on header height */ /* Adjust based on header height */
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@ -1706,6 +1712,9 @@ input[type=range] {
/* Less intense on mobile */ /* Less intense on mobile */
} }
header {
display: none;
}
.mobile-top-bar { .mobile-top-bar {
display: flex; display: flex;
@ -1916,8 +1925,8 @@ input[type=range] {
/* Streaming Button */ /* Streaming Button */
.streaming-btn { .streaming-btn {
position: fixed; position: fixed;
bottom: 25px; bottom: 175px;
right: 175px; right: 25px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -1947,8 +1956,8 @@ input[type=range] {
.upload-btn { .upload-btn {
position: fixed; position: fixed;
bottom: 25px; bottom: 100px;
right: 100px; right: 25px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -1980,7 +1989,7 @@ input[type=range] {
.streaming-panel { .streaming-panel {
position: fixed; position: fixed;
top: 0; top: 0;
right: -400px; right: -460px;
height: 100vh; height: 100vh;
width: 380px; width: 380px;
background: rgba(10, 10, 20, 0.98); background: rgba(10, 10, 20, 0.98);
@ -2587,8 +2596,8 @@ body.listening-active .landscape-prompt {
/* Base Settings Button Fix */ /* Base Settings Button Fix */
.keyboard-btn { .keyboard-btn {
position: fixed; position: fixed;
bottom: 25px; bottom: 250px;
right: 250px; right: 25px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -2637,7 +2646,7 @@ body.listening-active .landscape-prompt {
.settings-panel { .settings-panel {
position: fixed; position: fixed;
top: 0; top: 0;
right: -350px; right: -400px;
height: 100vh; height: 100vh;
width: 320px; width: 320px;
background: rgba(10, 10, 20, 0.98); background: rgba(10, 10, 20, 0.98);
@ -4773,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; }
}

View File

@ -852,21 +852,26 @@ class StreamingWorker(QThread):
"ffmpeg", "ffmpeg",
"-hide_banner", "-hide_banner",
"-loglevel", "error", "-loglevel", "error",
# Disable input buffering so frames reach the pipe immediately
"-fflags", "nobuffer",
"-f", "pulse", "-f", "pulse",
"-i", source, "-i", source,
"-ac", "2", "-ac", "2",
"-ar", "44100", "-ar", "44100",
"-f", "mp3",
"-b:a", "128k", "-b:a", "128k",
"-af", "aresample=async=1", "-af", "aresample=async=1",
# Flush every packet — critical for low-latency pipe streaming
"-flush_packets", "1",
"-f", "mp3",
"pipe:1" "pipe:1"
] ]
self.ffmpeg_proc = subprocess.Popen( self.ffmpeg_proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192 cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
) )
while self.is_running and self.ffmpeg_proc.poll() is None: while self.is_running and self.ffmpeg_proc.poll() is None:
chunk = self.ffmpeg_proc.stdout.read(8192) # 4096 bytes ≈ 10 MP3 frames ≈ ~260ms at 128kbps — low-latency chunks
chunk = self.ffmpeg_proc.stdout.read(4096)
if not chunk: if not chunk:
break break
sio = self.sio # Local ref to avoid race with stop_streaming() sio = self.sio # Local ref to avoid race with stop_streaming()
@ -2105,16 +2110,29 @@ class DJApp(QMainWindow):
return return
if self.library_mode == "local": if self.library_mode == "local":
filename = os.path.basename(file_path)
# Check for duplicates in local_library
if any(Path(track['path']).name.lower() == filename.lower() for track in self.local_library):
QMessageBox.information(self, "Import Skipped", f"'{filename}' is already in your local library.")
return
# Copy to local music folder # Copy to local music folder
dest = self.lib_path / os.path.basename(file_path) dest = self.lib_path / filename
try: try:
shutil.copy2(file_path, dest) shutil.copy2(file_path, dest)
self.status_label.setText(f"Imported: {os.path.basename(file_path)}") self.status_label.setText(f"Imported: {filename}")
self.load_library() self.load_library()
except Exception as e: except Exception as e:
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}") QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
else: else:
# Upload to server # Upload to server
filename = os.path.basename(file_path)
# Check for duplicates in server_library
if hasattr(self, 'server_library'):
if any(track['file'].split('/')[-1].lower() == filename.lower() for track in self.server_library):
QMessageBox.information(self, "Upload Skipped", f"'{filename}' already exists on the server.")
return
try: try:
self.status_label.setText("Uploading to server...") self.status_label.setText("Uploading to server...")
base_url = self.get_server_base_url() base_url = self.get_server_base_url()