""" Player Panel — UI for playing files with EQ applied. """ import os from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QProgressBar, QListWidget, QSplitter, QMessageBox, QCheckBox, QSlider) from PyQt6.QtCore import Qt, QTimer, QSettings from core.file_loader import get_audio_duration from core.audio_player import AudioPlayer from ui.waveform_widget import WaveformWidget from ui.spectrum_widget import SpectrumWidget class VUMeter(QProgressBar): """Custom progress bar for VU metering.""" def __init__(self): super().__init__() self.setOrientation(Qt.Orientation.Vertical) self.setRange(-60, 0) # dB range self.setValue(-60) self.setTextVisible(False) self.setFixedWidth(15) # Styling handled in global QSS, but we can dynamic color here if needed self.setStyleSheet(""" QProgressBar { border: 1px solid #30304a; background-color: #161625; } QProgressBar::chunk { background-color: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0, stop: 0 #4CAF50, stop: 0.7 #FFEB3B, stop: 1 #F44336); } """) class PlayerPanel(QWidget): def __init__(self, player: AudioPlayer): super().__init__() self.player = player self.playlist = [] self.current_track_index = -1 self.settings = QSettings("TapeRecorder", "Player") self._init_ui() # Init volume and compressor self.player.output_volume = self.settings.value("volume", 100, type=int) / 100.0 self.player.compressor.enabled = self.settings.value("compressor", False, type=bool) # Connect player callbacks # Note: These are called from the audio thread. # For thread safety, we use a QTimer to poll state instead of direct signals. self.poll_timer = QTimer() self.poll_timer.timeout.connect(self._poll_player_state) self.poll_timer.start(50) # 20Hz update def _init_ui(self): # --- Left Side: Player Controls --- left_widget = QWidget() left_layout = QVBoxLayout(left_widget) # --- Top: File Info & Load --- top_row = QHBoxLayout() info_layout = QVBoxLayout() self.file_lbl = QLabel("No file loaded") self.file_lbl.setStyleSheet("font-weight: bold; font-size: 12pt;") self.format_lbl = QLabel("") self.format_lbl.setStyleSheet("color: #808090; font-size: 10pt;") info_layout.addWidget(self.file_lbl) info_layout.addWidget(self.format_lbl) load_btn = QPushButton("Load Files...") load_btn.clicked.connect(self.load_files) load_folder_btn = QPushButton("Load Folder...") load_folder_btn.clicked.connect(self.load_folder) self.refresh_btn = QPushButton("Refresh Folder") self.refresh_btn.clicked.connect(self.refresh_folder) top_row.addLayout(info_layout, 1) top_row.addWidget(load_btn) top_row.addWidget(load_folder_btn) top_row.addWidget(self.refresh_btn) left_layout.addLayout(top_row) # --- Middle: Waveform --- self.waveform = WaveformWidget() self.waveform.setFixedHeight(120) self.waveform.seek_requested.connect(self.player.seek) left_layout.addWidget(self.waveform) # --- Time display --- self.time_lbl = QLabel("00:00.0 / 00:00.0") self.time_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) self.time_lbl.setStyleSheet("font-family: monospace; font-size: 14pt; color: #ff7b00;") left_layout.addWidget(self.time_lbl) # --- Controls & VU --- control_row = QHBoxLayout() # Buttons btn_layout = QHBoxLayout() self.play_btn = QPushButton("▶ Play") self.play_btn.clicked.connect(self.toggle_play) self.stop_btn = QPushButton("■ Stop") self.stop_btn.clicked.connect(self._on_stop_clicked) self.norm_cb = QCheckBox("Normalize") self.norm_cb.setChecked(self.settings.value("normalize_volume", True, type=bool)) self.norm_cb.clicked.connect(self._on_norm_changed) self.speed_slider = QSlider(Qt.Orientation.Horizontal) self.speed_slider.setRange(90, 110) self.speed_slider.setValue(100) self.speed_slider.setFixedWidth(100) self.speed_slider.valueChanged.connect(self._on_speed_changed) self.speed_lbl = QLabel("Speed: 1.00x") self.speed_lbl.setFixedWidth(80) btn_layout.addWidget(self.play_btn) btn_layout.addWidget(self.stop_btn) btn_layout.addWidget(self.norm_cb) self.comp_cb = QCheckBox("Movie Mode (Comp)") self.comp_cb.setChecked(self.settings.value("compressor", False, type=bool)) self.comp_cb.clicked.connect(self._on_comp_changed) btn_layout.addWidget(self.comp_cb) btn_layout.addWidget(QLabel(" | ")) self.vol_slider = QSlider(Qt.Orientation.Horizontal) self.vol_slider.setRange(0, 200) self.vol_slider.setValue(self.settings.value("volume", 100, type=int)) self.vol_slider.setFixedWidth(100) self.vol_slider.valueChanged.connect(self._on_vol_changed) self.vol_lbl = QLabel(f"Vol: {self.vol_slider.value()}%") self.vol_lbl.setFixedWidth(70) btn_layout.addWidget(self.vol_lbl) btn_layout.addWidget(self.vol_slider) btn_layout.addWidget(QLabel(" | ")) btn_layout.addWidget(self.speed_lbl) btn_layout.addWidget(self.speed_slider) btn_layout.addStretch() control_row.addLayout(btn_layout, 1) # VU Meters vu_layout = QHBoxLayout() self.vu_l = VUMeter() self.vu_r = VUMeter() vu_layout.addWidget(QLabel("L")) vu_layout.addWidget(self.vu_l) vu_layout.addWidget(self.vu_r) vu_layout.addWidget(QLabel("R")) control_row.addLayout(vu_layout) left_layout.addLayout(control_row) # --- Bottom: Spectrum Analyzer --- self.spectrum = SpectrumWidget() self.spectrum.setFixedHeight(200) left_layout.addWidget(self.spectrum) # --- Right Side: Playlist --- right_widget = QWidget() right_layout = QVBoxLayout(right_widget) self.playlist_lbl = QLabel("Playlist") self.playlist_lbl.setStyleSheet("font-weight: bold;") right_layout.addWidget(self.playlist_lbl) self.playlist_widget = QListWidget() self.playlist_widget.itemDoubleClicked.connect(self._on_playlist_double_click) right_layout.addWidget(self.playlist_widget) # Splitter splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(left_widget) splitter.addWidget(right_widget) splitter.setSizes([700, 300]) # Default proportions layout = QVBoxLayout(self) layout.addWidget(splitter) def load_files(self): files, _ = QFileDialog.getOpenFileNames( self, "Open Audio Files", "", "Audio Files (*.wav *.mp3 *.opus *.flac *.ogg)" ) if files: self.playlist = files self.current_track_index = 0 self._update_playlist_ui() self._load_track(self.playlist[0]) def load_folder(self): last_folder = self.settings.value("last_folder", "") folder_path = QFileDialog.getExistingDirectory(self, "Select Folder", last_folder) if folder_path: self.settings.setValue("last_folder", folder_path) supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff') files = [] for f in os.listdir(folder_path): if f.lower().endswith(supported): files.append(os.path.join(folder_path, f)) if not files: QMessageBox.information(self, "Info", "No supported audio files found in folder.") return self.playlist = sorted(files) self.current_track_index = 0 self._update_playlist_ui() self._load_track(self.playlist[self.current_track_index], auto_play=True) def refresh_folder(self): folder_path = self.settings.value("last_folder", "") if not folder_path or not os.path.isdir(folder_path): QMessageBox.information(self, "Info", "No folder currently loaded or folder no longer exists.") return supported = ('.wav', '.mp3', '.opus', '.flac', '.ogg', '.aiff') files = [] for f in os.listdir(folder_path): if f.lower().endswith(supported): files.append(os.path.join(folder_path, f)) if not files: QMessageBox.information(self, "Info", "No supported audio files found in folder.") return new_playlist = sorted(files) current_file = None if self.playlist and 0 <= self.current_track_index < len(self.playlist): current_file = self.playlist[self.current_track_index] self.playlist = new_playlist if current_file in self.playlist: self.current_track_index = self.playlist.index(current_file) else: self.current_track_index = -1 self._update_playlist_ui() def _update_playlist_ui(self): self.playlist_widget.clear() total_duration = 0.0 for f in self.playlist: self.playlist_widget.addItem(os.path.basename(f)) total_duration += get_audio_duration(f) total_duration /= self.player.playback_speed dur_h = int(total_duration // 3600) dur_m = int((total_duration % 3600) // 60) dur_s = int(total_duration % 60) if dur_h > 0: time_str = f"{dur_h:02d}:{dur_m:02d}:{dur_s:02d}" else: time_str = f"{dur_m:02d}:{dur_s:02d}" self.playlist_lbl.setText(f"Playlist - Total Time: {time_str}") self.playlist_lbl.setStyleSheet("font-weight: bold; color: #4CAF50; font-size: 11pt;") if self.playlist and 0 <= self.current_track_index < len(self.playlist): self.playlist_widget.setCurrentRow(self.current_track_index) def _on_playlist_double_click(self, item): idx = self.playlist_widget.row(item) if 0 <= idx < len(self.playlist): self.current_track_index = idx self._load_track(self.playlist[self.current_track_index], auto_play=True) def _on_speed_changed(self, value): speed = value / 100.0 self.speed_lbl.setText(f"Speed: {speed:.2f}x") self.player.set_speed(speed) self._update_playlist_ui() if self.player.audio_data: pos_sec = self.player.current_frame / self.player.audio_data.sample_rate self._update_time_display(pos_sec) def _on_norm_changed(self): self.settings.setValue("normalize_volume", self.norm_cb.isChecked()) # Reload track if playing to apply normalization immediately? # Actually normalize is done at load time, so we just inform the user. def _on_comp_changed(self): enabled = self.comp_cb.isChecked() self.settings.setValue("compressor", enabled) self.player.compressor.enabled = enabled def _load_track(self, file_path, auto_play=False): try: should_normalize = self.norm_cb.isChecked() self.player.load_file(file_path, normalize=should_normalize) filename = os.path.basename(file_path) prefix = f"[{self.current_track_index + 1}/{len(self.playlist)}] " if self.playlist else "" self.file_lbl.setText(f"Loaded: {prefix}{filename}") audio = self.player.audio_data if audio: ch_str = "Stereo" if audio.channels == 2 else "Mono" fmt = audio.format_name or "Audio" self.format_lbl.setText(f"{audio.sample_rate} Hz | {ch_str} | {fmt}") self.waveform.load_audio(self.player.audio_data) self._update_time_display(0) self.spectrum.set_sample_rate(self.player.device_manager.sample_rate) if auto_play: try: self.player.play() self.play_btn.setText("⏸ Pause") except Exception as e: QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}") except Exception as e: self.file_lbl.setText(f"Error loading file: {e}") def _on_vol_changed(self, value): self.vol_lbl.setText(f"Vol: {value}%") self.settings.setValue("volume", value) self.player.output_volume = value / 100.0 def play_next_track(self): if self.playlist and self.current_track_index < len(self.playlist) - 1: self.current_track_index += 1 self.playlist_widget.setCurrentRow(self.current_track_index) self._load_track(self.playlist[self.current_track_index], auto_play=True) else: self.play_btn.setText("▶ Play") def _on_stop_clicked(self): self.play_btn.setText("▶ Play") self.player.stop() def toggle_play(self): if self.player.is_playing: self.player.pause() self.play_btn.setText("▶ Play") else: if self.player.audio_data is not None: try: self.player.play() self.play_btn.setText("⏸ Pause") except Exception as e: QMessageBox.warning(self, "Playback Error", f"Could not start playback. Is your output device set?\n\n{e}") def _poll_player_state(self): """Poll the player state to update UI (thread-safe).""" # Update play button state if stopped automatically (reached end of file) if not self.player.is_playing and self.play_btn.text() == "⏸ Pause": self.play_next_track() # Update playhead if self.player.audio_data: pos_sec = self.player.current_frame / self.player.audio_data.sample_rate self.waveform.set_position(pos_sec) self._update_time_display(pos_sec) # If playing, pull the latest chunks for the spectrum. if self.player.is_playing: raw = self.player._last_raw_chunk processed = self.player._last_processed_chunk if raw is not None and processed is not None: self.spectrum.add_audio_data(raw, processed) # Consume to avoid adding the same chunk multiple times self.player._last_raw_chunk = None self.player._last_processed_chunk = None # Update VU meters l_db, r_db = self.player.vu_levels # Map dB to progress bar (-60 to 0) self.vu_l.setValue(int(max(-60, l_db))) self.vu_r.setValue(int(max(-60, r_db))) # Decay VU meters when stopped if not self.player.is_playing: self.player.vu_levels = (max(-100, l_db - 2), max(-100, r_db - 2)) def _update_time_display(self, pos_sec: float): if not self.player.audio_data: return speed = self.player.playback_speed pos_sec_actual = pos_sec / speed dur_sec_actual = self.player.audio_data.duration / speed pos_m = int(pos_sec_actual // 60) pos_s = pos_sec_actual % 60 dur_m = int(dur_sec_actual // 60) dur_s = dur_sec_actual % 60 self.time_lbl.setText(f"{pos_m:02d}:{pos_s:04.1f} / {dur_m:02d}:{dur_s:04.1f}")