feat: hide library toolbar buttons option, Ctrl+O hotkey, iTunes-style equalizer animation on playing track

This commit is contained in:
Maksim Totmin 2026-06-01 08:56:03 +07:00
parent ec797b8074
commit d153d69bfd
3 changed files with 201 additions and 5 deletions

View File

@ -142,6 +142,8 @@ class ConfigLoader:
self.config.set('Advanced', 'clean_temp', 'true') self.config.set('Advanced', 'clean_temp', 'true')
if not self.config.has_option('Advanced', 'show_download'): if not self.config.has_option('Advanced', 'show_download'):
self.config.set('Advanced', 'show_download', 'false') 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 # Hotkeys section
if not self.config.has_section('Hotkeys'): if not self.config.has_section('Hotkeys'):
@ -165,6 +167,7 @@ class ConfigLoader:
'ipod_refresh': 'Ctrl+R', 'ipod_refresh': 'Ctrl+R',
'download': 'Ctrl+Enter', 'download': 'Ctrl+Enter',
'delete_selected': 'Delete', 'delete_selected': 'Delete',
'library_add_files': 'Ctrl+O',
} }
for key, value in hotkey_defaults.items(): for key, value in hotkey_defaults.items():
if not self.config.has_option('Hotkeys', key): if not self.config.has_option('Hotkeys', key):

View File

@ -95,6 +95,7 @@ DEFAULTS = [
("download", "Ctrl+Enter", "Start Download", "global"), ("download", "Ctrl+Enter", "Start Download", "global"),
("delete_selected", "Delete", "Delete Selected", "global"), ("delete_selected", "Delete", "Delete Selected", "global"),
("edit_metadata", "F2", "Edit Metadata", "global"), ("edit_metadata", "F2", "Edit Metadata", "global"),
("library_add_files", "Ctrl+O", "Library: Add Files", "global"),
] ]

View File

@ -15,10 +15,11 @@ from PyQt6.QtWidgets import (
QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog,
QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem,
QMessageBox, QSpinBox, QGroupBox, QHeaderView, 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.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer
from PyQt6.QtGui import QPixmap from PyQt6.QtGui import QPixmap, QColor
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from youtube_downloader import YouTubeDownloader, TrackInfo from youtube_downloader import YouTubeDownloader, TrackInfo
@ -38,6 +39,23 @@ logger = logging.getLogger(__name__)
AUDIO_EXTS = {'.m4a', '.mp3', '.aac'} AUDIO_EXTS = {'.m4a', '.mp3', '.aac'}
SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac'} 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): class WorkerThread(QThread):
"""Worker thread for background tasks""" """Worker thread for background tasks"""
@ -268,6 +286,31 @@ class WorkerThread(QThread):
self.wait() 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): class MainWindow(QMainWindow):
"""Main application window""" """Main application window"""
@ -285,6 +328,8 @@ class MainWindow(QMainWindow):
self.ipod_devices: List[Dict] = [] self.ipod_devices: List[Dict] = []
self.current_mount_point: Optional[str] = None self.current_mount_point: Optional[str] = None
self.show_download_tab = False self.show_download_tab = False
self.hide_library_buttons = False
self._library_toolbar_buttons: list = []
self.ipod_tab_index = 2 self.ipod_tab_index = 2
self.config_loader = ConfigLoader() self.config_loader = ConfigLoader()
@ -293,6 +338,9 @@ class MainWindow(QMainWindow):
self.player: Optional[QMediaPlayer] = None self.player: Optional[QMediaPlayer] = None
self.audio_output: Optional[QAudioOutput] = None self.audio_output: Optional[QAudioOutput] = None
self.current_playback_index: int = -1 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._is_playing = False
self._saved_volume: int = 80 self._saved_volume: int = 80
self._saved_track_path: str = "" self._saved_track_path: str = ""
@ -351,6 +399,10 @@ class MainWindow(QMainWindow):
self.show_download_tab = show_download self.show_download_tab = show_download
self.tab_widget.setTabVisible(self.download_tab_index, 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_volume = config.get_int("Playback", "last_volume", fallback=80)
self._saved_track_path = config.get("Playback", "last_track_path", fallback="") self._saved_track_path = config.get("Playback", "last_track_path", fallback="")
self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0) 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("Advanced", "clean_temp", str(self.clean_temp_check.isChecked()).lower())
config.set("Device", "auto_detect", str(self.auto_detect_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", "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())) config.set("Playback", "last_volume", str(self.volume_slider.value()))
if self.current_playback_index >= 0 and self.player.source().isValid(): if self.current_playback_index >= 0 and self.player.source().isValid():
@ -409,6 +462,7 @@ class MainWindow(QMainWindow):
"download": self._on_download_clicked, "download": self._on_download_clicked,
"delete_selected": self._delete_selected_current, "delete_selected": self._delete_selected_current,
"edit_metadata": self._on_edit_metadata, "edit_metadata": self._on_edit_metadata,
"library_add_files": self._on_library_add_files,
}) })
self._refresh_shortcuts_table() self._refresh_shortcuts_table()
@ -522,12 +576,13 @@ class MainWindow(QMainWindow):
self.time_label_start.setFixedWidth(40) self.time_label_start.setFixedWidth(40)
self.time_label_start.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) 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.setMinimum(0)
self.position_slider.setMaximum(1000) self.position_slider.setMaximum(1000)
self.position_slider.setValue(0) self.position_slider.setValue(0)
self.position_slider.sliderPressed.connect(self._on_slider_pressed) 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 = QLabel("0:00")
self.time_label_end.setFixedWidth(40) self.time_label_end.setFixedWidth(40)
@ -633,6 +688,15 @@ class MainWindow(QMainWindow):
refresh_btn.clicked.connect(self._on_refresh_library_clicked) refresh_btn.clicked.connect(self._on_refresh_library_clicked)
toolbar.addWidget(refresh_btn) 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() toolbar.addStretch()
self.library_search_input = QLineEdit() self.library_search_input = QLineEdit()
@ -786,6 +850,11 @@ class MainWindow(QMainWindow):
self.show_download_check.toggled.connect(self._on_show_download_toggled) self.show_download_check.toggled.connect(self._on_show_download_toggled)
advanced_layout.addWidget(self.show_download_check) 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) layout.addWidget(advanced_group)
shortcuts_group = QGroupBox("Keyboard Shortcuts") shortcuts_group = QGroupBox("Keyboard Shortcuts")
@ -824,6 +893,11 @@ class MainWindow(QMainWindow):
self.show_download_tab = checked self.show_download_tab = checked
self.tab_widget.setTabVisible(self.download_tab_index, 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): def _setup_player(self):
"""Initialize QMediaPlayer and QAudioOutput for playback.""" """Initialize QMediaPlayer and QAudioOutput for playback."""
self.player = QMediaPlayer() self.player = QMediaPlayer()
@ -835,6 +909,10 @@ class MainWindow(QMainWindow):
self.player.durationChanged.connect(self._on_media_duration_changed) self.player.durationChanged.connect(self._on_media_duration_changed)
self.player.mediaStatusChanged.connect(self._on_media_status_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): 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.""" """Start playing a track from the library table by row index."""
if index < 0 or index >= self.library_table.rowCount(): 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", "")): if not track_data or not os.path.exists(track_data.get("path", "")):
return return
old_index = self.current_playback_index
self._clear_playing_row(old_index)
self.current_playback_index = index self.current_playback_index = index
self.player.setSource(QUrl.fromLocalFile(track_data["path"])) self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
if auto_play: if auto_play:
@ -881,6 +962,10 @@ class MainWindow(QMainWindow):
self.library_table.clearSelection() self.library_table.clearSelection()
self.library_table.selectRow(index) self.library_table.selectRow(index)
self._set_playing_row_bg(index)
if auto_play:
self._start_eq_animation()
if start_position_ms > 0: if start_position_ms > 0:
self._pending_seek_ms = start_position_ms self._pending_seek_ms = start_position_ms
else: else:
@ -893,6 +978,7 @@ class MainWindow(QMainWindow):
self.player.pause() self.player.pause()
self._is_playing = False self._is_playing = False
self.play_btn.setText("\u25B6") self.play_btn.setText("\u25B6")
self._stop_eq_animation()
else: else:
if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState:
# Nothing playing yet — play first selected row or row 0 # Nothing playing yet — play first selected row or row 0
@ -903,6 +989,7 @@ class MainWindow(QMainWindow):
self.player.play() self.player.play()
self._is_playing = True self._is_playing = True
self.play_btn.setText("\u23F8") self.play_btn.setText("\u23F8")
self._start_eq_animation()
def _on_prev_clicked(self): def _on_prev_clicked(self):
if self.player is None: if self.player is None:
@ -978,8 +1065,10 @@ class MainWindow(QMainWindow):
def _on_media_status_changed(self, status): def _on_media_status_changed(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia: if status == QMediaPlayer.MediaStatus.EndOfMedia:
self._stop_eq_animation()
self._on_next_clicked() self._on_next_clicked()
elif status == QMediaPlayer.MediaStatus.NoMedia: elif status == QMediaPlayer.MediaStatus.NoMedia:
self._stop_eq_animation()
self.play_btn.setText("\u25B6") self.play_btn.setText("\u25B6")
self._is_playing = False self._is_playing = False
elif status == QMediaPlayer.MediaStatus.LoadedMedia: elif status == QMediaPlayer.MediaStatus.LoadedMedia:
@ -987,6 +1076,75 @@ class MainWindow(QMainWindow):
self.player.setPosition(self._pending_seek_ms) self.player.setPosition(self._pending_seek_ms)
self._pending_seek_ms = 0 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): def _on_table_double_clicked(self, row: int):
self._play_track_from_index(row) 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" status = f"{len(all_tracks)} track(s) \u2014 {', '.join(parts)}" if parts else "No tracks in library"
self.library_status.setText(status) 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]]: def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]:
selected_rows = set() selected_rows = set()
for item in self.library_table.selectedItems(): for item in self.library_table.selectedItems():