A single Right-arrow press skipped 1,676 tracks in 81 s and hard-froze the desktop. On Wayland key repeat is generated by the client until the compositor delivers the release, and the key filter treated every repeat as a new press. Each skip sent gnome-shell an MPRIS Metadata whose xesam:artist was a plain Python list, which marshals as "av" instead of "as". gnome-shell logged an error for every one (50k/min) and reloaded the cover, and that load is what kept it from ever delivering the release. Space/Left/Right now act once per press and swallow the repeats. xesam:artist goes out through mpris.string_array, which build_properties_changed now shares for invalidated_properties. The new test round-trips the signal into GDBus, which is what gnome-shell validates with, and fails 'av' == 'as' against the old code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1705 lines
76 KiB
Python
1705 lines
76 KiB
Python
import shutil
|
|
from pathlib import Path
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
|
|
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
|
|
QLabel, QMessageBox, QFileDialog, QProgressBar, QPushButton,
|
|
)
|
|
from PyQt6.QtCore import Qt, QEvent, QTimer
|
|
from PyQt6.QtGui import QAction, QKeySequence
|
|
|
|
from lintunes import device_sync, mpris, music_folder, tagging, theme, url_import
|
|
from lintunes.andtunes import layout as andtunes_layout
|
|
from lintunes.andtunes import install as andtunes_install
|
|
from lintunes.andtunes import sync as andtunes_sync
|
|
from lintunes.art_search import AlbumArtFetcher
|
|
from lintunes.cast.controller import CastController
|
|
from lintunes.eventlog import log_control
|
|
from lintunes.export import exporter
|
|
from lintunes.fingerprint import TrackIdentifier, fpcalc_available
|
|
from lintunes.inhibit import INHIBIT_LOGOUT, INHIBIT_SUSPEND, SleepInhibitor
|
|
from lintunes.player import Player
|
|
from lintunes.importers import file_importer
|
|
from lintunes.gui.album_art_dialog import AlbumArtDialog
|
|
from lintunes.gui.cast_dialog import ChromecastDialog
|
|
from lintunes.gui.device_sync_dialog import DeviceSyncSettingsDialog
|
|
from lintunes.gui.export_dialog import ExportKindDialog, WebMixDialog
|
|
from lintunes.gui.identify_dialog import IdentifyDialog
|
|
from lintunes.gui.sidebar import SidebarPanel
|
|
from lintunes.gui.library_view import LibraryView
|
|
from lintunes.gui.playlist_view import PlaylistView
|
|
from lintunes.gui.transport import TransportBar
|
|
from lintunes.gui.url_import_dialog import UrlImportDialog
|
|
from lintunes.gui.info_dialog import InfoDialog
|
|
from lintunes.gui.music_folder_dialog import MusicFolderDialog
|
|
from lintunes.gui.preferences_dialog import PreferencesDialog
|
|
from lintunes.gui.track_table import format_total_time
|
|
from lintunes.gui.version_button import VersionButton
|
|
from lintunes.updater import Updater
|
|
|
|
|
|
def album_tracks(library, artist: str, album: str) -> list:
|
|
"""Every library track on the given album (case-insensitive match on
|
|
album name + album artist, falling back to track artist — the same
|
|
grouping the art download uses)."""
|
|
artist_cf, album_cf = artist.casefold(), album.casefold()
|
|
return [t for t in library.tracks.values()
|
|
if t.album.casefold() == album_cf
|
|
and (t.album_artist or t.artist).casefold() == artist_cf]
|
|
|
|
|
|
class _CancelSyncButton(QPushButton):
|
|
"""Compact "✕" in the status bar's right corner that expands to say what
|
|
it does ("cancel transfer") while hovered, then shrinks back. Expanding
|
|
pushes the progress bar and label left; the button itself stays put."""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.setFlat(True)
|
|
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
self.setToolTip("Cancel the transfer")
|
|
self.setStyleSheet("QPushButton { border: none; padding: 0 4px; }")
|
|
self._apply("✕")
|
|
|
|
def _apply(self, text: str):
|
|
self.setText(text)
|
|
self.setFixedWidth(self.fontMetrics().horizontalAdvance(text) + 14)
|
|
|
|
def enterEvent(self, event):
|
|
self._apply("cancel transfer")
|
|
super().enterEvent(event)
|
|
|
|
def leaveEvent(self, event):
|
|
self._apply("✕")
|
|
super().leaveEvent(event)
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
def __init__(self, manager, prefs, lastfm=None, parent=None):
|
|
super().__init__(parent)
|
|
self._manager = manager
|
|
self._prefs = prefs
|
|
self._lastfm = lastfm
|
|
self._prefs_dialog = None
|
|
self.player = Player(manager, self)
|
|
# Owns the cast session, if any. Built before the transport bar, which
|
|
# takes it to drive the little cast indicator under the volume slider.
|
|
self._cast = CastController(self.player, self)
|
|
# Keep the machine awake while audio is actually playing.
|
|
self._inhibitor = SleepInhibitor()
|
|
# Context playback started from: "library" or "playlist:<pid>". Scopes
|
|
# the now-playing speaker icon to where the song is actually playing.
|
|
self._now_playing_context = ""
|
|
|
|
self.setWindowTitle("LinTunes")
|
|
self.resize(1280, 800)
|
|
# Keep the window's minimum well under typical sizes so the WM never
|
|
# refuses an interactive shrink
|
|
self.setMinimumSize(560, 420)
|
|
self.setAcceptDrops(True)
|
|
|
|
central = QWidget()
|
|
layout = QVBoxLayout(central)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
self.setCentralWidget(central)
|
|
|
|
self._transport = TransportBar(self.player, manager, self._prefs,
|
|
cast=self._cast)
|
|
layout.addWidget(self._transport)
|
|
|
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
layout.addWidget(splitter, stretch=1)
|
|
|
|
self._sidebar = SidebarPanel(manager, self.player)
|
|
self._sidebar.setMinimumWidth(140)
|
|
splitter.addWidget(self._sidebar)
|
|
|
|
self._content = QStackedWidget()
|
|
splitter.addWidget(self._content)
|
|
splitter.setSizes([220, 1060])
|
|
splitter.setCollapsible(1, False)
|
|
|
|
self._library_view = LibraryView(manager)
|
|
self._content.addWidget(self._library_view)
|
|
self._playlist_view = PlaylistView(manager)
|
|
self._content.addWidget(self._playlist_view)
|
|
self._content.setCurrentWidget(self._library_view)
|
|
|
|
# Bottom status bar, left to right: version/self-update button, then a
|
|
# centered totals readout. Both are non-permanent widgets, so a
|
|
# transient showMessage (import/scrobble/error/update text) replaces
|
|
# them for its duration and they come back — a permanent widget with
|
|
# stretch would squeeze the message area to zero width and swallow
|
|
# every message.
|
|
self.restart_requested = False # run_gui re-execs when True on quit
|
|
self._missing_folder_prompted = False
|
|
self._updater = Updater(self)
|
|
self._version_button = VersionButton(self._updater)
|
|
self.statusBar().addWidget(self._version_button)
|
|
self._totals_label = QLabel()
|
|
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.statusBar().addWidget(self._totals_label, 1)
|
|
self._version_button.status_message.connect(
|
|
lambda msg: self.statusBar().showMessage(msg, 8000))
|
|
self._updater.update_applied.connect(self._restart_for_update)
|
|
self._updater.start_checking()
|
|
# Device-sync progress, right-justified: permanent widgets sit on the
|
|
# status bar's right and survive showMessage. No stretch (see above)
|
|
# and hidden while idle so it costs no space. Text lives in its own
|
|
# label — QProgressBar's overlay can't fit a sentence at this width.
|
|
# The group is laid out left to right but justified right, so whatever
|
|
# is added *last* owns the corner and never moves. The cancel button
|
|
# goes there deliberately: it is the one widget the user aims at, and
|
|
# the label beside it is as wide as the song title of the moment.
|
|
self._sync_label = QLabel()
|
|
self._sync_label.hide()
|
|
self.statusBar().addPermanentWidget(self._sync_label)
|
|
self._sync_progress = QProgressBar()
|
|
self._sync_progress.setFixedWidth(160)
|
|
self._sync_progress.setTextVisible(False)
|
|
self._sync_progress.hide()
|
|
self.statusBar().addPermanentWidget(self._sync_progress)
|
|
self._sync_cancel = _CancelSyncButton()
|
|
self._sync_cancel.hide()
|
|
self._sync_cancel.clicked.connect(self._confirm_cancel_sync)
|
|
self.statusBar().addPermanentWidget(self._sync_cancel)
|
|
self._andtunes_worker = None
|
|
self._andtunes_planner = None
|
|
self._install_worker = None
|
|
self._sync_device_name = ""
|
|
# Export shares the status-bar progress widgets with device sync —
|
|
# only one of the two may run at a time (each guards on the other).
|
|
self._export_worker = None
|
|
# Identify Track… works through its selection one at a time, each
|
|
# behind its own review dialog.
|
|
self._identify_queue = []
|
|
self._identify_total = 0
|
|
self._identifier = None
|
|
# True from the first lookup until the queue drains, so tracks queued
|
|
# mid-run (Import from URL) wait their turn instead of starting a
|
|
# second lookup alongside.
|
|
self._identify_running = False
|
|
# Import from URL: also shares the status-bar progress widgets.
|
|
self._url_worker = None
|
|
self._url_target = None # url_import.ImportTarget, or None
|
|
self._url_inserted = 0 # songs added to the target so far
|
|
self._url_imported = 0
|
|
self._url_identify = True # False after "Stop Identifying"
|
|
self._url_note = ""
|
|
# Separate instance from the playback inhibitor: pausing music
|
|
# mid-sync must not drop the sync's hold. Logout flag included so
|
|
# GNOME's shutdown/restart dialog names the transfer as the blocker.
|
|
self._sync_inhibitor = SleepInhibitor(
|
|
reason="Syncing a playlist to a device",
|
|
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
|
|
self._export_inhibitor = SleepInhibitor(
|
|
reason="Exporting a playlist",
|
|
flags=INHIBIT_LOGOUT | INHIBIT_SUSPEND)
|
|
|
|
# Wiring
|
|
self._transport.play_clicked.connect(self.play_pause)
|
|
self._sidebar.library_selected.connect(self._show_library)
|
|
self._sidebar.playlist_selected.connect(self._show_playlist)
|
|
self._sidebar.tree.export_requested.connect(self._export_playlist)
|
|
self._content.currentChanged.connect(self._update_totals)
|
|
for view in (self._library_view, self._playlist_view):
|
|
view.play_requested.connect(
|
|
lambda ids, start, v=view: self._play_from_view(v, ids, start))
|
|
view.info_requested.connect(self._show_info)
|
|
view.show_in_playlist_requested.connect(self._show_track_in_playlist)
|
|
view.table.tracks_changed.connect(self._update_totals)
|
|
view.table.download_art_requested.connect(self._download_album_art)
|
|
view.table.identify_requested.connect(self._identify_tracks)
|
|
view.table.remove_from_library_requested.connect(
|
|
lambda ids: self._delete_tracks(ids, delete_files=False))
|
|
view.table.delete_from_library_requested.connect(
|
|
lambda ids: self._delete_tracks(ids, delete_files=True))
|
|
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
|
|
manager.tracks_removed.connect(self._on_tracks_removed)
|
|
manager.tracks_restored.connect(self._on_tracks_restored)
|
|
self.player.error_occurred.connect(
|
|
lambda msg: QMessageBox.warning(self, "Can't play track", msg))
|
|
self.player.track_missing.connect(self._on_track_missing)
|
|
self.player.track_changed.connect(self._on_track_changed)
|
|
self.player.playing_changed.connect(self._update_now_playing_icon)
|
|
self.player.playing_changed.connect(self._on_playing_changed)
|
|
manager.track_updated.connect(self._on_track_meta_updated)
|
|
manager.tag_write_failed.connect(
|
|
lambda name, err: self.statusBar().showMessage(
|
|
f"Couldn't write tags to “{name}”: {err}", 8000))
|
|
manager.file_move_failed.connect(
|
|
lambda name, err: self.statusBar().showMessage(
|
|
f"Couldn't move the file for “{name}”: {err}", 8000))
|
|
self.player.track_unplayable.connect(self._on_track_unplayable)
|
|
self._cast.connecting.connect(self._on_cast_connecting)
|
|
self._cast.connected.connect(self._on_cast_connected)
|
|
self._cast.disconnected.connect(self._on_cast_disconnected)
|
|
self._cast.failed.connect(
|
|
lambda msg: QMessageBox.warning(self, "Chromecast", msg))
|
|
manager.library_reloaded.connect(self._on_library_reloaded)
|
|
manager.conflict_resolved.connect(self._on_conflict_resolved)
|
|
prefs.changed.connect(self._on_prefs_changed)
|
|
self._conflict_dialog = None
|
|
if lastfm is not None:
|
|
self.player.track_changed.connect(lastfm.handle_track_started)
|
|
self.player.track_finished.connect(lastfm.handle_track_finished)
|
|
lastfm.status_message.connect(
|
|
lambda msg: self.statusBar().showMessage(msg, 4000))
|
|
|
|
self._build_menus()
|
|
self.apply_ui_metrics()
|
|
self._update_totals()
|
|
|
|
def _restart_for_update(self):
|
|
# The pull already succeeded. Route through close() — not a bare
|
|
# quit() — so closeEvent runs: it flushes the library, tears down the
|
|
# player, and can veto the restart while a device sync is copying.
|
|
self.restart_requested = True
|
|
if self.close():
|
|
QApplication.instance().quit()
|
|
else:
|
|
self.restart_requested = False
|
|
|
|
# ---- status bar totals ----
|
|
|
|
def _update_totals(self):
|
|
view = self._content.currentWidget()
|
|
count, total_ms = view.table.total_stats()
|
|
if count:
|
|
noun = "track" if count == 1 else "tracks"
|
|
self._totals_label.setText(
|
|
f"{count:,} {noun} · {format_total_time(total_ms)}")
|
|
else:
|
|
self._totals_label.setText("")
|
|
|
|
# ---- preferences / theming ----
|
|
|
|
def _on_prefs_changed(self):
|
|
theme.apply_theme(QApplication.instance(), self._prefs)
|
|
self.apply_ui_metrics()
|
|
self._transport.refresh_theme()
|
|
self._version_button.refresh_theme()
|
|
|
|
def apply_ui_metrics(self):
|
|
metrics = theme.scale_metrics(self._prefs)
|
|
for view in (self._library_view, self._playlist_view):
|
|
view.table.verticalHeader().setDefaultSectionSize(metrics["row_height"])
|
|
self._library_view.set_header_height(metrics["header_strip"])
|
|
self._playlist_view.set_header_height(metrics["header_strip"])
|
|
self._sidebar.apply_metrics(metrics["header_strip"])
|
|
self.statusBar().setMinimumHeight(metrics["status_height"])
|
|
|
|
def _show_preferences(self):
|
|
if self._prefs_dialog is None:
|
|
self._prefs_dialog = PreferencesDialog(
|
|
self._prefs, self._lastfm, self,
|
|
music_folder=self._music_folder_summary,
|
|
on_music_folder_change=self._change_music_folder)
|
|
else:
|
|
self._prefs_dialog.refresh_music_folder()
|
|
self._prefs_dialog.show()
|
|
self._prefs_dialog.raise_()
|
|
self._prefs_dialog.activateWindow()
|
|
|
|
def _build_menus(self):
|
|
bar = self.menuBar()
|
|
|
|
file_menu = bar.addMenu("&File")
|
|
self._add_action(file_menu, "New Playlist", "Ctrl+N",
|
|
lambda: self._sidebar.tree.create_playlist_interactive())
|
|
self._add_action(file_menu, "New Smart Playlist", "Ctrl+Alt+N",
|
|
lambda: self._sidebar.tree.create_smart_playlist_interactive())
|
|
self._add_action(file_menu, "New Playlist Folder", "Ctrl+Shift+N",
|
|
lambda: self._manager.create_folder("untitled folder"))
|
|
file_menu.addSeparator()
|
|
self._add_action(file_menu, "Add Files to Library…", "Ctrl+O",
|
|
self._open_files_dialog)
|
|
self._url_import_action = self._add_action(
|
|
file_menu, "Import from URL…", "", self._import_from_url)
|
|
self._export_action = self._add_action(
|
|
file_menu, "Export Playlist…", "", self._export_playlist)
|
|
file_menu.aboutToShow.connect(self._refresh_export_action)
|
|
file_menu.aboutToShow.connect(self._refresh_url_import_action)
|
|
file_menu.addSeparator()
|
|
self._add_action(file_menu, "Quit", "Ctrl+Q", self.close)
|
|
|
|
edit_menu = bar.addMenu("&Edit")
|
|
self._undo_action = self._add_action(
|
|
edit_menu, "Undo", QKeySequence.StandardKey.Undo,
|
|
self._manager.undo_stack.undo)
|
|
self._redo_action = self._add_action(
|
|
edit_menu, "Redo", QKeySequence.StandardKey.Redo,
|
|
self._manager.undo_stack.redo)
|
|
# Accept both the platform default (Ctrl+Y) and Ctrl+Shift+Z for redo.
|
|
self._redo_action.setShortcuts([
|
|
QKeySequence(QKeySequence.StandardKey.Redo),
|
|
QKeySequence("Ctrl+Shift+Z"),
|
|
])
|
|
edit_menu.addSeparator()
|
|
self._add_action(edit_menu, "Preferences…", "Ctrl+,", self._show_preferences)
|
|
self._manager.undo_stack.changed.connect(self._refresh_undo_actions)
|
|
self._refresh_undo_actions()
|
|
|
|
controls = bar.addMenu("&Controls")
|
|
self._add_action(controls, "Play/Pause", "", self.play_pause)
|
|
self._add_action(controls, "Next", "", self.player.next)
|
|
self._add_action(controls, "Previous", "", self.player.previous)
|
|
|
|
view_menu = bar.addMenu("&View")
|
|
self._add_action(view_menu, "Toggle Column Browser", "Ctrl+B",
|
|
self._library_view.toggle_browser)
|
|
|
|
track_menu = bar.addMenu("&Track")
|
|
self._add_action(track_menu, "Get Info", "Ctrl+I", self._info_for_current_view)
|
|
self._add_action(track_menu, "Go to Current Song", "Ctrl+L",
|
|
self._go_to_current_song)
|
|
|
|
# Mnemonic on the "n": Alt+C already belongs to &Controls.
|
|
connections_menu = bar.addMenu("Co&nnections")
|
|
self._andtunes_action = self._add_action(
|
|
connections_menu, "Sync to Rabbit", "", self._sync_to_andtunes)
|
|
self._andtunes_action.setEnabled(False)
|
|
self._add_action(connections_menu, "Rabbit Sync Settings…", "",
|
|
self._show_device_sync_settings)
|
|
self._install_action = self._add_action(
|
|
connections_menu, "Install andTunes on Rabbit…", "",
|
|
self._install_andtunes)
|
|
# "Sync Playlist to Rabbit (Auxio)" was retired in Round 51, once
|
|
# andTunes could play what it syncs. device_sync's helpers stay: the
|
|
# export and andTunes code import them.
|
|
connections_menu.addSeparator()
|
|
self._cast_action = self._add_action(
|
|
connections_menu, "Connect to Chromecast…", "", self._toggle_cast)
|
|
# Re-checked every time the menu opens: cheap (one gvfs listdir), and
|
|
# always reflects plug/unplug, the cast session and the current view.
|
|
connections_menu.aboutToShow.connect(self._refresh_connection_actions)
|
|
|
|
def _add_action(self, menu, text, shortcut, slot):
|
|
action = QAction(text, self)
|
|
if shortcut:
|
|
action.setShortcut(QKeySequence(shortcut))
|
|
action.triggered.connect(slot)
|
|
menu.addAction(action)
|
|
return action
|
|
|
|
def _refresh_undo_actions(self):
|
|
stack = self._manager.undo_stack
|
|
can_undo = stack.can_undo()
|
|
can_redo = stack.can_redo()
|
|
self._undo_action.setEnabled(can_undo)
|
|
self._redo_action.setEnabled(can_redo)
|
|
self._undo_action.setText(
|
|
f"Undo {stack.undo_label()}" if can_undo else "Undo")
|
|
self._redo_action.setText(
|
|
f"Redo {stack.redo_label()}" if can_redo else "Redo")
|
|
|
|
# ---- connections: device sync ----
|
|
|
|
def _refresh_connection_actions(self):
|
|
# An export in flight owns the same status-bar widgets, so it blocks
|
|
# a sync just as another sync would.
|
|
busy = self._busy_worker() is not None
|
|
device = device_sync.find_device()
|
|
# Whole-library sync doesn't care which view is open — only that a
|
|
# device is plugged in and at least one playlist is ticked.
|
|
self._andtunes_action.setEnabled(not busy and device is not None)
|
|
# Enabled whenever there's an APK to offer; the click finds the
|
|
# Rabbit (adb or MTP) and says so if it can't — cheaper than asking
|
|
# adb on every menu open.
|
|
self._install_action.setEnabled(
|
|
andtunes_install.bundled_apk() is not None
|
|
and not (self._install_worker is not None
|
|
and self._install_worker.busy()))
|
|
|
|
if self._cast.is_connected():
|
|
self._cast_action.setText(
|
|
f"Disconnect from {self._cast.device_name()}")
|
|
self._cast_action.setEnabled(True)
|
|
self._cast_action.setToolTip("")
|
|
return
|
|
self._cast_action.setText("Connect to Chromecast…")
|
|
available, why = self._cast.is_available()
|
|
self._cast_action.setEnabled(available)
|
|
# The self-updater ships code but not dependencies, so a machine that
|
|
# has pulled this round hasn't necessarily installed pychromecast yet.
|
|
self._cast_action.setToolTip(
|
|
"" if available else f"{why} — run: pip install -e .")
|
|
|
|
# ---- connections: casting ----
|
|
|
|
def _toggle_cast(self):
|
|
if self._cast.is_connected():
|
|
self._cast.disconnect()
|
|
return
|
|
available, why = self._cast.is_available()
|
|
if not available:
|
|
QMessageBox.information(
|
|
self, "Chromecast support not installed",
|
|
f"{why}.\n\nRun “pip install -e .” in the lintunes checkout, "
|
|
"then restart LinTunes.")
|
|
return
|
|
dialog = ChromecastDialog(self._cast.discovery(), self)
|
|
if dialog.exec() and dialog.selected_device is not None:
|
|
self._cast.connect_to(dialog.selected_device)
|
|
|
|
def _on_cast_connecting(self, device):
|
|
self.statusBar().showMessage(f"Connecting to {device.name}…", 8000)
|
|
|
|
def _on_cast_connected(self, name: str):
|
|
self.statusBar().showMessage(f"Casting to {name}", 6000)
|
|
|
|
def _on_cast_disconnected(self, reason: str):
|
|
self.statusBar().showMessage(
|
|
reason or "Stopped casting — playing here again", 8000)
|
|
|
|
def _on_track_unplayable(self, track, reason: str):
|
|
self.statusBar().showMessage(
|
|
f"Skipped “{track.name}” — {reason}", 6000)
|
|
|
|
def _hide_sync_widgets(self):
|
|
self._sync_cancel.hide()
|
|
self._sync_label.hide()
|
|
self._sync_progress.hide()
|
|
|
|
def _busy_worker(self):
|
|
"""The transfer currently owning the status-bar progress widgets."""
|
|
for worker in (self._andtunes_planner, self._andtunes_worker,
|
|
self._export_worker, self._url_worker):
|
|
if worker is not None and worker.busy():
|
|
return worker
|
|
return None
|
|
|
|
def _confirm_cancel_sync(self):
|
|
if self._url_worker is not None and self._url_worker.busy():
|
|
self._confirm_cancel_url_import()
|
|
return
|
|
if self._export_worker is not None and self._export_worker.busy():
|
|
self._confirm_cancel_export()
|
|
return
|
|
worker = self._andtunes_worker
|
|
if worker is None or not worker.busy():
|
|
return
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle("Cancel transfer")
|
|
box.setText(f"Cancel the transfer to the {self._sync_device_name}?\n"
|
|
"Tracks already copied stay on the device; syncing again "
|
|
"later picks up where this left off.")
|
|
cancel_button = box.addButton(
|
|
"Cancel Transfer", QMessageBox.ButtonRole.DestructiveRole)
|
|
keep = box.addButton("Keep Copying", QMessageBox.ButtonRole.RejectRole)
|
|
box.setDefaultButton(keep)
|
|
box.exec()
|
|
# The transfer keeps running while the dialog is up — it may well
|
|
# have finished by now, in which case there is nothing to cancel.
|
|
if box.clickedButton() is cancel_button and worker is not None:
|
|
worker.cancel()
|
|
|
|
def _on_sync_cancelled(self, summary):
|
|
self._sync_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
self.statusBar().showMessage(
|
|
f"Sync cancelled — {summary['copied']} of {summary['total']} "
|
|
"tracks copied; sync again to finish.", 8000)
|
|
if summary.get("plays"):
|
|
# Counts are brought back before anything is copied, so even a
|
|
# cancelled sync has news for the journals.
|
|
self._manager.reload_from_disk()
|
|
|
|
def _on_sync_failed(self, message):
|
|
self._sync_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
QMessageBox.warning(self, "Sync failed", message)
|
|
|
|
# ---- connections: andTunes (whole-library sync) ----
|
|
|
|
def _show_device_sync_settings(self):
|
|
device = device_sync.find_device()
|
|
dialog = DeviceSyncSettingsDialog(
|
|
self._prefs, self._manager.library, self,
|
|
device_name=device.name if device else "Rabbit")
|
|
dialog.exec()
|
|
|
|
def _selected_sync_playlists(self) -> list:
|
|
"""The ticked playlists, in the order they were ticked in settings.
|
|
|
|
Ids that no longer resolve are skipped rather than cleaned up: a
|
|
playlist can be missing because the other machine hasn't synced yet,
|
|
and silently forgetting the choice would be worse than ignoring it.
|
|
"""
|
|
library = self._manager.library
|
|
return [library.playlists[pid]
|
|
for pid in self._prefs.device_sync.get("playlist_ids", [])
|
|
if pid in library.playlists]
|
|
|
|
def _sync_to_andtunes(self):
|
|
# Everything may have changed since the menu opened — re-verify.
|
|
if self._busy_worker() is not None:
|
|
return
|
|
device = device_sync.find_device()
|
|
if device is None:
|
|
return
|
|
playlists = self._selected_sync_playlists()
|
|
if not playlists:
|
|
QMessageBox.information(
|
|
self, "Nothing to sync",
|
|
"No playlists are set to sync yet.\n\n"
|
|
"Pick them in Connections → Rabbit Sync Settings…")
|
|
return
|
|
|
|
# Planning walks the whole device over MTP — off the GUI thread, or
|
|
# the window freezes and GNOME offers to kill it mid-sync.
|
|
root = andtunes_layout.root_for(device)
|
|
self._andtunes_planner = andtunes_sync.AndTunesPlanWorker(
|
|
playlists, self._manager.library.tracks, root, device,
|
|
copy_art=bool(self._prefs.device_sync.get("copy_art", True)),
|
|
data_dir=self._manager.data_dir, parent=self)
|
|
self._andtunes_planner.planned.connect(self._on_andtunes_planned)
|
|
self._andtunes_planner.failed.connect(self._on_sync_failed)
|
|
self._sync_device_name = device.name
|
|
self._sync_label.setText(f"Checking the {device.name}")
|
|
self._sync_progress.setRange(0, 0) # busy bar: no total to show yet
|
|
self._sync_label.show()
|
|
self._sync_progress.show()
|
|
self._andtunes_planner.start()
|
|
|
|
def _on_andtunes_planned(self, plan, free):
|
|
device = plan.device
|
|
playlists = plan.playlists
|
|
self._hide_sync_widgets()
|
|
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
|
|
conversions = plan.conversions
|
|
names = "\n".join(f" • {device_sync.track_display(i.track)}"
|
|
for i in conversions[:8])
|
|
more = (f"\n … and {len(conversions) - 8} more"
|
|
if len(conversions) > 8 else "")
|
|
answer = QMessageBox.question(
|
|
self, "FFmpeg not found",
|
|
f"{len(conversions)} song(s) need converting to FLAC before "
|
|
f"the {device.name} can play them, but the ffmpeg command "
|
|
f"isn't installed:\n\n{names}{more}\n\nSync without them?")
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
plan.drop_conversions("it needs ffmpeg to convert")
|
|
needed = plan.bytes_to_copy + device_sync.SPACE_MARGIN
|
|
if free + plan.bytes_freed < needed:
|
|
QMessageBox.warning(
|
|
self, "Not enough space",
|
|
f"Syncing {len(playlists)} playlist(s) needs "
|
|
f"{device_sync.format_bytes(needed - plan.bytes_freed)}, but "
|
|
f"the {device.name} only has "
|
|
f"{device_sync.format_bytes(free)} free.")
|
|
return
|
|
|
|
self._andtunes_worker = andtunes_sync.AndTunesSyncWorker(plan, self)
|
|
self._andtunes_worker.progress.connect(self._on_andtunes_progress)
|
|
self._andtunes_worker.finished.connect(self._on_andtunes_finished)
|
|
self._andtunes_worker.cancelled.connect(self._on_sync_cancelled)
|
|
self._andtunes_worker.failed.connect(self._on_sync_failed)
|
|
self._sync_device_name = device.name
|
|
self._sync_label.setText(f"Copying to {device.name}")
|
|
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
|
|
self._sync_progress.setValue(0)
|
|
self._sync_cancel.show()
|
|
self._sync_label.show()
|
|
self._sync_progress.show()
|
|
self._sync_inhibitor.inhibit()
|
|
self._andtunes_worker.start()
|
|
|
|
def _on_andtunes_progress(self, done_kib, total_kib, label):
|
|
# Unlike the per-playlist sync this run has phases that move no audio
|
|
# bytes (album art, then the index), so the label carries the story.
|
|
self._sync_progress.setRange(0, total_kib)
|
|
self._sync_progress.setValue(done_kib)
|
|
self._sync_label.setText(f"{self._sync_device_name} · {label}")
|
|
|
|
def _on_andtunes_finished(self, summary):
|
|
self._sync_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
msg = (f"Synced {summary['playlists']} playlist(s) to the "
|
|
f"{summary['device']}: {summary['tracks']} tracks "
|
|
f"({summary['copied']} copied, {summary['kept']} up to date), "
|
|
f"{summary['albums']} covers, {summary['removed']} removed")
|
|
if summary["skipped"]:
|
|
msg += f", {summary['skipped']} skipped (no local file)"
|
|
if summary.get("refused"):
|
|
msg += f", {summary['refused']} left out (can't play there)"
|
|
if summary.get("unconverted"):
|
|
msg += f", {summary['unconverted']} couldn't be converted"
|
|
unwritable = summary.get("unwritable") or []
|
|
if unwritable:
|
|
msg += f", {len(unwritable)} the device wouldn't take"
|
|
if summary.get("plays"):
|
|
msg += f"; play counts back for {summary['plays']} song(s)"
|
|
# The journal landed in the data dir; fold it in now rather than
|
|
# waiting for the file watcher.
|
|
self._manager.reload_from_disk()
|
|
self.statusBar().showMessage(msg, 10000)
|
|
if unwritable:
|
|
# Worth a dialog: these songs are *not* in the library the app
|
|
# reads, so they'd otherwise just be quietly absent from the
|
|
# playlist. A stale gvfs mount is the usual cause.
|
|
names = "\n".join(f" • {name} — {why}" for name, why in unwritable[:8])
|
|
more = (f"\n … and {len(unwritable) - 8} more"
|
|
if len(unwritable) > 8 else "")
|
|
QMessageBox.warning(
|
|
self, "Some songs wouldn't copy",
|
|
f"The {summary['device']} refused "
|
|
f"{len(unwritable)} song(s):\n\n{names}{more}\n\n"
|
|
"They've been left out of the library on the device, so the "
|
|
"app won't list a song it can't play. Unplugging the "
|
|
"device and plugging it back in usually clears this.")
|
|
|
|
# ---- connections: install the andTunes app ----
|
|
|
|
def _install_andtunes(self):
|
|
if self._install_worker is not None and self._install_worker.busy():
|
|
return
|
|
apk = andtunes_install.bundled_apk()
|
|
if apk is None:
|
|
return
|
|
adb = andtunes_install.adb_path()
|
|
serial = andtunes_install.rabbit_serial(adb) if adb else None
|
|
device = device_sync.find_device()
|
|
if serial is None and device is None:
|
|
QMessageBox.information(
|
|
self, "Rabbit not found",
|
|
"Plug the Rabbit in with USB debugging on, or in "
|
|
"File transfer mode, then try again.")
|
|
return
|
|
|
|
ours = andtunes_install.bundled_version()
|
|
ours_name = ours.get("version_name", "?")
|
|
if serial:
|
|
have = andtunes_install.installed_version(adb, serial)
|
|
if have is None:
|
|
verb = "Install"
|
|
detail = f"Install andTunes {ours_name} on the Rabbit?"
|
|
elif have["version_code"] < ours.get("version_code", 0):
|
|
verb = "Update"
|
|
detail = (f"The Rabbit has andTunes {have['version_name']}; "
|
|
f"this LinTunes carries {ours_name}. Update it?")
|
|
else:
|
|
verb = "Reinstall"
|
|
detail = (f"The Rabbit already has andTunes "
|
|
f"{have['version_name']}. Reinstall {ours_name}?")
|
|
else:
|
|
verb = "Copy"
|
|
detail = ("adb isn't available, so the app will be copied to "
|
|
"Download/ on the Rabbit for you to open in the Files "
|
|
"app and install by hand. Copy it?")
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle(f"{verb} andTunes")
|
|
box.setText(detail)
|
|
go = box.addButton(verb, QMessageBox.ButtonRole.AcceptRole)
|
|
box.addButton(QMessageBox.StandardButton.Cancel)
|
|
box.setDefaultButton(go)
|
|
box.exec()
|
|
if box.clickedButton() is not go:
|
|
return
|
|
|
|
self._install_worker = andtunes_install.InstallWorker(
|
|
apk, adb, serial, device, self)
|
|
self._install_worker.finished.connect(self._on_install_finished)
|
|
self._install_worker.failed.connect(
|
|
lambda message: QMessageBox.warning(self, "andTunes", message))
|
|
self.statusBar().showMessage("Installing andTunes on the Rabbit…")
|
|
self._install_worker.start()
|
|
|
|
def _on_install_finished(self, result):
|
|
if result["method"] == "copied":
|
|
self.statusBar().clearMessage()
|
|
QMessageBox.information(
|
|
self, "andTunes copied",
|
|
f"Copied to {result['path']}.\n\nOn the Rabbit, open Files → "
|
|
"Download → andTunes.apk to install it.")
|
|
return
|
|
msg = "andTunes is installed on the Rabbit"
|
|
if not result.get("granted", True):
|
|
msg += " — open it once to allow file access"
|
|
self.statusBar().showMessage(msg, 8000)
|
|
|
|
# ---- export ----
|
|
|
|
def _refresh_export_action(self):
|
|
"""Export needs a playlist in view and no transfer already running."""
|
|
self._export_action.setEnabled(
|
|
self._busy_worker() is None
|
|
and self._content.currentWidget() is self._playlist_view
|
|
and bool(self._playlist_view.playlist_id))
|
|
|
|
def _export_playlist(self, pid: str = ""):
|
|
# Reachable from the File menu (no argument) and from the sidebar
|
|
# context menu (which names the playlist that was right-clicked).
|
|
if self._busy_worker() is not None:
|
|
return
|
|
pid = pid or self._playlist_view.playlist_id
|
|
playlist = self._manager.library.playlists.get(pid)
|
|
if playlist is None:
|
|
return
|
|
tracks = [self._manager.library.tracks[tid]
|
|
for tid in playlist.track_ids
|
|
if tid in self._manager.library.tracks]
|
|
if not tracks:
|
|
QMessageBox.information(
|
|
self, "Export Playlist",
|
|
f"“{playlist.name}” is empty — there is nothing to export.")
|
|
return
|
|
|
|
kind_dialog = ExportKindDialog(playlist.name, self)
|
|
if kind_dialog.exec() != ExportKindDialog.DialogCode.Accepted:
|
|
return
|
|
kind = kind_dialog.kind()
|
|
|
|
details = None
|
|
if kind == exporter.WEB:
|
|
web_dialog = WebMixDialog(playlist.name, self)
|
|
if web_dialog.exec() != WebMixDialog.DialogCode.Accepted:
|
|
return
|
|
details = web_dialog.values()
|
|
|
|
start_dir = self._music_import_dir()
|
|
parent_dir = QFileDialog.getExistingDirectory(
|
|
self, "Export To",
|
|
str(start_dir.parent) if start_dir else str(Path.home()))
|
|
if not parent_dir:
|
|
return
|
|
|
|
try:
|
|
plan = exporter.plan_export(
|
|
playlist.name, tracks, Path(parent_dir), kind)
|
|
except OSError as e:
|
|
QMessageBox.warning(self, "Export failed",
|
|
f"Couldn't read the playlist's files: {e}")
|
|
return
|
|
if details is not None:
|
|
plan.title = details["title"] or playlist.name
|
|
plan.description = details["description"]
|
|
plan.image = details["image"]
|
|
plan.accent = details["accent"]
|
|
|
|
if not self._confirm_export(plan):
|
|
return
|
|
|
|
self._export_worker = exporter.ExportWorker(plan, self)
|
|
self._export_worker.progress.connect(self._on_export_progress)
|
|
self._export_worker.finished.connect(self._on_export_finished)
|
|
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
|
self._export_worker.failed.connect(self._on_export_failed)
|
|
self._sync_label.setText("Exporting")
|
|
self._sync_progress.setRange(0, max(plan.bytes_to_copy // 1024, 1))
|
|
self._sync_progress.setValue(0)
|
|
self._sync_cancel.show()
|
|
self._sync_label.show()
|
|
self._sync_progress.show()
|
|
self._export_inhibitor.inhibit()
|
|
self._export_worker.start()
|
|
|
|
def _confirm_export(self, plan) -> bool:
|
|
"""Last look before writing: overwrite, ffmpeg, and skipped tracks."""
|
|
if plan.needs_ffmpeg and not exporter.web_support.ffmpeg_available():
|
|
names = "\n".join(f" • {i.display}" for i in plan.conversions[:8])
|
|
more = (f"\n … and {len(plan.conversions) - 8} more"
|
|
if len(plan.conversions) > 8 else "")
|
|
answer = QMessageBox.question(
|
|
self, "FFmpeg not found",
|
|
f"{len(plan.conversions)} track(s) need converting before a "
|
|
f"browser can play them, but the ffmpeg command isn't "
|
|
f"installed:\n\n{names}{more}\n\nExport without them?")
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return False
|
|
plan.skipped += [(i.display, "it needs ffmpeg to convert")
|
|
for i in plan.conversions]
|
|
plan.items = [i for i in plan.items if not i.convert_to]
|
|
plan.bytes_to_copy = sum(i.size for i in plan.items)
|
|
if not plan.items:
|
|
QMessageBox.information(
|
|
self, "Export Playlist",
|
|
"Nothing left to export once those are left out.")
|
|
return False
|
|
|
|
if plan.skipped:
|
|
names = "\n".join(f" • {d} — {why}" for d, why in plan.skipped[:8])
|
|
more = (f"\n … and {len(plan.skipped) - 8} more"
|
|
if len(plan.skipped) > 8 else "")
|
|
answer = QMessageBox.question(
|
|
self, "Some tracks can't be exported",
|
|
f"{len(plan.skipped)} of {len(plan.skipped) + len(plan.items)} "
|
|
f"track(s) will be left out:\n\n{names}{more}\n\nExport the "
|
|
f"other {len(plan.items)}?")
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return False
|
|
|
|
if plan.dest_dir.exists():
|
|
answer = QMessageBox.question(
|
|
self, "Folder exists",
|
|
f"“{plan.dest_dir.name}” already exists in that folder. "
|
|
f"Files with the same names will be overwritten.\n\nContinue?")
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return False
|
|
return True
|
|
|
|
def _confirm_cancel_export(self):
|
|
if self._export_worker is None or not self._export_worker.busy():
|
|
return
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle("Cancel export")
|
|
box.setText("Cancel the export?\n"
|
|
"The folder is left as-is, without a playlist file — "
|
|
"exporting again starts it over.")
|
|
cancel_button = box.addButton(
|
|
"Cancel Export", QMessageBox.ButtonRole.DestructiveRole)
|
|
keep = box.addButton("Keep Exporting", QMessageBox.ButtonRole.RejectRole)
|
|
box.setDefaultButton(keep)
|
|
box.exec()
|
|
# The export keeps running while the dialog is up — it may well have
|
|
# finished by now, in which case there is nothing to cancel.
|
|
if (box.clickedButton() is cancel_button
|
|
and self._export_worker is not None):
|
|
self._export_worker.cancel()
|
|
|
|
def _on_export_progress(self, done_kib, total_kib, label):
|
|
self._sync_progress.setRange(0, total_kib)
|
|
self._sync_progress.setValue(done_kib)
|
|
self._sync_label.setText(
|
|
f"Exporting · {device_sync.format_bytes(done_kib * 1024)} / "
|
|
f"{device_sync.format_bytes(total_kib * 1024)}")
|
|
|
|
def _on_export_finished(self, summary):
|
|
self._export_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
what = "web mix" if summary["kind"] == exporter.WEB else "folder"
|
|
msg = (f"Exported “{summary['playlist']}” as a {what}: "
|
|
f"{summary['exported']} tracks → {summary['dest']}")
|
|
if summary["converted"]:
|
|
msg += f", {summary['converted']} converted to FLAC"
|
|
if summary["skipped"]:
|
|
msg += f", {summary['skipped']} skipped"
|
|
if summary["vanished"]:
|
|
msg += (f", {summary['vanished']} changed under us "
|
|
"(export again to pick them up)")
|
|
self.statusBar().showMessage(msg, 12000)
|
|
|
|
def _on_export_cancelled(self, summary):
|
|
self._export_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
self.statusBar().showMessage(
|
|
f"Export cancelled — {summary['exported']} of {summary['total']} "
|
|
f"tracks written to {summary['dest']}.", 8000)
|
|
|
|
def _on_export_failed(self, message):
|
|
self._export_inhibitor.release()
|
|
self._hide_sync_widgets()
|
|
QMessageBox.warning(self, "Export failed", message)
|
|
|
|
# ---- view switching ----
|
|
|
|
def _show_library(self):
|
|
self._content.setCurrentWidget(self._library_view)
|
|
self._update_now_playing_icon()
|
|
|
|
def _show_playlist(self, persistent_id: str):
|
|
playlist = self._manager.library.playlists.get(persistent_id)
|
|
if playlist:
|
|
self._playlist_view.show_playlist(playlist)
|
|
self._content.setCurrentWidget(self._playlist_view)
|
|
self._update_now_playing_icon()
|
|
|
|
def _go_to_current_song(self):
|
|
"""Jump to wherever the current song is playing from, select it, and
|
|
scroll it into view. No-op when nothing is cued."""
|
|
track = self.player.current_track
|
|
if track is None:
|
|
return
|
|
ctx = self._now_playing_context
|
|
if ctx == "library":
|
|
self._show_library()
|
|
self._sidebar.reflect_library()
|
|
self._library_view.reveal_track(track.track_id)
|
|
elif ctx.startswith("playlist:"):
|
|
pid = ctx.split(":", 1)[1]
|
|
if pid in self._manager.library.playlists:
|
|
self._show_playlist(pid)
|
|
self._sidebar.reflect_playlist(pid)
|
|
self._playlist_view.reveal_track(track.track_id)
|
|
|
|
def _show_track_in_playlist(self, track_id: int, pid: str):
|
|
"""Jump to a playlist and highlight the first instance of the track,
|
|
reusing the Go-to-Current-Song navigation pattern."""
|
|
if pid in self._manager.library.playlists:
|
|
self._show_playlist(pid)
|
|
self._sidebar.reflect_playlist(pid)
|
|
self._playlist_view.reveal_track(track_id)
|
|
|
|
# ---- multi-machine sync ----
|
|
|
|
def _on_library_reloaded(self):
|
|
"""Another machine's changes were reconciled in. Refresh the library
|
|
view (the sidebar and open playlist refresh via playlists_changed),
|
|
keeping the user's scroll and selection. Never touches the player."""
|
|
table = self._library_view.table
|
|
selected = table.selected_track_ids()
|
|
scroll = table.verticalScrollBar().value()
|
|
self._library_view.reload()
|
|
if selected:
|
|
table.reveal_track(selected[0])
|
|
table.verticalScrollBar().setValue(scroll)
|
|
|
|
def _on_conflict_resolved(self, summaries):
|
|
self.statusBar().showMessage(
|
|
f"Merged changes from your other machine "
|
|
f"({len(summaries)} item(s))", 8000)
|
|
self.show_conflict_summary(summaries)
|
|
|
|
def show_conflict_summary(self, summaries):
|
|
"""Show (or extend) the one merge window. Syncthing can deliver conflicts
|
|
every few minutes; each merge becomes another entry in the open window
|
|
rather than another window.
|
|
|
|
Only raise it when the new batch has something to say at the level on
|
|
screen — otherwise the entry is folded in quietly and is there when the
|
|
user dials the filter down."""
|
|
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
|
|
if self._conflict_dialog is not None:
|
|
if self._conflict_dialog.add_event(summaries):
|
|
self._conflict_dialog.raise_()
|
|
return
|
|
# Modeless so a merge never blocks playback or what you're doing.
|
|
self._conflict_dialog = ConflictSummaryDialog(
|
|
summaries, self._manager, self)
|
|
self._conflict_dialog.finished.connect(self._on_conflict_dialog_closed)
|
|
self._conflict_dialog.show()
|
|
self._conflict_dialog.raise_()
|
|
|
|
def _on_conflict_dialog_closed(self, _result):
|
|
"""Drop the reference (and the widget) so the next merge opens a fresh
|
|
window instead of touching a deleted C++ object."""
|
|
dialog, self._conflict_dialog = self._conflict_dialog, None
|
|
if dialog is not None:
|
|
dialog.deleteLater()
|
|
|
|
# ---- info dialog ----
|
|
|
|
def _show_info(self, track_ids: list[int]):
|
|
if track_ids:
|
|
InfoDialog(self._manager, track_ids, self).exec()
|
|
|
|
def _info_for_current_view(self):
|
|
view = self._content.currentWidget()
|
|
ids = view.table.selected_track_ids()
|
|
self._show_info(ids)
|
|
|
|
# ---- music folder ----
|
|
|
|
def check_music_folder(self):
|
|
"""Ask where the music goes, if we don't already know.
|
|
|
|
Called once shortly after the window appears, and again lazily if an
|
|
import happens while no folder is set. Never blocks startup: by the
|
|
time this runs the main window is already up, so the dialog is a normal
|
|
child dialog rather than a popup in front of an empty screen.
|
|
"""
|
|
state = self._manager.music_folder_state()
|
|
if state.path is not None:
|
|
return
|
|
if state.source == "unset":
|
|
if music_folder.prompt_seen():
|
|
return
|
|
elif self._missing_folder_prompted:
|
|
return # asked once this session; don't nag on every import
|
|
|
|
dialog = MusicFolderDialog(
|
|
self, missing_path=state.stored if state.source == "missing" else "")
|
|
dialog.exec()
|
|
chosen = dialog.chosen_folder()
|
|
|
|
# Remember we asked, whatever the answer — including "Not Now".
|
|
music_folder.mark_prompt_seen()
|
|
self._missing_folder_prompted = True
|
|
|
|
if chosen is not None:
|
|
# A folder that exists elsewhere but not here is this machine's own
|
|
# problem to solve; don't overwrite what the other machine set.
|
|
self._manager.set_music_folder(
|
|
chosen, machine_only=(state.source == "missing"))
|
|
self.statusBar().showMessage(
|
|
f"Music folder set to {self._manager.organize_root()}", 6000)
|
|
|
|
def _change_music_folder(self):
|
|
"""Preferences ▸ Music folder ▸ Change… — pick a new one."""
|
|
state = self._manager.music_folder_state()
|
|
start = str(state.path) if state.path else str(Path.home())
|
|
chosen = QFileDialog.getExistingDirectory(
|
|
self, "Choose Music Folder", start)
|
|
if not chosen:
|
|
return
|
|
|
|
media = music_folder.normalize_media_folder(chosen)
|
|
old_root = self._manager.organize_root()
|
|
if old_root is not None and old_root.exists() and \
|
|
media != Path(state.path or ""):
|
|
# Say the quiet part out loud: nothing moves, and from here on only
|
|
# new music goes to the new place. Otherwise the change looks total
|
|
# but silently applies to future additions only.
|
|
answer = QMessageBox.question(
|
|
self, "Change music folder?",
|
|
f"Your existing songs will stay where they are:\n{old_root}\n\n"
|
|
f"Only music you add from now on will go to:\n"
|
|
f"{music_folder.organize_root_for(media)}\n\n"
|
|
"Change the folder?",
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
QMessageBox.StandardButton.No)
|
|
if answer != QMessageBox.StandardButton.Yes:
|
|
return
|
|
|
|
self._manager.set_music_folder(
|
|
media, machine_only=state.is_machine_local)
|
|
self.statusBar().showMessage(
|
|
f"Music folder set to {self._manager.organize_root()}", 6000)
|
|
|
|
def _music_folder_summary(self) -> tuple[str, str]:
|
|
"""(folder, note) for the Preferences row."""
|
|
state = self._manager.music_folder_state()
|
|
if state.path is None:
|
|
return ("", "Not set yet — pick a folder to start adding music.")
|
|
if state.is_machine_local:
|
|
return (str(state.path), "Used on this computer only.")
|
|
return (str(state.path), "Shared with your other computers.")
|
|
|
|
# ---- file import ----
|
|
|
|
def _music_import_dir(self) -> Path | None:
|
|
"""Where imports are filed, or None when no music folder is usable.
|
|
|
|
Deliberately no fallback. The old `or Path("Music")` was *relative*, so
|
|
it resolved against the process working directory — which GNOME's dash
|
|
does not set predictably (see packaging/install-desktop.sh), scattering
|
|
a new user's music somewhere they'd never find it.
|
|
"""
|
|
return self._manager.organize_root()
|
|
|
|
def _open_files_dialog(self):
|
|
paths, _filter = QFileDialog.getOpenFileNames(
|
|
self, "Add Files to Library", "",
|
|
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff)")
|
|
if paths:
|
|
self.import_files([Path(p) for p in paths])
|
|
|
|
def _ensure_music_dir(self) -> Path | None:
|
|
music_dir = self._music_import_dir()
|
|
if music_dir is None:
|
|
# Ask rather than guess — better one question than music copied
|
|
# somewhere unfindable.
|
|
self.check_music_folder()
|
|
music_dir = self._music_import_dir()
|
|
if music_dir is None:
|
|
self.statusBar().showMessage(
|
|
"Choose where to keep your music first — Edit ▸ Preferences.",
|
|
8000)
|
|
return music_dir
|
|
|
|
def import_files(self, paths, playlist_pid: str = "",
|
|
position: int | None = None) -> list:
|
|
music_dir = self._ensure_music_dir()
|
|
if music_dir is None:
|
|
return []
|
|
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
|
|
imported = file_importer.import_paths(
|
|
paths, music_dir, self._manager, playlist_pid,
|
|
position)
|
|
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
|
|
if imported:
|
|
self._library_view.reload()
|
|
return imported
|
|
|
|
def _import_files_to_playlist(self, paths, pid, position=None):
|
|
self.import_files([Path(p) for p in paths], pid, position)
|
|
|
|
# ---- importing from a link (trav's `song` helper, in-process) ----
|
|
|
|
def _refresh_url_import_action(self):
|
|
"""One transfer owns the status-bar progress widgets at a time."""
|
|
self._url_import_action.setEnabled(self._busy_worker() is None)
|
|
|
|
def _url_import_target(self):
|
|
shown, row = "", None
|
|
if self._content.currentWidget() is self._playlist_view:
|
|
shown = self._playlist_view.playlist_id
|
|
rows = self._playlist_view.table.selected_source_rows()
|
|
row = rows[0] if rows else None
|
|
return url_import.resolve_target(
|
|
self._manager.library, shown, row, self._now_playing_context)
|
|
|
|
def _import_from_url(self):
|
|
if self._busy_worker() is not None:
|
|
return
|
|
missing = [name for name, ok in (
|
|
("yt-dlp", url_import.ytdlp_available()),
|
|
("ffmpeg", exporter.web_support.ffmpeg_available())) if not ok]
|
|
if missing:
|
|
QMessageBox.warning(
|
|
self, "Import from URL",
|
|
f"Importing from a link needs {' and '.join(missing)}, "
|
|
f"which {'is' if len(missing) == 1 else 'are'} not "
|
|
f"installed.\n\n"
|
|
"yt-dlp: pip install --user yt-dlp (or your distro's "
|
|
"package)\nffmpeg: your distro's “ffmpeg” package")
|
|
return
|
|
target = self._url_import_target()
|
|
dialog = UrlImportDialog(target.description if target else None, self)
|
|
if not dialog.exec():
|
|
return
|
|
if self._ensure_music_dir() is None:
|
|
return
|
|
self._url_target = target if dialog.add_to_playlist() else None
|
|
self._url_inserted = 0
|
|
self._url_imported = 0
|
|
self._url_identify = True
|
|
problem = self._identify_setup_problem()
|
|
self._url_note = (f"tags proposed from filenames only ({problem})"
|
|
if problem else "")
|
|
|
|
worker = url_import.UrlImportWorker(dialog.url(), self)
|
|
worker.item_started.connect(self._on_url_item_started)
|
|
worker.progress.connect(self._sync_progress.setValue)
|
|
worker.downloaded.connect(self._on_url_downloaded)
|
|
worker.finished.connect(self._on_url_finished)
|
|
worker.failed.connect(self._on_url_failed)
|
|
self._url_worker = worker
|
|
self._sync_label.setText("Downloading…")
|
|
self._sync_progress.setRange(0, 100)
|
|
self._sync_progress.setValue(0)
|
|
self._sync_cancel.show()
|
|
self._sync_label.show()
|
|
self._sync_progress.show()
|
|
worker.start()
|
|
|
|
def _on_url_item_started(self, index: int, count: int, title: str):
|
|
short = title if len(title) <= 40 else title[:39] + "…"
|
|
where = f"{index} of {count} · " if count > 1 else ""
|
|
self._sync_label.setText(f"Downloading {where}{short}")
|
|
self._sync_progress.setValue(0)
|
|
|
|
def _on_url_downloaded(self, path: str):
|
|
"""One song is off the network: file it and identify it now, so the
|
|
first dialog can be up while the rest are still downloading."""
|
|
target = self._url_target
|
|
pid, position = "", None
|
|
if target is not None and target.pid in self._manager.library.playlists:
|
|
pid = target.pid
|
|
if target.position is not None:
|
|
# Each song goes after the ones already added, so the batch
|
|
# keeps the link's order, all of it above the selected song.
|
|
position = target.position + self._url_inserted
|
|
imported = self.import_files([Path(path)], pid, position)
|
|
if pid:
|
|
self._url_inserted += len(imported)
|
|
self._url_imported += len(imported)
|
|
if imported and self._url_identify:
|
|
self._enqueue_identify(imported)
|
|
|
|
def _finish_url_import(self):
|
|
if self._url_worker is not None:
|
|
self._url_worker.cleanup()
|
|
self._hide_sync_widgets()
|
|
|
|
def _on_url_finished(self, summary: dict):
|
|
self._finish_url_import()
|
|
count = self._url_imported
|
|
if summary["cancelled"]:
|
|
msg = f"Import cancelled — {count} song(s) imported"
|
|
else:
|
|
msg = f"Imported {count} song(s) from the link"
|
|
target = self._url_target
|
|
playlist = (self._manager.library.playlists.get(target.pid)
|
|
if target is not None else None)
|
|
if playlist is not None and count:
|
|
msg += f" into “{playlist.name}”"
|
|
if summary["errors"]:
|
|
msg += f" · {len(summary['errors'])} couldn't be downloaded"
|
|
if self._url_note and count and self._url_identify:
|
|
msg += f" · {self._url_note}"
|
|
self.statusBar().showMessage(msg, 12000)
|
|
|
|
def _on_url_failed(self, message: str):
|
|
self._finish_url_import()
|
|
QMessageBox.warning(self, "Import from URL",
|
|
f"Couldn't import from that link:\n\n{message}")
|
|
|
|
def _confirm_cancel_url_import(self):
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle("Stop downloading")
|
|
box.setText("Stop downloading from the link?\n"
|
|
"Songs already imported stay in your library.")
|
|
stop = box.addButton("Stop Downloading",
|
|
QMessageBox.ButtonRole.DestructiveRole)
|
|
keep = box.addButton("Keep Downloading",
|
|
QMessageBox.ButtonRole.RejectRole)
|
|
box.setDefaultButton(keep)
|
|
box.exec()
|
|
if box.clickedButton() is stop and self._url_worker is not None:
|
|
self._url_worker.cancel()
|
|
|
|
# ---- deleting tracks ----
|
|
|
|
def _delete_tracks(self, track_ids: list[int], delete_files: bool):
|
|
tracks = [self._manager.library.tracks[tid] for tid in track_ids
|
|
if tid in self._manager.library.tracks]
|
|
if not tracks or not self._confirm_delete(tracks, delete_files):
|
|
return
|
|
removed, failures = self._manager.delete_tracks(
|
|
[t.track_id for t in tracks], delete_files=delete_files)
|
|
|
|
if removed:
|
|
where = "moved to Trash" if delete_files else "file kept"
|
|
self.statusBar().showMessage(
|
|
f"Removed {len(removed)} song(s) from your library "
|
|
f"({where}) — undo with Ctrl+Z", 8000)
|
|
if failures:
|
|
QMessageBox.warning(
|
|
self, "Couldn't delete",
|
|
f"{len(failures)} file(s) could not be moved to the Trash, so "
|
|
"those songs are still in your library:\n\n"
|
|
+ "\n".join(f"• “{name}”: {err}" for name, err in failures[:5]))
|
|
|
|
def _confirm_delete(self, tracks: list, delete_files: bool) -> bool:
|
|
if len(tracks) == 1:
|
|
track = tracks[0]
|
|
who = f" by {track.artist}" if track.artist else ""
|
|
question = f"Delete “{track.name}”{who} from your library?"
|
|
else:
|
|
question = f"Delete {len(tracks)} songs from your library?"
|
|
|
|
if delete_files:
|
|
fate = ("The file will be moved to your Trash, so you can put it "
|
|
"back later." if len(tracks) == 1 else
|
|
"The files will be moved to your Trash, so you can put "
|
|
"them back later.")
|
|
else:
|
|
fate = ("The file stays where it is on disk."
|
|
if len(tracks) == 1 else
|
|
"The files stay where they are on disk.")
|
|
|
|
doomed = {t.track_id for t in tracks}
|
|
playlists = {pid for pid, _name in
|
|
[entry for t in tracks
|
|
for entry in self._manager.playlists_containing(t.track_id)]}
|
|
lines = [question, fate]
|
|
if playlists:
|
|
lines.append(f"{'It' if len(doomed) == 1 else 'They'} will also be "
|
|
f"removed from {len(playlists)} playlist(s).")
|
|
|
|
box = QMessageBox(self)
|
|
box.setIcon(QMessageBox.Icon.Warning)
|
|
box.setWindowTitle("Delete from Library")
|
|
box.setText("\n".join(lines))
|
|
delete_button = box.addButton(
|
|
"Delete Song" if len(tracks) == 1 else "Delete Songs",
|
|
QMessageBox.ButtonRole.DestructiveRole)
|
|
cancel = box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
|
|
box.setDefaultButton(cancel)
|
|
box.exec()
|
|
return box.clickedButton() is delete_button
|
|
|
|
def _on_tracks_removed(self, track_ids: list):
|
|
"""Tracks left the library (deleted here, or undone/redone). Keep the
|
|
player off dead ids and rebuild the library view."""
|
|
self.player.drop_tracks(set(track_ids))
|
|
self._on_library_reloaded()
|
|
self._update_totals()
|
|
|
|
def _on_tracks_restored(self, track_ids: list):
|
|
"""An undo put tracks back — show them again."""
|
|
self._on_library_reloaded()
|
|
self._update_totals()
|
|
|
|
# ---- album art download ----
|
|
|
|
def _download_album_art(self, track_ids: list[int]):
|
|
"""Fetch cover art from the iTunes Search API for the selection,
|
|
grouped per album, each behind its own confirmation dialog."""
|
|
groups: dict[tuple, dict] = {}
|
|
skipped = 0
|
|
for tid in track_ids:
|
|
track = self._manager.library.tracks.get(tid)
|
|
if track is None:
|
|
continue
|
|
artist = track.album_artist or track.artist
|
|
if not artist or not track.album:
|
|
skipped += 1
|
|
continue
|
|
key = (artist.casefold(), track.album.casefold())
|
|
group = groups.setdefault(
|
|
key, {"artist": artist, "album": track.album, "tracks": []})
|
|
group["tracks"].append(track)
|
|
if skipped:
|
|
self.statusBar().showMessage(
|
|
f"Skipped {skipped} track(s) with no artist/album to search by",
|
|
6000)
|
|
self._art_groups = list(groups.values())
|
|
self._art_next_group()
|
|
|
|
def _art_next_group(self):
|
|
if not self._art_groups:
|
|
return
|
|
group = self._art_groups.pop(0)
|
|
self.statusBar().showMessage(
|
|
f"Searching artwork for {group['artist']} — {group['album']}…")
|
|
# Kept as an attribute so the fetcher (and its thread's signal source)
|
|
# outlives this method.
|
|
self._art_fetcher = AlbumArtFetcher()
|
|
self._art_fetcher.search_finished.connect(
|
|
lambda result, g=group: self._on_art_search(g, result))
|
|
self._art_fetcher.search(group["artist"], group["album"])
|
|
|
|
def _on_art_search(self, group, result):
|
|
if "error" in result:
|
|
self.statusBar().showMessage(
|
|
f"Artwork search failed: {result['error']}", 6000)
|
|
elif not result["candidates"]:
|
|
self.statusBar().showMessage(
|
|
f"No artwork found for {group['artist']} — {group['album']}",
|
|
6000)
|
|
else:
|
|
self.statusBar().clearMessage()
|
|
whole_album = album_tracks(self._manager.library,
|
|
group["artist"], group["album"])
|
|
dialog = AlbumArtDialog(result["candidates"],
|
|
len(group["tracks"]),
|
|
album_count=len(whole_album), parent=self)
|
|
if dialog.exec() and dialog.selected_image:
|
|
targets = whole_album if dialog.apply_to_album \
|
|
else group["tracks"]
|
|
self._embed_album_art(targets, dialog.selected_image,
|
|
dialog.selected_mime)
|
|
self._art_next_group()
|
|
|
|
def _embed_album_art(self, tracks, image: bytes, mime: str):
|
|
"""Embed confirmed artwork into each track's file. Like the Info
|
|
dialog, the artwork write itself is not undoable; the size refresh is
|
|
an in-memory update (size isn't a tag field)."""
|
|
failures = []
|
|
done = 0
|
|
for track in tracks:
|
|
if not track.location:
|
|
continue
|
|
try:
|
|
tagging.write_artwork(track.location, image, mime)
|
|
except Exception as e:
|
|
failures.append(f"{track.name}: {e}")
|
|
continue
|
|
done += 1
|
|
mpris.invalidate_artwork(track)
|
|
try:
|
|
size = Path(track.location).stat().st_size
|
|
except OSError:
|
|
continue
|
|
self._manager.update_track_fields(track.track_id, {"size": size})
|
|
if failures:
|
|
QMessageBox.warning(
|
|
self, "Download Album Art",
|
|
"Couldn't embed artwork in some files:\n\n"
|
|
+ "\n".join(failures[:10]))
|
|
elif done:
|
|
noun = "track" if done == 1 else "tracks"
|
|
self.statusBar().showMessage(
|
|
f"Artwork embedded in {done} {noun}", 5000)
|
|
|
|
# ---- track identification (fingerprint lookup) ----
|
|
|
|
def _identify_tracks(self, track_ids: list[int]):
|
|
"""Fingerprint the selection and look each track up on AcoustID, one
|
|
at a time, each behind its own proposal dialog."""
|
|
if not fpcalc_available():
|
|
QMessageBox.warning(
|
|
self, "Identify Track",
|
|
"Track identification needs the fpcalc tool from Chromaprint.\n\n"
|
|
"Install it (Fedora: chromaprint-tools, Debian: "
|
|
"libchromaprint-tools) and try again.")
|
|
return
|
|
api_key = self._prefs.acoustid.get("api_key", "").strip()
|
|
if not api_key:
|
|
QMessageBox.information(
|
|
self, "Identify Track",
|
|
"AcoustID needs a free application key.\n\n"
|
|
"Register one at acoustid.org/new-application, then paste it "
|
|
"into Preferences under Track Identification.")
|
|
return
|
|
tracks = []
|
|
skipped = 0
|
|
for tid in track_ids:
|
|
track = self._manager.library.tracks.get(tid)
|
|
if track is None:
|
|
continue
|
|
if not track.location:
|
|
skipped += 1
|
|
continue
|
|
tracks.append(track)
|
|
if skipped:
|
|
self.statusBar().showMessage(
|
|
f"Skipped {skipped} track(s) with no file to fingerprint", 6000)
|
|
if not tracks:
|
|
return
|
|
self._enqueue_identify(tracks)
|
|
|
|
def _identify_setup_problem(self) -> str:
|
|
"""Why an AcoustID lookup can't run on this machine, or ''."""
|
|
if not fpcalc_available():
|
|
return "fpcalc isn't installed"
|
|
if not self._prefs.acoustid.get("api_key", "").strip():
|
|
return "no AcoustID key in Preferences"
|
|
return ""
|
|
|
|
def _enqueue_identify(self, tracks: list):
|
|
"""Queue tracks for Identify and start the queue if it's idle. An
|
|
Import from URL adds songs one at a time while an earlier one is still
|
|
behind its dialog, so the queue can grow mid-run."""
|
|
if not self._identify_running:
|
|
self._identify_queue = []
|
|
self._identify_total = 0
|
|
self._identify_queue.extend(tracks)
|
|
self._identify_total += len(tracks)
|
|
if not self._identify_running:
|
|
self._identify_next()
|
|
|
|
def _identify_next(self):
|
|
if not self._identify_queue:
|
|
self._identify_running = False
|
|
return
|
|
self._identify_running = True
|
|
track = self._identify_queue.pop(0)
|
|
if track.track_id not in self._manager.library.tracks:
|
|
self._identify_next() # deleted while it waited its turn
|
|
return
|
|
done = self._identify_total - len(self._identify_queue)
|
|
counter = (f" ({done} of {self._identify_total})"
|
|
if self._identify_total > 1 else "")
|
|
self.statusBar().showMessage(
|
|
f"Identifying “{track.name}”{counter}…")
|
|
# Kept as an attribute so the identifier (and its thread's signal
|
|
# source) outlives this method.
|
|
self._identifier = TrackIdentifier()
|
|
self._identifier.finished.connect(
|
|
lambda result, t=track: self._on_identified(t, result))
|
|
# Read fresh for every track. With no key (only an Import from URL
|
|
# gets this far without one) the identifier proposes from the
|
|
# filename alone.
|
|
self._identifier.identify(
|
|
track, self._prefs.acoustid.get("api_key", "").strip())
|
|
|
|
def _on_identified(self, track, result: dict):
|
|
if "error" in result:
|
|
self.statusBar().showMessage(
|
|
f"Couldn't identify “{track.name}”: {result['error']}", 6000)
|
|
elif not result["candidates"]:
|
|
self.statusBar().showMessage(
|
|
f"Nothing to propose for “{track.name}” — no database match "
|
|
f"and the filename says nothing", 6000)
|
|
else:
|
|
self.statusBar().clearMessage()
|
|
dialog = IdentifyDialog(track, result["candidates"],
|
|
remaining=len(self._identify_queue),
|
|
parent=self)
|
|
accepted = dialog.exec()
|
|
if accepted:
|
|
fields = dialog.result_fields()
|
|
if fields:
|
|
# The edit funnel writes the tags, may relocate the file
|
|
# and records one undo step — see LibraryManager.
|
|
self._manager.edit_track_fields(track.track_id, fields)
|
|
self.statusBar().showMessage(
|
|
f"Updated tags for “{track.name}”", 5000)
|
|
elif dialog.cancel_all:
|
|
self._identify_queue.clear()
|
|
self._identify_running = False
|
|
# Also covers songs a URL import has yet to download: they
|
|
# still import, they just don't get a dialog each.
|
|
self._url_identify = False
|
|
self.statusBar().clearMessage()
|
|
return
|
|
self._identify_next()
|
|
|
|
# ---- relocating a moved/renamed file ----
|
|
|
|
def _existing_ancestor(self, path: str) -> str:
|
|
"""Deepest existing directory at or above `path`'s parent, so the file
|
|
picker opens somewhere real even if the exact folder is gone."""
|
|
if path:
|
|
p = Path(path).parent
|
|
while not p.exists() and p != p.parent:
|
|
p = p.parent
|
|
if p.exists():
|
|
return str(p)
|
|
music = self._music_import_dir()
|
|
return str(music) if music and music.exists() else str(Path.home())
|
|
|
|
def _on_track_missing(self, track):
|
|
"""A track's file is gone (moved/renamed outside lintunes). Offer to
|
|
relocate it manually; on success, repoint the track and resume."""
|
|
box = QMessageBox(self)
|
|
box.setIcon(QMessageBox.Icon.Warning)
|
|
box.setWindowTitle("Can't play track")
|
|
box.setText(f"Can't play “{track.name}”.")
|
|
box.setInformativeText(
|
|
f"File not found:\n{track.location or '(no location)'}")
|
|
locate_btn = box.addButton("Locate File…", QMessageBox.ButtonRole.ActionRole)
|
|
box.addButton(QMessageBox.StandardButton.Cancel)
|
|
box.exec()
|
|
if box.clickedButton() is not locate_btn:
|
|
return
|
|
chosen, _filter = QFileDialog.getOpenFileName(
|
|
self, f"Locate “{track.name}”", self._existing_ancestor(track.location),
|
|
"Audio files (*.mp3 *.m4a *.flac *.aac *.wav *.aiff *.aif);;All files (*)")
|
|
if not chosen:
|
|
return
|
|
self._manager.set_track_location(track.track_id, chosen)
|
|
# Defer the retry so it runs after this modal handler unwinds.
|
|
QTimer.singleShot(0, self.player.retry_current)
|
|
|
|
# ---- window-level drops (import anywhere) ----
|
|
|
|
def dragEnterEvent(self, event):
|
|
if event.mimeData().hasUrls():
|
|
event.acceptProposedAction()
|
|
|
|
def dropEvent(self, event):
|
|
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls()
|
|
if u.isLocalFile()]
|
|
if paths:
|
|
self.import_files(paths)
|
|
event.acceptProposedAction()
|
|
|
|
# ---- playback ----
|
|
|
|
def play_pause(self):
|
|
"""Play/pause like iTunes: with nothing cued, start the visible
|
|
view's tracks (from the selected row, else the top)."""
|
|
if self.player.current_track is not None:
|
|
self.player.toggle_play()
|
|
return
|
|
view = self._content.currentWidget()
|
|
ids = view.table.view_order_track_ids()
|
|
if not ids:
|
|
return
|
|
selected = view.table.selectionModel().selectedRows()
|
|
start = selected[0].row() if selected else 0
|
|
self._play_from_view(view, ids, start)
|
|
|
|
def _play_from_view(self, view, ids, start):
|
|
"""Start playback, recording which view it came from so the now-playing
|
|
icon (and Go to Current Song) can find the right context."""
|
|
self._now_playing_context = view.table.context_id()
|
|
self.player.play_queue(ids, start)
|
|
|
|
def _on_track_changed(self, track):
|
|
if track is None:
|
|
self.setWindowTitle("LinTunes")
|
|
else:
|
|
self.setWindowTitle(f"{track.name} — {track.artist} · LinTunes")
|
|
self._update_now_playing_icon()
|
|
|
|
def _on_track_meta_updated(self, track_id: int):
|
|
current = self.player.current_track
|
|
if current is not None and current.track_id == track_id:
|
|
self._sidebar.art.refresh()
|
|
|
|
def _update_now_playing_icon(self, _playing=None):
|
|
track = self.player.current_track
|
|
track_id = track.track_id if track else None
|
|
playing = self.player.is_playing()
|
|
# Only the table showing the context playback started from gets the icon,
|
|
# so the same song appearing in another playlist/library isn't flagged.
|
|
for view in (self._library_view, self._playlist_view):
|
|
match = (track_id is not None
|
|
and view.table.context_id() == self._now_playing_context)
|
|
view.table.model_.set_now_playing(track_id if match else None,
|
|
playing)
|
|
|
|
# ---- global media keys (spacebar / arrows), per spec detail 8 ----
|
|
|
|
def eventFilter(self, obj, event):
|
|
if event.type() == QEvent.Type.KeyPress and self.isActiveWindow():
|
|
focus = QApplication.focusWidget()
|
|
if isinstance(focus, (QLineEdit, QPlainTextEdit, QTextEdit,
|
|
QAbstractSpinBox, QComboBox)):
|
|
return False
|
|
key = event.key()
|
|
if key in (Qt.Key.Key_Space, Qt.Key.Key_Right, Qt.Key.Key_Left) \
|
|
and event.isAutoRepeat():
|
|
# On Wayland key repeat is generated client-side until the
|
|
# compositor delivers the release — and a compositor busy
|
|
# with our own MPRIS churn delivers it late, so one held
|
|
# arrow became 1,676 track skips (Round 56). A transport key
|
|
# acts once per press; the repeats are swallowed so they
|
|
# don't fall through and move the table's cursor either.
|
|
return True
|
|
if key == Qt.Key.Key_Space:
|
|
self._log_key(event, "space")
|
|
self.play_pause()
|
|
return True
|
|
if key == Qt.Key.Key_Right:
|
|
self._log_key(event, "right")
|
|
self.player.next()
|
|
return True
|
|
if key == Qt.Key.Key_Left:
|
|
self._log_key(event, "left")
|
|
self.player.previous()
|
|
return True
|
|
return super().eventFilter(obj, event)
|
|
|
|
@staticmethod
|
|
def _log_key(event, name: str):
|
|
"""Provenance for keys the filter acts on: which input device sent
|
|
the press (compositor permitting) — see eventlog module docstring."""
|
|
detail = ""
|
|
try:
|
|
device = event.device()
|
|
if device is not None:
|
|
detail = f"device={device.name()!r}"
|
|
except Exception:
|
|
pass
|
|
log_control("keyfilter", name, detail)
|
|
|
|
def _on_playing_changed(self, playing: bool):
|
|
if playing:
|
|
self._inhibitor.inhibit()
|
|
else:
|
|
self._inhibitor.release()
|
|
|
|
def closeEvent(self, event):
|
|
# Quitting mid-transfer abandons a partial file on the device (it
|
|
# self-heals next sync, but silently) — make it a deliberate choice.
|
|
# A link import has nothing half-written on a device to warn about:
|
|
# it is simply stopped below.
|
|
busy = self._busy_worker()
|
|
if busy is not None and busy not in (self._export_worker,
|
|
self._url_worker):
|
|
box = QMessageBox(self)
|
|
box.setWindowTitle("Sync in progress")
|
|
box.setText(
|
|
f"Still copying to the {self._sync_device_name}.\n"
|
|
"If you quit now, the unfinished track will be re-copied "
|
|
"on the next sync.")
|
|
quit_button = box.addButton(
|
|
"Quit Anyway", QMessageBox.ButtonRole.DestructiveRole)
|
|
keep = box.addButton(
|
|
"Keep Syncing", QMessageBox.ButtonRole.RejectRole)
|
|
box.setDefaultButton(keep)
|
|
box.exec()
|
|
if box.clickedButton() is not quit_button:
|
|
event.ignore()
|
|
return
|
|
if self._url_worker is not None:
|
|
self._url_worker.cancel()
|
|
self._url_worker.cleanup()
|
|
self._inhibitor.release()
|
|
self._sync_inhibitor.release()
|
|
# Before the player: this stops the device and kills the HTTP server it
|
|
# is fetching from. Skipped, the Chromecast sits buffering a dead URL.
|
|
self._cast.shutdown()
|
|
self.player.shutdown()
|
|
self._manager.flush()
|
|
super().closeEvent(event)
|