Add config.json system; fix bugs across server.py, script.js, techdj_qt.py
- Add config.example.json with all options: host, dj_port, listener_port, dj_panel_password, secret_key, music_folder, stream_bitrate, max_upload_mb, cors_origins, debug (copy to config.json to use) - server.py: drive host/ports/secrets/CORS/upload limit from config.json; fix serve_static to use allowlist only; move re import to top-level; fix inconsistent indentation in login/logout/before_request handlers - script.js: fix undefined decks.crossfader in updateUIFromMixerStatus; declare mediaRecorder as a proper let variable - techdj_qt.py: replace blocking time.sleep poll in YTDownloadWorker with non-blocking QTimer; fix fragile or-chained lambda in recording reset
This commit is contained in:
parent
1f07b87c36
commit
df283498eb
|
|
@ -1,3 +1,16 @@
|
||||||
{
|
{
|
||||||
"dj_panel_password": ""
|
"_comment": "TechDJ Pro - Configuration File. Copy to config.json and edit.",
|
||||||
|
|
||||||
|
"host": "0.0.0.0",
|
||||||
|
"dj_port": 5000,
|
||||||
|
"listener_port": 5001,
|
||||||
|
|
||||||
|
"dj_panel_password": "",
|
||||||
|
"secret_key": "",
|
||||||
|
|
||||||
|
"music_folder": "",
|
||||||
|
"stream_bitrate": "192k",
|
||||||
|
"max_upload_mb": 500,
|
||||||
|
"cors_origins": "*",
|
||||||
|
"debug": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1840,6 +1840,7 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let streamDestination = null;
|
let streamDestination = null;
|
||||||
let streamProcessor = null;
|
let streamProcessor = null;
|
||||||
|
let mediaRecorder = null;
|
||||||
let isBroadcasting = false;
|
let isBroadcasting = false;
|
||||||
let autoStartStream = false;
|
let autoStartStream = false;
|
||||||
let listenerAudioContext = null;
|
let listenerAudioContext = null;
|
||||||
|
|
@ -2063,8 +2064,12 @@ function updateUIFromMixerStatus(status) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update crossfader if changed significantly
|
// Update crossfader if changed significantly
|
||||||
if (Math.abs(decks.crossfader - status.crossfader) > 1) {
|
if (status.crossfader !== undefined) {
|
||||||
// We'd update the UI slider here
|
const cf = document.getElementById('crossfader');
|
||||||
|
if (cf && Math.abs(parseInt(cf.value) - status.crossfader) > 1) {
|
||||||
|
cf.value = status.crossfader;
|
||||||
|
updateCrossfader(status.crossfader);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
176
server.py
176
server.py
|
|
@ -4,6 +4,7 @@ eventlet.monkey_patch()
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
|
|
@ -33,6 +34,16 @@ def _load_config():
|
||||||
|
|
||||||
|
|
||||||
CONFIG = _load_config()
|
CONFIG = _load_config()
|
||||||
|
|
||||||
|
# --- Config-driven settings (config.json > env vars > defaults) ---
|
||||||
|
CONFIG_HOST = (CONFIG.get('host') or os.environ.get('HOST') or '0.0.0.0').strip()
|
||||||
|
CONFIG_DJ_PORT = int(CONFIG.get('dj_port') or os.environ.get('DJ_PORT') or 5000)
|
||||||
|
CONFIG_LISTENER_PORT = int(CONFIG.get('listener_port') or os.environ.get('LISTEN_PORT') or 5001)
|
||||||
|
CONFIG_SECRET = (CONFIG.get('secret_key') or '').strip() or 'dj_panel_secret'
|
||||||
|
CONFIG_CORS = CONFIG.get('cors_origins', '*')
|
||||||
|
CONFIG_MAX_UPLOAD_MB = int(CONFIG.get('max_upload_mb') or 500)
|
||||||
|
CONFIG_DEBUG = bool(CONFIG.get('debug', False))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -46,7 +57,7 @@ dj_sids = set()
|
||||||
# === Optional MP3 fallback stream (server-side transcoding) ===
|
# === Optional MP3 fallback stream (server-side transcoding) ===
|
||||||
_ffmpeg_proc = None
|
_ffmpeg_proc = None
|
||||||
_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming
|
_ffmpeg_in_q = queue.Queue(maxsize=20) # Optimized for low-latency live streaming
|
||||||
_current_bitrate = "192k"
|
_current_bitrate = (CONFIG.get('stream_bitrate') or '192k').strip()
|
||||||
_mp3_clients = set() # set[queue.Queue]
|
_mp3_clients = set() # set[queue.Queue]
|
||||||
_mp3_lock = threading.Lock()
|
_mp3_lock = threading.Lock()
|
||||||
_transcoder_bytes_out = 0
|
_transcoder_bytes_out = 0
|
||||||
|
|
@ -243,7 +254,9 @@ def _load_settings():
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
SETTINGS = _load_settings()
|
SETTINGS = _load_settings()
|
||||||
MUSIC_FOLDER = SETTINGS.get('library', {}).get('music_folder', 'music')
|
# Config.json music_folder overrides settings.json if set
|
||||||
|
_config_music = (CONFIG.get('music_folder') or '').strip()
|
||||||
|
MUSIC_FOLDER = _config_music or SETTINGS.get('library', {}).get('music_folder', 'music')
|
||||||
|
|
||||||
# Ensure music folder exists
|
# Ensure music folder exists
|
||||||
if not os.path.exists(MUSIC_FOLDER):
|
if not os.path.exists(MUSIC_FOLDER):
|
||||||
|
|
@ -329,18 +342,16 @@ def setup_shared_routes(app):
|
||||||
|
|
||||||
@app.route('/<path:filename>')
|
@app.route('/<path:filename>')
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
# Block access to sensitive files
|
|
||||||
blocked = ('.py', '.pyc', '.env', '.json', '.sh', '.bak', '.log', '.pem', '.key')
|
|
||||||
# Allow specific safe JSON/JS/CSS files
|
|
||||||
allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg',
|
|
||||||
'.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map')
|
|
||||||
if filename.endswith(blocked) and not filename.endswith(('.css', '.js')):
|
|
||||||
from flask import abort
|
|
||||||
abort(403)
|
|
||||||
# Prevent path traversal
|
# Prevent path traversal
|
||||||
if '..' in filename or filename.startswith('/'):
|
if '..' in filename or filename.startswith('/'):
|
||||||
from flask import abort
|
from flask import abort
|
||||||
abort(403)
|
abort(403)
|
||||||
|
# Allow only known safe static file extensions
|
||||||
|
allowed_extensions = ('.css', '.js', '.html', '.htm', '.png', '.jpg', '.jpeg',
|
||||||
|
'.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot', '.map')
|
||||||
|
if not filename.endswith(allowed_extensions):
|
||||||
|
from flask import abort
|
||||||
|
abort(403)
|
||||||
response = send_from_directory('.', filename)
|
response = send_from_directory('.', filename)
|
||||||
if filename.endswith(('.css', '.js', '.html')):
|
if filename.endswith(('.css', '.js', '.html')):
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
|
@ -366,7 +377,6 @@ def setup_shared_routes(app):
|
||||||
return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400
|
return jsonify({"success": False, "error": f"Supported formats: {', '.join(allowed_exts)}"}), 400
|
||||||
|
|
||||||
# Sanitize filename (keep extension)
|
# Sanitize filename (keep extension)
|
||||||
import re
|
|
||||||
name_without_ext = os.path.splitext(file.filename)[0]
|
name_without_ext = os.path.splitext(file.filename)[0]
|
||||||
name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext)
|
name_without_ext = re.sub(r'[^\w\s-]', '', name_without_ext)
|
||||||
name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip()
|
name_without_ext = re.sub(r'\s+', ' ', name_without_ext).strip()
|
||||||
|
|
@ -466,65 +476,66 @@ def setup_shared_routes(app):
|
||||||
'last_audio_chunk_ts': _last_audio_chunk_ts,
|
'last_audio_chunk_ts': _last_audio_chunk_ts,
|
||||||
})
|
})
|
||||||
|
|
||||||
# === DJ SERVER (Port 5000) ===
|
# === DJ SERVER ===
|
||||||
dj_app = Flask(__name__, static_folder='.', static_url_path='')
|
dj_app = Flask(__name__, static_folder='.', static_url_path='')
|
||||||
dj_app.config['SECRET_KEY'] = 'dj_panel_secret'
|
dj_app.config['SECRET_KEY'] = CONFIG_SECRET
|
||||||
|
dj_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
|
||||||
setup_shared_routes(dj_app)
|
setup_shared_routes(dj_app)
|
||||||
|
|
||||||
|
|
||||||
@dj_app.before_request
|
@dj_app.before_request
|
||||||
def _protect_dj_panel():
|
def _protect_dj_panel():
|
||||||
"""Optionally require a password for the DJ panel only (port 5000).
|
"""Optionally require a password for the DJ panel only (port 5000).
|
||||||
|
|
||||||
This does not affect the listener server (port 5001).
|
This does not affect the listener server (port 5001).
|
||||||
"""
|
"""
|
||||||
if not DJ_AUTH_ENABLED:
|
if not DJ_AUTH_ENABLED:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Allow login/logout endpoints
|
# Allow login/logout endpoints
|
||||||
if request.path in ('/login', '/logout'):
|
if request.path in ('/login', '/logout'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# If already authenticated, allow
|
# If already authenticated, allow
|
||||||
if session.get('dj_authed') is True:
|
if session.get('dj_authed') is True:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Redirect everything else to login
|
# Redirect everything else to login
|
||||||
return (
|
return (
|
||||||
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
|
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
|
||||||
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
|
"<body>Redirecting to <a href='/login'>/login</a>...</body></html>",
|
||||||
302,
|
302,
|
||||||
{'Location': '/login'}
|
{'Location': '/login'}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dj_app.route('/login', methods=['GET', 'POST'])
|
@dj_app.route('/login', methods=['GET', 'POST'])
|
||||||
def dj_login():
|
def dj_login():
|
||||||
if not DJ_AUTH_ENABLED:
|
if not DJ_AUTH_ENABLED:
|
||||||
# If auth is disabled, just go to the panel.
|
# If auth is disabled, just go to the panel.
|
||||||
session['dj_authed'] = True
|
session['dj_authed'] = True
|
||||||
return (
|
return (
|
||||||
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
|
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
|
||||||
"<body>Auth disabled. Redirecting...</body></html>",
|
"<body>Auth disabled. Redirecting...</body></html>",
|
||||||
302,
|
302,
|
||||||
{'Location': '/'}
|
{'Location': '/'}
|
||||||
)
|
)
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
pw = (request.form.get('password') or '').strip()
|
pw = (request.form.get('password') or '').strip()
|
||||||
if pw == DJ_PANEL_PASSWORD:
|
if pw == DJ_PANEL_PASSWORD:
|
||||||
session['dj_authed'] = True
|
session['dj_authed'] = True
|
||||||
return (
|
return (
|
||||||
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
|
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/' /></head>"
|
||||||
"<body>Logged in. Redirecting...</body></html>",
|
"<body>Logged in. Redirecting...</body></html>",
|
||||||
302,
|
302,
|
||||||
{'Location': '/'}
|
{'Location': '/'}
|
||||||
)
|
)
|
||||||
error = 'Invalid password'
|
error = 'Invalid password'
|
||||||
|
|
||||||
# Minimal inline login page (no new assets)
|
# Minimal inline login page (no new assets)
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html lang=\"en\">
|
<html lang=\"en\">
|
||||||
<head>
|
<head>
|
||||||
<meta charset=\"utf-8\" />
|
<meta charset=\"utf-8\" />
|
||||||
|
|
@ -561,22 +572,22 @@ def dj_login():
|
||||||
|
|
||||||
@dj_app.route('/logout')
|
@dj_app.route('/logout')
|
||||||
def dj_logout():
|
def dj_logout():
|
||||||
session.pop('dj_authed', None)
|
session.pop('dj_authed', None)
|
||||||
return (
|
return (
|
||||||
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
|
"<!doctype html><html><head><meta http-equiv='refresh' content='0; url=/login' /></head>"
|
||||||
"<body>Logged out. Redirecting...</body></html>",
|
"<body>Logged out. Redirecting...</body></html>",
|
||||||
302,
|
302,
|
||||||
{'Location': '/login'}
|
{'Location': '/login'}
|
||||||
)
|
)
|
||||||
dj_socketio = SocketIO(
|
dj_socketio = SocketIO(
|
||||||
dj_app,
|
dj_app,
|
||||||
cors_allowed_origins="*",
|
cors_allowed_origins=CONFIG_CORS,
|
||||||
async_mode='eventlet',
|
async_mode='eventlet',
|
||||||
max_http_buffer_size=1e8, # 100MB buffer
|
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
ping_interval=5,
|
ping_interval=5,
|
||||||
logger=False,
|
logger=CONFIG_DEBUG,
|
||||||
engineio_logger=False
|
engineio_logger=CONFIG_DEBUG
|
||||||
)
|
)
|
||||||
|
|
||||||
@dj_socketio.on('connect')
|
@dj_socketio.on('connect')
|
||||||
|
|
@ -649,9 +660,10 @@ def dj_audio(data):
|
||||||
if isinstance(data, (bytes, bytearray)):
|
if isinstance(data, (bytes, bytearray)):
|
||||||
_feed_transcoder(bytes(data))
|
_feed_transcoder(bytes(data))
|
||||||
|
|
||||||
# === LISTENER SERVER (Port 5001) ===
|
# === LISTENER SERVER ===
|
||||||
listener_app = Flask(__name__, static_folder='.', static_url_path='')
|
listener_app = Flask(__name__, static_folder='.', static_url_path='')
|
||||||
listener_app.config['SECRET_KEY'] = 'listener_secret'
|
listener_app.config['SECRET_KEY'] = CONFIG_SECRET + '_listener'
|
||||||
|
listener_app.config['MAX_CONTENT_LENGTH'] = CONFIG_MAX_UPLOAD_MB * 1024 * 1024
|
||||||
setup_shared_routes(listener_app)
|
setup_shared_routes(listener_app)
|
||||||
|
|
||||||
# Block write/admin endpoints on the listener server
|
# Block write/admin endpoints on the listener server
|
||||||
|
|
@ -664,13 +676,13 @@ def _restrict_listener_routes():
|
||||||
abort(403)
|
abort(403)
|
||||||
listener_socketio = SocketIO(
|
listener_socketio = SocketIO(
|
||||||
listener_app,
|
listener_app,
|
||||||
cors_allowed_origins="*",
|
cors_allowed_origins=CONFIG_CORS,
|
||||||
async_mode='eventlet',
|
async_mode='eventlet',
|
||||||
max_http_buffer_size=1e8, # 100MB buffer
|
max_http_buffer_size=CONFIG_MAX_UPLOAD_MB * 1024 * 1024,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
ping_interval=5,
|
ping_interval=5,
|
||||||
logger=False,
|
logger=CONFIG_DEBUG,
|
||||||
engineio_logger=False
|
engineio_logger=CONFIG_DEBUG
|
||||||
)
|
)
|
||||||
|
|
||||||
@listener_socketio.on('connect')
|
@listener_socketio.on('connect')
|
||||||
|
|
@ -726,19 +738,19 @@ if __name__ == '__main__':
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
|
print("TECHDJ PRO - DUAL PORT ARCHITECTURE")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
# Ports from environment or defaults
|
print(f"HOST: {CONFIG_HOST}")
|
||||||
dj_port = int(os.environ.get('DJ_PORT', 5000))
|
print(f"URL: DJ PANEL -> http://{CONFIG_HOST}:{CONFIG_DJ_PORT}")
|
||||||
listen_port = int(os.environ.get('LISTEN_PORT', 5001))
|
print(f"URL: LISTENER -> http://{CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
|
||||||
|
if DJ_AUTH_ENABLED:
|
||||||
print(f"URL: DJ PANEL API: http://0.0.0.0:{dj_port}")
|
print("AUTH: DJ panel password ENABLED")
|
||||||
print(f"URL: LISTEN PAGE: http://0.0.0.0:{listen_port}")
|
else:
|
||||||
|
print("AUTH: DJ panel password DISABLED")
|
||||||
|
print(f"DEBUG: {CONFIG_DEBUG}")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
print(f"READY: Server ready on {CONFIG_HOST}:{CONFIG_DJ_PORT} & {CONFIG_HOST}:{CONFIG_LISTENER_PORT}")
|
||||||
# Audio engine DISABLED
|
|
||||||
print(f"READY: Local Radio server ready on ports {dj_port} & {listen_port}")
|
|
||||||
|
|
||||||
# Run both servers using eventlet's spawn
|
# Run both servers using eventlet's spawn
|
||||||
eventlet.spawn(_listener_count_sync_loop)
|
eventlet.spawn(_listener_count_sync_loop)
|
||||||
eventlet.spawn(_transcoder_watchdog)
|
eventlet.spawn(_transcoder_watchdog)
|
||||||
eventlet.spawn(dj_socketio.run, dj_app, host='0.0.0.0', port=dj_port, debug=False)
|
eventlet.spawn(dj_socketio.run, dj_app, host=CONFIG_HOST, port=CONFIG_DJ_PORT, debug=False)
|
||||||
listener_socketio.run(listener_app, host='0.0.0.0', port=listen_port, debug=False)
|
listener_socketio.run(listener_app, host=CONFIG_HOST, port=CONFIG_LISTENER_PORT, debug=False)
|
||||||
|
|
|
||||||
35
techdj_qt.py
35
techdj_qt.py
|
|
@ -315,25 +315,27 @@ class YTDownloadWorker(QProcess):
|
||||||
|
|
||||||
def handle_finished(self):
|
def handle_finished(self):
|
||||||
if self.exitCode() == 0 and self.final_filename:
|
if self.exitCode() == 0 and self.final_filename:
|
||||||
# Poll for the file to fully appear on disk (replaces the unreliable 0.5s sleep).
|
# Use a non-blocking timer to poll for the file instead of blocking the GUI thread.
|
||||||
# yt-dlp moves the file after FFmpeg post-processing finishes, so the file
|
self._poll_deadline = time.time() + 10.0
|
||||||
# may take a moment to be visible. We wait up to 10 seconds.
|
self._poll_timer = QTimer()
|
||||||
deadline = time.time() + 10.0
|
self._poll_timer.setInterval(100)
|
||||||
while time.time() < deadline:
|
self._poll_timer.timeout.connect(self._check_file_ready)
|
||||||
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
self._poll_timer.start()
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
|
||||||
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
|
|
||||||
self.download_finished.emit(self.final_filename)
|
|
||||||
else:
|
|
||||||
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
|
|
||||||
elif self.exitCode() == 0 and not self.final_filename:
|
elif self.exitCode() == 0 and not self.final_filename:
|
||||||
self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.")
|
self.error_occurred.emit("Download finished but could not determine output filename.\nCheck the download folder manually.")
|
||||||
else:
|
else:
|
||||||
self.error_occurred.emit(f"Download process failed.\n{self.error_log}")
|
self.error_occurred.emit(f"Download process failed.\n{self.error_log}")
|
||||||
|
|
||||||
|
def _check_file_ready(self):
|
||||||
|
"""Non-blocking poll: check if the downloaded file has appeared on disk."""
|
||||||
|
if os.path.exists(self.final_filename) and os.path.getsize(self.final_filename) > 0:
|
||||||
|
self._poll_timer.stop()
|
||||||
|
print(f"[DEBUG] Download complete: {self.final_filename} ({os.path.getsize(self.final_filename)} bytes)")
|
||||||
|
self.download_finished.emit(self.final_filename)
|
||||||
|
elif time.time() > self._poll_deadline:
|
||||||
|
self._poll_timer.stop()
|
||||||
|
self.error_occurred.emit(f"Download finished but file missing or empty:\n{self.final_filename}")
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
def __init__(self, settings_data, parent=None):
|
def __init__(self, settings_data, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -2053,7 +2055,10 @@ class DJApp(QMainWindow):
|
||||||
print("[RECORDING] Stopped")
|
print("[RECORDING] Stopped")
|
||||||
|
|
||||||
# Reset status after 3 seconds
|
# Reset status after 3 seconds
|
||||||
QTimer.singleShot(3000, lambda: self.recording_status_label.setText("Ready to record") or self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;"))
|
def _reset_rec_status():
|
||||||
|
self.recording_status_label.setText("Ready to record")
|
||||||
|
self.recording_status_label.setStyleSheet("color: #666; font-size: 12px;")
|
||||||
|
QTimer.singleShot(3000, _reset_rec_status)
|
||||||
|
|
||||||
def update_recording_time(self):
|
def update_recording_time(self):
|
||||||
"""Update the recording timer display"""
|
"""Update the recording timer display"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue