626 lines
24 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
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 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 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._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._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()
self.library_tab._scan_library()
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.statusBar().showMessage(f"iPod mounted at {mount_point}")
def _on_device_unmounted(self):
self.library_tab.on_device_unmounted()
self.statusBar().showMessage("iPod disconnected")
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)
self._saved_volume = config.get_int("Playback", "last_volume", fallback=80)
self._saved_track_path = config.get("Playback", "last_track_path", fallback="")
self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0)
self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False)
self._shuffle_active = config.get_boolean("Playback", "shuffle_active", fallback=False)
self._repeat_mode = config.get("Playback", "repeat_mode", fallback="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
self.settings_tab.save_settings(config)
config.set("Playback", "last_volume", str(self.player_header.volume_slider.value()))
config.set("Playback", "shuffle_active", str(self._shuffle_active).lower())
config.set("Playback", "repeat_mode", self._repeat_mode)
if self.current_track_path and os.path.exists(self.current_track_path):
config.set("Playback", "last_track_path", self.current_track_path)
position = self.player.position()
duration = self.player.duration()
if duration > 0 and position > duration - 2000:
position = 0
config.set("Playback", "last_position_ms", str(position))
config.set("Playback", "last_playing", str(self._is_playing_now).lower())
else:
config.set("Playback", "last_track_path", "")
config.set("Playback", "last_position_ms", "0")
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()