Files
lintunes/lintunes/gui/main_window.py
T
trav 2b02f109f6 File drops from the file manager insert at the drop position
The table already computed and emitted the drop row for external URL
drops, but PlaylistView._on_files_dropped threw it away, so imported
files always appended. Thread it through: playlist_view.files_dropped
gains a row slot (None = append), MainWindow.import_files and
file_importer.import_paths take an optional position and hand it to
add_tracks_to_playlist, which already supported positional insert.
Files already in the library dedup to the existing track and insert at
the drop position without duplicating the library entry.
2026-07-02 16:54:54 -04:00

549 lines
23 KiB
Python

from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QStackedWidget, QWidget, QVBoxLayout, QLineEdit,
QPlainTextEdit, QTextEdit, QAbstractSpinBox, QComboBox, QApplication,
QLabel, QMessageBox, QFileDialog,
)
from PyQt6.QtCore import Qt, QEvent, QTimer
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import mpris, tagging, theme
from lintunes.art_search import AlbumArtFetcher
from lintunes.inhibit import SleepInhibitor
from lintunes.player import Player
from lintunes.importers import file_importer
from lintunes.gui.album_art_dialog import AlbumArtDialog
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.info_dialog import InfoDialog
from lintunes.gui.preferences_dialog import PreferencesDialog
from lintunes.gui.track_table import format_total_time
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 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)
# 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)
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: a permanent, centered totals readout (transient
# import/scrobble/error messages still use the left showMessage area).
self._totals_label = QLabel()
self._totals_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.statusBar().addPermanentWidget(self._totals_label, 1)
# 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._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)
self._playlist_view.files_dropped.connect(self._import_files_to_playlist)
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.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()
# ---- 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()
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)
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)
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)
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")
# ---- 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)) — see details", 8000)
self.show_conflict_summary(summaries)
def show_conflict_summary(self, summaries):
from lintunes.gui.conflict_dialog import ConflictSummaryDialog
# Modeless so a merge never blocks playback or what you're doing.
self._conflict_dialog = ConflictSummaryDialog(summaries, self._manager, self)
self._conflict_dialog.show()
self._conflict_dialog.raise_()
# ---- 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)
# ---- file import ----
def _music_import_dir(self) -> Path:
return Path(self._manager.library.music_folder) / "Music"
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 import_files(self, paths, playlist_pid: str = "",
position: int | None = None):
self.statusBar().showMessage(f"Importing {len(paths)} item(s)…")
imported = file_importer.import_paths(
paths, self._music_import_dir(), self._manager, playlist_pid,
position)
self.statusBar().showMessage(f"Imported {len(imported)} track(s)", 5000)
if imported:
self._library_view.reload()
def _import_files_to_playlist(self, paths, pid, position=None):
self.import_files([Path(p) for p in paths], pid, position)
# ---- 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)
# ---- 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.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 == Qt.Key.Key_Space:
self.play_pause()
return True
if key == Qt.Key.Key_Right:
self.player.next()
return True
if key == Qt.Key.Key_Left:
self.player.previous()
return True
return super().eventFilter(obj, event)
def _on_playing_changed(self, playing: bool):
if playing:
self._inhibitor.inhibit()
else:
self._inhibitor.release()
def closeEvent(self, event):
self._inhibitor.release()
self.player.shutdown()
self._manager.flush()
super().closeEvent(event)