# deck_widget.py from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QSizePolicy ) from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtGui import QFont, QColor, QPainter, QPen import numpy as np import os class WaveformWidget(QFrame): """Displays downsampled waveform with playhead position.""" def __init__(self, parent=None): super().__init__(parent) self.setFixedHeight(60) self.setStyleSheet(""" QFrame { background-color: #050505; border: 1px solid #1a1a1a; border-radius: 3px; } """) self.waveform_data = None self.position_pct = 0.0 self.warning_flash = False def set_waveform(self, audio: np.ndarray): if audio is None or audio.shape[1] == 0: self.waveform_data = None self.update() return mono = np.mean(audio, axis=0) width = max(500, self.width()) samples_per_pixel = max(1, len(mono) // width) num_pixels = len(mono) // samples_per_pixel self.waveform_data = np.array([ np.sqrt(np.mean(mono[i*samples_per_pixel:(i+1)*samples_per_pixel]**2)) for i in range(num_pixels) ], dtype=np.float32) self.update() def set_position(self, position_pct: float, warning: bool = False): self.position_pct = max(0.0, min(1.0, position_pct)) self.warning_flash = warning self.update() def paintEvent(self, event): super().paintEvent(event) if self.waveform_data is None or len(self.waveform_data) == 0: return painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) rect = self.rect().adjusted(2, 2, -2, -2) width = rect.width() height = rect.height() max_val = np.max(self.waveform_data) if np.max(self.waveform_data) > 0 else 1.0 scaled = (self.waveform_data / max_val) * (height * 0.8) x_scale = width / len(self.waveform_data) for i, rms in enumerate(self.waveform_data): x = rect.x() + int(i * x_scale) bar_h = int(scaled[i]) y_top = rect.y() + (height - bar_h) // 2 pct = i / len(self.waveform_data) if pct < self.position_pct: color = QColor("#004400") elif pct > 0.85: color = QColor("#ff0000") if self.warning_flash else QColor("#aa0000") elif pct > 0.70: color = QColor("#ffaa00") else: color = QColor("#00ff41") pen = QPen(color, 1) painter.setPen(pen) painter.drawLine(x, y_top, x, y_top + bar_h) playhead_x = rect.x() + int(self.position_pct * width) pen = QPen(QColor("#ffffff"), 2) painter.setPen(pen) painter.drawLine(playhead_x, rect.y(), playhead_x, rect.y() + height) class LCDDisplay(QFrame): def __init__(self, parent=None): super().__init__(parent) self.setFrameStyle(QFrame.Box | QFrame.Sunken) self.setStyleSheet(""" QFrame { background-color: #0a1a0a; border: 2px solid #1a2a1a; border-radius: 4px; } """) layout = QVBoxLayout(self) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(4) self.track_label = QLabel("NO TRACK LOADED") self.track_label.setFont(QFont("Courier New", 13, QFont.Bold)) self.track_label.setStyleSheet( "color: #00ff41; background: transparent;" ) self.track_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.track_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) time_row = QHBoxLayout() time_row.setSpacing(4) self.elapsed_label = QLabel("00:00") self.elapsed_label.setFont(QFont("Courier New", 32, QFont.Bold)) self.elapsed_label.setStyleSheet( "color: #00ff41; background: transparent;" ) self.elapsed_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) self.remaining_label = QLabel("-00:00") self.remaining_label.setFont(QFont("Courier New", 18, QFont.Bold)) self.remaining_label.setStyleSheet( "color: #00cc33; background: transparent;" ) self.remaining_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) time_row.addWidget(self.elapsed_label) time_row.addStretch() time_row.addWidget(self.remaining_label) status_row = QHBoxLayout() self.status_label = QLabel("STOPPED") self.status_label.setFont(QFont("Courier New", 11, QFont.Bold)) self.status_label.setStyleSheet( "color: #008822; background: transparent;" ) self.queue_label = QLabel("QUEUE: EMPTY") self.queue_label.setFont(QFont("Courier New", 10)) self.queue_label.setStyleSheet( "color: #008822; background: transparent;" ) self.queue_label.setAlignment(Qt.AlignRight) status_row.addWidget(self.status_label) status_row.addStretch() status_row.addWidget(self.queue_label) layout.addWidget(self.track_label) layout.addLayout(time_row) layout.addLayout(status_row) def update_track(self, name: str): if len(name) > 40: name = name[:37] + "..." self.track_label.setText(name) def update_time(self, elapsed: str, remaining: str): self.elapsed_label.setText(elapsed) self.remaining_label.setText(remaining) def update_status(self, status: str): self.status_label.setText(status) def update_queue(self, name: str): if name: short = name[:25] + "..." if len(name) > 25 else name self.queue_label.setText(f"QUEUE: {short}") else: self.queue_label.setText("QUEUE: EMPTY") def set_playing(self, playing: bool): colour = "#00ff41" if playing else "#00aa2a" self.elapsed_label.setStyleSheet( f"color: {colour}; background: transparent;" ) def flash_warning(self, active: bool): """Flash all time displays red when in warning zone.""" if active: self.elapsed_label.setStyleSheet( "color: #ff4444; background: transparent;" ) self.remaining_label.setStyleSheet( "color: #ff4444; background: transparent;" ) else: self.elapsed_label.setStyleSheet( "color: #00ff41; background: transparent;" ) self.remaining_label.setStyleSheet( "color: #00cc33; background: transparent;" ) def make_transport_button(text: str, color: str = "#3a3a3a", text_color: str = "white", width: int = 52, height: int = 48) -> QPushButton: btn = QPushButton(text) btn.setFixedSize(width, height) btn.setFont(QFont("Arial", 11, QFont.Bold)) btn.setStyleSheet(f""" QPushButton {{ background-color: {color}; color: {text_color}; border: 1px solid #555; border-radius: 4px; }} QPushButton:hover {{ background-color: #555; }} QPushButton:pressed {{ background-color: #222; border: 1px solid #888; }} """) return btn class DeckWidget(QWidget): track_started = Signal(str) def __init__(self, deck_key: str, label: str, accent_color: str, engine, parent=None): super().__init__(parent) self.deck_key = deck_key self.engine = engine self.accent_color = accent_color self.current_theme = None self.flash_state = False # Convert "DECK 1" to "DECK_001" deck_num = label.split()[-1] self.deck_label = f"DECK_{int(deck_num):03d}" self._build_ui() self._connect_signals() self.timer = QTimer(self) self.timer.setInterval(100) self.timer.timeout.connect(self._refresh_display) self.timer.start() self.flash_timer = QTimer(self) self.flash_timer.setInterval(500) self.flash_timer.timeout.connect(self._toggle_flash) self.flash_timer.start() def _build_ui(self): self.setStyleSheet(f""" QWidget {{ background-color: #1c1c1c; border: 2px solid {self.accent_color}; border-radius: 6px; }} """) root = QVBoxLayout(self) root.setContentsMargins(10, 10, 10, 10) root.setSpacing(8) self.header = QLabel(self.deck_label) self.header.setFont(QFont("Courier New", 24, QFont.Bold)) self.header.setStyleSheet(f""" color: {self.accent_color}; background: transparent; border: none; padding: 4px; """) self.header.setAlignment(Qt.AlignCenter) root.addWidget(self.header) self.lcd = LCDDisplay() root.addWidget(self.lcd) self.waveform = WaveformWidget() root.addWidget(self.waveform) transport = QHBoxLayout() transport.setSpacing(6) self.btn_to_start = make_transport_button("⏮", "#3a3a3a", "white", 52, 48) self.btn_back_15 = make_transport_button("-15", "#3a3a3a", "#aaaaaa", 52, 48) self.btn_play = make_transport_button("▶", "#1a6b3a", "#00ff41", 68, 48) self.btn_fwd_15 = make_transport_button("+15", "#3a3a3a", "#aaaaaa", 52, 48) self.btn_to_end = make_transport_button("⏭", "#3a3a3a", "white", 52, 48) self.btn_stop = make_transport_button("■", "#8a1a1a", "#ff4444", 52, 48) transport.addWidget(self.btn_to_start) transport.addWidget(self.btn_back_15) transport.addWidget(self.btn_play) transport.addWidget(self.btn_fwd_15) transport.addWidget(self.btn_to_end) transport.addStretch() transport.addWidget(self.btn_stop) root.addLayout(transport) def apply_theme(self, theme: dict): self.current_theme = theme border_color = theme['border'] bg_color = theme['bg2'] self.setStyleSheet(f""" QWidget {{ background-color: {bg_color}; border: 2px solid {border_color}; border-radius: 6px; }} """) self.lcd.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; border: 2px solid {border_color}; border-radius: 4px; }} """) self.waveform.setStyleSheet(f""" QFrame {{ background-color: {theme['bg']}; border: 1px solid {border_color}; border-radius: 3px; }} """) def _toggle_flash(self): self.flash_state = not self.flash_state def _connect_signals(self): self.btn_play.clicked.connect(self._toggle_play) self.btn_stop.clicked.connect(self._stop_eject) self.btn_to_start.clicked.connect(self._go_to_start) self.btn_to_end.clicked.connect(self._go_to_end) self.btn_back_15.clicked.connect(lambda: self._scrub(-15)) self.btn_fwd_15.clicked.connect(lambda: self._scrub(15)) def set_track_name(self, name: str): self.lcd.update_track(name) self.lcd.update_status("LOADED") self.lcd.update_time("00:00", "-00:00") QTimer.singleShot(200, self._update_waveform) def _update_waveform(self): deck = self.engine.decks[self.deck_key] if deck.track: self.waveform.set_waveform(deck.track.audio) def set_queue_name(self, name: str): self.lcd.update_queue(name) def toggle_play(self): self._toggle_play() def cue(self): deck = self.engine.decks[self.deck_key] if deck.track is None: return if deck.playing: deck.pause() deck.go_to_start() self.btn_play.setText("▶") self.btn_play.setStyleSheet(""" QPushButton { background-color: #1a6b3a; color: #00ff41; border: 1px solid #555; border-radius: 4px; } QPushButton:hover { background-color: #2a7b4a; } """) self.lcd.update_status("CUE") self.lcd.set_playing(False) def scrub(self, delta: float): self._scrub(delta) def _toggle_play(self): deck = self.engine.decks[self.deck_key] if deck.track is None: return if deck.playing: deck.pause() self.btn_play.setText("▶") self.btn_play.setStyleSheet(""" QPushButton { background-color: #1a6b3a; color: #00ff41; border: 1px solid #555; border-radius: 4px; } QPushButton:hover { background-color: #2a7b4a; } """) self.lcd.update_status("PAUSED") self.lcd.set_playing(False) else: deck.play() self.btn_play.setText("⏸") self.btn_play.setStyleSheet(""" QPushButton { background-color: #00aa33; color: white; border: 1px solid #00ff41; border-radius: 4px; } QPushButton:hover { background-color: #00cc44; } """) self.lcd.update_status("PLAYING") self.lcd.set_playing(True) if deck.track: self.track_started.emit(deck.track.filepath) def _stop_eject(self): deck = self.engine.decks[self.deck_key] deck.stop() self.btn_play.setText("▶") self.btn_play.setStyleSheet(""" QPushButton { background-color: #1a6b3a; color: #00ff41; border: 1px solid #555; border-radius: 4px; } QPushButton:hover { background-color: #2a7b4a; } """) self.lcd.update_track("NO TRACK LOADED") self.lcd.update_time("00:00", "-00:00") self.lcd.update_status("STOPPED") self.lcd.set_playing(False) self.waveform.set_waveform(None) def _go_to_start(self): self.engine.decks[self.deck_key].go_to_start() def _go_to_end(self): self.engine.decks[self.deck_key].go_to_end() def _scrub(self, seconds: float): self.engine.decks[self.deck_key].skip_seconds(seconds) def _refresh_display(self): deck = self.engine.decks[self.deck_key] # Auto-load queued track when current finishes if not deck.playing and deck.track is not None: if deck.position >= deck.track.num_samples - 1: if deck.queued: deck.load(deck.queued) self.lcd.update_track(os.path.splitext(os.path.basename(deck.queued.filepath))[0]) QTimer.singleShot(200, self._update_waveform) deck.queued = None self.lcd.update_queue("") self.lcd.update_status("LOADED") # Sync button if stopped manually if not deck.playing and self.btn_play.text() == "⏸": self.btn_play.setText("▶") self.btn_play.setStyleSheet(""" QPushButton { background-color: #1a6b3a; color: #00ff41; border: 1px solid #555; border-radius: 4px; } QPushButton:hover { background-color: #2a7b4a; } """) self.lcd.update_status("STOPPED") self.lcd.set_playing(False) if deck.track: self.lcd.update_time( deck.get_elapsed(), deck.get_remaining() ) position_pct = deck.position / deck.track.num_samples remaining_sec = deck.track.duration - (deck.position / deck.track.sr) # Warning if < 20 seconds OR < 15% remaining in_warning = (remaining_sec < 20) or (position_pct > 0.85) # Flash header AND all numbers if in warning zone and playing if deck.playing and in_warning: flash_color = '#ff4444' if self.flash_state else self.accent_color # Flash header self.header.setStyleSheet(f""" color: {flash_color}; background: transparent; border: none; padding: 4px; """) # Flash LCD numbers self.lcd.flash_warning(self.flash_state) else: # Normal colors self.header.setStyleSheet(f""" color: {self.accent_color}; background: transparent; border: none; padding: 4px; """) self.lcd.flash_warning(False) self.waveform.set_position(position_pct, in_warning and self.flash_state) if deck.queued: self.lcd.update_queue( os.path.basename(deck.queued.filepath) )