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",
"max_upload_mb": 500,
"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">
<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>
</div>
@ -426,8 +425,8 @@
<!-- Settings Panel -->
<div class="settings-panel" id="settings-panel">
<div class="settings-header">
<span>SETTINGS</span>
<button class="close-settings" onclick="toggleSettings()">X</button>
<span style="font-family:'Orbitron',sans-serif; font-size:1rem; letter-spacing:3px; color:var(--primary-cyan);">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>
</div>
<div class="settings-content">
<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"
onchange="updateManualGlow('B', this.checked)">Glow Deck B (Magenta)</label></div>
<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%;"
oninput="updateGlowIntensity(this.value)">
</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">
<button class="btn-primary" onclick="openKeyboardSettings()"
style="width: 100%; padding: 12px; margin-top: 10px;">
@ -480,6 +484,8 @@
onchange="handleFileUpload(event)">
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
<div class="toast-container" id="toast-container"></div>
</body>
</html>

View File

@ -45,34 +45,33 @@ body::before {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
z-index: 999;
opacity: var(--glow-opacity, 0.3);
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 {
animation: pulse-listener 4s ease-in-out infinite;
box-shadow:
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)),
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));
}
@keyframes pulse-listener {
0%, 100% {
filter: hue-rotate(0deg) brightness(1.2);
box-shadow:
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)),
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));
}
50% {
filter: hue-rotate(15deg) brightness(1.8);
box-shadow:
0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)),
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));
}
/* Deck-driven glow — mirrors the DJ panel colours */
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:
inset 0 0 var(--glow-spread) var(--primary-cyan),
inset 0 0 calc(var(--glow-spread) * 1.5) var(--secondary-magenta);
}
/* ========== Listener Mode Layout ========== */
@ -80,7 +79,7 @@ body.listener-glow::before {
.listener-mode {
position: fixed;
inset: 0;
background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%);
background: transparent;
z-index: 10;
display: flex;
flex-direction: column;

View File

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

View File

@ -56,11 +56,6 @@ function updateNowPlaying(text) {
if (el) el.textContent = text;
}
function updateListenerCount(count) {
const el = document.getElementById('listener-count');
if (el) el.textContent = count;
}
// --- Reconnection with Exponential Backoff ---
function scheduleReconnect() {
@ -258,7 +253,6 @@ function initSocket() {
socket.on('connect', () => {
console.log('[SOCKET] Connected');
socket.emit('join_listener');
socket.emit('get_listener_count');
if (window.listenerAudioEnabled) {
updateStatus('Connected', true);
}
@ -275,10 +269,6 @@ function initSocket() {
}
});
socket.on('listener_count', (data) => {
updateListenerCount(data.count);
});
// --- Broadcast lifecycle events ---
socket.on('stream_status', (data) => {
@ -304,9 +294,10 @@ function initSocket() {
updateNowPlaying('Stream is live!');
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();
setTimeout(() => connectStream(), 800);
setTimeout(() => connectStream(), 300);
}
});
@ -317,6 +308,15 @@ function initSocket() {
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;
}

676
script.js
View File

@ -2,8 +2,6 @@
// TechDJ Pro - Core DJ Logic
// ==========================================
// Server-side audio mode (true = server processes audio, false = browser processes)
const SERVER_SIDE_AUDIO = false;
let audioCtx;
const decks = {
@ -74,6 +72,42 @@ const queues = {
B: []
};
// Toast Notification System
function showToast(message, type) {
type = type || 'info';
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast toast-' + type;
toast.textContent = message;
container.appendChild(toast);
setTimeout(function() {
if (toast.parentNode) toast.parentNode.removeChild(toast);
}, 4000);
}
// Settings Persistence
function saveSettings() {
try {
localStorage.setItem('techdj_settings', JSON.stringify(settings));
} catch (e) { /* quota exceeded or private browsing */ }
}
function loadSettings() {
try {
var saved = localStorage.getItem('techdj_settings');
if (saved) {
var parsed = JSON.parse(saved);
Object.keys(parsed).forEach(function(key) {
if (settings.hasOwnProperty(key)) settings[key] = parsed[key];
});
}
} catch (e) { /* corrupt data, ignore */ }
}
// Restore saved settings on load
loadSettings();
// System Initialization
function initSystem() {
if (audioCtx) return;
@ -301,7 +335,19 @@ function initDropZones() {
queueEl.classList.remove('drag-over');
const file = e.dataTransfer.getData('trackFile');
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}`);
addToQueue(id, file, title);
}
@ -594,9 +640,21 @@ function generateWaveformData(buffer) {
return filteredData;
}
// Debounce guard: prevents redundant redraws within the same frame
const _waveformPending = { A: false, B: false };
function drawWaveform(id) {
if (_waveformPending[id]) return;
_waveformPending[id] = true;
requestAnimationFrame(() => {
_waveformPending[id] = false;
_drawWaveformImmediate(id);
});
}
function _drawWaveformImmediate(id) {
const canvas = document.getElementById('waveform-' + id);
if (!canvas) return; // Null check
if (!canvas) return;
const ctx = canvas.getContext('2d');
const data = decks[id].waveformData;
if (!data) return;
@ -773,22 +831,14 @@ function formatTime(seconds) {
}
// 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) {
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].playing) return;
@ -800,6 +850,7 @@ function playDeck(id) {
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.add('playing');
document.body.classList.add('playing-' + id);
_notifyListenerDeckGlow();
if (audioCtx.state === 'suspended') {
console.log(`[Deck ${id}] Resuming suspended AudioContext`);
@ -828,20 +879,6 @@ function playDeck(id) {
function pauseDeck(id) {
vibrate(15);
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_pause', { deck: id });
decks[id].playing = false;
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
console.log(`[Deck ${id}] Pause command sent to server`);
return;
}
// Browser-side audio mode (original code)
if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
if (!audioCtx) {
console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`);
@ -863,6 +900,7 @@ function pauseDeck(id) {
const deckEl = document.getElementById('deck-' + id);
if (deckEl) deckEl.classList.remove('playing');
document.body.classList.remove('playing-' + id);
_notifyListenerDeckGlow();
}
@ -870,27 +908,6 @@ function seekTo(id, time) {
// Update local state and timestamp for seek protection
decks[id].lastSeekTime = Date.now();
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_seek', { deck: id, position: time });
// Update local state immediately for UI responsiveness
decks[id].lastAnchorPosition = time;
if (!decks[id].playing) {
decks[id].pausedAt = time;
}
// Update UI immediately (Optimistic UI)
const progress = (time / decks[id].duration) * 100;
const playhead = document.getElementById('playhead-' + id);
if (playhead) playhead.style.left = progress + '%';
const timer = document.getElementById('time-current-' + id);
if (timer) timer.textContent = formatTime(time);
return;
}
if (!decks[id].localBuffer) {
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
return;
@ -900,6 +917,8 @@ function seekTo(id, time) {
if (decks[id].playing) {
if (decks[id].localSource) {
try {
// Capture playback rate before stopping for the new source
decks[id]._lastPlaybackRate = decks[id].localSource.playbackRate.value;
decks[id].localSource.stop();
decks[id].localSource.onended = null;
} catch (e) { }
@ -915,17 +934,27 @@ function seekTo(id, time) {
}
src.connect(decks[id].filters.low);
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
// Read current speed from old source before it was stopped, fall back to DOM slider
let speed = 1.0;
if (decks[id]._lastPlaybackRate != null) {
speed = decks[id]._lastPlaybackRate;
} else {
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
if (speedSlider) speed = parseFloat(speedSlider.value);
}
src.playbackRate.value = speed;
decks[id].localSource = src;
decks[id].lastAnchorTime = audioCtx.currentTime;
decks[id].lastAnchorPosition = time;
// Add error handler for the source
// Wire onended so natural playback completion always triggers auto-play
src.onended = () => {
console.log(`[Deck ${id}] Playback ended naturally`);
// Guard: only act if we didn't stop it intentionally
if (decks[id].playing && !decks[id].loading) {
console.log(`[Deck ${id}] Playback ended naturally (onended)`);
handleTrackEnd(id);
}
};
src.start(0, time);
@ -953,14 +982,6 @@ function seekTo(id, time) {
}
function changeSpeed(id, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) });
return;
}
// Browser-side audio mode
if (!audioCtx || !decks[id].localSource) return;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
@ -971,28 +992,12 @@ function changeSpeed(id, val) {
}
function changeVolume(id, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_volume', { deck: id, volume: val / 100 });
return;
}
// Browser-side audio mode
if (decks[id].volumeGain) {
decks[id].volumeGain.gain.value = val / 100;
}
}
function changeEQ(id, band, val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) });
return;
}
// Browser-side audio mode
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
}
@ -1262,14 +1267,6 @@ function syncDecks(id) {
}
function updateCrossfader(val) {
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
if (!socket) initSocket();
socket.emit('audio_set_crossfader', { value: parseInt(val) });
return;
}
// Browser-side audio mode
const volA = (100 - val) / 100;
const volB = val / 100;
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
@ -1404,19 +1401,6 @@ async function loadFromServer(id, url, title) {
console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
// Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform
if (SERVER_SIDE_AUDIO) {
// Extract filename from URL and DECODE IT (for spaces etc)
const filename = decodeURIComponent(url.split('/').pop());
if (!socket) initSocket();
socket.emit('audio_load_track', { deck: id, filename: filename });
console.log(`[Deck ${id}] Load command sent to server: ${filename}`);
// We DON'T return here anymore. We continue below to load for the UI.
}
// Browser-side audio mode (original code)
const wasPlaying = decks[id].playing;
const wasBroadcasting = isBroadcasting;
@ -1517,6 +1501,7 @@ async function loadFromServer(id, url, title) {
console.error(`[Deck ${id}] Load error:`, error);
d.innerText = 'LOAD ERROR';
d.classList.remove('blink');
showToast(`Deck ${id}: Failed to load track — ${error.message}`, 'error');
setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000);
}
}
@ -1547,14 +1532,24 @@ async function handleFileUpload(event) {
progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>';
progressContainer.classList.add('active');
const existingFilenames = allSongs.map(s => s.file.split('/').pop().toLowerCase());
const uploadPromises = files.map(async (file) => {
const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
if (!allowedExts.includes(ext)) {
console.warn(`${file.name} is not a supported audio file`);
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();
formData.append('file', file);
@ -1572,44 +1567,48 @@ async function handleFileUpload(event) {
progressRow.appendChild(barWrap);
progressContainer.appendChild(progressRow);
try {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true);
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload', true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
barInner.style.width = percent + '%';
}
};
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
barInner.style.width = percent + '%';
}
};
xhr.onload = () => {
if (xhr.status === 200) {
xhr.onload = () => {
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText);
if (result.success) {
barInner.style.background = '#00ff88';
resolve();
} else {
barInner.style.background = '#ff4444';
reject(new Error(result.error));
nameSpan.title = result.error || 'Upload failed';
console.error(`[UPLOAD] ${file.name}: ${result.error}`);
}
} else {
} catch (e) {
barInner.style.background = '#ff4444';
reject(new Error(`HTTP ${xhr.status}`));
console.error(`[UPLOAD] Bad response for ${file.name}`);
}
};
xhr.onerror = () => {
} else {
barInner.style.background = '#ff4444';
reject(new Error('Network error'));
};
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.send(formData);
});
} catch (error) {
console.error(`[ERROR] Upload error: ${error}`);
}
xhr.onerror = () => {
barInner.style.background = '#ff4444';
console.error(`[UPLOAD] ${file.name}: Network error`);
resolve();
};
xhr.send(formData);
});
});
// 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}`]}`);
vibrate(10);
saveSettings();
}
function toggleAutoMix(val) { settings.autoMix = val; }
function toggleShuffle(val) { settings.shuffleMode = val; }
function toggleQuantize(val) { settings.quantize = val; }
function toggleAutoPlay(val) { settings.autoPlay = val; }
function toggleAutoMix(val) { settings.autoMix = val; saveSettings(); }
function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); }
function toggleQuantize(val) { settings.quantize = val; saveSettings(); }
function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); }
function updateManualGlow(id, val) {
settings[`glow${id}`] = val;
@ -1758,6 +1758,7 @@ function updateManualGlow(id, val) {
} else {
document.body.classList.remove(`playing-${id}`);
}
saveSettings();
}
function updateGlowIntensity(val) {
@ -1765,9 +1766,16 @@ function updateGlowIntensity(val) {
const opacity = settings.glowIntensity / 100;
const spread = (settings.glowIntensity / 100) * 80;
// Dynamically update CSS variables for the glow
document.documentElement.style.setProperty('--glow-opacity', opacity);
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
saveSettings();
}
function updateListenerGlow(val) {
settings.listenerGlowIntensity = parseInt(val);
if (!socket) initSocket();
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
saveSettings();
}
// Dismiss landscape prompt
@ -1788,25 +1796,47 @@ window.addEventListener('DOMContentLoaded', () => {
if (prompt) prompt.classList.add('dismissed');
}
// Initialise glow intensity
// Sync all restored settings to UI controls
const syncCheckbox = (elId, val) => { const el = document.getElementById(elId); if (el) el.checked = !!val; };
const syncRange = (elId, val) => { const el = document.getElementById(elId); if (el && val != null) el.value = val; };
syncCheckbox('repeat-A', settings.repeatA);
syncCheckbox('repeat-B', settings.repeatB);
syncCheckbox('auto-mix', settings.autoMix);
syncCheckbox('shuffle-mode', settings.shuffleMode);
syncCheckbox('quantize', settings.quantize);
syncCheckbox('auto-play', settings.autoPlay);
syncCheckbox('glow-A', settings.glowA);
syncCheckbox('glow-B', settings.glowB);
syncRange('glow-intensity', settings.glowIntensity);
syncRange('listener-glow-intensity', settings.listenerGlowIntensity);
// Initialise glow intensity CSS variables
updateGlowIntensity(settings.glowIntensity);
const glowAToggle = document.getElementById('glow-A');
if (glowAToggle) glowAToggle.checked = settings.glowA;
const glowBToggle = document.getElementById('glow-B');
if (glowBToggle) glowBToggle.checked = settings.glowB;
const intensitySlider = document.getElementById('glow-intensity');
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
// Apply initial glow state
updateManualGlow('A', settings.glowA);
updateManualGlow('B', settings.glowB);
// Set stream URL in the streaming panel
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');
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 ==========
@ -1997,24 +2027,7 @@ function startBroadcast() {
return;
}
// Server-side audio mode
if (SERVER_SIDE_AUDIO) {
isBroadcasting = true;
document.getElementById('broadcast-btn').classList.add('active');
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
document.getElementById('broadcast-status').textContent = 'LIVE';
document.getElementById('broadcast-status').classList.add('live');
if (!socket) initSocket();
const bitrateValue = document.getElementById('stream-quality').value + 'k';
socket.emit('start_broadcast', { bitrate: bitrateValue });
socket.emit('get_listener_count');
console.log('[OK] Server-side broadcast started');
return;
}
// Browser-side audio mode (original code)
// Browser-side audio mode
// Check if any audio is playing
const anyPlaying = decks.A.playing || decks.B.playing;
if (!anyPlaying) {
@ -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
if (mediaRecorder.state === 'inactive') {
mediaRecorder.start(1000);
mediaRecorder.start(250);
streamProcessor = mediaRecorder;
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
} else {
@ -2238,47 +2253,36 @@ function startBroadcast() {
function stopBroadcast() {
console.log('[BROADCAST] Stopping...');
if (SERVER_SIDE_AUDIO) {
isBroadcasting = false;
if (socket) {
socket.emit('stop_broadcast');
}
} else {
if (streamProcessor) {
streamProcessor.stop();
streamProcessor = null;
}
if (streamProcessor) {
streamProcessor.stop();
streamProcessor = null;
}
if (streamDestination) {
// Disconnect from stream destination and restore normal playback
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.A.crossfaderGain.disconnect();
decks.A.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck A audio:', e);
}
if (streamDestination) {
if (decks.A.crossfaderGain) {
try {
decks.A.crossfaderGain.disconnect(streamDestination);
decks.A.crossfaderGain.disconnect();
decks.A.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck A audio:', e);
}
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect(streamDestination);
// Ensure connection to speakers is maintained
decks.B.crossfaderGain.disconnect();
decks.B.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck B audio:', e);
}
}
streamDestination = null;
}
isBroadcasting = false;
if (decks.B.crossfaderGain) {
try {
decks.B.crossfaderGain.disconnect(streamDestination);
decks.B.crossfaderGain.disconnect();
decks.B.crossfaderGain.connect(audioCtx.destination);
} catch (e) {
console.warn('Error restoring Deck B audio:', e);
}
}
streamDestination = null;
}
isBroadcasting = false;
// Notify server (browser-side mode also needs to tell server to stop relaying)
if (socket) {
socket.emit('stop_broadcast');
}
if (socket) {
socket.emit('stop_broadcast');
}
// Update UI
@ -2386,91 +2390,155 @@ window.addEventListener('load', () => {
}
});
// Monitoring
// Auto-crossfade: smoothly transitions from the ending deck to the other deck.
let _autoMixTimer = null;
function startAutoMixFade(endingDeckId) {
const otherDeck = endingDeckId === 'A' ? 'B' : 'A';
// The other deck must have a track loaded and be playing (or about to play)
if (!decks[otherDeck].localBuffer) {
console.log(`[AutoMix] Other deck ${otherDeck} has no track loaded, skipping crossfade`);
return false;
}
// If the other deck isn't playing, start it
if (!decks[otherDeck].playing) {
playDeck(otherDeck);
}
// Cancel any existing auto-mix animation
if (_autoMixTimer) {
clearInterval(_autoMixTimer);
_autoMixTimer = null;
}
const slider = document.getElementById('crossfader');
if (!slider) return false;
const target = endingDeckId === 'A' ? 100 : 0; // Fade toward the OTHER deck
const duration = 5000; // 5 seconds
const steps = 50;
const interval = duration / steps;
const start = parseInt(slider.value);
const delta = (target - start) / steps;
let step = 0;
console.log(`[AutoMix] Crossfading from Deck ${endingDeckId} → Deck ${otherDeck} over ${duration / 1000}s`);
showToast(`Auto-crossfading to Deck ${otherDeck}`, 'info');
_autoMixTimer = setInterval(() => {
step++;
const val = Math.round(start + delta * step);
slider.value = val;
updateCrossfader(val);
if (step >= steps) {
clearInterval(_autoMixTimer);
_autoMixTimer = null;
console.log(`[AutoMix] Crossfade complete. Now on Deck ${otherDeck}`);
// Load next track on the ending deck so it's ready for the next crossfade
if (queues[endingDeckId] && queues[endingDeckId].length > 0) {
const next = queues[endingDeckId].shift();
renderQueue(endingDeckId);
loadFromServer(endingDeckId, next.file, next.title);
}
}
}, interval);
return true;
}
// Shared handler called both by onended and the monitor poll.
// Handles repeat, auto-crossfade, auto-play-from-queue, or stop.
function handleTrackEnd(id) {
// Already being handled or loop is active — skip
if (decks[id].loading || decks[id].loopActive) return;
if (isBroadcasting) {
console.log(`Track ending during broadcast on Deck ${id}`);
if (settings[`repeat${id}`]) {
console.log(`Repeating track on Deck ${id} (broadcast)`);
seekTo(id, 0);
return;
}
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
decks[id].loading = true;
const next = queues[id].shift();
renderQueue(id);
loadFromServer(id, next.file, next.title)
.then(() => { decks[id].loading = false; playDeck(id); })
.catch(() => { decks[id].loading = false; });
}
return;
}
if (settings[`repeat${id}`]) {
console.log(`Repeating track on Deck ${id}`);
seekTo(id, 0);
} else if (settings.autoMix) {
// Auto-crossfade takes priority over simple auto-play
decks[id].loading = true;
if (!startAutoMixFade(id)) {
// Crossfade not possible (other deck empty) — fall through to normal auto-play
decks[id].loading = false;
handleAutoPlay(id);
} else {
decks[id].loading = false;
}
} else if (settings.autoPlay) {
handleAutoPlay(id);
} else {
pauseDeck(id);
decks[id].pausedAt = 0;
}
}
function handleAutoPlay(id) {
decks[id].loading = true;
pauseDeck(id);
if (queues[id] && queues[id].length > 0) {
console.log(`Auto-play: loading next from Queue ${id}`);
const next = queues[id].shift();
renderQueue(id);
loadFromServer(id, next.file, next.title)
.then(() => { decks[id].loading = false; playDeck(id); })
.catch(() => { decks[id].loading = false; });
} else {
console.log(`Auto-play: queue empty on Deck ${id}, stopping`);
decks[id].loading = false;
decks[id].pausedAt = 0;
}
}
// Monitoring — safety net for cases where onended doesn't fire
// (e.g. AudioContext suspended, very short buffer, scrub to near-end).
function monitorTrackEnd() {
setInterval(() => {
if (!audioCtx) return; // Safety check
if (!audioCtx) return;
// In server-side mode, poll for status from server
if (SERVER_SIDE_AUDIO && socket && socket.connected) {
socket.emit('get_mixer_status');
}
['A', 'B'].forEach(id => {
if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) {
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
const current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
const remaining = decks[id].duration - current;
if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
if (decks[id].loopActive) return;
// If end reached (with 0.5s buffer for safety)
// Skip if a loop is active — the Web Audio API handles looping natively
// and lastAnchorPosition is not updated on each native loop repeat, so
// the monotonically-growing `current` would incorrectly trigger end-of-track.
if (remaining <= 0.5 && !decks[id].loopActive) {
// During broadcast, still handle auto-play/queue to avoid dead air
if (isBroadcasting) {
console.log(`Track ending during broadcast on Deck ${id}`);
if (settings[`repeat${id}`]) {
console.log(`LOOP Repeating track on Deck ${id}`);
seekTo(id, 0);
return;
}
// Auto-play from queue during broadcast to maintain stream
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
decks[id].loading = true;
console.log(`Auto-play (broadcast): Loading next from Queue ${id}...`);
const next = queues[id].shift();
renderQueue(id);
loadFromServer(id, next.file, next.title).then(() => {
decks[id].loading = false;
playDeck(id);
}).catch(() => {
decks[id].loading = false;
});
return;
}
// No repeat, no queue - just let the stream continue silently
return;
}
const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
const current = decks[id].lastAnchorPosition + (elapsed * rate);
const remaining = decks[id].duration - current;
if (settings[`repeat${id}`]) {
// Full song repeat
console.log(`LOOP Repeating track on Deck ${id}`);
seekTo(id, 0);
} else if (settings.autoPlay) {
// Prevent race condition
decks[id].loading = true;
pauseDeck(id);
// Check queue for auto-play
if (queues[id] && queues[id].length > 0) {
console.log(`Auto-play: Loading next from Queue ${id}...`);
const next = queues[id].shift();
renderQueue(id); // Update queue UI
loadFromServer(id, next.file, next.title).then(() => {
decks[id].loading = false;
playDeck(id);
}).catch(() => {
decks[id].loading = false;
});
} else {
// No queue - just stop
console.log(`Track ended, queue empty - stopping playback`);
decks[id].loading = false;
pauseDeck(id);
decks[id].pausedAt = 0;
}
} else {
// Just stop if no auto-play
pauseDeck(id);
decks[id].pausedAt = 0;
}
}
// Threshold scales with playback rate so we don't miss fast playback.
// Use 1.5× the poll interval (0.75s) as a safe window.
const threshold = Math.max(0.75, 0.75 * rate);
if (remaining <= threshold) {
console.log(`[Monitor] Deck ${id} near end (${remaining.toFixed(2)}s left) — triggering handleTrackEnd`);
handleTrackEnd(id);
}
});
}, 500); // Check every 0.5s
}, 500);
}
monitorTrackEnd();
@ -2566,11 +2634,6 @@ function addToQueue(deckId, file, title) {
queues[deckId].push({ file, title });
renderQueue(deckId);
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
}
// Remove track from queue
@ -2578,11 +2641,6 @@ function removeFromQueue(deckId, index) {
const removed = queues[deckId].splice(index, 1)[0];
renderQueue(deckId);
console.log(`Removed "${removed.title}" from Queue ${deckId}`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
}
// Clear entire queue
@ -2591,11 +2649,6 @@ function clearQueue(deckId) {
queues[deckId] = [];
renderQueue(deckId);
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
// Sync with server if in server-side mode
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
}
// Load next track from queue
@ -2610,11 +2663,6 @@ function loadNextFromQueue(deckId) {
loadFromServer(deckId, next.file, next.title);
renderQueue(deckId);
// Sync with server if in server-side mode (after shift)
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
return true;
}
@ -2686,11 +2734,14 @@ function renderQueue(deckId) {
removeFromQueue(deckId, index);
};
// Drag and drop reordering
// Drag and drop reordering / moving
item.ondragstart = (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('queueIndex', index);
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');
};
@ -2705,17 +2756,30 @@ function renderQueue(deckId) {
item.ondrop = (e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('queueIndex'));
const fromIndex = e.dataTransfer.getData('queueIndex');
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);
if (SERVER_SIDE_AUDIO && socket) {
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
}
if (fromDeck !== deckId) renderQueue(fromDeck);
console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`);
} 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}`);
}
};

173
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_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
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_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
@ -66,7 +67,7 @@ _mp3_lock = threading.Lock()
_transcoder_bytes_out = 0
_transcoder_last_error = None
_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):
@ -234,9 +235,9 @@ def _stop_transcoder():
def _feed_transcoder(data: bytes):
global _last_audio_chunk_ts
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'):
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
_start_transcoder_if_needed(is_mp3_input=False)
else:
return
@ -246,6 +247,28 @@ def _feed_transcoder(data: bytes):
except queue.Full:
# Drop chunk if overflow to prevent memory bloat
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
def _load_settings():
try:
@ -386,6 +409,9 @@ def setup_shared_routes(app, index_file='index.html'):
filepath = os.path.join(MUSIC_FOLDER, filename)
if os.path.exists(filepath):
return jsonify({"success": False, "error": "File already exists in library"}), 409
try:
file.save(filepath)
print(f"UPLOADED: {filename}")
@ -431,18 +457,21 @@ def setup_shared_routes(app, index_file='index.html'):
'Cache-Control': 'no-cache, no-store',
})
# If the transcoder isn't ready yet, wait briefly (it may still be starting)
waited = 0.0
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
eventlet.sleep(0.5)
waited += 0.5
# 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
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
eventlet.sleep(0.5)
waited += 0.5
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return Response(b'', status=503, content_type='audio/mpeg', headers={
'Retry-After': '3',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache, no-store',
})
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
return Response(b'', status=503, content_type='audio/mpeg', headers={
'Retry-After': '3',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-cache, no-store',
})
preroll_count = len(_mp3_preroll)
print(f"LISTENER: New listener joined stream (Pre-roll: {preroll_count} chunks)")
@ -485,6 +514,7 @@ def setup_shared_routes(app, index_file='index.html'):
'X-Content-Type-Options': 'nosniff',
'Access-Control-Allow-Origin': '*',
'Icy-Name': 'TechDJ Live',
'X-Accel-Buffering': 'no', # Tell nginx/Cloudflare not to buffer this stream
})
@app.route('/stream_debug')
@ -588,7 +618,6 @@ def dj_login():
<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus />
<button type=\"submit\">Unlock DJ Panel</button>
{f"<div class='err'>{error}</div>" if error else ""}
<div class=\"hint\">Set/disable this in config.json (dj_panel_password).</div>
</form>
</div>
</div>
@ -605,6 +634,12 @@ def dj_logout():
302,
{'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_app,
cors_allowed_origins=CONFIG_CORS,
@ -680,17 +715,21 @@ def dj_start(data=None):
broadcast_state['is_mp3_input'] = is_mp3_input
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:
_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)
listener_socketio.emit('broadcast_started', namespace='/')
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
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
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
@ -699,6 +738,23 @@ def dj_start(data=None):
def dj_get_listener_count():
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')
def dj_stop():
broadcast_state['active'] = False
@ -712,15 +768,14 @@ def dj_stop():
@dj_socketio.on('audio_chunk')
def dj_audio(data):
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
if broadcast_state['active']:
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
# 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)):
if broadcast_state['active'] and isinstance(data, (bytes, bytearray)):
if broadcast_state.get('is_mp3_input', False):
# 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:
_start_transcoder_if_needed(is_mp3_input=False)
_feed_transcoder(bytes(data))
# === LISTENER SERVER ===
@ -748,34 +803,49 @@ listener_socketio = SocketIO(
cors_allowed_origins=CONFIG_CORS,
async_mode='eventlet',
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
ping_timeout=60,
ping_interval=25,
# Lower timeouts: stale connections detected in ~25s instead of ~85s
# 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,
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')
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')
def listener_disconnect():
listener_sids.discard(request.sid)
count = len(listener_sids)
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='/')
count = _broadcast_listener_count()
print(f"REMOVED: Listener left {request.sid}. Total: {count}")
@listener_socketio.on('join_listener')
def listener_join():
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='/')
# SID already added in listener_connect(); just send stream status back
emit('stream_status', {'active': broadcast_state['active']})
@listener_socketio.on('get_listener_count')
@ -784,23 +854,24 @@ def listener_get_count():
# DJ Panel Routes (No engine commands needed in local mode)
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:
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:
# Only log if it's actually dead and supposed to be alive
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)
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:
count = len(listener_sids)
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
eventlet.sleep(5)
_broadcast_listener_count()
if __name__ == '__main__':

View File

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

View File

@ -24,6 +24,12 @@
html {
scroll-behavior: smooth;
overflow: hidden;
scrollbar-width: none; /* Firefox */
}
html::-webkit-scrollbar {
display: none; /* Chrome / Edge / Safari */
}
body {
@ -218,7 +224,7 @@ header h1 {
grid-template-rows: 1fr 80px;
gap: 10px;
padding: 10px;
height: calc(100vh - 60px);
height: 100vh;
/* Adjust based on header height */
min-height: 0;
overflow: hidden;
@ -1706,6 +1712,9 @@ input[type=range] {
/* Less intense on mobile */
}
header {
display: none;
}
.mobile-top-bar {
display: flex;
@ -1916,8 +1925,8 @@ input[type=range] {
/* Streaming Button */
.streaming-btn {
position: fixed;
bottom: 25px;
right: 175px;
bottom: 175px;
right: 25px;
width: 60px;
height: 60px;
border-radius: 50%;
@ -1947,8 +1956,8 @@ input[type=range] {
.upload-btn {
position: fixed;
bottom: 25px;
right: 100px;
bottom: 100px;
right: 25px;
width: 60px;
height: 60px;
border-radius: 50%;
@ -1980,7 +1989,7 @@ input[type=range] {
.streaming-panel {
position: fixed;
top: 0;
right: -400px;
right: -460px;
height: 100vh;
width: 380px;
background: rgba(10, 10, 20, 0.98);
@ -2587,8 +2596,8 @@ body.listening-active .landscape-prompt {
/* Base Settings Button Fix */
.keyboard-btn {
position: fixed;
bottom: 25px;
right: 250px;
bottom: 250px;
right: 25px;
width: 60px;
height: 60px;
border-radius: 50%;
@ -2637,7 +2646,7 @@ body.listening-active .landscape-prompt {
.settings-panel {
position: fixed;
top: 0;
right: -350px;
right: -400px;
height: 100vh;
width: 320px;
background: rgba(10, 10, 20, 0.98);
@ -4773,3 +4782,57 @@ body.listening-active .landscape-prompt {
background: rgba(188, 19, 254, 0.2);
box-shadow: 0 0 10px rgba(188, 19, 254, 0.4);
}
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 30px;
left: 30px;
z-index: 20000;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 12px 20px;
border-radius: 6px;
font-family: 'Rajdhani', sans-serif;
font-size: 0.9rem;
font-weight: 500;
color: #fff;
pointer-events: auto;
animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 3.7s forwards;
max-width: 360px;
word-break: break-word;
border-left: 4px solid transparent;
}
.toast-error {
background: rgba(255, 40, 40, 0.9);
border-left-color: #ff0000;
box-shadow: 0 4px 20px rgba(255, 0, 0, 0.3);
}
.toast-success {
background: rgba(0, 200, 80, 0.9);
border-left-color: #00ff55;
box-shadow: 0 4px 20px rgba(0, 255, 85, 0.3);
}
.toast-info {
background: rgba(0, 120, 255, 0.9);
border-left-color: var(--primary-cyan);
box-shadow: 0 4px 20px rgba(0, 243, 255, 0.3);
}
@keyframes toast-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes toast-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}

View File

@ -852,21 +852,26 @@ class StreamingWorker(QThread):
"ffmpeg",
"-hide_banner",
"-loglevel", "error",
# Disable input buffering so frames reach the pipe immediately
"-fflags", "nobuffer",
"-f", "pulse",
"-i", source,
"-ac", "2",
"-ar", "44100",
"-f", "mp3",
"-b:a", "128k",
"-af", "aresample=async=1",
# Flush every packet — critical for low-latency pipe streaming
"-flush_packets", "1",
"-f", "mp3",
"pipe:1"
]
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:
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:
break
sio = self.sio # Local ref to avoid race with stop_streaming()
@ -2105,16 +2110,29 @@ class DJApp(QMainWindow):
return
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
dest = self.lib_path / os.path.basename(file_path)
dest = self.lib_path / filename
try:
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()
except Exception as e:
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
else:
# 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:
self.status_label.setText("Uploading to server...")
base_url = self.get_server_base_url()