A recoverable player error meant "playback is over": the frontend's error handler stopped the player unconditionally, so a wifi blip killed the track. Android already decides in its JNI callback, but MpvBackend is constructed before PlayerController exists, so its event thread has no controller to ask. So MPV reports the failure and the frontend echoes it into the new player_recover_stream command — the same shape as PlaybackEnded -> player_on_playback_ended, keeping the decision in Rust. The command re-opens the stream where it stopped, with the existing attempt budget and backoff, and returns whether it handled it; only a false answer falls through to the old stop path. Android now reports the errors it has already declined as *unrecoverable*, so the echo never asks the same question twice. TRACES: UR-004, UR-040 | DR-130 | UT-117
1333 lines
51 KiB
Rust
1333 lines
51 KiB
Rust
mod auth;
|
|
mod commands;
|
|
mod connectivity;
|
|
mod credentials;
|
|
mod domain;
|
|
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;
|
|
|
|
#[cfg(target_os = "android")]
|
|
use log::warn;
|
|
use log::{error, info};
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::{Emitter, Manager};
|
|
use tauri_specta::Builder;
|
|
use tokio::sync::Mutex as TokioMutex;
|
|
|
|
use auth::AuthManager;
|
|
use commands::{
|
|
auth_connect_to_server,
|
|
auth_get_session,
|
|
// Auth commands
|
|
auth_initialize,
|
|
auth_login,
|
|
auth_logout,
|
|
auth_reauthenticate,
|
|
auth_set_session,
|
|
auth_start_verification,
|
|
auth_stop_verification,
|
|
auth_verify_session,
|
|
calc_progress,
|
|
cancel_download,
|
|
catalog_sync_status,
|
|
clear_stale_downloads,
|
|
// Connectivity commands
|
|
connectivity_check_server,
|
|
connectivity_get_status,
|
|
connectivity_mark_reachable,
|
|
connectivity_mark_unreachable,
|
|
connectivity_set_server_url,
|
|
connectivity_start_monitoring,
|
|
connectivity_stop_monitoring,
|
|
convert_percent_to_volume,
|
|
convert_ticks_to_seconds,
|
|
delete_album_downloads,
|
|
delete_all_downloads,
|
|
delete_download,
|
|
delete_downloads_under,
|
|
// Device commands
|
|
device_get_id,
|
|
device_set_id,
|
|
download_album,
|
|
download_item,
|
|
download_item_and_start,
|
|
download_season,
|
|
download_series,
|
|
download_video,
|
|
enqueue_download,
|
|
enqueue_video_downloads,
|
|
// Conversion commands
|
|
format_time_seconds,
|
|
format_time_seconds_long,
|
|
get_album_affinity_status,
|
|
get_album_recommendations,
|
|
get_download_manager_stats,
|
|
get_download_storage_stats,
|
|
get_downloads,
|
|
get_downloads_allowed,
|
|
get_smart_cache_config,
|
|
get_smart_cache_stats,
|
|
image_get_url,
|
|
is_item_pinned,
|
|
lms_create_sync_group,
|
|
lms_dissolve_sync_group,
|
|
// LMS multi-room sync group commands
|
|
lms_get_sync_groups,
|
|
lms_unsync_player,
|
|
mark_download_completed,
|
|
mark_download_failed,
|
|
offline_get_items,
|
|
offline_is_available,
|
|
offline_search,
|
|
pause_download,
|
|
pin_item,
|
|
playback_mark_played,
|
|
// Playback mode commands
|
|
playback_mode_get_current,
|
|
playback_mode_get_remote_status,
|
|
playback_mode_is_transferring,
|
|
playback_mode_set,
|
|
playback_mode_set_transferring,
|
|
playback_mode_transfer_to_local,
|
|
playback_mode_transfer_to_remote,
|
|
playback_report_progress,
|
|
playback_report_start,
|
|
playback_report_stopped,
|
|
playback_reporter_destroy,
|
|
// Playback reporting commands
|
|
playback_reporter_init,
|
|
// Queue manipulation commands
|
|
player_add_to_queue,
|
|
player_add_track_by_id,
|
|
player_add_tracks_by_ids,
|
|
player_cancel_autoplay_countdown,
|
|
player_cancel_sleep_timer,
|
|
// Jellyfin reporting commands
|
|
player_configure_jellyfin,
|
|
player_cycle_repeat,
|
|
player_disable_jellyfin,
|
|
player_dismiss_session,
|
|
player_enter_background_audio,
|
|
player_exit_background_audio,
|
|
player_get_audio_settings,
|
|
player_get_autoplay_settings,
|
|
player_get_cache_config,
|
|
player_get_eq_presets,
|
|
player_get_queue,
|
|
// Session management commands
|
|
player_get_session,
|
|
player_get_sleep_timer,
|
|
player_get_status,
|
|
player_get_video_settings,
|
|
// Preload commands
|
|
player_local_media_path,
|
|
player_move_in_queue,
|
|
player_next,
|
|
player_on_playback_ended,
|
|
player_pause,
|
|
player_play,
|
|
player_play_album_track,
|
|
player_play_item,
|
|
player_play_next_episode,
|
|
player_play_queue,
|
|
player_play_tracks,
|
|
player_preload_upcoming,
|
|
player_previous,
|
|
player_recover_stream,
|
|
player_remove_from_queue,
|
|
player_report_media_loaded,
|
|
player_report_position,
|
|
// HTML5 video state-report commands
|
|
player_report_state,
|
|
player_seek,
|
|
player_seek_video,
|
|
player_set_audio_settings,
|
|
player_set_audio_track,
|
|
player_set_autoplay_settings,
|
|
player_set_cache_config,
|
|
// Sleep timer and autoplay commands
|
|
player_set_sleep_timer,
|
|
player_set_subtitle_track,
|
|
player_set_video_settings,
|
|
player_set_volume,
|
|
player_skip_to,
|
|
player_stop,
|
|
player_switch_audio_track,
|
|
player_toggle,
|
|
player_toggle_mute,
|
|
player_toggle_shuffle,
|
|
playlist_add_items,
|
|
// Playlist commands
|
|
playlist_create,
|
|
playlist_delete,
|
|
playlist_get_items,
|
|
playlist_move_item,
|
|
playlist_remove_items,
|
|
playlist_rename,
|
|
// Remote session control commands
|
|
remote_play_on_session,
|
|
remote_send_command,
|
|
remote_session_seek,
|
|
remote_session_set_volume,
|
|
remote_session_toggle_mute,
|
|
// Repository commands
|
|
repository_clear_watch_history,
|
|
repository_create,
|
|
repository_destroy,
|
|
repository_get_audio_only_stream_url_for_video,
|
|
repository_get_audio_stream_url,
|
|
repository_get_channels,
|
|
repository_get_download_disk_usage,
|
|
repository_get_downloaded_items,
|
|
repository_get_downloaded_libraries,
|
|
repository_get_favorites,
|
|
repository_get_genres,
|
|
repository_get_image_url,
|
|
repository_get_item,
|
|
repository_get_items,
|
|
repository_get_items_by_person,
|
|
repository_get_latest_items,
|
|
repository_get_libraries,
|
|
repository_get_live_tv_channels,
|
|
repository_get_next_up_episodes,
|
|
repository_get_person,
|
|
repository_get_playback_info,
|
|
repository_get_recently_played_audio,
|
|
repository_get_rediscover_albums,
|
|
repository_get_resume_items,
|
|
repository_get_resume_movies,
|
|
repository_get_series_current_episode,
|
|
repository_get_series_episodes,
|
|
repository_get_similar_items,
|
|
repository_get_subtitle_url,
|
|
repository_get_video_download_url,
|
|
repository_get_video_stream_url,
|
|
repository_jray_actors_at,
|
|
repository_mark_favorite,
|
|
repository_open_live_stream,
|
|
repository_report_playback_progress,
|
|
repository_report_playback_start,
|
|
repository_report_playback_stopped,
|
|
repository_search,
|
|
repository_unmark_favorite,
|
|
resume_download,
|
|
resume_queued_downloads,
|
|
sessions_poll_now,
|
|
// Session polling commands
|
|
sessions_set_polling_hint,
|
|
set_max_concurrent_downloads,
|
|
set_network_state,
|
|
set_show_server_catalog,
|
|
start_download,
|
|
// Storage commands
|
|
storage_delete_server,
|
|
storage_delete_user,
|
|
storage_get_access_token,
|
|
storage_get_active_session,
|
|
storage_get_active_user,
|
|
storage_get_item,
|
|
storage_get_item_people,
|
|
storage_get_items,
|
|
// Offline cache commands
|
|
storage_get_libraries,
|
|
storage_get_path,
|
|
storage_get_pending_sync_count,
|
|
storage_get_person,
|
|
storage_get_playback_progress,
|
|
storage_get_security_status,
|
|
storage_get_series_audio_preference,
|
|
storage_get_servers,
|
|
storage_get_size,
|
|
storage_get_users,
|
|
storage_init,
|
|
storage_mark_played,
|
|
storage_mark_synced,
|
|
storage_save_item,
|
|
storage_save_item_people,
|
|
storage_save_library,
|
|
// People cache commands
|
|
storage_save_person,
|
|
// Series audio preferences
|
|
storage_save_series_audio_preference,
|
|
storage_save_server,
|
|
storage_save_user,
|
|
storage_search_items,
|
|
storage_set_active_user,
|
|
storage_toggle_favorite,
|
|
storage_update_playback_context,
|
|
storage_update_playback_progress,
|
|
sync_cleanup_completed,
|
|
sync_clear_user,
|
|
sync_full_catalog,
|
|
sync_get_pending,
|
|
sync_get_pending_count,
|
|
sync_mark_completed,
|
|
sync_mark_failed,
|
|
sync_mark_processing,
|
|
// Sync queue commands
|
|
sync_queue_mutation,
|
|
thumbnail_clear_cache,
|
|
thumbnail_delete_item,
|
|
// Thumbnail cache and image commands
|
|
thumbnail_get_cached,
|
|
thumbnail_get_stats,
|
|
thumbnail_save,
|
|
thumbnail_set_limit,
|
|
unpin_item,
|
|
update_smart_cache_config,
|
|
AuthManagerWrapper,
|
|
ConnectivityMonitorWrapper,
|
|
CredentialStoreWrapper,
|
|
DatabaseWrapper,
|
|
DownloadManagerWrapper,
|
|
MediaSessionManagerWrapper,
|
|
PlaybackModeManagerWrapper,
|
|
PlaybackReporterWrapper,
|
|
PlayerStateWrapper,
|
|
RepositoryManagerWrapper,
|
|
SessionPollerWrapper,
|
|
SessionVerifierWrapper,
|
|
SmartCacheWrapper,
|
|
ThumbnailCacheWrapper,
|
|
VideoSettingsWrapper,
|
|
};
|
|
use connectivity::ConnectivityMonitor;
|
|
use credentials::CredentialStore;
|
|
use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
|
|
use download::DownloadManager;
|
|
use jellyfin::{HttpClient, HttpConfig};
|
|
#[cfg(target_os = "android")]
|
|
use playback_mode::PlaybackModeManager;
|
|
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::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
|
|
|
|
#[cfg(target_os = "android")]
|
|
use credentials::initialize_secure_storage;
|
|
|
|
#[cfg(target_os = "android")]
|
|
use player::ExoPlayerBackend;
|
|
|
|
#[cfg(target_os = "android")]
|
|
use player::{
|
|
set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
|
|
};
|
|
|
|
/// 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<TokioMutex<PlayerController>>,
|
|
playback_mode: Arc<PlaybackModeManager>,
|
|
event_emitter: Arc<TauriEventEmitter>,
|
|
}
|
|
|
|
#[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::<f64>() {
|
|
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::<f64>() {
|
|
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<PlaybackModeManager>,
|
|
}
|
|
|
|
#[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.
|
|
// playback_reporter/position_throttler are consumed only by the native audio
|
|
// backends (mpv/exo); on platforms using the webview audio backend they're unused.
|
|
#[cfg_attr(
|
|
not(any(target_os = "linux", target_os = "android")),
|
|
allow(unused_variables)
|
|
)]
|
|
fn create_player_backend(
|
|
app_handle: tauri::AppHandle,
|
|
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
|
|
position_throttler: Arc<playback_reporting::EventThrottler>,
|
|
) -> Box<dyn PlayerBackend> {
|
|
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());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Platforms with no native audio backend (e.g. Windows): render audio-only
|
|
// playback through a webview <audio> element (all video already renders in
|
|
// the webview). Falls back to NullBackend only if the backend can't init.
|
|
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
|
{
|
|
info!("No native audio backend for this platform - using webview <audio> backend");
|
|
match player::WebviewAudioBackend::new(_event_emitter) {
|
|
Ok(backend) => Box::new(backend),
|
|
Err(e) => {
|
|
emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
|
|
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<tauri::Wry> {
|
|
Builder::<tauri::Wry>::new()
|
|
// Throw on error so generated `commands.*` return Promise<T> 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_enter_background_audio,
|
|
player_exit_background_audio,
|
|
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_get_eq_presets,
|
|
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_recover_stream,
|
|
player_report_state,
|
|
player_report_position,
|
|
player_report_media_loaded,
|
|
// Preload commands
|
|
player_local_media_path,
|
|
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,
|
|
delete_downloads_under,
|
|
clear_stale_downloads,
|
|
get_download_storage_stats,
|
|
mark_download_completed,
|
|
mark_download_failed,
|
|
start_download,
|
|
enqueue_download,
|
|
enqueue_video_downloads,
|
|
sync_full_catalog,
|
|
catalog_sync_status,
|
|
set_show_server_catalog,
|
|
resume_queued_downloads,
|
|
get_download_manager_stats,
|
|
set_max_concurrent_downloads,
|
|
get_smart_cache_stats,
|
|
update_smart_cache_config,
|
|
get_smart_cache_config,
|
|
// WiFi-only download gate (UR-053)
|
|
set_network_state,
|
|
get_downloads_allowed,
|
|
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_get_downloaded_libraries,
|
|
repository_get_downloaded_items,
|
|
repository_get_download_disk_usage,
|
|
repository_jray_actors_at,
|
|
repository_get_latest_items,
|
|
repository_get_resume_items,
|
|
repository_get_next_up_episodes,
|
|
repository_get_series_episodes,
|
|
repository_get_series_current_episode,
|
|
repository_clear_watch_history,
|
|
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_audio_only_stream_url_for_video,
|
|
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_favorites,
|
|
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 `<video>`
|
|
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
|
|
/// host provides it, falling back to software decoding otherwise.
|
|
///
|
|
/// All variables are only set if the user has not already exported them, so an
|
|
/// explicit override (e.g. forcing software decode for debugging) is respected.
|
|
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
|
|
/// call at the very top of `run()`.
|
|
#[cfg(target_os = "linux")]
|
|
fn enable_linux_hardware_video_decoding() {
|
|
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
|
|
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
|
|
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
|
|
// codec; unsupported codecs simply fall through to software.
|
|
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
|
|
vampeg2dec:MAX,vavp8dec:MAX";
|
|
|
|
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
|
|
|
|
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
|
|
// this to "0" would force software decoding, so only default it to "1".
|
|
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
|
|
|
|
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
|
|
|
|
log_available_vaapi_decoders();
|
|
}
|
|
|
|
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
|
|
/// video decoders GStreamer can actually load on this host, and log the result so
|
|
/// it is clear at startup whether hardware decoding is genuinely available or
|
|
/// whether playback will fall back to software.
|
|
#[cfg(target_os = "linux")]
|
|
fn log_available_vaapi_decoders() {
|
|
const HW_DECODERS: &[&str] = &[
|
|
"vah264dec",
|
|
"vah265dec",
|
|
"vavp9dec",
|
|
"vaav1dec",
|
|
"vampeg2dec",
|
|
"vavp8dec",
|
|
];
|
|
|
|
let available: Vec<&str> = HW_DECODERS
|
|
.iter()
|
|
.copied()
|
|
.filter(|name| {
|
|
std::process::Command::new("gst-inspect-1.0")
|
|
.arg(name)
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
})
|
|
.collect();
|
|
|
|
if available.is_empty() {
|
|
log::warn!(
|
|
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
|
|
video will use software decoding. Install the GStreamer 'va' plugin \
|
|
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
|
|
);
|
|
} else {
|
|
info!(
|
|
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
|
|
available.join(", ")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn set_env_if_unset(key: &str, value: &str) {
|
|
if std::env::var_os(key).is_none() {
|
|
// SAFETY: called once at startup before any threads that read the
|
|
// environment (WebKitGTK/GStreamer) are spawned.
|
|
std::env::set_var(key, value);
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
// Initialize logger
|
|
env_logger::Builder::from_default_env()
|
|
.filter_level(log::LevelFilter::Info)
|
|
.init();
|
|
|
|
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
|
|
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
|
|
// when available so video transcoding/decoding does not fall back to the CPU.
|
|
// These must be set before WebKitGTK initializes its GStreamer pipeline.
|
|
#[cfg(target_os = "linux")]
|
|
enable_linux_hardware_video_decoding();
|
|
|
|
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
|
|
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
|
|
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
|
|
// startup, which panics on devices (e.g. Android) where that path doesn't exist.
|
|
let builder = specta_builder();
|
|
let invoke_handler = builder.invoke_handler();
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_opener::init())
|
|
.plugin(tauri_plugin_os::init())
|
|
.invoke_handler(invoke_handler)
|
|
.setup(move |app| {
|
|
// Mount tauri-specta events so PlayerStatusEvent can be emitted to and
|
|
// listened for on the frontend via the generated bindings.
|
|
builder.mount_events(app);
|
|
|
|
// Initialize database with proper app data directory
|
|
// Check for test mode environment variable first
|
|
let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
|
let test_path = std::path::PathBuf::from(test_data_dir);
|
|
info!("[INIT] Using test data directory: {:?}", test_path);
|
|
test_path.join("jellytau.db")
|
|
} else {
|
|
app
|
|
.path()
|
|
.app_data_dir()
|
|
.expect("Failed to get app data directory")
|
|
.join("jellytau.db")
|
|
};
|
|
|
|
info!("[INIT] Initializing database at: {:?}", db_path);
|
|
|
|
// Create the directory if it doesn't exist
|
|
if let Some(parent) = db_path.parent() {
|
|
info!("[INIT] Creating database directory: {:?}", parent);
|
|
match std::fs::create_dir_all(parent) {
|
|
Ok(_) => info!("[INIT] Database directory ready"),
|
|
Err(e) => {
|
|
error!("[INIT ERROR] Failed to create database directory: {}", e);
|
|
panic!("Failed to create database directory: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
info!("[INIT] Opening database...");
|
|
let database = match Database::open(&db_path) {
|
|
Ok(db) => {
|
|
info!("[INIT] Database initialized successfully");
|
|
db
|
|
}
|
|
Err(e) => {
|
|
error!("[INIT ERROR] Failed to initialize database: {}", e);
|
|
panic!("Failed to initialize database: {}", e);
|
|
}
|
|
};
|
|
let db_wrapper = DatabaseWrapper(Mutex::new(database));
|
|
app.manage(db_wrapper);
|
|
|
|
// On Android, initialize SecureStorage BEFORE creating CredentialStore
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
info!("[INIT] Initializing Android SecureStorage for credentials...");
|
|
let ctx = ndk_context::android_context();
|
|
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 initialize_secure_storage(&mut env, &context_obj) {
|
|
Ok(()) => {
|
|
info!("[INIT] Android SecureStorage initialized successfully");
|
|
}
|
|
Err(e) => {
|
|
warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize credential store (keyring with encrypted file fallback)
|
|
info!("[INIT] Initializing credential store...");
|
|
let credential_store = CredentialStore::new();
|
|
let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
|
|
app.manage(creds_wrapper);
|
|
|
|
// Create shared reporter and throttler Arc wrappers before backend/controller
|
|
info!("[INIT] Creating shared playback reporting infrastructure...");
|
|
let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
|
|
let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
|
|
|
|
// Create player backend with access to AppHandle for event emission
|
|
info!("[INIT] Creating player backend...");
|
|
let backend = create_player_backend(
|
|
app.handle().clone(),
|
|
playback_reporter.clone(),
|
|
position_throttler.clone(),
|
|
);
|
|
let player_controller = PlayerController::new(
|
|
backend,
|
|
playback_reporter.clone(),
|
|
position_throttler.clone(),
|
|
);
|
|
|
|
// Wire up event emitter for sleep timer and autoplay notifications
|
|
let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
|
|
player_controller.set_event_emitter(event_emitter.clone());
|
|
|
|
let player_arc = Arc::new(TokioMutex::new(player_controller));
|
|
|
|
// On Android, register the player controller for autoplay decisions.
|
|
// The MediaSession (lockscreen) handler is set up later, once the
|
|
// playback mode manager exists, so it can route to remote sessions.
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
player::android::set_player_controller(player_arc.clone());
|
|
}
|
|
|
|
let player_state = PlayerStateWrapper(player_arc.clone());
|
|
app.manage(player_state);
|
|
|
|
// Initialize media session manager
|
|
info!("[INIT] Initializing media session manager...");
|
|
let session_manager = MediaSessionManager::new();
|
|
let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
|
|
app.manage(session_wrapper);
|
|
|
|
// Initialize playback mode manager
|
|
info!("[INIT] Initializing playback mode manager...");
|
|
let jellyfin_client = {
|
|
let player = player_arc.blocking_lock();
|
|
player.jellyfin_client()
|
|
};
|
|
let playback_mode_manager = playback_mode::PlaybackModeManager::new(
|
|
jellyfin_client.clone(),
|
|
player_arc.clone(),
|
|
);
|
|
let playback_mode_arc = Arc::new(playback_mode_manager);
|
|
// Broadcast mode changes so the frontend's mirror store reconciles to
|
|
// this authoritative one (prevents remote/local control desync).
|
|
playback_mode_arc.set_event_emitter(event_emitter.clone());
|
|
let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
|
|
app.manage(playback_mode_wrapper);
|
|
|
|
// Initialize session poller manager for remote session polling
|
|
info!("[INIT] Initializing session poller manager...");
|
|
let session_poller = session_poller::SessionPollerManager::new(
|
|
jellyfin_client,
|
|
playback_mode_arc.clone(),
|
|
);
|
|
session_poller.set_event_emitter(event_emitter.clone());
|
|
// Note: start() is deferred until after the connectivity monitor is
|
|
// created below, so the poller can report reachability from its first
|
|
// poll (it drives offline detection + recovery while the user is idle).
|
|
let session_poller_arc = Arc::new(session_poller);
|
|
let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
|
|
app.manage(session_poller_wrapper);
|
|
|
|
// On Android, set up the MediaSession (lockscreen) handler and the
|
|
// remote volume handler. Both need the playback mode manager so they
|
|
// can route to the active remote session while casting.
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
|
|
let media_handler = Arc::new(MediaSessionHandler {
|
|
player: player_arc.clone(),
|
|
playback_mode: playback_mode_arc.clone(),
|
|
event_emitter: event_emitter.clone(),
|
|
});
|
|
set_media_command_handler(media_handler);
|
|
|
|
info!("[INIT] Setting up remote volume handler for Android...");
|
|
let handler = Arc::new(RemoteVolumeSessionHandler {
|
|
playback_mode: playback_mode_arc.clone(),
|
|
});
|
|
set_remote_volume_handler(handler);
|
|
}
|
|
|
|
// Initialize video settings with defaults
|
|
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
|
app.manage(video_settings);
|
|
|
|
// Initialize thumbnail cache
|
|
info!("[INIT] Initializing thumbnail cache...");
|
|
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
|
std::path::PathBuf::from(test_data_dir)
|
|
} else {
|
|
app
|
|
.path()
|
|
.app_data_dir()
|
|
.expect("Failed to get app data directory")
|
|
};
|
|
let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
|
|
let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
|
|
app.manage(thumbnail_wrapper);
|
|
|
|
// Initialize smart cache for preloading
|
|
info!("[INIT] Initializing smart cache...");
|
|
let smart_cache = SmartCache::new(SmartCacheConfig::default());
|
|
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
|
|
app.manage(smart_cache_wrapper);
|
|
|
|
// Initialize download manager
|
|
info!("[INIT] Initializing download manager...");
|
|
let download_dir = app_data_dir.join("downloads");
|
|
let download_manager = DownloadManager::new(download_dir);
|
|
let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
|
|
app.manage(download_manager_wrapper);
|
|
|
|
// Current network transport, for the WiFi-only download gate (UR-053).
|
|
// Defaults to unmetered ethernet so desktop is never gated; Android
|
|
// overwrites it via set_network_state as soon as the UI starts.
|
|
app.manage(commands::download::NetworkStateWrapper(
|
|
download::network::NetworkStateHandle::new(),
|
|
));
|
|
|
|
// Initialize connectivity monitor
|
|
info!("[INIT] Initializing connectivity monitor...");
|
|
let http_config = HttpConfig::default();
|
|
let http_client = HttpClient::new(http_config)
|
|
.expect("Failed to create HTTP client");
|
|
let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
|
|
connectivity_monitor.set_app_handle(app.handle().clone());
|
|
|
|
// Wire the connectivity reporter into the session poller so its
|
|
// continuous background polls drive reachability (offline detection
|
|
// + recovery) even when the user isn't browsing, then start it.
|
|
session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
|
|
session_poller_arc.start();
|
|
|
|
// Wrap in Arc for sharing with AuthManager
|
|
let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
|
|
let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
|
|
app.manage(connectivity_wrapper);
|
|
|
|
// Initialize auth manager
|
|
info!("[INIT] Initializing auth manager...");
|
|
let auth_http_config = HttpConfig::default();
|
|
let auth_http_client = HttpClient::new(auth_http_config)
|
|
.expect("Failed to create HTTP client for auth");
|
|
let mut auth_manager = AuthManager::new(auth_http_client);
|
|
|
|
// Give auth manager a reference to connectivity monitor
|
|
auth_manager.set_connectivity_monitor(connectivity_arc.clone());
|
|
|
|
let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
|
|
app.manage(auth_manager_wrapper);
|
|
|
|
// Initialize session verifier wrapper (initially empty)
|
|
info!("[INIT] Initializing session verifier wrapper...");
|
|
let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
|
|
app.manage(session_verifier_wrapper);
|
|
|
|
// Initialize repository manager
|
|
info!("[INIT] Initializing repository manager...");
|
|
let repository_manager = commands::RepositoryManager::new();
|
|
let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
|
|
app.manage(repository_manager_wrapper);
|
|
|
|
// Initialize playback reporter wrapper. This MUST share the same Arc
|
|
// the player controller and MPV progress loop report through (created
|
|
// above at `playback_reporter`), otherwise `playback_reporter_init`
|
|
// would populate a dead, parallel Option and no Start/Progress/Stopped
|
|
// would ever reach Jellyfin.
|
|
info!("[INIT] Initializing playback reporter wrapper...");
|
|
let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
|
|
app.manage(playback_reporter_wrapper);
|
|
|
|
// Keep the local search index fresh. Ownership of *when* to re-index
|
|
// sits here rather than in the frontend: it is sync policy over
|
|
// domain data, and a startup-only trigger left a long session
|
|
// searching a stale catalog.
|
|
// TRACES: UR-065 | DR-109, IR-030
|
|
info!("[INIT] Starting background catalog indexer...");
|
|
commands::catalog::spawn_catalog_indexer(app.handle().clone());
|
|
|
|
// Push favourite toggles made while the server was unreachable, on
|
|
// every reconnect. In Rust rather than the frontend so it runs
|
|
// whether or not the screen that made the change is still mounted.
|
|
// TRACES: UR-069 | DR-120
|
|
info!("[INIT] Starting favourites drain...");
|
|
commands::favorites::spawn_favorites_drain(app.handle().clone());
|
|
|
|
info!("[INIT] Application setup completed successfully");
|
|
Ok(())
|
|
})
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running tauri application");
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod specta_bindings {
|
|
/// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
|
|
#[test]
|
|
fn export_typescript_bindings() {
|
|
super::specta_builder()
|
|
.export(
|
|
specta_typescript::Typescript::default()
|
|
.bigint(specta_typescript::BigIntExportBehavior::Number),
|
|
"../src/lib/api/bindings.ts",
|
|
)
|
|
.expect("failed to export typescript bindings");
|
|
}
|
|
}
|