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.
549 lines
23 KiB
Python
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)
|