mod auth; mod commands; mod connectivity; mod credentials; mod download; mod jellyfin; mod playback_mode; mod playback_reporting; mod player; mod repository; mod session_poller; pub mod settings; mod storage; mod thumbnail; pub mod utils; use std::sync::{Arc, Mutex}; use tokio::sync::Mutex as TokioMutex; use tauri::{Emitter, Manager}; use tauri_specta::Builder; use log::{error, info}; #[cfg(target_os = "android")] use log::warn; use commands::{ cancel_download, clear_stale_downloads, delete_album_downloads, delete_all_downloads, delete_download, download_album, download_item, download_item_and_start, download_video, download_series, download_season, get_download_storage_stats, get_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations, get_album_affinity_status, mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads, pin_item, unpin_item, is_item_pinned, offline_get_items, offline_is_available, offline_search, pause_download, resume_download, player_cycle_repeat, player_get_audio_settings, player_get_queue, player_get_status, player_get_video_settings, player_next, player_pause, player_play, player_play_album_track, player_play_item, player_play_queue, player_play_tracks, player_previous, player_seek, player_seek_video, player_set_audio_settings, player_set_audio_track, player_switch_audio_track, player_set_subtitle_track, player_set_video_settings, player_set_volume, player_toggle_mute, player_stop, player_toggle, player_toggle_shuffle, // Sleep timer and autoplay commands player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer, player_get_autoplay_settings, player_set_autoplay_settings, player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended, // HTML5 video state-report commands player_report_state, player_report_position, player_report_media_loaded, // Queue manipulation commands player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids, player_remove_from_queue, player_move_in_queue, player_skip_to, // Preload commands player_preload_upcoming, player_set_cache_config, player_get_cache_config, // Jellyfin reporting commands player_configure_jellyfin, player_disable_jellyfin, // Session management commands player_get_session, player_dismiss_session, // Remote session control commands remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_session_toggle_mute, // LMS multi-room sync group commands lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group, // Session polling commands sessions_set_polling_hint, sessions_poll_now, SessionPollerWrapper, // Playback mode commands playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, playback_mode_transfer_to_remote, playback_mode_transfer_to_local, playback_mode_set_transferring, playback_mode_get_remote_status, // Playback reporting commands playback_reporter_init, playback_reporter_destroy, playback_report_start, playback_report_progress, playback_report_stopped, playback_mark_played, PlaybackReporterWrapper, // Auth commands auth_initialize, auth_connect_to_server, auth_login, auth_verify_session, auth_logout, auth_get_session, auth_set_session, auth_start_verification, auth_stop_verification, auth_reauthenticate, // Device commands device_get_id, device_set_id, // Connectivity commands connectivity_check_server, connectivity_set_server_url, connectivity_get_status, connectivity_start_monitoring, connectivity_stop_monitoring, connectivity_mark_reachable, connectivity_mark_unreachable, // Storage commands storage_delete_server, storage_delete_user, storage_get_access_token, storage_get_active_session, storage_get_active_user, storage_get_path, storage_get_playback_progress, storage_get_security_status, storage_get_servers, storage_get_size, storage_get_users, storage_init, storage_mark_played, storage_mark_synced, storage_save_server, storage_save_user, storage_set_active_user, storage_toggle_favorite, storage_update_playback_progress, storage_update_playback_context, // Offline cache commands storage_get_libraries, storage_get_items, storage_get_item, storage_search_items, storage_save_library, storage_save_item, storage_get_pending_sync_count, // Sync queue commands sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed, sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user, // Thumbnail cache and image commands thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit, thumbnail_clear_cache, thumbnail_delete_item, image_get_url, // People cache commands storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people, // Series audio preferences storage_save_series_audio_preference, storage_get_series_audio_preference, // Repository commands repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items, repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies, repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_subtitle_url, repository_get_video_download_url, // Playlist commands playlist_create, playlist_delete, playlist_rename, playlist_get_items, playlist_add_items, playlist_remove_items, playlist_move_item, // Conversion commands format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds, calc_progress, convert_percent_to_volume, AuthManagerWrapper, SessionVerifierWrapper, ConnectivityMonitorWrapper, CredentialStoreWrapper, DatabaseWrapper, PlayerStateWrapper, MediaSessionManagerWrapper, VideoSettingsWrapper, ThumbnailCacheWrapper, SmartCacheWrapper, PlaybackModeManagerWrapper, RepositoryManagerWrapper, DownloadManagerWrapper, }; #[cfg(target_os = "android")] use playback_mode::PlaybackModeManager; use auth::AuthManager; use connectivity::ConnectivityMonitor; use credentials::CredentialStore; use download::cache::{CacheConfig as SmartCacheConfig, SmartCache}; use download::DownloadManager; use jellyfin::{HttpClient, HttpConfig}; use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter}; // NullBackend is used both for platforms without a native backend AND as a graceful // fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can // still launch (browse library, manage downloads, see an error) instead of crashing. use player::NullBackend; #[cfg(target_os = "linux")] use player::MpvBackend; use settings::VideoSettings; use storage::Database; use thumbnail::{ThumbnailCache, CacheConfig as ThumbnailCacheConfig}; #[cfg(target_os = "android")] use credentials::initialize_secure_storage; #[cfg(target_os = "android")] use player::ExoPlayerBackend; #[cfg(target_os = "android")] use player::{MediaCommandHandler, RemoteVolumeHandler, set_media_command_handler, set_remote_volume_handler}; /// Handler for media commands from Android MediaSession (lockscreen/notification controls). /// /// Routes commands from the system media controls to the right place depending on /// playback mode: in local mode it drives the local `PlayerController`; in remote /// (cast) mode it forwards transport commands to the remote Jellyfin session so /// the lockscreen can control whatever is casting. Stop while casting requests a /// disconnect back to local playback. #[cfg(target_os = "android")] struct MediaSessionHandler { player: Arc>, playback_mode: Arc, event_emitter: Arc, } #[cfg(target_os = "android")] impl MediaSessionHandler { /// Forward a transport command to the active remote Jellyfin session. /// /// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary /// threads without a Tokio context. fn handle_remote_command(&self, command: &str, session_id: String) { use crate::player::{PlayerEventEmitter, PlayerStatusEvent}; // Stop while casting means "disconnect and resume locally". The frontend // owns the remote->local transfer (it reloads the item locally), so we // just signal intent. if command == "stop" { self.event_emitter .emit(PlayerStatusEvent::RemoteDisconnectRequested); return; } let jellyfin_client = { let player = self.player.blocking_lock(); player.jellyfin_client() }; let command = command.to_string(); tauri::async_runtime::spawn(async move { let client = { let guard = match jellyfin_client.lock() { Ok(g) => g, Err(e) => { error!("[MediaSession] Failed to lock Jellyfin client: {}", e); return; } }; match guard.as_ref() { Some(c) => c.clone(), None => { warn!("[MediaSession] No Jellyfin client for remote command"); return; } } }; // Map lockscreen transport commands onto Jellyfin session commands. let result = match command.as_str() { "play" => client.send_session_command(session_id, "Unpause").await, "pause" => client.send_session_command(session_id, "Pause").await, "next" => client.send_session_command(session_id, "NextTrack").await, "previous" => client.send_session_command(session_id, "PreviousTrack").await, cmd if cmd.starts_with("seek:") => match cmd[5..].parse::() { Ok(seconds) => { let ticks = (seconds * 10_000_000.0) as i64; client.session_seek(session_id, ticks).await } Err(_) => { warn!("[MediaSession] Bad seek command: {}", command); Ok(()) } }, _ => { warn!("[MediaSession] Unknown remote command: {}", command); Ok(()) } }; if let Err(e) = result { error!("[MediaSession] Remote command '{}' failed: {}", command, e); } }); } /// Drive the local player for a transport command. fn handle_local_command(&self, command: &str) { // Use blocking_lock since this is called from a non-async JNI callback let controller = self.player.blocking_lock(); let result = match command { "play" => controller.play(), "pause" => controller.pause(), "next" => controller.next(), "previous" => controller.previous(), "stop" => controller.stop(), cmd if cmd.starts_with("seek:") => match cmd[5..].parse::() { Ok(pos) => controller.seek(pos), Err(_) => { warn!("[MediaSession] Bad seek command: {}", command); Ok(()) } }, _ => { warn!("[MediaSession] Unknown command: {}", command); Ok(()) } }; if let Err(e) = result { error!("[MediaSession] Command '{}' failed: {}", command, e); } } } #[cfg(target_os = "android")] impl MediaCommandHandler for MediaSessionHandler { fn on_command(&self, command: &str) { match self.playback_mode.get_mode() { playback_mode::PlaybackMode::Remote { session_id } => { self.handle_remote_command(command, session_id); } _ => self.handle_local_command(command), } } } /// Handler for remote volume changes from Android volume buttons when in remote playback mode. /// /// Routes volume commands to the Jellyfin session via the playback mode manager. #[cfg(target_os = "android")] struct RemoteVolumeSessionHandler { playback_mode: Arc, } #[cfg(target_os = "android")] impl RemoteVolumeHandler for RemoteVolumeSessionHandler { fn on_remote_volume_change(&self, command: &str, volume: i32) { log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume); // Send the volume command to the remote session asynchronously let playback_mode = Arc::clone(&self.playback_mode); let command_str = command.to_string(); // Use tauri::async_runtime::spawn instead of tokio::spawn // JNI callbacks happen on arbitrary threads without a Tokio runtime log::info!("[RemoteVolume] Spawning async task to send volume command..."); tauri::async_runtime::spawn(async move { log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command..."); match playback_mode.send_remote_volume_command(&command_str, volume).await { Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"), Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e), } log::info!("[RemoteVolume] Async task completed"); }); log::info!("[RemoteVolume] Async task spawned, returning from JNI callback"); } } /// Payload emitted to the frontend when a native player backend fails to /// initialize and the app falls back to a no-op backend. #[derive(Clone, serde::Serialize)] struct BackendInitError { platform: &'static str, backend: &'static str, message: String, } /// Log a backend-initialization failure and notify the frontend, so the UI can /// surface "playback unavailable" instead of the app hard-crashing. fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) { error!( "[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).", backend, message ); let _ = app_handle.emit( "backend-init-failed", BackendInitError { platform: std::env::consts::OS, backend, message, }, ); } /// Create the appropriate player backend for the current platform. fn create_player_backend( app_handle: tauri::AppHandle, playback_reporter: Arc>>, position_throttler: Arc, ) -> Box { let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone())); #[cfg(target_os = "android")] { info!("Android platform detected - initializing ExoPlayer backend"); // Get the Android context via ndk-context let ctx = ndk_context::android_context(); // Get JavaVM and create JNI environment let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }; match vm { Ok(java_vm) => { match java_vm.attach_current_thread() { Ok(mut env) => { let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; match ExoPlayerBackend::new(&mut env, &context_obj, _event_emitter.clone(), playback_reporter.clone(), position_throttler.clone()) { Ok(backend) => { info!("Successfully initialized ExoPlayer backend for Android"); return Box::new(backend); } Err(e) => { // Degrade gracefully instead of crashing the app. emit_backend_init_failed(&app_handle, "exoplayer", e.to_string()); return Box::new(NullBackend::new()); } } } Err(e) => { emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e)); return Box::new(NullBackend::new()); } } } Err(e) => { emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e)); return Box::new(NullBackend::new()); } } } // For Linux, use MPV backend for audio playback #[cfg(target_os = "linux")] { info!("Linux platform detected - initializing MPV backend for audio"); match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) { Ok(backend) => { info!("Successfully initialized MPV backend for Linux"); return Box::new(backend); } Err(e) => { error!("\n========================================"); error!("FATAL ERROR: Failed to initialize MPV backend"); error!("========================================"); error!("Error: {}", e); error!("\nCommon causes:"); error!(" 1. MPV is not installed"); error!(" Solution: Install MPV using your package manager"); error!(" - Arch/CachyOS: sudo pacman -S mpv"); error!(" - Ubuntu/Debian: sudo apt install mpv libmpv-dev"); error!(" - Fedora: sudo dnf install mpv mpv-libs-devel"); error!("\n 2. MPV version mismatch (app was built with different libmpv version)"); error!(" Solution: Rebuild the application"); error!(" - cd src-tauri && cargo clean && cargo build --release"); error!("\n 3. Audio system not working"); error!(" Solution: Verify audio works with: pactl info"); error!("\nAudio playback will NOT work until this is fixed."); error!("========================================\n"); // Degrade gracefully: launch with a no-op backend so the user can // still browse the library and manage downloads, and the frontend // can show a "playback unavailable" notice via this event. emit_backend_init_failed(&app_handle, "mpv", e.to_string()); return Box::new(NullBackend::new()); } } } // Fallback for other platforms #[cfg(not(any(target_os = "linux", target_os = "android")))] { warn!("WARNING: No audio backend available for this platform"); Box::new(NullBackend::new()) } } /// Construct the tauri-specta command builder. Shared by `run()` and the /// bindings-export test so the TypeScript bindings always match the handler. fn specta_builder() -> Builder { Builder::::new() // Throw on error so generated `commands.*` return Promise and throw, // matching the existing frontend's invoke() try/catch convention. .error_handling(tauri_specta::ErrorHandlingMode::Throw) .events(tauri_specta::collect_events![ crate::player::events::PlayerStatusEvent ]) .commands(tauri_specta::collect_commands![ // Player commands player_play_item, player_play_queue, player_play_album_track, player_play_tracks, player_play, player_pause, player_toggle, player_stop, player_next, player_previous, player_seek, player_seek_video, player_set_volume, player_toggle_mute, player_set_audio_track, player_switch_audio_track, player_set_subtitle_track, player_toggle_shuffle, player_cycle_repeat, player_get_status, player_get_queue, player_add_to_queue, player_add_track_by_id, player_add_tracks_by_ids, player_remove_from_queue, player_move_in_queue, player_skip_to, player_set_audio_settings, player_get_audio_settings, player_set_video_settings, player_get_video_settings, // Sleep timer and autoplay commands player_set_sleep_timer, player_cancel_sleep_timer, player_get_sleep_timer, player_get_autoplay_settings, player_set_autoplay_settings, player_cancel_autoplay_countdown, player_play_next_episode, player_on_playback_ended, player_report_state, player_report_position, player_report_media_loaded, // Preload commands player_preload_upcoming, player_set_cache_config, player_get_cache_config, // Jellyfin reporting commands player_configure_jellyfin, player_disable_jellyfin, // Session management commands player_get_session, player_dismiss_session, // Remote session control commands remote_play_on_session, remote_send_command, remote_session_seek, remote_session_set_volume, remote_session_toggle_mute, // LMS multi-room sync group commands lms_get_sync_groups, lms_create_sync_group, lms_unsync_player, lms_dissolve_sync_group, // Session polling commands sessions_set_polling_hint, sessions_poll_now, // Playback mode commands playback_mode_get_current, playback_mode_set, playback_mode_is_transferring, playback_mode_transfer_to_remote, playback_mode_get_remote_status, playback_mode_transfer_to_local, playback_mode_set_transferring, // Playback reporting commands playback_reporter_init, playback_reporter_destroy, playback_report_start, playback_report_progress, playback_report_stopped, playback_mark_played, // Auth commands auth_initialize, auth_connect_to_server, auth_login, auth_verify_session, auth_logout, auth_get_session, auth_set_session, auth_start_verification, auth_stop_verification, auth_reauthenticate, // Device commands device_get_id, device_set_id, // Connectivity commands connectivity_check_server, connectivity_set_server_url, connectivity_get_status, connectivity_start_monitoring, connectivity_stop_monitoring, connectivity_mark_reachable, connectivity_mark_unreachable, // Storage commands storage_init, storage_get_path, storage_get_size, storage_get_security_status, storage_save_server, storage_get_servers, storage_delete_server, storage_save_user, storage_get_users, storage_set_active_user, storage_get_active_user, storage_get_active_session, storage_get_access_token, storage_delete_user, // Playback progress commands storage_update_playback_progress, storage_update_playback_context, storage_mark_played, storage_get_playback_progress, storage_mark_synced, storage_toggle_favorite, // Download commands download_item, download_item_and_start, download_album, download_video, download_series, download_season, get_downloads, pause_download, resume_download, cancel_download, delete_download, delete_all_downloads, delete_album_downloads, clear_stale_downloads, get_download_storage_stats, mark_download_completed, mark_download_failed, start_download, enqueue_download, enqueue_video_downloads, get_download_manager_stats, set_max_concurrent_downloads, get_smart_cache_stats, update_smart_cache_config, get_smart_cache_config, get_album_recommendations, get_album_affinity_status, // Pinning commands pin_item, unpin_item, is_item_pinned, // Offline commands offline_is_available, offline_get_items, offline_search, // Offline cache commands storage_get_libraries, storage_get_items, storage_get_item, storage_search_items, storage_save_library, storage_save_item, storage_get_pending_sync_count, // Sync queue commands sync_queue_mutation, sync_get_pending, sync_mark_processing, sync_mark_completed, sync_mark_failed, sync_get_pending_count, sync_cleanup_completed, sync_clear_user, // Thumbnail cache and image commands thumbnail_get_cached, thumbnail_save, thumbnail_get_stats, thumbnail_set_limit, thumbnail_clear_cache, thumbnail_delete_item, image_get_url, // People cache commands storage_save_person, storage_get_person, storage_save_item_people, storage_get_item_people, // Series audio preferences storage_save_series_audio_preference, storage_get_series_audio_preference, // Repository commands repository_create, repository_destroy, repository_get_libraries, repository_get_items, repository_get_item, repository_jray_actors_at, repository_get_latest_items, repository_get_resume_items, repository_get_next_up_episodes, repository_get_recently_played_audio, repository_get_resume_movies, repository_get_rediscover_albums, repository_get_genres, repository_search, repository_get_playback_info, repository_get_video_stream_url, repository_get_audio_stream_url, repository_get_live_tv_channels, repository_get_channels, repository_open_live_stream, repository_report_playback_start, repository_report_playback_progress, repository_report_playback_stopped, repository_get_image_url, repository_mark_favorite, repository_unmark_favorite, repository_get_person, repository_get_items_by_person, repository_get_similar_items, repository_get_subtitle_url, repository_get_video_download_url, // Playlist commands playlist_create, playlist_delete, playlist_rename, playlist_get_items, playlist_add_items, playlist_remove_items, playlist_move_item, // Conversion commands format_time_seconds, format_time_seconds_long, convert_ticks_to_seconds, calc_progress, convert_percent_to_volume, ]) } /// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `