Stops double-counting when iPod Nano 7G regenerates Play Counts file after a DB write (firmware quirk, new mtime with stale data). Architecture: - Mount: only max(cache, base) — never reads Play Counts delta - 2s after mount → auto-sync worker: reads delta, subtracts stored (persistent guard), applies effective to cache, defers DB write - iTunesDB commit deferred to next stable mount (checks W_OK first) - read_play_stats() uses 3s retry + read_play_counts_delta() fallback (filesystem-based PC parsing, independent of iTunesDB I/O errors) Changes: - ipod_nano7_db.py: sync_itunescdb() returns bool; new read_play_counts_delta() method; get_all_tracks() no longer adds delta; _merge_play_stats() drops delta addition; suppress iOpenPod ERROR logs to CRITICAL - library_cache.py: schema v2 with playcounts_log table + get/set_last_synced_delta, migration from v1 - worker.py: session guards (_processed_fwids, _auto_synced_fwids, _pending_commit_fwids); auto_sync_playcounts task; deferred commit in mount - app.py: QTimer.singleShot(2000) after mount for auto-sync - ipod_tab.py: _on_auto_state_changed rewritten (no unmount, mp guard) - library_tab.py: refresh_play_counts_from_cache() method
669 lines
26 KiB
Python
669 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Main Application Module for neo-pod-desktop
|
|
Provides a GUI for converting, managing and transferring music to iPod Nano devices.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import random
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout,
|
|
QSplitter, QStackedWidget,
|
|
)
|
|
from PyQt6.QtCore import Qt, QUrl, QTimer
|
|
from PyQt6.QtGui import QPixmap
|
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
|
|
from config_loader import ConfigLoader
|
|
from device_monitor import DeviceMonitor
|
|
from hotkeys import HotkeyManager
|
|
from playlist_manager import PlaylistManager
|
|
from session_store import SessionStore
|
|
from library_cache import get_library_cache
|
|
from ui.library_tab import LibraryTab
|
|
from ui.ipod_tab import iPodTab
|
|
from ui.settings_tab import SettingsTab
|
|
from ui.sidebar_widget import SIDEBAR_DEFAULT_WIDTH
|
|
from ui.player_header import PlayerHeader
|
|
from ui.splash_overlay import SplashOverlay
|
|
from artwork.cache import cover_cache
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
"""Main application window"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
|
|
self.setWindowTitle("neo-pod-desktop")
|
|
self.setMinimumSize(800, 600)
|
|
|
|
self.config_loader = ConfigLoader()
|
|
self.hotkey_manager: Optional[HotkeyManager] = None
|
|
self.device_monitor: Optional[DeviceMonitor] = None
|
|
|
|
self.playlist_manager = PlaylistManager()
|
|
self.session_store = SessionStore()
|
|
self._sidebar_visible = True
|
|
self._pending_playlist_id: str = ""
|
|
self._pending_playlist_tracks: list = []
|
|
|
|
self._saved_volume: int = 80
|
|
self._saved_track_path: str = ""
|
|
self._saved_position_ms: int = 0
|
|
self._saved_playing: bool = False
|
|
self._pending_seek_ms: int = 0
|
|
|
|
self._shuffle_active: bool = False
|
|
self._repeat_mode: str = "no_repeat"
|
|
|
|
self._auto_sync_worker = None
|
|
|
|
self._setup_player()
|
|
self._setup_ui()
|
|
self.library_tab.set_playlist_manager(self.playlist_manager)
|
|
self._load_playlists_into_sidebar()
|
|
self._load_settings()
|
|
self._setup_hotkeys()
|
|
self._wire_signals()
|
|
|
|
cache = get_library_cache()
|
|
splash = SplashOverlay(self)
|
|
if len(cache) == 0:
|
|
splash.show()
|
|
QApplication.processEvents()
|
|
|
|
self.library_tab._scan_library(on_progress=splash.set_progress)
|
|
|
|
if splash.isVisible():
|
|
splash.close()
|
|
self._resume_playback_if_saved()
|
|
self._restore_library_state()
|
|
self._start_device_monitor()
|
|
|
|
def _setup_player(self):
|
|
self.player = QMediaPlayer()
|
|
self.audio_output = QAudioOutput()
|
|
self.audio_output.setVolume(self._saved_volume / 100.0)
|
|
self.player.setAudioOutput(self.audio_output)
|
|
self.player.positionChanged.connect(self._on_player_position)
|
|
self.player.durationChanged.connect(self._on_player_duration)
|
|
self.player.mediaStatusChanged.connect(self._on_media_status)
|
|
|
|
def _setup_ui(self):
|
|
from ui.sidebar_widget import SidebarWidget
|
|
|
|
main_widget = QWidget()
|
|
main_layout = QVBoxLayout(main_widget)
|
|
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
main_layout.setSpacing(0)
|
|
|
|
self.player_header = PlayerHeader()
|
|
self.player_header.prev_requested.connect(self._on_prev)
|
|
self.player_header.play_pause_requested.connect(self._on_play_pause)
|
|
self.player_header.next_requested.connect(self._on_next)
|
|
self.player_header.shuffle_requested.connect(self._on_shuffle_toggle)
|
|
self.player_header.repeat_requested.connect(self._on_repeat_cycle)
|
|
self.player_header.position_changed_by_user.connect(self._on_user_seek_frac)
|
|
self.player_header.volume_changed.connect(self._on_volume_changed)
|
|
main_layout.addWidget(self.player_header)
|
|
|
|
self.stack = QStackedWidget()
|
|
|
|
self.library_tab = LibraryTab(self.config_loader)
|
|
self.library_tab.play_requested.connect(self._on_play_requested)
|
|
self.stack.addWidget(self.library_tab)
|
|
|
|
self.ipod_tab = iPodTab()
|
|
self.stack.addWidget(self.ipod_tab)
|
|
|
|
self.settings_tab = SettingsTab()
|
|
self.stack.addWidget(self.settings_tab)
|
|
|
|
self.stack.setCurrentIndex(0)
|
|
|
|
self.sidebar = SidebarWidget()
|
|
self.sidebar.item_selected.connect(self._on_sidebar_item_selected)
|
|
self.sidebar.playlist_selected.connect(self._on_playlist_selected)
|
|
|
|
self.splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
self.splitter.addWidget(self.sidebar)
|
|
self.splitter.addWidget(self.stack)
|
|
self.splitter.setStretchFactor(0, 0)
|
|
self.splitter.setStretchFactor(1, 1)
|
|
self.splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, 600])
|
|
self.splitter.setHandleWidth(1)
|
|
mid = self.palette().color(self.palette().ColorRole.Midlight)
|
|
self.splitter.setStyleSheet(f"QSplitter::handle {{ background: {mid.name()}; }}")
|
|
|
|
main_layout.addWidget(self.splitter, stretch=1)
|
|
|
|
self.statusBar().showMessage("Ready")
|
|
self.setCentralWidget(main_widget)
|
|
|
|
def _wire_signals(self):
|
|
self.ipod_tab.device_mounted.connect(self._on_device_mounted)
|
|
self.ipod_tab.device_unmounted.connect(self._on_device_unmounted)
|
|
self.settings_tab.hide_library_buttons_changed.connect(
|
|
self.library_tab.set_buttons_visible
|
|
)
|
|
self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled)
|
|
|
|
self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist)
|
|
self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
|
|
self.sidebar.playlist_dropped_for_nav_transfer.connect(self._on_playlist_dropped_for_nav_transfer)
|
|
self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer)
|
|
self.library_tab.transfer_finished.connect(self._on_transfer_finished_for_playlist)
|
|
|
|
def _on_sidebar_item_selected(self, kind: str, value: str):
|
|
if kind == "tab":
|
|
tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0)
|
|
self.stack.setCurrentIndex(tab_index)
|
|
if tab_index == 0:
|
|
self.library_tab.set_view_mode("table")
|
|
elif kind == "filter":
|
|
self.stack.setCurrentIndex(0)
|
|
self.library_tab.set_view_mode(value)
|
|
elif kind == "playlist_create":
|
|
self._create_playlist(value)
|
|
elif kind == "playlist_rename":
|
|
playlist_id, new_name = value.split("|", 1)
|
|
self._rename_playlist(playlist_id, new_name)
|
|
elif kind == "playlist_delete":
|
|
self._delete_playlist(playlist_id=value)
|
|
elif kind == "playlist_reorder":
|
|
ids = value.split("|") if value else []
|
|
self.playlist_manager.reorder(ids)
|
|
|
|
def _on_playlist_selected(self, playlist_id: str):
|
|
self.stack.setCurrentIndex(0)
|
|
playlist = self.playlist_manager.get_by_id(playlist_id)
|
|
if playlist:
|
|
self.library_tab.set_current_playlist_id(playlist_id)
|
|
self.library_tab.apply_filter("playlist", playlist.track_paths)
|
|
|
|
def _create_playlist(self, name: str):
|
|
playlist_id = self.playlist_manager.create(name)
|
|
self.sidebar.add_playlist_item(playlist_id, name)
|
|
|
|
def _rename_playlist(self, playlist_id: str, new_name: str):
|
|
self.playlist_manager.rename(playlist_id, new_name)
|
|
|
|
def _delete_playlist(self, playlist_id: str):
|
|
self.playlist_manager.delete(playlist_id)
|
|
self.sidebar.remove_playlist_item(playlist_id)
|
|
self.library_tab.clear_filter()
|
|
self.sidebar.select_library_music()
|
|
|
|
def _on_tracks_dropped_on_playlist(self, playlist_id: str, paths: list):
|
|
self.playlist_manager.add_tracks(playlist_id, paths)
|
|
playlist = self.playlist_manager.get_by_id(playlist_id)
|
|
if playlist:
|
|
self._on_playlist_selected(playlist_id)
|
|
self.statusBar().showMessage(f"Added {len(paths)} track(s) to playlist", 5000)
|
|
|
|
def _on_tracks_dropped_for_transfer(self, paths: list):
|
|
self.library_tab.transfer_tracks_by_paths(paths)
|
|
|
|
def _on_tracks_dropped_for_nav_transfer(self, paths: list):
|
|
self.stack.setCurrentIndex(0)
|
|
self.library_tab.transfer_tracks_by_paths(paths)
|
|
|
|
def _on_playlist_dropped_for_nav_transfer(self, playlist_id: str):
|
|
playlist = self.playlist_manager.get_by_id(playlist_id)
|
|
if not playlist or not playlist.track_paths:
|
|
return
|
|
|
|
self.stack.setCurrentIndex(0)
|
|
self._pending_playlist_id = playlist_id
|
|
self._pending_playlist_tracks = list(playlist.track_paths)
|
|
self.library_tab.transfer_tracks_by_paths(playlist.track_paths)
|
|
|
|
def _on_transfer_finished_for_playlist(self):
|
|
playlist_id = self._pending_playlist_id
|
|
track_paths = self._pending_playlist_tracks
|
|
self._pending_playlist_id = ""
|
|
self._pending_playlist_tracks = []
|
|
|
|
if not playlist_id or not track_paths:
|
|
return
|
|
|
|
mount_point = getattr(self.ipod_tab, "current_mount_point", None)
|
|
if not mount_point:
|
|
self.statusBar().showMessage("Cannot create playlist: iPod not mounted", 5000)
|
|
return
|
|
|
|
playlist = self.playlist_manager.get_by_id(playlist_id)
|
|
if not playlist:
|
|
return
|
|
|
|
self.statusBar().showMessage(f"Creating playlist '{playlist.name}' on iPod...")
|
|
|
|
from worker import WorkerThread
|
|
self._pl_worker = WorkerThread(
|
|
task_type="create_playlist",
|
|
mount_point=mount_point,
|
|
playlist_name=playlist.name,
|
|
track_paths=track_paths,
|
|
)
|
|
self._pl_worker.finished_signal.connect(self._on_playlist_creation_finished)
|
|
self._pl_worker.start()
|
|
|
|
def _on_playlist_creation_finished(self, success, message, result):
|
|
self.statusBar().showMessage(message, 8000)
|
|
try:
|
|
self._pl_worker.quit()
|
|
self._pl_worker.wait()
|
|
except Exception:
|
|
pass
|
|
self._pl_worker = None
|
|
|
|
def _load_playlists_into_sidebar(self):
|
|
for pl in self.playlist_manager.get_all():
|
|
self.sidebar.add_playlist_item(pl.id, pl.name)
|
|
|
|
def _on_device_mounted(self, mount_point: str):
|
|
self.library_tab.on_device_mounted(mount_point)
|
|
self.library_tab.refresh_play_counts_from_cache()
|
|
self.statusBar().showMessage(f"iPod mounted at {mount_point}")
|
|
QTimer.singleShot(2000, lambda: self._start_auto_sync(mount_point))
|
|
|
|
def _on_device_unmounted(self):
|
|
self.library_tab.on_device_unmounted()
|
|
self.statusBar().showMessage("iPod disconnected")
|
|
|
|
def _start_auto_sync(self, mount_point: str):
|
|
if self._auto_sync_worker is not None:
|
|
return
|
|
from worker import WorkerThread
|
|
self._auto_sync_worker = WorkerThread(
|
|
"auto_sync_playcounts", parent=self, mount_point=mount_point,
|
|
)
|
|
self._auto_sync_worker.progress_signal.connect(self._on_auto_sync_progress)
|
|
self._auto_sync_worker.finished_signal.connect(self._on_auto_sync_finished)
|
|
self._auto_sync_worker.start()
|
|
|
|
def _on_auto_sync_progress(self, pct: int, msg: str):
|
|
pass
|
|
|
|
def _on_auto_sync_finished(self, success: bool, msg: str, _result):
|
|
if success and msg.startswith("Auto-sync complete"):
|
|
self.library_tab.refresh_play_counts_from_cache()
|
|
self._auto_sync_worker = None
|
|
|
|
def _toggle_sidebar(self):
|
|
visible = not self.sidebar.isVisible()
|
|
self.sidebar.setVisible(visible)
|
|
if visible:
|
|
self.splitter.setSizes([SIDEBAR_DEFAULT_WIDTH, self.splitter.width() - SIDEBAR_DEFAULT_WIDTH])
|
|
else:
|
|
self.splitter.setSizes([0, self.splitter.width()])
|
|
self._sidebar_visible = visible
|
|
|
|
def _start_device_monitor(self):
|
|
auto_detect = self.config_loader.get_boolean("Device", "auto_detect", fallback=True)
|
|
self.device_monitor = DeviceMonitor()
|
|
if auto_detect:
|
|
self.ipod_tab.start_monitoring(self.device_monitor)
|
|
self.device_monitor.start()
|
|
|
|
def _on_auto_detect_toggled(self, enabled: bool):
|
|
if not self.device_monitor:
|
|
return
|
|
if enabled:
|
|
self.ipod_tab.start_monitoring(self.device_monitor)
|
|
self.device_monitor.start()
|
|
else:
|
|
self.device_monitor.stop()
|
|
self.ipod_tab.stop_monitoring(self.device_monitor)
|
|
|
|
def _load_settings(self):
|
|
config = self.config_loader
|
|
self.settings_tab.load_settings(config)
|
|
hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
|
|
self.library_tab.set_buttons_visible(not hide_buttons)
|
|
|
|
# Migrate Playback section → SessionStore if needed
|
|
self.session_store.migrate_from_config(config)
|
|
|
|
self._saved_volume = self.session_store.get_int("last_volume", 80)
|
|
self._saved_track_path = self.session_store.get_str("last_track_path", "")
|
|
self._saved_position_ms = self.session_store.get_int("last_position_ms", 0)
|
|
self._saved_playing = self.session_store.get_bool("last_playing", False)
|
|
self._shuffle_active = self.session_store.get_bool("shuffle_active", False)
|
|
self._repeat_mode = self.session_store.get_str("repeat_mode", "no_repeat")
|
|
|
|
self.player_header.set_shuffle_active(self._shuffle_active)
|
|
self.player_header.set_repeat_mode(self._repeat_mode)
|
|
|
|
self.audio_output.setVolume(self._saved_volume / 100.0)
|
|
self.player_header.set_volume(self._saved_volume)
|
|
|
|
def _save_settings(self):
|
|
config = self.config_loader
|
|
session = self.session_store
|
|
self.settings_tab.save_settings(config)
|
|
|
|
session.set("last_volume", self.player_header.volume_slider.value())
|
|
session.set("shuffle_active", self._shuffle_active)
|
|
session.set("repeat_mode", self._repeat_mode)
|
|
if self.current_track_path and os.path.exists(self.current_track_path):
|
|
session.set("last_track_path", self.current_track_path)
|
|
position = self.player.position()
|
|
duration = self.player.duration()
|
|
if duration > 0 and position > duration - 2000:
|
|
position = 0
|
|
session.set("last_position_ms", position)
|
|
session.set("last_playing", self._is_playing_now)
|
|
else:
|
|
session.set("last_track_path", "")
|
|
session.set("last_position_ms", 0)
|
|
session.save()
|
|
|
|
state = self.library_tab.get_state()
|
|
config.set("Library", "view_mode", state.get("view_mode", "table"))
|
|
config.set("Library", "filtered_album", state.get("filtered_album", ""))
|
|
config.set("Library", "filtered_playlist_id", state.get("filtered_playlist_id", ""))
|
|
config.save()
|
|
|
|
@property
|
|
def current_track_path(self) -> str:
|
|
if self.player.source().isValid():
|
|
return self.player.source().toLocalFile()
|
|
return ""
|
|
|
|
@property
|
|
def _is_playing_now(self) -> bool:
|
|
return self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
|
|
|
|
def _setup_hotkeys(self):
|
|
self.hotkey_manager = HotkeyManager(self, self.config_loader)
|
|
self.hotkey_manager.register_all({
|
|
"play_pause": self._on_play_pause,
|
|
"prev_track": self._on_prev,
|
|
"next_track": self._on_next,
|
|
"volume_up": lambda: self._adjust_volume(5),
|
|
"volume_down": lambda: self._adjust_volume(-5),
|
|
"seek_forward": self._seek_forward,
|
|
"seek_backward": self._seek_backward,
|
|
"toggle_sidebar": self._toggle_sidebar,
|
|
"tab_library": lambda: self.stack.setCurrentIndex(0),
|
|
"tab_ipod": lambda: self.stack.setCurrentIndex(1),
|
|
"tab_settings": lambda: self.stack.setCurrentIndex(2),
|
|
"search_focus": self.library_tab._focus_search,
|
|
"select_all": self.library_tab._select_all_current,
|
|
"library_refresh": self.library_tab._on_refresh_library_clicked,
|
|
"ipod_refresh": self.ipod_tab._on_refresh_devices_clicked,
|
|
"delete_selected": self.library_tab._delete_selected_current,
|
|
"edit_metadata": self.library_tab._on_edit_metadata,
|
|
"library_add_files": self.library_tab._on_library_add_files,
|
|
})
|
|
self.settings_tab.set_hotkey_manager(self.hotkey_manager)
|
|
self.settings_tab.refresh_shortcuts_table()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Playback
|
|
# ------------------------------------------------------------------
|
|
|
|
def _on_play_requested(self, track_data: dict, start_position_ms: int, auto_play: bool):
|
|
path = track_data.get("path", "")
|
|
if not path or not os.path.exists(path):
|
|
return
|
|
self._pending_seek_ms = start_position_ms
|
|
|
|
self.player.setSource(QUrl.fromLocalFile(path))
|
|
if auto_play:
|
|
self.player.play()
|
|
self.player_header.set_playing(True)
|
|
else:
|
|
self.player_header.set_playing(False)
|
|
|
|
self.player_header.set_track_info(
|
|
f"{track_data['artist']} \u2014 {track_data['title']}"
|
|
)
|
|
|
|
cover_path = track_data.get("cover_path")
|
|
if cover_path and os.path.exists(cover_path):
|
|
self.player_header.set_cover(QPixmap(cover_path), track_data.get("album", ""))
|
|
else:
|
|
artist = track_data.get("artist", "")
|
|
album = track_data.get("album", "")
|
|
cached = cover_cache.get(artist, album) if artist else None
|
|
if cached and os.path.exists(cached):
|
|
self.player_header.set_cover(QPixmap(cached), album)
|
|
else:
|
|
self.player_header.set_cover()
|
|
|
|
def _on_play_pause(self):
|
|
if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
|
self.player.pause()
|
|
self.player_header.set_playing(False)
|
|
self.library_tab.on_playing_changed(False)
|
|
elif self.player.playbackState() == QMediaPlayer.PlaybackState.StoppedState:
|
|
self.library_tab.play_track_at_index(
|
|
max(0, self.library_tab.current_playback_index)
|
|
)
|
|
else:
|
|
self.player.play()
|
|
self.player_header.set_playing(True)
|
|
self.library_tab.on_playing_changed(True)
|
|
|
|
def _on_prev(self):
|
|
if self.player.position() > 3000:
|
|
self.player.setPosition(0)
|
|
return
|
|
idx = max(0, self.library_tab.current_playback_index - 1)
|
|
self.library_tab.play_track_at_index(idx)
|
|
|
|
def _get_next_index(self):
|
|
playable = self.library_tab.playable_row_indices()
|
|
if not playable:
|
|
return None
|
|
|
|
current = self.library_tab.current_playback_index
|
|
|
|
if self._shuffle_active:
|
|
candidates = [r for r in playable if r != current]
|
|
if not candidates:
|
|
candidates = playable
|
|
return random.choice(candidates)
|
|
|
|
try:
|
|
pos = playable.index(current)
|
|
except ValueError:
|
|
pos = -1
|
|
|
|
if pos >= 0 and pos < len(playable) - 1:
|
|
return playable[pos + 1]
|
|
|
|
if self._repeat_mode == "repeat_all" and playable:
|
|
return playable[0]
|
|
|
|
return None
|
|
|
|
def _stop_playback(self):
|
|
self.player.stop()
|
|
self.library_tab.on_playing_changed(False)
|
|
self.player_header.set_playing(False)
|
|
|
|
def _on_next(self):
|
|
idx = self._get_next_index()
|
|
if idx is not None:
|
|
self.library_tab.play_track_at_index(idx)
|
|
|
|
def _seek_forward(self):
|
|
if self.player.duration() <= 0:
|
|
return
|
|
new_pos = min(self.player.duration(), self.player.position() + 5000)
|
|
self.player.setPosition(new_pos)
|
|
|
|
def _seek_backward(self):
|
|
if self.player.duration() <= 0:
|
|
return
|
|
new_pos = max(0, self.player.position() - 5000)
|
|
self.player.setPosition(new_pos)
|
|
|
|
def _adjust_volume(self, delta: int):
|
|
slider = self.player_header.volume_slider
|
|
new_val = max(0, min(100, slider.value() + delta))
|
|
slider.setValue(new_val)
|
|
|
|
def _on_volume_changed(self, value: int):
|
|
if self.audio_output:
|
|
self.audio_output.setVolume(value / 100.0)
|
|
|
|
def _on_user_seek_frac(self, value: int):
|
|
duration = self.player.duration()
|
|
if duration <= 0:
|
|
return
|
|
new_pos = int(value / 1000.0 * duration)
|
|
new_pos = max(0, min(duration - 1, new_pos))
|
|
self.player.setPosition(new_pos)
|
|
|
|
def _on_player_position(self, position: int):
|
|
duration = self.player.duration()
|
|
self.player_header.set_position(position, duration)
|
|
self.library_tab.on_position_changed(position, duration)
|
|
|
|
def _on_player_duration(self, duration: int):
|
|
pos = self.player.position()
|
|
self.player_header.set_position(pos, duration)
|
|
|
|
def _on_shuffle_toggle(self):
|
|
self._shuffle_active = not self._shuffle_active
|
|
self.player_header.set_shuffle_active(self._shuffle_active)
|
|
|
|
def _on_repeat_cycle(self):
|
|
cycle = {"no_repeat": "repeat_all", "repeat_all": "repeat_one", "repeat_one": "no_repeat"}
|
|
self._repeat_mode = cycle[self._repeat_mode]
|
|
self.player_header.set_repeat_mode(self._repeat_mode)
|
|
|
|
def _on_media_status(self, status):
|
|
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
|
self.library_tab.on_track_ended()
|
|
|
|
if self._repeat_mode == "repeat_one":
|
|
self.player.setPosition(0)
|
|
self.player.play()
|
|
return
|
|
|
|
next_idx = self._get_next_index()
|
|
if next_idx is not None:
|
|
self.library_tab.play_track_at_index(next_idx)
|
|
else:
|
|
self._stop_playback()
|
|
elif status == QMediaPlayer.MediaStatus.NoMedia:
|
|
self.library_tab.on_playing_changed(False)
|
|
self.player_header.reset_info()
|
|
elif status == QMediaPlayer.MediaStatus.LoadedMedia:
|
|
if self._pending_seek_ms > 0:
|
|
self.player.setPosition(self._pending_seek_ms)
|
|
self._pending_seek_ms = 0
|
|
|
|
def _resume_playback_if_saved(self):
|
|
if not self._saved_track_path or not os.path.exists(self._saved_track_path):
|
|
return
|
|
for row in range(self.library_tab.library_table.rowCount()):
|
|
item = self.library_tab.library_table.item(row, 0)
|
|
if item is None:
|
|
continue
|
|
_is_ready, data = item.data(Qt.ItemDataRole.UserRole)
|
|
if data and data.get("path", "") == self._saved_track_path:
|
|
self.library_tab.play_track_at_index(
|
|
row, start_position_ms=self._saved_position_ms,
|
|
auto_play=self._saved_playing
|
|
)
|
|
return
|
|
|
|
def _restore_library_state(self):
|
|
view_mode = self.config_loader.get("Library", "view_mode", fallback="table")
|
|
filtered_album = self.config_loader.get("Library", "filtered_album", fallback="")
|
|
filtered_playlist_id = self.config_loader.get("Library", "filtered_playlist_id", fallback="")
|
|
|
|
if filtered_album:
|
|
self.library_tab.filter_by_album(filtered_album)
|
|
elif filtered_playlist_id:
|
|
pl = self.playlist_manager.get_by_id(filtered_playlist_id)
|
|
if pl:
|
|
self.library_tab.set_current_playlist_id(filtered_playlist_id)
|
|
self.library_tab.apply_filter("playlist", pl.track_paths)
|
|
else:
|
|
self.library_tab.set_view_mode(view_mode)
|
|
|
|
self.sidebar.blockSignals(True)
|
|
if filtered_playlist_id:
|
|
self.sidebar.select_playlist_silent(filtered_playlist_id)
|
|
else:
|
|
self.sidebar.select_by_view_mode(view_mode)
|
|
self.sidebar.blockSignals(False)
|
|
|
|
def closeEvent(self, event):
|
|
if self.device_monitor:
|
|
self.device_monitor.stop()
|
|
self.ipod_tab.stop_monitoring(self.device_monitor)
|
|
for tab in (self.ipod_tab, self.library_tab):
|
|
if hasattr(tab, 'worker_thread') and tab.worker_thread:
|
|
try:
|
|
if tab.worker_thread.isRunning():
|
|
tab.worker_thread.stop()
|
|
except RuntimeError:
|
|
tab.worker_thread = None
|
|
self._save_settings()
|
|
from PyQt6.QtWidgets import QApplication
|
|
QApplication.processEvents()
|
|
event.accept()
|
|
|
|
|
|
# GTK3 platform theme paths for Linux desktop integration
|
|
_GTK3_PLUGIN_PATHS = [
|
|
"/usr/lib/qt6/plugins/platformthemes/libqgtk3.so",
|
|
"/usr/lib/x86_64-linux-gnu/qt6/plugins/platformthemes/libqgtk3.so",
|
|
"/usr/lib64/qt6/plugins/platformthemes/libqgtk3.so",
|
|
"/usr/lib/aarch64-linux-gnu/qt6/plugins/platformthemes/libqgtk3.so",
|
|
]
|
|
|
|
|
|
def _setup_linux_theming():
|
|
if sys.platform != "linux":
|
|
return
|
|
|
|
if "QT_QPA_PLATFORMTHEME" in os.environ:
|
|
return
|
|
|
|
for path in _GTK3_PLUGIN_PATHS:
|
|
if os.path.exists(path):
|
|
os.environ["QT_QPA_PLATFORMTHEME"] = "gtk3"
|
|
logger.debug(f"Using GTK3 platform theme: {path}")
|
|
return
|
|
|
|
if os.path.exists("/usr/bin/qt6ct"):
|
|
os.environ["QT_QPA_PLATFORMTHEME"] = "qt6ct"
|
|
logger.debug("Using qt6ct platform theme")
|
|
return
|
|
|
|
logger.debug("No GTK platform theme plugin found, using Qt default style")
|
|
|
|
|
|
def main():
|
|
_setup_linux_theming()
|
|
|
|
app = QApplication(sys.argv)
|
|
|
|
if not os.environ.get("QT_QPA_PLATFORMTHEME"):
|
|
app.setStyle("Fusion")
|
|
|
|
window = MainWindow()
|
|
window.show()
|
|
sys.exit(app.exec())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|