Compare commits
10 Commits
dfccec2b48
...
d2e6e2a7d7
| Author | SHA1 | Date |
|---|---|---|
|
|
d2e6e2a7d7 | |
|
|
eb3e66ba61 | |
|
|
44b36bf08d | |
|
|
9513c11747 | |
|
|
2e64870daa | |
|
|
6027f2e973 | |
|
|
514f9899a3 | |
|
|
af109381c1 | |
|
|
43a3e692fc | |
|
|
1fa6887efd |
|
|
@ -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
|
|
||||||
|
|
@ -12,5 +12,8 @@
|
||||||
"stream_bitrate": "192k",
|
"stream_bitrate": "192k",
|
||||||
"max_upload_mb": 500,
|
"max_upload_mb": 500,
|
||||||
"cors_origins": "*",
|
"cors_origins": "*",
|
||||||
"debug": false
|
"debug": false,
|
||||||
|
|
||||||
|
"_comment_listener_url": "Public URL of the listener page. Shown in DJ panel as the shareable stream link. Leave empty to auto-detect.",
|
||||||
|
"listener_url": ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
index.html
14
index.html
|
|
@ -71,7 +71,6 @@
|
||||||
|
|
||||||
<div class="lib-header">
|
<div class="lib-header">
|
||||||
<input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()">
|
<input type="text" id="lib-search" placeholder="FILTER LIBRARY..." onkeyup="filterLibrary()">
|
||||||
<button class="folder-btn" onclick="openFolderPicker()" title="Choose Folder">OPEN</button>
|
|
||||||
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button>
|
<button class="refresh-btn" onclick="refreshLibrary()" title="Refresh Library">REFRESH</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -426,8 +425,8 @@
|
||||||
<!-- Settings Panel -->
|
<!-- Settings Panel -->
|
||||||
<div class="settings-panel" id="settings-panel">
|
<div class="settings-panel" id="settings-panel">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<span>SETTINGS</span>
|
<span style="font-family:'Orbitron',sans-serif; font-size:1rem; letter-spacing:3px; color:var(--primary-cyan);">SETTINGS</span>
|
||||||
<button class="close-settings" onclick="toggleSettings()">X</button>
|
<button onclick="toggleSettings()" style="background:transparent; border:none; color:#aaa; font-size:1.4rem; cursor:pointer; line-height:1; padding:4px 8px;">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
<div class="setting-item"><label><input type="checkbox" id="repeat-A"
|
<div class="setting-item"><label><input type="checkbox" id="repeat-A"
|
||||||
|
|
@ -447,10 +446,15 @@
|
||||||
<div class="setting-item"><label><input type="checkbox" id="glow-B"
|
<div class="setting-item"><label><input type="checkbox" id="glow-B"
|
||||||
onchange="updateManualGlow('B', this.checked)">Glow Deck B (Magenta)</label></div>
|
onchange="updateManualGlow('B', this.checked)">Glow Deck B (Magenta)</label></div>
|
||||||
<div class="setting-item" style="flex-direction: column; align-items: flex-start;">
|
<div class="setting-item" style="flex-direction: column; align-items: flex-start;">
|
||||||
<label>Glow Intensity</label>
|
<label>Glow Intensity (DJ Panel)</label>
|
||||||
<input type="range" id="glow-intensity" min="1" max="100" value="30" style="width: 100%;"
|
<input type="range" id="glow-intensity" min="1" max="100" value="30" style="width: 100%;"
|
||||||
oninput="updateGlowIntensity(this.value)">
|
oninput="updateGlowIntensity(this.value)">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item" style="flex-direction: column; align-items: flex-start;">
|
||||||
|
<label>Listener Page Glow</label>
|
||||||
|
<input type="range" id="listener-glow-intensity" min="0" max="100" value="30" style="width: 100%;"
|
||||||
|
oninput="updateListenerGlow(this.value)">
|
||||||
|
</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<button class="btn-primary" onclick="openKeyboardSettings()"
|
<button class="btn-primary" onclick="openKeyboardSettings()"
|
||||||
style="width: 100%; padding: 12px; margin-top: 10px;">
|
style="width: 100%; padding: 12px; margin-top: 10px;">
|
||||||
|
|
@ -480,6 +484,8 @@
|
||||||
onchange="handleFileUpload(event)">
|
onchange="handleFileUpload(event)">
|
||||||
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
|
<button class="settings-btn pc-only" onclick="toggleSettings()">SET</button>
|
||||||
|
|
||||||
|
<div class="toast-container" id="toast-container"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
35
listener.css
35
listener.css
|
|
@ -45,34 +45,33 @@ body::before {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 999;
|
||||||
opacity: var(--glow-opacity, 0.3);
|
opacity: var(--glow-opacity, 0.3);
|
||||||
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Listener atmospheric glow */
|
/* Listener atmospheric glow — static, intensity driven by --glow-opacity/--glow-spread */
|
||||||
body.listener-glow::before {
|
body.listener-glow::before {
|
||||||
animation: pulse-listener 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse-listener {
|
|
||||||
0%, 100% {
|
|
||||||
filter: hue-rotate(0deg) brightness(1.2);
|
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)),
|
0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1.5)),
|
||||||
0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)),
|
0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1.5)),
|
||||||
inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)),
|
inset 0 0 var(--glow-spread) rgba(0, 80, 255, calc(var(--glow-opacity) * 1)),
|
||||||
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1));
|
inset 0 0 calc(var(--glow-spread) * 1.5) rgba(188, 19, 254, calc(var(--glow-opacity) * 1));
|
||||||
}
|
}
|
||||||
50% {
|
|
||||||
filter: hue-rotate(15deg) brightness(1.8);
|
/* Deck-driven glow — mirrors the DJ panel colours */
|
||||||
|
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:
|
||||||
0 0 calc(var(--glow-spread) * 1.5) rgba(0, 120, 255, calc(var(--glow-opacity) * 2.2)),
|
inset 0 0 var(--glow-spread) var(--primary-cyan),
|
||||||
0 0 calc(var(--glow-spread) * 2) rgba(220, 50, 255, calc(var(--glow-opacity) * 2.2)),
|
inset 0 0 calc(var(--glow-spread) * 1.5) var(--secondary-magenta);
|
||||||
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 ========== */
|
||||||
|
|
@ -80,7 +79,7 @@ body.listener-glow::before {
|
||||||
.listener-mode {
|
.listener-mode {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(135deg, #0a0a12 0%, #1a0a1a 100%);
|
background: transparent;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@
|
||||||
<!-- Listener UI -->
|
<!-- Listener UI -->
|
||||||
<div class="listener-mode" id="listener-mode">
|
<div class="listener-mode" id="listener-mode">
|
||||||
<div class="listener-header">
|
<div class="listener-header">
|
||||||
<h1>TECHDJ LIVE</h1>
|
<h1>TECHY.MUSIC</h1>
|
||||||
<div class="live-indicator">
|
<div class="live-indicator">
|
||||||
<span class="pulse-dot"></span>
|
<span class="pulse-dot"></span>
|
||||||
<span>LIVE</span>
|
<span>LIVE</span>
|
||||||
<span class="listener-count-badge"><span id="listener-count">0</span> listening</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="listener-content">
|
<div class="listener-content">
|
||||||
|
|
|
||||||
24
listener.js
24
listener.js
|
|
@ -56,11 +56,6 @@ function updateNowPlaying(text) {
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateListenerCount(count) {
|
|
||||||
const el = document.getElementById('listener-count');
|
|
||||||
if (el) el.textContent = count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Reconnection with Exponential Backoff ---
|
// --- Reconnection with Exponential Backoff ---
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
|
|
@ -258,7 +253,6 @@ function initSocket() {
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
console.log('[SOCKET] Connected');
|
console.log('[SOCKET] Connected');
|
||||||
socket.emit('join_listener');
|
socket.emit('join_listener');
|
||||||
socket.emit('get_listener_count');
|
|
||||||
if (window.listenerAudioEnabled) {
|
if (window.listenerAudioEnabled) {
|
||||||
updateStatus('Connected', true);
|
updateStatus('Connected', true);
|
||||||
}
|
}
|
||||||
|
|
@ -275,10 +269,6 @@ function initSocket() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('listener_count', (data) => {
|
|
||||||
updateListenerCount(data.count);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Broadcast lifecycle events ---
|
// --- Broadcast lifecycle events ---
|
||||||
|
|
||||||
socket.on('stream_status', (data) => {
|
socket.on('stream_status', (data) => {
|
||||||
|
|
@ -304,9 +294,10 @@ function initSocket() {
|
||||||
updateNowPlaying('Stream is live!');
|
updateNowPlaying('Stream is live!');
|
||||||
|
|
||||||
if (window.listenerAudioEnabled) {
|
if (window.listenerAudioEnabled) {
|
||||||
// Small delay to let the transcoder produce initial data
|
// Brief delay: 300ms is enough for ffmpeg to produce its first output
|
||||||
|
// (was 800ms — reduced to cut perceived startup lag)
|
||||||
resetReconnectBackoff();
|
resetReconnectBackoff();
|
||||||
setTimeout(() => connectStream(), 800);
|
setTimeout(() => connectStream(), 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -317,6 +308,15 @@ function initSocket() {
|
||||||
handleBroadcastOffline();
|
handleBroadcastOffline();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('listener_glow', (data) => {
|
||||||
|
updateGlowIntensity(data.intensity ?? 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('deck_glow', (data) => {
|
||||||
|
document.body.classList.toggle('playing-A', !!data.A);
|
||||||
|
document.body.classList.toggle('playing-B', !!data.B);
|
||||||
|
});
|
||||||
|
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
548
script.js
548
script.js
|
|
@ -2,8 +2,6 @@
|
||||||
// TechDJ Pro - Core DJ Logic
|
// TechDJ Pro - Core DJ Logic
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
// Server-side audio mode (true = server processes audio, false = browser processes)
|
|
||||||
const SERVER_SIDE_AUDIO = false;
|
|
||||||
|
|
||||||
let audioCtx;
|
let audioCtx;
|
||||||
const decks = {
|
const decks = {
|
||||||
|
|
@ -74,6 +72,42 @@ const queues = {
|
||||||
B: []
|
B: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Toast Notification System
|
||||||
|
function showToast(message, type) {
|
||||||
|
type = type || 'info';
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
if (!container) return;
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast toast-' + type;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
setTimeout(function() {
|
||||||
|
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings Persistence
|
||||||
|
function saveSettings() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('techdj_settings', JSON.stringify(settings));
|
||||||
|
} catch (e) { /* quota exceeded or private browsing */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSettings() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('techdj_settings');
|
||||||
|
if (saved) {
|
||||||
|
var parsed = JSON.parse(saved);
|
||||||
|
Object.keys(parsed).forEach(function(key) {
|
||||||
|
if (settings.hasOwnProperty(key)) settings[key] = parsed[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { /* corrupt data, ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved settings on load
|
||||||
|
loadSettings();
|
||||||
|
|
||||||
// System Initialization
|
// System Initialization
|
||||||
function initSystem() {
|
function initSystem() {
|
||||||
if (audioCtx) return;
|
if (audioCtx) return;
|
||||||
|
|
@ -301,7 +335,19 @@ function initDropZones() {
|
||||||
queueEl.classList.remove('drag-over');
|
queueEl.classList.remove('drag-over');
|
||||||
const file = e.dataTransfer.getData('trackFile');
|
const file = e.dataTransfer.getData('trackFile');
|
||||||
const title = e.dataTransfer.getData('trackTitle');
|
const title = e.dataTransfer.getData('trackTitle');
|
||||||
if (file && title) {
|
const fromDeck = e.dataTransfer.getData('queueDeck');
|
||||||
|
const fromIndex = e.dataTransfer.getData('queueIndex');
|
||||||
|
|
||||||
|
if (fromDeck && fromDeck !== id && fromIndex !== "") {
|
||||||
|
// Move from another queue
|
||||||
|
const idx = parseInt(fromIndex);
|
||||||
|
const [movedItem] = queues[fromDeck].splice(idx, 1);
|
||||||
|
queues[id].push(movedItem);
|
||||||
|
renderQueue(fromDeck);
|
||||||
|
renderQueue(id);
|
||||||
|
console.log(`Moved track from Queue ${fromDeck} to end of Queue ${id}: ${movedItem.title}`);
|
||||||
|
} else if (file && title) {
|
||||||
|
// Add from library (or re-append from same queue - which is essentially a no-op move to end)
|
||||||
console.log(`Dropped track into Queue ${id}: ${title}`);
|
console.log(`Dropped track into Queue ${id}: ${title}`);
|
||||||
addToQueue(id, file, title);
|
addToQueue(id, file, title);
|
||||||
}
|
}
|
||||||
|
|
@ -594,9 +640,21 @@ function generateWaveformData(buffer) {
|
||||||
return filteredData;
|
return filteredData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce guard: prevents redundant redraws within the same frame
|
||||||
|
const _waveformPending = { A: false, B: false };
|
||||||
|
|
||||||
function drawWaveform(id) {
|
function drawWaveform(id) {
|
||||||
|
if (_waveformPending[id]) return;
|
||||||
|
_waveformPending[id] = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
_waveformPending[id] = false;
|
||||||
|
_drawWaveformImmediate(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawWaveformImmediate(id) {
|
||||||
const canvas = document.getElementById('waveform-' + id);
|
const canvas = document.getElementById('waveform-' + id);
|
||||||
if (!canvas) return; // Null check
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const data = decks[id].waveformData;
|
const data = decks[id].waveformData;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
@ -773,22 +831,14 @@ function formatTime(seconds) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playback Logic
|
// Playback Logic
|
||||||
|
function _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;
|
||||||
|
|
||||||
|
|
@ -800,6 +850,7 @@ function playDeck(id) {
|
||||||
const deckEl = document.getElementById('deck-' + id);
|
const deckEl = document.getElementById('deck-' + id);
|
||||||
if (deckEl) deckEl.classList.add('playing');
|
if (deckEl) deckEl.classList.add('playing');
|
||||||
document.body.classList.add('playing-' + id);
|
document.body.classList.add('playing-' + id);
|
||||||
|
_notifyListenerDeckGlow();
|
||||||
|
|
||||||
if (audioCtx.state === 'suspended') {
|
if (audioCtx.state === 'suspended') {
|
||||||
console.log(`[Deck ${id}] Resuming suspended AudioContext`);
|
console.log(`[Deck ${id}] Resuming suspended AudioContext`);
|
||||||
|
|
@ -828,20 +879,6 @@ function playDeck(id) {
|
||||||
|
|
||||||
function pauseDeck(id) {
|
function pauseDeck(id) {
|
||||||
vibrate(15);
|
vibrate(15);
|
||||||
// Server-side audio mode
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_pause', { deck: id });
|
|
||||||
decks[id].playing = false;
|
|
||||||
const deckEl = document.getElementById('deck-' + id);
|
|
||||||
if (deckEl) deckEl.classList.remove('playing');
|
|
||||||
document.body.classList.remove('playing-' + id);
|
|
||||||
|
|
||||||
console.log(`[Deck ${id}] Pause command sent to server`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode (original code)
|
|
||||||
if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
|
if (decks[id].type === 'local' && decks[id].localSource && decks[id].playing) {
|
||||||
if (!audioCtx) {
|
if (!audioCtx) {
|
||||||
console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`);
|
console.warn(`[Deck ${id}] Cannot calculate pause position - audioCtx not initialised`);
|
||||||
|
|
@ -863,6 +900,7 @@ function pauseDeck(id) {
|
||||||
const deckEl = document.getElementById('deck-' + id);
|
const deckEl = document.getElementById('deck-' + id);
|
||||||
if (deckEl) deckEl.classList.remove('playing');
|
if (deckEl) deckEl.classList.remove('playing');
|
||||||
document.body.classList.remove('playing-' + id);
|
document.body.classList.remove('playing-' + id);
|
||||||
|
_notifyListenerDeckGlow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -870,27 +908,6 @@ function seekTo(id, time) {
|
||||||
// Update local state and timestamp for seek protection
|
// Update local state and timestamp for seek protection
|
||||||
decks[id].lastSeekTime = Date.now();
|
decks[id].lastSeekTime = Date.now();
|
||||||
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_seek', { deck: id, position: time });
|
|
||||||
|
|
||||||
// Update local state immediately for UI responsiveness
|
|
||||||
decks[id].lastAnchorPosition = time;
|
|
||||||
if (!decks[id].playing) {
|
|
||||||
decks[id].pausedAt = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI immediately (Optimistic UI)
|
|
||||||
const progress = (time / decks[id].duration) * 100;
|
|
||||||
const playhead = document.getElementById('playhead-' + id);
|
|
||||||
if (playhead) playhead.style.left = progress + '%';
|
|
||||||
|
|
||||||
const timer = document.getElementById('time-current-' + id);
|
|
||||||
if (timer) timer.textContent = formatTime(time);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!decks[id].localBuffer) {
|
if (!decks[id].localBuffer) {
|
||||||
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
|
console.warn(`[Deck ${id}] Cannot seek - no buffer loaded`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -900,6 +917,8 @@ function seekTo(id, time) {
|
||||||
if (decks[id].playing) {
|
if (decks[id].playing) {
|
||||||
if (decks[id].localSource) {
|
if (decks[id].localSource) {
|
||||||
try {
|
try {
|
||||||
|
// Capture playback rate before stopping for the new source
|
||||||
|
decks[id]._lastPlaybackRate = decks[id].localSource.playbackRate.value;
|
||||||
decks[id].localSource.stop();
|
decks[id].localSource.stop();
|
||||||
decks[id].localSource.onended = null;
|
decks[id].localSource.onended = null;
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
@ -915,17 +934,27 @@ function seekTo(id, time) {
|
||||||
}
|
}
|
||||||
|
|
||||||
src.connect(decks[id].filters.low);
|
src.connect(decks[id].filters.low);
|
||||||
|
// Read current speed from old source before it was stopped, fall back to DOM slider
|
||||||
|
let speed = 1.0;
|
||||||
|
if (decks[id]._lastPlaybackRate != null) {
|
||||||
|
speed = decks[id]._lastPlaybackRate;
|
||||||
|
} else {
|
||||||
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
||||||
const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
|
if (speedSlider) speed = parseFloat(speedSlider.value);
|
||||||
|
}
|
||||||
src.playbackRate.value = speed;
|
src.playbackRate.value = speed;
|
||||||
|
|
||||||
decks[id].localSource = src;
|
decks[id].localSource = src;
|
||||||
decks[id].lastAnchorTime = audioCtx.currentTime;
|
decks[id].lastAnchorTime = audioCtx.currentTime;
|
||||||
decks[id].lastAnchorPosition = time;
|
decks[id].lastAnchorPosition = time;
|
||||||
|
|
||||||
// Add error handler for the source
|
// Wire onended so natural playback completion always triggers auto-play
|
||||||
src.onended = () => {
|
src.onended = () => {
|
||||||
console.log(`[Deck ${id}] Playback ended naturally`);
|
// Guard: only act if we didn't stop it intentionally
|
||||||
|
if (decks[id].playing && !decks[id].loading) {
|
||||||
|
console.log(`[Deck ${id}] Playback ended naturally (onended)`);
|
||||||
|
handleTrackEnd(id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
src.start(0, time);
|
src.start(0, time);
|
||||||
|
|
@ -953,14 +982,6 @@ function seekTo(id, time) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSpeed(id, val) {
|
function changeSpeed(id, val) {
|
||||||
// Server-side audio mode
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_set_pitch', { deck: id, pitch: parseFloat(val) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode
|
|
||||||
if (!audioCtx || !decks[id].localSource) return;
|
if (!audioCtx || !decks[id].localSource) return;
|
||||||
|
|
||||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
||||||
|
|
@ -971,28 +992,12 @@ function changeSpeed(id, val) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeVolume(id, val) {
|
function changeVolume(id, val) {
|
||||||
// Server-side audio mode
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_set_volume', { deck: id, volume: val / 100 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode
|
|
||||||
if (decks[id].volumeGain) {
|
if (decks[id].volumeGain) {
|
||||||
decks[id].volumeGain.gain.value = val / 100;
|
decks[id].volumeGain.gain.value = val / 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeEQ(id, band, val) {
|
function changeEQ(id, band, val) {
|
||||||
// Server-side audio mode
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_set_eq', { deck: id, band: band, value: parseFloat(val) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode
|
|
||||||
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
|
if (decks[id].filters[band]) decks[id].filters[band].gain.value = parseFloat(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1262,14 +1267,6 @@ function syncDecks(id) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCrossfader(val) {
|
function updateCrossfader(val) {
|
||||||
// Server-side audio mode
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_set_crossfader', { value: parseInt(val) });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode
|
|
||||||
const volA = (100 - val) / 100;
|
const volA = (100 - val) / 100;
|
||||||
const volB = val / 100;
|
const volB = val / 100;
|
||||||
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
|
if (decks.A.crossfaderGain) decks.A.crossfaderGain.gain.value = volA;
|
||||||
|
|
@ -1404,19 +1401,6 @@ async function loadFromServer(id, url, title) {
|
||||||
|
|
||||||
console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
|
console.log(`[Deck ${id}] Loading: ${title} from ${url}`);
|
||||||
|
|
||||||
// Server-side audio mode: Send command immediately but CONTINUE for local UI/waveform
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
// Extract filename from URL and DECODE IT (for spaces etc)
|
|
||||||
const filename = decodeURIComponent(url.split('/').pop());
|
|
||||||
|
|
||||||
if (!socket) initSocket();
|
|
||||||
socket.emit('audio_load_track', { deck: id, filename: filename });
|
|
||||||
console.log(`[Deck ${id}] Load command sent to server: ${filename}`);
|
|
||||||
|
|
||||||
// We DON'T return here anymore. We continue below to load for the UI.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode (original code)
|
|
||||||
const wasPlaying = decks[id].playing;
|
const wasPlaying = decks[id].playing;
|
||||||
const wasBroadcasting = isBroadcasting;
|
const wasBroadcasting = isBroadcasting;
|
||||||
|
|
||||||
|
|
@ -1517,6 +1501,7 @@ async function loadFromServer(id, url, title) {
|
||||||
console.error(`[Deck ${id}] Load error:`, error);
|
console.error(`[Deck ${id}] Load error:`, error);
|
||||||
d.innerText = 'LOAD ERROR';
|
d.innerText = 'LOAD ERROR';
|
||||||
d.classList.remove('blink');
|
d.classList.remove('blink');
|
||||||
|
showToast(`Deck ${id}: Failed to load track — ${error.message}`, 'error');
|
||||||
setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000);
|
setTimeout(() => { d.innerText = 'NO TRACK'; }, 3000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1547,14 +1532,24 @@ async function handleFileUpload(event) {
|
||||||
progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>';
|
progressContainer.innerHTML = '<h3>UPLOADING TRACKS...</h3>';
|
||||||
progressContainer.classList.add('active');
|
progressContainer.classList.add('active');
|
||||||
|
|
||||||
|
const existingFilenames = allSongs.map(s => s.file.split('/').pop().toLowerCase());
|
||||||
|
|
||||||
const uploadPromises = files.map(async (file) => {
|
const uploadPromises = files.map(async (file) => {
|
||||||
const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
|
const allowedExts = ['.mp3', '.m4a', '.wav', '.flac', '.ogg'];
|
||||||
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||||
|
|
||||||
if (!allowedExts.includes(ext)) {
|
if (!allowedExts.includes(ext)) {
|
||||||
console.warn(`${file.name} is not a supported audio file`);
|
console.warn(`${file.name} is not a supported audio file`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
if (existingFilenames.includes(file.name.toLowerCase())) {
|
||||||
|
console.log(`[UPLOAD] Skipping duplicate: ${file.name}`);
|
||||||
|
showToast(`Skipped duplicate: ${file.name}`, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
|
|
@ -1572,8 +1567,7 @@ async function handleFileUpload(event) {
|
||||||
progressRow.appendChild(barWrap);
|
progressRow.appendChild(barWrap);
|
||||||
progressContainer.appendChild(progressRow);
|
progressContainer.appendChild(progressRow);
|
||||||
|
|
||||||
try {
|
return new Promise((resolve) => {
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', '/upload', true);
|
xhr.open('POST', '/upload', true);
|
||||||
|
|
||||||
|
|
@ -1586,30 +1580,35 @@ async function handleFileUpload(event) {
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
if (xhr.status === 200) {
|
if (xhr.status === 200) {
|
||||||
|
try {
|
||||||
const result = JSON.parse(xhr.responseText);
|
const result = JSON.parse(xhr.responseText);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
barInner.style.background = '#00ff88';
|
barInner.style.background = '#00ff88';
|
||||||
resolve();
|
|
||||||
} else {
|
} else {
|
||||||
barInner.style.background = '#ff4444';
|
barInner.style.background = '#ff4444';
|
||||||
reject(new Error(result.error));
|
nameSpan.title = result.error || 'Upload failed';
|
||||||
|
console.error(`[UPLOAD] ${file.name}: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
barInner.style.background = '#ff4444';
|
||||||
|
console.error(`[UPLOAD] Bad response for ${file.name}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
barInner.style.background = '#ff4444';
|
barInner.style.background = '#ff4444';
|
||||||
reject(new Error(`HTTP ${xhr.status}`));
|
nameSpan.title = `HTTP ${xhr.status}`;
|
||||||
|
console.error(`[UPLOAD] ${file.name}: HTTP ${xhr.status}${xhr.status === 413 ? ' — file too large (nginx limit)' : ''}`);
|
||||||
}
|
}
|
||||||
|
resolve(); // Always resolve so other uploads continue
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
barInner.style.background = '#ff4444';
|
barInner.style.background = '#ff4444';
|
||||||
reject(new Error('Network error'));
|
console.error(`[UPLOAD] ${file.name}: Network error`);
|
||||||
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error(`[ERROR] Upload error: ${error}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all)
|
// Run uploads in parallel (limited to 3 at a time for stability if needed, but let's try all)
|
||||||
|
|
@ -1745,11 +1744,12 @@ function toggleRepeat(id, val) {
|
||||||
|
|
||||||
console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`);
|
console.log(`Deck ${id} Repeat: ${settings[`repeat${id}`]}`);
|
||||||
vibrate(10);
|
vibrate(10);
|
||||||
|
saveSettings();
|
||||||
}
|
}
|
||||||
function toggleAutoMix(val) { settings.autoMix = val; }
|
function toggleAutoMix(val) { settings.autoMix = val; saveSettings(); }
|
||||||
function toggleShuffle(val) { settings.shuffleMode = val; }
|
function toggleShuffle(val) { settings.shuffleMode = val; saveSettings(); }
|
||||||
function toggleQuantize(val) { settings.quantize = val; }
|
function toggleQuantize(val) { settings.quantize = val; saveSettings(); }
|
||||||
function toggleAutoPlay(val) { settings.autoPlay = val; }
|
function toggleAutoPlay(val) { settings.autoPlay = val; saveSettings(); }
|
||||||
|
|
||||||
function updateManualGlow(id, val) {
|
function updateManualGlow(id, val) {
|
||||||
settings[`glow${id}`] = val;
|
settings[`glow${id}`] = val;
|
||||||
|
|
@ -1758,6 +1758,7 @@ function updateManualGlow(id, val) {
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove(`playing-${id}`);
|
document.body.classList.remove(`playing-${id}`);
|
||||||
}
|
}
|
||||||
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlowIntensity(val) {
|
function updateGlowIntensity(val) {
|
||||||
|
|
@ -1765,9 +1766,16 @@ function updateGlowIntensity(val) {
|
||||||
const opacity = settings.glowIntensity / 100;
|
const opacity = settings.glowIntensity / 100;
|
||||||
const spread = (settings.glowIntensity / 100) * 80;
|
const spread = (settings.glowIntensity / 100) * 80;
|
||||||
|
|
||||||
// Dynamically update CSS variables for the glow
|
|
||||||
document.documentElement.style.setProperty('--glow-opacity', opacity);
|
document.documentElement.style.setProperty('--glow-opacity', opacity);
|
||||||
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
|
document.documentElement.style.setProperty('--glow-spread', `${spread}px`);
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateListenerGlow(val) {
|
||||||
|
settings.listenerGlowIntensity = parseInt(val);
|
||||||
|
if (!socket) initSocket();
|
||||||
|
socket.emit('listener_glow', { intensity: settings.listenerGlowIntensity });
|
||||||
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dismiss landscape prompt
|
// Dismiss landscape prompt
|
||||||
|
|
@ -1788,25 +1796,47 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
if (prompt) prompt.classList.add('dismissed');
|
if (prompt) prompt.classList.add('dismissed');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialise glow intensity
|
// Sync all restored settings to UI controls
|
||||||
|
const syncCheckbox = (elId, val) => { const el = document.getElementById(elId); if (el) el.checked = !!val; };
|
||||||
|
const syncRange = (elId, val) => { const el = document.getElementById(elId); if (el && val != null) el.value = val; };
|
||||||
|
|
||||||
|
syncCheckbox('repeat-A', settings.repeatA);
|
||||||
|
syncCheckbox('repeat-B', settings.repeatB);
|
||||||
|
syncCheckbox('auto-mix', settings.autoMix);
|
||||||
|
syncCheckbox('shuffle-mode', settings.shuffleMode);
|
||||||
|
syncCheckbox('quantize', settings.quantize);
|
||||||
|
syncCheckbox('auto-play', settings.autoPlay);
|
||||||
|
syncCheckbox('glow-A', settings.glowA);
|
||||||
|
syncCheckbox('glow-B', settings.glowB);
|
||||||
|
syncRange('glow-intensity', settings.glowIntensity);
|
||||||
|
syncRange('listener-glow-intensity', settings.listenerGlowIntensity);
|
||||||
|
|
||||||
|
// Initialise glow intensity CSS variables
|
||||||
updateGlowIntensity(settings.glowIntensity);
|
updateGlowIntensity(settings.glowIntensity);
|
||||||
const glowAToggle = document.getElementById('glow-A');
|
|
||||||
if (glowAToggle) glowAToggle.checked = settings.glowA;
|
|
||||||
const glowBToggle = document.getElementById('glow-B');
|
|
||||||
if (glowBToggle) glowBToggle.checked = settings.glowB;
|
|
||||||
const intensitySlider = document.getElementById('glow-intensity');
|
|
||||||
if (intensitySlider) intensitySlider.value = settings.glowIntensity;
|
|
||||||
|
|
||||||
// Apply initial glow state
|
// Apply initial glow state
|
||||||
updateManualGlow('A', settings.glowA);
|
updateManualGlow('A', settings.glowA);
|
||||||
updateManualGlow('B', settings.glowB);
|
updateManualGlow('B', settings.glowB);
|
||||||
|
|
||||||
// Set stream URL in the streaming panel
|
// Set stream URL in the streaming panel
|
||||||
const streamUrl = window.location.hostname.startsWith('dj.')
|
|
||||||
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
|
|
||||||
: `${window.location.protocol}//${window.location.hostname}:5001`;
|
|
||||||
const streamInput = document.getElementById('stream-url');
|
const streamInput = document.getElementById('stream-url');
|
||||||
if (streamInput) streamInput.value = streamUrl;
|
if (streamInput) {
|
||||||
|
const _autoDetectListenerUrl = () => {
|
||||||
|
const host = window.location.hostname;
|
||||||
|
if (host.startsWith('dj.')) {
|
||||||
|
return `${window.location.protocol}//${host.slice(3)}`;
|
||||||
|
}
|
||||||
|
return `${window.location.protocol}//${host}:5001`;
|
||||||
|
};
|
||||||
|
fetch('/client_config')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(cfg => {
|
||||||
|
streamInput.value = cfg.listener_url || _autoDetectListenerUrl();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
streamInput.value = _autoDetectListenerUrl();
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========== LIVE STREAMING FUNCTIONALITY ==========
|
// ========== LIVE STREAMING FUNCTIONALITY ==========
|
||||||
|
|
@ -1997,24 +2027,7 @@ function startBroadcast() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-side audio mode
|
// Browser-side audio mode
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
isBroadcasting = true;
|
|
||||||
document.getElementById('broadcast-btn').classList.add('active');
|
|
||||||
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
|
|
||||||
document.getElementById('broadcast-status').textContent = 'LIVE';
|
|
||||||
document.getElementById('broadcast-status').classList.add('live');
|
|
||||||
|
|
||||||
if (!socket) initSocket();
|
|
||||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
|
||||||
socket.emit('start_broadcast', { bitrate: bitrateValue });
|
|
||||||
socket.emit('get_listener_count');
|
|
||||||
|
|
||||||
console.log('[OK] Server-side broadcast started');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser-side audio mode (original code)
|
|
||||||
// Check if any audio is playing
|
// Check if any audio is playing
|
||||||
const anyPlaying = decks.A.playing || decks.B.playing;
|
const anyPlaying = decks.A.playing || decks.B.playing;
|
||||||
if (!anyPlaying) {
|
if (!anyPlaying) {
|
||||||
|
|
@ -2190,10 +2203,12 @@ function startBroadcast() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1000ms chunks: Dramatically reduces CPU interrupts on low-RAM machines
|
// 250ms chunks: More frequent smaller chunks reduces stall gaps on weak connections.
|
||||||
|
// A 1-second chunk creates a 1-second starvation gap if the network hiccups;
|
||||||
|
// 250ms chunks keep the server fed 4x more often.
|
||||||
// Validate state before starting
|
// Validate state before starting
|
||||||
if (mediaRecorder.state === 'inactive') {
|
if (mediaRecorder.state === 'inactive') {
|
||||||
mediaRecorder.start(1000);
|
mediaRecorder.start(250);
|
||||||
streamProcessor = mediaRecorder;
|
streamProcessor = mediaRecorder;
|
||||||
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
|
console.log('[OK] MediaRecorder started in state:', mediaRecorder.state);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2238,23 +2253,15 @@ function startBroadcast() {
|
||||||
function stopBroadcast() {
|
function stopBroadcast() {
|
||||||
console.log('[BROADCAST] Stopping...');
|
console.log('[BROADCAST] Stopping...');
|
||||||
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
|
||||||
isBroadcasting = false;
|
|
||||||
if (socket) {
|
|
||||||
socket.emit('stop_broadcast');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (streamProcessor) {
|
if (streamProcessor) {
|
||||||
streamProcessor.stop();
|
streamProcessor.stop();
|
||||||
streamProcessor = null;
|
streamProcessor = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamDestination) {
|
if (streamDestination) {
|
||||||
// Disconnect from stream destination and restore normal playback
|
|
||||||
if (decks.A.crossfaderGain) {
|
if (decks.A.crossfaderGain) {
|
||||||
try {
|
try {
|
||||||
decks.A.crossfaderGain.disconnect(streamDestination);
|
decks.A.crossfaderGain.disconnect(streamDestination);
|
||||||
// Ensure connection to speakers is maintained
|
|
||||||
decks.A.crossfaderGain.disconnect();
|
decks.A.crossfaderGain.disconnect();
|
||||||
decks.A.crossfaderGain.connect(audioCtx.destination);
|
decks.A.crossfaderGain.connect(audioCtx.destination);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2264,7 +2271,6 @@ function stopBroadcast() {
|
||||||
if (decks.B.crossfaderGain) {
|
if (decks.B.crossfaderGain) {
|
||||||
try {
|
try {
|
||||||
decks.B.crossfaderGain.disconnect(streamDestination);
|
decks.B.crossfaderGain.disconnect(streamDestination);
|
||||||
// Ensure connection to speakers is maintained
|
|
||||||
decks.B.crossfaderGain.disconnect();
|
decks.B.crossfaderGain.disconnect();
|
||||||
decks.B.crossfaderGain.connect(audioCtx.destination);
|
decks.B.crossfaderGain.connect(audioCtx.destination);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -2275,11 +2281,9 @@ function stopBroadcast() {
|
||||||
}
|
}
|
||||||
isBroadcasting = false;
|
isBroadcasting = false;
|
||||||
|
|
||||||
// Notify server (browser-side mode also needs to tell server to stop relaying)
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit('stop_broadcast');
|
socket.emit('stop_broadcast');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
document.getElementById('broadcast-btn').classList.remove('active');
|
document.getElementById('broadcast-btn').classList.remove('active');
|
||||||
|
|
@ -2386,91 +2390,155 @@ window.addEventListener('load', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Monitoring
|
// Auto-crossfade: smoothly transitions from the ending deck to the other deck.
|
||||||
function monitorTrackEnd() {
|
let _autoMixTimer = null;
|
||||||
setInterval(() => {
|
|
||||||
if (!audioCtx) return; // Safety check
|
|
||||||
|
|
||||||
// In server-side mode, poll for status from server
|
function startAutoMixFade(endingDeckId) {
|
||||||
if (SERVER_SIDE_AUDIO && socket && socket.connected) {
|
const otherDeck = endingDeckId === 'A' ? 'B' : 'A';
|
||||||
socket.emit('get_mixer_status');
|
|
||||||
|
// The other deck must have a track loaded and be playing (or about to play)
|
||||||
|
if (!decks[otherDeck].localBuffer) {
|
||||||
|
console.log(`[AutoMix] Other deck ${otherDeck} has no track loaded, skipping crossfade`);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
['A', 'B'].forEach(id => {
|
// If the other deck isn't playing, start it
|
||||||
if (decks[id].playing && decks[id].localBuffer && !decks[id].loading) {
|
if (!decks[otherDeck].playing) {
|
||||||
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
playDeck(otherDeck);
|
||||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
}
|
||||||
const current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
|
|
||||||
const remaining = decks[id].duration - current;
|
// Cancel any existing auto-mix animation
|
||||||
|
if (_autoMixTimer) {
|
||||||
|
clearInterval(_autoMixTimer);
|
||||||
|
_autoMixTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slider = document.getElementById('crossfader');
|
||||||
|
if (!slider) return false;
|
||||||
|
|
||||||
|
const target = endingDeckId === 'A' ? 100 : 0; // Fade toward the OTHER deck
|
||||||
|
const duration = 5000; // 5 seconds
|
||||||
|
const steps = 50;
|
||||||
|
const interval = duration / steps;
|
||||||
|
const start = parseInt(slider.value);
|
||||||
|
const delta = (target - start) / steps;
|
||||||
|
let step = 0;
|
||||||
|
|
||||||
|
console.log(`[AutoMix] Crossfading from Deck ${endingDeckId} → Deck ${otherDeck} over ${duration / 1000}s`);
|
||||||
|
showToast(`Auto-crossfading to Deck ${otherDeck}`, 'info');
|
||||||
|
|
||||||
|
_autoMixTimer = setInterval(() => {
|
||||||
|
step++;
|
||||||
|
const val = Math.round(start + delta * step);
|
||||||
|
slider.value = val;
|
||||||
|
updateCrossfader(val);
|
||||||
|
|
||||||
|
if (step >= steps) {
|
||||||
|
clearInterval(_autoMixTimer);
|
||||||
|
_autoMixTimer = null;
|
||||||
|
console.log(`[AutoMix] Crossfade complete. Now on Deck ${otherDeck}`);
|
||||||
|
|
||||||
|
// Load next track on the ending deck so it's ready for the next crossfade
|
||||||
|
if (queues[endingDeckId] && queues[endingDeckId].length > 0) {
|
||||||
|
const next = queues[endingDeckId].shift();
|
||||||
|
renderQueue(endingDeckId);
|
||||||
|
loadFromServer(endingDeckId, next.file, next.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared handler called both by onended and the monitor poll.
|
||||||
|
// Handles repeat, auto-crossfade, auto-play-from-queue, or stop.
|
||||||
|
function handleTrackEnd(id) {
|
||||||
|
// Already being handled or loop is active — skip
|
||||||
|
if (decks[id].loading || decks[id].loopActive) return;
|
||||||
|
|
||||||
// If end reached (with 0.5s buffer for safety)
|
|
||||||
// Skip if a loop is active — the Web Audio API handles looping natively
|
|
||||||
// and lastAnchorPosition is not updated on each native loop repeat, so
|
|
||||||
// the monotonically-growing `current` would incorrectly trigger end-of-track.
|
|
||||||
if (remaining <= 0.5 && !decks[id].loopActive) {
|
|
||||||
// During broadcast, still handle auto-play/queue to avoid dead air
|
|
||||||
if (isBroadcasting) {
|
if (isBroadcasting) {
|
||||||
console.log(`Track ending during broadcast on Deck ${id}`);
|
console.log(`Track ending during broadcast on Deck ${id}`);
|
||||||
if (settings[`repeat${id}`]) {
|
if (settings[`repeat${id}`]) {
|
||||||
console.log(`LOOP Repeating track on Deck ${id}`);
|
console.log(`Repeating track on Deck ${id} (broadcast)`);
|
||||||
seekTo(id, 0);
|
seekTo(id, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Auto-play from queue during broadcast to maintain stream
|
|
||||||
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
|
if (settings.autoPlay && queues[id] && queues[id].length > 0) {
|
||||||
decks[id].loading = true;
|
decks[id].loading = true;
|
||||||
console.log(`Auto-play (broadcast): Loading next from Queue ${id}...`);
|
|
||||||
const next = queues[id].shift();
|
const next = queues[id].shift();
|
||||||
renderQueue(id);
|
renderQueue(id);
|
||||||
loadFromServer(id, next.file, next.title).then(() => {
|
loadFromServer(id, next.file, next.title)
|
||||||
decks[id].loading = false;
|
.then(() => { decks[id].loading = false; playDeck(id); })
|
||||||
playDeck(id);
|
.catch(() => { decks[id].loading = false; });
|
||||||
}).catch(() => {
|
|
||||||
decks[id].loading = false;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// No repeat, no queue - just let the stream continue silently
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings[`repeat${id}`]) {
|
if (settings[`repeat${id}`]) {
|
||||||
// Full song repeat
|
console.log(`Repeating track on Deck ${id}`);
|
||||||
console.log(`LOOP Repeating track on Deck ${id}`);
|
|
||||||
seekTo(id, 0);
|
seekTo(id, 0);
|
||||||
|
} else if (settings.autoMix) {
|
||||||
|
// Auto-crossfade takes priority over simple auto-play
|
||||||
|
decks[id].loading = true;
|
||||||
|
if (!startAutoMixFade(id)) {
|
||||||
|
// Crossfade not possible (other deck empty) — fall through to normal auto-play
|
||||||
|
decks[id].loading = false;
|
||||||
|
handleAutoPlay(id);
|
||||||
|
} else {
|
||||||
|
decks[id].loading = false;
|
||||||
|
}
|
||||||
} else if (settings.autoPlay) {
|
} else if (settings.autoPlay) {
|
||||||
// Prevent race condition
|
handleAutoPlay(id);
|
||||||
|
} else {
|
||||||
|
pauseDeck(id);
|
||||||
|
decks[id].pausedAt = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAutoPlay(id) {
|
||||||
decks[id].loading = true;
|
decks[id].loading = true;
|
||||||
pauseDeck(id);
|
pauseDeck(id);
|
||||||
|
|
||||||
// Check queue for auto-play
|
|
||||||
if (queues[id] && queues[id].length > 0) {
|
if (queues[id] && queues[id].length > 0) {
|
||||||
console.log(`Auto-play: Loading next from Queue ${id}...`);
|
console.log(`Auto-play: loading next from Queue ${id}`);
|
||||||
const next = queues[id].shift();
|
const next = queues[id].shift();
|
||||||
renderQueue(id); // Update queue UI
|
renderQueue(id);
|
||||||
|
loadFromServer(id, next.file, next.title)
|
||||||
|
.then(() => { decks[id].loading = false; playDeck(id); })
|
||||||
|
.catch(() => { decks[id].loading = false; });
|
||||||
|
} else {
|
||||||
|
console.log(`Auto-play: queue empty on Deck ${id}, stopping`);
|
||||||
|
decks[id].loading = false;
|
||||||
|
decks[id].pausedAt = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadFromServer(id, next.file, next.title).then(() => {
|
// Monitoring — safety net for cases where onended doesn't fire
|
||||||
decks[id].loading = false;
|
// (e.g. AudioContext suspended, very short buffer, scrub to near-end).
|
||||||
playDeck(id);
|
function monitorTrackEnd() {
|
||||||
}).catch(() => {
|
setInterval(() => {
|
||||||
decks[id].loading = false;
|
if (!audioCtx) return;
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No queue - just stop
|
|
||||||
console.log(`Track ended, queue empty - stopping playback`);
|
['A', 'B'].forEach(id => {
|
||||||
decks[id].loading = false;
|
if (!decks[id].playing || !decks[id].localBuffer || decks[id].loading) return;
|
||||||
pauseDeck(id);
|
if (decks[id].loopActive) return;
|
||||||
decks[id].pausedAt = 0;
|
|
||||||
}
|
const rate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
||||||
} else {
|
const elapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
||||||
// Just stop if no auto-play
|
const current = decks[id].lastAnchorPosition + (elapsed * rate);
|
||||||
pauseDeck(id);
|
const remaining = decks[id].duration - current;
|
||||||
decks[id].pausedAt = 0;
|
|
||||||
}
|
// Threshold scales with playback rate so we don't miss fast playback.
|
||||||
}
|
// Use 1.5× the poll interval (0.75s) as a safe window.
|
||||||
|
const threshold = Math.max(0.75, 0.75 * rate);
|
||||||
|
if (remaining <= threshold) {
|
||||||
|
console.log(`[Monitor] Deck ${id} near end (${remaining.toFixed(2)}s left) — triggering handleTrackEnd`);
|
||||||
|
handleTrackEnd(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 500); // Check every 0.5s
|
}, 500);
|
||||||
}
|
}
|
||||||
monitorTrackEnd();
|
monitorTrackEnd();
|
||||||
|
|
||||||
|
|
@ -2566,11 +2634,6 @@ function addToQueue(deckId, file, title) {
|
||||||
queues[deckId].push({ file, title });
|
queues[deckId].push({ file, title });
|
||||||
renderQueue(deckId);
|
renderQueue(deckId);
|
||||||
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
|
console.log(`Added "${title}" to Queue ${deckId} (${queues[deckId].length} tracks)`);
|
||||||
|
|
||||||
// Sync with server if in server-side mode
|
|
||||||
if (SERVER_SIDE_AUDIO && socket) {
|
|
||||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove track from queue
|
// Remove track from queue
|
||||||
|
|
@ -2578,11 +2641,6 @@ function removeFromQueue(deckId, index) {
|
||||||
const removed = queues[deckId].splice(index, 1)[0];
|
const removed = queues[deckId].splice(index, 1)[0];
|
||||||
renderQueue(deckId);
|
renderQueue(deckId);
|
||||||
console.log(`Removed "${removed.title}" from Queue ${deckId}`);
|
console.log(`Removed "${removed.title}" from Queue ${deckId}`);
|
||||||
|
|
||||||
// Sync with server if in server-side mode
|
|
||||||
if (SERVER_SIDE_AUDIO && socket) {
|
|
||||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear entire queue
|
// Clear entire queue
|
||||||
|
|
@ -2591,11 +2649,6 @@ function clearQueue(deckId) {
|
||||||
queues[deckId] = [];
|
queues[deckId] = [];
|
||||||
renderQueue(deckId);
|
renderQueue(deckId);
|
||||||
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
|
console.log(`Cleared Queue ${deckId} (${count} tracks removed)`);
|
||||||
|
|
||||||
// Sync with server if in server-side mode
|
|
||||||
if (SERVER_SIDE_AUDIO && socket) {
|
|
||||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load next track from queue
|
// Load next track from queue
|
||||||
|
|
@ -2610,11 +2663,6 @@ function loadNextFromQueue(deckId) {
|
||||||
loadFromServer(deckId, next.file, next.title);
|
loadFromServer(deckId, next.file, next.title);
|
||||||
renderQueue(deckId);
|
renderQueue(deckId);
|
||||||
|
|
||||||
// Sync with server if in server-side mode (after shift)
|
|
||||||
if (SERVER_SIDE_AUDIO && socket) {
|
|
||||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2686,11 +2734,14 @@ function renderQueue(deckId) {
|
||||||
removeFromQueue(deckId, index);
|
removeFromQueue(deckId, index);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag and drop reordering
|
// Drag and drop reordering / moving
|
||||||
item.ondragstart = (e) => {
|
item.ondragstart = (e) => {
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('queueIndex', index);
|
e.dataTransfer.setData('queueIndex', index);
|
||||||
e.dataTransfer.setData('queueDeck', deckId);
|
e.dataTransfer.setData('queueDeck', deckId);
|
||||||
|
// Also set track data so it can be dropped as a generic track
|
||||||
|
e.dataTransfer.setData('trackFile', track.file);
|
||||||
|
e.dataTransfer.setData('trackTitle', track.title);
|
||||||
item.classList.add('dragging');
|
item.classList.add('dragging');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2705,17 +2756,30 @@ function renderQueue(deckId) {
|
||||||
|
|
||||||
item.ondrop = (e) => {
|
item.ondrop = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fromIndex = parseInt(e.dataTransfer.getData('queueIndex'));
|
const fromIndex = e.dataTransfer.getData('queueIndex');
|
||||||
const fromDeck = e.dataTransfer.getData('queueDeck');
|
const fromDeck = e.dataTransfer.getData('queueDeck');
|
||||||
|
const trackFile = e.dataTransfer.getData('trackFile');
|
||||||
|
const trackTitle = e.dataTransfer.getData('trackTitle');
|
||||||
|
|
||||||
|
if (fromDeck !== "" && fromIndex !== "") {
|
||||||
|
// Move from a queue (same or different)
|
||||||
|
const srcIdx = parseInt(fromIndex);
|
||||||
|
const [movedItem] = queues[fromDeck].splice(srcIdx, 1);
|
||||||
|
|
||||||
|
// If same deck and after removal index changes, adjust target index if needed?
|
||||||
|
// Actually splice(index, 0, item) works fine if we handle same deck carefully.
|
||||||
|
let targetIdx = index;
|
||||||
|
|
||||||
|
queues[deckId].splice(targetIdx, 0, movedItem);
|
||||||
|
|
||||||
if (fromDeck === deckId && fromIndex !== index) {
|
|
||||||
const [movedItem] = queues[deckId].splice(fromIndex, 1);
|
|
||||||
queues[deckId].splice(index, 0, movedItem);
|
|
||||||
renderQueue(deckId);
|
renderQueue(deckId);
|
||||||
|
if (fromDeck !== deckId) renderQueue(fromDeck);
|
||||||
if (SERVER_SIDE_AUDIO && socket) {
|
console.log(`Moved track from Queue ${fromDeck} to Queue ${deckId} at index ${targetIdx}`);
|
||||||
socket.emit('audio_sync_queue', { deck: deckId, tracks: queues[deckId] });
|
} else if (trackFile && trackTitle) {
|
||||||
}
|
// Drop from library into middle of queue
|
||||||
|
queues[deckId].splice(index, 0, { file: trackFile, title: trackTitle });
|
||||||
|
renderQueue(deckId);
|
||||||
|
console.log(`Inserted library track into Queue ${deckId} at index ${index}: ${trackTitle}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
151
server.py
151
server.py
|
|
@ -42,6 +42,7 @@ CONFIG_SECRET = (CONFIG.get('secret_key') or '').strip() or 'dj_panel_secret'
|
||||||
CONFIG_CORS = CONFIG.get('cors_origins', '*')
|
CONFIG_CORS = CONFIG.get('cors_origins', '*')
|
||||||
CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
|
CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
|
||||||
CONFIG_DEBUG = bool(CONFIG.get('debug', False))
|
CONFIG_DEBUG = bool(CONFIG.get('debug', False))
|
||||||
|
CONFIG_LISTENER_URL = (CONFIG.get('listener_url') or '').strip()
|
||||||
|
|
||||||
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
|
DJ_PANEL_PASSWORD = (CONFIG.get('dj_panel_password') or '').strip()
|
||||||
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
|
DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
|
||||||
|
|
@ -66,7 +67,7 @@ _mp3_lock = threading.Lock()
|
||||||
_transcoder_bytes_out = 0
|
_transcoder_bytes_out = 0
|
||||||
_transcoder_last_error = None
|
_transcoder_last_error = None
|
||||||
_last_audio_chunk_ts = 0.0
|
_last_audio_chunk_ts = 0.0
|
||||||
_mp3_preroll = collections.deque(maxlen=512)
|
_mp3_preroll = collections.deque(maxlen=1024) # ~83s at 96kbps for fast reconnect buffer fill
|
||||||
|
|
||||||
|
|
||||||
def _start_transcoder_if_needed(is_mp3_input=False):
|
def _start_transcoder_if_needed(is_mp3_input=False):
|
||||||
|
|
@ -234,9 +235,9 @@ def _stop_transcoder():
|
||||||
def _feed_transcoder(data: bytes):
|
def _feed_transcoder(data: bytes):
|
||||||
global _last_audio_chunk_ts
|
global _last_audio_chunk_ts
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
# If active but dead, restart it automatically
|
# If active but dead, restart it automatically (non-MP3 mode only)
|
||||||
if broadcast_state.get('active'):
|
if broadcast_state.get('active'):
|
||||||
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -246,6 +247,28 @@ def _feed_transcoder(data: bytes):
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Drop chunk if overflow to prevent memory bloat
|
# Drop chunk if overflow to prevent memory bloat
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _distribute_mp3(data: bytes):
|
||||||
|
"""Distribute MP3 bytes directly to preroll buffer and all connected listener
|
||||||
|
clients, bypassing the ffmpeg transcoder entirely.
|
||||||
|
|
||||||
|
Used when the DJ is already sending valid MP3 (e.g. the Qt desktop client)
|
||||||
|
to eliminate the unnecessary encode/decode round-trip and cut pipeline latency
|
||||||
|
roughly in half.
|
||||||
|
"""
|
||||||
|
global _transcoder_bytes_out, _last_audio_chunk_ts
|
||||||
|
_last_audio_chunk_ts = time.time()
|
||||||
|
_transcoder_bytes_out += len(data)
|
||||||
|
with _mp3_lock:
|
||||||
|
_mp3_preroll.append(data)
|
||||||
|
for q in list(_mp3_clients):
|
||||||
|
try:
|
||||||
|
q.put_nowait(data)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Load settings to get MUSIC_FOLDER
|
# Load settings to get MUSIC_FOLDER
|
||||||
def _load_settings():
|
def _load_settings():
|
||||||
try:
|
try:
|
||||||
|
|
@ -386,6 +409,9 @@ def setup_shared_routes(app, index_file='index.html'):
|
||||||
|
|
||||||
filepath = os.path.join(MUSIC_FOLDER, filename)
|
filepath = os.path.join(MUSIC_FOLDER, filename)
|
||||||
|
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
return jsonify({"success": False, "error": "File already exists in library"}), 409
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
print(f"UPLOADED: {filename}")
|
print(f"UPLOADED: {filename}")
|
||||||
|
|
@ -431,7 +457,10 @@ def setup_shared_routes(app, index_file='index.html'):
|
||||||
'Cache-Control': 'no-cache, no-store',
|
'Cache-Control': 'no-cache, no-store',
|
||||||
})
|
})
|
||||||
|
|
||||||
# If the transcoder isn't ready yet, wait briefly (it may still be starting)
|
# For non-MP3 input the server runs an ffmpeg transcoder; wait for it to start.
|
||||||
|
# For MP3 input (e.g. Qt client) chunks are distributed directly — no ffmpeg needed.
|
||||||
|
is_mp3_direct = broadcast_state.get('is_mp3_input', False)
|
||||||
|
if not is_mp3_direct:
|
||||||
waited = 0.0
|
waited = 0.0
|
||||||
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
|
while (_ffmpeg_proc is None or _ffmpeg_proc.poll() is not None) and waited < 5.0:
|
||||||
eventlet.sleep(0.5)
|
eventlet.sleep(0.5)
|
||||||
|
|
@ -485,6 +514,7 @@ def setup_shared_routes(app, index_file='index.html'):
|
||||||
'X-Content-Type-Options': 'nosniff',
|
'X-Content-Type-Options': 'nosniff',
|
||||||
'Access-Control-Allow-Origin': '*',
|
'Access-Control-Allow-Origin': '*',
|
||||||
'Icy-Name': 'TechDJ Live',
|
'Icy-Name': 'TechDJ Live',
|
||||||
|
'X-Accel-Buffering': 'no', # Tell nginx/Cloudflare not to buffer this stream
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/stream_debug')
|
@app.route('/stream_debug')
|
||||||
|
|
@ -588,7 +618,6 @@ def dj_login():
|
||||||
<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus />
|
<input id=\"password\" name=\"password\" type=\"password\" autocomplete=\"current-password\" autofocus />
|
||||||
<button type=\"submit\">Unlock DJ Panel</button>
|
<button type=\"submit\">Unlock DJ Panel</button>
|
||||||
{f"<div class='err'>{error}</div>" if error else ""}
|
{f"<div class='err'>{error}</div>" if error else ""}
|
||||||
<div class=\"hint\">Set/disable this in config.json (dj_panel_password).</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -605,6 +634,12 @@ def dj_logout():
|
||||||
302,
|
302,
|
||||||
{'Location': '/login'}
|
{'Location': '/login'}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@dj_app.route('/client_config')
|
||||||
|
def client_config():
|
||||||
|
"""Expose server-side config values needed by the DJ panel client."""
|
||||||
|
return jsonify({'listener_url': CONFIG_LISTENER_URL})
|
||||||
|
|
||||||
dj_socketio = SocketIO(
|
dj_socketio = SocketIO(
|
||||||
dj_app,
|
dj_app,
|
||||||
cors_allowed_origins=CONFIG_CORS,
|
cors_allowed_origins=CONFIG_CORS,
|
||||||
|
|
@ -680,17 +715,21 @@ def dj_start(data=None):
|
||||||
broadcast_state['is_mp3_input'] = is_mp3_input
|
broadcast_state['is_mp3_input'] = is_mp3_input
|
||||||
|
|
||||||
if not was_already_active:
|
if not was_already_active:
|
||||||
# Fresh broadcast start - clear pre-roll and start transcoder cleanly
|
# Fresh broadcast start — clear pre-roll.
|
||||||
|
# For non-MP3 input start the ffmpeg transcoder; for MP3 input chunks are
|
||||||
|
# distributed directly via _distribute_mp3(), no transcoder required.
|
||||||
with _mp3_lock:
|
with _mp3_lock:
|
||||||
_mp3_preroll.clear()
|
_mp3_preroll.clear()
|
||||||
_start_transcoder_if_needed(is_mp3_input=is_mp3_input)
|
if not is_mp3_input:
|
||||||
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
# Tell listeners a new broadcast has begun (triggers audio player reload)
|
# Tell listeners a new broadcast has begun (triggers audio player reload)
|
||||||
listener_socketio.emit('broadcast_started', namespace='/')
|
listener_socketio.emit('broadcast_started', namespace='/')
|
||||||
else:
|
else:
|
||||||
# DJ reconnected mid-broadcast - just ensure transcoder is alive
|
# DJ reconnected mid-broadcast - just ensure transcoder is alive (non-MP3 only)
|
||||||
# Do NOT clear pre-roll or trigger listener reload
|
# Do NOT clear pre-roll or trigger listener reload
|
||||||
print("BROADCAST: DJ reconnected - resuming existing broadcast")
|
print("BROADCAST: DJ reconnected - resuming existing broadcast")
|
||||||
_start_transcoder_if_needed(is_mp3_input=is_mp3_input)
|
if not is_mp3_input:
|
||||||
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
|
|
||||||
# Always send current status so any waiting listeners get unblocked
|
# Always send current status so any waiting listeners get unblocked
|
||||||
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
||||||
|
|
@ -699,6 +738,23 @@ def dj_start(data=None):
|
||||||
def dj_get_listener_count():
|
def dj_get_listener_count():
|
||||||
emit('listener_count', {'count': len(listener_sids)})
|
emit('listener_count', {'count': len(listener_sids)})
|
||||||
|
|
||||||
|
@dj_socketio.on('listener_glow')
|
||||||
|
def dj_listener_glow(data):
|
||||||
|
"""DJ sets the glow intensity on the listener page."""
|
||||||
|
intensity = int(data.get('intensity', 30)) if isinstance(data, dict) else 30
|
||||||
|
intensity = max(0, min(100, intensity))
|
||||||
|
listener_socketio.emit('listener_glow', {'intensity': intensity}, namespace='/')
|
||||||
|
|
||||||
|
@dj_socketio.on('deck_glow')
|
||||||
|
def dj_deck_glow(data):
|
||||||
|
"""Relay which decks are playing so the listener page can mirror the glow colour."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return
|
||||||
|
listener_socketio.emit('deck_glow', {
|
||||||
|
'A': bool(data.get('A', False)),
|
||||||
|
'B': bool(data.get('B', False)),
|
||||||
|
}, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('stop_broadcast')
|
@dj_socketio.on('stop_broadcast')
|
||||||
def dj_stop():
|
def dj_stop():
|
||||||
broadcast_state['active'] = False
|
broadcast_state['active'] = False
|
||||||
|
|
@ -712,15 +768,14 @@ def dj_stop():
|
||||||
|
|
||||||
@dj_socketio.on('audio_chunk')
|
@dj_socketio.on('audio_chunk')
|
||||||
def dj_audio(data):
|
def dj_audio(data):
|
||||||
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
|
if broadcast_state['active'] and isinstance(data, (bytes, bytearray)):
|
||||||
if broadcast_state['active']:
|
if broadcast_state.get('is_mp3_input', False):
|
||||||
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
|
# MP3 input (e.g. Qt client): skip ffmpeg, send directly to listeners
|
||||||
|
_distribute_mp3(bytes(data))
|
||||||
|
else:
|
||||||
|
# Other formats (e.g. webm/opus from browser): route through ffmpeg transcoder
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
# If we don't know the format, default to transcode,
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
# but usually start_broadcast handles this
|
|
||||||
_start_transcoder_if_needed()
|
|
||||||
|
|
||||||
if isinstance(data, (bytes, bytearray)):
|
|
||||||
_feed_transcoder(bytes(data))
|
_feed_transcoder(bytes(data))
|
||||||
|
|
||||||
# === LISTENER SERVER ===
|
# === LISTENER SERVER ===
|
||||||
|
|
@ -748,34 +803,49 @@ listener_socketio = SocketIO(
|
||||||
cors_allowed_origins=CONFIG_CORS,
|
cors_allowed_origins=CONFIG_CORS,
|
||||||
async_mode='eventlet',
|
async_mode='eventlet',
|
||||||
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
|
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
ping_timeout=60,
|
# Lower timeouts: stale connections detected in ~25s instead of ~85s
|
||||||
ping_interval=25,
|
# ping_interval: how often to probe (seconds)
|
||||||
|
# ping_timeout: how long to wait for pong before declaring dead
|
||||||
|
ping_timeout=15,
|
||||||
|
ping_interval=10,
|
||||||
logger=CONFIG_DEBUG,
|
logger=CONFIG_DEBUG,
|
||||||
engineio_logger=CONFIG_DEBUG
|
engineio_logger=CONFIG_DEBUG
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _broadcast_listener_count():
|
||||||
|
"""Compute the most accurate listener count and broadcast to both panels.
|
||||||
|
|
||||||
|
Uses the larger of:
|
||||||
|
- listener_sids: Socket.IO connections (people with the page open)
|
||||||
|
- _mp3_clients: active /stream.mp3 HTTP connections (people actually hearing audio)
|
||||||
|
|
||||||
|
Taking the max avoids undercounting when someone hasn't clicked Enable Audio
|
||||||
|
yet, and also avoids undercounting direct stream URL listeners (e.g. VLC).
|
||||||
|
"""
|
||||||
|
with _mp3_lock:
|
||||||
|
stream_count = len(_mp3_clients)
|
||||||
|
count = max(len(listener_sids), stream_count)
|
||||||
|
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||||
|
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
@listener_socketio.on('connect')
|
@listener_socketio.on('connect')
|
||||||
def listener_connect():
|
def listener_connect():
|
||||||
print(f"LISTENER: Listener Socket Connected: {request.sid}")
|
# Count immediately on connect — don't wait for join_listener
|
||||||
|
listener_sids.add(request.sid)
|
||||||
|
count = _broadcast_listener_count()
|
||||||
|
print(f"LISTENER: Connected {request.sid}. Total: {count}")
|
||||||
|
|
||||||
@listener_socketio.on('disconnect')
|
@listener_socketio.on('disconnect')
|
||||||
def listener_disconnect():
|
def listener_disconnect():
|
||||||
listener_sids.discard(request.sid)
|
listener_sids.discard(request.sid)
|
||||||
count = len(listener_sids)
|
count = _broadcast_listener_count()
|
||||||
print(f"REMOVED: Listener left. Total: {count}")
|
print(f"REMOVED: Listener left {request.sid}. Total: {count}")
|
||||||
# Notify BOTH namespaces
|
|
||||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
|
|
||||||
@listener_socketio.on('join_listener')
|
@listener_socketio.on('join_listener')
|
||||||
def listener_join():
|
def listener_join():
|
||||||
if request.sid not in listener_sids:
|
# SID already added in listener_connect(); just send stream status back
|
||||||
listener_sids.add(request.sid)
|
|
||||||
count = len(listener_sids)
|
|
||||||
print(f"LISTENER: New listener joined. Total: {count}")
|
|
||||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
|
|
||||||
emit('stream_status', {'active': broadcast_state['active']})
|
emit('stream_status', {'active': broadcast_state['active']})
|
||||||
|
|
||||||
@listener_socketio.on('get_listener_count')
|
@listener_socketio.on('get_listener_count')
|
||||||
|
|
@ -784,23 +854,24 @@ def listener_get_count():
|
||||||
|
|
||||||
# DJ Panel Routes (No engine commands needed in local mode)
|
# DJ Panel Routes (No engine commands needed in local mode)
|
||||||
def _transcoder_watchdog():
|
def _transcoder_watchdog():
|
||||||
"""Periodic check to ensure the transcoder stays alive during active broadcasts."""
|
"""Periodic check to ensure the ffmpeg transcoder stays alive.
|
||||||
|
Only applies to non-MP3 input; MP3 input (Qt client) is distributed directly.
|
||||||
|
"""
|
||||||
while True:
|
while True:
|
||||||
if broadcast_state.get('active'):
|
is_mp3_direct = broadcast_state.get('is_mp3_input', False)
|
||||||
|
if broadcast_state.get('active') and not is_mp3_direct:
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
# Only log if it's actually dead and supposed to be alive
|
|
||||||
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
|
print("WARNING: Watchdog: Transcoder dead during active broadcast, reviving...")
|
||||||
_start_transcoder_if_needed(is_mp3_input=broadcast_state.get('is_mp3_input', False))
|
_start_transcoder_if_needed(is_mp3_input=False)
|
||||||
eventlet.sleep(5)
|
eventlet.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
def _listener_count_sync_loop():
|
def _listener_count_sync_loop():
|
||||||
"""Periodic background sync to ensure listener count is always accurate."""
|
"""Periodic reconciliation — catches any edge cases where connect/disconnect
|
||||||
|
events were missed (e.g. server under load, eventlet greenlet delays)."""
|
||||||
while True:
|
while True:
|
||||||
count = len(listener_sids)
|
|
||||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
dj_socketio.emit('listener_count', {'count': count}, namespace='/')
|
|
||||||
eventlet.sleep(5)
|
eventlet.sleep(5)
|
||||||
|
_broadcast_listener_count()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"audio": {
|
"audio": {
|
||||||
"recording_sample_rate": 48000,
|
"recording_sample_rate": 48000,
|
||||||
"recording_format": "wav",
|
"recording_format": "wav",
|
||||||
"stream_server_url": "http://54.37.246.24:5000"
|
"stream_server_url": "http://54.37.246.24:5001/"
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"neon_mode": 2
|
"neon_mode": 2
|
||||||
|
|
|
||||||
81
style.css
81
style.css
|
|
@ -24,6 +24,12 @@
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
|
overflow: hidden;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome / Edge / Safari */
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|
@ -218,7 +224,7 @@ header h1 {
|
||||||
grid-template-rows: 1fr 80px;
|
grid-template-rows: 1fr 80px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: calc(100vh - 60px);
|
height: 100vh;
|
||||||
/* Adjust based on header height */
|
/* Adjust based on header height */
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -1706,6 +1712,9 @@ input[type=range] {
|
||||||
/* Less intense on mobile */
|
/* Less intense on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-top-bar {
|
.mobile-top-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1916,8 +1925,8 @@ input[type=range] {
|
||||||
/* Streaming Button */
|
/* Streaming Button */
|
||||||
.streaming-btn {
|
.streaming-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 25px;
|
bottom: 175px;
|
||||||
right: 175px;
|
right: 25px;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -1947,8 +1956,8 @@ input[type=range] {
|
||||||
|
|
||||||
.upload-btn {
|
.upload-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 25px;
|
bottom: 100px;
|
||||||
right: 100px;
|
right: 25px;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -1980,7 +1989,7 @@ input[type=range] {
|
||||||
.streaming-panel {
|
.streaming-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -400px;
|
right: -460px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 380px;
|
width: 380px;
|
||||||
background: rgba(10, 10, 20, 0.98);
|
background: rgba(10, 10, 20, 0.98);
|
||||||
|
|
@ -2587,8 +2596,8 @@ body.listening-active .landscape-prompt {
|
||||||
/* Base Settings Button Fix */
|
/* Base Settings Button Fix */
|
||||||
.keyboard-btn {
|
.keyboard-btn {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 25px;
|
bottom: 250px;
|
||||||
right: 250px;
|
right: 25px;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -2637,7 +2646,7 @@ body.listening-active .landscape-prompt {
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -350px;
|
right: -400px;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
background: rgba(10, 10, 20, 0.98);
|
background: rgba(10, 10, 20, 0.98);
|
||||||
|
|
@ -4773,3 +4782,57 @@ body.listening-active .landscape-prompt {
|
||||||
background: rgba(188, 19, 254, 0.2);
|
background: rgba(188, 19, 254, 0.2);
|
||||||
box-shadow: 0 0 10px rgba(188, 19, 254, 0.4);
|
box-shadow: 0 0 10px rgba(188, 19, 254, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast Notifications */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 30px;
|
||||||
|
left: 30px;
|
||||||
|
z-index: 20000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: toast-in 0.3s ease-out, toast-out 0.3s ease-in 3.7s forwards;
|
||||||
|
max-width: 360px;
|
||||||
|
word-break: break-word;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error {
|
||||||
|
background: rgba(255, 40, 40, 0.9);
|
||||||
|
border-left-color: #ff0000;
|
||||||
|
box-shadow: 0 4px 20px rgba(255, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-success {
|
||||||
|
background: rgba(0, 200, 80, 0.9);
|
||||||
|
border-left-color: #00ff55;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 255, 85, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-info {
|
||||||
|
background: rgba(0, 120, 255, 0.9);
|
||||||
|
border-left-color: var(--primary-cyan);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 243, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { transform: translateX(-100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-out {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(-100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
|
||||||
28
techdj_qt.py
28
techdj_qt.py
|
|
@ -852,21 +852,26 @@ class StreamingWorker(QThread):
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "error",
|
"-loglevel", "error",
|
||||||
|
# Disable input buffering so frames reach the pipe immediately
|
||||||
|
"-fflags", "nobuffer",
|
||||||
"-f", "pulse",
|
"-f", "pulse",
|
||||||
"-i", source,
|
"-i", source,
|
||||||
"-ac", "2",
|
"-ac", "2",
|
||||||
"-ar", "44100",
|
"-ar", "44100",
|
||||||
"-f", "mp3",
|
|
||||||
"-b:a", "128k",
|
"-b:a", "128k",
|
||||||
"-af", "aresample=async=1",
|
"-af", "aresample=async=1",
|
||||||
|
# Flush every packet — critical for low-latency pipe streaming
|
||||||
|
"-flush_packets", "1",
|
||||||
|
"-f", "mp3",
|
||||||
"pipe:1"
|
"pipe:1"
|
||||||
]
|
]
|
||||||
self.ffmpeg_proc = subprocess.Popen(
|
self.ffmpeg_proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=8192
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
||||||
)
|
)
|
||||||
|
|
||||||
while self.is_running and self.ffmpeg_proc.poll() is None:
|
while self.is_running and self.ffmpeg_proc.poll() is None:
|
||||||
chunk = self.ffmpeg_proc.stdout.read(8192)
|
# 4096 bytes ≈ 10 MP3 frames ≈ ~260ms at 128kbps — low-latency chunks
|
||||||
|
chunk = self.ffmpeg_proc.stdout.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
sio = self.sio # Local ref to avoid race with stop_streaming()
|
sio = self.sio # Local ref to avoid race with stop_streaming()
|
||||||
|
|
@ -2105,16 +2110,29 @@ class DJApp(QMainWindow):
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.library_mode == "local":
|
if self.library_mode == "local":
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
# Check for duplicates in local_library
|
||||||
|
if any(Path(track['path']).name.lower() == filename.lower() for track in self.local_library):
|
||||||
|
QMessageBox.information(self, "Import Skipped", f"'{filename}' is already in your local library.")
|
||||||
|
return
|
||||||
|
|
||||||
# Copy to local music folder
|
# Copy to local music folder
|
||||||
dest = self.lib_path / os.path.basename(file_path)
|
dest = self.lib_path / filename
|
||||||
try:
|
try:
|
||||||
shutil.copy2(file_path, dest)
|
shutil.copy2(file_path, dest)
|
||||||
self.status_label.setText(f"Imported: {os.path.basename(file_path)}")
|
self.status_label.setText(f"Imported: {filename}")
|
||||||
self.load_library()
|
self.load_library()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
|
QMessageBox.warning(self, "Import Error", f"Failed to import file: {e}")
|
||||||
else:
|
else:
|
||||||
# Upload to server
|
# Upload to server
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
# Check for duplicates in server_library
|
||||||
|
if hasattr(self, 'server_library'):
|
||||||
|
if any(track['file'].split('/')[-1].lower() == filename.lower() for track in self.server_library):
|
||||||
|
QMessageBox.information(self, "Upload Skipped", f"'{filename}' already exists on the server.")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.status_label.setText("Uploading to server...")
|
self.status_label.setText("Uploading to server...")
|
||||||
base_url = self.get_server_base_url()
|
base_url = self.get_server_base_url()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue