The playlist list and the album art under it share one frame (the list's own style, drawn around both), and the no-art placeholder is a shade lighter than that frame line so the line still reads around it. The playlist name, times and playhead sit 2px up (HeaderRow.LIFT), on the Library button's bottom line by eye. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
846 lines
36 KiB
Python
846 lines
36 KiB
Python
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
|
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
|
|
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy,
|
|
QFrame,
|
|
)
|
|
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 = "❧"
|
|
# Bare window left of the sidebar, so the playlists (and the Library button
|
|
# over them) don't run into the window's edge.
|
|
SIDEBAR_INSET = 8
|
|
|
|
|
|
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)
|
|
# Friend sharing: the ▾ beside Library was clicked (show the friends
|
|
# popup), and in friend mode their library / one of their playlists was
|
|
# chosen, or a playlist's UFO (follow) toggled.
|
|
friend_selector_clicked = pyqtSignal()
|
|
friend_library_selected = pyqtSignal()
|
|
friend_playlist_selected = pyqtSignal(str)
|
|
follow_toggled = pyqtSignal(str)
|
|
|
|
def __init__(self, manager, player, parent=None):
|
|
super().__init__(parent)
|
|
layout = QVBoxLayout(self)
|
|
# A strip of bare window left of the playlists; the Library button
|
|
# above them (in the transport bar's header row) is exactly as wide
|
|
# as the list, scrollbar included — see TransportBar.track_split.
|
|
layout.setContentsMargins(SIDEBAR_INSET, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
self.friend_mode = False
|
|
top = QWidget()
|
|
top_row = QHBoxLayout(top)
|
|
top_row.setContentsMargins(0, 0, 0, 0)
|
|
top_row.setSpacing(0)
|
|
self.library_button = QPushButton("Library")
|
|
self.library_button.setCheckable(True)
|
|
self.library_button.setChecked(True)
|
|
# The library selector: a small square cut from the button's right
|
|
# end, flush against it. Shown once there is a friend to choose.
|
|
self.friend_button = QPushButton("▾")
|
|
self.friend_button.setToolTip("Browse a friend's library")
|
|
self.friend_button.setVisible(False)
|
|
for button in (self.library_button, self.friend_button):
|
|
# As tall as the header row makes the strip.
|
|
button.setSizePolicy(button.sizePolicy().horizontalPolicy(),
|
|
QSizePolicy.Policy.Expanding)
|
|
top_row.addWidget(self.library_button, stretch=1)
|
|
top_row.addWidget(self.friend_button)
|
|
self.refresh_style()
|
|
# Not in this panel's layout: MainWindow lays it into the transport
|
|
# bar's header row, over this panel's width (TransportBar.track_split).
|
|
self.header = top
|
|
top.header_owner = self
|
|
|
|
# The playlists and the album art under them are one group inside
|
|
# one frame — the list's own frame, drawn around both, so the art
|
|
# sits within it rather than hanging below.
|
|
group = QFrame()
|
|
self._lists = QStackedWidget()
|
|
self.tree = PlaylistTree(manager)
|
|
self._lists.addWidget(self.tree)
|
|
self.friend_list = FriendPlaylistList()
|
|
self._lists.addWidget(self.friend_list)
|
|
group.setFrameShape(self.tree.frameShape())
|
|
group.setFrameShadow(self.tree.frameShadow())
|
|
for view in (self.tree, self.friend_list):
|
|
view.setFrameShape(QFrame.Shape.NoFrame)
|
|
inner = QVBoxLayout(group)
|
|
inner.setContentsMargins(0, 0, 0, 0)
|
|
inner.setSpacing(0)
|
|
inner.addWidget(self._lists, stretch=1)
|
|
self.art = SidebarArt(player)
|
|
inner.addWidget(self.art)
|
|
layout.addWidget(group, stretch=1)
|
|
# What the header row lines the Library button up with.
|
|
self.list_area = group
|
|
|
|
self.library_button.clicked.connect(self._on_library_clicked)
|
|
self.tree.playlist_selected.connect(self._on_playlist_selected)
|
|
self.tree.followed_selected.connect(
|
|
lambda _key: self.library_button.setChecked(False))
|
|
self.friend_button.clicked.connect(self.friend_selector_clicked)
|
|
self.friend_list.playlist_selected.connect(self._on_friend_playlist)
|
|
self.friend_list.follow_toggled.connect(self.follow_toggled)
|
|
|
|
def _on_library_clicked(self):
|
|
self.library_button.setChecked(True)
|
|
if self.friend_mode:
|
|
self.friend_list.clearSelection()
|
|
self.friend_list.setCurrentItem(None)
|
|
self.friend_library_selected.emit()
|
|
return
|
|
self.tree.clearSelection()
|
|
self.tree.setCurrentItem(None)
|
|
self.library_selected.emit()
|
|
|
|
def _on_friend_playlist(self, pid: str):
|
|
self.library_button.setChecked(False)
|
|
self.friend_playlist_selected.emit(pid)
|
|
|
|
# ---- friend mode ----
|
|
|
|
def set_friend_selector_visible(self, visible: bool):
|
|
self.friend_button.setVisible(visible)
|
|
self.refresh_style()
|
|
|
|
def show_friend(self, library_name: str, playlists, followed: set):
|
|
"""Friend mode: the Library button becomes their library (purple) and
|
|
the tree their shared playlists, each with a UFO follow toggle."""
|
|
self.friend_mode = True
|
|
self.library_button.setText(library_name)
|
|
self.friend_list.set_playlists(playlists, followed)
|
|
self._lists.setCurrentWidget(self.friend_list)
|
|
self.library_button.setChecked(True)
|
|
self.refresh_style()
|
|
|
|
def show_mine(self):
|
|
self.friend_mode = False
|
|
self.library_button.setText("Library")
|
|
self._lists.setCurrentWidget(self.tree)
|
|
self.refresh_style()
|
|
|
|
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):
|
|
# Square: as wide as the button is tall (the header row sets that).
|
|
self.friend_button.setFixedWidth(max(16, header_strip - 4))
|
|
|
|
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. With the
|
|
friend selector showing, the two share a border and have square
|
|
corners where they meet; in friend mode both turn light purple."""
|
|
from lintunes.gui.cassette_art import FRIEND_PURPLE
|
|
palette = QApplication.palette()
|
|
base = palette.color(QPalette.ColorRole.AlternateBase)
|
|
lit = palette.color(QPalette.ColorRole.Highlight)
|
|
checked_text = palette.color(QPalette.ColorRole.HighlightedText).name()
|
|
if self.friend_mode:
|
|
base = lit = FRIEND_PURPLE
|
|
checked_text = "black"
|
|
split = not self.friend_button.isHidden()
|
|
joint = ("border-top-right-radius: 0; border-bottom-right-radius: 0;"
|
|
if split else "")
|
|
self.library_button.setStyleSheet(theme.button_face_qss(
|
|
"QPushButton", base, extra=f"text-align: left; padding-left: 10px; {joint}",
|
|
checked_color=lit, checked_text=checked_text))
|
|
# The ▾ is the same button cut off at the end: same face, no left
|
|
# border (it shares the Library button's), square where they meet.
|
|
self.friend_button.setStyleSheet(theme.button_face_qss(
|
|
"QPushButton", base,
|
|
extra="padding: 0; border-left: none; border-top-left-radius: 0;"
|
|
" border-bottom-left-radius: 0;"))
|
|
|
|
class FriendPlaylistList(QListWidget):
|
|
"""A friend's shared playlists in friend mode. The UFO at each row's left
|
|
is the follow toggle: purple when followed, a grey outline when not."""
|
|
|
|
playlist_selected = pyqtSignal(str)
|
|
follow_toggled = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setUniformItemSizes(True)
|
|
self._followed: set[str] = set()
|
|
self.currentItemChanged.connect(self._on_current)
|
|
|
|
def set_playlists(self, playlists, followed: set):
|
|
self._followed = set(followed)
|
|
self.blockSignals(True)
|
|
self.clear()
|
|
for playlist in playlists:
|
|
item = QListWidgetItem(playlist.name)
|
|
item.setData(Qt.ItemDataRole.UserRole, playlist.pid)
|
|
self.addItem(item)
|
|
self.blockSignals(False)
|
|
self._refresh_icons()
|
|
|
|
def set_followed(self, pid: str, on: bool):
|
|
(self._followed.add if on else self._followed.discard)(pid)
|
|
self._refresh_icons()
|
|
|
|
def _refresh_icons(self):
|
|
from lintunes.gui.cassette_art import ufo_icon
|
|
on, off = ufo_icon(followed=True), ufo_icon(followed=False)
|
|
for row in range(self.count()):
|
|
item = self.item(row)
|
|
followed = item.data(Qt.ItemDataRole.UserRole) in self._followed
|
|
item.setIcon(on if followed else off)
|
|
item.setToolTip("Following — plays from your sidebar. Click the "
|
|
"UFO to stop." if followed else
|
|
"Click the UFO to follow this playlist.")
|
|
|
|
def _icon_hit(self, pos) -> bool:
|
|
index = self.indexAt(pos)
|
|
if not index.isValid():
|
|
return False
|
|
rect = self.visualRect(index)
|
|
return pos.x() - rect.left() < self.iconSize().width() + 8
|
|
|
|
def mousePressEvent(self, event):
|
|
pos = event.position().toPoint()
|
|
if event.button() == Qt.MouseButton.LeftButton and self._icon_hit(pos):
|
|
pid = self.indexAt(pos).data(Qt.ItemDataRole.UserRole)
|
|
self.follow_toggled.emit(pid)
|
|
return
|
|
super().mousePressEvent(event)
|
|
|
|
def _on_current(self, current, _previous):
|
|
if current is not None:
|
|
self.playlist_selected.emit(current.data(Qt.ItemDataRole.UserRole))
|
|
|
|
|
|
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)
|
|
# A shade lighter than mid: the sidebar group's frame line is drawn
|
|
# in mid, and would vanish into a placeholder of the same gray.
|
|
painter.fillRect(self.rect(), self.palette().mid().color().lighter(112))
|
|
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
|
|
# A friend's playlist I follow (key "cassette:<token>:<their pid>").
|
|
followed_selected = pyqtSignal(str)
|
|
unfollow_requested = pyqtSignal(str)
|
|
|
|
def __init__(self, manager, parent=None):
|
|
super().__init__(parent)
|
|
self._manager = manager
|
|
# Friend sharing: Callable[[], list[dict]] of followed playlists, each
|
|
# {key, name, token, friend, layout}. Never Playlists — they aren't
|
|
# mine, so they stay out of the library and its conflict merge.
|
|
self.followed_provider = None
|
|
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)
|
|
|
|
followed = self.followed_provider() if self.followed_provider else []
|
|
mixed = [f for f in followed if f["layout"] != "folder"]
|
|
foldered: dict[str, list] = {}
|
|
for entry in followed:
|
|
if entry["layout"] == "folder":
|
|
foldered.setdefault(entry["token"], []).append(entry)
|
|
if not mixed and not foldered:
|
|
add_level(None, "")
|
|
else:
|
|
# One alphabetical top level: my playlists and folders, followed
|
|
# playlists shown mixed in, and a folder per grouped friend.
|
|
mine = {p.persistent_id: p for p in children.get("", [])}
|
|
rows = [(p.name.lower(), "mine", pid) for pid, p in mine.items()]
|
|
rows += [(f["name"].lower(), "followed", f) for f in mixed]
|
|
rows += [(entries[0]["friend"].lower(), "friend", entries)
|
|
for entries in foldered.values()]
|
|
for _key, kind, value in sorted(rows, key=lambda r: (r[0], r[1])):
|
|
if kind == "mine":
|
|
self._add_mine(value, children, expanded, add_level)
|
|
elif kind == "followed":
|
|
self.addTopLevelItem(self._followed_item(value))
|
|
else:
|
|
self.addTopLevelItem(self._friend_folder(value, expanded))
|
|
self.blockSignals(False)
|
|
|
|
if selected_pid:
|
|
item = self._find_item(selected_pid)
|
|
if item:
|
|
self.setCurrentItem(item)
|
|
|
|
def _add_mine(self, pid, children, expanded, add_level):
|
|
"""One of my top-level playlists/folders (and a folder's contents),
|
|
when the top level is being interleaved with followed ones."""
|
|
playlist = self._manager.library.playlists[pid]
|
|
is_folder = playlist.playlist_type == PlaylistType.FOLDER
|
|
item = QTreeWidgetItem([playlist.name])
|
|
item.setData(0, PID_ROLE, pid)
|
|
item.setData(0, KIND_ROLE, "folder" if is_folder else
|
|
"smart" if playlist.is_smart else "playlist")
|
|
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
|
|
self.addTopLevelItem(item)
|
|
if is_folder:
|
|
add_level(item, pid)
|
|
item.setExpanded(pid in expanded)
|
|
|
|
def _followed_item(self, entry) -> QTreeWidgetItem:
|
|
from lintunes.gui.cassette_art import ufo_icon
|
|
item = QTreeWidgetItem([entry["name"]])
|
|
item.setData(0, PID_ROLE, entry["key"])
|
|
item.setData(0, KIND_ROLE, "followed")
|
|
item.setIcon(0, ufo_icon())
|
|
item.setToolTip(0, f"{entry['friend']}'s playlist")
|
|
item.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
|
|
return item
|
|
|
|
def _friend_folder(self, entries, expanded) -> QTreeWidgetItem:
|
|
from lintunes.gui.cassette_art import ufo_icon
|
|
token = entries[0]["token"]
|
|
folder = QTreeWidgetItem([entries[0]["friend"]])
|
|
folder.setData(0, PID_ROLE, f"cassette-folder:{token}")
|
|
folder.setData(0, KIND_ROLE, "friend-folder")
|
|
folder.setIcon(0, ufo_icon())
|
|
folder.setFlags(Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
|
|
for entry in sorted(entries, key=lambda e: e["name"].lower()):
|
|
folder.addChild(self._followed_item(entry))
|
|
folder.setExpanded(f"cassette-folder:{token}" in expanded)
|
|
return folder
|
|
|
|
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) in ("folder", "friend-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))
|
|
elif current.data(0, KIND_ROLE) == "followed":
|
|
self.followed_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 ""
|
|
if kind == "followed":
|
|
menu = QMenu(self)
|
|
unfollow = menu.addAction("Unfollow")
|
|
if menu.exec(self.viewport().mapToGlobal(pos)) is unfollow:
|
|
self.unfollow_requested.emit(pid)
|
|
return
|
|
if kind == "friend-folder":
|
|
return
|
|
# 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)
|