Add custom keyboard mapping and remove remote stream relay
This commit is contained in:
parent
508b93125d
commit
405efb6472
17
index.html
17
index.html
|
|
@ -369,15 +369,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="remote-relay-section">
|
|
||||||
<h4>🔗 Remote Stream Relay</h4>
|
|
||||||
<div class="relay-controls">
|
|
||||||
<input type="text" id="remote-stream-url" placeholder="Paste remote stream URL (e.g., http://remote.dj/stream.mp3)" class="relay-url-input">
|
|
||||||
<button class="relay-btn" id="start-relay-btn" onclick="startRemoteRelay()">START RELAY</button>
|
|
||||||
<button class="relay-btn stop" id="stop-relay-btn" onclick="stopRemoteRelay()" style="display: none;">STOP RELAY</button>
|
|
||||||
</div>
|
|
||||||
<div class="relay-status" id="relay-status"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -442,6 +433,12 @@
|
||||||
<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">
|
||||||
|
<button class="btn-primary" onclick="openKeyboardSettings()"
|
||||||
|
style="width: 100%; padding: 12px; margin-top: 10px;">
|
||||||
|
⌨️ CUSTOM KEYBOARD MAPS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="keyboard-btn" onclick="openKeyboardSettings()" title="Keyboard Shortcuts (H)">⌨️</button>
|
<button class="keyboard-btn" onclick="openKeyboardSettings()" title="Keyboard Shortcuts (H)">⌨️</button>
|
||||||
|
|
@ -452,4 +449,4 @@
|
||||||
<button class="settings-btn" onclick="toggleSettings()">⚙️</button>
|
<button class="settings-btn" onclick="toggleSettings()">⚙️</button>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
91
script.js
91
script.js
|
|
@ -2042,49 +2042,6 @@ function toggleAutoStream(enabled) {
|
||||||
localStorage.setItem('autoStartStream', enabled);
|
localStorage.setItem('autoStartStream', enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== REMOTE RELAY FUNCTIONS ==========
|
|
||||||
|
|
||||||
function startRemoteRelay() {
|
|
||||||
const urlInput = document.getElementById('remote-stream-url');
|
|
||||||
const url = urlInput.value.trim();
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
alert('Please enter a remote stream URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!socket) initSocket();
|
|
||||||
|
|
||||||
// Stop any existing broadcast first
|
|
||||||
if (isBroadcasting) {
|
|
||||||
stopBroadcast();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔗 Starting remote relay for:', url);
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
document.getElementById('start-relay-btn').style.display = 'none';
|
|
||||||
document.getElementById('stop-relay-btn').style.display = 'inline-block';
|
|
||||||
document.getElementById('relay-status').textContent = 'Connecting to remote stream...';
|
|
||||||
document.getElementById('relay-status').style.color = '#00f3ff';
|
|
||||||
|
|
||||||
const bitrateValue = document.getElementById('stream-quality').value + 'k';
|
|
||||||
socket.emit('start_remote_relay', { url: url, bitrate: bitrateValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRemoteRelay() {
|
|
||||||
if (!socket) return;
|
|
||||||
|
|
||||||
console.log('🛑 Stopping remote relay');
|
|
||||||
|
|
||||||
socket.emit('stop_remote_relay');
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
document.getElementById('start-relay-btn').style.display = 'inline-block';
|
|
||||||
document.getElementById('stop-relay-btn').style.display = 'none';
|
|
||||||
document.getElementById('relay-status').textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== LISTENER MODE ==========
|
// ========== LISTENER MODE ==========
|
||||||
|
|
||||||
function initListenerMode() {
|
function initListenerMode() {
|
||||||
|
|
@ -2692,7 +2649,7 @@ function checkAndLoadNextFromQueue(deckId) {
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
||||||
// Default keyboard mappings (can be customized)
|
// Default keyboard mappings (can be customized)
|
||||||
let keyboardMappings = {
|
const DEFAULT_KEYBOARD_MAPPINGS = {
|
||||||
// Deck A Controls
|
// Deck A Controls
|
||||||
'q': { action: 'playDeckA', label: 'Play Deck A' },
|
'q': { action: 'playDeckA', label: 'Play Deck A' },
|
||||||
'a': { action: 'pauseDeckA', label: 'Pause Deck A' },
|
'a': { action: 'pauseDeckA', label: 'Pause Deck A' },
|
||||||
|
|
@ -2735,23 +2692,36 @@ let keyboardMappings = {
|
||||||
'Escape': { action: 'closeAllPanels', label: 'Close All Panels' }
|
'Escape': { action: 'closeAllPanels', label: 'Close All Panels' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load custom mappings from localStorage
|
let keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
|
||||||
function loadKeyboardMappings() {
|
|
||||||
const saved = localStorage.getItem('keyboardMappings');
|
// Load custom mappings from server
|
||||||
if (saved) {
|
async function loadKeyboardMappings() {
|
||||||
try {
|
try {
|
||||||
keyboardMappings = JSON.parse(saved);
|
const response = await fetch('/load_keymaps');
|
||||||
console.log('✅ Loaded custom keyboard mappings');
|
const data = await response.json();
|
||||||
} catch (e) {
|
if (data.success && data.keymaps) {
|
||||||
console.error('Failed to load keyboard mappings:', e);
|
keyboardMappings = data.keymaps;
|
||||||
|
console.log('✅ Loaded custom keyboard mappings from server');
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Using default keyboard mappings');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load keyboard mappings from server:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save custom mappings to localStorage
|
// Save custom mappings to server
|
||||||
function saveKeyboardMappings() {
|
async function saveKeyboardMappings() {
|
||||||
localStorage.setItem('keyboardMappings', JSON.stringify(keyboardMappings));
|
try {
|
||||||
console.log('💾 Saved keyboard mappings');
|
await fetch('/save_keymaps', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(keyboardMappings)
|
||||||
|
});
|
||||||
|
console.log('💾 Saved keyboard mappings to server');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save keyboard mappings to server:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute action based on key
|
// Execute action based on key
|
||||||
|
|
@ -2968,10 +2938,11 @@ function reassignKey(oldKey) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset to default mappings
|
// Reset to default mappings
|
||||||
function resetKeyboardMappings() {
|
async function resetKeyboardMappings() {
|
||||||
if (confirm('Reset all keyboard shortcuts to defaults?')) {
|
if (confirm('Reset all keyboard shortcuts to defaults?')) {
|
||||||
localStorage.removeItem('keyboardMappings');
|
keyboardMappings = { ...DEFAULT_KEYBOARD_MAPPINGS };
|
||||||
location.reload();
|
await saveKeyboardMappings();
|
||||||
|
renderKeyboardMappings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
136
server.py
136
server.py
|
|
@ -55,7 +55,6 @@ _transcode_threads_started = False
|
||||||
_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
|
||||||
_remote_stream_url = None # For relaying remote streams
|
|
||||||
_mp3_preroll = collections.deque(maxlen=60) # Pre-roll (~2.5s at 192k)
|
_mp3_preroll = collections.deque(maxlen=60) # Pre-roll (~2.5s at 192k)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,59 +64,35 @@ 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:
|
# Local broadcast mode: input from pipe (Relay mode removed)
|
||||||
# Remote relay mode: input from URL
|
cmd = [
|
||||||
cmd = [
|
'ffmpeg',
|
||||||
'ffmpeg',
|
'-hide_banner',
|
||||||
'-hide_banner',
|
'-loglevel', 'error',
|
||||||
'-loglevel', 'error',
|
'-i', 'pipe:0',
|
||||||
'-i', _remote_stream_url,
|
'-vn',
|
||||||
'-vn',
|
'-acodec', 'libmp3lame',
|
||||||
'-acodec', 'libmp3lame',
|
'-b:a', _current_bitrate,
|
||||||
'-b:a', _current_bitrate,
|
'-tune', 'zerolatency',
|
||||||
'-tune', 'zerolatency',
|
'-flush_packets', '1',
|
||||||
'-flush_packets', '1',
|
'-f', 'mp3',
|
||||||
'-f', 'mp3',
|
'pipe:1',
|
||||||
'pipe:1',
|
]
|
||||||
]
|
|
||||||
else:
|
|
||||||
# Local broadcast mode: input from pipe
|
|
||||||
cmd = [
|
|
||||||
'ffmpeg',
|
|
||||||
'-hide_banner',
|
|
||||||
'-loglevel', 'error',
|
|
||||||
'-i', 'pipe:0',
|
|
||||||
'-vn',
|
|
||||||
'-acodec', 'libmp3lame',
|
|
||||||
'-b:a', _current_bitrate,
|
|
||||||
'-tune', 'zerolatency',
|
|
||||||
'-flush_packets', '1',
|
|
||||||
'-f', 'mp3',
|
|
||||||
'pipe:1',
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if _remote_stream_url:
|
_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,
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
_ffmpeg_proc = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
bufsize=0,
|
|
||||||
)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
_ffmpeg_proc = None
|
_ffmpeg_proc = None
|
||||||
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" })')
|
print(f'🎛️ ffmpeg transcoder started for /stream.mp3 (local broadcast)')
|
||||||
|
|
||||||
def _writer():
|
def _writer():
|
||||||
global _transcoder_last_error
|
global _transcoder_last_error
|
||||||
|
|
@ -193,7 +168,7 @@ 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 or _remote_stream_url:
|
if _ffmpeg_proc is None or _ffmpeg_proc.poll() is not None:
|
||||||
return
|
return
|
||||||
_last_audio_chunk_ts = time.time()
|
_last_audio_chunk_ts = time.time()
|
||||||
try:
|
try:
|
||||||
|
|
@ -262,6 +237,31 @@ def setup_shared_routes(app):
|
||||||
print(f"❌ Upload error: {e}")
|
print(f"❌ Upload error: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/save_keymaps', methods=['POST'])
|
||||||
|
def save_keymaps():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
with open('keymaps.json', 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, indent=4)
|
||||||
|
print("💾 Keymaps saved to keymaps.json")
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Save keymaps error: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/load_keymaps', methods=['GET'])
|
||||||
|
def load_keymaps():
|
||||||
|
try:
|
||||||
|
if os.path.exists('keymaps.json'):
|
||||||
|
with open('keymaps.json', 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return jsonify({"success": True, "keymaps": data})
|
||||||
|
else:
|
||||||
|
return jsonify({"success": True, "keymaps": None})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Load keymaps error: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/stream.mp3')
|
@app.route('/stream.mp3')
|
||||||
def stream_mp3():
|
def stream_mp3():
|
||||||
# Streaming response from the ffmpeg transcoder output.
|
# Streaming response from the ffmpeg transcoder output.
|
||||||
|
|
@ -475,46 +475,6 @@ def dj_stop():
|
||||||
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}, namespace='/')
|
||||||
|
|
||||||
@dj_socketio.on('start_remote_relay')
|
|
||||||
def dj_start_remote_relay(data):
|
|
||||||
global _remote_stream_url
|
|
||||||
url = data.get('url', '').strip()
|
|
||||||
if not url:
|
|
||||||
dj_socketio.emit('error', {'message': 'No URL provided for remote relay'})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stop any existing broadcast/relay
|
|
||||||
if broadcast_state['active']:
|
|
||||||
dj_stop()
|
|
||||||
|
|
||||||
global _remote_stream_url, _current_bitrate
|
|
||||||
_remote_stream_url = url
|
|
||||||
broadcast_state['active'] = True
|
|
||||||
broadcast_state['remote_relay'] = True
|
|
||||||
session['is_dj'] = True
|
|
||||||
|
|
||||||
if data and 'bitrate' in data:
|
|
||||||
_current_bitrate = data['bitrate']
|
|
||||||
print(f"📡 Setting relay bitrate to: {_current_bitrate}")
|
|
||||||
|
|
||||||
print(f"🔗 Starting remote relay from: {url}")
|
|
||||||
|
|
||||||
_start_transcoder_if_needed()
|
|
||||||
|
|
||||||
listener_socketio.emit('broadcast_started', namespace='/')
|
|
||||||
listener_socketio.emit('stream_status', {'active': True, 'remote_relay': True}, namespace='/')
|
|
||||||
|
|
||||||
@dj_socketio.on('stop_remote_relay')
|
|
||||||
def dj_stop_remote_relay():
|
|
||||||
global _remote_stream_url
|
|
||||||
_remote_stream_url = None
|
|
||||||
broadcast_state['active'] = False
|
|
||||||
broadcast_state['remote_relay'] = False
|
|
||||||
session['is_dj'] = False
|
|
||||||
print("🛑 Remote relay stopped")
|
|
||||||
|
|
||||||
_stop_transcoder()
|
|
||||||
|
|
||||||
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}, namespace='/')
|
||||||
|
|
||||||
|
|
|
||||||
110
style.css
110
style.css
|
|
@ -2500,6 +2500,114 @@ body.listening-active .landscape-prompt {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard Mappings List */
|
||||||
|
#keyboard-mappings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-mapping-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 80px 30px 1fr 80px;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-mapping-item:hover {
|
||||||
|
background: rgba(0, 243, 255, 0.05);
|
||||||
|
border-color: rgba(0, 243, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keyboard-mapping-item.listening {
|
||||||
|
background: rgba(188, 19, 254, 0.1) !important;
|
||||||
|
border-color: var(--secondary-magenta) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(188, 19, 254, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-display {
|
||||||
|
background: #222;
|
||||||
|
color: var(--primary-cyan);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #333;
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-arrow {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-reassign-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #888;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-reassign-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
border-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listening .key-reassign-btn {
|
||||||
|
color: var(--secondary-magenta);
|
||||||
|
border-color: var(--secondary-magenta);
|
||||||
|
background: transparent;
|
||||||
|
animation: blink-magenta 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink-magenta {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #222;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #eee;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #333;
|
||||||
|
border-color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-item label {
|
.setting-item label {
|
||||||
|
|
@ -3646,4 +3754,4 @@ body.listening-active .landscape-prompt {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 40px;
|
padding-left: 40px;
|
||||||
padding-right: 40px;
|
padding-right: 40px;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue