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')
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):

View File

@ -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"),
]

View File

@ -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():