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:
parent
4c703db802
commit
199fb43f04
328
src/app.py
328
src/app.py
@ -11,16 +11,22 @@ from typing import Optional
|
|||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout,
|
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 config_loader import ConfigLoader
|
||||||
from device_monitor import DeviceMonitor
|
from device_monitor import DeviceMonitor
|
||||||
from hotkeys import HotkeyManager
|
from hotkeys import HotkeyManager
|
||||||
|
from playlist_manager import PlaylistManager
|
||||||
from ui.library_tab import LibraryTab
|
from ui.library_tab import LibraryTab
|
||||||
from ui.ipod_tab import iPodTab
|
from ui.ipod_tab import iPodTab
|
||||||
from ui.settings_tab import SettingsTab
|
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')
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -40,33 +46,83 @@ class MainWindow(QMainWindow):
|
|||||||
self.device_monitor: Optional[DeviceMonitor] = None
|
self.device_monitor: Optional[DeviceMonitor] = None
|
||||||
self.ipod_tab_index = 1
|
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._setup_ui()
|
||||||
|
self.library_tab.set_playlist_manager(self.playlist_manager)
|
||||||
|
self._load_playlists_into_sidebar()
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
self._setup_hotkeys()
|
self._setup_hotkeys()
|
||||||
self._wire_signals()
|
self._wire_signals()
|
||||||
|
|
||||||
self.library_tab._scan_library()
|
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.ipod_tab._on_refresh_devices_clicked()
|
||||||
self._start_device_monitor()
|
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):
|
def _setup_ui(self):
|
||||||
|
from ui.sidebar_widget import SidebarWidget
|
||||||
|
|
||||||
main_widget = QWidget()
|
main_widget = QWidget()
|
||||||
main_layout = QVBoxLayout(main_widget)
|
main_layout = QVBoxLayout(main_widget)
|
||||||
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
main_layout.setSpacing(0)
|
||||||
|
|
||||||
self.tab_widget = QTabWidget()
|
self.player_header = PlayerHeader()
|
||||||
main_layout.addWidget(self.tab_widget)
|
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.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.ipod_tab = iPodTab()
|
||||||
self.tab_widget.addTab(self.ipod_tab, "iPod")
|
self.stack.addWidget(self.ipod_tab)
|
||||||
|
|
||||||
self.settings_tab = SettingsTab()
|
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.statusBar().showMessage("Ready")
|
||||||
self.setCentralWidget(main_widget)
|
self.setCentralWidget(main_widget)
|
||||||
@ -79,16 +135,67 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled)
|
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):
|
def _on_device_mounted(self, mount_point: str):
|
||||||
self.library_tab.on_device_mounted(mount_point)
|
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}")
|
self.statusBar().showMessage(f"iPod mounted at {mount_point}")
|
||||||
|
|
||||||
def _on_device_unmounted(self):
|
def _on_device_unmounted(self):
|
||||||
self.library_tab.on_device_unmounted()
|
self.library_tab.on_device_unmounted()
|
||||||
self.tab_widget.setTabVisible(self.ipod_tab_index, False)
|
|
||||||
self.statusBar().showMessage("iPod disconnected")
|
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):
|
def _start_device_monitor(self):
|
||||||
auto_detect = self.config_loader.get_boolean("Device", "auto_detect", fallback=True)
|
auto_detect = self.config_loader.get_boolean("Device", "auto_detect", fallback=True)
|
||||||
self.device_monitor = DeviceMonitor()
|
self.device_monitor = DeviceMonitor()
|
||||||
@ -108,7 +215,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _load_settings(self):
|
def _load_settings(self):
|
||||||
config = self.config_loader
|
config = self.config_loader
|
||||||
self.library_tab.load_player_settings(config)
|
|
||||||
self.settings_tab.load_settings(config)
|
self.settings_tab.load_settings(config)
|
||||||
hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
|
hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
|
||||||
self.library_tab.set_buttons_visible(not hide_buttons)
|
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_position_ms = config.get_int("Playback", "last_position_ms", fallback=0)
|
||||||
self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False)
|
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):
|
def _save_settings(self):
|
||||||
config = self.config_loader
|
config = self.config_loader
|
||||||
self.settings_tab.save_settings(config)
|
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()
|
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):
|
def _setup_hotkeys(self):
|
||||||
self.hotkey_manager = HotkeyManager(self, self.config_loader)
|
self.hotkey_manager = HotkeyManager(self, self.config_loader)
|
||||||
self.hotkey_manager.register_all({
|
self.hotkey_manager.register_all({
|
||||||
"play_pause": self.library_tab._on_play_pause_clicked,
|
"play_pause": self._on_play_pause,
|
||||||
"prev_track": self.library_tab._on_prev_clicked,
|
"prev_track": self._on_prev,
|
||||||
"next_track": self.library_tab._on_next_clicked,
|
"next_track": self._on_next,
|
||||||
"volume_up": lambda: self.library_tab._adjust_volume(5),
|
"volume_up": lambda: self._adjust_volume(5),
|
||||||
"volume_down": lambda: self.library_tab._adjust_volume(-5),
|
"volume_down": lambda: self._adjust_volume(-5),
|
||||||
"seek_forward": self.library_tab._seek_forward,
|
"seek_forward": self._seek_forward,
|
||||||
"seek_backward": self.library_tab._seek_backward,
|
"seek_backward": self._seek_backward,
|
||||||
"tab_library": lambda: self.tab_widget.setCurrentIndex(0),
|
"toggle_sidebar": self._toggle_sidebar,
|
||||||
"tab_ipod": lambda: self.tab_widget.setCurrentIndex(self.ipod_tab_index),
|
"tab_library": lambda: self.stack.setCurrentIndex(0),
|
||||||
"tab_settings": lambda: self.tab_widget.setCurrentIndex(2),
|
"tab_ipod": lambda: self.stack.setCurrentIndex(self.ipod_tab_index),
|
||||||
|
"tab_settings": lambda: self.stack.setCurrentIndex(2),
|
||||||
"search_focus": self.library_tab._focus_search,
|
"search_focus": self.library_tab._focus_search,
|
||||||
"select_all": self.library_tab._select_all_current,
|
"select_all": self.library_tab._select_all_current,
|
||||||
"library_refresh": self.library_tab._on_refresh_library_clicked,
|
"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.set_hotkey_manager(self.hotkey_manager)
|
||||||
self.settings_tab.refresh_shortcuts_table()
|
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):
|
def closeEvent(self, event):
|
||||||
if self.device_monitor:
|
if self.device_monitor:
|
||||||
self.device_monitor.stop()
|
self.device_monitor.stop()
|
||||||
@ -165,12 +441,6 @@ _GTK3_PLUGIN_PATHS = [
|
|||||||
|
|
||||||
|
|
||||||
def _setup_linux_theming():
|
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":
|
if sys.platform != "linux":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@ -93,6 +93,7 @@ DEFAULTS = [
|
|||||||
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
|
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
|
||||||
("delete_selected", "Delete", "Delete Selected", "global"),
|
("delete_selected", "Delete", "Delete Selected", "global"),
|
||||||
("edit_metadata", "F2", "Edit Metadata", "global"),
|
("edit_metadata", "F2", "Edit Metadata", "global"),
|
||||||
|
("toggle_sidebar", "Ctrl+B", "Toggle Sidebar", "global"),
|
||||||
("library_add_files", "Ctrl+O", "Library: Add Files", "global"),
|
("library_add_files", "Ctrl+O", "Library: Add Files", "global"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -366,6 +366,7 @@ class MetadataHandler:
|
|||||||
genre=genre,
|
genre=genre,
|
||||||
download_path=file_path,
|
download_path=file_path,
|
||||||
cover_path=cover_path,
|
cover_path=cover_path,
|
||||||
|
album_artist=album_artist,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
130
src/playlist_manager.py
Normal file
130
src/playlist_manager.py
Normal 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
|
||||||
@ -20,6 +20,7 @@ class TrackInfo:
|
|||||||
playlist_title: Optional[str] = None
|
playlist_title: Optional[str] = None
|
||||||
download_path: Optional[str] = None
|
download_path: Optional[str] = None
|
||||||
genre: Optional[str] = None
|
genre: Optional[str] = None
|
||||||
|
album_artist: Optional[str] = None
|
||||||
cover_path: Optional[str] = None
|
cover_path: Optional[str] = None
|
||||||
play_count: int = 0
|
play_count: int = 0
|
||||||
rating: int = 0
|
rating: int = 0
|
||||||
|
|||||||
@ -14,11 +14,11 @@ from pathlib import Path
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
|
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit,
|
||||||
QProgressBar, QFileDialog, QMessageBox, QGroupBox, QHeaderView,
|
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.QtCore import Qt, QTimer, pyqtSignal, QSize
|
||||||
from PyQt6.QtGui import QPixmap, QColor
|
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath
|
||||||
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
||||||
|
|
||||||
from track_info import TrackInfo
|
from track_info import TrackInfo
|
||||||
from metadata_handler import MetadataHandler
|
from metadata_handler import MetadataHandler
|
||||||
@ -41,19 +41,20 @@ EQ_FRAMES = [
|
|||||||
"\u2585\u2586\u2583", "\u2586\u2584\u2581",
|
"\u2585\u2586\u2583", "\u2586\u2584\u2581",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
class NumericTableItem(QTableWidgetItem):
|
||||||
class JumpStyle(QProxyStyle):
|
def __lt__(self, other):
|
||||||
def styleHint(self, hint, opt=None, widget=None, returnData=None):
|
try:
|
||||||
if hint == QStyle.StyleHint.SH_Slider_AbsoluteSetButtons:
|
return int(self.text()) < int(other.text())
|
||||||
return Qt.MouseButton.LeftButton.value
|
except ValueError:
|
||||||
return super().styleHint(hint, opt, widget, returnData)
|
return super().__lt__(other)
|
||||||
|
|
||||||
|
|
||||||
class LibraryTab(QWidget):
|
class LibraryTab(QWidget):
|
||||||
"""Library tab widget with playback, conversion and transfer."""
|
"""Library tab widget with library table, filtering, and transfer workflows."""
|
||||||
|
|
||||||
transfer_finished = pyqtSignal()
|
transfer_finished = pyqtSignal()
|
||||||
tab_switch_requested = pyqtSignal(int)
|
tab_switch_requested = pyqtSignal(int)
|
||||||
|
play_requested = pyqtSignal(dict, int, bool)
|
||||||
|
|
||||||
def __init__(self, config_loader: ConfigLoader, parent=None):
|
def __init__(self, config_loader: ConfigLoader, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -63,19 +64,16 @@ class LibraryTab(QWidget):
|
|||||||
self.library_source_files: List[str] = []
|
self.library_source_files: List[str] = []
|
||||||
self.library_scanned_sources: List[Dict] = []
|
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_playback_index: int = -1
|
||||||
|
self._current_track_path: str = ""
|
||||||
|
self._eq_active: bool = False
|
||||||
self._eq_timer: Optional[QTimer] = None
|
self._eq_timer: Optional[QTimer] = None
|
||||||
self._eq_frame: int = 0
|
self._eq_frame: int = 0
|
||||||
self._eq_running: bool = False
|
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._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 = []
|
self._library_toolbar_buttons: list = []
|
||||||
|
|
||||||
@ -84,7 +82,9 @@ class LibraryTab(QWidget):
|
|||||||
self._current_mount_point: Optional[str] = None
|
self._current_mount_point: Optional[str] = None
|
||||||
|
|
||||||
self._setup_ui()
|
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]):
|
def set_mount_point(self, mount_point: Optional[str]):
|
||||||
self._current_mount_point = mount_point
|
self._current_mount_point = mount_point
|
||||||
@ -92,76 +92,6 @@ class LibraryTab(QWidget):
|
|||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
layout = QVBoxLayout(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._setup_library_toolbar(layout)
|
||||||
|
|
||||||
self.library_table = QTableWidget()
|
self.library_table = QTableWidget()
|
||||||
@ -177,6 +107,7 @@ class LibraryTab(QWidget):
|
|||||||
self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked)
|
self.library_table.cellDoubleClicked.connect(self._on_table_double_clicked)
|
||||||
self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
self.library_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self.library_table.customContextMenuRequested.connect(self._on_library_table_context_menu)
|
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 = self.library_table.horizontalHeader()
|
||||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
@ -188,7 +119,28 @@ class LibraryTab(QWidget):
|
|||||||
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
header.setSectionResizeMode(7, 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 = QProgressBar()
|
||||||
self.library_progress.setRange(0, 100)
|
self.library_progress.setRange(0, 100)
|
||||||
@ -258,21 +210,7 @@ class LibraryTab(QWidget):
|
|||||||
|
|
||||||
layout.addLayout(toolbar)
|
layout.addLayout(toolbar)
|
||||||
|
|
||||||
def _setup_player(self):
|
def play_track_at_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True):
|
||||||
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):
|
|
||||||
if index < 0 or index >= self.library_table.rowCount():
|
if index < 0 or index >= self.library_table.rowCount():
|
||||||
return
|
return
|
||||||
data_item = self.library_table.item(index, 0)
|
data_item = self.library_table.item(index, 0)
|
||||||
@ -287,98 +225,34 @@ class LibraryTab(QWidget):
|
|||||||
self._halfway_passed.discard(old_index)
|
self._halfway_passed.discard(old_index)
|
||||||
|
|
||||||
self.current_playback_index = index
|
self.current_playback_index = index
|
||||||
self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
|
self._current_track_path = 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.library_table.clearSelection()
|
self.library_table.clearSelection()
|
||||||
self.library_table.selectRow(index)
|
self.library_table.selectRow(index)
|
||||||
|
|
||||||
self._set_playing_row_bg(index)
|
self._set_playing_row_bg(index)
|
||||||
|
|
||||||
if auto_play:
|
if auto_play:
|
||||||
self._start_eq_animation()
|
self._start_eq_animation()
|
||||||
|
|
||||||
if start_position_ms > 0:
|
self.play_requested.emit(track_data, start_position_ms, auto_play)
|
||||||
self._pending_seek_ms = start_position_ms
|
|
||||||
else:
|
|
||||||
self._pending_seek_ms = 0
|
|
||||||
|
|
||||||
def _on_play_pause_clicked(self):
|
def on_position_changed(self, position_ms: int, duration_ms: int):
|
||||||
if self.player is None:
|
if duration_ms > 0 and position_ms >= duration_ms * 0.5:
|
||||||
return
|
self._halfway_passed.add(self.current_playback_index)
|
||||||
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
|
||||||
self.player.pause()
|
def on_track_ended(self):
|
||||||
self._is_playing = False
|
self._increment_play_count()
|
||||||
self.play_btn.setText("\u25B6")
|
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()
|
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):
|
def _focus_search(self):
|
||||||
self.library_search_input.setFocus()
|
self.library_search_input.setFocus()
|
||||||
@ -390,50 +264,6 @@ class LibraryTab(QWidget):
|
|||||||
def _delete_selected_current(self):
|
def _delete_selected_current(self):
|
||||||
self._on_library_remove_selected()
|
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):
|
def _increment_play_count(self):
|
||||||
idx = self.current_playback_index
|
idx = self.current_playback_index
|
||||||
if idx not in self._halfway_passed:
|
if idx not in self._halfway_passed:
|
||||||
@ -475,8 +305,8 @@ class LibraryTab(QWidget):
|
|||||||
return
|
return
|
||||||
self._eq_timer.stop()
|
self._eq_timer.stop()
|
||||||
self._eq_running = False
|
self._eq_running = False
|
||||||
idx = self.current_playback_index
|
idx = self._find_row_by_path(self._current_track_path) if self._current_track_path else -1
|
||||||
if idx >= 0 and idx < self.library_table.rowCount():
|
if idx >= 0:
|
||||||
item = self.library_table.item(idx, 0)
|
item = self.library_table.item(idx, 0)
|
||||||
if item:
|
if item:
|
||||||
is_ready, data = item.data(Qt.ItemDataRole.UserRole)
|
is_ready, data = item.data(Qt.ItemDataRole.UserRole)
|
||||||
@ -485,8 +315,10 @@ class LibraryTab(QWidget):
|
|||||||
item.setData(Qt.ItemDataRole.ForegroundRole, None)
|
item.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||||
|
|
||||||
def _tick_eq_animation(self):
|
def _tick_eq_animation(self):
|
||||||
idx = self.current_playback_index
|
if not self._current_track_path:
|
||||||
if idx < 0 or idx >= self.library_table.rowCount():
|
return
|
||||||
|
idx = self._find_row_by_path(self._current_track_path)
|
||||||
|
if idx < 0:
|
||||||
return
|
return
|
||||||
item = self.library_table.item(idx, 0)
|
item = self.library_table.item(idx, 0)
|
||||||
if item is None:
|
if item is None:
|
||||||
@ -531,14 +363,27 @@ class LibraryTab(QWidget):
|
|||||||
item0.setText(str(track_num) if (is_ready and track_num) else "")
|
item0.setText(str(track_num) if (is_ready and track_num) else "")
|
||||||
item0.setData(Qt.ItemDataRole.ForegroundRole, None)
|
item0.setData(Qt.ItemDataRole.ForegroundRole, None)
|
||||||
|
|
||||||
def _on_table_double_clicked(self, row: int):
|
def _find_row_by_path(self, path: str) -> int:
|
||||||
self._play_track_from_index(row)
|
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 _on_sort_changed(self, _column: int, _order: int):
|
||||||
def _format_time_ms(ms: int) -> str:
|
if self._current_track_path:
|
||||||
total_seconds = ms // 1000
|
idx = self._find_row_by_path(self._current_track_path)
|
||||||
m, s = divmod(total_seconds, 60)
|
if idx >= 0:
|
||||||
return f"{m}:{s:02d}"
|
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:
|
def _format_duration(self, seconds: int) -> str:
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
@ -615,18 +460,6 @@ class LibraryTab(QWidget):
|
|||||||
self.library_scanned_sources = sources
|
self.library_scanned_sources = sources
|
||||||
self._refresh_library_ui()
|
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]:
|
def _extract_ready_track(self, fpath: str, fname: str, handler) -> Dict[str, Any]:
|
||||||
size = os.path.getsize(fpath)
|
size = os.path.getsize(fpath)
|
||||||
cover_path = None
|
cover_path = None
|
||||||
@ -639,6 +472,7 @@ class LibraryTab(QWidget):
|
|||||||
genre = info.genre if info else ""
|
genre = info.genre if info else ""
|
||||||
track_num = info.track_number if info else 0
|
track_num = info.track_number if info else 0
|
||||||
cover_path = info.cover_path if info else None
|
cover_path = info.cover_path if info else None
|
||||||
|
album_artist = info.album_artist if info else ""
|
||||||
except Exception:
|
except Exception:
|
||||||
duration = 0
|
duration = 0
|
||||||
artist = "Unknown"
|
artist = "Unknown"
|
||||||
@ -646,10 +480,12 @@ class LibraryTab(QWidget):
|
|||||||
album = ""
|
album = ""
|
||||||
genre = ""
|
genre = ""
|
||||||
track_num = 0
|
track_num = 0
|
||||||
|
album_artist = ""
|
||||||
return {
|
return {
|
||||||
"path": fpath, "title": title, "artist": artist,
|
"path": fpath, "title": title, "artist": artist,
|
||||||
"album": album, "duration_s": duration, "size": size,
|
"album": album, "duration_s": duration, "size": size,
|
||||||
"genre": genre, "track_num": track_num, "cover_path": cover_path,
|
"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]:
|
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"
|
duration_str = self._format_duration(track.get("duration_s", 0)) if is_ready else "\u2014"
|
||||||
|
|
||||||
cells = [
|
cells = [
|
||||||
QTableWidgetItem(track_num_str),
|
NumericTableItem(track_num_str),
|
||||||
QTableWidgetItem(track["artist"]),
|
QTableWidgetItem(track["artist"]),
|
||||||
QTableWidgetItem(track["title"]),
|
QTableWidgetItem(track["title"]),
|
||||||
QTableWidgetItem(track.get("album", "") or ""),
|
QTableWidgetItem(track.get("album", "") or ""),
|
||||||
@ -777,14 +613,13 @@ class LibraryTab(QWidget):
|
|||||||
self.library_status.setText(status)
|
self.library_status.setText(status)
|
||||||
|
|
||||||
self._restore_playing_row()
|
self._restore_playing_row()
|
||||||
|
self._build_album_grid()
|
||||||
|
|
||||||
def _restore_playing_row(self):
|
def _restore_playing_row(self):
|
||||||
idx = self.current_playback_index
|
idx = self.current_playback_index
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
return
|
return
|
||||||
track_path = ""
|
track_path = self._current_track_path
|
||||||
if self.player and self.player.source().isValid():
|
|
||||||
track_path = self.player.source().toLocalFile()
|
|
||||||
if not track_path and idx < self.library_table.rowCount():
|
if not track_path and idx < self.library_table.rowCount():
|
||||||
item = self.library_table.item(idx, 0)
|
item = self.library_table.item(idx, 0)
|
||||||
if item:
|
if item:
|
||||||
@ -804,7 +639,7 @@ class LibraryTab(QWidget):
|
|||||||
if data and data.get("path") == track_path:
|
if data and data.get("path") == track_path:
|
||||||
self.current_playback_index = row
|
self.current_playback_index = row
|
||||||
self._set_playing_row_bg(row)
|
self._set_playing_row_bg(row)
|
||||||
if self._is_playing:
|
if self._eq_active:
|
||||||
self._start_eq_animation()
|
self._start_eq_animation()
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -1027,7 +862,7 @@ class LibraryTab(QWidget):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
|
||||||
play_action = menu.addAction("\u25B6 Play")
|
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:
|
if is_ready and self._current_mount_point is not None:
|
||||||
sync_action = menu.addAction("\uD83D\uDD04 Sync Metadata to iPod")
|
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 = menu.addAction("\uD83D\uDD04 Convert to iPod Format")
|
||||||
convert_action.triggered.connect(self._on_library_convert_clicked)
|
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()
|
menu.addSeparator()
|
||||||
|
|
||||||
edit_action = menu.addAction("\u270F\uFE0F Edit Metadata")
|
edit_action = menu.addAction("\u270F\uFE0F Edit Metadata")
|
||||||
@ -1051,6 +900,38 @@ class LibraryTab(QWidget):
|
|||||||
|
|
||||||
menu.exec(self.library_table.viewport().mapToGlobal(pos))
|
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):
|
def _on_show_in_file_manager(self, track_data):
|
||||||
path = track_data.get("path", "")
|
path = track_data.get("path", "")
|
||||||
if not path:
|
if not path:
|
||||||
@ -1261,31 +1142,6 @@ class LibraryTab(QWidget):
|
|||||||
self.worker_thread.deleteLater()
|
self.worker_thread.deleteLater()
|
||||||
self.worker_thread = None
|
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):
|
def on_device_mounted(self, mount_point: str):
|
||||||
self._current_mount_point = mount_point
|
self._current_mount_point = mount_point
|
||||||
self.library_progress.setVisible(True)
|
self.library_progress.setVisible(True)
|
||||||
@ -1298,6 +1154,153 @@ class LibraryTab(QWidget):
|
|||||||
self.library_progress.setVisible(False)
|
self.library_progress.setVisible(False)
|
||||||
self.library_status.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):
|
def set_buttons_visible(self, visible: bool):
|
||||||
for btn in self._library_toolbar_buttons:
|
for btn in self._library_toolbar_buttons:
|
||||||
btn.setVisible(visible)
|
btn.setVisible(visible)
|
||||||
@ -1310,3 +1313,14 @@ class LibraryTab(QWidget):
|
|||||||
|
|
||||||
def get_convert_quality(self):
|
def get_convert_quality(self):
|
||||||
return self.config_loader.get_int("General", "quality", fallback=256)
|
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
137
src/ui/player_header.py
Normal 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
307
src/ui/sidebar_widget.py
Normal 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()
|
||||||
Loading…
x
Reference in New Issue
Block a user