feat: iTunes-style sidebar + global player header + album grid

- 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
This commit is contained in:
Maksim Totmin 2026-06-01 14:46:58 +07:00
parent 4c703db802
commit 199fb43f04
8 changed files with 1177 additions and 316 deletions

View File

@ -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

View File

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

View File

@ -366,6 +366,7 @@ class MetadataHandler:
genre=genre,
download_path=file_path,
cover_path=cover_path,
album_artist=album_artist,
)
except Exception as e:

130
src/playlist_manager.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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()
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")
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()
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)
else:
self._stop_eq_animation()
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,
}

137
src/ui/player_header.py Normal file
View File

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

307
src/ui/sidebar_widget.py Normal file
View File

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