mpv has never decoded a video frame in this app: the backend sets `video: no` unconditionally, because Linux video has always been the webview's job and decoding it twice would burn a core for a picture nobody sees. The render path built in the previous commit therefore had nothing to draw. With native video on, mpv is configured for video *and* `vo=libmpv` — the render API only works through that output, and the default would try to open a window of its own. Set at construction, because mpv resolves its video output when it initialises and flipping the property later does not re-open one. The flag lives in `player::native_video`, read by all three things that must agree: the backend (configured before anything plays), the surface (nothing to draw otherwise), and `get_player_status` (which tells the frontend whether to use a `<video>` element — two decoders on one stream would fight over the audio). A function rather than three `env::var` checks, because a capability answered in several places is a capability whose answers drift: four separate bugs this cycle came from exactly that shape. Also fixes an ordering bug the first run exposed. The surface was attached in `setup` before the player backend was constructed, and the mpv handle is registered *during* that construction — so it found nothing every time and logged "no mpv handle". Attaching after the backend exists is the whole fix. Confirmed on a real run: mpv accepts `vo=libmpv`, the GL context comes up on Tauri's vbox, and `mpv_render_context_create` succeeds — which also proves the libepoxy data-symbol handling is right, since a wrong `get_proc_address` would have taken SIGSEGV on the first GL call rather than returning cleanly. No frame has reached the screen yet. The webview is still opaque, so it will paint over anything drawn beneath it until transparency is set up. Security: quick-xml 0.38.4 carried RUSTSEC-2026-0194 (quadratic parse on duplicate attribute names) and RUSTSEC-2026-0195 (unbounded namespace allocation, memory-exhaustion DoS). `cargo deny` gates CI on advisories, so this would have failed the next release. Fixed by plist 1.8 -> 1.10, which pulls quick-xml 0.41. Licences, bans and sources still pass. UT-216 pins the flag's parsing: absent, empty, `0`, `no` and anything unrecognised all mean off. A half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle. Also removes a wall-clock timer from the waitForRepository late-arrival test, which failed once under load. The assertion is about ordering, so it now publishes on a microtask and cannot race.
1663 lines
66 KiB
Rust
1663 lines
66 KiB
Rust
#[cfg(target_os = "android")]
|
|
mod android_context;
|
|
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,
|
|
// Diagnostics commands
|
|
diagnostics_export,
|
|
diagnostics_get_info,
|
|
diagnostics_set_level,
|
|
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,
|
|
// Library browsing preferences (hidden folders)
|
|
library_get_exclusion_candidates,
|
|
library_get_settings,
|
|
library_set_settings,
|
|
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_selection,
|
|
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_background_action,
|
|
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_streaming_qualities,
|
|
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_stream_quality,
|
|
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_stream_selection,
|
|
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_set_watched,
|
|
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;
|
|
// Only the Android MediaSessionHandler resolves lockscreen skips; on other
|
|
// targets this would be an unused import.
|
|
#[cfg(target_os = "android")]
|
|
use player::seek::{resolve_skip_action, SkipAction};
|
|
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) {
|
|
// A lockscreen scrub is an ABSOLUTE position — the scrubber shows the
|
|
// whole episode — and resolving it during a background-audio handoff means
|
|
// re-opening the stream, which is async. So it runs on the runtime and,
|
|
// critically, is handled *before* the blocking lock below: taking that
|
|
// guard and then spawning a task that waits for the same mutex would
|
|
// deadlock the media session. (DR-159)
|
|
if let Some(raw) = command.strip_prefix("seek:") {
|
|
match raw.parse::<f64>() {
|
|
Ok(position) => {
|
|
let player = self.player.clone();
|
|
tokio::spawn(async move {
|
|
let controller = player.lock().await;
|
|
if let Err(e) = controller.seek_absolute(position).await {
|
|
error!("[MediaSession] Seek to {:.1}s failed: {}", position, e);
|
|
}
|
|
});
|
|
}
|
|
Err(_) => warn!("[MediaSession] Bad seek command: {}", command),
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Skip means different things depending on what is actually playing, so
|
|
// the decision belongs here rather than in the Kotlin that drew the
|
|
// button: music advances the queue, while a video whose audio is running
|
|
// through a background-audio handoff scrubs instead (UR-040). Routed
|
|
// through the same spawn-and-seek path as "seek:" above, because
|
|
// `seek_absolute` rebuilds the stream during a handoff and must not run
|
|
// under the blocking lock (DR-159).
|
|
//
|
|
// TRACES: UR-040, UR-006 | DR-201
|
|
if command == "next" || command == "previous" {
|
|
let is_next = command == "next";
|
|
let player = self.player.clone();
|
|
tokio::spawn(async move {
|
|
let controller = player.lock().await;
|
|
let action = resolve_skip_action(
|
|
is_next,
|
|
controller.is_background_audio_active(),
|
|
controller.position(),
|
|
controller.duration(),
|
|
);
|
|
let label = if is_next { "next" } else { "previous" };
|
|
let result: Result<(), String> = match action {
|
|
SkipAction::Advance => if is_next {
|
|
controller.next()
|
|
} else {
|
|
controller.previous()
|
|
}
|
|
.map_err(|e| e.to_string()),
|
|
SkipAction::SeekTo(position) => {
|
|
info!(
|
|
"[MediaSession] Background audio: '{}' scrubs to {:.1}s",
|
|
label, position
|
|
);
|
|
controller.seek_absolute(position).await
|
|
}
|
|
};
|
|
if let Err(e) = result {
|
|
error!("[MediaSession] Skip '{}' failed: {}", label, e);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 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(),
|
|
"stop" => controller.stop(),
|
|
_ => {
|
|
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");
|
|
|
|
// Same precondition as the credential path: ndk_context must be
|
|
// populated before it is read, and nothing outside this crate populates
|
|
// it any more. Idempotent, so it does not matter which of the two runs
|
|
// first. TRACES: UR-012 | DR-223
|
|
if let Err(e) = crate::android_context::ensure_initialized() {
|
|
log::error!("[INIT] Android context unavailable for the player: {e}");
|
|
}
|
|
|
|
// 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");
|
|
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());
|
|
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_background_action,
|
|
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,
|
|
player_get_streaming_qualities,
|
|
player_set_stream_quality,
|
|
// 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_set_watched,
|
|
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,
|
|
media_local_selection,
|
|
start_download,
|
|
enqueue_download,
|
|
enqueue_video_downloads,
|
|
sync_full_catalog,
|
|
catalog_sync_status,
|
|
set_show_server_catalog,
|
|
// Library browsing preferences (UR-076 / DR-209)
|
|
library_get_settings,
|
|
library_set_settings,
|
|
library_get_exclusion_candidates,
|
|
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_stream_selection,
|
|
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,
|
|
// Diagnostics commands
|
|
// TRACES: UR-078 | DR-218
|
|
diagnostics_get_info,
|
|
diagnostics_set_level,
|
|
diagnostics_export,
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
|
|
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
|
|
/// `http://asset.localhost/…` on Windows/Android). 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`. 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.
|
|
///
|
|
/// The scope is `$APPDATA/thumbnails/**`, not the storage root: downloaded media
|
|
/// moved to the loopback media server in DR-137, so `imageCache` is the only
|
|
/// remaining `convertFileSrc` caller and the database and the encrypted-token
|
|
/// fallback file — which share that root — never need to be readable by the
|
|
/// webview. Widen it only if something other than thumbnails starts resolving
|
|
/// through `convertFileSrc` again.
|
|
///
|
|
/// TRACES: UR-012, UR-071 | DR-134, DR-137, DR-198
|
|
/// Build the logging plugin.
|
|
///
|
|
/// Replaces the previous `env_logger` init, which wrote to **stdout only**. That
|
|
/// was invisible to anyone who launched from a desktop icon, and worse than
|
|
/// useless on Android: stdout is not logcat, so the Rust backend produced no
|
|
/// visible output at all on the platform carrying the hardest bugs in this
|
|
/// project's history (the autoplay deadlock, the truncated-stream restart, the
|
|
/// background-audio stall). tauri-plugin-log routes to logcat there for free.
|
|
///
|
|
/// Three decisions worth keeping:
|
|
///
|
|
/// * **Every line goes through `redact` first.** A credential must never reach
|
|
/// disk, not merely be stripped later when a bundle is exported — a file on
|
|
/// the device is already the disclosure.
|
|
/// * **The size cap is deliberate.** `RotationStrategy::KeepAll` would let a
|
|
/// long-running session fill a phone. One rotation keeps yesterday's evidence
|
|
/// without unbounded growth.
|
|
/// * **The level is read from disk.** Someone reproducing a bug needs debug
|
|
/// logging to survive the restart that reproduces it.
|
|
///
|
|
/// TRACES: UR-078 | DR-218
|
|
fn build_log_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
|
use tauri_plugin_log::{Target, TargetKind};
|
|
|
|
let mut targets = vec![
|
|
Target::new(TargetKind::Stdout),
|
|
Target::new(TargetKind::LogDir {
|
|
file_name: Some("jellytau".to_string()),
|
|
}),
|
|
];
|
|
|
|
// Rust lines in the webview console, so a developer sees both halves of the
|
|
// app in one place. Dev only -- in a release build this would ship backend
|
|
// logging into a console the user can open.
|
|
if cfg!(debug_assertions) {
|
|
targets.push(Target::new(TargetKind::Webview));
|
|
}
|
|
|
|
tauri_plugin_log::Builder::new()
|
|
.targets(targets)
|
|
.level(log::LevelFilter::Info)
|
|
.max_file_size(5 * 1024 * 1024)
|
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
|
|
.format(|out, message, record| {
|
|
out.finish(format_args!(
|
|
"[{}][{}] {}",
|
|
record.level(),
|
|
record.target(),
|
|
crate::utils::diagnostics::redact(&message.to_string())
|
|
))
|
|
})
|
|
.build()
|
|
}
|
|
|
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
|
pub fn run() {
|
|
// Crash capture before anything else, so a panic during startup is recorded
|
|
// rather than vanishing with the process.
|
|
//
|
|
// TRACES: UR-078 | DR-218
|
|
crate::utils::diagnostics::install_panic_hook();
|
|
|
|
// 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(build_log_plugin())
|
|
.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);
|
|
|
|
|
|
// In-app update, desktop only.
|
|
//
|
|
// Registered here rather than in the builder chain above because a
|
|
// `#[cfg]` attribute cannot be attached to one link of a method
|
|
// chain -- this block is the shape Tauri's own docs use.
|
|
//
|
|
// Android is excluded on purpose: tauri-plugin-updater cannot
|
|
// replace an installed APK, and the frontend offers the releases
|
|
// page there instead.
|
|
//
|
|
// Re-apply the log level the user last chose. Without this the
|
|
// picker would only affect the running session -- and the whole
|
|
// point of a persisted level is that somebody reproducing a bug
|
|
// keeps debug logging across the restart that reproduces it.
|
|
//
|
|
// TRACES: UR-078 | DR-218
|
|
if let Ok(config_dir) = app.path().app_config_dir() {
|
|
if let Some(level) = crate::commands::diagnostics::stored_level(&config_dir) {
|
|
log::set_max_level(level);
|
|
log::info!("[DIAG] restored log level from settings: {level}");
|
|
}
|
|
}
|
|
|
|
// TRACES: UR-077 | DR-217
|
|
#[cfg(desktop)]
|
|
{
|
|
app.handle()
|
|
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
|
app.handle().plugin(tauri_plugin_process::init())?;
|
|
}
|
|
|
|
// 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")]
|
|
{
|
|
// Publish the JavaVM and Application into ndk_context first.
|
|
//
|
|
// Everything below reads that global. tao used to populate it
|
|
// and stopped doing so in 0.35 (Tauri 2.11), at which point the
|
|
// first read here aborted the process on launch. See
|
|
// android_context.rs. A failure is logged rather than fatal:
|
|
// credentials then fall back to the encrypted-file path, which
|
|
// is a supported degraded mode -- unlike aborting.
|
|
//
|
|
// TRACES: UR-012 | DR-223
|
|
if let Err(e) = crate::android_context::ensure_initialized() {
|
|
log::error!("[INIT] Android context unavailable: {e}");
|
|
log::error!("[INIT] Secure credential storage will fall back to the encrypted file.");
|
|
}
|
|
|
|
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(),
|
|
);
|
|
// Attached *after* the backend exists: the mpv handle is registered
|
|
// during its construction, and doing this in the order the code
|
|
// used to read produced "no mpv handle" every time — the surface was
|
|
// built before there was anything to draw from.
|
|
// Native video surface: put a GL area under Tauri's webview so mpv
|
|
// can draw beneath the controls (UR-080 / DR-231).
|
|
//
|
|
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
|
|
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
|
|
// walks a hard-coded two-hop path on every button press in the
|
|
// webview:
|
|
//
|
|
// webview.parent() // "This one should be GtkBox"
|
|
// .parent() // ...and this one the GtkWindow
|
|
// .downcast::<gtk::Window>().unwrap()
|
|
//
|
|
// Wrapping the webview in a GtkOverlay makes that chain
|
|
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
|
|
// panic is non-unwinding it aborts the process. The decoration check
|
|
// that would otherwise make this handler inert runs *after* the
|
|
// unwrap, so no window configuration avoids it.
|
|
//
|
|
// This is the "only place Tauri-specific behaviour could still bite"
|
|
// that the spike named as the untested half of G1. It bites. The
|
|
// surface attaches perfectly and then dies on interaction, so
|
|
// "attached successfully" in the log is not the gate — a click is.
|
|
//
|
|
// Kept behind an env var rather than deleted so the next attempt has
|
|
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
|
//
|
|
// TRACES: UR-080 | DR-231
|
|
#[cfg(target_os = "linux")]
|
|
if crate::player::native_video::enabled() {
|
|
use tauri::Manager;
|
|
log::warn!(
|
|
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
|
|
video surface (mpv drawn behind the webview, no reparenting)"
|
|
);
|
|
if let Some(window) = app.get_webview_window("main") {
|
|
match window.default_vbox() {
|
|
Ok(vbox) => {
|
|
let handle = crate::player::mpv_backend::registered_handle();
|
|
if crate::player::video_surface::attach(&vbox, handle) {
|
|
info!("[INIT] Native video surface attached");
|
|
} else {
|
|
log::warn!("[INIT] Native video surface unavailable");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("[INIT] No GTK vbox for the main window: {e}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Restore the persisted streaming bandwidth ceiling. Deferred to the
|
|
// async runtime because the read is async, and ordered after the
|
|
// wrapper above because it writes into it. Until it lands, streams
|
|
// are uncapped — the pre-existing behaviour — and no playback can
|
|
// have started this early anyway (login happens after setup).
|
|
//
|
|
// TRACES: UR-074 | DR-162
|
|
{
|
|
let handle = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
crate::commands::restore_streaming_quality(&handle).await;
|
|
});
|
|
}
|
|
|
|
// Restore the folders the user hid from browsing, for the same
|
|
// reason and in the same way. Until it lands nothing is hidden —
|
|
// the pre-existing behaviour — and no query can have run this early.
|
|
//
|
|
// TRACES: UR-076 | DR-209
|
|
{
|
|
let handle = app.handle().clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
crate::commands::restore_library_settings(&handle).await;
|
|
});
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
}
|