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.
609 lines
25 KiB
Python
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)
|