Files
lintunes/lintunes/gui/sidebar.py
T
trav 549c5bf94f v0.25.0: New Tracks, a soft emboss, date added pinned
Groundwork for Cassette (friend library sharing) that stands on its own:

- A brand-new library (fresh data dir, or a fresh iTunes import) starts
  with a "New Tracks" smart playlist: added in the last 3 months, newest
  first. Fixed persistent id, so two machines seeding one unsynced share
  merge into a single playlist.
- Every non-flat button, the sidebar Library button and the transport
  boxes get a very slight top-lit gradient computed from the live palette,
  with the chosen color kept exactly at its midpoint.
- Tests pin that individual imports stamp date added = now even for an
  old file, and that the iTunes migration keeps the XML's dates.
2026-09-28 20:56:28 -07:00

609 lines
25 KiB
Python

from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
)
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
from PyQt6.QtGui import QBrush, QPainter, QPalette, QPixmap
from lintunes.models import PlaylistType
from lintunes import tagging, theme
from lintunes.gui.track_table import (
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
)
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
from lintunes.gui.art_window import ArtWindow
from lintunes.gui import drag_ghost
PID_ROLE = Qt.ItemDataRole.UserRole
KIND_ROLE = Qt.ItemDataRole.UserRole + 1 # "folder" | "playlist" | "smart"
# Rotated Floral Heart Bullet, painted in the branch area (where the folder
# triangle sits) for smart playlists, so the name stays aligned with the others.
SMART_GLYPH = "❧"
class SidebarPanel(QWidget):
"""Left panel: Library button on top, playlist tree, album art nestled
flush at the bottom."""
library_selected = pyqtSignal()
playlist_selected = pyqtSignal(str)
def __init__(self, manager, player, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.library_button = QPushButton("Library")
self.library_button.setCheckable(True)
self.library_button.setChecked(True)
self.refresh_style()
layout.addWidget(self.library_button)
self.tree = PlaylistTree(manager)
layout.addWidget(self.tree, stretch=1)
self.art = SidebarArt(player)
layout.addWidget(self.art)
self.library_button.clicked.connect(self._on_library_clicked)
self.tree.playlist_selected.connect(self._on_playlist_selected)
def _on_library_clicked(self):
self.library_button.setChecked(True)
self.tree.clearSelection()
self.tree.setCurrentItem(None)
self.library_selected.emit()
def _on_playlist_selected(self, pid: str):
self.library_button.setChecked(False)
self.playlist_selected.emit(pid)
def current_playlist_id(self) -> str:
return self.tree.current_playlist_id()
def reflect_library(self):
"""Mirror 'Library is selected' in the sidebar without re-navigating."""
self.library_button.setChecked(True)
self.tree.blockSignals(True)
self.tree.clearSelection()
self.tree.setCurrentItem(None)
self.tree.blockSignals(False)
def reflect_playlist(self, pid: str):
"""Mirror 'this playlist is selected' without re-navigating."""
self.library_button.setChecked(False)
self.tree.select_item(pid)
def apply_metrics(self, header_strip: int):
self.library_button.setFixedHeight(header_strip)
def refresh_style(self):
"""(Re)build the Library button's look from the live palette, so its
emboss follows theme changes like every other button's."""
palette = QApplication.palette()
base = palette.color(QPalette.ColorRole.AlternateBase)
lit = palette.color(QPalette.ColorRole.Highlight)
self.library_button.setStyleSheet(
"QPushButton { text-align: left; padding-left: 10px;"
" border: 1px solid palette(mid); border-radius: 4px;"
" margin: 2px 4px 2px 8px;"
f" background: {theme.emboss_gradient(base)}; }}"
"QPushButton:checked {"
f" background: {theme.emboss_gradient(lit, pressed=True)};"
" color: palette(highlighted-text); }")
class SidebarArt(QWidget):
"""Square album art pinned to the sidebar bottom; gray square when the
current track has no embedded art. A click opens the big art window, or,
on the gray square, asks for the track's art to be downloaded."""
download_art_requested = pyqtSignal(int) # track id
def __init__(self, player, parent=None):
super().__init__(parent)
self._player = player
self._pixmap: QPixmap | None = None
self._track = None
self._art_window = ArtWindow()
player.track_changed.connect(self.set_track)
def set_track(self, track):
self._track = track
self._pixmap = None
if track is not None and track.location:
data = tagging.read_embedded_artwork(track.location)
if data:
pixmap = QPixmap()
if pixmap.loadFromData(data):
self._pixmap = pixmap
if self._pixmap is not None:
self.setToolTip("Click to view full size")
elif track is not None:
self.setToolTip("Click to download album art")
else:
self.setToolTip("")
if self._art_window.isVisible():
self._art_window.set_artwork(self._pixmap, self._title())
self.update()
def refresh(self):
"""Re-read art for the current track (after tag edits)."""
self.set_track(self._track)
def _title(self) -> str:
if self._track is None:
return "Album Art"
return (" — ".join(p for p in (self._track.artist, self._track.album) if p)
or self._track.name)
def resizeEvent(self, event):
# Keep it square: height follows the sidebar's width
if self.height() != self.width():
self.setFixedHeight(self.width())
super().resizeEvent(event)
def mouseReleaseEvent(self, event):
# A click is a release that lands on the art. Opening on press would
# also fire when a drag merely starts here.
if (event.button() == Qt.MouseButton.LeftButton
and self.rect().contains(event.position().toPoint())):
if self._pixmap is not None:
self._art_window.show_artwork(self._pixmap, self._title(),
self.screen())
elif self._track is not None:
self.download_art_requested.emit(self._track.track_id)
super().mouseReleaseEvent(event)
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), self.palette().mid().color())
if self._pixmap is None or self._pixmap.isNull():
return
scaled = self._pixmap.scaled(
self.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation)
x = (self.width() - scaled.width()) // 2
y = (self.height() - scaled.height()) // 2
painter.drawPixmap(x, y, scaled)
class PlaylistTree(QTreeWidget):
"""The user's playlist folder tree. Mutations go through LibraryManager;
the tree rebuilds on its playlists_changed signal."""
playlist_selected = pyqtSignal(str) # persistent_id
export_requested = pyqtSignal(str) # persistent_id
def __init__(self, manager, parent=None):
super().__init__(parent)
self._manager = manager
self._drop_target: QTreeWidgetItem | None = None
# KIND_ROLE value a drop during the active drag would land on
# ("folder" for internal playlist moves, "playlist" for track drops).
self._drag_target_kind: str | None = None
self.setHeaderHidden(True)
self.setIndentation(14)
self.setAcceptDrops(True)
self.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.currentItemChanged.connect(self._on_selection_changed)
self.itemChanged.connect(self._on_item_renamed)
# Clicking the already-selected playlist renames it (Qt waits out the
# double-click interval first, and a press that becomes a drag never
# opens the editor).
self.setEditTriggers(self.editTriggers()
| QAbstractItemView.EditTrigger.SelectedClicked)
# Only a brand-new playlist opens its editor with everything selected,
# so typing replaces "untitled playlist"; see edit().
self._select_all_next_edit = False
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
# because our dragMoveEvent accepts the event itself). Mirrors
# track_table's Round 13 machinery.
self._autoscroll_dir = 0
self._autoscroll_pos: QPoint | None = None
self._autoscroll_timer = QTimer(self)
self._autoscroll_timer.setInterval(40)
self._autoscroll_timer.timeout.connect(self._autoscroll_tick)
manager.playlists_changed.connect(self.rebuild)
self.rebuild()
# ---- tree construction ----
def rebuild(self):
selected_pid = self.current_playlist_id()
# Preserve which folders the user has open across rebuilds. On the very
# first build this set is empty, so the app opens fully collapsed.
expanded = self._expanded_pids()
self.blockSignals(True)
self.clear()
playlists = self._manager.library.playlists
children: dict[str, list] = {}
for playlist in playlists.values():
parent_pid = playlist.parent_persistent_id
if parent_pid not in playlists:
parent_pid = ""
children.setdefault(parent_pid, []).append(playlist)
def add_level(parent_item, parent_pid):
for playlist in sorted(children.get(parent_pid, []),
key=lambda p: p.name.lower()):
is_folder = playlist.playlist_type == PlaylistType.FOLDER
is_smart = playlist.is_smart
item = QTreeWidgetItem([playlist.name])
item.setData(0, PID_ROLE, playlist.persistent_id)
kind = "folder" if is_folder else "smart" if is_smart else "playlist"
item.setData(0, KIND_ROLE, kind)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
if parent_item is None:
self.addTopLevelItem(item)
else:
parent_item.addChild(item)
if is_folder:
add_level(item, playlist.persistent_id)
item.setExpanded(playlist.persistent_id in expanded)
add_level(None, "")
self.blockSignals(False)
if selected_pid:
item = self._find_item(selected_pid)
if item:
self.setCurrentItem(item)
def _expanded_pids(self) -> set[str]:
"""Persistent ids of folders currently expanded in the tree."""
result: set[str] = set()
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
while stack:
item = stack.pop()
if item.data(0, KIND_ROLE) == "folder" and item.isExpanded():
result.add(item.data(0, PID_ROLE))
stack.extend(item.child(i) for i in range(item.childCount()))
return result
def _find_item(self, pid: str):
stack = [self.topLevelItem(i) for i in range(self.topLevelItemCount())]
while stack:
item = stack.pop()
if item.data(0, PID_ROLE) == pid:
return item
stack.extend(item.child(i) for i in range(item.childCount()))
return None
def drawBranches(self, painter, rect, index):
"""Paint the ❧ for smart playlists in the branch column (where a folder's
disclosure triangle sits), so the playlist name stays left-aligned with
the others instead of being pushed right by a text prefix."""
super().drawBranches(painter, rect, index)
item = self.itemFromIndex(index)
if item is None or item.data(0, KIND_ROLE) != "smart":
return
selected = self.selectionModel().isSelected(index)
role = (self.palette().highlightedText() if selected
else self.palette().text())
painter.save()
painter.setPen(role.color())
painter.drawText(rect.adjusted(0, 0, -2, 0),
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
SMART_GLYPH)
painter.restore()
def current_playlist_id(self) -> str:
item = self.currentItem()
if item is None:
return ""
return item.data(0, PID_ROLE) or ""
def select_item(self, pid: str):
"""Select a playlist row without emitting the selection signal."""
item = self._find_item(pid)
if item is not None:
self.blockSignals(True)
self.setCurrentItem(item)
self.blockSignals(False)
def create_playlist_interactive(self, parent_pid: str = ""):
"""Create a playlist, select it, and open inline rename so the user can
name it just by typing (Enter commits, Escape keeps "untitled playlist").
The manager's playlists_changed signal rebuilds the tree synchronously,
so the new item already exists by the time create_playlist() returns."""
playlist = self._manager.create_playlist("untitled playlist", parent_pid)
item = self._find_item(playlist.persistent_id)
if item is not None:
self.setCurrentItem(item) # selects + navigates to the new playlist
self._select_all_next_edit = True
self.editItem(item, 0) # live inline rename; editor selects all
def create_smart_playlist_interactive(self, parent_pid: str = ""):
"""Open the criteria editor; on accept, create the smart playlist and
drop into inline rename (mirrors create_playlist_interactive)."""
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
from lintunes.smart import SmartCriteria
dialog = SmartPlaylistEditorDialog(SmartCriteria(), self,
title="New Smart Playlist")
if not dialog.exec():
return
playlist = self._manager.create_smart_playlist(
"untitled smart playlist", dialog.criteria(), parent_pid)
item = self._find_item(playlist.persistent_id)
if item is not None:
self.setCurrentItem(item)
self._select_all_next_edit = True
self.editItem(item, 0)
def edit_smart_playlist(self, pid: str):
playlist = self._manager.library.playlists.get(pid)
if not playlist or not playlist.is_smart:
return
from lintunes.gui.smart_playlist_dialog import SmartPlaylistEditorDialog
dialog = SmartPlaylistEditorDialog(playlist.smart_criteria, self,
title="Edit Smart Playlist")
if dialog.exec():
self._manager.set_smart_criteria(pid, dialog.criteria())
# ---- selection / rename ----
def _on_selection_changed(self, current, _previous):
if current is None:
return
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
self.playlist_selected.emit(current.data(0, PID_ROLE))
def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers,
event=None):
"""Every inline rename (click, double-click, F2, context menu) comes
through here. Qt selects the editor's whole text inside this call,
after the delegate has run, so renaming an existing playlist undoes
that afterwards: nothing selected, cursor at the end. Defaulted args
because this override shadows PyQt's one-argument edit() slot too."""
opened = super().edit(index, trigger, event)
# A SelectedClicked edit returns True having only armed Qt's
# double-click timer; the editor opens when that fires (through here
# again), so only a real editor settles the one-shot flag.
editor = self.indexWidget(index) if opened else None
if isinstance(editor, QLineEdit):
if not self._select_all_next_edit:
editor.deselect()
editor.end(False)
self._select_all_next_edit = False
return opened
def _on_item_renamed(self, item, _column):
pid = item.data(0, PID_ROLE)
if pid:
self._manager.rename_playlist(pid, item.text(0).strip())
# ---- context menu ----
def _show_context_menu(self, pos):
item = self.itemAt(pos)
kind = item.data(0, KIND_ROLE) if item else ""
pid = item.data(0, PID_ROLE) if item else ""
# New items land inside the folder under the cursor (or at top level)
parent_pid = ""
if kind == "folder":
parent_pid = pid
elif kind in ("playlist", "smart"):
playlist = self._manager.library.playlists.get(pid)
if playlist:
parent_pid = playlist.parent_persistent_id
menu = QMenu(self)
new_playlist = menu.addAction("New Playlist")
new_smart = menu.addAction("New Smart Playlist")
new_folder = menu.addAction("New Playlist Folder")
rename = delete = edit_smart = export = None
if kind in ("playlist", "folder", "smart"):
menu.addSeparator()
if kind == "smart":
edit_smart = menu.addAction("Edit Smart Playlist…")
rename = menu.addAction("Rename")
delete = menu.addAction("Delete")
if kind in ("playlist", "smart"):
menu.addSeparator()
export = menu.addAction("Export Playlist…")
chosen = menu.exec(self.viewport().mapToGlobal(pos))
if chosen is None:
return
if chosen is new_playlist:
self.create_playlist_interactive(parent_pid)
elif chosen is new_smart:
self.create_smart_playlist_interactive(parent_pid)
elif edit_smart is not None and chosen is edit_smart:
self.edit_smart_playlist(pid)
elif chosen is new_folder:
name, ok = QInputDialog.getText(self, "New Playlist Folder", "Name:",
text="untitled folder")
if ok and name.strip():
self._manager.create_folder(name.strip(), parent_pid)
elif export is not None and chosen is export:
self.export_requested.emit(pid)
elif rename is not None and chosen is rename:
self.editItem(item, 0)
elif delete is not None and chosen is delete:
label = "folder (and everything in it)" if kind == "folder" else "playlist"
answer = QMessageBox.question(
self, "Delete", f'Delete the {label} "{item.text(0)}"?')
if answer == QMessageBox.StandardButton.Yes:
self._manager.delete_playlist(pid)
# ---- drag and drop ----
def mimeTypes(self):
return [TRACKS_MIME]
def dragEnterEvent(self, event):
if event.mimeData().hasFormat(TRACKS_MIME) or event.source() is self:
event.acceptProposedAction()
else:
event.ignore()
def dragMoveEvent(self, event):
pos = event.position().toPoint()
drag_ghost.move(self.viewport().mapToGlobal(pos))
if event.source() is self:
# Moving a playlist into/out of folders. The tree accepts an
# internal move somewhere no matter what (empty area = top level),
# so edge auto-scroll stays live even over non-folder rows —
# otherwise scrolling down to a distant folder would be nearly
# impossible past the playlists in between.
self._drag_target_kind = "folder"
target = self.itemAt(pos)
kind = target.data(0, KIND_ROLE) if target else ""
if kind in ("folder", "") or target is None:
event.acceptProposedAction()
else:
event.ignore()
self._highlight_drop_target(pos, "folder")
self._update_autoscroll(pos, accepted=True)
return
if not event.mimeData().hasFormat(TRACKS_MIME):
self._clear_drop_target()
self._update_autoscroll(pos, accepted=False)
event.ignore()
return
self._drag_target_kind = "playlist"
target = self.itemAt(pos)
if target and target.data(0, KIND_ROLE) == "playlist":
event.acceptProposedAction()
self._update_autoscroll(pos, accepted=True)
else:
event.ignore()
self._update_autoscroll(pos, accepted=False)
self._highlight_drop_target(pos, "playlist")
def dragLeaveEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
super().dragLeaveEvent(event)
def _highlight_drop_target(self, pos: QPoint, kind: str):
"""Highlight the row at ``pos`` if it's a valid target for the active
drag — a folder for internal playlist moves, a playlist for track
drops — so the user can see where the drop would land. Clears any
stale highlight otherwise. A folder inside the dragged row's own
branch is never highlighted: the manager would reject that drop as a
cycle, so lighting it up would lie."""
target = self.itemAt(pos)
ok = target is not None and target.data(0, KIND_ROLE) == kind
if ok and kind == "folder":
ok = not self._is_own_branch(self.currentItem(), target)
if ok:
self._set_drop_target(target)
else:
self._clear_drop_target()
@staticmethod
def _is_own_branch(dragged, item) -> bool:
"""True if ``item`` is the dragged row or inside its subtree."""
while item is not None:
if item is dragged:
return True
item = item.parent()
return False
def _set_drop_target(self, item):
"""Highlight the row a drop would land on (cleared on the way
out). Foreground is set too so the name stays legible on the highlight."""
if item is self._drop_target:
return
self._clear_drop_target()
self._drop_target = item
self._drop_target_bg = item.background(0)
self._drop_target_fg = item.foreground(0)
item.setBackground(0, QBrush(self.palette().highlight().color()))
item.setForeground(0, QBrush(self.palette().highlightedText().color()))
def _clear_drop_target(self):
if self._drop_target is not None:
self._drop_target.setBackground(0, self._drop_target_bg)
self._drop_target.setForeground(0, self._drop_target_fg)
self._drop_target = None
# ---- drag auto-scroll ----
def _update_autoscroll(self, pos: QPoint, accepted: bool):
"""Start/stop edge auto-scroll based on the drag's position."""
self._autoscroll_pos = pos
self._autoscroll_dir = autoscroll_direction(
pos.y(), self.viewport().height(), accepted)
if self._autoscroll_dir and not self._autoscroll_timer.isActive():
self._autoscroll_timer.start()
elif not self._autoscroll_dir and self._autoscroll_timer.isActive():
self._autoscroll_timer.stop()
def _autoscroll_tick(self):
bar = self.verticalScrollBar()
before = bar.value()
bar.setValue(before + self._autoscroll_dir)
if bar.value() == before: # reached the top/bottom — nothing more to do
self._autoscroll_timer.stop()
return
# The cursor may be held still while rows scroll under it, so keep the
# drop-target highlight on the row now beneath it.
if (self._autoscroll_pos is not None
and self._drag_target_kind is not None):
self._highlight_drop_target(self._autoscroll_pos,
self._drag_target_kind)
def _stop_autoscroll(self):
self._autoscroll_dir = 0
self._autoscroll_pos = None
self._drag_target_kind = None
self._autoscroll_timer.stop()
def dropEvent(self, event):
self._clear_drop_target()
self._stop_autoscroll()
target = self.itemAt(event.position().toPoint())
if event.source() is self:
item = self.currentItem()
pid = item.data(0, PID_ROLE) if item else ""
if not pid:
return
new_parent = ""
if target is not None and target.data(0, KIND_ROLE) == "folder":
new_parent = target.data(0, PID_ROLE)
self._manager.move_playlist(pid, new_parent)
event.acceptProposedAction()
return
payload = parse_tracks_mime(event.mimeData())
if payload and target and target.data(0, KIND_ROLE) == "playlist":
added = add_tracks_with_dup_check(
self._manager, self, target.data(0, PID_ROLE),
payload.get("track_ids", []))
if added:
self._flash_item(target)
event.acceptProposedAction()
def _flash_item(self, item):
"""Briefly pulse a playlist row's highlight to confirm a drop landed.
Adding tracks emits playlist_content_changed (not playlists_changed),
so the tree isn't rebuilt and the item reference stays valid.
"""
highlight = QBrush(self.palette().highlight().color())
original = item.background(0)
def on():
item.setBackground(0, highlight)
def off():
item.setBackground(0, original)
on()
QTimer.singleShot(150, off)
QTimer.singleShot(300, on)
QTimer.singleShot(450, off)