Skip to main content

jellytau_lib/
lib.rs

1#[cfg(target_os = "android")]
2mod android_context;
3mod auth;
4mod commands;
5/// The MediaPlayer conformance suite, exposed for the `player-conformance`
6/// binary. One entry point rather than a public player module tree.
7#[cfg(feature = "conformance")]
8pub mod conformance_runner;
9mod connectivity;
10mod credentials;
11mod domain;
12mod download;
13mod jellyfin;
14mod media_server;
15mod playback_mode;
16mod playback_reporting;
17mod player;
18mod profiles;
19mod repository;
20mod session_poller;
21pub mod settings;
22mod storage;
23mod thumbnail;
24pub mod utils;
25
26#[cfg(target_os = "android")]
27use log::warn;
28use log::{error, info};
29use std::sync::{Arc, Mutex};
30use tauri::{Emitter, Manager};
31use tauri_specta::Builder;
32use tokio::sync::Mutex as TokioMutex;
33
34use auth::AuthManager;
35use commands::{
36    auth_connect_to_server,
37    auth_get_session,
38    // Auth commands
39    auth_initialize,
40    auth_login,
41    auth_logout,
42    auth_reauthenticate,
43    auth_set_session,
44    auth_start_verification,
45    auth_stop_verification,
46    auth_verify_session,
47    calc_progress,
48    cancel_download,
49    catalog_sync_status,
50    clear_stale_downloads,
51    // Connectivity commands
52    connectivity_check_server,
53    connectivity_get_status,
54    connectivity_mark_reachable,
55    connectivity_mark_unreachable,
56    connectivity_set_server_url,
57    connectivity_start_monitoring,
58    connectivity_stop_monitoring,
59    convert_percent_to_volume,
60    convert_ticks_to_seconds,
61    delete_album_downloads,
62    delete_all_downloads,
63    delete_download,
64    delete_downloads_under,
65    // Device commands
66    device_get_id,
67    device_set_id,
68    // Diagnostics commands
69    diagnostics_export,
70    diagnostics_get_info,
71    diagnostics_set_level,
72    download_album,
73    download_item,
74    download_item_and_start,
75    download_season,
76    download_series,
77    download_video,
78    enqueue_download,
79    enqueue_video_downloads,
80    // Conversion commands
81    format_time_seconds,
82    format_time_seconds_long,
83    get_album_affinity_status,
84    get_album_recommendations,
85    get_download_manager_stats,
86    get_download_storage_stats,
87    get_downloads,
88    get_downloads_allowed,
89    get_smart_cache_config,
90    get_smart_cache_stats,
91    image_get_url,
92    is_item_pinned,
93    // Library browsing preferences (hidden folders)
94    library_get_exclusion_candidates,
95    library_get_settings,
96    library_set_settings,
97    lms_create_sync_group,
98    lms_dissolve_sync_group,
99    // LMS multi-room sync group commands
100    lms_get_sync_groups,
101    lms_unsync_player,
102    mark_download_completed,
103    mark_download_failed,
104    media_local_selection,
105    media_local_url,
106    offline_get_items,
107    offline_is_available,
108    offline_search,
109    pause_download,
110    pin_item,
111    playback_mark_played,
112    // Playback mode commands
113    playback_mode_get_current,
114    playback_mode_get_remote_status,
115    playback_mode_is_transferring,
116    playback_mode_set,
117    playback_mode_set_transferring,
118    playback_mode_transfer_to_local,
119    playback_mode_transfer_to_remote,
120    playback_report_progress,
121    playback_report_start,
122    playback_report_stopped,
123    playback_reporter_destroy,
124    // Playback reporting commands
125    playback_reporter_init,
126    // Queue manipulation commands
127    player_add_to_queue,
128    player_add_track_by_id,
129    player_add_tracks_by_ids,
130    player_background_action,
131    player_cancel_autoplay_countdown,
132    player_cancel_sleep_timer,
133    // Jellyfin reporting commands
134    player_configure_jellyfin,
135    player_cycle_repeat,
136    player_disable_jellyfin,
137    player_dismiss_session,
138    player_enter_background_audio,
139    player_exit_background_audio,
140    player_get_audio_settings,
141    player_get_autoplay_settings,
142    player_get_cache_config,
143    player_get_capabilities,
144    player_get_eq_presets,
145    player_get_queue,
146    // Session management commands
147    player_get_session,
148    player_get_sleep_timer,
149    player_get_status,
150    player_get_streaming_qualities,
151    player_get_video_settings,
152    // Preload commands
153    player_local_media_path,
154    player_move_in_queue,
155    player_next,
156    player_on_playback_ended,
157    player_pause,
158    player_play,
159    player_play_album_track,
160    player_play_item,
161    player_play_next_episode,
162    player_play_queue,
163    player_play_tracks,
164    player_preload_upcoming,
165    player_previous,
166    player_recover_stream,
167    player_remove_from_queue,
168    player_report_media_loaded,
169    player_report_position,
170    // HTML5 video state-report commands
171    player_report_state,
172    player_seek,
173    player_seek_video,
174    player_set_audio_settings,
175    player_set_audio_track,
176    player_set_autoplay_settings,
177    player_set_cache_config,
178    // Sleep timer and autoplay commands
179    player_set_sleep_timer,
180    player_set_stream_quality,
181    player_set_subtitle_track,
182    player_set_video_settings,
183    player_set_volume,
184    player_skip_to,
185    player_stop,
186    player_switch_audio_track,
187    player_toggle,
188    player_toggle_mute,
189    player_toggle_shuffle,
190    playlist_add_items,
191    // Playlist commands
192    playlist_create,
193    playlist_delete,
194    playlist_get_items,
195    playlist_move_item,
196    playlist_remove_items,
197    playlist_rename,
198    profiles_add,
199    profiles_get_ask_on_start,
200    profiles_list,
201    profiles_remove,
202    profiles_set_ask_on_start,
203    profiles_set_pin,
204    profiles_startup_target,
205    profiles_unlock,
206    profiles_unlock_with_password,
207    // Remote session control commands
208    remote_play_on_session,
209    remote_send_command,
210    remote_session_seek,
211    remote_session_set_volume,
212    remote_session_toggle_mute,
213    // Repository commands
214    repository_clear_watch_history,
215    repository_create,
216    repository_destroy,
217    repository_get_audio_only_stream_url_for_video,
218    repository_get_audio_stream_url,
219    repository_get_channels,
220    repository_get_download_disk_usage,
221    repository_get_downloaded_items,
222    repository_get_downloaded_libraries,
223    repository_get_favorites,
224    repository_get_genres,
225    repository_get_image_url,
226    repository_get_item,
227    repository_get_items,
228    repository_get_items_by_person,
229    repository_get_latest_items,
230    repository_get_libraries,
231    repository_get_live_tv_channels,
232    repository_get_next_up_episodes,
233    repository_get_person,
234    repository_get_playback_info,
235    repository_get_recently_played_audio,
236    repository_get_rediscover_albums,
237    repository_get_resume_items,
238    repository_get_resume_movies,
239    repository_get_series_current_episode,
240    repository_get_series_episodes,
241    repository_get_series_view,
242    repository_get_similar_items,
243    repository_get_stream_selection,
244    repository_get_subtitle_url,
245    repository_get_video_download_url,
246    repository_get_video_stream_url,
247    repository_jray_actors_at,
248    repository_mark_favorite,
249    repository_open_live_stream,
250    repository_report_playback_progress,
251    repository_report_playback_start,
252    repository_report_playback_stopped,
253    repository_search,
254    repository_unmark_favorite,
255    resume_download,
256    resume_queued_downloads,
257    sessions_poll_now,
258    // Session polling commands
259    sessions_set_polling_hint,
260    set_max_concurrent_downloads,
261    set_network_state,
262    set_show_server_catalog,
263    start_download,
264    // Storage commands
265    storage_delete_server,
266    storage_delete_user,
267    storage_get_access_token,
268    storage_get_active_session,
269    storage_get_active_user,
270    storage_get_item,
271    storage_get_item_people,
272    storage_get_items,
273    // Offline cache commands
274    storage_get_libraries,
275    storage_get_path,
276    storage_get_pending_sync_count,
277    storage_get_person,
278    storage_get_playback_progress,
279    storage_get_security_status,
280    storage_get_series_audio_preference,
281    storage_get_servers,
282    storage_get_size,
283    storage_get_users,
284    storage_init,
285    storage_mark_played,
286    storage_mark_synced,
287    storage_save_item,
288    storage_save_item_people,
289    storage_save_library,
290    // People cache commands
291    storage_save_person,
292    // Series audio preferences
293    storage_save_series_audio_preference,
294    storage_save_server,
295    storage_save_user,
296    storage_search_items,
297    storage_set_active_user,
298    storage_set_watched,
299    storage_toggle_favorite,
300    storage_update_playback_context,
301    storage_update_playback_progress,
302    sync_cleanup_completed,
303    sync_clear_user,
304    sync_full_catalog,
305    sync_get_pending,
306    sync_get_pending_count,
307    sync_mark_completed,
308    sync_mark_failed,
309    sync_mark_processing,
310    sync_process_pending,
311    // Sync queue commands
312    sync_queue_mutation,
313    thumbnail_clear_cache,
314    thumbnail_delete_item,
315    // Thumbnail cache and image commands
316    thumbnail_get_cached,
317    thumbnail_get_stats,
318    thumbnail_save,
319    thumbnail_set_limit,
320    unpin_item,
321    update_smart_cache_config,
322    AuthManagerWrapper,
323    ConnectivityMonitorWrapper,
324    CredentialStoreWrapper,
325    DatabaseWrapper,
326    DownloadManagerWrapper,
327    MediaSessionManagerWrapper,
328    PlaybackModeManagerWrapper,
329    PlaybackReporterWrapper,
330    PlayerStateWrapper,
331    RepositoryManagerWrapper,
332    SessionPollerWrapper,
333    SessionVerifierWrapper,
334    SmartCacheWrapper,
335    ThumbnailCacheWrapper,
336    VideoSettingsWrapper,
337};
338use connectivity::ConnectivityMonitor;
339use credentials::CredentialStore;
340use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
341use download::DownloadManager;
342use jellyfin::{HttpClient, HttpConfig};
343#[cfg(target_os = "android")]
344use playback_mode::PlaybackModeManager;
345// Only the Android MediaSessionHandler resolves lockscreen skips; on other
346// targets this would be an unused import.
347#[cfg(target_os = "android")]
348use player::seek::{resolve_skip_action, SkipAction};
349use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
350// NullBackend is used both for platforms without a native backend AND as a graceful
351// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can
352// still launch (browse library, manage downloads, see an error) instead of crashing.
353use player::NullBackend;
354
355#[cfg(target_os = "linux")]
356use player::MpvBackend;
357use settings::VideoSettings;
358use storage::Database;
359use thumbnail::{CacheConfig as ThumbnailCacheConfig, ThumbnailCache};
360
361#[cfg(target_os = "android")]
362use credentials::initialize_secure_storage;
363
364#[cfg(target_os = "android")]
365use player::ExoPlayerBackend;
366
367#[cfg(target_os = "android")]
368use player::{
369    set_media_command_handler, set_remote_volume_handler, MediaCommandHandler, RemoteVolumeHandler,
370};
371
372/// Handler for media commands from Android MediaSession (lockscreen/notification controls).
373///
374/// Routes commands from the system media controls to the right place depending on
375/// playback mode: in local mode it drives the local `PlayerController`; in remote
376/// (cast) mode it forwards transport commands to the remote Jellyfin session so
377/// the lockscreen can control whatever is casting. Stop while casting requests a
378/// disconnect back to local playback.
379#[cfg(target_os = "android")]
380struct MediaSessionHandler {
381    player: Arc<TokioMutex<PlayerController>>,
382    playback_mode: Arc<PlaybackModeManager>,
383    event_emitter: Arc<TauriEventEmitter>,
384}
385
386#[cfg(target_os = "android")]
387impl MediaSessionHandler {
388    /// Forward a transport command to the active remote Jellyfin session.
389    ///
390    /// Runs async on the Tauri runtime because JNI callbacks arrive on arbitrary
391    /// threads without a Tokio context.
392    fn handle_remote_command(&self, command: &str, session_id: String) {
393        use crate::player::{PlayerEventEmitter, PlayerStatusEvent};
394
395        // Stop while casting means "disconnect and resume locally". The frontend
396        // owns the remote->local transfer (it reloads the item locally), so we
397        // just signal intent.
398        if command == "stop" {
399            self.event_emitter
400                .emit(PlayerStatusEvent::RemoteDisconnectRequested);
401            return;
402        }
403
404        let jellyfin_client = {
405            let player = self.player.blocking_lock();
406            player.jellyfin_client()
407        };
408        let command = command.to_string();
409
410        tauri::async_runtime::spawn(async move {
411            let client = {
412                let guard = match jellyfin_client.lock() {
413                    Ok(g) => g,
414                    Err(e) => {
415                        error!("[MediaSession] Failed to lock Jellyfin client: {}", e);
416                        return;
417                    }
418                };
419                match guard.as_ref() {
420                    Some(c) => c.clone(),
421                    None => {
422                        warn!("[MediaSession] No Jellyfin client for remote command");
423                        return;
424                    }
425                }
426            };
427
428            // Map lockscreen transport commands onto Jellyfin session commands.
429            let result = match command.as_str() {
430                "play" => client.send_session_command(session_id, "Unpause").await,
431                "pause" => client.send_session_command(session_id, "Pause").await,
432                "next" => client.send_session_command(session_id, "NextTrack").await,
433                "previous" => {
434                    client
435                        .send_session_command(session_id, "PreviousTrack")
436                        .await
437                }
438                cmd if cmd.starts_with("seek:") => match cmd[5..].parse::<f64>() {
439                    Ok(seconds) => {
440                        let ticks = (seconds * 10_000_000.0) as i64;
441                        client.session_seek(session_id, ticks).await
442                    }
443                    Err(_) => {
444                        warn!("[MediaSession] Bad seek command: {}", command);
445                        Ok(())
446                    }
447                },
448                _ => {
449                    warn!("[MediaSession] Unknown remote command: {}", command);
450                    Ok(())
451                }
452            };
453
454            if let Err(e) = result {
455                error!("[MediaSession] Remote command '{}' failed: {}", command, e);
456            }
457        });
458    }
459
460    /// Drive the local player for a transport command.
461    fn handle_local_command(&self, command: &str) {
462        // A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
463        // whole episode — and resolving it during a background-audio handoff means
464        // re-opening the stream, which is async. So it runs on the runtime and,
465        // critically, is handled *before* the blocking lock below: taking that
466        // guard and then spawning a task that waits for the same mutex would
467        // deadlock the media session. (DR-159)
468        if let Some(raw) = command.strip_prefix("seek:") {
469            match raw.parse::<f64>() {
470                Ok(position) => {
471                    let player = self.player.clone();
472                    tokio::spawn(async move {
473                        let controller = player.lock().await;
474                        if let Err(e) = controller.seek_absolute(position).await {
475                            error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
476                        }
477                    });
478                }
479                Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
480            }
481            return;
482        }
483
484        // Skip means different things depending on what is actually playing, so
485        // the decision belongs here rather than in the Kotlin that drew the
486        // button: music advances the queue, while a video whose audio is running
487        // through a background-audio handoff scrubs instead (UR-040). Routed
488        // through the same spawn-and-seek path as "seek:" above, because
489        // `seek_absolute` rebuilds the stream during a handoff and must not run
490        // under the blocking lock (DR-159).
491        //
492        // TRACES: UR-040, UR-006 | DR-201
493        if command == "next" || command == "previous" {
494            let is_next = command == "next";
495            let player = self.player.clone();
496            tokio::spawn(async move {
497                let controller = player.lock().await;
498                let action = resolve_skip_action(
499                    is_next,
500                    controller.is_background_audio_active(),
501                    controller.position(),
502                    controller.duration(),
503                );
504                let label = if is_next { "next" } else { "previous" };
505                let result: Result<(), String> = match action {
506                    SkipAction::Advance => if is_next {
507                        controller.next()
508                    } else {
509                        controller.previous()
510                    }
511                    .map_err(|e| e.to_string()),
512                    SkipAction::SeekTo(position) => {
513                        info!(
514                            "[MediaSession] Background audio: '{}' scrubs to {:.1}s",
515                            label, position
516                        );
517                        controller.seek_absolute(position).await
518                    }
519                };
520                if let Err(e) = result {
521                    error!("[MediaSession] Skip '{}' failed: {}", label, e);
522                }
523            });
524            return;
525        }
526
527        // Use blocking_lock since this is called from a non-async JNI callback
528        let controller = self.player.blocking_lock();
529
530        let result = match command {
531            "play" => controller.play(),
532            "pause" => controller.pause(),
533            "stop" => controller.stop(),
534            _ => {
535                warn!("[MediaSession] Unknown command: {}", command);
536                Ok(())
537            }
538        };
539
540        if let Err(e) = result {
541            error!("[MediaSession] Command '{}' failed: {}", command, e);
542        }
543    }
544}
545
546#[cfg(target_os = "android")]
547impl MediaCommandHandler for MediaSessionHandler {
548    fn on_command(&self, command: &str) {
549        match self.playback_mode.get_mode() {
550            playback_mode::PlaybackMode::Remote { session_id } => {
551                self.handle_remote_command(command, session_id);
552            }
553            _ => self.handle_local_command(command),
554        }
555    }
556}
557
558/// Handler for remote volume changes from Android volume buttons when in remote playback mode.
559///
560/// Routes volume commands to the Jellyfin session via the playback mode manager.
561#[cfg(target_os = "android")]
562struct RemoteVolumeSessionHandler {
563    playback_mode: Arc<PlaybackModeManager>,
564}
565
566#[cfg(target_os = "android")]
567impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
568    fn on_remote_volume_change(&self, command: &str, volume: i32) {
569        log::info!("[RemoteVolume] Command: {}, Volume: {}", command, volume);
570
571        // Send the volume command to the remote session asynchronously
572        let playback_mode = Arc::clone(&self.playback_mode);
573        let command_str = command.to_string();
574
575        // Use tauri::async_runtime::spawn instead of tokio::spawn
576        // JNI callbacks happen on arbitrary threads without a Tokio runtime
577        log::info!("[RemoteVolume] Spawning async task to send volume command...");
578        tauri::async_runtime::spawn(async move {
579            log::info!("[RemoteVolume] Async task started, calling send_remote_volume_command...");
580            match playback_mode
581                .send_remote_volume_command(&command_str, volume)
582                .await
583            {
584                Ok(_) => log::info!("[RemoteVolume] Volume command completed successfully"),
585                Err(e) => log::error!("[RemoteVolume] Failed to send volume command: {}", e),
586            }
587            log::info!("[RemoteVolume] Async task completed");
588        });
589        log::info!("[RemoteVolume] Async task spawned, returning from JNI callback");
590    }
591}
592
593/// Payload emitted to the frontend when a native player backend fails to
594/// initialize and the app falls back to a no-op backend.
595#[derive(Clone, serde::Serialize)]
596struct BackendInitError {
597    platform: &'static str,
598    backend: &'static str,
599    message: String,
600}
601
602/// Log a backend-initialization failure and notify the frontend, so the UI can
603/// surface "playback unavailable" instead of the app hard-crashing.
604fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
605    error!(
606        "[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
607        backend, message
608    );
609    let _ = app_handle.emit(
610        "backend-init-failed",
611        BackendInitError {
612            platform: std::env::consts::OS,
613            backend,
614            message,
615        },
616    );
617}
618
619/// Create the appropriate player backend for the current platform.
620// playback_reporter/position_throttler are consumed only by the native audio
621// backends (mpv/exo); on platforms using the webview audio backend they're unused.
622#[cfg_attr(
623    not(any(target_os = "linux", target_os = "android")),
624    allow(unused_variables)
625)]
626fn create_player_backend(
627    app_handle: tauri::AppHandle,
628    playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
629    position_throttler: Arc<playback_reporting::EventThrottler>,
630) -> Box<dyn PlayerBackend> {
631    let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone()));
632
633    #[cfg(target_os = "android")]
634    {
635        info!("Android platform detected - initializing ExoPlayer backend");
636
637        // Same precondition as the credential path: ndk_context must be
638        // populated before it is read, and nothing outside this crate populates
639        // it any more. Idempotent, so it does not matter which of the two runs
640        // first. TRACES: UR-012 | DR-223
641        if let Err(e) = crate::android_context::ensure_initialized() {
642            log::error!("[INIT] Android context unavailable for the player: {e}");
643        }
644
645        // Get the Android context via ndk-context
646        let ctx = ndk_context::android_context();
647
648        // Get JavaVM and create JNI environment
649        let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
650
651        match vm {
652            Ok(java_vm) => {
653                match java_vm.attach_current_thread() {
654                    Ok(mut env) => {
655                        let context_obj =
656                            unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
657
658                        match ExoPlayerBackend::new(
659                            &mut env,
660                            &context_obj,
661                            _event_emitter.clone(),
662                            playback_reporter.clone(),
663                            position_throttler.clone(),
664                        ) {
665                            Ok(backend) => {
666                                info!("Successfully initialized ExoPlayer backend for Android");
667                                return Box::new(backend);
668                            }
669                            Err(e) => {
670                                // Degrade gracefully instead of crashing the app.
671                                emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
672                                return Box::new(NullBackend::new());
673                            }
674                        }
675                    }
676                    Err(e) => {
677                        emit_backend_init_failed(
678                            &app_handle,
679                            "exoplayer",
680                            format!("attach JNI thread failed: {}", e),
681                        );
682                        return Box::new(NullBackend::new());
683                    }
684                }
685            }
686            Err(e) => {
687                emit_backend_init_failed(
688                    &app_handle,
689                    "exoplayer",
690                    format!("create JavaVM failed: {}", e),
691                );
692                return Box::new(NullBackend::new());
693            }
694        }
695    }
696
697    // For Linux, use MPV backend for audio playback
698    #[cfg(target_os = "linux")]
699    {
700        info!("Linux platform detected - initializing MPV backend for audio");
701        match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
702            Ok(backend) => {
703                info!("Successfully initialized MPV backend for Linux");
704                Box::new(backend)
705            }
706            Err(e) => {
707                error!("\n========================================");
708                error!("FATAL ERROR: Failed to initialize MPV backend");
709                error!("========================================");
710                error!("Error: {}", e);
711                error!("\nCommon causes:");
712                error!("  1. MPV is not installed");
713                error!("     Solution: Install MPV using your package manager");
714                error!("     - Arch/CachyOS: sudo pacman -S mpv");
715                error!("     - Ubuntu/Debian: sudo apt install mpv libmpv-dev");
716                error!("     - Fedora: sudo dnf install mpv mpv-libs-devel");
717                error!("\n  2. MPV version mismatch (app was built with different libmpv version)");
718                error!("     Solution: Rebuild the application");
719                error!("     - cd src-tauri && cargo clean && cargo build --release");
720                error!("\n  3. Audio system not working");
721                error!("     Solution: Verify audio works with: pactl info");
722                error!("\nAudio playback will NOT work until this is fixed.");
723                error!("========================================\n");
724
725                // Degrade gracefully: launch with a no-op backend so the user can
726                // still browse the library and manage downloads, and the frontend
727                // can show a "playback unavailable" notice via this event.
728                emit_backend_init_failed(&app_handle, "mpv", e.to_string());
729                Box::new(NullBackend::new())
730            }
731        }
732    }
733
734    // Platforms with no native audio backend (e.g. Windows): render audio-only
735    // playback through a webview <audio> element (all video already renders in
736    // the webview). Falls back to NullBackend only if the backend can't init.
737    #[cfg(not(any(target_os = "linux", target_os = "android")))]
738    {
739        info!("No native audio backend for this platform - using webview <audio> backend");
740        match player::WebviewAudioBackend::new(_event_emitter) {
741            Ok(backend) => Box::new(backend),
742            Err(e) => {
743                emit_backend_init_failed(&app_handle, "webview-audio", e.to_string());
744                Box::new(NullBackend::new())
745            }
746        }
747    }
748}
749
750/// Construct the tauri-specta command builder. Shared by `run()` and the
751/// bindings-export test so the TypeScript bindings always match the handler.
752/// What the engine built for this platform can do.
753///
754/// Declared per engine, not per category. ExoPlayer speaks HLS and can seek a
755/// server-side transcode in place; mpv cannot, because its HLS demuxer will not
756/// make the server produce segments from a new offset. Grouping them as "native
757/// engines" gets that backwards — being native is not the property that
758/// matters, speaking HLS is — and treating a category as a proxy for an ability
759/// is exactly the inference DR-246 removed.
760///
761/// TRACES: UR-081 | DR-246
762fn engine_capabilities() -> crate::player::media_player::Capabilities {
763    #[cfg(target_os = "android")]
764    {
765        crate::player::media_player::Capabilities::exoplayer()
766    }
767    #[cfg(not(target_os = "android"))]
768    {
769        crate::player::media_player::Capabilities::mpv()
770    }
771}
772
773fn specta_builder() -> Builder<tauri::Wry> {
774    Builder::<tauri::Wry>::new()
775        // Throw on error so generated `commands.*` return Promise<T> and throw,
776        // matching the existing frontend's invoke() try/catch convention.
777        .error_handling(tauri_specta::ErrorHandlingMode::Throw)
778        .events(tauri_specta::collect_events![
779            crate::player::events::PlayerStatusEvent
780        ])
781        .commands(tauri_specta::collect_commands![
782            // Player commands
783            player_play_item,
784            player_background_action,
785            player_enter_background_audio,
786            player_exit_background_audio,
787            player_play_queue,
788            player_play_album_track,
789            player_play_tracks,
790            player_play,
791            player_pause,
792            player_toggle,
793            player_stop,
794            player_next,
795            player_previous,
796            player_seek,
797            player_seek_video,
798            player_set_volume,
799            player_toggle_mute,
800            player_set_audio_track,
801            player_switch_audio_track,
802            player_set_subtitle_track,
803            player_toggle_shuffle,
804            player_cycle_repeat,
805            player_get_status,
806            player_get_queue,
807            player_get_capabilities,
808            player_add_to_queue,
809            player_add_track_by_id,
810            player_add_tracks_by_ids,
811            player_remove_from_queue,
812            player_move_in_queue,
813            player_skip_to,
814            player_set_audio_settings,
815            player_get_audio_settings,
816            player_get_eq_presets,
817            player_set_video_settings,
818            player_get_video_settings,
819            player_get_streaming_qualities,
820            player_set_stream_quality,
821            // Sleep timer and autoplay commands
822            player_set_sleep_timer,
823            player_cancel_sleep_timer,
824            player_get_sleep_timer,
825            player_get_autoplay_settings,
826            player_set_autoplay_settings,
827            player_cancel_autoplay_countdown,
828            player_play_next_episode,
829            player_on_playback_ended,
830            player_recover_stream,
831            player_report_state,
832            player_report_position,
833            player_report_media_loaded,
834            // Preload commands
835            player_local_media_path,
836            player_preload_upcoming,
837            player_set_cache_config,
838            player_get_cache_config,
839            // Jellyfin reporting commands
840            player_configure_jellyfin,
841            player_disable_jellyfin,
842            // Session management commands
843            player_get_session,
844            player_dismiss_session,
845            // Remote session control commands
846            remote_play_on_session,
847            remote_send_command,
848            remote_session_seek,
849            remote_session_set_volume,
850            remote_session_toggle_mute,
851            // LMS multi-room sync group commands
852            lms_get_sync_groups,
853            lms_create_sync_group,
854            lms_unsync_player,
855            lms_dissolve_sync_group,
856            // Session polling commands
857            sessions_set_polling_hint,
858            sessions_poll_now,
859            // Playback mode commands
860            playback_mode_get_current,
861            playback_mode_set,
862            playback_mode_is_transferring,
863            playback_mode_transfer_to_remote,
864            playback_mode_get_remote_status,
865            playback_mode_transfer_to_local,
866            playback_mode_set_transferring,
867            // Playback reporting commands
868            playback_reporter_init,
869            playback_reporter_destroy,
870            playback_report_start,
871            playback_report_progress,
872            playback_report_stopped,
873            playback_mark_played,
874            // Auth commands
875            auth_initialize,
876            auth_connect_to_server,
877            auth_login,
878            auth_verify_session,
879            auth_logout,
880            auth_get_session,
881            auth_set_session,
882            auth_start_verification,
883            auth_stop_verification,
884            auth_reauthenticate,
885            // Device commands
886            device_get_id,
887            device_set_id,
888            // Connectivity commands
889            connectivity_check_server,
890            connectivity_set_server_url,
891            connectivity_get_status,
892            connectivity_start_monitoring,
893            connectivity_stop_monitoring,
894            connectivity_mark_reachable,
895            connectivity_mark_unreachable,
896            // Storage commands
897            storage_init,
898            storage_get_path,
899            storage_get_size,
900            storage_get_security_status,
901            storage_save_server,
902            storage_get_servers,
903            storage_delete_server,
904            storage_save_user,
905            storage_get_users,
906            storage_set_active_user,
907            storage_get_active_user,
908            storage_get_active_session,
909            storage_get_access_token,
910            storage_delete_user,
911            // Playback progress commands
912            storage_update_playback_progress,
913            storage_update_playback_context,
914            storage_mark_played,
915            storage_set_watched,
916            storage_get_playback_progress,
917            storage_mark_synced,
918            storage_toggle_favorite,
919            // Download commands
920            download_item,
921            download_item_and_start,
922            download_album,
923            download_video,
924            download_series,
925            download_season,
926            get_downloads,
927            pause_download,
928            resume_download,
929            cancel_download,
930            delete_download,
931            delete_all_downloads,
932            delete_album_downloads,
933            delete_downloads_under,
934            clear_stale_downloads,
935            get_download_storage_stats,
936            mark_download_completed,
937            mark_download_failed,
938            media_local_url,
939            media_local_selection,
940            start_download,
941            enqueue_download,
942            enqueue_video_downloads,
943            sync_full_catalog,
944            catalog_sync_status,
945            set_show_server_catalog,
946            // Library browsing preferences (UR-076 / DR-209)
947            library_get_settings,
948            library_set_settings,
949            library_get_exclusion_candidates,
950            resume_queued_downloads,
951            get_download_manager_stats,
952            set_max_concurrent_downloads,
953            get_smart_cache_stats,
954            update_smart_cache_config,
955            get_smart_cache_config,
956            // WiFi-only download gate (UR-053)
957            set_network_state,
958            get_downloads_allowed,
959            get_album_recommendations,
960            get_album_affinity_status,
961            // Pinning commands
962            pin_item,
963            unpin_item,
964            is_item_pinned,
965            // Offline commands
966            offline_is_available,
967            offline_get_items,
968            offline_search,
969            // Offline cache commands
970            storage_get_libraries,
971            storage_get_items,
972            storage_get_item,
973            storage_search_items,
974            storage_save_library,
975            storage_save_item,
976            storage_get_pending_sync_count,
977            // Sync queue commands
978            sync_queue_mutation,
979            sync_get_pending,
980            sync_mark_processing,
981            sync_mark_completed,
982            sync_mark_failed,
983            sync_get_pending_count,
984            sync_process_pending,
985            sync_cleanup_completed,
986            sync_clear_user,
987            // Thumbnail cache and image commands
988            thumbnail_get_cached,
989            thumbnail_save,
990            thumbnail_get_stats,
991            thumbnail_set_limit,
992            thumbnail_clear_cache,
993            thumbnail_delete_item,
994            image_get_url,
995            // People cache commands
996            storage_save_person,
997            storage_get_person,
998            storage_save_item_people,
999            storage_get_item_people,
1000            // Series audio preferences
1001            storage_save_series_audio_preference,
1002            storage_get_series_audio_preference,
1003            // Repository commands
1004            repository_create,
1005            repository_destroy,
1006            repository_get_libraries,
1007            repository_get_items,
1008            repository_get_item,
1009            repository_get_downloaded_libraries,
1010            repository_get_downloaded_items,
1011            repository_get_download_disk_usage,
1012            repository_jray_actors_at,
1013            repository_get_latest_items,
1014            repository_get_resume_items,
1015            repository_get_next_up_episodes,
1016            repository_get_series_episodes,
1017            repository_get_series_current_episode,
1018            repository_get_series_view,
1019            repository_clear_watch_history,
1020            repository_get_recently_played_audio,
1021            repository_get_resume_movies,
1022            repository_get_rediscover_albums,
1023            repository_get_genres,
1024            repository_search,
1025            repository_get_playback_info,
1026            repository_get_video_stream_url,
1027            repository_get_stream_selection,
1028            repository_get_audio_stream_url,
1029            repository_get_audio_only_stream_url_for_video,
1030            repository_get_live_tv_channels,
1031            repository_get_channels,
1032            repository_open_live_stream,
1033            repository_report_playback_start,
1034            repository_report_playback_progress,
1035            repository_report_playback_stopped,
1036            repository_get_image_url,
1037            repository_mark_favorite,
1038            repository_unmark_favorite,
1039            repository_get_favorites,
1040            repository_get_person,
1041            repository_get_items_by_person,
1042            repository_get_similar_items,
1043            repository_get_subtitle_url,
1044            repository_get_video_download_url,
1045            // Playlist commands
1046            playlist_create,
1047            playlist_delete,
1048            playlist_rename,
1049            playlist_get_items,
1050            playlist_add_items,
1051            profiles_add,
1052            profiles_get_ask_on_start,
1053            profiles_list,
1054            profiles_remove,
1055            profiles_set_ask_on_start,
1056            profiles_set_pin,
1057            profiles_startup_target,
1058            profiles_unlock,
1059            profiles_unlock_with_password,
1060            playlist_remove_items,
1061            playlist_move_item,
1062            // Diagnostics commands
1063            // TRACES: UR-078 | DR-218
1064            diagnostics_get_info,
1065            diagnostics_set_level,
1066            diagnostics_export,
1067            // Conversion commands
1068            format_time_seconds,
1069            format_time_seconds_long,
1070            convert_ticks_to_seconds,
1071            calc_progress,
1072            convert_percent_to_volume,
1073        ])
1074}
1075
1076/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
1077/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
1078/// host provides it, falling back to software decoding otherwise.
1079///
1080/// All variables are only set if the user has not already exported them, so an
1081/// explicit override (e.g. forcing software decode for debugging) is respected.
1082/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
1083/// call at the very top of `run()`.
1084#[cfg(target_os = "linux")]
1085fn enable_linux_hardware_video_decoding() {
1086    // Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
1087    // `va` plugin) so GStreamer selects them ahead of the software decoders. The
1088    // `MAX` rank wins decoder autoplugging when the hardware/driver supports the
1089    // codec; unsupported codecs simply fall through to software.
1090    let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
1091                          vampeg2dec:MAX,vavp8dec:MAX";
1092
1093    set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
1094
1095    // Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
1096    // this to "0" would force software decoding, so only default it to "1".
1097    set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
1098
1099    info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
1100
1101    log_available_vaapi_decoders();
1102}
1103
1104/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
1105/// video decoders GStreamer can actually load on this host, and log the result so
1106/// it is clear at startup whether hardware decoding is genuinely available or
1107/// whether playback will fall back to software.
1108#[cfg(target_os = "linux")]
1109fn log_available_vaapi_decoders() {
1110    const HW_DECODERS: &[&str] = &[
1111        "vah264dec",
1112        "vah265dec",
1113        "vavp9dec",
1114        "vaav1dec",
1115        "vampeg2dec",
1116        "vavp8dec",
1117    ];
1118
1119    let available: Vec<&str> = HW_DECODERS
1120        .iter()
1121        .copied()
1122        .filter(|name| {
1123            std::process::Command::new("gst-inspect-1.0")
1124                .arg(name)
1125                .stdout(std::process::Stdio::null())
1126                .stderr(std::process::Stdio::null())
1127                .status()
1128                .map(|s| s.success())
1129                .unwrap_or(false)
1130        })
1131        .collect();
1132
1133    if available.is_empty() {
1134        log::warn!(
1135            "[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
1136             video will use software decoding. Install the GStreamer 'va' plugin \
1137             (gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
1138        );
1139    } else {
1140        info!(
1141            "[INIT] VAAPI hardware video decoders available to GStreamer: {}",
1142            available.join(", ")
1143        );
1144    }
1145}
1146
1147#[cfg(target_os = "linux")]
1148fn set_env_if_unset(key: &str, value: &str) {
1149    if std::env::var_os(key).is_none() {
1150        // SAFETY: called once at startup before any threads that read the
1151        // environment (WebKitGTK/GStreamer) are spawned.
1152        std::env::set_var(key, value);
1153    }
1154}
1155
1156/// Cached thumbnails are handed to the webview as asset-protocol URLs by
1157/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
1158/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
1159/// origin when the `protocol-asset` cargo feature is compiled in *and*
1160/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`. Both are
1161/// required together: with either missing the URL resolves to nothing and the
1162/// webview reports `NETWORK_NO_SOURCE`, which is how offline video came to fail
1163/// silently.
1164///
1165/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
1166/// moved to the loopback media server in DR-137, so `imageCache` is the only
1167/// remaining `convertFileSrc` caller and the database and the encrypted-token
1168/// fallback file — which share that root — never need to be readable by the
1169/// webview. Widen it only if something other than thumbnails starts resolving
1170/// through `convertFileSrc` again.
1171///
1172/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
1173/// Build the logging plugin.
1174///
1175/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
1176/// was invisible to anyone who launched from a desktop icon, and worse than
1177/// useless on Android: stdout is not logcat, so the Rust backend produced no
1178/// visible output at all on the platform carrying the hardest bugs in this
1179/// project's history (the autoplay deadlock, the truncated-stream restart, the
1180/// background-audio stall). tauri-plugin-log routes to logcat there for free.
1181///
1182/// Three decisions worth keeping:
1183///
1184/// * **Every line goes through `redact` first.** A credential must never reach
1185///   disk, not merely be stripped later when a bundle is exported — a file on
1186///   the device is already the disclosure.
1187/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
1188///   long-running session fill a phone. One rotation keeps yesterday's evidence
1189///   without unbounded growth.
1190/// * **The level is read from disk.** Someone reproducing a bug needs debug
1191///   logging to survive the restart that reproduces it.
1192///
1193/// TRACES: UR-078 | DR-218
1194fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
1195    use tauri_plugin_log::{Target, TargetKind};
1196
1197    let mut targets = vec![
1198        Target::new(TargetKind::Stdout),
1199        Target::new(TargetKind::LogDir {
1200            file_name: Some("jellytau".to_string()),
1201        }),
1202    ];
1203
1204    // Rust lines in the webview console, so a developer sees both halves of the
1205    // app in one place. Dev only -- in a release build this would ship backend
1206    // logging into a console the user can open.
1207    if cfg!(debug_assertions) {
1208        targets.push(Target::new(TargetKind::Webview));
1209    }
1210
1211    tauri_plugin_log::Builder::new()
1212        .targets(targets)
1213        .level(log::LevelFilter::Info)
1214        .max_file_size(5 * 1024 * 1024)
1215        .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
1216        .format(|out, message, record| {
1217            out.finish(format_args!(
1218                "[{}][{}] {}",
1219                record.level(),
1220                record.target(),
1221                crate::utils::diagnostics::redact(&message.to_string())
1222            ))
1223        })
1224        .build()
1225}
1226
1227#[cfg_attr(mobile, tauri::mobile_entry_point)]
1228pub fn run() {
1229    // Crash capture before anything else, so a panic during startup is recorded
1230    // rather than vanishing with the process.
1231    //
1232    // TRACES: UR-078 | DR-218
1233    crate::utils::diagnostics::install_panic_hook();
1234
1235    // On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
1236    // GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
1237    // when available so video transcoding/decoding does not fall back to the CPU.
1238    // These must be set before WebKitGTK initializes its GStreamer pipeline.
1239    #[cfg(target_os = "linux")]
1240    enable_linux_hardware_video_decoding();
1241
1242    // NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
1243    // test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
1244    // `.export()` here would try to write `../src/lib/api/bindings.ts` at app
1245    // startup, which panics on devices (e.g. Android) where that path doesn't exist.
1246    let builder = specta_builder();
1247    let invoke_handler = builder.invoke_handler();
1248
1249    tauri::Builder::default()
1250        .plugin(build_log_plugin())
1251        .plugin(tauri_plugin_opener::init())
1252        .plugin(tauri_plugin_os::init())
1253        .invoke_handler(invoke_handler)
1254        .setup(move |app| {
1255            // Mount tauri-specta events so PlayerStatusEvent can be emitted to and
1256            // listened for on the frontend via the generated bindings.
1257            builder.mount_events(app);
1258
1259
1260            // In-app update, desktop only.
1261            //
1262            // Registered here rather than in the builder chain above because a
1263            // `#[cfg]` attribute cannot be attached to one link of a method
1264            // chain -- this block is the shape Tauri's own docs use.
1265            //
1266            // Android is excluded on purpose: tauri-plugin-updater cannot
1267            // replace an installed APK, and the frontend offers the releases
1268            // page there instead.
1269            //
1270            // Re-apply the log level the user last chose. Without this the
1271            // picker would only affect the running session -- and the whole
1272            // point of a persisted level is that somebody reproducing a bug
1273            // keeps debug logging across the restart that reproduces it.
1274            //
1275            // TRACES: UR-078 | DR-218
1276            if let Ok(config_dir) = app.path().app_config_dir() {
1277                if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
1278                    log::set_max_level(level);
1279                    log::info!("[DIAG] restored log level from settings: {level}");
1280                }
1281            }
1282
1283            // TRACES: UR-077 | DR-217
1284            #[cfg(desktop)]
1285            {
1286                app.handle()
1287                    .plugin(tauri_plugin_updater::Builder::new().build())?;
1288                app.handle().plugin(tauri_plugin_process::init())?;
1289            }
1290
1291            // Initialize database with proper app data directory
1292            // Check for test mode environment variable first
1293            let db_path = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
1294                let test_path = std::path::PathBuf::from(test_data_dir);
1295                info!("[INIT] Using test data directory: {:?}", test_path);
1296                test_path.join("jellytau.db")
1297            } else {
1298                app
1299                    .path()
1300                    .app_data_dir()
1301                    .expect("Failed to get app data directory")
1302                    .join("jellytau.db")
1303            };
1304
1305            info!("[INIT] Initializing database at: {:?}", db_path);
1306
1307            // Create the directory if it doesn't exist
1308            if let Some(parent) = db_path.parent() {
1309                info!("[INIT] Creating database directory: {:?}", parent);
1310                match std::fs::create_dir_all(parent) {
1311                    Ok(_) => info!("[INIT] Database directory ready"),
1312                    Err(e) => {
1313                        error!("[INIT ERROR] Failed to create database directory: {}", e);
1314                        panic!("Failed to create database directory: {}", e);
1315                    }
1316                }
1317            }
1318
1319            info!("[INIT] Opening database...");
1320            let database = match Database::open(&db_path) {
1321                Ok(db) => {
1322                    info!("[INIT] Database initialized successfully");
1323                    db
1324                }
1325                Err(e) => {
1326                    error!("[INIT ERROR] Failed to initialize database: {}", e);
1327                    panic!("Failed to initialize database: {}", e);
1328                }
1329            };
1330            let db_wrapper = DatabaseWrapper(Mutex::new(database));
1331            app.manage(db_wrapper);
1332
1333            // On Android, initialize SecureStorage BEFORE creating CredentialStore
1334            #[cfg(target_os = "android")]
1335            {
1336                // Publish the JavaVM and Application into ndk_context first.
1337                //
1338                // Everything below reads that global. tao used to populate it
1339                // and stopped doing so in 0.35 (Tauri 2.11), at which point the
1340                // first read here aborted the process on launch. See
1341                // android_context.rs. A failure is logged rather than fatal:
1342                // credentials then fall back to the encrypted-file path, which
1343                // is a supported degraded mode -- unlike aborting.
1344                //
1345                // TRACES: UR-012 | DR-223
1346                if let Err(e) = crate::android_context::ensure_initialized() {
1347                    log::error!("[INIT] Android context unavailable: {e}");
1348                    log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
1349                }
1350
1351                info!("[INIT] Initializing Android SecureStorage for credentials...");
1352                let ctx = ndk_context::android_context();
1353                let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) };
1354
1355                match vm {
1356                    Ok(java_vm) => {
1357                        match java_vm.attach_current_thread() {
1358                            Ok(mut env) => {
1359                                let context_obj = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) };
1360
1361                                match initialize_secure_storage(&mut env, &context_obj) {
1362                                    Ok(()) => {
1363                                        info!("[INIT] Android SecureStorage initialized successfully");
1364                                    }
1365                                    Err(e) => {
1366                                        warn!("[INIT WARNING] Failed to initialize SecureStorage: {}. Credentials will use encrypted file fallback.", e);
1367                                    }
1368                                }
1369                            }
1370                            Err(e) => {
1371                                warn!("[INIT WARNING] Failed to attach JNI thread: {}. Credentials will use encrypted file fallback.", e);
1372                            }
1373                        }
1374                    }
1375                    Err(e) => {
1376                        warn!("[INIT WARNING] Failed to create JavaVM: {}. Credentials will use encrypted file fallback.", e);
1377                    }
1378                }
1379            }
1380
1381            // Initialize credential store (keyring with encrypted file fallback)
1382            info!("[INIT] Initializing credential store...");
1383            let credential_store = CredentialStore::new();
1384            let creds_wrapper = CredentialStoreWrapper(Mutex::new(credential_store));
1385            app.manage(creds_wrapper);
1386
1387            // Create shared reporter and throttler Arc wrappers before backend/controller
1388            info!("[INIT] Creating shared playback reporting infrastructure...");
1389            let playback_reporter = Arc::new(tokio::sync::Mutex::new(None));
1390            let position_throttler = Arc::new(playback_reporting::EventThrottler::new());
1391
1392            // Create player backend with access to AppHandle for event emission
1393            info!("[INIT] Creating player backend...");
1394            let backend = create_player_backend(
1395                app.handle().clone(),
1396                playback_reporter.clone(),
1397                position_throttler.clone(),
1398            );
1399            // Attached *after* the backend exists: the mpv handle is registered
1400            // during its construction, and doing this in the order the code
1401            // used to read produced "no mpv handle" every time — the surface was
1402            // built before there was anything to draw from.
1403            // Native video surface: put a GL area under Tauri's webview so mpv
1404            // can draw beneath the controls (UR-080 / DR-231).
1405            //
1406            // 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
1407            // first click. `tauri-runtime-wry`'s undecorated-resizing handler
1408            // walks a hard-coded two-hop path on every button press in the
1409            // webview:
1410            //
1411            //     webview.parent()          // "This one should be GtkBox"
1412            //            .parent()          // ...and this one the GtkWindow
1413            //            .downcast::<gtk::Window>().unwrap()
1414            //
1415            // Wrapping the webview in a GtkOverlay makes that chain
1416            // webview → GtkOverlay → GtkBox, the downcast fails, and because the
1417            // panic is non-unwinding it aborts the process. The decoration check
1418            // that would otherwise make this handler inert runs *after* the
1419            // unwrap, so no window configuration avoids it.
1420            //
1421            // This is the "only place Tauri-specific behaviour could still bite"
1422            // that the spike named as the untested half of G1. It bites. The
1423            // surface attaches perfectly and then dies on interaction, so
1424            // "attached successfully" in the log is not the gate — a click is.
1425            //
1426            // Kept behind an env var rather than deleted so the next attempt has
1427            // something to iterate on:  JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
1428            //
1429            // TRACES: UR-080 | DR-231
1430            #[cfg(target_os = "linux")]
1431            if crate::player::native_video::enabled() {
1432                use tauri::Manager;
1433                log::warn!(
1434                    "[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
1435                     video surface (mpv drawn behind the webview, no reparenting)"
1436                );
1437                if let Some(window) = app.get_webview_window("main") {
1438                    match window.default_vbox() {
1439                        Ok(vbox) => {
1440                            let handle = crate::player::mpv_backend::registered_handle();
1441                            if crate::player::video_surface::attach(&vbox, handle) {
1442                                info!("[INIT] Native video surface attached");
1443                            } else {
1444                                log::warn!("[INIT] Native video surface unavailable");
1445                            }
1446                        }
1447                        Err(e) => {
1448                            log::warn!("[INIT] No GTK vbox for the main window: {e}")
1449                        }
1450                    }
1451                }
1452            }
1453
1454            // Every engine reaches the controller through the one contract.
1455            // `LegacyPlayer` carries the not-yet-ported ones across unchanged,
1456            // so this port swaps a seam rather than four implementations.
1457            // TRACES: UR-081 | DR-245
1458            let player_controller = PlayerController::new(
1459                Box::new(crate::player::LegacyPlayer::new(
1460                    backend,
1461                    engine_capabilities(),
1462                )),
1463                playback_reporter.clone(),
1464                position_throttler.clone(),
1465            );
1466
1467            // Wire up event emitter for sleep timer and autoplay notifications
1468            let event_emitter = Arc::new(TauriEventEmitter::new(app.handle().clone()));
1469            player_controller.set_event_emitter(event_emitter.clone());
1470
1471            let player_arc = Arc::new(TokioMutex::new(player_controller));
1472
1473            // On Android, register the player controller for autoplay decisions.
1474            // The MediaSession (lockscreen) handler is set up later, once the
1475            // playback mode manager exists, so it can route to remote sessions.
1476            #[cfg(target_os = "android")]
1477            {
1478                player::android::set_player_controller(player_arc.clone());
1479            }
1480
1481            let player_state = PlayerStateWrapper(player_arc.clone());
1482            app.manage(player_state);
1483
1484            // Initialize media session manager
1485            info!("[INIT] Initializing media session manager...");
1486            let session_manager = MediaSessionManager::new();
1487            let session_wrapper = MediaSessionManagerWrapper(Mutex::new(session_manager));
1488            app.manage(session_wrapper);
1489
1490            // Initialize playback mode manager
1491            info!("[INIT] Initializing playback mode manager...");
1492            let jellyfin_client = {
1493                let player = player_arc.blocking_lock();
1494                player.jellyfin_client()
1495            };
1496            let playback_mode_manager = playback_mode::PlaybackModeManager::new(
1497                jellyfin_client.clone(),
1498                player_arc.clone(),
1499            );
1500            let playback_mode_arc = Arc::new(playback_mode_manager);
1501            // Broadcast mode changes so the frontend's mirror store reconciles to
1502            // this authoritative one (prevents remote/local control desync).
1503            playback_mode_arc.set_event_emitter(event_emitter.clone());
1504            let playback_mode_wrapper = PlaybackModeManagerWrapper(playback_mode_arc.clone());
1505            app.manage(playback_mode_wrapper);
1506
1507            // Initialize session poller manager for remote session polling
1508            info!("[INIT] Initializing session poller manager...");
1509            let session_poller = session_poller::SessionPollerManager::new(
1510                jellyfin_client,
1511                playback_mode_arc.clone(),
1512            );
1513            session_poller.set_event_emitter(event_emitter.clone());
1514            // Note: start() is deferred until after the connectivity monitor is
1515            // created below, so the poller can report reachability from its first
1516            // poll (it drives offline detection + recovery while the user is idle).
1517            let session_poller_arc = Arc::new(session_poller);
1518            let session_poller_wrapper = SessionPollerWrapper(session_poller_arc.clone());
1519            app.manage(session_poller_wrapper);
1520
1521            // On Android, set up the MediaSession (lockscreen) handler and the
1522            // remote volume handler. Both need the playback mode manager so they
1523            // can route to the active remote session while casting.
1524            #[cfg(target_os = "android")]
1525            {
1526                info!("[INIT] Setting up MediaSession handler for lockscreen controls...");
1527                let media_handler = Arc::new(MediaSessionHandler {
1528                    player: player_arc.clone(),
1529                    playback_mode: playback_mode_arc.clone(),
1530                    event_emitter: event_emitter.clone(),
1531                });
1532                set_media_command_handler(media_handler);
1533
1534                info!("[INIT] Setting up remote volume handler for Android...");
1535                let handler = Arc::new(RemoteVolumeSessionHandler {
1536                    playback_mode: playback_mode_arc.clone(),
1537                });
1538                set_remote_volume_handler(handler);
1539            }
1540
1541            // Initialize video settings with defaults
1542            let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
1543            app.manage(video_settings);
1544
1545            // Restore the persisted streaming bandwidth ceiling. Deferred to the
1546            // async runtime because the read is async, and ordered after the
1547            // wrapper above because it writes into it. Until it lands, streams
1548            // are uncapped — the pre-existing behaviour — and no playback can
1549            // have started this early anyway (login happens after setup).
1550            //
1551            // TRACES: UR-074 | DR-162
1552            {
1553                let handle = app.handle().clone();
1554                tauri::async_runtime::spawn(async move {
1555                    crate::commands::restore_streaming_quality(&handle).await;
1556                });
1557            }
1558
1559            // Restore the folders the user hid from browsing, for the same
1560            // reason and in the same way. Until it lands nothing is hidden —
1561            // the pre-existing behaviour — and no query can have run this early.
1562            //
1563            // TRACES: UR-076 | DR-209
1564            {
1565                let handle = app.handle().clone();
1566                tauri::async_runtime::spawn(async move {
1567                    crate::commands::restore_library_settings(&handle).await;
1568                });
1569            }
1570
1571            // Initialize thumbnail cache
1572            info!("[INIT] Initializing thumbnail cache...");
1573            let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
1574                std::path::PathBuf::from(test_data_dir)
1575            } else {
1576                app
1577                    .path()
1578                    .app_data_dir()
1579                    .expect("Failed to get app data directory")
1580            };
1581            let thumbnail_cache = ThumbnailCache::new(app_data_dir.clone(), ThumbnailCacheConfig::default());
1582            let thumbnail_wrapper = ThumbnailCacheWrapper(Arc::new(thumbnail_cache));
1583            app.manage(thumbnail_wrapper);
1584
1585            // Initialize smart cache for preloading
1586            info!("[INIT] Initializing smart cache...");
1587            let smart_cache = SmartCache::new(SmartCacheConfig::default());
1588            let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
1589            app.manage(smart_cache_wrapper);
1590
1591            // Serve downloaded media over loopback HTTP. The webview cannot
1592            // stream a large file through the asset protocol (see media_server),
1593            // so local playback resolves its URL from here instead.
1594            // TRACES: UR-071 | DR-137
1595            info!("[INIT] Starting local media server...");
1596            let media_server = match media_server::start(app_data_dir.clone()) {
1597                Ok(s) => Some(s),
1598                Err(e) => {
1599                    // Not fatal: streaming still works, and the command reports
1600                    // a clear error if local playback is attempted.
1601                    error!("[INIT ERROR] Local media server failed to start: {}", e);
1602                    None
1603                }
1604            };
1605            app.manage(media_server::MediaServerWrapper(media_server));
1606
1607            // Initialize download manager
1608            info!("[INIT] Initializing download manager...");
1609            let download_dir = app_data_dir.join("downloads");
1610            let download_manager = DownloadManager::new(download_dir);
1611            let download_manager_wrapper = DownloadManagerWrapper(Mutex::new(download_manager));
1612            app.manage(download_manager_wrapper);
1613
1614            // Current network transport, for the WiFi-only download gate (UR-053).
1615            // Defaults to unmetered ethernet so desktop is never gated; Android
1616            // overwrites it via set_network_state as soon as the UI starts.
1617            app.manage(commands::download::NetworkStateWrapper(
1618                download::network::NetworkStateHandle::new(),
1619            ));
1620
1621            // Initialize connectivity monitor
1622            info!("[INIT] Initializing connectivity monitor...");
1623            let http_config = HttpConfig::default();
1624            let http_client = HttpClient::new(http_config)
1625                .expect("Failed to create HTTP client");
1626            let mut connectivity_monitor = ConnectivityMonitor::new(http_client);
1627            connectivity_monitor.set_app_handle(app.handle().clone());
1628
1629            // Wire the connectivity reporter into the session poller so its
1630            // continuous background polls drive reachability (offline detection
1631            // + recovery) even when the user isn't browsing, then start it.
1632            session_poller_arc.set_connectivity_reporter(connectivity_monitor.reporter());
1633            session_poller_arc.start();
1634
1635            // Wrap in Arc for sharing with AuthManager
1636            let connectivity_arc = Arc::new(tokio::sync::Mutex::new(connectivity_monitor));
1637            let connectivity_wrapper = ConnectivityMonitorWrapper(connectivity_arc.clone());
1638            app.manage(connectivity_wrapper);
1639
1640            // Initialize auth manager
1641            info!("[INIT] Initializing auth manager...");
1642            let auth_http_config = HttpConfig::default();
1643            let auth_http_client = HttpClient::new(auth_http_config)
1644                .expect("Failed to create HTTP client for auth");
1645            let mut auth_manager = AuthManager::new(auth_http_client);
1646
1647            // Give auth manager a reference to connectivity monitor
1648            auth_manager.set_connectivity_monitor(connectivity_arc.clone());
1649
1650            let auth_manager_wrapper = AuthManagerWrapper(Arc::new(auth_manager));
1651            app.manage(auth_manager_wrapper);
1652
1653            // Initialize session verifier wrapper (initially empty)
1654            info!("[INIT] Initializing session verifier wrapper...");
1655            let session_verifier_wrapper = SessionVerifierWrapper(Arc::new(tokio::sync::Mutex::new(None)));
1656            app.manage(session_verifier_wrapper);
1657
1658            // Initialize repository manager
1659            info!("[INIT] Initializing repository manager...");
1660            let repository_manager = commands::RepositoryManager::new();
1661            let repository_manager_wrapper = RepositoryManagerWrapper(repository_manager);
1662            app.manage(repository_manager_wrapper);
1663
1664            // Initialize playback reporter wrapper. This MUST share the same Arc
1665            // the player controller and MPV progress loop report through (created
1666            // above at `playback_reporter`), otherwise `playback_reporter_init`
1667            // would populate a dead, parallel Option and no Start/Progress/Stopped
1668            // would ever reach Jellyfin.
1669            info!("[INIT] Initializing playback reporter wrapper...");
1670            let playback_reporter_wrapper = PlaybackReporterWrapper(playback_reporter.clone());
1671            app.manage(playback_reporter_wrapper);
1672
1673            // Keep the local search index fresh. Ownership of *when* to re-index
1674            // sits here rather than in the frontend: it is sync policy over
1675            // domain data, and a startup-only trigger left a long session
1676            // searching a stale catalog.
1677            // TRACES: UR-065 | DR-109, IR-030
1678            info!("[INIT] Starting background catalog indexer...");
1679            commands::catalog::spawn_catalog_indexer(app.handle().clone());
1680
1681            // Push favourite toggles made while the server was unreachable, on
1682            // every reconnect. In Rust rather than the frontend so it runs
1683            // whether or not the screen that made the change is still mounted.
1684            // TRACES: UR-069 | DR-120
1685            info!("[INIT] Starting favourites drain...");
1686            commands::favorites::spawn_favorites_drain(app.handle().clone());
1687
1688            // Push playback reports queued while the server was unreachable.
1689            // Without this the `sync_queue` rows the reporter writes offline
1690            // are never sent and the offline banner's count only grows.
1691            // TRACES: UR-025, UR-002 | DR-131
1692            info!("[INIT] Starting sync-queue drain...");
1693            commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
1694
1695            info!("[INIT] Application setup completed successfully");
1696            Ok(())
1697        })
1698        .run(tauri::generate_context!())
1699        .expect("error while running tauri application");
1700}
1701
1702#[cfg(test)]
1703mod specta_bindings {
1704    /// Generates `src/lib/api/bindings.ts`. Run with `cargo test export_typescript_bindings`.
1705    #[test]
1706    fn export_typescript_bindings() {
1707        super::specta_builder()
1708            .export(
1709                specta_typescript::Typescript::default()
1710                    .bigint(specta_typescript::BigIntExportBehavior::Number),
1711                "../src/lib/api/bindings.ts",
1712            )
1713            .expect("failed to export typescript bindings");
1714    }
1715}