Files
jellytau/src-tauri/src/lib.rs
T
dtourolleandClaude Opus 5 e144e62b31 feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend
overrides threw that answer away, so ExoPlayer's video path had never actually
run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off).

The flag is a suppressor, never a promoter: off forces HTML5 even where Rust
says native, so an in-progress spike cannot ship as the default, but it can
never select native where Rust reported HTML5 — Linux cannot composite behind
WebKitGTK, and promoting there would be a black screen.

Two blockers the spec did not anticipate, both in code assumed to be merely
unreachable rather than broken:

- `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was
  always null and `autoAttachSurface()` bailed. The SurfaceView was created and
  wired to ExoPlayer but never added to the view hierarchy — video would have
  decoded to a surface that was never on screen, whatever the webview did.
  This also revives PiP on the video path, which gated on the same flag.
- `createAdapter()` was not the real gate; it is never called in production.
  The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped
  the native backend `player_play_item` had just started. Both sites now route
  through `createAdapter()`.

Compositing needs two independent opaque layers cleared, not one. Clearing only
the page leaves the WebView widget opaque — audio over a black picture, exactly
the symptom the old INTERIM comment described. `videoSurface.ts` toggles both:
the widget background and window drawable from Kotlin, the page backgrounds via
a `data-native-video` attribute keyed by app.css. Transparency lives in
`tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the
playback session so the launcher never shows through the rest of the app.

Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the
player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on
rotation. The mini-player transition remains unverified on device.

Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a
second copy of the Rust cfg gate free to drift from it. `player_get_capabilities`
now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates.

Tests: adapter selection covers the full matrix, including the regression guard
that the flag off beats Rust. Written first and confirmed failing (2 of 7) before
the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00

1373 lines
53 KiB
Rust

mod auth;
mod commands;
mod connectivity;
mod credentials;
mod domain;
mod download;
mod jellyfin;
mod media_server;
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,
media_local_url,
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_capabilities,
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_process_pending,
// 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_get_capabilities,
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,
media_local_url,
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_process_pending,
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);
}
}
/// Downloaded media and cached thumbnails are handed to the webview as
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
/// origin when the `protocol-asset` cargo feature is compiled in *and*
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also
/// scopes it to `$APPDATA/**` — the storage root holding the database,
/// `downloads/` and the thumbnail cache. Both are required together: with either
/// missing the URL resolves to nothing and the webview reports
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
///
/// TRACES: UR-071 | DR-134
#[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);
// Serve downloaded media over loopback HTTP. The webview cannot
// stream a large file through the asset protocol (see media_server),
// so local playback resolves its URL from here instead.
// TRACES: UR-071 | DR-137
info!("[INIT] Starting local media server...");
let media_server = match media_server::start(app_data_dir.clone()) {
Ok(s) => Some(s),
Err(e) => {
// Not fatal: streaming still works, and the command reports
// a clear error if local playback is attempted.
error!("[INIT ERROR] Local media server failed to start: {}", e);
None
}
};
app.manage(media_server::MediaServerWrapper(media_server));
// 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());
// Push playback reports queued while the server was unreachable.
// Without this the `sync_queue` rows the reporter writes offline
// are never sent and the offline banner's count only grows.
// TRACES: UR-025, UR-002 | DR-131
info!("[INIT] Starting sync-queue drain...");
commands::sync_drain::spawn_sync_queue_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");
}
}