feat: drag-and-drop tracks onto playlist (sidebar) and iPod (tab + nav icon)
This commit is contained in:
parent
bfd146a4a0
commit
b68b86929b
18
src/app.py
18
src/app.py
@ -134,6 +134,10 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled)
|
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):
|
def _on_sidebar_item_selected(self, kind: str, value: str):
|
||||||
if kind == "tab":
|
if kind == "tab":
|
||||||
tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0)
|
tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0)
|
||||||
@ -174,6 +178,20 @@ class MainWindow(QMainWindow):
|
|||||||
self.library_tab.clear_filter()
|
self.library_tab.clear_filter()
|
||||||
self.sidebar.select_library_music()
|
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):
|
def _load_playlists_into_sidebar(self):
|
||||||
for pl in self.playlist_manager.get_all():
|
for pl in self.playlist_manager.get_all():
|
||||||
self.sidebar.add_playlist_item(pl.id, pl.name)
|
self.sidebar.add_playlist_item(pl.id, pl.name)
|
||||||
|
|||||||
@ -8,7 +8,8 @@ from PyQt6.QtWidgets import (
|
|||||||
QComboBox, QListWidget, QListWidgetItem, QMessageBox,
|
QComboBox, QListWidget, QListWidgetItem, QMessageBox,
|
||||||
QFileDialog,
|
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 ipod_device import IPodDevice
|
||||||
from device_monitor import DeviceMonitor
|
from device_monitor import DeviceMonitor
|
||||||
@ -21,12 +22,15 @@ class iPodTab(QWidget):
|
|||||||
|
|
||||||
device_mounted = pyqtSignal(str)
|
device_mounted = pyqtSignal(str)
|
||||||
device_unmounted = pyqtSignal()
|
device_unmounted = pyqtSignal()
|
||||||
|
tracks_dropped_for_transfer = pyqtSignal(list)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.ipod_devices: List[Dict] = []
|
self.ipod_devices: List[Dict] = []
|
||||||
self.current_mount_point: Optional[str] = None
|
self.current_mount_point: Optional[str] = None
|
||||||
self.worker_thread: Optional[WorkerThread] = None
|
self.worker_thread: Optional[WorkerThread] = None
|
||||||
|
self._drag_hover_active = False
|
||||||
|
self.setAcceptDrops(True)
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
@ -36,6 +40,7 @@ class iPodTab(QWidget):
|
|||||||
device_label = QLabel("iPod Device:")
|
device_label = QLabel("iPod Device:")
|
||||||
self.device_combo = QComboBox()
|
self.device_combo = QComboBox()
|
||||||
self.device_combo.setPlaceholderText("No devices found")
|
self.device_combo.setPlaceholderText("No devices found")
|
||||||
|
self.device_combo.currentIndexChanged.connect(self._on_device_combo_changed)
|
||||||
refresh_button = QPushButton("Refresh")
|
refresh_button = QPushButton("Refresh")
|
||||||
refresh_button.clicked.connect(self._on_refresh_devices_clicked)
|
refresh_button.clicked.connect(self._on_refresh_devices_clicked)
|
||||||
device_layout.addWidget(device_label)
|
device_layout.addWidget(device_label)
|
||||||
@ -71,6 +76,17 @@ class iPodTab(QWidget):
|
|||||||
self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
||||||
layout.addWidget(self.transferred_list)
|
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 = QPushButton("Remove Selected from iPod")
|
||||||
remove_button.clicked.connect(self._on_remove_tracks_clicked)
|
remove_button.clicked.connect(self._on_remove_tracks_clicked)
|
||||||
layout.addWidget(remove_button)
|
layout.addWidget(remove_button)
|
||||||
@ -106,6 +122,46 @@ class iPodTab(QWidget):
|
|||||||
self.delete_status.setVisible(False)
|
self.delete_status.setVisible(False)
|
||||||
layout.addWidget(self.delete_status)
|
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):
|
def _on_refresh_devices_clicked(self):
|
||||||
if self._is_worker_running():
|
if self._is_worker_running():
|
||||||
return
|
return
|
||||||
@ -126,18 +182,24 @@ class iPodTab(QWidget):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.ipod_devices = result
|
self.ipod_devices = result
|
||||||
|
self.device_combo.blockSignals(True)
|
||||||
self.device_combo.clear()
|
self.device_combo.clear()
|
||||||
for device in self.ipod_devices:
|
for device in self.ipod_devices:
|
||||||
mounted = device.get("mounted", False)
|
mounted = device.get("mounted", False)
|
||||||
mount_text = " [mounted]" if mounted else " [not mounted]"
|
mount_text = " [mounted]" if mounted else " [not mounted]"
|
||||||
self.device_combo.addItem(device.get("name", "Unknown Device") + mount_text)
|
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:
|
if self.ipod_devices:
|
||||||
|
self.device_combo.setCurrentIndex(0)
|
||||||
|
self.device_combo.blockSignals(False)
|
||||||
device = self.ipod_devices[0]
|
device = self.ipod_devices[0]
|
||||||
detected_mount = device.get("mount_point")
|
detected_mount = device.get("mount_point")
|
||||||
already_mounted = device.get("mounted", False)
|
already_mounted = device.get("mounted", False)
|
||||||
self._start_mount_and_load(device, detected_mount, already_mounted)
|
self._start_mount_and_load(device, detected_mount, already_mounted)
|
||||||
else:
|
else:
|
||||||
|
self.device_combo.blockSignals(False)
|
||||||
self._set_device_not_found()
|
self._set_device_not_found()
|
||||||
else:
|
else:
|
||||||
self._set_device_not_found()
|
self._set_device_not_found()
|
||||||
@ -169,6 +231,12 @@ class iPodTab(QWidget):
|
|||||||
self.current_mount_point = mount_point
|
self.current_mount_point = mount_point
|
||||||
device["info"] = device_info
|
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)
|
free_space = device_info.get("free_space", 0)
|
||||||
total_space = device_info.get("total_space", 0)
|
total_space = device_info.get("total_space", 0)
|
||||||
|
|
||||||
@ -237,10 +305,12 @@ class iPodTab(QWidget):
|
|||||||
self.device_unmounted.emit()
|
self.device_unmounted.emit()
|
||||||
|
|
||||||
def _on_mount_clicked(self):
|
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
|
return
|
||||||
|
|
||||||
device = self.ipod_devices[0]
|
|
||||||
detected_mount = device.get("mount_point")
|
detected_mount = device.get("mount_point")
|
||||||
self._start_mount_and_load(device, detected_mount, already_mounted=False)
|
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]["mounted"] = False
|
||||||
self.ipod_devices[0]["mount_point"] = None
|
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_info_label.setText("iPod ejected. You can now disconnect it.")
|
||||||
self.device_status_label.setText("Status: Ejected - safe to disconnect")
|
self.device_status_label.setText("Status: Ejected - safe to disconnect")
|
||||||
self.mount_button.setEnabled(False)
|
self.mount_button.setEnabled(False)
|
||||||
@ -511,6 +587,21 @@ class iPodTab(QWidget):
|
|||||||
return
|
return
|
||||||
self._on_device_detection_finished(True, f"Found {len(devices)} devices", devices)
|
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):
|
def _cleanup_worker(self):
|
||||||
if self.worker_thread:
|
if self.worker_thread:
|
||||||
self.worker_thread.quit()
|
self.worker_thread.quit()
|
||||||
|
|||||||
@ -17,8 +17,8 @@ from PyQt6.QtWidgets import (
|
|||||||
QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog,
|
QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog,
|
||||||
QStackedWidget, QListWidget, QListWidgetItem, QListView,
|
QStackedWidget, QListWidget, QListWidgetItem, QListView,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize
|
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize, QUrl, QMimeData
|
||||||
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath
|
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath, QDrag
|
||||||
|
|
||||||
from track_info import TrackInfo
|
from track_info import TrackInfo
|
||||||
from metadata_handler import MetadataHandler
|
from metadata_handler import MetadataHandler
|
||||||
@ -41,6 +41,51 @@ EQ_FRAMES = [
|
|||||||
"\u2585\u2586\u2583", "\u2586\u2584\u2581",
|
"\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):
|
class NumericTableItem(QTableWidgetItem):
|
||||||
def __lt__(self, other):
|
def __lt__(self, other):
|
||||||
_, self_data = self.data(Qt.ItemDataRole.UserRole) or (False, {})
|
_, self_data = self.data(Qt.ItemDataRole.UserRole) or (False, {})
|
||||||
@ -95,7 +140,7 @@ class LibraryTab(QWidget):
|
|||||||
|
|
||||||
self._setup_library_toolbar(layout)
|
self._setup_library_toolbar(layout)
|
||||||
|
|
||||||
self.library_table = QTableWidget()
|
self.library_table = DraggableLibraryTable()
|
||||||
self.library_table.setColumnCount(8)
|
self.library_table.setColumnCount(8)
|
||||||
self.library_table.setHorizontalHeaderLabels(
|
self.library_table.setHorizontalHeaderLabels(
|
||||||
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"]
|
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"]
|
||||||
@ -1060,6 +1105,72 @@ class LibraryTab(QWidget):
|
|||||||
|
|
||||||
self._cleanup_worker()
|
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):
|
def _on_sync_metadata_clicked(self):
|
||||||
selected_tracks = self._get_selected_ready_tracks()
|
selected_tracks = self._get_selected_ready_tracks()
|
||||||
if not selected_tracks:
|
if not selected_tracks:
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
@ -6,8 +7,9 @@ from PyQt6.QtWidgets import (
|
|||||||
QListWidget, QListWidgetItem, QMenu, QInputDialog, QMessageBox,
|
QListWidget, QListWidgetItem, QMenu, QInputDialog, QMessageBox,
|
||||||
QFrame,
|
QFrame,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal
|
from PyQt6.QtCore import Qt, pyqtSignal, QUrl
|
||||||
from PyQt6.QtGui import QColor
|
from PyQt6.QtGui import QColor
|
||||||
|
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -16,9 +18,134 @@ SIDEBAR_MAX_WIDTH = 240
|
|||||||
SIDEBAR_DEFAULT_WIDTH = 200
|
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):
|
class SidebarWidget(QWidget):
|
||||||
item_selected = pyqtSignal(str, str)
|
item_selected = pyqtSignal(str, str)
|
||||||
playlist_selected = pyqtSignal(str)
|
playlist_selected = pyqtSignal(str)
|
||||||
|
tracks_dropped_on_playlist = pyqtSignal(str, list)
|
||||||
|
tracks_dropped_for_nav_transfer = pyqtSignal(list)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@ -126,7 +253,7 @@ class SidebarWidget(QWidget):
|
|||||||
self._playlists_header.setStyleSheet(section_style)
|
self._playlists_header.setStyleSheet(section_style)
|
||||||
layout.addWidget(self._playlists_header)
|
layout.addWidget(self._playlists_header)
|
||||||
|
|
||||||
self._playlist_list = QListWidget()
|
self._playlist_list = PlaylistDropListWidget()
|
||||||
self._playlist_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
|
self._playlist_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
|
||||||
self._playlist_list.setDefaultDropAction(Qt.DropAction.MoveAction)
|
self._playlist_list.setDefaultDropAction(Qt.DropAction.MoveAction)
|
||||||
self._playlist_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
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.itemClicked.connect(self._on_playlist_clicked)
|
||||||
self._playlist_list.model().rowsMoved.connect(self._on_playlists_reordered)
|
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)
|
layout.addWidget(self._playlist_list, stretch=1)
|
||||||
|
|
||||||
btn_style = f"""
|
btn_style = f"""
|
||||||
@ -163,7 +291,7 @@ class SidebarWidget(QWidget):
|
|||||||
line2.setFixedHeight(1)
|
line2.setFixedHeight(1)
|
||||||
layout.addWidget(line2)
|
layout.addWidget(line2)
|
||||||
|
|
||||||
self._nav_list = QListWidget()
|
self._nav_list = NavDropListWidget()
|
||||||
items = [
|
items = [
|
||||||
("🎸 iPod", "ipod"),
|
("🎸 iPod", "ipod"),
|
||||||
("⚙️ Settings", "settings"),
|
("⚙️ Settings", "settings"),
|
||||||
@ -174,6 +302,7 @@ class SidebarWidget(QWidget):
|
|||||||
item.setData(Qt.ItemDataRole.UserRole + 2, tab_id)
|
item.setData(Qt.ItemDataRole.UserRole + 2, tab_id)
|
||||||
self._nav_list.addItem(item)
|
self._nav_list.addItem(item)
|
||||||
self._nav_list.itemClicked.connect(self._on_nav_item_clicked)
|
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)
|
layout.addWidget(self._nav_list)
|
||||||
|
|
||||||
self._playlist_items: dict = {}
|
self._playlist_items: dict = {}
|
||||||
@ -261,6 +390,12 @@ class SidebarWidget(QWidget):
|
|||||||
ids.append(item.data(Qt.ItemDataRole.UserRole))
|
ids.append(item.data(Qt.ItemDataRole.UserRole))
|
||||||
return ids
|
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):
|
def _on_playlists_reordered(self):
|
||||||
order = self.get_playlist_order_ids()
|
order = self.get_playlist_order_ids()
|
||||||
self.item_selected.emit("playlist_reorder", "|".join(order))
|
self.item_selected.emit("playlist_reorder", "|".join(order))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user