Compare commits

..

No commits in common. "d2e6e2a7d710271d7ed6a2a89e4513c85df15b63" and "dfccec2b4816b3370a7fce9ce5042b43bfac5388" have entirely different histories.

11 changed files with 540 additions and 637 deletions

126
MOBILE_IMPROVEMENTS.md Normal file
View File

@ -0,0 +1,126 @@
# 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,8 +12,5 @@
"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,6 +71,7 @@
<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>
@ -425,8 +426,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 style="font-family:'Orbitron',sans-serif; font-size:1rem; letter-spacing:3px; color:var(--primary-cyan);">SETTINGS</span> <span>SETTINGS</span>
<button onclick="toggleSettings()" style="background:transparent; border:none; color:#aaa; font-size:1.4rem; cursor:pointer; line-height:1; padding:4px 8px;">&times;</button> <button class="close-settings" onclick="toggleSettings()">X</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"
@ -446,15 +447,10 @@
<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 (DJ Panel)</label> <label>Glow Intensity</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;">
@ -484,8 +480,6 @@
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,33 +45,34 @@ body::before {
position: fixed; position: fixed;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 999; z-index: 1;
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 — static, intensity driven by --glow-opacity/--glow-spread */ /* Listener atmospheric glow */
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% {
/* Deck-driven glow — mirrors the DJ panel colours */ filter: hue-rotate(15deg) brightness(1.8);
body.listener-glow.playing-A::before {
box-shadow: inset 0 0 var(--glow-spread) var(--primary-cyan);
}
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: box-shadow:
inset 0 0 var(--glow-spread) var(--primary-cyan), 0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)),
inset 0 0 calc(var(--glow-spread) * 1.5) var(--secondary-magenta); 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));
}
} }
/* ========== Listener Mode Layout ========== */ /* ========== Listener Mode Layout ========== */
@ -79,7 +80,7 @@ body.listener-glow.playing-A.playing-B::before {
.listener-mode { .listener-mode {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: transparent; background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%);
z-index: 10; z-index: 10;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -12,10 +12,11 @@
<!-- 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>TECHY.MUSIC</h1> <h1>TECHDJ LIVE</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,6 +56,11 @@ 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() {
@ -253,6 +258,7 @@ 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);
} }
@ -269,6 +275,10 @@ 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) => {
@ -294,10 +304,9 @@ function initSocket() {
updateNowPlaying('Stream is live!'); updateNowPlaying('Stream is live!');
if (window.listenerAudioEnabled) { if (window.listenerAudioEnabled) {
// Brief delay: 300ms is enough for ffmpeg to produce its first output // Small delay to let the transcoder produce initial data
// (was 800ms — reduced to cut perceived startup lag)
resetReconnectBackoff(); resetReconnectBackoff();
setTimeout(() => connectStream(), 300); setTimeout(() => connectStream(), 800);
} }
}); });
@ -308,15 +317,6 @@ 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;
} }

544
script.js
View File

@ -2,6 +2,8 @@
// 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 = {
@ -72,42 +74,6 @@ 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;
@ -335,19 +301,7 @@ 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');
const fromDeck = e.dataTransfer.getData('queueDeck'); if (file && title) {
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);
} }
@ -640,21 +594,9 @@ 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; if (!canvas) return; // Null check
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;
@ -831,14 +773,22 @@ function formatTime(seconds) {
} }
// Playback Logic // Playback Logic
function _notifyListenerDeckGlow() {
// Emit the current playing state of both decks to the listener page
if (!socket) return;
socket.emit('deck_glow', { A: !!decks.A.playing, B: !!decks.B.playing });
}
function playDeck(id) { function playDeck(id) {
vibrate(15); vibrate(15);
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
socket.emit('audio_play', { deck: id });
decks[id].playing = true;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id);
console.log(`[Deck ${id}] Play command sent to server`);
return;
}
// Browser-side audio mode (original code)
if (decks[id].type === 'local' && decks[id].localBuffer) { if (decks[id].type === 'local' && decks[id].localBuffer) {
if (decks[id].playing) return; if (decks[id].playing) return;
@ -850,7 +800,6 @@ 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`);
@ -879,6 +828,20 @@ 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`);
@ -900,7 +863,6 @@ 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();
} }
@ -908,6 +870,27 @@ 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;
@ -917,8 +900,6 @@ 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) { }
@ -934,27 +915,17 @@ 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`);
if (speedSlider) speed = parseFloat(speedSlider.value); const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
}
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;
// Wire onended so natural playback completion always triggers auto-play // Add error handler for the source
src.onended = () => { src.onended = () => {
// Guard: only act if we didn't stop it intentionally console.log(`[Deck ${id}] Playback ended naturally`);
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);
@ -982,6 +953,14 @@ 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;
@ -992,12 +971,28 @@ 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);
} }
@ -1267,6 +1262,14 @@ 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;
@ -1401,6 +1404,19 @@ 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;
@ -1501,7 +1517,6 @@ 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);
} }
} }
@ -1532,24 +1547,14 @@ 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);
@ -1567,7 +1572,8 @@ async function handleFileUpload(event) {
progressRow.appendChild(barWrap); progressRow.appendChild(barWrap);
progressContainer.appendChild(progressRow); progressContainer.appendChild(progressRow);
return new Promise((resolve) => { try {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true); xhr.open('POST', '/upload', true);
@ -1580,35 +1586,30 @@ 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';
nameSpan.title = result.error || 'Upload failed'; reject(new Error(result.error));
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';
nameSpan.title = `HTTP ${xhr.status}`; reject(new Error(`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';
console.error(`[UPLOAD] ${file.name}: Network error`); reject(new Error('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)
@ -1744,12 +1745,11 @@ 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; saveSettings(); } function toggleAutoMix(val) { settings.autoMix = val; }
function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); } function toggleShuffle(val) { settings.shuffleMode = val; }
function toggleQuantize(val) { settings.quantize = val; saveSettings(); } function toggleQuantize(val) { settings.quantize = val; }
function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); } function toggleAutoPlay(val) { settings.autoPlay = val; }
function updateManualGlow(id, val) { function updateManualGlow(id, val) {
settings[`glow${id}`] = val; settings[`glow${id}`] = val;
@ -1758,7 +1758,6 @@ 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) {
@ -1766,16 +1765,9 @@ 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
@ -1796,47 +1788,25 @@ window.addEventListener('DOMContentLoaded', () => {
if (prompt) prompt.classList.add('dismissed'); if (prompt) prompt.classList.add('dismissed');
} }
// Sync all restored settings to UI controls // Initialise glow intensity
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) { if (streamInput) streamInput.value = streamUrl;
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 ==========
@ -2027,7 +1997,24 @@ function startBroadcast() {
return; return;
} }
// Browser-side audio mode // 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)
// 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) {
@ -2203,12 +2190,10 @@ function startBroadcast() {
} }
}; };
// 250ms chunks: More frequent smaller chunks reduces stall gaps on weak connections. // 1000ms chunks: Dramatically reduces CPU interrupts on low-RAM machines
// 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(250); mediaRecorder.start(1000);
streamProcessor = mediaRecorder; streamProcessor = mediaRecorder;
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state); console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
} else { } else {
@ -2253,15 +2238,23 @@ 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) {
@ -2271,6 +2264,7 @@ 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) {
@ -2281,9 +2275,11 @@ 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');
@ -2390,155 +2386,91 @@ window.addEventListener('load', () => {
} }
}); });
// Auto-crossfade: smoothly transitions from the ending deck to the other deck. // Monitoring
let _autoMixTimer = null; function monitorTrackEnd() {
setInterval(() => {
if (!audioCtx) return; // Safety check
function startAutoMixFade(endingDeckId) { // In server-side mode, poll for status from server
const otherDeck = endingDeckId === 'A' ? 'B' : 'A'; if (SERVER_SIDE_AUDIO && socket && socket.connected) {
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;
} }
// If the other deck isn't playing, start it ['A', 'B'].forEach(id => {
if (!decks[otherDeck].playing) { if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) {
playDeck(otherDeck); 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);
// Cancel any existing auto-mix animation const remaining = decks[id].duration - current;
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(`Repeating track on Deck ${id} (broadcast)`); console.log(`LOOP Repeating track on Deck ${id}`);
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) loadFromServer(id, next.file, next.title).then(() => {
.then(() => { decks[id].loading = false; playDeck(id); }) decks[id].loading = false;
.catch(() => { decks[id].loading = false; }); playDeck(id);
}).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}`]) {
console.log(`Repeating track on Deck ${id}`); // Full song repeat
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) {
handleAutoPlay(id); // Prevent race condition
} 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); renderQueue(id); // Update queue UI
loadFromServer(id, next.file, next.title)
.then(() => { decks[id].loading = false; playDeck(id); }) loadFromServer(id, next.file, next.title).then(() => {
.catch(() => { decks[id].loading = false; });
} else {
console.log(`Auto-play: queue empty on Deck ${id}, stopping`);
decks[id].loading = false; 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; decks[id].pausedAt = 0;
} }
} } else {
// Just stop if no auto-play
// Monitoring — safety net for cases where onended doesn't fire pauseDeck(id);
// (e.g. AudioContext suspended, very short buffer, scrub to near-end). decks[id].pausedAt = 0;
function monitorTrackEnd() { }
setInterval(() => { }
if (!audioCtx) return;
['A', 'B'].forEach(id => {
if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
if (decks[id].loopActive) return;
const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
const current = decks[id].lastAnchorPosition + (elapsed * rate);
const remaining = decks[id].duration - current;
// Threshold scales with playback rate so we don't miss fast playback.
// Use 1.5× the poll interval (0.75s) as a safe window.
const threshold = Math.max(0.75, 0.75 * rate);
if (remaining <= threshold) {
console.log(`[Monitor] Deck ${id} near end (${remaining.toFixed(2)}s left) — triggering handleTrackEnd`);
handleTrackEnd(id);
} }
}); });
}, 500); }, 500); // Check every 0.5s
} }
monitorTrackEnd(); monitorTrackEnd();
@ -2634,6 +2566,11 @@ 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
@ -2641,6 +2578,11 @@ 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
@ -2649,6 +2591,11 @@ 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
@ -2663,6 +2610,11 @@ 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;
} }
@ -2734,14 +2686,11 @@ function renderQueue(deckId) {
removeFromQueue(deckId, index); removeFromQueue(deckId, index);
}; };
// Drag and drop reordering / moving // Drag and drop reordering
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');
}; };
@ -2756,30 +2705,17 @@ function renderQueue(deckId) {
item.ondrop = (e) => { item.ondrop = (e) => {
e.preventDefault(); e.preventDefault();
const fromIndex = e.dataTransfer.getData('queueIndex'); const fromIndex = parseInt(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);
console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`); if (SERVER_SIDE_AUDIO && socket) {
} else if (trackFile && trackTitle) { socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
// 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,7 +42,6 @@ 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)
@ -67,7 +66,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=1024) # ~83s at 96kbps for fast reconnect buffer fill _mp3_preroll = collections.deque(maxlen=512)
def _start_transcoder_if_needed(is_mp3_input=False): def _start_transcoder_if_needed(is_mp3_input=False):
@ -235,9 +234,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 (non-MP3 mode only) # If active but dead, restart it automatically
if broadcast_state.get('active'): if broadcast_state.get('active'):
_start_transcoder_if_needed(is_mp3_input=False) _start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
else: else:
return return
@ -247,28 +246,6 @@ 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:
@ -409,9 +386,6 @@ 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}")
@ -457,10 +431,7 @@ def setup_shared_routes(app, index_file='index.html'):
'Cache-Control': 'no-cache, no-store', 'Cache-Control': 'no-cache, no-store',
}) })
# For non-MP3 input the server runs an ffmpeg transcoder; wait for it to start. # If the transcoder isn't ready yet, wait briefly (it may still be starting)
# 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)
@ -514,7 +485,6 @@ 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')
@ -618,6 +588,7 @@ 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>
@ -634,12 +605,6 @@ 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,
@ -715,21 +680,17 @@ 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. # Fresh broadcast start - clear pre-roll and start transcoder cleanly
# 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()
if not is_mp3_input: _start_transcoder_if_needed(is_mp3_input=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 (non-MP3 only) # DJ reconnected mid-broadcast - just ensure transcoder is alive
# 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")
if not is_mp3_input: _start_transcoder_if_needed(is_mp3_input=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='/')
@ -738,23 +699,6 @@ 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
@ -768,14 +712,15 @@ def dj_stop():
@dj_socketio.on('audio_chunk') @dj_socketio.on('audio_chunk')
def dj_audio(data): def dj_audio(data):
if broadcast_state['active'] and isinstance(data, (bytes, bytearray)): # MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
if broadcast_state.get('is_mp3_input', False): if broadcast_state['active']:
# MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners # Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
_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:
_start_transcoder_if_needed(is_mp3_input=False) # If we don't know the format, default to transcode,
# 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 ===
@ -803,49 +748,34 @@ 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,
# Lower timeouts: stale connections detected in ~25s instead of ~85s ping_timeout=60,
# ping_interval: how often to probe (seconds) ping_interval=25,
# 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():
# Count immediately on connect — don't wait for join_listener print(f"LISTENER: Listener Socket Connected: {request.sid}")
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 = _broadcast_listener_count() count = len(listener_sids)
print(f"REMOVED: Listener left {request.sid}. Total: {count}") print(f"REMOVED: Listener left. 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():
# SID already added in listener_connect(); just send stream status back if request.sid not in listener_sids:
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')
@ -854,24 +784,23 @@ 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 ffmpeg transcoder stays alive. """Periodic check to ensure the transcoder stays alive during active broadcasts."""
Only applies to non-MP3 input; MP3 input (Qt client) is distributed directly.
"""
while True: while True:
is_mp3_direct = broadcast_state.get('is_mp3_input', False) if broadcast_state.get('active'):
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=False) _start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
eventlet.sleep(5) eventlet.sleep(5)
def _listener_count_sync_loop(): def _listener_count_sync_loop():
"""Periodic reconciliation — catches any edge cases where connect/disconnect """Periodic background sync to ensure listener count is always accurate."""
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:5001/" "stream_server_url": "http://54.37.246.24:5000"
}, },
"ui": { "ui": {
"neon_mode": 2 "neon_mode": 2

View File

@ -24,12 +24,6 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
overflow: hidden;
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar {
display: none; /* Chrome / Edge / Safari */
} }
body { body {
@ -224,7 +218,7 @@ header h1 {
grid-template-rows: 1fr 80px; grid-template-rows: 1fr 80px;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
height: 100vh; height: calc(100vh - 60px);
/* Adjust based on header height */ /* Adjust based on header height */
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@ -1712,9 +1706,6 @@ input[type=range] {
/* Less intense on mobile */ /* Less intense on mobile */
} }
header {
display: none;
}
.mobile-top-bar { .mobile-top-bar {
display: flex; display: flex;
@ -1925,8 +1916,8 @@ input[type=range] {
/* Streaming Button */ /* Streaming Button */
.streaming-btn { .streaming-btn {
position: fixed; position: fixed;
bottom: 175px; bottom: 25px;
right: 25px; right: 175px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -1956,8 +1947,8 @@ input[type=range] {
.upload-btn { .upload-btn {
position: fixed; position: fixed;
bottom: 100px; bottom: 25px;
right: 25px; right: 100px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -1989,7 +1980,7 @@ input[type=range] {
.streaming-panel { .streaming-panel {
position: fixed; position: fixed;
top: 0; top: 0;
right: -460px; right: -400px;
height: 100vh; height: 100vh;
width: 380px; width: 380px;
background: rgba(10, 10, 20, 0.98); background: rgba(10, 10, 20, 0.98);
@ -2596,8 +2587,8 @@ body.listening-active .landscape-prompt {
/* Base Settings Button Fix */ /* Base Settings Button Fix */
.keyboard-btn { .keyboard-btn {
position: fixed; position: fixed;
bottom: 250px; bottom: 25px;
right: 25px; right: 250px;
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 50%; border-radius: 50%;
@ -2646,7 +2637,7 @@ body.listening-active .landscape-prompt {
.settings-panel { .settings-panel {
position: fixed; position: fixed;
top: 0; top: 0;
right: -400px; right: -350px;
height: 100vh; height: 100vh;
width: 320px; width: 320px;
background: rgba(10, 10, 20, 0.98); background: rgba(10, 10, 20, 0.98);
@ -4782,57 +4773,3 @@ 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,26 +852,21 @@ 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=0 cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192
) )
while self.is_running and self.ffmpeg_proc.poll() is None: while self.is_running and self.ffmpeg_proc.poll() is None:
# 4096 bytes ≈ 10 MP3 frames ≈ ~260ms at 128kbps — low-latency chunks chunk = self.ffmpeg_proc.stdout.read(8192)
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()
@ -2110,29 +2105,16 @@ 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 / filename dest = self.lib_path / os.path.basename(file_path)
try: try:
shutil.copy2(file_path, dest) shutil.copy2(file_path, dest)
self.status_label.setText(f"Imported: {filename}") self.status_label.setText(f"Imported: {os.path.basename(file_path)}")
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()