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