Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a1844ae1b |
24
index.html
24
index.html
@@ -139,17 +139,17 @@
|
|||||||
<!-- Volume Fader -->
|
<!-- Volume Fader -->
|
||||||
<div class="fader-group">
|
<div class="fader-group">
|
||||||
<label>VOLUME</label>
|
<label>VOLUME</label>
|
||||||
<input type="range" orient="vertical" class="volume-fader" min="0" max="100" value="80"
|
<input type="range" orient="vertical" class="volume-fader" min="0" max="100" value="80" data-role="volume" data-deck="A"
|
||||||
oninput="changeVolume('A', this.value)">
|
oninput="changeVolume('A', this.value)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EQ -->
|
<!-- EQ -->
|
||||||
<div class="eq-container">
|
<div class="eq-container">
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="A" data-band="high"
|
||||||
oninput="changeEQ('A', 'high', this.value)"><label>HI</label></div>
|
oninput="changeEQ('A', 'high', this.value)"><label>HI</label></div>
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="A" data-band="mid"
|
||||||
oninput="changeEQ('A', 'mid', this.value)"><label>MID</label></div>
|
oninput="changeEQ('A', 'mid', this.value)"><label>MID</label></div>
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="A" data-band="low"
|
||||||
oninput="changeEQ('A', 'low', this.value)"><label>LO</label></div>
|
oninput="changeEQ('A', 'low', this.value)"><label>LO</label></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -278,17 +278,17 @@
|
|||||||
<!-- Volume Fader -->
|
<!-- Volume Fader -->
|
||||||
<div class="fader-group">
|
<div class="fader-group">
|
||||||
<label>VOLUME</label>
|
<label>VOLUME</label>
|
||||||
<input type="range" orient="vertical" class="volume-fader" min="0" max="100" value="80"
|
<input type="range" orient="vertical" class="volume-fader" min="0" max="100" value="80" data-role="volume" data-deck="B"
|
||||||
oninput="changeVolume('B', this.value)">
|
oninput="changeVolume('B', this.value)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EQ -->
|
<!-- EQ -->
|
||||||
<div class="eq-container">
|
<div class="eq-container">
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="B" data-band="high"
|
||||||
oninput="changeEQ('B', 'high', this.value)"><label>HI</label></div>
|
oninput="changeEQ('B', 'high', this.value)"><label>HI</label></div>
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="B" data-band="mid"
|
||||||
oninput="changeEQ('B', 'mid', this.value)"><label>MID</label></div>
|
oninput="changeEQ('B', 'mid', this.value)"><label>MID</label></div>
|
||||||
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0"
|
<div class="eq-band"><input type="range" orient="vertical" min="-20" max="20" value="0" data-role="eq" data-deck="B" data-band="low"
|
||||||
oninput="changeEQ('B', 'low', this.value)"><label>LO</label></div>
|
oninput="changeEQ('B', 'low', this.value)"><label>LO</label></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -364,6 +364,12 @@
|
|||||||
<div class="broadcast-status" id="broadcast-status">Offline</div>
|
<div class="broadcast-status" id="broadcast-status">Offline</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dj-control-section" id="dj-control-section">
|
||||||
|
<div class="dj-control-status" id="dj-control-status">Controller: Unknown</div>
|
||||||
|
<button class="dj-control-btn" id="take-control-btn" onclick="takeDjControl()">TAKE CONTROL</button>
|
||||||
|
<div class="dj-control-hint" id="dj-control-hint">If another DJ has control, this will be denied.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="listener-info">
|
<div class="listener-info">
|
||||||
<div class="listener-count">
|
<div class="listener-count">
|
||||||
<span class="count-icon">👂</span>
|
<span class="count-icon">👂</span>
|
||||||
@@ -435,7 +441,7 @@
|
|||||||
|
|
||||||
<div class="volume-control">
|
<div class="volume-control">
|
||||||
<label>🔊 Volume</label>
|
<label>🔊 Volume</label>
|
||||||
<input type="range" id="listener-volume" min="0" max="100" value="80"
|
<input type="range" id="listener-volume" min="0" max="200" value="100"
|
||||||
oninput="setListenerVolume(this.value)">
|
oninput="setListenerVolume(this.value)">
|
||||||
</div>
|
</div>
|
||||||
<div class="connection-status" id="connection-status">Connecting...</div>
|
<div class="connection-status" id="connection-status">Connecting...</div>
|
||||||
|
|||||||
353
script.js
353
script.js
@@ -3,7 +3,21 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
// Server-side audio mode (true = server processes audio, false = browser processes)
|
// Server-side audio mode (true = server processes audio, false = browser processes)
|
||||||
const SERVER_SIDE_AUDIO = false;
|
const SERVER_SIDE_AUDIO = true;
|
||||||
|
|
||||||
|
function getDjIdentity() {
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem('techdj_identity');
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = (crypto && typeof crypto.randomUUID === 'function')
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: String(Date.now()) + '-' + String(Math.random()).slice(2);
|
||||||
|
localStorage.setItem('techdj_identity', id);
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
return 'anon';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let audioCtx;
|
let audioCtx;
|
||||||
const decks = {
|
const decks = {
|
||||||
@@ -12,6 +26,8 @@ const decks = {
|
|||||||
playing: false,
|
playing: false,
|
||||||
pausedAt: 0,
|
pausedAt: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
serverPitch: 1.0,
|
||||||
|
lastAnchorWallTime: 0,
|
||||||
localBuffer: null,
|
localBuffer: null,
|
||||||
localSource: null,
|
localSource: null,
|
||||||
gainNode: null,
|
gainNode: null,
|
||||||
@@ -35,6 +51,8 @@ const decks = {
|
|||||||
playing: false,
|
playing: false,
|
||||||
pausedAt: 0,
|
pausedAt: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
serverPitch: 1.0,
|
||||||
|
lastAnchorWallTime: 0,
|
||||||
localBuffer: null,
|
localBuffer: null,
|
||||||
localSource: null,
|
localSource: null,
|
||||||
gainNode: null,
|
gainNode: null,
|
||||||
@@ -499,41 +517,43 @@ function updateTimeDisplays() {
|
|||||||
if (!anyPlaying) return;
|
if (!anyPlaying) return;
|
||||||
|
|
||||||
['A', 'B'].forEach(id => {
|
['A', 'B'].forEach(id => {
|
||||||
if (decks[id].playing && decks[id].localBuffer) {
|
if (!decks[id].playing) return;
|
||||||
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
|
||||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
|
||||||
let current = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
|
|
||||||
|
|
||||||
// Handle Looping wrapping for UI
|
// Server-side mode: viewers may not have AudioContext or localBuffer.
|
||||||
if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
|
if (!decks[id].localBuffer && !SERVER_SIDE_AUDIO) return;
|
||||||
const loopLen = decks[id].loopEnd - decks[id].loopStart;
|
|
||||||
if (current >= decks[id].loopEnd && loopLen > 0) {
|
|
||||||
current = ((current - decks[id].loopStart) % loopLen) + decks[id].loopStart;
|
|
||||||
}
|
|
||||||
} else if (settings[`repeat${id}`]) {
|
|
||||||
// Full song repeat wrapping for UI
|
|
||||||
current = current % decks[id].duration;
|
|
||||||
} else {
|
|
||||||
current = Math.min(current, decks[id].duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('time-current-' + id).textContent = formatTime(current);
|
const current = getCurrentPosition(id);
|
||||||
|
const timerEl = document.getElementById('time-current-' + id);
|
||||||
|
if (timerEl) timerEl.textContent = formatTime(current);
|
||||||
|
|
||||||
// Update playhead
|
// Update playhead (guard against zero duration)
|
||||||
const progress = (current / decks[id].duration) * 100;
|
const dur = Number(decks[id].duration) || 0;
|
||||||
const playhead = document.getElementById('playhead-' + id);
|
const playhead = document.getElementById('playhead-' + id);
|
||||||
if (playhead) playhead.style.left = progress + '%';
|
if (playhead && dur > 0) {
|
||||||
|
const progress = (current / dur) * 100;
|
||||||
|
playhead.style.left = progress + '%';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentPosition(id) {
|
function getCurrentPosition(id) {
|
||||||
if (!decks[id].playing) return decks[id].pausedAt;
|
if (!decks[id].playing) return decks[id].pausedAt;
|
||||||
if (!audioCtx) return decks[id].pausedAt;
|
|
||||||
|
|
||||||
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
// If AudioContext isn't initialized (common in server-side mode / viewers),
|
||||||
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
// still advance position using wall-clock time + server pitch.
|
||||||
let pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
|
let pos;
|
||||||
|
if (!audioCtx) {
|
||||||
|
const now = (performance && typeof performance.now === 'function')
|
||||||
|
? performance.now() / 1000
|
||||||
|
: Date.now() / 1000;
|
||||||
|
const realElapsed = now - (decks[id].lastAnchorWallTime || 0);
|
||||||
|
const playbackRate = Number.isFinite(decks[id].serverPitch) ? decks[id].serverPitch : 1.0;
|
||||||
|
pos = (decks[id].lastAnchorPosition || 0) + (realElapsed * playbackRate);
|
||||||
|
} else {
|
||||||
|
const playbackRate = decks[id].localSource ? decks[id].localSource.playbackRate.value : 1.0;
|
||||||
|
const realElapsed = audioCtx.currentTime - decks[id].lastAnchorTime;
|
||||||
|
pos = decks[id].lastAnchorPosition + (realElapsed * playbackRate);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle wrapping for correct position return
|
// Handle wrapping for correct position return
|
||||||
if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
|
if (decks[id].loopActive && decks[id].loopStart !== null && decks[id].loopEnd !== null) {
|
||||||
@@ -565,6 +585,42 @@ function playDeck(id) {
|
|||||||
decks[id].playing = true;
|
decks[id].playing = true;
|
||||||
const deckEl = document.getElementById('deck-' + id);
|
const deckEl = document.getElementById('deck-' + id);
|
||||||
if (deckEl) deckEl.classList.add('playing');
|
if (deckEl) deckEl.classList.add('playing');
|
||||||
|
|
||||||
|
// Local monitoring (controller only): keep the DJ able to hear while the server streams.
|
||||||
|
if (djController?.youAreController && audioCtx && decks[id].localBuffer) {
|
||||||
|
try {
|
||||||
|
if (decks[id].localSource) {
|
||||||
|
try {
|
||||||
|
decks[id].localSource.stop();
|
||||||
|
} catch (e) { }
|
||||||
|
decks[id].localSource.onended = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = audioCtx.createBufferSource();
|
||||||
|
src.buffer = decks[id].localBuffer;
|
||||||
|
|
||||||
|
if (decks[id].loopActive) {
|
||||||
|
src.loop = true;
|
||||||
|
src.loopStart = decks[id].loopStart || 0;
|
||||||
|
src.loopEnd = decks[id].loopEnd || decks[id].duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
src.connect(decks[id].filters.low);
|
||||||
|
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
||||||
|
const speed = speedSlider ? parseFloat(speedSlider.value) : 1.0;
|
||||||
|
src.playbackRate.value = speed;
|
||||||
|
|
||||||
|
decks[id].localSource = src;
|
||||||
|
decks[id].lastAnchorTime = audioCtx.currentTime;
|
||||||
|
decks[id].lastAnchorPosition = decks[id].pausedAt || 0;
|
||||||
|
|
||||||
|
src.start(0, decks[id].pausedAt || 0);
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Deck ${id}] Local monitor play failed:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Deck ${id}] Play command sent to server`);
|
console.log(`[Deck ${id}] Play command sent to server`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -612,6 +668,16 @@ function pauseDeck(id) {
|
|||||||
socket.emit('audio_pause', { deck: id });
|
socket.emit('audio_pause', { deck: id });
|
||||||
decks[id].playing = false;
|
decks[id].playing = false;
|
||||||
document.getElementById('deck-' + id).classList.remove('playing');
|
document.getElementById('deck-' + id).classList.remove('playing');
|
||||||
|
|
||||||
|
// Local monitoring (controller only)
|
||||||
|
if (djController?.youAreController && decks[id].localSource) {
|
||||||
|
try {
|
||||||
|
decks[id].localSource.stop();
|
||||||
|
decks[id].localSource.onended = null;
|
||||||
|
} catch (e) { }
|
||||||
|
decks[id].localSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Deck ${id}] Pause command sent to server`);
|
console.log(`[Deck ${id}] Pause command sent to server`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1274,6 +1340,21 @@ async function loadFromServer(id, url, title) {
|
|||||||
socket.emit('audio_load_track', { deck: id, filename: filename });
|
socket.emit('audio_load_track', { deck: id, filename: filename });
|
||||||
console.log(`[Deck ${id}] 📡 Load command sent to server: ${filename}`);
|
console.log(`[Deck ${id}] 📡 Load command sent to server: ${filename}`);
|
||||||
|
|
||||||
|
// In server-side mode, if the user is controller and AudioContext isn't initialized,
|
||||||
|
// init it so they can have local monitoring + waveform.
|
||||||
|
if (SERVER_SIDE_AUDIO && djController.youAreController && !audioCtx) {
|
||||||
|
initSystem();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If AudioContext still isn't initialized (e.g., viewer or failed init), skip local loading.
|
||||||
|
if (!audioCtx) {
|
||||||
|
decks[id].currentFile = url;
|
||||||
|
decks[id].localBuffer = null;
|
||||||
|
d.innerText = title.toUpperCase();
|
||||||
|
d.classList.remove('blink');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// We DON'T return here anymore. We continue below to load for the UI.
|
// We DON'T return here anymore. We continue below to load for the UI.
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1527,12 +1608,21 @@ window.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isListenerPort && !isListenerHostname) {
|
if (!isListenerPort && !isListenerHostname) {
|
||||||
|
// In server-side audio mode, allow viewing the UI without initializing AudioContext.
|
||||||
|
if (SERVER_SIDE_AUDIO) {
|
||||||
|
const overlay = document.getElementById('start-overlay');
|
||||||
|
if (overlay) overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Set stream URL to the listener domain
|
// Set stream URL to the listener domain
|
||||||
const streamUrl = window.location.hostname.startsWith('dj.')
|
const streamUrl = window.location.hostname.startsWith('dj.')
|
||||||
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
|
? `${window.location.protocol}//music.${window.location.hostname.split('.').slice(1).join('.')}`
|
||||||
: `${window.location.protocol}//${window.location.hostname}:5001`;
|
: `${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) streamInput.value = streamUrl;
|
||||||
|
|
||||||
|
// Connect early so controller/viewer status is visible and UI can be synced.
|
||||||
|
initSocket();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1543,6 +1633,7 @@ let streamDestination = null;
|
|||||||
let streamProcessor = null;
|
let streamProcessor = null;
|
||||||
let isBroadcasting = false;
|
let isBroadcasting = false;
|
||||||
let autoStartStream = false;
|
let autoStartStream = false;
|
||||||
|
let djController = { controllerActive: false, youAreController: false, controllerSid: null };
|
||||||
let listenerAudioContext = null;
|
let listenerAudioContext = null;
|
||||||
let listenerGainNode = null;
|
let listenerGainNode = null;
|
||||||
let listenerAnalyserNode = null;
|
let listenerAnalyserNode = null;
|
||||||
@@ -1645,6 +1736,30 @@ function initSocket() {
|
|||||||
console.log('✅ Connected to streaming server');
|
console.log('✅ Connected to streaming server');
|
||||||
console.log(` Socket ID: ${socket.id}`);
|
console.log(` Socket ID: ${socket.id}`);
|
||||||
console.log(` Transport: ${socket.io.engine.transport.name}`);
|
console.log(` Transport: ${socket.io.engine.transport.name}`);
|
||||||
|
|
||||||
|
// DJ page reconnect hydration: explicitly request the latest mixer state.
|
||||||
|
// (Server also pushes on connect, but this ensures we never miss it.)
|
||||||
|
const isDjUi = !!document.getElementById('deck-A') || !!document.getElementById('deck-B');
|
||||||
|
if (isDjUi) {
|
||||||
|
socket.emit('dj_identity', {
|
||||||
|
id: getDjIdentity(),
|
||||||
|
auto_reclaim: (localStorage.getItem('techdj_wants_autoreclaim') ?? 'true') === 'true'
|
||||||
|
});
|
||||||
|
socket.emit('get_mixer_status');
|
||||||
|
socket.emit('get_listener_count');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('controller_status', (data) => {
|
||||||
|
djController.controllerActive = !!data.controller_active;
|
||||||
|
djController.youAreController = !!data.you_are_controller;
|
||||||
|
djController.controllerSid = data.controller_sid || null;
|
||||||
|
|
||||||
|
// If we ever become controller, remember that we want auto-reclaim on reconnect.
|
||||||
|
if (djController.youAreController) {
|
||||||
|
try { localStorage.setItem('techdj_wants_autoreclaim', 'true'); } catch { }
|
||||||
|
}
|
||||||
|
updateDjControllerUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect_error', (error) => {
|
socket.on('connect_error', (error) => {
|
||||||
@@ -1662,6 +1777,30 @@ function initSocket() {
|
|||||||
if (el) el.textContent = data.count;
|
if (el) el.textContent = data.count;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DJ-side: keep broadcast UI in sync for multi-DJ viewing.
|
||||||
|
socket.on('stream_status', (data) => {
|
||||||
|
const broadcastBtn = document.getElementById('broadcast-btn');
|
||||||
|
const broadcastText = document.getElementById('broadcast-text');
|
||||||
|
const broadcastStatus = document.getElementById('broadcast-status');
|
||||||
|
|
||||||
|
// If we're not on the DJ page, these elements won't exist.
|
||||||
|
if (!broadcastBtn || !broadcastText || !broadcastStatus) return;
|
||||||
|
|
||||||
|
isBroadcasting = !!data.active;
|
||||||
|
|
||||||
|
if (isBroadcasting) {
|
||||||
|
broadcastBtn.classList.add('active');
|
||||||
|
broadcastText.textContent = 'STOP BROADCAST';
|
||||||
|
broadcastStatus.textContent = '🔴 LIVE';
|
||||||
|
broadcastStatus.classList.add('live');
|
||||||
|
} else {
|
||||||
|
broadcastBtn.classList.remove('active');
|
||||||
|
broadcastText.textContent = 'START BROADCAST';
|
||||||
|
broadcastStatus.textContent = 'Offline';
|
||||||
|
broadcastStatus.classList.remove('live');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('broadcast_started', () => {
|
socket.on('broadcast_started', () => {
|
||||||
console.log('🎙️ Broadcast started notification received');
|
console.log('🎙️ Broadcast started notification received');
|
||||||
// Update relay UI if it's a relay
|
// Update relay UI if it's a relay
|
||||||
@@ -1691,6 +1830,12 @@ function initSocket() {
|
|||||||
|
|
||||||
socket.on('error', (data) => {
|
socket.on('error', (data) => {
|
||||||
console.error('📡 Server error:', data.message);
|
console.error('📡 Server error:', data.message);
|
||||||
|
// Avoid spamming alerts for expected controller lock denials.
|
||||||
|
if (data?.message === 'Control is currently held by another DJ' ||
|
||||||
|
data?.message === 'No active DJ controller. Click Take Control.') {
|
||||||
|
setDjControlHint(data.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(`SERVER ERROR: ${data.message}`);
|
alert(`SERVER ERROR: ${data.message}`);
|
||||||
// Reset relay UI on error
|
// Reset relay UI on error
|
||||||
document.getElementById('start-relay-btn').style.display = 'inline-block';
|
document.getElementById('start-relay-btn').style.display = 'inline-block';
|
||||||
@@ -1701,6 +1846,67 @@ function initSocket() {
|
|||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDjControlHint(text, isError = false) {
|
||||||
|
const hint = document.getElementById('dj-control-hint');
|
||||||
|
if (!hint) return;
|
||||||
|
hint.textContent = text || '';
|
||||||
|
hint.style.color = isError ? '#ff4444' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDjControllerUI() {
|
||||||
|
const statusEl = document.getElementById('dj-control-status');
|
||||||
|
const btn = document.getElementById('take-control-btn');
|
||||||
|
|
||||||
|
if (statusEl) {
|
||||||
|
if (!djController.controllerActive) {
|
||||||
|
statusEl.textContent = 'Controller: None';
|
||||||
|
} else if (djController.youAreController) {
|
||||||
|
statusEl.textContent = 'Controller: YOU';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Controller: Another DJ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = djController.controllerActive;
|
||||||
|
btn.textContent = djController.youAreController ? 'YOU HAVE CONTROL' : 'TAKE CONTROL';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hint text based on latest state.
|
||||||
|
if (!djController.controllerActive) {
|
||||||
|
setDjControlHint('No controller. Click TAKE CONTROL.', false);
|
||||||
|
} else if (djController.youAreController) {
|
||||||
|
setDjControlHint('You have control.', false);
|
||||||
|
} else {
|
||||||
|
setDjControlHint('Another DJ has control. You are in view-only mode.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isViewer = djController.controllerActive && !djController.youAreController;
|
||||||
|
document.body.classList.toggle('viewer-mode', isViewer);
|
||||||
|
|
||||||
|
// Disable broadcast + relay controls for viewers
|
||||||
|
const broadcastBtn = document.getElementById('broadcast-btn');
|
||||||
|
if (broadcastBtn) broadcastBtn.disabled = isViewer;
|
||||||
|
const relayStart = document.getElementById('start-relay-btn');
|
||||||
|
const relayStop = document.getElementById('stop-relay-btn');
|
||||||
|
const relayUrl = document.getElementById('remote-stream-url');
|
||||||
|
if (relayStart) relayStart.disabled = isViewer;
|
||||||
|
if (relayStop) relayStop.disabled = isViewer;
|
||||||
|
if (relayUrl) relayUrl.disabled = isViewer;
|
||||||
|
|
||||||
|
// Disable volume/EQ sliders and crossfader for viewers
|
||||||
|
const controlSliders = document.querySelectorAll('input[data-role="volume"], input[data-role="eq"], #crossfader, .speed-slider');
|
||||||
|
controlSliders.forEach(slider => {
|
||||||
|
slider.disabled = isViewer;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeDjControl() {
|
||||||
|
if (!socket) initSocket();
|
||||||
|
setDjControlHint('Requesting control...', false);
|
||||||
|
socket.emit('take_control');
|
||||||
|
}
|
||||||
|
|
||||||
// Update DJ UI from server status
|
// Update DJ UI from server status
|
||||||
function updateUIFromMixerStatus(status) {
|
function updateUIFromMixerStatus(status) {
|
||||||
if (!status) return;
|
if (!status) return;
|
||||||
@@ -1709,6 +1915,29 @@ function updateUIFromMixerStatus(status) {
|
|||||||
const deckStatus = id === 'A' ? status.deck_a : status.deck_b;
|
const deckStatus = id === 'A' ? status.deck_a : status.deck_b;
|
||||||
if (!deckStatus) return;
|
if (!deckStatus) return;
|
||||||
|
|
||||||
|
// Reflect playing state in the UI (important for reconnects/viewers).
|
||||||
|
const deckEl = document.getElementById('deck-' + id);
|
||||||
|
if (deckEl) {
|
||||||
|
deckEl.classList.toggle('playing', !!deckStatus.playing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror volume + EQ sliders for viewers (and keep controllers visually in sync too).
|
||||||
|
const volSlider = document.querySelector(`input[data-role="volume"][data-deck="${id}"]`);
|
||||||
|
if (volSlider && typeof deckStatus.volume === 'number') {
|
||||||
|
const target = Math.max(0, Math.min(100, Math.round(deckStatus.volume * 100)));
|
||||||
|
if (volSlider.value !== String(target)) volSlider.value = String(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eq = deckStatus.eq || {};
|
||||||
|
['high', 'mid', 'low'].forEach((band) => {
|
||||||
|
const eqSlider = document.querySelector(`input[data-role="eq"][data-deck="${id}"][data-band="${band}"]`);
|
||||||
|
if (!eqSlider) return;
|
||||||
|
const val = Number(eq[band]);
|
||||||
|
if (!Number.isFinite(val)) return;
|
||||||
|
const clamped = Math.max(-20, Math.min(20, val));
|
||||||
|
if (eqSlider.value !== String(clamped)) eqSlider.value = String(clamped);
|
||||||
|
});
|
||||||
|
|
||||||
// Update position (only if not currently dragging the waveform)
|
// Update position (only if not currently dragging the waveform)
|
||||||
const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0);
|
const timeSinceSeek = Date.now() - (decks[id].lastSeekTime || 0);
|
||||||
if (timeSinceSeek < 1500) {
|
if (timeSinceSeek < 1500) {
|
||||||
@@ -1719,8 +1948,8 @@ function updateUIFromMixerStatus(status) {
|
|||||||
// Update playing state
|
// Update playing state
|
||||||
decks[id].playing = deckStatus.playing;
|
decks[id].playing = deckStatus.playing;
|
||||||
|
|
||||||
// Update loaded track if changed
|
// Update loaded track (always sync from server to ensure UI matches authoritative state)
|
||||||
if (deckStatus.filename && (!decks[id].currentFile || decks[id].currentFile !== deckStatus.filename)) {
|
if (deckStatus.filename) {
|
||||||
console.log(`📡 Server synced: Deck ${id} is playing ${deckStatus.filename}`);
|
console.log(`📡 Server synced: Deck ${id} is playing ${deckStatus.filename}`);
|
||||||
decks[id].currentFile = deckStatus.filename;
|
decks[id].currentFile = deckStatus.filename;
|
||||||
decks[id].duration = deckStatus.duration;
|
decks[id].duration = deckStatus.duration;
|
||||||
@@ -1737,9 +1966,18 @@ function updateUIFromMixerStatus(status) {
|
|||||||
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
const speedSlider = document.querySelector(`#deck-${id} .speed-slider`);
|
||||||
if (speedSlider) speedSlider.value = deckStatus.pitch;
|
if (speedSlider) speedSlider.value = deckStatus.pitch;
|
||||||
|
|
||||||
|
// Store pitch so viewers (no AudioContext) can still animate time/playhead.
|
||||||
|
if (typeof deckStatus.pitch === 'number' && Number.isFinite(deckStatus.pitch)) {
|
||||||
|
decks[id].serverPitch = deckStatus.pitch;
|
||||||
|
}
|
||||||
|
|
||||||
// Update anchor for local interpolation
|
// Update anchor for local interpolation
|
||||||
decks[id].lastAnchorPosition = deckStatus.position;
|
decks[id].lastAnchorPosition = deckStatus.position;
|
||||||
decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0;
|
decks[id].lastAnchorTime = audioCtx ? audioCtx.currentTime : 0;
|
||||||
|
const now = (performance && typeof performance.now === 'function')
|
||||||
|
? performance.now() / 1000
|
||||||
|
: Date.now() / 1000;
|
||||||
|
decks[id].lastAnchorWallTime = now;
|
||||||
|
|
||||||
if (!decks[id].playing) {
|
if (!decks[id].playing) {
|
||||||
decks[id].pausedAt = deckStatus.position;
|
decks[id].pausedAt = deckStatus.position;
|
||||||
@@ -1754,9 +1992,10 @@ function updateUIFromMixerStatus(status) {
|
|||||||
if (timer) timer.textContent = formatTime(currentPos);
|
if (timer) timer.textContent = formatTime(currentPos);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update crossfader if changed significantly
|
// Update crossfader UI (do NOT call updateCrossfader here; viewers must not emit).
|
||||||
if (Math.abs(decks.crossfader - status.crossfader) > 1) {
|
const xf = document.getElementById('crossfader');
|
||||||
// We'd update the UI slider here
|
if (xf && typeof status.crossfader === 'number') {
|
||||||
|
xf.value = String(status.crossfader);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1773,7 +2012,8 @@ function toggleStreamingPanel() {
|
|||||||
|
|
||||||
// Toggle broadcast
|
// Toggle broadcast
|
||||||
function toggleBroadcast() {
|
function toggleBroadcast() {
|
||||||
if (!audioCtx) {
|
// In server-side audio mode, broadcast does not require the browser AudioContext.
|
||||||
|
if (!SERVER_SIDE_AUDIO && !audioCtx) {
|
||||||
alert('Please initialize the system first (click INITIALIZE SYSTEM)');
|
alert('Please initialize the system first (click INITIALIZE SYSTEM)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1793,18 +2033,12 @@ function startBroadcast() {
|
|||||||
try {
|
try {
|
||||||
console.log('🎙️ Starting broadcast...');
|
console.log('🎙️ Starting broadcast...');
|
||||||
|
|
||||||
if (!audioCtx) {
|
|
||||||
alert('Please initialize the system first!');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side audio mode
|
// Server-side audio mode
|
||||||
if (SERVER_SIDE_AUDIO) {
|
if (SERVER_SIDE_AUDIO) {
|
||||||
isBroadcasting = true;
|
if (!djController?.youAreController) {
|
||||||
document.getElementById('broadcast-btn').classList.add('active');
|
setDjControlHint('You must TAKE CONTROL before starting the broadcast.', true);
|
||||||
document.getElementById('broadcast-text').textContent = 'STOP BROADCAST';
|
return;
|
||||||
document.getElementById('broadcast-status').textContent = '🔴 LIVE';
|
}
|
||||||
document.getElementById('broadcast-status').classList.add('live');
|
|
||||||
|
|
||||||
if (!socket) initSocket();
|
if (!socket) initSocket();
|
||||||
socket.emit('start_broadcast');
|
socket.emit('start_broadcast');
|
||||||
@@ -1814,6 +2048,11 @@ function startBroadcast() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!audioCtx) {
|
||||||
|
alert('Please initialize the system first!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Browser-side audio mode (original code)
|
// 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;
|
||||||
@@ -2038,10 +2277,11 @@ function stopBroadcast() {
|
|||||||
console.log('🛑 Stopping broadcast...');
|
console.log('🛑 Stopping broadcast...');
|
||||||
|
|
||||||
if (SERVER_SIDE_AUDIO) {
|
if (SERVER_SIDE_AUDIO) {
|
||||||
isBroadcasting = false;
|
if (!djController?.youAreController) {
|
||||||
if (socket) {
|
setDjControlHint('Only the controller can stop the broadcast.', true);
|
||||||
socket.emit('stop_broadcast');
|
return;
|
||||||
}
|
}
|
||||||
|
if (socket) socket.emit('stop_broadcast');
|
||||||
} else {
|
} else {
|
||||||
if (streamProcessor) {
|
if (streamProcessor) {
|
||||||
streamProcessor.stop();
|
streamProcessor.stop();
|
||||||
@@ -2080,11 +2320,13 @@ function stopBroadcast() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update UI
|
// Update UI only in browser-side mode. In server-side mode, rely on stream_status.
|
||||||
document.getElementById('broadcast-btn').classList.remove('active');
|
if (!SERVER_SIDE_AUDIO) {
|
||||||
document.getElementById('broadcast-text').textContent = 'START BROADCAST';
|
document.getElementById('broadcast-btn').classList.remove('active');
|
||||||
document.getElementById('broadcast-status').textContent = 'Offline';
|
document.getElementById('broadcast-text').textContent = 'START BROADCAST';
|
||||||
document.getElementById('broadcast-status').classList.remove('live');
|
document.getElementById('broadcast-status').textContent = 'Offline';
|
||||||
|
document.getElementById('broadcast-status').classList.remove('live');
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Broadcast stopped');
|
console.log('✅ Broadcast stopped');
|
||||||
}
|
}
|
||||||
@@ -2384,7 +2626,7 @@ async function enableListenerAudio() {
|
|||||||
try {
|
try {
|
||||||
if (!listenerGainNode) {
|
if (!listenerGainNode) {
|
||||||
listenerGainNode = listenerAudioContext.createGain();
|
listenerGainNode = listenerAudioContext.createGain();
|
||||||
listenerGainNode.gain.value = 0.8;
|
listenerGainNode.gain.value = 1.0;
|
||||||
listenerGainNode.connect(listenerAudioContext.destination);
|
listenerGainNode.connect(listenerAudioContext.destination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2431,8 +2673,11 @@ async function enableListenerAudio() {
|
|||||||
window.listenerAudio.volume = 1.0;
|
window.listenerAudio.volume = 1.0;
|
||||||
|
|
||||||
const volEl = document.getElementById('listener-volume');
|
const volEl = document.getElementById('listener-volume');
|
||||||
const volValue = volEl ? parseInt(volEl.value, 10) : 80;
|
const volValue = volEl ? parseInt(volEl.value, 10) : 100;
|
||||||
setListenerVolume(Number.isFinite(volValue) ? volValue : 80);
|
const savedVol = localStorage.getItem('listenerVolume');
|
||||||
|
const finalVol = savedVol ? parseInt(savedVol, 10) : volValue;
|
||||||
|
if (volEl) volEl.value = finalVol;
|
||||||
|
setListenerVolume(Number.isFinite(finalVol) ? finalVol : 100);
|
||||||
|
|
||||||
const hasBufferedData = () => {
|
const hasBufferedData = () => {
|
||||||
return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0;
|
return window.listenerAudio.buffered && window.listenerAudio.buffered.length > 0;
|
||||||
@@ -2497,6 +2742,8 @@ function setListenerVolume(value) {
|
|||||||
if (listenerGainNode) {
|
if (listenerGainNode) {
|
||||||
listenerGainNode.gain.value = value / 100;
|
listenerGainNode.gain.value = value / 100;
|
||||||
}
|
}
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('listenerVolume', value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load auto-start preference
|
// Load auto-start preference
|
||||||
|
|||||||
510
server.py
510
server.py
@@ -38,16 +38,87 @@ DJ_AUTH_ENABLED = bool(DJ_PANEL_PASSWORD)
|
|||||||
# Relay State
|
# Relay State
|
||||||
broadcast_state = {
|
broadcast_state = {
|
||||||
'active': False,
|
'active': False,
|
||||||
|
'remote_relay': False,
|
||||||
|
'server_mix': False,
|
||||||
}
|
}
|
||||||
listener_sids = set()
|
listener_sids = set()
|
||||||
dj_sids = set()
|
dj_sids = set()
|
||||||
|
|
||||||
|
# DJ identity mapping (for auto-reclaim)
|
||||||
|
dj_identity_by_sid: dict[str, str] = {}
|
||||||
|
last_controller_identity: str | None = None
|
||||||
|
last_controller_released_at: float = 0.0
|
||||||
|
|
||||||
|
# === Multi-DJ controller lock (one active controller at a time) ===
|
||||||
|
active_controller_sid = None
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_controller_status(to_sid: str | None = None):
|
||||||
|
payload = {
|
||||||
|
'controller_active': active_controller_sid is not None,
|
||||||
|
'controller_sid': active_controller_sid,
|
||||||
|
}
|
||||||
|
if to_sid:
|
||||||
|
payload['you_are_controller'] = (to_sid == active_controller_sid)
|
||||||
|
dj_socketio.emit('controller_status', payload, to=to_sid, namespace='/')
|
||||||
|
else:
|
||||||
|
# Send individualized payload so each DJ can reliably know whether they are the controller.
|
||||||
|
for sid in list(dj_sids):
|
||||||
|
dj_socketio.emit(
|
||||||
|
'controller_status',
|
||||||
|
{
|
||||||
|
**payload,
|
||||||
|
'you_are_controller': (sid == active_controller_sid),
|
||||||
|
},
|
||||||
|
to=sid,
|
||||||
|
namespace='/',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deny_if_not_controller() -> bool:
|
||||||
|
"""Returns True if caller is NOT the controller and was denied."""
|
||||||
|
if active_controller_sid is None:
|
||||||
|
dj_socketio.emit('error', {'message': 'No active DJ controller. Click Take Control.'}, to=request.sid)
|
||||||
|
return True
|
||||||
|
if request.sid != active_controller_sid:
|
||||||
|
dj_socketio.emit('error', {'message': 'Control is currently held by another DJ'}, to=request.sid)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sid_identity(sid: str) -> str | None:
|
||||||
|
return dj_identity_by_sid.get(sid)
|
||||||
|
|
||||||
|
|
||||||
|
# === Server-side mixer state (authoritative UI sync) ===
|
||||||
|
def _default_deck_state():
|
||||||
|
return {
|
||||||
|
'filename': None,
|
||||||
|
'duration': 0.0,
|
||||||
|
'position': 0.0,
|
||||||
|
'playing': False,
|
||||||
|
'pitch': 1.0,
|
||||||
|
'volume': 0.8,
|
||||||
|
'eq': {'low': 0.0, 'mid': 0.0, 'high': 0.0},
|
||||||
|
# Internal anchors for time interpolation
|
||||||
|
'_started_at': None,
|
||||||
|
'_started_pos': 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
mixer_state = {
|
||||||
|
'deck_a': _default_deck_state(),
|
||||||
|
'deck_b': _default_deck_state(),
|
||||||
|
'crossfader': 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# === Optional MP3 fallback stream (server-side transcoding) ===
|
# === Optional MP3 fallback stream (server-side transcoding) ===
|
||||||
# This allows listeners on browsers that don't support WebM/Opus via MediaSource
|
# This allows listeners on browsers that don't support WebM/Opus via MediaSource
|
||||||
# (notably some Safari / locked-down environments) to still hear the stream.
|
# (notably some Safari / locked-down environments) to still hear the stream.
|
||||||
_ffmpeg_proc = None
|
_ffmpeg_proc = None
|
||||||
_ffmpeg_in_q = queue.Queue(maxsize=200)
|
_ffmpeg_in_q = eventlet.queue.LightQueue(maxsize=200)
|
||||||
_mp3_clients = set() # set[queue.Queue]
|
_mp3_clients = set() # set[eventlet.queue.LightQueue]
|
||||||
_mp3_lock = threading.Lock()
|
_mp3_lock = threading.Lock()
|
||||||
_transcode_threads_started = False
|
_transcode_threads_started = False
|
||||||
_transcoder_bytes_out = 0
|
_transcoder_bytes_out = 0
|
||||||
@@ -55,6 +126,9 @@ _transcoder_last_error = None
|
|||||||
_last_audio_chunk_ts = 0.0
|
_last_audio_chunk_ts = 0.0
|
||||||
_remote_stream_url = None # For relaying remote streams
|
_remote_stream_url = None # For relaying remote streams
|
||||||
|
|
||||||
|
_mix_restart_timer = None
|
||||||
|
_mix_restart_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _start_transcoder_if_needed():
|
def _start_transcoder_if_needed():
|
||||||
global _ffmpeg_proc, _transcode_threads_started
|
global _ffmpeg_proc, _transcode_threads_started
|
||||||
@@ -62,12 +136,105 @@ def _start_transcoder_if_needed():
|
|||||||
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
|
if _ffmpeg_proc is not None and _ffmpeg_proc.poll() is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if _remote_stream_url:
|
def _safe_float(val, default=0.0):
|
||||||
# Remote relay mode: input from URL
|
try:
|
||||||
|
return float(val)
|
||||||
|
except Exception:
|
||||||
|
return float(default)
|
||||||
|
|
||||||
|
def _clamp(val, lo, hi):
|
||||||
|
return max(lo, min(hi, val))
|
||||||
|
|
||||||
|
def _deck_runtime_position(d: dict) -> float:
|
||||||
|
if not d.get('playing'):
|
||||||
|
return _safe_float(d.get('position'), 0.0)
|
||||||
|
started_at = d.get('_started_at')
|
||||||
|
started_pos = _safe_float(d.get('_started_pos'), _safe_float(d.get('position'), 0.0))
|
||||||
|
if started_at is None:
|
||||||
|
return _safe_float(d.get('position'), 0.0)
|
||||||
|
pitch = _safe_float(d.get('pitch'), 1.0)
|
||||||
|
return max(0.0, started_pos + (time.time() - _safe_float(started_at)) * pitch)
|
||||||
|
|
||||||
|
def _ffmpeg_atempo_chain(speed: float) -> str:
|
||||||
|
# atempo supports ~[0.5, 2.0] per filter; clamp for now
|
||||||
|
s = _clamp(speed, 0.5, 2.0)
|
||||||
|
return f"atempo={s:.4f}"
|
||||||
|
|
||||||
|
def _build_server_mix_cmd() -> list[str]:
|
||||||
|
# Always include an infinite silent input so ffmpeg never exits.
|
||||||
|
silence_src = 'anullsrc=channel_layout=stereo:sample_rate=44100'
|
||||||
|
|
||||||
|
deck_a = mixer_state['deck_a']
|
||||||
|
deck_b = mixer_state['deck_b']
|
||||||
|
cf = int(mixer_state.get('crossfader', 50))
|
||||||
|
|
||||||
|
# Volumes: crossfader scaling * per-deck volume
|
||||||
|
cf_a = (100 - cf) / 100.0
|
||||||
|
cf_b = cf / 100.0
|
||||||
|
vol_a = _clamp(_safe_float(deck_a.get('volume'), 0.8) * cf_a, 0.0, 1.5)
|
||||||
|
vol_b = _clamp(_safe_float(deck_b.get('volume'), 0.8) * cf_b, 0.0, 1.5)
|
||||||
|
|
||||||
|
# Source selection
|
||||||
|
def _input_args(deck: dict) -> list[str]:
|
||||||
|
fn = deck.get('filename')
|
||||||
|
if fn and deck.get('playing'):
|
||||||
|
pos = _deck_runtime_position(deck)
|
||||||
|
path = os.path.join(os.getcwd(), fn)
|
||||||
|
return ['-re', '-ss', f"{pos:.3f}", '-i', path]
|
||||||
|
return ['-re', '-f', 'lavfi', '-i', silence_src]
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
'-loglevel', 'error',
|
'-loglevel', 'error',
|
||||||
|
*_input_args(deck_a),
|
||||||
|
*_input_args(deck_b),
|
||||||
|
'-re', '-f', 'lavfi', '-i', silence_src,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filters per deck
|
||||||
|
def _deck_filters(deck: dict, vol: float) -> str:
|
||||||
|
parts = [f"volume={vol:.4f}"]
|
||||||
|
|
||||||
|
eq = deck.get('eq') or {}
|
||||||
|
low = _clamp(_safe_float(eq.get('low'), 0.0), -20.0, 20.0)
|
||||||
|
mid = _clamp(_safe_float(eq.get('mid'), 0.0), -20.0, 20.0)
|
||||||
|
high = _clamp(_safe_float(eq.get('high'), 0.0), -20.0, 20.0)
|
||||||
|
# Use octave width (o) so it's somewhat musical.
|
||||||
|
if abs(low) > 0.001:
|
||||||
|
parts.append(f"equalizer=f=320:width_type=o:width=1:g={low:.2f}")
|
||||||
|
if abs(mid) > 0.001:
|
||||||
|
parts.append(f"equalizer=f=1000:width_type=o:width=1:g={mid:.2f}")
|
||||||
|
if abs(high) > 0.001:
|
||||||
|
parts.append(f"equalizer=f=3200:width_type=o:width=1:g={high:.2f}")
|
||||||
|
return ','.join(parts)
|
||||||
|
|
||||||
|
fc = (
|
||||||
|
f"[0:a]{_deck_filters(deck_a, vol_a)}[a0];"
|
||||||
|
f"[1:a]{_deck_filters(deck_b, vol_b)}[a1];"
|
||||||
|
f"[2:a]volume=0[sil];"
|
||||||
|
f"[a0][a1][sil]amix=inputs=3:duration=longest:dropout_transition=0[m]"
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd += [
|
||||||
|
'-filter_complex', fc,
|
||||||
|
'-map', '[m]',
|
||||||
|
'-vn',
|
||||||
|
'-ac', '2',
|
||||||
|
'-ar', '44100',
|
||||||
|
'-acodec', 'libmp3lame',
|
||||||
|
'-b:a', '192k',
|
||||||
|
'-f', 'mp3',
|
||||||
|
'pipe:1',
|
||||||
|
]
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
if _remote_stream_url:
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg',
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-re',
|
||||||
'-i', _remote_stream_url,
|
'-i', _remote_stream_url,
|
||||||
'-vn',
|
'-vn',
|
||||||
'-acodec', 'libmp3lame',
|
'-acodec', 'libmp3lame',
|
||||||
@@ -75,8 +242,10 @@ def _start_transcoder_if_needed():
|
|||||||
'-f', 'mp3',
|
'-f', 'mp3',
|
||||||
'pipe:1',
|
'pipe:1',
|
||||||
]
|
]
|
||||||
|
elif broadcast_state.get('server_mix'):
|
||||||
|
cmd = _build_server_mix_cmd()
|
||||||
else:
|
else:
|
||||||
# Local broadcast mode: input from pipe
|
# Local browser-broadcast mode: input from pipe
|
||||||
cmd = [
|
cmd = [
|
||||||
'ffmpeg',
|
'ffmpeg',
|
||||||
'-hide_banner',
|
'-hide_banner',
|
||||||
@@ -89,10 +258,13 @@ def _start_transcoder_if_needed():
|
|||||||
'pipe:1',
|
'pipe:1',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
needs_stdin = (not _remote_stream_url) and (not broadcast_state.get('server_mix'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if _remote_stream_url:
|
if needs_stdin:
|
||||||
_ffmpeg_proc = subprocess.Popen(
|
_ffmpeg_proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
@@ -100,7 +272,6 @@ def _start_transcoder_if_needed():
|
|||||||
else:
|
else:
|
||||||
_ffmpeg_proc = subprocess.Popen(
|
_ffmpeg_proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=0,
|
bufsize=0,
|
||||||
@@ -110,14 +281,15 @@ def _start_transcoder_if_needed():
|
|||||||
print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled')
|
print('⚠️ ffmpeg not found; /stream.mp3 fallback disabled')
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({ "remote relay" if _remote_stream_url else "local broadcast" })')
|
mode = 'remote relay' if _remote_stream_url else ('server mix' if broadcast_state.get('server_mix') else 'local broadcast')
|
||||||
|
print(f'🎛️ ffmpeg transcoder started for /stream.mp3 ({mode})')
|
||||||
|
|
||||||
def _writer():
|
def _writer():
|
||||||
global _transcoder_last_error
|
global _transcoder_last_error
|
||||||
while True:
|
while True:
|
||||||
chunk = _ffmpeg_in_q.get()
|
chunk = _ffmpeg_in_q.get()
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
break
|
continue
|
||||||
proc = _ffmpeg_proc
|
proc = _ffmpeg_proc
|
||||||
if proc is None or proc.stdin is None:
|
if proc is None or proc.stdin is None:
|
||||||
continue
|
continue
|
||||||
@@ -152,18 +324,14 @@ def _start_transcoder_if_needed():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if not _transcode_threads_started:
|
if not _transcode_threads_started:
|
||||||
threading.Thread(target=_writer, daemon=True).start()
|
eventlet.spawn_n(_writer)
|
||||||
threading.Thread(target=_reader, daemon=True).start()
|
|
||||||
_transcode_threads_started = True
|
_transcode_threads_started = True
|
||||||
|
|
||||||
|
eventlet.spawn_n(_reader)
|
||||||
|
|
||||||
|
|
||||||
def _stop_transcoder():
|
def _stop_transcoder():
|
||||||
global _ffmpeg_proc
|
global _ffmpeg_proc
|
||||||
try:
|
|
||||||
_ffmpeg_in_q.put_nowait(None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
proc = _ffmpeg_proc
|
proc = _ffmpeg_proc
|
||||||
_ffmpeg_proc = None
|
_ffmpeg_proc = None
|
||||||
if proc is None:
|
if proc is None:
|
||||||
@@ -174,9 +342,30 @@ def _stop_transcoder():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_mix_restart():
|
||||||
|
global _mix_restart_timer
|
||||||
|
if not broadcast_state.get('active') or not broadcast_state.get('server_mix'):
|
||||||
|
return
|
||||||
|
if _remote_stream_url:
|
||||||
|
return
|
||||||
|
with _mix_restart_lock:
|
||||||
|
if _mix_restart_timer is not None:
|
||||||
|
try:
|
||||||
|
_mix_restart_timer.cancel()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _do():
|
||||||
|
# Restart ffmpeg so changes apply.
|
||||||
|
_stop_transcoder()
|
||||||
|
_start_transcoder_if_needed()
|
||||||
|
|
||||||
|
_mix_restart_timer = eventlet.spawn_after(0.20, _do)
|
||||||
|
|
||||||
|
|
||||||
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 or _remote_stream_url:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None or _remote_stream_url or broadcast_state.get('server_mix'):
|
||||||
return
|
return
|
||||||
_last_audio_chunk_ts = time.time()
|
_last_audio_chunk_ts = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -313,7 +502,7 @@ def setup_shared_routes(app):
|
|||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
|
return jsonify({"success": False, "error": "MP3 stream not available"}), 503
|
||||||
|
|
||||||
client_q: queue.Queue = queue.Queue(maxsize=200)
|
client_q = eventlet.queue.LightQueue(maxsize=200)
|
||||||
with _mp3_lock:
|
with _mp3_lock:
|
||||||
_mp3_clients.add(client_q)
|
_mp3_clients.add(client_q)
|
||||||
|
|
||||||
@@ -473,39 +662,120 @@ def dj_connect():
|
|||||||
print(f"🎧 DJ connected: {request.sid}")
|
print(f"🎧 DJ connected: {request.sid}")
|
||||||
dj_sids.add(request.sid)
|
dj_sids.add(request.sid)
|
||||||
|
|
||||||
|
# Send controller status + current stream status to the new DJ
|
||||||
|
_emit_controller_status(to_sid=request.sid)
|
||||||
|
dj_socketio.emit('mixer_status', {
|
||||||
|
'deck_a': _public_deck_state(mixer_state['deck_a']),
|
||||||
|
'deck_b': _public_deck_state(mixer_state['deck_b']),
|
||||||
|
'crossfader': mixer_state.get('crossfader', 50),
|
||||||
|
}, to=request.sid, namespace='/')
|
||||||
|
dj_socketio.emit('stream_status', {
|
||||||
|
'active': broadcast_state.get('active', False),
|
||||||
|
'remote_relay': bool(broadcast_state.get('remote_relay', False)),
|
||||||
|
'server_mix': bool(broadcast_state.get('server_mix', False)),
|
||||||
|
}, to=request.sid, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('disconnect')
|
@dj_socketio.on('disconnect')
|
||||||
def dj_disconnect():
|
def dj_disconnect():
|
||||||
dj_sids.discard(request.sid)
|
dj_sids.discard(request.sid)
|
||||||
|
ident = dj_identity_by_sid.get(request.sid)
|
||||||
|
dj_identity_by_sid.pop(request.sid, None)
|
||||||
|
global active_controller_sid
|
||||||
|
was_controller = (request.sid == active_controller_sid)
|
||||||
|
if was_controller:
|
||||||
|
global last_controller_identity, last_controller_released_at
|
||||||
|
last_controller_identity = ident
|
||||||
|
last_controller_released_at = time.time()
|
||||||
|
active_controller_sid = None
|
||||||
|
print("🧑✈️ DJ controller disconnected; control released")
|
||||||
|
_emit_controller_status()
|
||||||
print("⚠️ DJ disconnected - broadcast will continue until manually stopped")
|
print("⚠️ DJ disconnected - broadcast will continue until manually stopped")
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('dj_identity')
|
||||||
|
def dj_identity(data):
|
||||||
|
"""Associate a stable client identity with this socket and optionally auto-reclaim control."""
|
||||||
|
global active_controller_sid
|
||||||
|
ident = (data or {}).get('id')
|
||||||
|
auto_reclaim = bool((data or {}).get('auto_reclaim'))
|
||||||
|
if not ident or not isinstance(ident, str):
|
||||||
|
return
|
||||||
|
dj_identity_by_sid[request.sid] = ident
|
||||||
|
|
||||||
|
# Auto-reclaim: only if no controller exists AND you were the last controller.
|
||||||
|
if auto_reclaim and active_controller_sid is None:
|
||||||
|
if last_controller_identity and ident == last_controller_identity:
|
||||||
|
active_controller_sid = request.sid
|
||||||
|
print(f"🧑✈️ Auto-reclaimed control for identity: {ident}")
|
||||||
|
_emit_controller_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('take_control')
|
||||||
|
def dj_take_control():
|
||||||
|
global active_controller_sid
|
||||||
|
if active_controller_sid is not None and active_controller_sid != request.sid:
|
||||||
|
dj_socketio.emit('error', {'message': 'Control is currently held by another DJ'}, to=request.sid)
|
||||||
|
_emit_controller_status(to_sid=request.sid)
|
||||||
|
return
|
||||||
|
active_controller_sid = request.sid
|
||||||
|
global last_controller_identity
|
||||||
|
last_controller_identity = _sid_identity(request.sid)
|
||||||
|
print(f"🧑✈️ DJ took control: {request.sid}")
|
||||||
|
_emit_controller_status()
|
||||||
|
|
||||||
def stop_broadcast_after_timeout():
|
def stop_broadcast_after_timeout():
|
||||||
"""No longer used - broadcasts don't auto-stop"""
|
"""No longer used - broadcasts don't auto-stop"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@dj_socketio.on('start_broadcast')
|
@dj_socketio.on('start_broadcast')
|
||||||
def dj_start(data=None):
|
def dj_start(data=None):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
global _remote_stream_url
|
||||||
|
_remote_stream_url = None
|
||||||
broadcast_state['active'] = True
|
broadcast_state['active'] = True
|
||||||
|
broadcast_state['remote_relay'] = False
|
||||||
|
broadcast_state['server_mix'] = True
|
||||||
session['is_dj'] = True
|
session['is_dj'] = True
|
||||||
print("🎙️ Broadcast -> ACTIVE")
|
print("🎙️ Broadcast -> ACTIVE")
|
||||||
|
|
||||||
_start_transcoder_if_needed()
|
_start_transcoder_if_needed()
|
||||||
|
|
||||||
|
dj_socketio.emit('stream_status', {
|
||||||
|
'active': True,
|
||||||
|
'remote_relay': False,
|
||||||
|
'server_mix': True,
|
||||||
|
}, namespace='/')
|
||||||
|
|
||||||
listener_socketio.emit('broadcast_started', namespace='/')
|
listener_socketio.emit('broadcast_started', namespace='/')
|
||||||
listener_socketio.emit('stream_status', {'active': True}, namespace='/')
|
listener_socketio.emit('stream_status', {
|
||||||
|
'active': True,
|
||||||
|
'remote_relay': bool(broadcast_state.get('remote_relay', False)),
|
||||||
|
'server_mix': bool(broadcast_state.get('server_mix', False)),
|
||||||
|
}, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('stop_broadcast')
|
@dj_socketio.on('stop_broadcast')
|
||||||
def dj_stop():
|
def dj_stop():
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
broadcast_state['active'] = False
|
broadcast_state['active'] = False
|
||||||
session['is_dj'] = False
|
session['is_dj'] = False
|
||||||
print("🛑 DJ stopped broadcasting")
|
print("🛑 DJ stopped broadcasting")
|
||||||
|
|
||||||
|
broadcast_state['remote_relay'] = False
|
||||||
|
broadcast_state['server_mix'] = False
|
||||||
|
|
||||||
_stop_transcoder()
|
_stop_transcoder()
|
||||||
|
|
||||||
|
dj_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
listener_socketio.emit('broadcast_stopped', namespace='/')
|
listener_socketio.emit('broadcast_stopped', namespace='/')
|
||||||
listener_socketio.emit('stream_status', {'active': False}, namespace='/')
|
listener_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('start_remote_relay')
|
@dj_socketio.on('start_remote_relay')
|
||||||
def dj_start_remote_relay(data):
|
def dj_start_remote_relay(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
global _remote_stream_url
|
global _remote_stream_url
|
||||||
url = data.get('url', '').strip()
|
url = data.get('url', '').strip()
|
||||||
if not url:
|
if not url:
|
||||||
@@ -519,32 +789,40 @@ def dj_start_remote_relay(data):
|
|||||||
_remote_stream_url = url
|
_remote_stream_url = url
|
||||||
broadcast_state['active'] = True
|
broadcast_state['active'] = True
|
||||||
broadcast_state['remote_relay'] = True
|
broadcast_state['remote_relay'] = True
|
||||||
|
broadcast_state['server_mix'] = False
|
||||||
session['is_dj'] = True
|
session['is_dj'] = True
|
||||||
print(f"🔗 Starting remote relay from: {url}")
|
print(f"🔗 Starting remote relay from: {url}")
|
||||||
|
|
||||||
_start_transcoder_if_needed()
|
_start_transcoder_if_needed()
|
||||||
|
|
||||||
|
dj_socketio.emit('stream_status', {'active': True, 'remote_relay': True, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
listener_socketio.emit('broadcast_started', namespace='/')
|
listener_socketio.emit('broadcast_started', namespace='/')
|
||||||
listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True}, namespace='/')
|
listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('stop_remote_relay')
|
@dj_socketio.on('stop_remote_relay')
|
||||||
def dj_stop_remote_relay():
|
def dj_stop_remote_relay():
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
global _remote_stream_url
|
global _remote_stream_url
|
||||||
_remote_stream_url = None
|
_remote_stream_url = None
|
||||||
broadcast_state['active'] = False
|
broadcast_state['active'] = False
|
||||||
broadcast_state['remote_relay'] = False
|
broadcast_state['remote_relay'] = False
|
||||||
|
broadcast_state['server_mix'] = False
|
||||||
session['is_dj'] = False
|
session['is_dj'] = False
|
||||||
print("🛑 Remote relay stopped")
|
print("🛑 Remote relay stopped")
|
||||||
|
|
||||||
_stop_transcoder()
|
_stop_transcoder()
|
||||||
|
|
||||||
|
dj_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
listener_socketio.emit('broadcast_stopped', namespace='/')
|
listener_socketio.emit('broadcast_stopped', namespace='/')
|
||||||
listener_socketio.emit('stream_status', {'active': False}, namespace='/')
|
listener_socketio.emit('stream_status', {'active': False, 'remote_relay': False, 'server_mix': False}, namespace='/')
|
||||||
|
|
||||||
@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.
|
# MP3-only mode: do not relay raw chunks to listeners; feed transcoder only.
|
||||||
if broadcast_state['active']:
|
if broadcast_state['active'] and not broadcast_state.get('server_mix'):
|
||||||
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
|
# Ensure MP3 fallback transcoder is running (if ffmpeg is installed)
|
||||||
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
_start_transcoder_if_needed()
|
_start_transcoder_if_needed()
|
||||||
@@ -589,7 +867,11 @@ def listener_join():
|
|||||||
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
listener_socketio.emit('listener_count', {'count': count}, namespace='/')
|
||||||
dj_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.get('active', False),
|
||||||
|
'remote_relay': bool(broadcast_state.get('remote_relay', False)),
|
||||||
|
'server_mix': bool(broadcast_state.get('server_mix', False)),
|
||||||
|
})
|
||||||
|
|
||||||
@listener_socketio.on('get_listener_count')
|
@listener_socketio.on('get_listener_count')
|
||||||
def listener_get_count():
|
def listener_get_count():
|
||||||
@@ -598,7 +880,185 @@ def listener_get_count():
|
|||||||
# DJ Panel Routes (No engine commands needed in local mode)
|
# DJ Panel Routes (No engine commands needed in local mode)
|
||||||
@dj_socketio.on('get_mixer_status')
|
@dj_socketio.on('get_mixer_status')
|
||||||
def get_mixer_status():
|
def get_mixer_status():
|
||||||
pass
|
emit('mixer_status', {
|
||||||
|
'deck_a': _public_deck_state(mixer_state['deck_a']),
|
||||||
|
'deck_b': _public_deck_state(mixer_state['deck_b']),
|
||||||
|
'crossfader': mixer_state.get('crossfader', 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ffprobe_duration_seconds(path: str) -> float:
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', path],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return float(out.decode('utf-8').strip() or 0.0)
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _deck_key(deck: str) -> str:
|
||||||
|
return 'deck_a' if deck == 'A' else 'deck_b'
|
||||||
|
|
||||||
|
|
||||||
|
def _deck_current_position(d: dict) -> float:
|
||||||
|
if not d.get('playing'):
|
||||||
|
return float(d.get('position') or 0.0)
|
||||||
|
started_at = d.get('_started_at')
|
||||||
|
started_pos = float(d.get('_started_pos') or 0.0)
|
||||||
|
if started_at is None:
|
||||||
|
return float(d.get('position') or 0.0)
|
||||||
|
pitch = float(d.get('pitch') or 1.0)
|
||||||
|
return max(0.0, started_pos + (time.time() - float(started_at)) * pitch)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_deck_state(d: dict) -> dict:
|
||||||
|
out = {k: v for k, v in d.items() if not k.startswith('_')}
|
||||||
|
out['position'] = _deck_current_position(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_mixer_status():
|
||||||
|
dj_socketio.emit('mixer_status', {
|
||||||
|
'deck_a': _public_deck_state(mixer_state['deck_a']),
|
||||||
|
'deck_b': _public_deck_state(mixer_state['deck_b']),
|
||||||
|
'crossfader': mixer_state.get('crossfader', 50),
|
||||||
|
}, namespace='/')
|
||||||
|
_schedule_mix_restart()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_load_track')
|
||||||
|
def audio_load_track(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
filename = (data or {}).get('filename')
|
||||||
|
if deck not in ('A', 'B') or not filename:
|
||||||
|
dj_socketio.emit('error', {'message': 'Invalid load request'}, to=request.sid)
|
||||||
|
return
|
||||||
|
path = os.path.join(MUSIC_FOLDER, filename)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
dj_socketio.emit('error', {'message': f'Track not found: {filename}'}, to=request.sid)
|
||||||
|
return
|
||||||
|
|
||||||
|
key = _deck_key(deck)
|
||||||
|
d = mixer_state[key]
|
||||||
|
d['filename'] = f"music/{filename}"
|
||||||
|
d['duration'] = _ffprobe_duration_seconds(path)
|
||||||
|
d['position'] = 0.0
|
||||||
|
d['playing'] = False
|
||||||
|
d['_started_at'] = None
|
||||||
|
d['_started_pos'] = 0.0
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_play')
|
||||||
|
def audio_play(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
if not d.get('filename'):
|
||||||
|
dj_socketio.emit('error', {'message': f'No track loaded on Deck {deck}'}, to=request.sid)
|
||||||
|
return
|
||||||
|
# Anchor for interpolation
|
||||||
|
d['position'] = _deck_current_position(d)
|
||||||
|
d['playing'] = True
|
||||||
|
d['_started_at'] = time.time()
|
||||||
|
d['_started_pos'] = float(d['position'])
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_pause')
|
||||||
|
def audio_pause(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
d['position'] = _deck_current_position(d)
|
||||||
|
d['playing'] = False
|
||||||
|
d['_started_at'] = None
|
||||||
|
d['_started_pos'] = float(d['position'])
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_seek')
|
||||||
|
def audio_seek(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
pos = float((data or {}).get('position') or 0.0)
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
d['position'] = max(0.0, pos)
|
||||||
|
if d.get('playing'):
|
||||||
|
d['_started_at'] = time.time()
|
||||||
|
d['_started_pos'] = float(d['position'])
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_set_volume')
|
||||||
|
def audio_set_volume(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
vol = float((data or {}).get('volume') or 0.0)
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
d['volume'] = max(0.0, min(1.0, vol))
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_set_pitch')
|
||||||
|
def audio_set_pitch(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
pitch = float((data or {}).get('pitch') or 1.0)
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
d['pitch'] = max(0.5, min(2.0, pitch))
|
||||||
|
# Re-anchor so position interpolation is consistent
|
||||||
|
d['position'] = _deck_current_position(d)
|
||||||
|
if d.get('playing'):
|
||||||
|
d['_started_at'] = time.time()
|
||||||
|
d['_started_pos'] = float(d['position'])
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_set_eq')
|
||||||
|
def audio_set_eq(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
deck = (data or {}).get('deck')
|
||||||
|
band = (data or {}).get('band')
|
||||||
|
value = float((data or {}).get('value') or 0.0)
|
||||||
|
if deck not in ('A', 'B'):
|
||||||
|
return
|
||||||
|
if band not in ('low', 'mid', 'high'):
|
||||||
|
return
|
||||||
|
d = mixer_state[_deck_key(deck)]
|
||||||
|
eq = d.get('eq') or {'low': 0.0, 'mid': 0.0, 'high': 0.0}
|
||||||
|
eq[band] = max(-20.0, min(20.0, value))
|
||||||
|
d['eq'] = eq
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
|
|
||||||
|
@dj_socketio.on('audio_set_crossfader')
|
||||||
|
def audio_set_crossfader(data):
|
||||||
|
if _deny_if_not_controller():
|
||||||
|
return
|
||||||
|
val = int((data or {}).get('value') or 50)
|
||||||
|
mixer_state['crossfader'] = max(0, min(100, val))
|
||||||
|
_broadcast_mixer_status()
|
||||||
|
|
||||||
@dj_socketio.on('audio_sync_queue')
|
@dj_socketio.on('audio_sync_queue')
|
||||||
def audio_sync_queue(data):
|
def audio_sync_queue(data):
|
||||||
|
|||||||
60
style.css
60
style.css
@@ -2177,6 +2177,66 @@ input[type=range] {
|
|||||||
text-shadow: 0 0 10px var(--secondary-magenta);
|
text-shadow: 0 0 10px var(--secondary-magenta);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DJ Controller Lock UI */
|
||||||
|
.dj-control-section {
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(0, 243, 255, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dj-control-status {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dj-control-btn {
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(145deg, #1a1a1a, #0a0a0a);
|
||||||
|
border: 2px solid var(--primary-cyan);
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 243, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dj-control-btn:hover {
|
||||||
|
background: linear-gradient(145deg, #2a2a2a, #1a1a1a);
|
||||||
|
box-shadow: 0 0 25px rgba(0, 243, 255, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dj-control-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dj-control-hint {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewer mode: dim + prevent accidental interactions */
|
||||||
|
body.viewer-mode .deck,
|
||||||
|
body.viewer-mode .mixer-section,
|
||||||
|
body.viewer-mode .library-section,
|
||||||
|
body.viewer-mode #settings-panel {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Listener Info */
|
/* Listener Info */
|
||||||
.listener-info {
|
.listener-info {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
|||||||
Reference in New Issue
Block a user