From 199fb43f04a179dd2e3ec29d448a4d05d29e4078 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 14:46:58 +0700 Subject: [PATCH] feat: iTunes-style sidebar + global player header + album grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: sidebar_widget.py — iTunes-like sidebar with library/playlist/nav sections, GTK palette - New: player_header.py — global player header (cover, now playing, controls) above splitter - New: playlist_manager.py — playlist CRUD with JSON persistence - Refactor: moved QMediaPlayer from LibraryTab to MainWindow for clean architecture - Refactor: LibraryTab — removed all player code, added play_requested signal - Feat: album grid (IconMode) — grouped by album name, rounded covers 8px, Spotify-like - Feat: NumericTableItem — numeric sort for track # column (1,2,3...10,11 not 1,10,2) - Feat: track_num animation fixed — finds row by path, not index (no freeze on sort) - Feat: album_artist tag extracted and stored in TrackInfo - Feat: state persistence — view_mode/filtered_album saved to config.ini on close - Feat: Ctrl+B toggle sidebar - Fix: sidebar splitter handle colour from GTK palette --- src/app.py | 328 ++++++++++++++++++++-- src/hotkeys.py | 1 + src/metadata_handler.py | 1 + src/playlist_manager.py | 130 +++++++++ src/track_info.py | 1 + src/ui/library_tab.py | 588 ++++++++++++++++++++------------------- src/ui/player_header.py | 137 +++++++++ src/ui/sidebar_widget.py | 307 ++++++++++++++++++++ 8 files changed, 1177 insertions(+), 316 deletions(-) create mode 100644 src/playlist_manager.py create mode 100644 src/ui/player_header.py create mode 100644 src/ui/sidebar_widget.py diff --git a/src/app.py b/src/app.py index 702c525..de3603a 100644 --- a/src/app.py +++ b/src/app.py @@ -11,16 +11,22 @@ from typing import Optional from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, - QTabWidget, + QSplitter, QStackedWidget, ) -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QPixmap +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from config_loader import ConfigLoader from device_monitor import DeviceMonitor from hotkeys import HotkeyManager +from playlist_manager import PlaylistManager from ui.library_tab import LibraryTab from ui.ipod_tab import iPodTab from ui.settings_tab import SettingsTab +from ui.sidebar_widget import SIDEBAR_DEFAULT_WIDTH +from ui.player_header import PlayerHeader +from artwork.cache import cover_cache logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -40,33 +46,83 @@ class MainWindow(QMainWindow): self.device_monitor: Optional[DeviceMonitor] = None self.ipod_tab_index = 1 + self.playlist_manager = PlaylistManager() + self._sidebar_visible = True + + self._saved_volume: int = 80 + self._saved_track_path: str = "" + self._saved_position_ms: int = 0 + self._saved_playing: bool = False + self._pending_seek_ms: int = 0 + + self._setup_player() self._setup_ui() + self.library_tab.set_playlist_manager(self.playlist_manager) + self._load_playlists_into_sidebar() self._load_settings() self._setup_hotkeys() self._wire_signals() self.library_tab._scan_library() - self.library_tab._resume_playback_if_saved() + self._resume_playback_if_saved() + self._restore_library_state() self.ipod_tab._on_refresh_devices_clicked() self._start_device_monitor() + def _setup_player(self): + self.player = QMediaPlayer() + self.audio_output = QAudioOutput() + self.audio_output.setVolume(self._saved_volume / 100.0) + self.player.setAudioOutput(self.audio_output) + self.player.positionChanged.connect(self._on_player_position) + self.player.durationChanged.connect(self._on_player_duration) + self.player.mediaStatusChanged.connect(self._on_media_status) + def _setup_ui(self): + from ui.sidebar_widget import SidebarWidget + main_widget = QWidget() main_layout = QVBoxLayout(main_widget) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) - self.tab_widget = QTabWidget() - main_layout.addWidget(self.tab_widget) + self.player_header = PlayerHeader() + self.player_header.prev_requested.connect(self._on_prev) + self.player_header.play_pause_requested.connect(self._on_play_pause) + self.player_header.next_requested.connect(self._on_next) + self.player_header.position_changed_by_user.connect(self._on_user_seek_frac) + self.player_header.volume_changed.connect(self._on_volume_changed) + main_layout.addWidget(self.player_header) + + self.stack = QStackedWidget() self.library_tab = LibraryTab(self.config_loader) - self.tab_widget.addTab(self.library_tab, "Library") + self.library_tab.play_requested.connect(self._on_play_requested) + self.stack.addWidget(self.library_tab) self.ipod_tab = iPodTab() - self.tab_widget.addTab(self.ipod_tab, "iPod") + self.stack.addWidget(self.ipod_tab) self.settings_tab = SettingsTab() - self.tab_widget.addTab(self.settings_tab, "Settings") + self.stack.addWidget(self.settings_tab) - self.tab_widget.setTabVisible(self.ipod_tab_index, False) + self.stack.setCurrentIndex(0) + + self.sidebar = SidebarWidget() + self.sidebar.item_selected.connect(self._on_sidebar_item_selected) + self.sidebar.playlist_selected.connect(self._on_playlist_selected) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.addWidget(self.sidebar) + self.splitter.addWidget(self.stack) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + self.splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 600]) + self.splitter.setHandleWidth(1) + mid = self.palette().color(self.palette().ColorRole.Midlight) + self.splitter.setStyleSheet(f"QSplitter::handle {{ background: {mid.name()}; }}") + + main_layout.addWidget(self.splitter, stretch=1) self.statusBar().showMessage("Ready") self.setCentralWidget(main_widget) @@ -79,16 +135,67 @@ class MainWindow(QMainWindow): ) self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled) + def _on_sidebar_item_selected(self, kind: str, value: str): + if kind == "tab": + tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0) + self.stack.setCurrentIndex(tab_index) + if tab_index == 0: + self.library_tab.set_view_mode("table") + elif kind == "filter": + self.stack.setCurrentIndex(0) + self.library_tab.set_view_mode(value) + elif kind == "playlist_create": + self._create_playlist(value) + elif kind == "playlist_rename": + playlist_id, new_name = value.split("|", 1) + self._rename_playlist(playlist_id, new_name) + elif kind == "playlist_delete": + self._delete_playlist(playlist_id=value) + elif kind == "playlist_reorder": + ids = value.split("|") if value else [] + self.playlist_manager.reorder(ids) + + def _on_playlist_selected(self, playlist_id: str): + self.stack.setCurrentIndex(0) + playlist = self.playlist_manager.get_by_id(playlist_id) + if playlist: + self.library_tab.set_current_playlist_id(playlist_id) + self.library_tab.apply_filter("playlist", playlist.track_paths) + + def _create_playlist(self, name: str): + playlist_id = self.playlist_manager.create(name) + self.sidebar.add_playlist_item(playlist_id, name) + + def _rename_playlist(self, playlist_id: str, new_name: str): + self.playlist_manager.rename(playlist_id, new_name) + + def _delete_playlist(self, playlist_id: str): + self.playlist_manager.delete(playlist_id) + self.sidebar.remove_playlist_item(playlist_id) + self.library_tab.clear_filter() + self.sidebar.select_library_music() + + def _load_playlists_into_sidebar(self): + for pl in self.playlist_manager.get_all(): + self.sidebar.add_playlist_item(pl.id, pl.name) + def _on_device_mounted(self, mount_point: str): self.library_tab.on_device_mounted(mount_point) - self.tab_widget.setTabVisible(self.ipod_tab_index, True) self.statusBar().showMessage(f"iPod mounted at {mount_point}") def _on_device_unmounted(self): self.library_tab.on_device_unmounted() - self.tab_widget.setTabVisible(self.ipod_tab_index, False) self.statusBar().showMessage("iPod disconnected") + def _toggle_sidebar(self): + visible = not self.sidebar.isVisible() + self.sidebar.setVisible(visible) + if visible: + self.splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, self.splitter.width() - SIDEBAR_DEFAULT_WIDTH]) + else: + self.splitter.setSizes([0, self.splitter.width()]) + self._sidebar_visible = visible + def _start_device_monitor(self): auto_detect = self.config_loader.get_boolean("Device", "auto_detect", fallback=True) self.device_monitor = DeviceMonitor() @@ -108,7 +215,6 @@ class MainWindow(QMainWindow): def _load_settings(self): config = self.config_loader - self.library_tab.load_player_settings(config) self.settings_tab.load_settings(config) hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False) self.library_tab.set_buttons_visible(not hide_buttons) @@ -118,25 +224,54 @@ class MainWindow(QMainWindow): self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0) self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False) + self.audio_output.setVolume(self._saved_volume / 100.0) + self.player_header.set_volume(self._saved_volume) + def _save_settings(self): config = self.config_loader self.settings_tab.save_settings(config) - self.library_tab.save_player_settings(config) + config.set("Playback", "last_volume", str(self.player_header.volume_slider.value())) + if self.current_track_path and os.path.exists(self.current_track_path): + config.set("Playback", "last_track_path", self.current_track_path) + position = self.player.position() + duration = self.player.duration() + if duration > 0 and position > duration - 2000: + position = 0 + config.set("Playback", "last_position_ms", str(position)) + config.set("Playback", "last_playing", str(self._is_playing_now).lower()) + else: + config.set("Playback", "last_track_path", "") + config.set("Playback", "last_position_ms", "0") + state = self.library_tab.get_state() + config.set("Library", "view_mode", state.get("view_mode", "table")) + config.set("Library", "filtered_album", state.get("filtered_album", "")) + config.set("Library", "filtered_playlist_id", state.get("filtered_playlist_id", "")) config.save() + @property + def current_track_path(self) -> str: + if self.player.source().isValid(): + return self.player.source().toLocalFile() + return "" + + @property + def _is_playing_now(self) -> bool: + return self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState + def _setup_hotkeys(self): self.hotkey_manager = HotkeyManager(self, self.config_loader) self.hotkey_manager.register_all({ - "play_pause": self.library_tab._on_play_pause_clicked, - "prev_track": self.library_tab._on_prev_clicked, - "next_track": self.library_tab._on_next_clicked, - "volume_up": lambda: self.library_tab._adjust_volume(5), - "volume_down": lambda: self.library_tab._adjust_volume(-5), - "seek_forward": self.library_tab._seek_forward, - "seek_backward": self.library_tab._seek_backward, - "tab_library": lambda: self.tab_widget.setCurrentIndex(0), - "tab_ipod": lambda: self.tab_widget.setCurrentIndex(self.ipod_tab_index), - "tab_settings": lambda: self.tab_widget.setCurrentIndex(2), + "play_pause": self._on_play_pause, + "prev_track": self._on_prev, + "next_track": self._on_next, + "volume_up": lambda: self._adjust_volume(5), + "volume_down": lambda: self._adjust_volume(-5), + "seek_forward": self._seek_forward, + "seek_backward": self._seek_backward, + "toggle_sidebar": self._toggle_sidebar, + "tab_library": lambda: self.stack.setCurrentIndex(0), + "tab_ipod": lambda: self.stack.setCurrentIndex(self.ipod_tab_index), + "tab_settings": lambda: self.stack.setCurrentIndex(2), "search_focus": self.library_tab._focus_search, "select_all": self.library_tab._select_all_current, "library_refresh": self.library_tab._on_refresh_library_clicked, @@ -148,6 +283,147 @@ class MainWindow(QMainWindow): self.settings_tab.set_hotkey_manager(self.hotkey_manager) self.settings_tab.refresh_shortcuts_table() + # ------------------------------------------------------------------ + # Playback + # ------------------------------------------------------------------ + + def _on_play_requested(self, track_data: dict, start_position_ms: int, auto_play: bool): + path = track_data.get("path", "") + if not path or not os.path.exists(path): + return + self._pending_seek_ms = start_position_ms + + self.player.setSource(QUrl.fromLocalFile(path)) + if auto_play: + self.player.play() + self.player_header.set_playing(True) + else: + self.player_header.set_playing(False) + + self.player_header.set_track_info( + f"{track_data['artist']} \u2014 {track_data['title']}" + ) + + cover_path = track_data.get("cover_path") + if cover_path and os.path.exists(cover_path): + self.player_header.set_cover(QPixmap(cover_path), track_data.get("album", "")) + else: + artist = track_data.get("artist", "") + album = track_data.get("album", "") + cached = cover_cache.get(artist, album) if artist else None + if cached and os.path.exists(cached): + self.player_header.set_cover(QPixmap(cached), album) + else: + self.player_header.set_cover() + + def _on_play_pause(self): + if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.player.pause() + self.player_header.set_playing(False) + self.library_tab.on_playing_changed(False) + elif self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: + self.library_tab.play_track_at_index( + max(0, self.library_tab.current_playback_index) + ) + else: + self.player.play() + self.player_header.set_playing(True) + self.library_tab.on_playing_changed(True) + + def _on_prev(self): + if self.player.position() > 3000: + self.player.setPosition(0) + return + idx = max(0, self.library_tab.current_playback_index - 1) + self.library_tab.play_track_at_index(idx) + + def _on_next(self): + rows = self.library_tab.library_table.rowCount() + idx = min(rows - 1, self.library_tab.current_playback_index + 1) + self.library_tab.play_track_at_index(idx) + + def _seek_forward(self): + if self.player.duration() <= 0: + return + new_pos = min(self.player.duration(), self.player.position() + 5000) + self.player.setPosition(new_pos) + + def _seek_backward(self): + if self.player.duration() <= 0: + return + new_pos = max(0, self.player.position() - 5000) + self.player.setPosition(new_pos) + + def _adjust_volume(self, delta: int): + slider = self.player_header.volume_slider + new_val = max(0, min(100, slider.value() + delta)) + slider.setValue(new_val) + + def _on_volume_changed(self, value: int): + if self.audio_output: + self.audio_output.setVolume(value / 100.0) + + def _on_user_seek_frac(self, frac: int): + self.player.setPosition(int(frac * self.player.duration())) + + def _on_player_position(self, position: int): + duration = self.player.duration() + self.player_header.set_position(position, duration) + self.library_tab.on_position_changed(position, duration) + + def _on_player_duration(self, duration: int): + pos = self.player.position() + self.player_header.set_position(pos, duration) + + def _on_media_status(self, status): + if status == QMediaPlayer.MediaStatus.EndOfMedia: + self.library_tab.on_track_ended() + self._on_next() + elif status == QMediaPlayer.MediaStatus.NoMedia: + self.library_tab.on_playing_changed(False) + self.player_header.reset_info() + elif status == QMediaPlayer.MediaStatus.LoadedMedia: + if self._pending_seek_ms > 0: + self.player.setPosition(self._pending_seek_ms) + self._pending_seek_ms = 0 + + def _resume_playback_if_saved(self): + if not self._saved_track_path or not os.path.exists(self._saved_track_path): + return + for row in range(self.library_tab.library_table.rowCount()): + item = self.library_tab.library_table.item(row, 0) + if item is None: + continue + _is_ready, data = item.data(Qt.ItemDataRole.UserRole) + if data and data.get("path", "") == self._saved_track_path: + self.library_tab.play_track_at_index( + row, start_position_ms=self._saved_position_ms, + auto_play=self._saved_playing + ) + return + + def _restore_library_state(self): + view_mode = self.config_loader.get("Library", "view_mode", fallback="table") + filtered_album = self.config_loader.get("Library", "filtered_album", fallback="") + filtered_playlist_id = self.config_loader.get("Library", "filtered_playlist_id", fallback="") + + if filtered_album: + self.library_tab.filter_by_album(filtered_album) + elif filtered_playlist_id: + pl = self.playlist_manager.get_by_id(filtered_playlist_id) + if pl: + self.library_tab.set_current_playlist_id(filtered_playlist_id) + self.library_tab.apply_filter("playlist", pl.track_paths) + else: + self.library_tab.set_view_mode(view_mode) + + self.sidebar.blockSignals(True) + if filtered_playlist_id: + self.sidebar.select_playlist_silent(filtered_playlist_id) + else: + self.sidebar.select_by_view_mode(view_mode) + self.sidebar.blockSignals(False) + def closeEvent(self, event): if self.device_monitor: self.device_monitor.stop() @@ -165,12 +441,6 @@ _GTK3_PLUGIN_PATHS = [ def _setup_linux_theming(): - """Configure Qt to follow the system GTK theme on Linux. - - Must be called BEFORE QApplication is created. - Respects user-set QT_QPA_PLATFORMTHEME (doesn't override it). - Falls back through available platform themes: gtk3 → qt6ct → none. - """ if sys.platform != "linux": return diff --git a/src/hotkeys.py b/src/hotkeys.py index 54c7f73..032f68a 100644 --- a/src/hotkeys.py +++ b/src/hotkeys.py @@ -93,6 +93,7 @@ DEFAULTS = [ ("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"), ("delete_selected", "Delete", "Delete Selected", "global"), ("edit_metadata", "F2", "Edit Metadata", "global"), + ("toggle_sidebar", "Ctrl+B", "Toggle Sidebar", "global"), ("library_add_files", "Ctrl+O", "Library: Add Files", "global"), ] diff --git a/src/metadata_handler.py b/src/metadata_handler.py index 8e940ab..0d49577 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -366,6 +366,7 @@ class MetadataHandler: genre=genre, download_path=file_path, cover_path=cover_path, + album_artist=album_artist, ) except Exception as e: diff --git a/src/playlist_manager.py b/src/playlist_manager.py new file mode 100644 index 0000000..d10a5ad --- /dev/null +++ b/src/playlist_manager.py @@ -0,0 +1,130 @@ +import os +import json +import uuid +import time +import logging +from typing import List, Dict, Optional + +logger = logging.getLogger(__name__) + +_PLAYLIST_FILE = os.path.join( + os.path.expanduser("~"), ".config", "neo-pod-desktop", "playlists.json" +) + + +class Playlist: + def __init__(self, name: str, playlist_id: Optional[str] = None, + track_paths: Optional[List[str]] = None, + created_at: Optional[float] = None, + updated_at: Optional[float] = None): + self.id = playlist_id or str(uuid.uuid4()) + self.name = name + self.track_paths = track_paths or [] + self.created_at = created_at or time.time() + self.updated_at = updated_at or time.time() + + def to_dict(self) -> Dict: + return { + "id": self.id, + "name": self.name, + "track_paths": self.track_paths, + "created_at": self.created_at, + "updated_at": self.updated_at, + } + + @classmethod + def from_dict(cls, data: Dict) -> "Playlist": + return cls( + name=data["name"], + playlist_id=data.get("id"), + track_paths=data.get("track_paths", []), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + ) + + +class PlaylistManager: + def __init__(self, filepath: str = _PLAYLIST_FILE): + self._filepath = filepath + self._playlists: List[Playlist] = [] + self._load() + + def _load(self): + try: + os.makedirs(os.path.dirname(self._filepath), exist_ok=True) + if os.path.exists(self._filepath): + with open(self._filepath, "r", encoding="utf-8") as f: + data = json.load(f) + self._playlists = [Playlist.from_dict(p) for p in data] + except Exception as e: + logger.error("Failed to load playlists: %s", e) + self._playlists = [] + + def save(self): + try: + os.makedirs(os.path.dirname(self._filepath), exist_ok=True) + with open(self._filepath, "w", encoding="utf-8") as f: + json.dump([p.to_dict() for p in self._playlists], f, indent=2) + except Exception as e: + logger.error("Failed to save playlists: %s", e) + + def create(self, name: str) -> str: + playlist = Playlist(name=name) + self._playlists.append(playlist) + self.save() + return playlist.id + + def rename(self, playlist_id: str, new_name: str) -> bool: + for p in self._playlists: + if p.id == playlist_id: + p.name = new_name + p.updated_at = time.time() + self.save() + return True + return False + + def delete(self, playlist_id: str) -> bool: + for i, p in enumerate(self._playlists): + if p.id == playlist_id: + del self._playlists[i] + self.save() + return True + return False + + def add_tracks(self, playlist_id: str, paths: List[str]) -> bool: + for p in self._playlists: + if p.id == playlist_id: + existing = set(p.track_paths) + for path in paths: + if path not in existing: + p.track_paths.append(path) + existing.add(path) + p.updated_at = time.time() + self.save() + return True + return False + + def remove_tracks(self, playlist_id: str, paths: List[str]) -> bool: + for p in self._playlists: + if p.id == playlist_id: + path_set = set(paths) + p.track_paths = [t for t in p.track_paths if t not in path_set] + p.updated_at = time.time() + self.save() + return True + return False + + def reorder(self, playlist_ids: List[str]) -> bool: + id_order = {pid: i for i, pid in enumerate(playlist_ids)} + self._playlists.sort(key=lambda p: id_order.get(p.id, 999)) + self.save() + return True + + def get_all(self) -> List[Playlist]: + return list(self._playlists) + + def get_by_id(self, playlist_id: str) -> Optional[Playlist]: + for p in self._playlists: + if p.id == playlist_id: + return p + return None diff --git a/src/track_info.py b/src/track_info.py index 628a811..1a55e54 100644 --- a/src/track_info.py +++ b/src/track_info.py @@ -20,6 +20,7 @@ class TrackInfo: playlist_title: Optional[str] = None download_path: Optional[str] = None genre: Optional[str] = None + album_artist: Optional[str] = None cover_path: Optional[str] = None play_count: int = 0 rating: int = 0 diff --git a/src/ui/library_tab.py b/src/ui/library_tab.py index e77c195..6714674 100644 --- a/src/ui/library_tab.py +++ b/src/ui/library_tab.py @@ -14,11 +14,11 @@ from pathlib import Path from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QMessageBox, QGroupBox, QHeaderView, - QTableWidget, QTableWidgetItem, QSlider, QMenu, QStyle, QProxyStyle + QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog, + QStackedWidget, QListWidget, QListWidgetItem, QListView, ) -from PyQt6.QtCore import Qt, QUrl, QTimer, pyqtSignal -from PyQt6.QtGui import QPixmap, QColor -from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize +from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath from track_info import TrackInfo from metadata_handler import MetadataHandler @@ -41,19 +41,20 @@ EQ_FRAMES = [ "\u2585\u2586\u2583", "\u2586\u2584\u2581", ] - -class JumpStyle(QProxyStyle): - def styleHint(self, hint, opt=None, widget=None, returnData=None): - if hint == QStyle.StyleHint.SH_Slider_AbsoluteSetButtons: - return Qt.MouseButton.LeftButton.value - return super().styleHint(hint, opt, widget, returnData) +class NumericTableItem(QTableWidgetItem): + def __lt__(self, other): + try: + return int(self.text()) < int(other.text()) + except ValueError: + return super().__lt__(other) class LibraryTab(QWidget): - """Library tab widget with playback, conversion and transfer.""" + """Library tab widget with library table, filtering, and transfer workflows.""" transfer_finished = pyqtSignal() tab_switch_requested = pyqtSignal(int) + play_requested = pyqtSignal(dict, int, bool) def __init__(self, config_loader: ConfigLoader, parent=None): super().__init__(parent) @@ -63,19 +64,16 @@ class LibraryTab(QWidget): self.library_source_files: List[str] = [] self.library_scanned_sources: List[Dict] = [] - self.player: Optional[QMediaPlayer] = None - self.audio_output: Optional[QAudioOutput] = None self.current_playback_index: int = -1 + self._current_track_path: str = "" + self._eq_active: bool = False 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 = "" - self._saved_position_ms: int = 0 - self._saved_playing: bool = False - self._pending_seek_ms: int = 0 self._halfway_passed: set[int] = set() + self._current_view_mode: str = "table" + self._current_filtered_album: str = "" + self._current_playlist_id: str = "" self._library_toolbar_buttons: list = [] @@ -84,7 +82,9 @@ class LibraryTab(QWidget): self._current_mount_point: Optional[str] = None self._setup_ui() - self._setup_player() + self._eq_timer = QTimer() + self._eq_timer.setInterval(80) + self._eq_timer.timeout.connect(self._tick_eq_animation) def set_mount_point(self, mount_point: Optional[str]): self._current_mount_point = mount_point @@ -92,76 +92,6 @@ class LibraryTab(QWidget): def _setup_ui(self): layout = QVBoxLayout(self) - playback_group = QGroupBox() - playback_layout = QVBoxLayout(playback_group) - playback_layout.setContentsMargins(8, 6, 8, 6) - - controls_row = QHBoxLayout() - controls_row.setSpacing(8) - - self.prev_btn = QPushButton("\u23EE") - self.prev_btn.setFixedWidth(36) - self.prev_btn.clicked.connect(self._on_prev_clicked) - - self.play_btn = QPushButton("\u25B6") - self.play_btn.setFixedWidth(44) - self.play_btn.clicked.connect(self._on_play_pause_clicked) - - self.next_btn = QPushButton("\u23ED") - self.next_btn.setFixedWidth(36) - self.next_btn.clicked.connect(self._on_next_clicked) - - controls_row.addWidget(self.prev_btn) - controls_row.addWidget(self.play_btn) - controls_row.addWidget(self.next_btn) - - self.time_label_start = QLabel("0:00") - 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.setStyle(JumpStyle(self.position_slider.style())) - 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.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) - self.time_label_end.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - - controls_row.addWidget(self.time_label_start) - controls_row.addWidget(self.position_slider, stretch=1) - controls_row.addWidget(self.time_label_end) - - vol_icon = QLabel("\U0001F50A") - self.volume_slider = QSlider(Qt.Orientation.Horizontal) - self.volume_slider.setFixedWidth(80) - self.volume_slider.setRange(0, 100) - self.volume_slider.setValue(80) - self.volume_slider.valueChanged.connect(self._on_volume_changed) - - controls_row.addWidget(vol_icon) - controls_row.addWidget(self.volume_slider) - playback_layout.addLayout(controls_row) - - now_playing_layout = QHBoxLayout() - self.cover_label = QLabel() - self.cover_label.setFixedSize(60, 60) - self.cover_label.setScaledContents(True) - self.cover_label.setStyleSheet("background: palette(window); border-radius: 4px;") - now_playing_layout.addWidget(self.cover_label) - - self.now_playing_label = QLabel("Select a track to play") - self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) - self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;") - now_playing_layout.addWidget(self.now_playing_label, stretch=1) - playback_layout.addLayout(now_playing_layout) - - layout.addWidget(playback_group) - self._setup_library_toolbar(layout) self.library_table = QTableWidget() @@ -177,6 +107,7 @@ class LibraryTab(QWidget): self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked) self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.library_table.customContextMenuRequested.connect(self._on_library_table_context_menu) + self.library_table.horizontalHeader().sortIndicatorChanged.connect(self._on_sort_changed) header = self.library_table.horizontalHeader() header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) @@ -188,7 +119,28 @@ class LibraryTab(QWidget): header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) - layout.addWidget(self.library_table, stretch=1) + self.view_stack = QStackedWidget() + + self.library_table.setParent(self.view_stack) + self.view_stack.addWidget(self.library_table) + + self.album_grid = QListWidget() + self.album_grid.setViewMode(QListView.ViewMode.IconMode) + self.album_grid.setIconSize(QSize(140, 140)) + self.album_grid.setGridSize(QSize(170, 210)) + self.album_grid.setWordWrap(True) + self.album_grid.setResizeMode(QListWidget.ResizeMode.Adjust) + self.album_grid.setSpacing(8) + self.album_grid.setMovement(QListWidget.Movement.Static) + self.album_grid.setStyleSheet("QListWidget { padding: 24px 12px 12px 12px; }") + self.album_grid.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + self.album_grid.setFlow(QListWidget.Flow.LeftToRight) + self.album_grid.setWrapping(True) + self.album_grid.itemClicked.connect(self._on_album_clicked) + self.view_stack.addWidget(self.album_grid) + + self.view_stack.setCurrentIndex(0) + layout.addWidget(self.view_stack, stretch=1) self.library_progress = QProgressBar() self.library_progress.setRange(0, 100) @@ -258,21 +210,7 @@ class LibraryTab(QWidget): layout.addLayout(toolbar) - def _setup_player(self): - self.player = QMediaPlayer() - self.audio_output = QAudioOutput() - self.audio_output.setVolume(self._saved_volume / 100.0) - self.player.setAudioOutput(self.audio_output) - - self.player.positionChanged.connect(self._on_media_position_changed) - 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): + def play_track_at_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True): if index < 0 or index >= self.library_table.rowCount(): return data_item = self.library_table.item(index, 0) @@ -287,98 +225,34 @@ class LibraryTab(QWidget): self._halfway_passed.discard(old_index) self.current_playback_index = index - self.player.setSource(QUrl.fromLocalFile(track_data["path"])) - if auto_play: - self.player.play() - self._is_playing = True - self.play_btn.setText("\u23F8") - else: - self._is_playing = False - self.play_btn.setText("\u25B6") - - self.now_playing_label.setText(f"{track_data['artist']} \u2014 {track_data['title']}") - self.now_playing_label.setStyleSheet( - "color: palette(text); font-weight: bold;" - ) - - cover_path = track_data.get("cover_path") - if cover_path and os.path.exists(cover_path): - pixmap = QPixmap(cover_path) - self.cover_label.setPixmap(pixmap) - self.cover_label.setToolTip(track_data.get("album", "")) - else: - artist = track_data.get("artist", "") - album = track_data.get("album", "") - cached = cover_cache.get(artist, album) if artist else None - if cached and os.path.exists(cached): - pixmap = QPixmap(cached) - self.cover_label.setPixmap(pixmap) - self.cover_label.setToolTip(album) - else: - self.cover_label.clear() - self.cover_label.setToolTip("") + self._current_track_path = track_data["path"] 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: - self._pending_seek_ms = 0 + self.play_requested.emit(track_data, start_position_ms, auto_play) - def _on_play_pause_clicked(self): - if self.player is None: - return - if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: - self.player.pause() - self._is_playing = False - self.play_btn.setText("\u25B6") + def on_position_changed(self, position_ms: int, duration_ms: int): + if duration_ms > 0 and position_ms >= duration_ms * 0.5: + self._halfway_passed.add(self.current_playback_index) + + def on_track_ended(self): + self._increment_play_count() + self._stop_eq_animation() + + def on_track_paused(self): + self._stop_eq_animation() + + def on_playing_changed(self, is_playing: bool): + self._eq_active = is_playing + if is_playing: + self._start_eq_animation() + else: self._stop_eq_animation() - else: - if self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState: - selected_rows = {item.row() for item in self.library_table.selectedItems()} - start = min(selected_rows) if selected_rows else 0 - self._play_track_from_index(start) - else: - 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: - return - if self.player.position() > 3000: - self.player.setPosition(0) - return - idx = max(0, self.current_playback_index - 1) - self._play_track_from_index(idx) - - def _on_next_clicked(self): - if self.player is None: - return - idx = min(self.library_table.rowCount() - 1, self.current_playback_index + 1) - self._play_track_from_index(idx) - - def _seek_forward(self): - if self.player is None or self.player.duration() <= 0: - return - new_pos = min(self.player.duration(), self.player.position() + 5000) - self.player.setPosition(new_pos) - - def _seek_backward(self): - if self.player is None or self.player.duration() <= 0: - return - new_pos = max(0, self.player.position() - 5000) - self.player.setPosition(new_pos) - - def _adjust_volume(self, delta: int): - new_val = max(0, min(100, self.volume_slider.value() + delta)) - self.volume_slider.setValue(new_val) def _focus_search(self): self.library_search_input.setFocus() @@ -390,50 +264,6 @@ class LibraryTab(QWidget): def _delete_selected_current(self): self._on_library_remove_selected() - def _on_slider_pressed(self): - if self.player: - duration = self.player.duration() - new_pos = int(self.position_slider.value() / 1000.0 * duration) - new_pos = max(0, min(duration - 1, new_pos)) - self.player.setPosition(new_pos) - - def _on_slider_released(self): - pass - - def _on_volume_changed(self, value): - if self.audio_output: - self.audio_output.setVolume(value / 100.0) - - def _on_media_position_changed(self, position: int): - if self.position_slider is None or self.player is None: - return - duration = self.player.duration() - if duration > 0 and not self.position_slider.isSliderDown(): - self.position_slider.setValue(int(position / duration * 1000)) - self.time_label_start.setText(self._format_time_ms(position)) - if duration > 0 and position >= duration * 0.5: - self._halfway_passed.add(self.current_playback_index) - - def _on_media_duration_changed(self, duration: int): - self.time_label_end.setText(self._format_time_ms(duration)) - if self.position_slider and not self.position_slider.isSliderDown(): - self.position_slider.setValue(0) - - def _on_media_status_changed(self, status): - if status == QMediaPlayer.MediaStatus.EndOfMedia: - self._increment_play_count() - self._stop_eq_animation() - self._on_next_clicked() - elif status == QMediaPlayer.MediaStatus.NoMedia: - self._halfway_passed.discard(self.current_playback_index) - self._stop_eq_animation() - self.play_btn.setText("\u25B6") - self._is_playing = False - elif status == QMediaPlayer.MediaStatus.LoadedMedia: - if self._pending_seek_ms > 0: - self.player.setPosition(self._pending_seek_ms) - self._pending_seek_ms = 0 - def _increment_play_count(self): idx = self.current_playback_index if idx not in self._halfway_passed: @@ -475,8 +305,8 @@ class LibraryTab(QWidget): return self._eq_timer.stop() self._eq_running = False - idx = self.current_playback_index - if idx >= 0 and idx < self.library_table.rowCount(): + idx = self._find_row_by_path(self._current_track_path) if self._current_track_path else -1 + if idx >= 0: item = self.library_table.item(idx, 0) if item: is_ready, data = item.data(Qt.ItemDataRole.UserRole) @@ -485,8 +315,10 @@ class LibraryTab(QWidget): 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(): + if not self._current_track_path: + return + idx = self._find_row_by_path(self._current_track_path) + if idx < 0: return item = self.library_table.item(idx, 0) if item is None: @@ -531,14 +363,27 @@ class LibraryTab(QWidget): 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) + def _find_row_by_path(self, path: str) -> int: + if not path: + return -1 + 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", "") == path: + return row + return -1 - @staticmethod - def _format_time_ms(ms: int) -> str: - total_seconds = ms // 1000 - m, s = divmod(total_seconds, 60) - return f"{m}:{s:02d}" + def _on_sort_changed(self, _column: int, _order: int): + if self._current_track_path: + idx = self._find_row_by_path(self._current_track_path) + if idx >= 0: + self.current_playback_index = idx + self._set_playing_row_bg(idx) + + def _on_table_double_clicked(self, row: int): + self.play_track_at_index(row) def _format_duration(self, seconds: int) -> str: if seconds <= 0: @@ -615,18 +460,6 @@ class LibraryTab(QWidget): self.library_scanned_sources = sources self._refresh_library_ui() - def _resume_playback_if_saved(self): - if not self._saved_track_path or not os.path.exists(self._saved_track_path): - return - for row in range(self.library_table.rowCount()): - data_item = self.library_table.item(row, 0) - if data_item is None: - continue - _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) - if track_data and track_data.get("path", "") == self._saved_track_path: - self._play_track_from_index(row, start_position_ms=self._saved_position_ms, auto_play=self._saved_playing) - return - def _extract_ready_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]: size = os.path.getsize(fpath) cover_path = None @@ -639,6 +472,7 @@ class LibraryTab(QWidget): genre = info.genre if info else "" track_num = info.track_number if info else 0 cover_path = info.cover_path if info else None + album_artist = info.album_artist if info else "" except Exception: duration = 0 artist = "Unknown" @@ -646,10 +480,12 @@ class LibraryTab(QWidget): album = "" genre = "" track_num = 0 + album_artist = "" return { "path": fpath, "title": title, "artist": artist, "album": album, "duration_s": duration, "size": size, "genre": genre, "track_num": track_num, "cover_path": cover_path, + "album_artist": album_artist, } def _extract_source_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]: @@ -731,7 +567,7 @@ class LibraryTab(QWidget): duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014" cells = [ - QTableWidgetItem(track_num_str), + NumericTableItem(track_num_str), QTableWidgetItem(track["artist"]), QTableWidgetItem(track["title"]), QTableWidgetItem(track.get("album", "") or ""), @@ -777,14 +613,13 @@ class LibraryTab(QWidget): self.library_status.setText(status) self._restore_playing_row() + self._build_album_grid() 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() + track_path = self._current_track_path if not track_path and idx < self.library_table.rowCount(): item = self.library_table.item(idx, 0) if item: @@ -804,7 +639,7 @@ class LibraryTab(QWidget): if data and data.get("path") == track_path: self.current_playback_index = row self._set_playing_row_bg(row) - if self._is_playing: + if self._eq_active: self._start_eq_animation() return @@ -1027,7 +862,7 @@ class LibraryTab(QWidget): menu = QMenu(self) play_action = menu.addAction("\u25B6 Play") - play_action.triggered.connect(lambda: self._play_track_from_index(row)) + play_action.triggered.connect(lambda: self.play_track_at_index(row)) if is_ready and self._current_mount_point is not None: sync_action = menu.addAction("\uD83D\uDD04 Sync Metadata to iPod") @@ -1038,6 +873,20 @@ class LibraryTab(QWidget): convert_action = menu.addAction("\uD83D\uDD04 Convert to iPod Format") convert_action.triggered.connect(self._on_library_convert_clicked) + add_pl_menu = menu.addMenu("Add to Playlist") + playlist_manager = getattr(self, "_playlist_manager", None) + if playlist_manager is not None: + for pl in playlist_manager.get_all(): + pl_action = add_pl_menu.addAction(f"\U0001F4CB {pl.name}") + pl_action.triggered.connect( + lambda checked, pid=pl.id: self._add_selected_to_playlist(pid) + ) + add_pl_menu.addSeparator() + new_pl_action = add_pl_menu.addAction("\u2795 New Playlist...") + new_pl_action.triggered.connect(self._add_selected_to_new_playlist) + else: + add_pl_menu.setEnabled(False) + menu.addSeparator() edit_action = menu.addAction("\u270F\uFE0F Edit Metadata") @@ -1051,6 +900,38 @@ class LibraryTab(QWidget): menu.exec(self.library_table.viewport().mapToGlobal(pos)) + def _add_selected_to_playlist(self, playlist_id: str): + paths = self._get_selected_track_paths() + if not paths: + return + playlist_manager = getattr(self, "_playlist_manager", None) + if playlist_manager: + playlist_manager.add_tracks(playlist_id, paths) + + def _add_selected_to_new_playlist(self): + from PyQt6.QtWidgets import QInputDialog + name, ok = QInputDialog.getText(self, "New Playlist", "Playlist name:") + if not (ok and name.strip()): + return + name = name.strip() + playlist_manager = getattr(self, "_playlist_manager", None) + if playlist_manager is None: + return + paths = self._get_selected_track_paths() + playlist_id = playlist_manager.create(name) + if paths: + playlist_manager.add_tracks(playlist_id, paths) + self._notify_sidebar_playlist_created(playlist_id, name) + + def _notify_sidebar_playlist_created(self, playlist_id: str, name: str): + parent = self.parent() + while parent is not None: + sidebar = getattr(parent, "sidebar", None) + if sidebar is not None: + sidebar.add_playlist_item(playlist_id, name) + break + parent = parent.parent() + def _on_show_in_file_manager(self, track_data): path = track_data.get("path", "") if not path: @@ -1261,31 +1142,6 @@ class LibraryTab(QWidget): self.worker_thread.deleteLater() self.worker_thread = None - def load_player_settings(self, config): - 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) - self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False) - - def save_player_settings(self, config): - config.set("Playback", "last_volume", str(self.volume_slider.value())) - if self.current_playback_index >= 0 and self.player.source().isValid(): - track_path = self.player.source().toLocalFile() - if track_path and os.path.exists(track_path): - config.set("Playback", "last_track_path", track_path) - position = self.player.position() - duration = self.player.duration() - if duration > 0 and position > duration - 2000: - position = 0 - config.set("Playback", "last_position_ms", str(position)) - config.set("Playback", "last_playing", str(self._is_playing).lower()) - else: - config.set("Playback", "last_track_path", "") - config.set("Playback", "last_position_ms", "0") - else: - config.set("Playback", "last_track_path", "") - config.set("Playback", "last_position_ms", "0") - def on_device_mounted(self, mount_point: str): self._current_mount_point = mount_point self.library_progress.setVisible(True) @@ -1298,6 +1154,153 @@ class LibraryTab(QWidget): self.library_progress.setVisible(False) self.library_status.setVisible(False) + def set_playlist_manager(self, playlist_manager): + self._playlist_manager = playlist_manager + + def _get_selected_track_paths(self) -> list: + paths = [] + for item in self.library_table.selectedItems(): + data_item = self.library_table.item(item.row(), 0) + if data_item is None: + continue + _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + if track_data and track_data.get("path"): + paths.append(track_data["path"]) + return list(set(paths)) + + def apply_filter(self, kind: str, value): + if kind == "playlist": + self._apply_playlist_filter(value) + + def set_view_mode(self, mode: str): + """mode: 'table', 'recent', 'artists', 'albums', 'songs'""" + self._current_view_mode = mode + self._current_filtered_album = "" + self._current_playlist_id = "" + self.view_stack.setCurrentIndex(0) + self._unhide_all_rows() + + if mode == "albums": + self.view_stack.setCurrentIndex(1) + self.album_grid.clearSelection() + elif mode == "artists": + self.library_table.sortItems(1, Qt.SortOrder.AscendingOrder) + elif mode == "songs": + self.library_table.sortItems(2, Qt.SortOrder.AscendingOrder) + elif mode == "recent": + cutoff = int(time.time()) - (30 * 86400) + cache = get_library_cache() + for row in range(self.library_table.rowCount()): + data_item = self.library_table.item(row, 0) + if data_item is None: + continue + _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + path = track_data.get("path", "") if track_data else "" + entry = cache.get(path) if path else None + visible = False + if entry and isinstance(entry, dict): + added = entry.get("_mtime", 0) or entry.get("added_at", 0) or 0 + visible = added >= cutoff + self.library_table.setRowHidden(row, not visible) + + def clear_filter(self): + self._unhide_all_rows() + + def _unhide_all_rows(self): + for row in range(self.library_table.rowCount()): + self.library_table.setRowHidden(row, False) + + def _round_pixmap(self, source: QPixmap, radius: int) -> QPixmap: + size = min(source.width(), source.height()) + result = QPixmap(size, size) + result.fill(Qt.GlobalColor.transparent) + p = QPainter(result) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + path = QPainterPath() + path.addRoundedRect(0, 0, size, size, radius, radius) + p.setClipPath(path) + p.drawPixmap(0, 0, source.scaled(size, size, Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation)) + p.end() + return result + + def _build_album_grid(self): + self.album_grid.clear() + albums = {} + for track in self.library_ready: + album = track.get("album", "") or "Unknown Album" + key = album.lower() + if key not in albums: + albums[key] = (album, track.get("cover_path", "")) + for track in self.library_ready: + album = track.get("album", "") or "Unknown Album" + key = album.lower() + if key in albums and not albums[key][1]: + albums[key] = (album, track.get("cover_path", "")) + + for album, cover_path in albums.values(): + pixmap = None + if cover_path and os.path.exists(cover_path): + pixmap = QPixmap(cover_path) + if pixmap is None or pixmap.isNull(): + for track in self.library_ready: + t_album = track.get("album", "") or "Unknown Album" + if t_album.lower() == album.lower() and track.get("cover_path"): + cached = cover_cache.get( + track.get("album_artist") or track.get("artist", ""), + album, + ) + if cached and os.path.exists(cached): + pixmap = QPixmap(cached) + break + if pixmap is None or pixmap.isNull(): + pixmap = QPixmap(140, 140) + clr = self.palette().color(self.palette().ColorRole.Midlight) + pixmap.fill(clr) + + scaled = pixmap.scaled(140, 140, Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation) + rounded = self._round_pixmap(scaled, 8) + icon = QIcon(rounded) + item = QListWidgetItem(icon, album) + item.setData(Qt.ItemDataRole.UserRole, album) + self.album_grid.addItem(item) + + def _on_album_clicked(self, item: QListWidgetItem): + album = item.data(Qt.ItemDataRole.UserRole) + self.album_grid.clearSelection() + self.filter_by_album(album) + + def filter_by_album(self, album: str): + if not album: + return + self._current_filtered_album = album + self._current_view_mode = "albums" + self.view_stack.setCurrentIndex(0) + album_lower = album.lower() + for row in range(self.library_table.rowCount()): + t_item = self.library_table.item(row, 0) + if t_item is None: + self.library_table.setRowHidden(row, False) + continue + _is_ready, track_data = t_item.data(Qt.ItemDataRole.UserRole) + if not track_data: + self.library_table.setRowHidden(row, True) + continue + t_album = (track_data.get("album") or "").lower() + self.library_table.setRowHidden(row, t_album != album_lower) + + def _apply_playlist_filter(self, track_paths): + path_set = set(os.path.normpath(p) for p in track_paths) + for row in range(self.library_table.rowCount()): + data_item = self.library_table.item(row, 0) + if data_item is None: + self.library_table.setRowHidden(row, False) + continue + _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + row_path = os.path.normpath(track_data.get("path", "")) if track_data else "" + self.library_table.setRowHidden(row, row_path not in path_set) + def set_buttons_visible(self, visible: bool): for btn in self._library_toolbar_buttons: btn.setVisible(visible) @@ -1310,3 +1313,14 @@ class LibraryTab(QWidget): def get_convert_quality(self): return self.config_loader.get_int("General", "quality", fallback=256) + + def set_current_playlist_id(self, playlist_id: str): + self._current_playlist_id = playlist_id + self._current_view_mode = "playlist" + + def get_state(self) -> dict: + return { + "view_mode": self._current_view_mode, + "filtered_album": self._current_filtered_album, + "filtered_playlist_id": self._current_playlist_id, + } diff --git a/src/ui/player_header.py b/src/ui/player_header.py new file mode 100644 index 0000000..0fd4fe9 --- /dev/null +++ b/src/ui/player_header.py @@ -0,0 +1,137 @@ +from PyQt6.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QSlider, QGroupBox, QStyle, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QPixmap, QColor + + +def _fmt_time(ms: int) -> str: + total = ms // 1000 + m, s = divmod(total, 60) + return f"{m}:{s:02d}" + + +class PlayerHeader(QGroupBox): + prev_requested = pyqtSignal() + play_pause_requested = pyqtSignal() + next_requested = pyqtSignal() + position_changed_by_user = pyqtSignal(int) + volume_changed = pyqtSignal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFlat(True) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + + controls_row = QHBoxLayout() + controls_row.setSpacing(8) + + self.prev_btn = QPushButton("\u23EE") + self.prev_btn.setFixedWidth(36) + self.prev_btn.setToolTip("Previous") + self.prev_btn.clicked.connect(self.prev_requested) + + self.play_btn = QPushButton("\u25B6") + self.play_btn.setFixedWidth(44) + self.play_btn.setToolTip("Play / Pause") + self.play_btn.clicked.connect(self.play_pause_requested) + + self.next_btn = QPushButton("\u23ED") + self.next_btn.setFixedWidth(36) + self.next_btn.setToolTip("Next") + self.next_btn.clicked.connect(self.next_requested) + + controls_row.addWidget(self.prev_btn) + controls_row.addWidget(self.play_btn) + controls_row.addWidget(self.next_btn) + + self.time_label_start = QLabel("0:00") + 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.setMinimum(0) + self.position_slider.setMaximum(1000) + self.position_slider.setValue(0) + self.position_slider.sliderPressed.connect(self._on_slider_user_action) + self.position_slider.sliderMoved.connect(self._on_slider_user_action) + self.position_slider.sliderReleased.connect(self._on_slider_user_action) + + self.time_label_end = QLabel("0:00") + self.time_label_end.setFixedWidth(40) + self.time_label_end.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + + controls_row.addWidget(self.time_label_start) + controls_row.addWidget(self.position_slider, stretch=1) + controls_row.addWidget(self.time_label_end) + + vol_icon = QLabel("\U0001F50A") + self.volume_slider = QSlider(Qt.Orientation.Horizontal) + self.volume_slider.setFixedWidth(80) + self.volume_slider.setRange(0, 100) + self.volume_slider.setValue(80) + self.volume_slider.valueChanged.connect(self.volume_changed) + + controls_row.addWidget(vol_icon) + controls_row.addWidget(self.volume_slider) + layout.addLayout(controls_row) + + now_playing_layout = QHBoxLayout() + now_playing_layout.setContentsMargins(6, 0, 0, 0) + self.cover_label = QLabel() + self.cover_label.setFixedSize(60, 60) + self.cover_label.setScaledContents(True) + self.cover_label.setStyleSheet("background: palette(window); border-radius: 6px;") + now_playing_layout.addWidget(self.cover_label) + + self.now_playing_label = QLabel("Select a track to play") + self.now_playing_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;") + now_playing_layout.addWidget(self.now_playing_label, stretch=1) + layout.addLayout(now_playing_layout) + + def _on_slider_user_action(self): + frac = self.position_slider.value() / 1000.0 + self.position_changed_by_user.emit(int(frac)) + + def set_cover(self, pixmap: QPixmap = None, tooltip: str = ""): + if pixmap: + self.cover_label.setPixmap(pixmap) + else: + self.cover_label.clear() + self.cover_label.setToolTip(tooltip) + + def set_track_info(self, text: str): + self.now_playing_label.setText(text) + self.now_playing_label.setStyleSheet( + "color: palette(text); font-weight: bold;" if text else "color: palette(mid); font-size: 12px;" + ) + + def set_playing(self, playing: bool): + self.play_btn.setText("\u23F8" if playing else "\u25B6") + + def set_position(self, position_ms: int, duration_ms: int): + if not self.position_slider.isSliderDown(): + if duration_ms > 0: + self.position_slider.setValue(int(position_ms / duration_ms * 1000)) + else: + self.position_slider.setValue(0) + self.time_label_start.setText(_fmt_time(position_ms)) + self.time_label_end.setText(_fmt_time(duration_ms)) + + def set_volume(self, value: int): + self.volume_slider.setValue(value) + + def reset_info(self): + self.cover_label.clear() + self.cover_label.setToolTip("") + self.now_playing_label.setText("Select a track to play") + self.now_playing_label.setStyleSheet("color: palette(mid); font-size: 12px;") + self.time_label_start.setText("0:00") + self.time_label_end.setText("0:00") + self.position_slider.setValue(0) + self.play_btn.setText("\u25B6") diff --git a/src/ui/sidebar_widget.py b/src/ui/sidebar_widget.py new file mode 100644 index 0000000..c036f1b --- /dev/null +++ b/src/ui/sidebar_widget.py @@ -0,0 +1,307 @@ +import logging +from typing import List, Optional + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QPushButton, + QListWidget, QListWidgetItem, QMenu, QInputDialog, QMessageBox, + QFrame, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor + +logger = logging.getLogger(__name__) + +SIDEBAR_MIN_WIDTH = 48 +SIDEBAR_MAX_WIDTH = 240 +SIDEBAR_DEFAULT_WIDTH = 200 + + +class SidebarWidget(QWidget): + item_selected = pyqtSignal(str, str) + playlist_selected = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("sidebar") + self.setMinimumWidth(SIDEBAR_MIN_WIDTH) + self.setMaximumWidth(SIDEBAR_MAX_WIDTH) + self.setFixedWidth(SIDEBAR_DEFAULT_WIDTH) + self.setAutoFillBackground(True) + + p = self.palette() + self._bg = p.color(p.ColorRole.Window) + self._text = p.color(p.ColorRole.WindowText) + self._dim_text = p.color(p.ColorRole.PlaceholderText) + if not self._dim_text.isValid(): + self._dim_text = self._text.darker(160) + self._border = p.color(p.ColorRole.Midlight) + if not self._border.isValid(): + self._border = self._bg.darker(120) + + hl = p.color(p.ColorRole.Highlight) + hl_light = QColor(hl) + hl_light.setAlpha(60) + hl_text = p.color(p.ColorRole.HighlightedText) + + self._apply_palette_style(hl, hl_light, hl_text) + self._build_ui() + + def _apply_palette_style(self, hl: QColor, hl_light: QColor, hl_text: QColor): + bg_name = self._bg.name() + border_name = self._border.name() + dim_name = self._dim_text.name() + text_name = self._text.name() + hl_name = hl.name() + hl_light_name = hl_light.name() + hl_text_name = hl_text.name() + + self.setStyleSheet(f""" + QWidget#sidebar {{ + background: {bg_name}; + border-right: 1px solid {border_name}; + }} + QListWidget {{ + background: transparent; + border: none; + outline: none; + font-size: 13px; + color: {text_name}; + }} + QListWidget::item {{ + padding: 4px 12px; + border-radius: 4px; + }} + QListWidget::item:selected {{ + background: {hl_name}; + color: {hl_text_name}; + }} + QListWidget::item:hover:!selected {{ + background: {hl_light_name}; + }} + """) + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 8) + layout.setSpacing(0) + + dim_color = self._dim_text.name() + section_style = f""" + color: {dim_color}; + font-size: 11px; + font-weight: bold; + letter-spacing: 1px; + padding: 8px 12px 4px 12px; + background: transparent; + """ + + header = QLabel("LIBRARY") + header.setStyleSheet(section_style) + layout.addWidget(header) + + self._library_list = QListWidget() + items = [ + ("\U0001F4BF Music", "tab", "library"), + ("\u23F1 Recently Added", "filter", "recent"), + ("\U0001F3A4 Artists", "filter", "artists"), + ("\U0001F4BF Albums", "filter", "albums"), + ("\U0001F3B5 Songs", "filter", "songs"), + ] + for label, kind, value in items: + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole + 1, kind) + item.setData(Qt.ItemDataRole.UserRole + 2, value) + self._library_list.addItem(item) + self._library_list.itemClicked.connect(self._on_library_item_clicked) + self._library_list.setCurrentRow(0) + layout.addWidget(self._library_list) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setStyleSheet(f"color: {self._border.name()}; margin: 4px 12px;") + line.setFixedHeight(1) + layout.addWidget(line) + + self._playlists_header = QLabel("PLAYLISTS") + self._playlists_header.setStyleSheet(section_style) + layout.addWidget(self._playlists_header) + + self._playlist_list = QListWidget() + self._playlist_list.setDragDropMode(QListWidget.DragDropMode.InternalMove) + self._playlist_list.setDefaultDropAction(Qt.DropAction.MoveAction) + self._playlist_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self._playlist_list.customContextMenuRequested.connect( + self._on_playlist_context_menu + ) + self._playlist_list.itemClicked.connect(self._on_playlist_clicked) + self._playlist_list.model().rowsMoved.connect(self._on_playlists_reordered) + layout.addWidget(self._playlist_list, stretch=1) + + btn_style = f""" + QPushButton {{ + text-align: left; + padding: 6px 12px; + border: none; + color: {dim_color}; + font-size: 12px; + }} + QPushButton:hover {{ + color: {self._text.name()}; + background: {self._border.name()}; + }} + """ + self._new_playlist_btn = QPushButton(" \u2795 New Playlist") + self._new_playlist_btn.setFlat(True) + self._new_playlist_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._new_playlist_btn.setStyleSheet(btn_style) + self._new_playlist_btn.clicked.connect(self._on_new_playlist) + layout.addWidget(self._new_playlist_btn) + + line2 = QFrame() + line2.setFrameShape(QFrame.Shape.HLine) + line2.setStyleSheet(f"color: {self._border.name()}; margin: 4px 12px;") + line2.setFixedHeight(1) + layout.addWidget(line2) + + self._nav_list = QListWidget() + items = [ + ("🎸 iPod", "ipod"), + ("⚙️ Settings", "settings"), + ] + for label, tab_id in items: + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole + 1, "tab") + item.setData(Qt.ItemDataRole.UserRole + 2, tab_id) + self._nav_list.addItem(item) + self._nav_list.itemClicked.connect(self._on_nav_item_clicked) + layout.addWidget(self._nav_list) + + self._playlist_items: dict = {} + + def _on_library_item_clicked(self, item: QListWidgetItem): + kind = item.data(Qt.ItemDataRole.UserRole + 1) + value = item.data(Qt.ItemDataRole.UserRole + 2) + self._library_list.setCurrentItem(item) + self._playlist_list.clearSelection() + self._nav_list.clearSelection() + self.item_selected.emit(kind, value) + + def _on_playlist_clicked(self, item: QListWidgetItem): + playlist_id = item.data(Qt.ItemDataRole.UserRole) + self._library_list.clearSelection() + self._playlist_list.setCurrentItem(item) + self._nav_list.clearSelection() + self.playlist_selected.emit(playlist_id) + + def _on_playlist_context_menu(self, pos): + item = self._playlist_list.itemAt(pos) + if item is None: + return + menu = QMenu(self) + rename_action = menu.addAction("Rename") + delete_action = menu.addAction("Delete") + + action = menu.exec(self._playlist_list.viewport().mapToGlobal(pos)) + playlist_id = item.data(Qt.ItemDataRole.UserRole) + if action == rename_action: + self._rename_playlist(item, playlist_id) + elif action == delete_action: + self._delete_playlist(item, playlist_id) + + def _rename_playlist(self, item: QListWidgetItem, playlist_id: str): + current = item.text() + name, ok = QInputDialog.getText( + self, "Rename Playlist", "New name:", text=current + ) + if ok and name.strip(): + item.setText(name.strip()) + self.item_selected.emit("playlist_rename", playlist_id + "|" + name.strip()) + + def _delete_playlist(self, item: QListWidgetItem, playlist_id: str): + reply = QMessageBox.question( + self, "Delete Playlist", + f'Delete playlist "{item.text()}"?\nThis cannot be undone.', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + row = self._playlist_list.row(item) + self._playlist_list.takeItem(row) + self.item_selected.emit("playlist_delete", playlist_id) + + def _on_new_playlist(self): + name, ok = QInputDialog.getText(self, "New Playlist", "Playlist name:") + if ok and name.strip(): + self.item_selected.emit("playlist_create", name.strip()) + + def add_playlist_item(self, playlist_id: str, name: str): + item = QListWidgetItem(f"\U0001F4CB {name}") + item.setData(Qt.ItemDataRole.UserRole, playlist_id) + self._playlist_list.addItem(item) + self._playlist_items[playlist_id] = item + + def remove_playlist_item(self, playlist_id: str): + item = self._playlist_items.pop(playlist_id, None) + if item: + row = self._playlist_list.row(item) + self._playlist_list.takeItem(row) + + def rename_playlist_item(self, playlist_id: str, new_name: str): + item = self._playlist_items.get(playlist_id) + if item: + item.setText(f"\U0001F4CB {new_name}") + + def clear_playlist_items(self): + self._playlist_list.clear() + self._playlist_items.clear() + + def get_playlist_order_ids(self) -> List[str]: + ids = [] + for i in range(self._playlist_list.count()): + item = self._playlist_list.item(i) + ids.append(item.data(Qt.ItemDataRole.UserRole)) + return ids + + def _on_playlists_reordered(self): + order = self.get_playlist_order_ids() + self.item_selected.emit("playlist_reorder", "|".join(order)) + + def _on_nav_item_clicked(self, item: QListWidgetItem): + kind = item.data(Qt.ItemDataRole.UserRole + 1) + value = item.data(Qt.ItemDataRole.UserRole + 2) + self._library_list.clearSelection() + self._playlist_list.clearSelection() + self._nav_list.setCurrentItem(item) + self.item_selected.emit(kind, value) + + def select_library_music(self): + if self._library_list.count() > 0: + self._library_list.setCurrentRow(0) + self._on_library_item_clicked(self._library_list.item(0)) + + def select_playlist(self, playlist_id: str): + for i in range(self._playlist_list.count()): + item = self._playlist_list.item(i) + if item.data(Qt.ItemDataRole.UserRole) == playlist_id: + self._playlist_list.setCurrentItem(item) + self._on_playlist_clicked(item) + return + + def select_playlist_silent(self, playlist_id: str): + for i in range(self._playlist_list.count()): + item = self._playlist_list.item(i) + if item.data(Qt.ItemDataRole.UserRole) == playlist_id: + self._playlist_list.setCurrentItem(item) + self._library_list.clearSelection() + self._nav_list.clearSelection() + return + + def select_by_view_mode(self, mode: str): + mapping = {"songs": 4, "albums": 3, "artists": 2, "recent": 1} + row = mapping.get(mode, 0) + if row < self._library_list.count(): + self._library_list.setCurrentRow(row) + self._playlist_list.clearSelection() + self._nav_list.clearSelection() + + def width(self) -> int: + return self.geometry().width()