#!/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()