import json import os from PyQt6.QtWidgets import ( QTableView, QAbstractItemView, QMenu, QApplication, QProxyStyle, QStyle, ) from PyQt6.QtCore import ( Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal, QMimeData, QUrl, QPoint, QPointF, QRectF, QMetaType, QTimer, ) from PyQt6.QtGui import ( QDrag, QKeySequence, QShortcut, QPixmap, QPainter, QPen, QPolygonF, QColor, QFontMetrics, QDesktopServices, QCursor, ) from PyQt6.QtDBus import ( QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument, ) from lintunes.models import Track from lintunes.gui import drag_ghost TRACKS_MIME = "application/x-lintunes-tracks" SORT_ROLE = Qt.ItemDataRole.UserRole # While dragging a track within this many pixels of the list's top/bottom edge, # the list auto-scrolls so off-screen rows can be reached. AUTOSCROLL_MARGIN = 28 class ClickToJumpScrollStyle(QProxyStyle): """Makes a left-click on the scrollbar trough jump the thumb straight to that spot (absolute position) instead of paging toward it a step at a time. Wraps the default app style and only overrides the one hint.""" def styleHint(self, hint, option=None, widget=None, returnData=None): if hint == QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition: return 1 return super().styleHint(hint, option, widget, returnData) def autoscroll_direction(y: int, height: int, accepted: bool, margin: int = AUTOSCROLL_MARGIN) -> int: """-1 to scroll up, +1 down, 0 to stop: based on how close the drag is to the top/bottom edge. Only scrolls over a valid drop target (accepted).""" if not accepted: return 0 if y < margin: return -1 if y > height - margin: return 1 return 0 # Manual play-order pseudo-column (playlist views only, always first) INDEX_FIELD = "#" # Column definitions: (field_name, display_name, default_width) ALL_COLUMNS = [ ("name", "Name", 250), ("artist", "Artist", 180), ("album", "Album", 180), ("album_artist", "Album Artist", 180), ("genre", "Genre", 120), ("total_time", "Time", 60), ("year", "Year", 50), ("track_number", "Track #", 60), ("disc_number", "Disc", 40), ("play_count", "Plays", 50), ("skip_count", "Skips", 50), ("rating", "Rating", 80), ("date_added", "Date Added", 140), ("date_modified", "Date Modified", 140), ("play_date_utc", "Last Played", 140), ("skip_date", "Last Skipped", 140), ("bit_rate", "Bit Rate", 60), ("sample_rate", "Sample Rate", 80), ("composer", "Composer", 150), ("grouping", "Grouping", 120), ("comments", "Comments", 150), ("bpm", "BPM", 50), ("kind", "Kind", 120), ("size", "Size", 70), ] COLUMN_MAP = {col[0]: col for col in ALL_COLUMNS} def format_time(ms: int) -> str: if ms <= 0: return "" total_secs = round(ms / 1000) mins, secs = divmod(total_secs, 60) if mins >= 60: hours, mins = divmod(mins, 60) return f"{hours}:{mins:02d}:{secs:02d}" return f"{mins}:{secs:02d}" def format_total_time(ms: int) -> str: """Trimmed DD:HH:MM:SS: drop empty leading units, keep at least M:SS. e.g. 14:21, 9:47:33, 24:18:42:07.""" total_secs = round(max(0, ms) / 1000) days, rem = divmod(total_secs, 86400) hours, rem = divmod(rem, 3600) mins, secs = divmod(rem, 60) parts = [days, hours, mins, secs] while len(parts) > 2 and parts[0] == 0: parts.pop(0) return ":".join(str(p) if i == 0 else f"{p:02d}" for i, p in enumerate(parts)) def _format_rating(rating: int) -> str: if rating <= 0: return "" return "★" * (rating // 20) def _format_size(size: int) -> str: if size <= 0: return "" mb = size / (1024 * 1024) return f"{mb:.1f} MB" def _format_date(iso_str: str) -> str: return iso_str[:10] if iso_str else "" # Now-playing speaker pixmaps, cached per (playing, color) _speaker_cache: dict[tuple[bool, str], QPixmap] = {} def speaker_pixmap(playing: bool, color: QColor) -> QPixmap: """16px speaker silhouette; with sound waves when playing.""" key = (playing, color.name()) cached = _speaker_cache.get(key) if cached is not None: return cached from PyQt6.QtCore import QPointF, QRectF # 19px wide (not 16) so the outer wave arc, which reaches ~x=17.7 with # its pen, isn't clipped on the right. Height stays 16. pixmap = QPixmap(19, 16) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(color) painter.drawPolygon(QPolygonF([ QPointF(1, 5.5), QPointF(4.5, 5.5), QPointF(8.5, 1.5), QPointF(8.5, 14.5), QPointF(4.5, 10.5), QPointF(1, 10.5), ])) if playing: pen = QPen(color, 1.4) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawArc(QRectF(9.5, 5.5, 4, 5), -60 * 16, 120 * 16) painter.drawArc(QRectF(10.5, 3.0, 6.5, 10), -60 * 16, 120 * 16) painter.end() _speaker_cache[key] = pixmap return pixmap def make_tracks_mime(track_ids: list[int], source_playlist: str = "", rows: list[int] | None = None) -> QMimeData: mime = QMimeData() payload = {"track_ids": track_ids, "source_playlist": source_playlist, "rows": rows or []} mime.setData(TRACKS_MIME, json.dumps(payload).encode()) return mime def parse_tracks_mime(mime: QMimeData) -> dict | None: if mime is None or not mime.hasFormat(TRACKS_MIME): return None try: return json.loads(bytes(mime.data(TRACKS_MIME)).decode()) except (ValueError, UnicodeDecodeError): return None def reveal_paths(paths: list[str]) -> None: """Select the given files in the desktop file manager. Uses the freedesktop org.freedesktop.FileManager1 interface (Nautilus, Dolphin, Nemo, …), which highlights the files in their folder. Falls back to just opening the containing folder if that service is unavailable. """ if not paths: return bus = QDBusConnection.sessionBus() if bus.isConnected(): # The URIs must be marshalled as a D-Bus string array ("as"); a plain # Python list goes over as "av" and Nautilus rejects it (InvalidArgs). uris = QDBusArgument() uris.beginArray(QMetaType(QMetaType.Type.QString.value).id()) for p in paths: uris.add(QUrl.fromLocalFile(p).toString()) uris.endArray() iface = QDBusInterface( "org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1", bus, ) reply = iface.call("ShowItems", uris, "") if reply.type() != QDBusMessage.MessageType.ErrorMessage: return # No FileManager1 service (or it errored): open the folder instead. QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(paths[0]))) class TrackTableModel(QAbstractTableModel): def __init__(self, parent=None, with_index_column=False): super().__init__(parent) self._tracks: list[Track] = [] self._fields: list[str] = [] self._with_index = with_index_column self._now_playing_id: int | None = None self._now_playing_active = False self.set_visible_columns(["name", "artist", "album"]) def set_now_playing(self, track_id: int | None, playing: bool): old_id = self._now_playing_id self._now_playing_id = track_id self._now_playing_active = playing for refresh_id in {old_id, track_id} - {None}: self.refresh_track(refresh_id) @property def fields(self) -> list[str]: return self._fields def set_visible_columns(self, columns: list[str]): self.beginResetModel() fields = [c for c in columns if c in COLUMN_MAP] if not fields: fields = ["name"] if self._with_index: fields = [INDEX_FIELD] + fields self._fields = fields self.endResetModel() def set_tracks(self, tracks: list[Track]): self.beginResetModel() self._tracks = list(tracks) self.endResetModel() def track_at(self, row: int) -> Track: return self._tracks[row] @property def tracks(self) -> list[Track]: return self._tracks def refresh_track(self, track_id: int): for row, track in enumerate(self._tracks): if track.track_id == track_id: self.dataChanged.emit(self.index(row, 0), self.index(row, self.columnCount() - 1)) def flags(self, index): # ItemIsDragEnabled is what lets the view initiate track drags return (Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsDragEnabled) def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self._tracks) def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self._fields) def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole: field = self._fields[section] if field == INDEX_FIELD: return "#" return COLUMN_MAP[field][1] return None def data(self, index, role=Qt.ItemDataRole.DisplayRole): if not index.isValid(): return None field = self._fields[index.column()] track = self._tracks[index.row()] if field == INDEX_FIELD: if role == Qt.ItemDataRole.DisplayRole or role == SORT_ROLE: return index.row() + 1 return None if (role == Qt.ItemDataRole.DecorationRole and field == "name" and track.track_id == self._now_playing_id): from PyQt6.QtWidgets import QApplication color = QApplication.palette().text().color() return speaker_pixmap(self._now_playing_active, color) value = getattr(track, field, "") if role == SORT_ROLE: if isinstance(value, str): return value.lower() return value if value is not None else 0 if role == Qt.ItemDataRole.DisplayRole: if field == "total_time": return format_time(value) if field == "rating": return _format_rating(value) if field == "size": return _format_size(value) if field in ("date_added", "play_date_utc", "skip_date", "date_modified"): return _format_date(value or "") if isinstance(value, int) and value == 0: return "" return str(value) if value else "" return None class TrackTableView(QTableView): """Shared track list for the library and playlist views. Owners connect to the signals and apply changes through LibraryManager; the view itself never mutates the library. """ play_requested = pyqtSignal(list, int) # ids in view order, start index sort_changed = pyqtSignal(str, bool) # field, ascending columns_changed = pyqtSignal(list) # visible field names column_width_changed = pyqtSignal(str, int) reorder_requested = pyqtSignal(list, int) # manual rows, dest manual row tracks_dropped = pyqtSignal(list, object) # track ids, insert row or None files_dropped = pyqtSignal(list, object) # paths, insert row or None remove_requested = pyqtSignal(list) # manual rows cut_requested = pyqtSignal(list) # source rows to remove now paste_requested = pyqtSignal() info_requested = pyqtSignal(list) # selected track ids tracks_changed = pyqtSignal() # displayed track set changed show_in_playlist_requested = pyqtSignal(int, str) # track_id, playlist pid def __init__(self, parent=None, playlist_mode=False): super().__init__(parent) self._playlist_mode = playlist_mode self._source_playlist_id = "" # False for smart playlists: blocks manual add/remove/reorder/cut/paste. self._content_editable = True self.model_ = TrackTableModel(self, with_index_column=playlist_mode) self.proxy = QSortFilterProxyModel(self) self.proxy.setSourceModel(self.model_) self.proxy.setSortRole(SORT_ROLE) self.setModel(self.proxy) self.setAlternatingRowColors(True) self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.setSortingEnabled(True) self.setShowGrid(False) self.verticalHeader().setVisible(False) self.verticalHeader().setDefaultSectionSize(22) self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerItem) header = self.horizontalHeader() header.setSectionsMovable(True) header.setStretchLastSection(True) # Don't bold header labels when a row/cell becomes current (playback # sets the current index, which would otherwise bold every section). header.setHighlightSections(False) header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) header.customContextMenuRequested.connect(self._show_header_menu) header.sortIndicatorChanged.connect(self._on_sort_indicator) header.sectionResized.connect(self._on_section_resized) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._show_context_menu) self.doubleClicked.connect(self._on_double_click) # Set by the owning view (which holds the manager) so the right-click # menu can list the playlists a track belongs to without the view # itself reaching into the library. self.playlists_for_track = None # Callable[[int], list[tuple[str,str]]] # Drag and drop (default indicator off: we paint a glowing line) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(False) self._drop_indicator_y: int | None = None # Edge auto-scroll during a drag (Qt's built-in autoscroll never runs # because our dragMoveEvent accepts the event itself). 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) QShortcut(QKeySequence.StandardKey.Copy, self, context=Qt.ShortcutContext.WidgetShortcut, activated=self.copy_selection) QShortcut(QKeySequence.StandardKey.Cut, self, context=Qt.ShortcutContext.WidgetShortcut, activated=self.cut_selection) QShortcut(QKeySequence.StandardKey.Paste, self, context=Qt.ShortcutContext.WidgetShortcut, activated=self.paste_requested) self._suppress_signals = False # ---- configuration ---- def set_source_playlist(self, pid: str): self._source_playlist_id = pid def set_content_editable(self, editable: bool): """Smart playlists pass False: dropping, reordering, cut/paste and remove are all disabled (membership is derived from criteria).""" self._content_editable = editable self.setAcceptDrops(editable) def context_id(self) -> str: """Identifies what this table is currently showing, so the now-playing icon can be scoped to the context playback started from.""" if self._playlist_mode: return f"playlist:{self._source_playlist_id}" return "library" def reveal_track(self, track_id: int) -> bool: """Scroll to and select the row for ``track_id``; False if not shown.""" for src_row, track in enumerate(self.model_.tracks): if track.track_id == track_id: proxy_index = self.proxy.mapFromSource( self.model_.index(src_row, 0)) self.setCurrentIndex(proxy_index) self.selectRow(proxy_index.row()) self.scrollTo( proxy_index, QAbstractItemView.ScrollHint.PositionAtCenter) return True return False def set_tracks(self, tracks: list[Track]): self.model_.set_tracks(tracks) self.tracks_changed.emit() def total_stats(self) -> tuple[int, int]: """(track count, summed total_time in ms) of the displayed tracks.""" tracks = self.model_.tracks return len(tracks), sum(t.total_time for t in tracks) def apply_settings(self, settings): """Apply a PlaylistSettings: columns, widths, sort.""" self._suppress_signals = True try: self.model_.set_visible_columns(settings.visible_columns) self._apply_default_widths() for field, width in settings.column_widths.items(): if field in self.model_.fields: self.setColumnWidth(self.model_.fields.index(field), width) sort_field = settings.sort_column if sort_field not in self.model_.fields: sort_field = self.model_.fields[0] order = (Qt.SortOrder.AscendingOrder if settings.sort_ascending else Qt.SortOrder.DescendingOrder) self.sortByColumn(self.model_.fields.index(sort_field), order) finally: self._suppress_signals = False def _apply_default_widths(self): for i, field in enumerate(self.model_.fields): if field == INDEX_FIELD: self.setColumnWidth(i, 50) else: self.setColumnWidth(i, COLUMN_MAP[field][2]) # ---- selection / ordering helpers ---- def view_order_track_ids(self) -> list[int]: ids = [] for proxy_row in range(self.proxy.rowCount()): source_row = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)).row() ids.append(self.model_.track_at(source_row).track_id) return ids def selected_source_rows(self) -> list[int]: rows = {self.proxy.mapToSource(idx).row() for idx in self.selectionModel().selectedRows()} return sorted(rows) def selected_track_ids(self) -> list[int]: return [self.model_.track_at(r).track_id for r in self.selected_source_rows()] def selected_locations(self) -> list[str]: """File paths of the selected tracks, skipping any without a location.""" locs = [self.model_.track_at(r).location for r in self.selected_source_rows()] return [loc for loc in locs if loc] def is_manual_sort(self) -> bool: header = self.horizontalHeader() return (self._playlist_mode and self.model_.fields and self.model_.fields[header.sortIndicatorSection()] == INDEX_FIELD and header.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder) # ---- copy / paste ---- def copy_selection(self): ids = self.selected_track_ids() if not ids: return QApplication.clipboard().setMimeData(make_tracks_mime( ids, self._source_playlist_id, self.selected_source_rows())) def cut_selection(self): """Copy the selection, then — in a playlist — remove it immediately so it visibly leaves; a paste re-inserts it at the target. From the library there's nothing to remove, so this is just a copy.""" ids = self.selected_track_ids() if not ids: return rows = self.selected_source_rows() QApplication.clipboard().setMimeData( make_tracks_mime(ids, self._source_playlist_id, rows)) if self._playlist_mode and self._source_playlist_id and self._content_editable: self.cut_requested.emit(rows) def paste_anchor_row(self): """Source-model row to paste *above* — the first selected track — or None to append when nothing is selected.""" rows = self.selected_source_rows() return rows[0] if rows else None # ---- header interactions ---- def _on_sort_indicator(self, section, order): if self._suppress_signals or not self.model_.fields: return field = self.model_.fields[section] self.sort_changed.emit(field, order == Qt.SortOrder.AscendingOrder) def _on_section_resized(self, section, _old, new_width): if self._suppress_signals or section >= len(self.model_.fields): return field = self.model_.fields[section] if field != INDEX_FIELD: self.column_width_changed.emit(field, new_width) def _show_header_menu(self, pos): menu = QMenu(self) visible = [f for f in self.model_.fields if f != INDEX_FIELD] for field, label, _w in ALL_COLUMNS: action = menu.addAction(label) action.setCheckable(True) action.setChecked(field in visible) action.setData(field) chosen = menu.exec(self.horizontalHeader().mapToGlobal(pos)) if chosen is None: return field = chosen.data() if chosen.isChecked(): new_visible = visible + [field] # Preserve canonical column ordering new_visible = [f for f, _l, _w in ALL_COLUMNS if f in new_visible] else: new_visible = [f for f in visible if f != field] if not new_visible: return self.columns_changed.emit(new_visible) # ---- context menu ---- def _show_context_menu(self, pos): if not self.selected_track_ids(): return menu = QMenu(self) info_action = menu.addAction("Get Info\tCtrl+I") copy_action = menu.addAction("Copy\tCtrl+C") cut_action = menu.addAction("Cut\tCtrl+X") paste_action = None if (self._playlist_mode and self._content_editable and parse_tracks_mime(QApplication.clipboard().mimeData())): paste_action = menu.addAction("Paste\tCtrl+V") locations = self.selected_locations() reveal_action = copy_path_action = None if locations: menu.addSeparator() reveal_action = menu.addAction("Reveal in File Browser") copy_path_action = menu.addAction("Copy File Path") # "Show in Playlist" — only for a single track, listing the regular # playlists it belongs to. Hovering reveals the submenu; choosing an # entry jumps to that playlist and highlights the first instance. show_in_menu = None selected = self.selected_track_ids() if len(selected) == 1 and self.playlists_for_track is not None: entries = self.playlists_for_track(selected[0]) if entries: menu.addSeparator() show_in_menu = menu.addMenu("Show in Playlist") for pid, name in entries: show_in_menu.addAction(name).setData(pid) remove_action = None if self._playlist_mode and self._content_editable: menu.addSeparator() remove_action = menu.addAction("Remove from Playlist\tDel") chosen = menu.exec(self.viewport().mapToGlobal(pos)) if chosen is None: return if chosen is info_action: self.info_requested.emit(self.selected_track_ids()) elif chosen is copy_action: self.copy_selection() elif chosen is cut_action: self.cut_selection() elif paste_action is not None and chosen is paste_action: self.paste_requested.emit() elif chosen is reveal_action: reveal_paths(locations) elif chosen is copy_path_action: QApplication.clipboard().setText("\n".join(locations)) elif show_in_menu is not None and chosen in show_in_menu.actions(): self.show_in_playlist_requested.emit(selected[0], chosen.data()) elif remove_action is not None and chosen is remove_action: self.remove_requested.emit(self.selected_source_rows()) # ---- playback ---- def _on_double_click(self, index): ids = self.view_order_track_ids() if ids: self.play_requested.emit(ids, index.row()) # ---- keyboard ---- def keyPressEvent(self, event): if (event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace) and self._playlist_mode and self._content_editable and self.selected_source_rows()): self.remove_requested.emit(self.selected_source_rows()) return if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): current = self.currentIndex() if current.isValid(): self._on_double_click(current) return super().keyPressEvent(event) # ---- drag and drop ---- def startDrag(self, supported_actions): ids = self.selected_track_ids() if not ids: return drag = QDrag(self) drag.setMimeData(make_tracks_mime(ids, self._source_playlist_id, self.selected_source_rows())) # Mutter/Wayland won't render QDrag.setPixmap, so we drive our own # cursor-following overlay (drag_ghost) instead. pixmap, hotspot = self._drag_pixmap(ids) drag_ghost.begin(self.window(), pixmap, hotspot, QCursor.pos()) try: drag.exec(Qt.DropAction.CopyAction | Qt.DropAction.MoveAction) finally: drag_ghost.end() def _drag_pixmap(self, ids: list[int]) -> tuple[QPixmap, QPoint]: """A song-file icon plus a rounded chip naming the dragged track(s). Returns the composed pixmap and the hotspot (the icon's centre) so the icon sits directly under the cursor with the name chip trailing right. """ name = self.model_.track_at(self.selected_source_rows()[0]).name text = name or "(untitled)" if len(ids) > 1: text += f" +{len(ids) - 1} more" metrics = QFontMetrics(self.font()) text = metrics.elidedText(text, Qt.TextElideMode.ElideRight, 260) pad_x = 10 chip_w = metrics.horizontalAdvance(text) + 2 * pad_x h = metrics.height() + 10 icon_size = h gap = 6 w = icon_size + gap + chip_w ratio = self.devicePixelRatioF() pixmap = QPixmap(int(w * ratio), int(h * ratio)) pixmap.setDevicePixelRatio(ratio) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) painter.drawPixmap(0, 0, drag_ghost.song_icon(icon_size, ratio)) chip_x = icon_size + gap background = QColor(self.palette().highlight().color()) background.setAlpha(230) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(background) painter.drawRoundedRect(QRectF(chip_x, 0, chip_w, h), h / 2, h / 2) painter.setPen(self.palette().highlightedText().color()) painter.setFont(self.font()) painter.drawText(QRectF(chip_x + pad_x, 0, chip_w - 2 * pad_x, h), Qt.AlignmentFlag.AlignVCenter, text) painter.end() return pixmap, QPoint(icon_size // 2, h // 2) def dragEnterEvent(self, event): if event.mimeData().hasFormat(TRACKS_MIME) or event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event): if not self._content_editable: # smart playlist: no drops event.ignore() self._set_drop_indicator(None) return drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint())) mime = event.mimeData() accepted = False if mime.hasUrls(): accepted = True else: payload = parse_tracks_mime(mime) if payload is None: accepted = False elif (self._playlist_mode and self._source_playlist_id and payload.get("source_playlist") == self._source_playlist_id): # reorder only allowed in manual order accepted = self.is_manual_sort() else: # library view is not a drop target for tracks accepted = self._playlist_mode pos = event.position().toPoint() if accepted: event.acceptProposedAction() self._set_drop_indicator(self._indicator_y(pos)) else: event.ignore() self._set_drop_indicator(None) self._update_autoscroll(pos, accepted) def dragLeaveEvent(self, event): self._set_drop_indicator(None) self._stop_autoscroll() super().dragLeaveEvent(event) def dropEvent(self, event): if not self._content_editable: # smart playlist: no drops return self._set_drop_indicator(None) self._stop_autoscroll() mime = event.mimeData() drop_row = self._drop_row(event.position().toPoint()) if mime.hasUrls(): paths = [u.toLocalFile() for u in mime.urls() if u.isLocalFile()] if paths: self.files_dropped.emit(paths, drop_row) event.acceptProposedAction() return payload = parse_tracks_mime(mime) if payload is None or not self._playlist_mode: return same_playlist = (self._source_playlist_id and payload.get("source_playlist") == self._source_playlist_id) if same_playlist: if self.is_manual_sort(): dest = drop_row if drop_row is not None else self.model_.rowCount() self.reorder_requested.emit(payload.get("rows", []), dest) event.acceptProposedAction() else: self.tracks_dropped.emit(payload.get("track_ids", []), drop_row) event.acceptProposedAction() def _drop_row(self, pos) -> int | None: """Manual-order row to insert before, or None to append. Only meaningful when the view order equals manual order; otherwise callers should append. """ index = self.indexAt(pos) if not index.isValid(): return None if not self.is_manual_sort(): return None rect = self.visualRect(index) row = index.row() if pos.y() > rect.center().y(): row += 1 return row # ---- drop indicator ---- def _indicator_y(self, pos) -> int: """Viewport y for the insertion line matching where the drop lands.""" count = self.proxy.rowCount() if count == 0: return 0 row = self._drop_row(pos) if row is None: # drop appends row = count if row < count: return self.visualRect(self.proxy.index(row, 0)).top() return self.visualRect(self.proxy.index(count - 1, 0)).bottom() + 1 def _set_drop_indicator(self, y: int | None): if y != self._drop_indicator_y: self._drop_indicator_y = y self.viewport().update() # ---- 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 content scrolls under it, so # refresh the drop line for the rows now beneath it. if self._autoscroll_pos is not None: self._set_drop_indicator(self._indicator_y(self._autoscroll_pos)) def _stop_autoscroll(self): self._autoscroll_dir = 0 self._autoscroll_pos = None self._autoscroll_timer.stop() def paintEvent(self, event): super().paintEvent(event) if self._drop_indicator_y is None: return y = max(1, min(self._drop_indicator_y, self.viewport().height() - 2)) right = self.viewport().width() - 4 painter = QPainter(self.viewport()) painter.setRenderHint(QPainter.RenderHint.Antialiasing) color = QColor(self.palette().highlight().color()) glow = QColor(color) glow.setAlpha(70) painter.setPen(QPen(glow, 6, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)) painter.drawLine(4, y, right, y) painter.setPen(QPen(color, 2)) painter.drawLine(4, y, right, y) painter.setPen(Qt.PenStyle.NoPen) painter.setBrush(color) painter.drawEllipse(QPointF(4, y), 3, 3) painter.drawEllipse(QPointF(right, y), 3, 3)