904 lines
39 KiB
Python
904 lines
39 KiB
Python
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
|
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, QApplication,
|
|
QHBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QSizePolicy,
|
|
QFrame, QStyle, QStyleOptionFrame,
|
|
)
|
|
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QRectF, 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)
|
|
layout.addWidget(group, stretch=1)
|
|
self._inner, self._outer = inner, layout
|
|
self.art.has_art_changed.connect(self._place_art)
|
|
self._place_art(False)
|
|
# 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 _place_art(self, has_art: bool):
|
|
"""Art goes inside the list's frame; the empty panel below it, on
|
|
the window, where it reads as set into the window like the
|
|
visualizer rather than as a box inside a white one."""
|
|
for lay in (self._inner, self._outer):
|
|
lay.removeWidget(self.art)
|
|
(self._inner if has_art else self._outer).addWidget(self.art)
|
|
|
|
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))
|
|
|
|
|
|
# The no-art panel: on the window under the list's frame, this far below
|
|
# it, with the visualizer's corners.
|
|
NO_ART_GAP = 6
|
|
NO_ART_RADIUS = 8
|
|
# Art sits inside the list's frame, so the frame never closes between them;
|
|
# the art draws that closing line itself, this tall, so a cover with a white
|
|
# border doesn't run straight on from the list.
|
|
ART_RULE = 1
|
|
|
|
|
|
class SidebarArt(QWidget):
|
|
"""Square album art pinned to the sidebar bottom, inside the playlist
|
|
list's frame. With no embedded art it is an empty panel instead, set
|
|
into the window below the frame like the visualizer (SidebarPanel moves
|
|
it on ``has_art_changed``). A click opens the big art window, or, on the
|
|
empty panel, asks for the track's art to be downloaded."""
|
|
|
|
download_art_requested = pyqtSignal(int) # track id
|
|
has_art_changed = pyqtSignal(bool)
|
|
|
|
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._fit_height()
|
|
self.update()
|
|
if self.has_art() != getattr(self, "_had_art", None):
|
|
self._had_art = self.has_art()
|
|
self.has_art_changed.emit(self._had_art)
|
|
|
|
def has_art(self) -> bool:
|
|
return self._pixmap is not None and not self._pixmap.isNull()
|
|
|
|
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 _fit_height(self):
|
|
# Keep it square: height follows the sidebar's width (plus the gap
|
|
# above the empty panel)
|
|
height = self.width() + (ART_RULE if self.has_art() else NO_ART_GAP)
|
|
if self.height() != height:
|
|
self.setFixedHeight(height)
|
|
|
|
def resizeEvent(self, event):
|
|
self._fit_height()
|
|
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)
|
|
if self._pixmap is None or self._pixmap.isNull():
|
|
# No art: straight onto the window, a panel debossed in the
|
|
# visualizer's color and shaded at its edges as the visualizer is.
|
|
from lintunes.gui.transport import CONTROL_HEIGHT
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = QRectF(self.rect()).adjusted(0.5, NO_ART_GAP + 0.5, -0.5, -0.5)
|
|
theme.paint_deboss(painter, rect,
|
|
self.palette().alternateBase().color(),
|
|
NO_ART_RADIUS, like=CONTROL_HEIGHT)
|
|
return
|
|
# The list frame's own bottom edge, drawn by the style: a frame
|
|
# whose bottom row is this widget's top row, its sides pushed out of
|
|
# view, so the line is exactly the one under the list with no art.
|
|
option = QStyleOptionFrame()
|
|
option.initFrom(self)
|
|
option.frameShape = QFrame.Shape.StyledPanel
|
|
option.state |= QStyle.StateFlag.State_Sunken
|
|
option.lineWidth = option.midLineWidth = 0
|
|
frame = self.style().pixelMetric(QStyle.PixelMetric.PM_DefaultFrameWidth)
|
|
option.rect = QRect(-frame - 4, ART_RULE - 4 * frame - 8,
|
|
self.width() + 2 * frame + 8, 4 * frame + 8)
|
|
painter.save()
|
|
painter.setClipRect(0, 0, self.width(), ART_RULE)
|
|
self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Frame,
|
|
option, painter, self)
|
|
painter.restore()
|
|
area = self.rect().adjusted(0, ART_RULE, 0, 0)
|
|
scaled = self._pixmap.scaled(
|
|
area.size(), Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
|
Qt.TransformationMode.SmoothTransformation)
|
|
x = (area.width() - scaled.width()) // 2
|
|
y = area.top() + (area.height() - scaled.height()) // 2
|
|
painter.setClipRect(area)
|
|
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)
|