//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001 // Cohesive command clusters live in their own submodules and are re-exported so // the command names remain at `commands::player::*` (invoke_handler unchanged). mod queue; mod remote; mod session; mod settings; mod timers; pub use queue::*; pub use remote::*; pub use session::*; pub use settings::*; pub use timers::*; use crate::utils::lock::MutexSafe; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tauri::State; use tokio::sync::Mutex as TokioMutex; use super::DatabaseWrapper; use crate::download::cache::{CacheConfig, SmartCache}; use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::player::{ determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy, }; use crate::repository::{ types::{GetItemsOptions, ImageOptions, ImageType}, MediaRepository, }; use crate::settings::VideoSettings; use crate::storage::db_service::{DatabaseService, Query, QueryParam}; /// SmartCache wrapper for Tauri state management pub struct SmartCacheWrapper(pub Mutex); /// Player state wrapper for Tauri. /// /// Uses Arc to allow sharing with the MediaSession handler on Android /// for lockscreen control integration. /// /// @req: UR-005 - Control media playback /// @req: DR-001 - Player state machine pub struct PlayerStateWrapper(pub Arc>); /// Media session manager wrapper for Tauri state management /// /// @req: DR-009 - Audio player UI (mini player, full screen) pub struct MediaSessionManagerWrapper(pub Mutex); /// Video settings state wrapper for Tauri /// /// @req: DR-048 - Video settings (auto-play toggle, countdown duration) pub struct VideoSettingsWrapper(pub Mutex); /// Response for player state queries #[derive(specta::Type, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlayerStatus { pub state: PlayerState, pub position: f64, pub duration: Option, pub volume: f32, pub muted: bool, pub shuffle: bool, pub repeat: RepeatMode, /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback) pub backend: VideoBackend, /// Whether frontend should render HTML5 video element pub use_html5_element: bool, // Merged fields (prefer remote session when available) /// Media item from either local queue or remote session pub merged_media: Option, /// Playing state from either local player or remote session pub merged_is_playing: bool, /// Volume from either local player or remote session (0-1 normalized) pub merged_volume: f32, } /// Lightweight media item for merged playback state /// Converts from both local MediaItem and remote NowPlayingItem #[derive(specta::Type, Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct MergedMediaItem { pub id: String, pub title: String, pub artist: Option, pub album: Option, pub album_id: Option, pub duration: Option, pub primary_image_tag: Option, /// Neutral image identifier — replaces `primary_image_tag` (same value). pub image_id: Option, pub media_type: String, } // Convert from local MediaItem impl From<&crate::player::MediaItem> for MergedMediaItem { fn from(item: &crate::player::MediaItem) -> Self { Self { id: item.id.clone(), title: item.title.clone(), artist: item.artist.clone(), album: item.album.clone(), album_id: item.album_id.clone(), duration: item.duration, primary_image_tag: item.primary_image_tag.clone(), image_id: item.primary_image_tag.clone(), media_type: match item.media_type { crate::player::MediaType::Audio => "audio".to_string(), crate::player::MediaType::Video => "video".to_string(), }, } } } // Convert from remote NowPlayingItem impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem { fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self { Self { id: item.id.clone().unwrap_or_default(), title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()), artist: item .album_artist .clone() .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())), album: item.album.clone(), album_id: item.album_id.clone(), duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0), primary_image_tag: item.primary_image_tag.clone(), image_id: item.primary_image_tag.clone(), media_type: item .item_type .clone() .unwrap_or_else(|| "audio".to_string()) .to_lowercase(), } } } /// Response for queue queries #[derive(specta::Type, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct QueueStatus { pub items: Vec, pub current_index: Option, pub shuffle: bool, pub repeat: RepeatMode, pub has_next: bool, pub has_previous: bool, } /// Backend type for video playback #[derive(specta::Type, Debug, Serialize)] #[serde(rename_all = "lowercase")] pub enum VideoBackend { /// Native backend (ExoPlayer on Android, libmpv on Linux) Native, /// HTML5 video element fallback Html5, } /// Request to play a single video item /// /// Simplified to video playback only. Audio playback uses player_play_tracks /// to avoid Tauri Android serialization issues with complex objects. #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayItemRequest { pub id: String, pub title: String, pub stream_url: String, /// Video codec (e.g., "h264", "hevc") for video media pub video_codec: String, /// Whether the video requires server-side transcoding pub needs_transcoding: bool, /// Optional now-playing metadata. Used by the background-audio handoff so the /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so /// existing video-only callers need not send them. #[serde(default)] pub artist: Option, #[serde(default)] pub primary_image_tag: Option, #[serde(default)] pub server_id: Option, /// Total media duration (seconds). Threaded through the background-audio /// handoff so the lockscreen MediaSession advertises a real duration — a /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set. #[serde(default)] pub duration_seconds: Option, /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the /// background-audio handoff so an episode played as audio-only is still /// recognised as an episode by autoplay (UR-040) and advances to the next one. #[serde(default)] pub item_type: Option, /// Series ID for TV episodes. Needed alongside `item_type` so the backend can /// look up the next episode when a background-audio track ends. #[serde(default)] pub series_id: Option, /// Subtitle tracks to sideload, with URLs the frontend has already resolved. /// /// Only the native backends use these: on Android they become the /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path /// builds its own `` children instead and ignores this list. /// /// **Order is the contract.** `player_set_subtitle_track(n)` reaches /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's /// *text track groups* — i.e. the position of the sideloaded configuration, /// not the Jellyfin stream index (which is kept on each entry for the UI's /// benefit). So `n` must be a position in this very array, and the array /// must not be reordered or filtered between building it and sending it. /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same /// list that is sent here, for exactly this reason. /// /// Defaulted so the background-audio handoff and the autoplay/next-episode /// callers, which have no subtitles to offer, need not send the field. /// /// TRACES: UR-020 | IR-016, JA-008 | UT-145 #[serde(default)] pub subtitles: Vec, } /// Queue context for remote transfer - what type of queue is this? #[derive(specta::Type, Debug, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlayQueueContext { /// Playing from a specific album Album { #[serde(rename = "albumId")] album_id: String, #[serde(rename = "albumName")] album_name: String, }, /// Playing from a specific playlist Playlist { #[serde(rename = "playlistId")] playlist_id: String, #[serde(rename = "playlistName")] playlist_name: String, }, /// Custom queue (search results, manual queue, etc.) Custom, } /// Request to play a queue of items #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayQueueRequest { pub items: Vec, pub start_index: usize, pub shuffle: bool, /// Optional context for the queue (album, playlist, or custom) /// Used for remote playback transfer #[serde(default)] pub context: Option, } /// Request to play a track from an album (backend fetches all tracks) #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayAlbumTrackRequest { pub album_id: String, pub album_name: String, pub track_id: String, pub shuffle: bool, } /// Request to play tracks by ID (backend fetches metadata) #[derive(specta::Type, Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PlayTracksRequest { pub track_ids: Vec, pub start_index: usize, pub shuffle: bool, pub context: PlayTracksContext, /// Position (seconds) to resume the starting track from. Used when taking /// over playback from a remote session so we don't restart from 0. #[serde(default)] pub start_position: Option, } /// Context information for track playback #[derive(specta::Type, Debug, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum PlayTracksContext { Playlist { #[serde(rename = "playlistId")] playlist_id: String, #[serde(rename = "playlistName")] playlist_name: String, }, Search { #[serde(rename = "searchQuery")] #[allow(dead_code)] // Used for deserialization, may be used later for analytics search_query: String, }, Custom { #[serde(rename = "label")] #[allow(dead_code)] // Used for deserialization, may be used later for UI display label: Option, }, } /// Response for video seek operations #[derive(specta::Type, Debug, Serialize)] #[serde(tag = "strategy", rename_all = "camelCase")] pub enum VideoSeekResponse { /// Use native seeking (HLS or direct stream) Native { /// Confirmed position after seek position: f64, }, /// Reload stream from new position (transcoded non-HLS) ReloadStream { /// New stream URL starting at seek position new_url: String, /// Position offset to track (for display purposes) seek_offset: f64, }, } /// Response for audio track switching operations #[derive(specta::Type, Debug, Serialize)] #[serde(tag = "strategy", rename_all = "camelCase")] pub enum AudioTrackSwitchResponse { /// Native backend handled it (Android ExoPlayer) Native { /// Confirmation message success: bool, }, /// HTML5 needs to reload stream with new audio track ReloadStream { /// New stream URL with selected audio track new_url: String, /// Current position to resume from position: f64, }, } /// Response for a mid-playback streaming-quality change. /// /// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller /// has to reload anything, so no strategy branch lives in the UI. /// /// TRACES: UR-074 | DR-162 #[derive(specta::Type, Debug, Serialize)] #[serde(tag = "strategy", rename_all = "camelCase")] pub enum StreamQualityResponse { /// The native backend was reloaded here; nothing left for the frontend. Native { /// Position playback resumed at. position: f64, }, /// HTML5 must reload its element with this URL. ReloadStream { /// New stream URL, already transcoded to the requested ceiling. new_url: String, /// Position to resume from. position: f64, }, } /// Helper function to create MediaItem from video request /// /// PlayItemRequest is now video-only, so we create a video MediaItem. /// Audio playback uses player_play_tracks which fetches full metadata from backend. pub(super) async fn create_media_item( req: PlayItemRequest, db: Option<&DatabaseWrapper>, ) -> Result { // For video-only requests, we use the item ID as the jellyfin ID let jellyfin_id = req.id.clone(); // Check if item is downloaded locally let local_path = if let Some(db_wrapper) = db { check_for_local_download(db_wrapper, &jellyfin_id).await? } else { None }; let source = if let Some(path) = local_path { MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(jellyfin_id.clone()), } } else { MediaSource::Remote { stream_url: req.stream_url, jellyfin_item_id: jellyfin_id.clone(), } }; Ok(MediaItem { id: req.id.clone(), title: req.title.clone(), name: Some(req.title.clone()), artist: None, // Not available from video-only request album: None, // Not available from video-only request album_name: None, // Not available from video-only request album_id: None, // Not available from video-only request artist_items: None, // Not available from video-only request artists: None, // Not available from video-only request primary_image_tag: None, // Not available from video-only request image_id: None, item_type: None, // Not available from video-only request playlist_id: None, // Not available from video-only request duration: None, // Not available from video-only request artwork_url: None, // Not available from video-only request media_type: crate::player::MediaType::Video, // Video-only request source, video_codec: Some(req.video_codec), needs_transcoding: req.needs_transcoding, video_width: None, // Not available from video-only request video_height: None, // Not available from video-only request // Sideloaded subtitles, in the order the frontend sent them — that order // is what `player_set_subtitle_track(n)` indexes into on Android. // TRACES: UR-020 | IR-016 | UT-145 subtitles: req.subtitles, series_id: None, // Not available from video-only request server_id: None, // Not available from video-only request }) } /// Pick the source for an audio-only handoff. /// /// A downloaded file wins over the audio-only stream URL. No transcode or audio /// extraction is involved or wanted: the native backends already play a video /// container without decoding its video — the Linux MPV backend is configured /// with `video: no`, and ExoPlayer simply has no surface to render to when the /// item is `MediaType::Audio`. Producing a separate audio-only file would cost /// CPU and battery, need an encoder the project does not ship, and leave a /// second artifact to keep in step with the first. /// /// TRACES: UR-071 | DR-128 | UT-119 pub(super) fn background_audio_source( local_path: Option, stream_url: String, item_id: &str, ) -> MediaSource { match local_path { Some(path) => MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(item_id.to_string()), }, None => MediaSource::Remote { stream_url, jellyfin_item_id: item_id.to_string(), }, } } /// How a background-audio handoff must start playback, given where its audio /// actually begins. /// /// TRACES: UR-040, UR-071 | DR-180 | UT-181 pub(super) struct BackgroundAudioPlan { /// The position the stream's own zero corresponds to, recorded as the /// handoff base so later readings can be shifted back to the episode's /// timeline. pub base_seconds: f64, /// Where to seek after loading, if the source does not already start there. pub seek_to: Option, } /// Decide the base and the seek for a handoff at `position_seconds`. /// /// The two sources start in different places. An audio-only **stream** is built /// with `StartTimeTicks`, so the server makes the handoff point that stream's /// zero: the base is the handoff position, and seeking would skip *past* the /// content by that much again. A downloaded **file** has no such parameter and /// begins at the episode's own zero, so it needs the opposite — no base, and a /// real seek. Treating a file like a stream is why backgrounding a downloaded /// episode restarted it from 0:00 while the lockscreen showed the right time. /// /// TRACES: UR-040, UR-071 | DR-180 | UT-181 pub(super) fn background_audio_plan( is_local_file: bool, position_seconds: f64, ) -> BackgroundAudioPlan { let position = position_seconds.max(0.0); if is_local_file { BackgroundAudioPlan { base_seconds: 0.0, seek_to: (position > 0.0).then_some(position), } } else { BackgroundAudioPlan { base_seconds: position, seek_to: None, } } } /// Resolve the on-disk file backing a completed download, if there is one. /// /// A `downloads` row is not proof of a file: it can outlive the bytes (manual /// deletion, a cleared cache directory, a restored database). Every caller wants /// "can I play this from disk right now", so existence is checked here rather /// than trusted from the row. /// /// Split out from [`check_for_local_download`] so the resolution is testable /// without a `DatabaseWrapper`, and reusable by the video path. /// /// TRACES: UR-071 | DR-123 | UT-116 pub(super) async fn resolve_local_media_path( db_service: &Arc, item_id: &str, ) -> Result, String> { let query = Query::with_params( "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1", vec![QueryParam::String(item_id.to_string())], ); let path: Option = db_service .query_optional(query, |row| row.get(0)) .await .map_err(|e| e.to_string())?; match path { Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path), Some(file_path) => { warn!( "[Player] Download entry exists in DB but file not found: {}", file_path ); Ok(None) } None => Ok(None), } } /// Check if an item has a completed download pub(super) async fn check_for_local_download( db: &DatabaseWrapper, item_id: &str, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; resolve_local_media_path(&db_service, item_id).await } /// The on-disk path for a downloaded item, for playback surfaces that resolve /// their own source rather than going through the queue. /// /// The video player is the reason this exists: audio has preferred local files /// since queue construction, but video asks the repository for a stream URL and /// never consults `downloads`, so a downloaded film was still streamed — costing /// bandwidth that had already been spent and failing outright when offline. /// /// Returns `None` when nothing is downloaded *or* the file is missing, so the /// caller falls back to streaming. /// /// TRACES: UR-071 | DR-123 | UT-116 #[tauri::command] #[specta::specta] pub async fn player_local_media_path( db: State<'_, DatabaseWrapper>, item_id: String, ) -> Result, String> { let db_service = { let database = db.0.lock().map_err(|e| e.to_string())?; Arc::new(database.service()) }; resolve_local_media_path(&db_service, &item_id).await } /// Re-point queued streaming items at completed local downloads. /// /// Sources are resolved once when the queue is built, so downloads that finish /// while it plays (preloaded upcoming tracks) — or that existed before the /// connection dropped — would otherwise keep streaming. Called before advancing /// so the next track always prefers the on-disk copy. /// /// Returns the number of items switched to a local source. pub(super) async fn refresh_queue_local_sources( controller: &PlayerController, db: &DatabaseWrapper, ) -> Result { // Collect remote item IDs first; the queue lock must not be held across awaits. let remote_ids: Vec = { let queue = controller.queue(); let queue_lock = queue.lock().map_err(|e| e.to_string())?; queue_lock .items() .iter() .filter_map(|item| match &item.source { MediaSource::Remote { jellyfin_item_id, .. } => Some(jellyfin_item_id.clone()), _ => None, }) .collect() }; if remote_ids.is_empty() { return Ok(0); } let mut local_paths: Vec<(String, String)> = Vec::new(); for id in remote_ids { if let Some(path) = check_for_local_download(db, &id).await? { local_paths.push((id, path)); } } if local_paths.is_empty() { return Ok(0); } let queue = controller.queue(); let mut queue_lock = queue.lock().map_err(|e| e.to_string())?; let mut switched = 0; for item in queue_lock.items_mut() { if let MediaSource::Remote { jellyfin_item_id, .. } = &item.source { if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) { info!( "[Player] Switching queued track {} to local download: {}", id, path ); item.source = MediaSource::Local { file_path: PathBuf::from(path), jellyfin_item_id: Some(id.clone()), }; switched += 1; } } } Ok(switched) } /// Play a single media item (audio or video) /// /// Accepts a PlayItemRequest with all optional fields properly defaulted. /// This avoids Tauri's Android serialization issues with complex objects. /// /// @req: UR-003 - Play videos /// @req: UR-004 - Play audio uninterrupted /// @req: UR-005 - Control media playback (play operation) /// @req: DR-009 - Audio player UI #[tauri::command] #[specta::specta] pub async fn player_play_item( player: State<'_, PlayerStateWrapper>, session: State<'_, MediaSessionManagerWrapper>, db: State<'_, DatabaseWrapper>, item: PlayItemRequest, ) -> Result { info!( "player_play_item called: {} - {}", item.title, item.stream_url ); // Create media item, checking for local download first let media_item = create_media_item(item, Some(&db)).await?; // Start appropriate session based on media type { let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?; match media_item.media_type { MediaType::Audio => { session_mgr.start_audio_session(media_item.clone()); } MediaType::Video => { // For single video items, treat as Movie (no series_id available here) session_mgr.start_movie_session(media_item.clone()); } } } let controller = player.0.lock().await; // On Linux, video plays in the WebKitGTK HTML5