feat: drag-and-drop tracks onto playlist (sidebar) and iPod (tab + nav icon)

This commit is contained in:
Maksim Totmin 2026-06-01 20:00:56 +07:00
parent bfd146a4a0
commit b68b86929b
4 changed files with 365 additions and 10 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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:

View File

@ -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))