From d153d69bfd9b245c7c01012f0bd229f227984970 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 08:56:03 +0700 Subject: [PATCH] feat: hide library toolbar buttons option, Ctrl+O hotkey, iTunes-style equalizer animation on playing track --- src/config_loader.py | 3 + src/hotkeys.py | 1 + src/main.py | 202 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 201 insertions(+), 5 deletions(-) diff --git a/src/config_loader.py b/src/config_loader.py index 3b5e707..c7b9a35 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -142,6 +142,8 @@ class ConfigLoader: self.config.set('Advanced', 'clean_temp', 'true') if not self.config.has_option('Advanced', 'show_download'): self.config.set('Advanced', 'show_download', 'false') + if not self.config.has_option('Advanced', 'hide_library_buttons'): + self.config.set('Advanced', 'hide_library_buttons', 'false') # Hotkeys section if not self.config.has_section('Hotkeys'): @@ -165,6 +167,7 @@ class ConfigLoader: 'ipod_refresh': 'Ctrl+R', 'download': 'Ctrl+Enter', 'delete_selected': 'Delete', + 'library_add_files': 'Ctrl+O', } for key, value in hotkey_defaults.items(): if not self.config.has_option('Hotkeys', key): diff --git a/src/hotkeys.py b/src/hotkeys.py index 7fa88b3..9eed1f4 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -95,6 +95,7 @@ DEFAULTS = [ ("download", "Ctrl+Enter", "Start Download", "global"), ("delete_selected", "Delete", "Delete Selected", "global"), ("edit_metadata", "F2", "Edit Metadata", "global"), + ("library_add_files", "Ctrl+O", "Library: Add Files", "global"), ] diff --git a/src/main.py b/src/main.py index 8a9995e..b9a1fc2 100644 --- a/src/main.py +++ b/src/main.py @@ -15,10 +15,11 @@ from PyQt6.QtWidgets import ( QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, QMessageBox, QSpinBox, QGroupBox, QHeaderView, - QTableWidget, QTableWidgetItem, QSlider, QMenu, QDialog + QTableWidget, QTableWidgetItem, QSlider, QMenu, QDialog, QStyle, + QStyleOptionSlider ) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl -from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer +from PyQt6.QtGui import QPixmap, QColor from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from youtube_downloader import YouTubeDownloader, TrackInfo @@ -38,6 +39,23 @@ logger = logging.getLogger(__name__) AUDIO_EXTS = {'.m4a', '.mp3', '.aac'} SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac'} +EQ_FRAMES = [ + "\u2581\u2583\u2585", + "\u2582\u2585\u2587", + "\u2583\u2587\u2586", + "\u2585\u2586\u2584", + "\u2586\u2584\u2583", + "\u2587\u2583\u2582", + "\u2586\u2582\u2581", + "\u2585\u2581\u2583", + "\u2583\u2582\u2585", + "\u2581\u2583\u2587", + "\u2582\u2585\u2586", + "\u2583\u2587\u2584", + "\u2585\u2586\u2583", + "\u2586\u2584\u2581", +] + class WorkerThread(QThread): """Worker thread for background tasks""" @@ -268,6 +286,31 @@ class WorkerThread(QThread): self.wait() +class JumpSlider(QSlider): + def _jump_to_pos(self, x): + opt = QStyleOptionSlider() + self.initStyleOption(opt) + gr = self.style().subControlRect( + QStyle.ComplexControl.CC_Slider, opt, + QStyle.SubControl.SC_SliderGroove, self + ) + val = QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), + int(x) - gr.x(), gr.width() + ) + self.setValue(val) + + def mousePressEvent(self, ev): + if ev.button() == Qt.MouseButton.LeftButton: + self._jump_to_pos(ev.position().x()) + super().mousePressEvent(ev) + + def mouseMoveEvent(self, ev): + if ev.buttons() & Qt.MouseButton.LeftButton: + self._jump_to_pos(ev.position().x()) + super().mouseMoveEvent(ev) + + class MainWindow(QMainWindow): """Main application window""" @@ -285,6 +328,8 @@ class MainWindow(QMainWindow): self.ipod_devices: List[Dict] = [] self.current_mount_point: Optional[str] = None self.show_download_tab = False + self.hide_library_buttons = False + self._library_toolbar_buttons: list = [] self.ipod_tab_index = 2 self.config_loader = ConfigLoader() @@ -293,6 +338,9 @@ class MainWindow(QMainWindow): self.player: Optional[QMediaPlayer] = None self.audio_output: Optional[QAudioOutput] = None self.current_playback_index: int = -1 + self._eq_timer: Optional[QTimer] = None + self._eq_frame: int = 0 + self._eq_running: bool = False self._is_playing = False self._saved_volume: int = 80 self._saved_track_path: str = "" @@ -351,6 +399,10 @@ class MainWindow(QMainWindow): self.show_download_tab = show_download self.tab_widget.setTabVisible(self.download_tab_index, show_download) + hide_library_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False) + self.hide_library_buttons_check.setChecked(hide_library_buttons) + self._on_hide_library_buttons_toggled(hide_library_buttons) + self._saved_volume = config.get_int("Playback", "last_volume", fallback=80) self._saved_track_path = config.get("Playback", "last_track_path", fallback="") self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0) @@ -368,6 +420,7 @@ class MainWindow(QMainWindow): config.set("Advanced", "clean_temp", str(self.clean_temp_check.isChecked()).lower()) config.set("Device", "auto_detect", str(self.auto_detect_check.isChecked()).lower()) config.set("Advanced", "show_download", str(self.show_download_check.isChecked()).lower()) + config.set("Advanced", "hide_library_buttons", str(self.hide_library_buttons_check.isChecked()).lower()) config.set("Playback", "last_volume", str(self.volume_slider.value())) if self.current_playback_index >= 0 and self.player.source().isValid(): @@ -409,6 +462,7 @@ class MainWindow(QMainWindow): "download": self._on_download_clicked, "delete_selected": self._delete_selected_current, "edit_metadata": self._on_edit_metadata, + "library_add_files": self._on_library_add_files, }) self._refresh_shortcuts_table() @@ -522,12 +576,13 @@ class MainWindow(QMainWindow): self.time_label_start.setFixedWidth(40) self.time_label_start.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - self.position_slider = QSlider(Qt.Orientation.Horizontal) + self.position_slider = JumpSlider(Qt.Orientation.Horizontal) self.position_slider.setMinimum(0) self.position_slider.setMaximum(1000) self.position_slider.setValue(0) self.position_slider.sliderPressed.connect(self._on_slider_pressed) - self.position_slider.sliderReleased.connect(self._on_slider_released) + self.position_slider.sliderMoved.connect(self._on_slider_pressed) + self.position_slider.sliderReleased.connect(self._on_slider_pressed) self.time_label_end = QLabel("0:00") self.time_label_end.setFixedWidth(40) @@ -633,6 +688,15 @@ class MainWindow(QMainWindow): refresh_btn.clicked.connect(self._on_refresh_library_clicked) toolbar.addWidget(refresh_btn) + self._library_toolbar_buttons = [ + self.library_add_btn, + self.library_convert_btn, + self.library_transfer_btn, + self.library_remove_btn, + self.library_edit_btn, + refresh_btn, + ] + toolbar.addStretch() self.library_search_input = QLineEdit() @@ -786,6 +850,11 @@ class MainWindow(QMainWindow): self.show_download_check.toggled.connect(self._on_show_download_toggled) advanced_layout.addWidget(self.show_download_check) + self.hide_library_buttons_check = QCheckBox("Hide Library Toolbar Buttons") + self.hide_library_buttons_check.setChecked(False) + self.hide_library_buttons_check.toggled.connect(self._on_hide_library_buttons_toggled) + advanced_layout.addWidget(self.hide_library_buttons_check) + layout.addWidget(advanced_group) shortcuts_group = QGroupBox("Keyboard Shortcuts") @@ -824,6 +893,11 @@ class MainWindow(QMainWindow): self.show_download_tab = checked self.tab_widget.setTabVisible(self.download_tab_index, checked) + def _on_hide_library_buttons_toggled(self, checked: bool): + self.hide_library_buttons = checked + for btn in self._library_toolbar_buttons: + btn.setVisible(not checked) + def _setup_player(self): """Initialize QMediaPlayer and QAudioOutput for playback.""" self.player = QMediaPlayer() @@ -835,6 +909,10 @@ class MainWindow(QMainWindow): self.player.durationChanged.connect(self._on_media_duration_changed) self.player.mediaStatusChanged.connect(self._on_media_status_changed) + self._eq_timer = QTimer() + self._eq_timer.setInterval(80) + self._eq_timer.timeout.connect(self._tick_eq_animation) + def _play_track_from_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True): """Start playing a track from the library table by row index.""" if index < 0 or index >= self.library_table.rowCount(): @@ -846,6 +924,9 @@ class MainWindow(QMainWindow): if not track_data or not os.path.exists(track_data.get("path", "")): return + old_index = self.current_playback_index + self._clear_playing_row(old_index) + self.current_playback_index = index self.player.setSource(QUrl.fromLocalFile(track_data["path"])) if auto_play: @@ -881,6 +962,10 @@ class MainWindow(QMainWindow): self.library_table.clearSelection() self.library_table.selectRow(index) + self._set_playing_row_bg(index) + if auto_play: + self._start_eq_animation() + if start_position_ms > 0: self._pending_seek_ms = start_position_ms else: @@ -893,6 +978,7 @@ class MainWindow(QMainWindow): self.player.pause() self._is_playing = False self.play_btn.setText("\u25B6") + self._stop_eq_animation() else: if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: # Nothing playing yet — play first selected row or row 0 @@ -903,6 +989,7 @@ class MainWindow(QMainWindow): self.player.play() self._is_playing = True self.play_btn.setText("\u23F8") + self._start_eq_animation() def _on_prev_clicked(self): if self.player is None: @@ -978,8 +1065,10 @@ class MainWindow(QMainWindow): def _on_media_status_changed(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: + self._stop_eq_animation() self._on_next_clicked() elif status == QMediaPlayer.MediaStatus.NoMedia: + self._stop_eq_animation() self.play_btn.setText("\u25B6") self._is_playing = False elif status == QMediaPlayer.MediaStatus.LoadedMedia: @@ -987,6 +1076,75 @@ class MainWindow(QMainWindow): self.player.setPosition(self._pending_seek_ms) self._pending_seek_ms = 0 + def _start_eq_animation(self): + if not self._eq_timer or self._eq_running: + return + self._eq_running = True + self._eq_frame = 0 + self._tick_eq_animation() + self._eq_timer.start() + + def _stop_eq_animation(self): + if not self._eq_timer: + return + self._eq_timer.stop() + self._eq_running = False + idx = self.current_playback_index + if idx >= 0 and idx < self.library_table.rowCount(): + item = self.library_table.item(idx, 0) + if item: + is_ready, data = item.data(Qt.ItemDataRole.UserRole) + track_num = data.get("track_num", 0) if data else 0 + item.setText(str(track_num) if (is_ready and track_num) else "") + item.setData(Qt.ItemDataRole.ForegroundRole, None) + + def _tick_eq_animation(self): + idx = self.current_playback_index + if idx < 0 or idx >= self.library_table.rowCount(): + return + item = self.library_table.item(idx, 0) + if item is None: + return + glyph = EQ_FRAMES[self._eq_frame % len(EQ_FRAMES)] + self._eq_frame += 1 + item.setText(glyph) + accent = self.palette().color(self.palette().ColorRole.Highlight) + item.setForeground(accent) + + def _set_playing_row_bg(self, row: int): + if row < 0 or row >= self.library_table.rowCount(): + return + bg = QColor(self.palette().color(self.palette().ColorRole.Highlight)) + bg.setAlpha(50) + for col in range(self.library_table.columnCount()): + item = self.library_table.item(row, col) + if item: + item.setBackground(bg) + + def _clear_playing_row_bg(self, row: int): + if row < 0 or row >= self.library_table.rowCount(): + return + for col in range(self.library_table.columnCount()): + item = self.library_table.item(row, col) + if item: + item.setData(Qt.ItemDataRole.BackgroundRole, None) + + def _clear_playing_row(self, row: int): + if row < 0 or row >= self.library_table.rowCount(): + return + for col in range(self.library_table.columnCount()): + item = self.library_table.item(row, col) + if item: + item.setData(Qt.ItemDataRole.BackgroundRole, None) + self._eq_timer.stop() if self._eq_timer else None + self._eq_running = False + item0 = self.library_table.item(row, 0) + if item0: + is_ready, data = item0.data(Qt.ItemDataRole.UserRole) + track_num = data.get("track_num", 0) if data else 0 + item0.setText(str(track_num) if (is_ready and track_num) else "") + item0.setData(Qt.ItemDataRole.ForegroundRole, None) + def _on_table_double_clicked(self, row: int): self._play_track_from_index(row) @@ -1240,6 +1398,40 @@ class MainWindow(QMainWindow): status = f"{len(all_tracks)} track(s) \u2014 {', '.join(parts)}" if parts else "No tracks in library" self.library_status.setText(status) + self._restore_playing_row() + + def _restore_playing_row(self): + idx = self.current_playback_index + if idx < 0: + return + track_path = "" + if self.player and self.player.source().isValid(): + track_path = self.player.source().toLocalFile() + if not track_path and idx < self.library_table.rowCount(): + item = self.library_table.item(idx, 0) + if item: + _, data = item.data(Qt.ItemDataRole.UserRole) + if data: + track_path = data.get("path", "") + + if not track_path: + self.current_playback_index = -1 + return + + for row in range(self.library_table.rowCount()): + item = self.library_table.item(row, 0) + if item is None: + continue + _, data = item.data(Qt.ItemDataRole.UserRole) + if data and data.get("path") == track_path: + self.current_playback_index = row + self._set_playing_row_bg(row) + if self._is_playing: + self._start_eq_animation() + return + + self.current_playback_index = -1 + def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]: selected_rows = set() for item in self.library_table.selectedItems():