neo-pod-desktop/src/ui/sidebar_widget.py
Maksim Totmin 199fb43f04 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
2026-06-01 14:46:58 +07:00

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