From b68b86929b77b6bdbd0f086352331787c4993488 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 20:00:56 +0700 Subject: [PATCH] feat: drag-and-drop tracks onto playlist (sidebar) and iPod (tab + nav icon) --- src/app.py | 18 +++++ src/ui/ipod_tab.py | 99 +++++++++++++++++++++++++-- src/ui/library_tab.py | 117 +++++++++++++++++++++++++++++++- src/ui/sidebar_widget.py | 141 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 365 insertions(+), 10 deletions(-) diff --git a/src/app.py b/src/app.py index 9f742c4..d24411f 100644 --- a/src/app.py +++ b/src/app.py @@ -134,6 +134,10 @@ class MainWindow(QMainWindow): ) self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled) + self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist) + self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer) + self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer) + def _on_sidebar_item_selected(self, kind: str, value: str): if kind == "tab": tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0) @@ -174,6 +178,20 @@ class MainWindow(QMainWindow): self.library_tab.clear_filter() self.sidebar.select_library_music() + def _on_tracks_dropped_on_playlist(self, playlist_id: str, paths: list): + self.playlist_manager.add_tracks(playlist_id, paths) + playlist = self.playlist_manager.get_by_id(playlist_id) + if playlist: + self._on_playlist_selected(playlist_id) + self.statusBar().showMessage(f"Added {len(paths)} track(s) to playlist", 5000) + + def _on_tracks_dropped_for_transfer(self, paths: list): + self.library_tab.transfer_tracks_by_paths(paths) + + def _on_tracks_dropped_for_nav_transfer(self, paths: list): + self.stack.setCurrentIndex(0) + self.library_tab.transfer_tracks_by_paths(paths) + def _load_playlists_into_sidebar(self): for pl in self.playlist_manager.get_all(): self.sidebar.add_playlist_item(pl.id, pl.name) diff --git a/src/ui/ipod_tab.py b/src/ui/ipod_tab.py index 0cd9462..93ca611 100644 --- a/src/ui/ipod_tab.py +++ b/src/ui/ipod_tab.py @@ -8,7 +8,8 @@ from PyQt6.QtWidgets import ( QComboBox, QListWidget, QListWidgetItem, QMessageBox, QFileDialog, ) -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import Qt, pyqtSignal, QUrl +from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent from ipod_device import IPodDevice from device_monitor import DeviceMonitor @@ -21,12 +22,15 @@ class iPodTab(QWidget): device_mounted = pyqtSignal(str) device_unmounted = pyqtSignal() + tracks_dropped_for_transfer = pyqtSignal(list) def __init__(self, parent=None): super().__init__(parent) self.ipod_devices: List[Dict] = [] self.current_mount_point: Optional[str] = None self.worker_thread: Optional[WorkerThread] = None + self._drag_hover_active = False + self.setAcceptDrops(True) self._setup_ui() def _setup_ui(self): @@ -36,6 +40,7 @@ class iPodTab(QWidget): device_label = QLabel("iPod Device:") self.device_combo = QComboBox() self.device_combo.setPlaceholderText("No devices found") + self.device_combo.currentIndexChanged.connect(self._on_device_combo_changed) refresh_button = QPushButton("Refresh") refresh_button.clicked.connect(self._on_refresh_devices_clicked) device_layout.addWidget(device_label) @@ -71,6 +76,17 @@ class iPodTab(QWidget): self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) layout.addWidget(self.transferred_list) + self._drop_label = QLabel("\u2B07 Drop tracks here to transfer to iPod") + self._drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._drop_label.setFixedHeight(48) + hl = self.palette().color(self.palette().ColorRole.Highlight) + self._drop_label.setStyleSheet( + f"background: {hl.name()}20; border: 2px dashed {hl.name()}; " + f"border-radius: 8px; font-weight: bold; color: {hl.name()};" + ) + self._drop_label.setVisible(False) + layout.addWidget(self._drop_label) + remove_button = QPushButton("Remove Selected from iPod") remove_button.clicked.connect(self._on_remove_tracks_clicked) layout.addWidget(remove_button) @@ -106,6 +122,46 @@ class iPodTab(QWidget): self.delete_status.setVisible(False) layout.addWidget(self.delete_status) + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + self._drag_hover_active = True + self._drop_label.setVisible(True) + event.acceptProposedAction() + else: + super().dragEnterEvent(event) + + def dragMoveEvent(self, event: QDragMoveEvent): + if self._drag_hover_active and event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + super().dragMoveEvent(event) + + def dragLeaveEvent(self, event: QDragLeaveEvent): + self._drag_hover_active = False + self._drop_label.setVisible(False) + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent): + self._drag_hover_active = False + self._drop_label.setVisible(False) + if event.mimeData().hasUrls(): + paths = [url.toLocalFile() for url in event.mimeData().urls()] + paths = [p for p in paths if os.path.exists(p)] + if paths: + if not self.current_mount_point: + QMessageBox.warning( + self, "iPod Not Mounted", + "Mount the iPod first before transferring tracks." + ) + event.ignore() + return + self.tracks_dropped_for_transfer.emit(paths) + event.acceptProposedAction() + else: + event.ignore() + else: + super().dropEvent(event) + def _on_refresh_devices_clicked(self): if self._is_worker_running(): return @@ -126,18 +182,24 @@ class iPodTab(QWidget): if success: self.ipod_devices = result + self.device_combo.blockSignals(True) self.device_combo.clear() for device in self.ipod_devices: mounted = device.get("mounted", False) mount_text = " [mounted]" if mounted else " [not mounted]" self.device_combo.addItem(device.get("name", "Unknown Device") + mount_text) + idx = self.device_combo.count() - 1 + self.device_combo.setItemData(idx, device, Qt.ItemDataRole.UserRole) if self.ipod_devices: + self.device_combo.setCurrentIndex(0) + self.device_combo.blockSignals(False) device = self.ipod_devices[0] detected_mount = device.get("mount_point") already_mounted = device.get("mounted", False) self._start_mount_and_load(device, detected_mount, already_mounted) else: + self.device_combo.blockSignals(False) self._set_device_not_found() else: self._set_device_not_found() @@ -169,6 +231,12 @@ class iPodTab(QWidget): self.current_mount_point = mount_point device["info"] = device_info + idx = self.device_combo.currentIndex() + if idx >= 0: + name = device.get("name", "Unknown Device") + self.device_combo.setItemText(idx, name + " [mounted]") + self.device_combo.setItemData(idx, device, Qt.ItemDataRole.UserRole) + free_space = device_info.get("free_space", 0) total_space = device_info.get("total_space", 0) @@ -237,10 +305,12 @@ class iPodTab(QWidget): self.device_unmounted.emit() def _on_mount_clicked(self): - if not self.ipod_devices: + idx = self.device_combo.currentIndex() + if idx < 0: + return + device = self.device_combo.itemData(idx, Qt.ItemDataRole.UserRole) + if not device: return - - device = self.ipod_devices[0] detected_mount = device.get("mount_point") self._start_mount_and_load(device, detected_mount, already_mounted=False) @@ -265,6 +335,12 @@ class iPodTab(QWidget): self.ipod_devices[0]["mounted"] = False self.ipod_devices[0]["mount_point"] = None + idx = self.device_combo.currentIndex() + if idx >= 0: + name = self.ipod_devices[0].get("name", "Unknown Device") + self.device_combo.setItemText(idx, name + " [not mounted]") + self.device_combo.setItemData(idx, self.ipod_devices[0], Qt.ItemDataRole.UserRole) + self.device_info_label.setText("iPod ejected. You can now disconnect it.") self.device_status_label.setText("Status: Ejected - safe to disconnect") self.mount_button.setEnabled(False) @@ -511,6 +587,21 @@ class iPodTab(QWidget): return self._on_device_detection_finished(True, f"Found {len(devices)} devices", devices) + def _on_device_combo_changed(self, index): + device = self.device_combo.itemData(index, Qt.ItemDataRole.UserRole) + if not device: + return + mp = device.get("mount_point") + if mp and mp == self.current_mount_point: + return + if self._is_worker_running(): + return + self._start_mount_and_load( + device, + mount_point=mp, + already_mounted=device.get("mounted", False), + ) + def _cleanup_worker(self): if self.worker_thread: self.worker_thread.quit() diff --git a/src/ui/library_tab.py b/src/ui/library_tab.py index 2f6ced8..db1741a 100644 --- a/src/ui/library_tab.py +++ b/src/ui/library_tab.py @@ -17,8 +17,8 @@ from PyQt6.QtWidgets import ( QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog, QStackedWidget, QListWidget, QListWidgetItem, QListView, ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize -from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath +from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize, QUrl, QMimeData +from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath, QDrag from track_info import TrackInfo from metadata_handler import MetadataHandler @@ -41,6 +41,51 @@ EQ_FRAMES = [ "\u2585\u2586\u2583", "\u2586\u2584\u2581", ] +class DraggableLibraryTable(QTableWidget): + """QTableWidget subclass that supports dragging tracks as file URLs.""" + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + + def startDrag(self, supportedActions): + paths = [] + for item in self.selectedItems(): + data_item = self.item(item.row(), 0) + if data_item is None: + continue + _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + if track_data and track_data.get("path"): + paths.append(track_data["path"]) + + if not paths: + return + + paths = list(set(paths)) + mime = QMimeData() + mime.setUrls([QUrl.fromLocalFile(p) for p in paths]) + + drag = QDrag(self) + drag.setMimeData(mime) + + count = len(paths) + label = f"{count} track{'s' if count != 1 else ''}" + pixmap = QPixmap(160, 28) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + bg = self.palette().color(self.palette().ColorRole.Highlight) + painter.setBrush(bg) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(0, 0, 160, 28, 6, 6) + painter.setPen(self.palette().color(self.palette().ColorRole.HighlightedText)) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, label) + painter.end() + drag.setPixmap(pixmap) + drag.setHotSpot(pixmap.rect().center()) + + drag.exec(Qt.DropAction.CopyAction) + + class NumericTableItem(QTableWidgetItem): def __lt__(self, other): _, self_data = self.data(Qt.ItemDataRole.UserRole) or (False, {}) @@ -95,7 +140,7 @@ class LibraryTab(QWidget): self._setup_library_toolbar(layout) - self.library_table = QTableWidget() + self.library_table = DraggableLibraryTable() self.library_table.setColumnCount(8) self.library_table.setHorizontalHeaderLabels( ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"] @@ -1060,6 +1105,72 @@ class LibraryTab(QWidget): self._cleanup_worker() + def transfer_tracks_by_paths(self, paths: List[str]): + """Transfer tracks by file paths (used for drag-drop onto iPod tab).""" + ready_by_path = {} + for t in self.library_ready: + p = t.get("path", "") + if p: + ready_by_path[p] = t + + handler = MetadataHandler() + tracks = [] + for path in paths: + if not os.path.exists(path): + continue + if path in ready_by_path: + d = ready_by_path[path] + ti = TrackInfo( + source_id=f"local_{hash(path)}", + title=d["title"], artist=d["artist"], + album=d.get("album", ""), thumbnail_url="", + duration=d.get("duration_s", 0), + track_number=d.get("track_num"), + genre=d.get("genre", ""), download_path=path, + ) + tracks.append((ti, path)) + else: + try: + info = handler.extract_tags(path) + ti = TrackInfo( + source_id=f"local_{hash(path)}", + title=info.title if info else os.path.basename(path), + artist=info.artist if info else "Unknown", + album=info.album if info else "", thumbnail_url="", + duration=info.duration if info else 0, + track_number=info.track_number if info else 0, + genre=info.genre if info else "", download_path=path, + ) + tracks.append((ti, path)) + except Exception as e: + logger.warning(f"Skipping {path}: {e}") + continue + + if not tracks: + QMessageBox.warning(self, "Error", "No valid tracks to transfer") + return + + if not self._current_mount_point: + QMessageBox.warning(self, "Error", "No iPod device mounted. Go to iPod tab and mount first.") + return + + if self._is_worker_running(): + QMessageBox.information(self, "Info", "Please wait for the current task to finish") + return + + self.library_transfer_btn.setEnabled(False) + self.library_status.setText("Transferring...") + self.library_progress.setValue(0) + + self.worker_thread = WorkerThread( + task_type="transfer", + tracks=tracks, + mount_point=self._current_mount_point, + ) + self.worker_thread.progress_signal.connect(self._on_library_transfer_progress) + self.worker_thread.finished_signal.connect(self._on_library_transfer_finished) + self.worker_thread.start() + def _on_sync_metadata_clicked(self): selected_tracks = self._get_selected_ready_tracks() if not selected_tracks: diff --git a/src/ui/sidebar_widget.py b/src/ui/sidebar_widget.py index c036f1b..31b2c85 100644 --- a/src/ui/sidebar_widget.py +++ b/src/ui/sidebar_widget.py @@ -1,4 +1,5 @@ import logging +import os from typing import List, Optional from PyQt6.QtWidgets import ( @@ -6,8 +7,9 @@ from PyQt6.QtWidgets import ( QListWidget, QListWidgetItem, QMenu, QInputDialog, QMessageBox, QFrame, ) -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import Qt, pyqtSignal, QUrl from PyQt6.QtGui import QColor +from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent logger = logging.getLogger(__name__) @@ -16,9 +18,134 @@ SIDEBAR_MAX_WIDTH = 240 SIDEBAR_DEFAULT_WIDTH = 200 +class PlaylistDropListWidget(QListWidget): + """QListWidget that accepts external track drops onto playlist items.""" + tracks_dropped_on_playlist = pyqtSignal(str, list) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self._hovered_item = None + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.source() is not self and event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + super().dragEnterEvent(event) + + def dragMoveEvent(self, event: QDragMoveEvent): + if event.source() is not self and event.mimeData().hasUrls(): + pos = event.position().toPoint() + item = self.itemAt(pos) + if item != self._hovered_item: + self._clear_hover() + if item: + self._hovered_item = item + bg = self.palette().color(self.palette().ColorRole.Highlight) + c = bg.name() + item.setBackground(bg) + event.acceptProposedAction() + else: + super().dragMoveEvent(event) + + def dragLeaveEvent(self, event: QDragLeaveEvent): + self._clear_hover() + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent): + self._clear_hover() + if event.source() is not self and event.mimeData().hasUrls(): + pos = event.position().toPoint() + item = self.itemAt(pos) + if item is None: + event.ignore() + return + playlist_id = item.data(Qt.ItemDataRole.UserRole) + if not playlist_id: + event.ignore() + return + paths = [url.toLocalFile() for url in event.mimeData().urls()] + paths = [p for p in paths if os.path.exists(p)] + if paths: + self.tracks_dropped_on_playlist.emit(playlist_id, paths) + event.acceptProposedAction() + else: + event.ignore() + else: + super().dropEvent(event) + + def _clear_hover(self): + if self._hovered_item: + self._hovered_item.setData(Qt.ItemDataRole.BackgroundRole, None) + self._hovered_item = None + + +class NavDropListWidget(QListWidget): + """QListWidget that accepts external track drops on the iPod nav item.""" + tracks_dropped_for_transfer = pyqtSignal(list) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self._hovered_item = None + + def _target_item_at(self, pos): + item = self.itemAt(pos) + if item and item.data(Qt.ItemDataRole.UserRole + 2) == "ipod": + return item + return None + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.source() is not self and event.mimeData().hasUrls(): + pos = event.position().toPoint() + if self._target_item_at(pos): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event: QDragMoveEvent): + if event.source() is not self and event.mimeData().hasUrls(): + pos = event.position().toPoint() + item = self._target_item_at(pos) + if item != self._hovered_item: + self._clear_hover() + if item: + self._hovered_item = item + bg = self.palette().color(self.palette().ColorRole.Highlight) + item.setBackground(bg) + if item: + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dragLeaveEvent(self, event: QDragLeaveEvent): + self._clear_hover() + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent): + self._clear_hover() + if event.source() is not self and event.mimeData().hasUrls(): + pos = event.position().toPoint() + if self._target_item_at(pos): + paths = [url.toLocalFile() for url in event.mimeData().urls()] + paths = [p for p in paths if os.path.exists(p)] + if paths: + self.tracks_dropped_for_transfer.emit(paths) + event.acceptProposedAction() + return + super().dropEvent(event) + + def _clear_hover(self): + if self._hovered_item: + self._hovered_item.setData(Qt.ItemDataRole.BackgroundRole, None) + self._hovered_item = None + + class SidebarWidget(QWidget): item_selected = pyqtSignal(str, str) playlist_selected = pyqtSignal(str) + tracks_dropped_on_playlist = pyqtSignal(str, list) + tracks_dropped_for_nav_transfer = pyqtSignal(list) def __init__(self, parent=None): super().__init__(parent) @@ -126,7 +253,7 @@ class SidebarWidget(QWidget): self._playlists_header.setStyleSheet(section_style) layout.addWidget(self._playlists_header) - self._playlist_list = QListWidget() + self._playlist_list = PlaylistDropListWidget() self._playlist_list.setDragDropMode(QListWidget.DragDropMode.InternalMove) self._playlist_list.setDefaultDropAction(Qt.DropAction.MoveAction) self._playlist_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -135,6 +262,7 @@ class SidebarWidget(QWidget): ) self._playlist_list.itemClicked.connect(self._on_playlist_clicked) self._playlist_list.model().rowsMoved.connect(self._on_playlists_reordered) + self._playlist_list.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist) layout.addWidget(self._playlist_list, stretch=1) btn_style = f""" @@ -163,7 +291,7 @@ class SidebarWidget(QWidget): line2.setFixedHeight(1) layout.addWidget(line2) - self._nav_list = QListWidget() + self._nav_list = NavDropListWidget() items = [ ("🎸 iPod", "ipod"), ("⚙️ Settings", "settings"), @@ -174,6 +302,7 @@ class SidebarWidget(QWidget): item.setData(Qt.ItemDataRole.UserRole + 2, tab_id) self._nav_list.addItem(item) self._nav_list.itemClicked.connect(self._on_nav_item_clicked) + self._nav_list.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_nav_transfer) layout.addWidget(self._nav_list) self._playlist_items: dict = {} @@ -261,6 +390,12 @@ class SidebarWidget(QWidget): ids.append(item.data(Qt.ItemDataRole.UserRole)) return ids + def _on_tracks_dropped_on_playlist(self, playlist_id: str, paths: list): + self.tracks_dropped_on_playlist.emit(playlist_id, paths) + + def _on_tracks_dropped_for_nav_transfer(self, paths: list): + self.tracks_dropped_for_nav_transfer.emit(paths) + def _on_playlists_reordered(self): order = self.get_playlist_order_ids() self.item_selected.emit("playlist_reorder", "|".join(order))