- 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
308 lines
11 KiB
Python
308 lines
11 KiB
Python
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()
|