//! 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 tokio::sync::Mutex as TokioMutex; use tauri::State; use super::DatabaseWrapper; use crate::download::cache::{CacheConfig, SmartCache}; use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::player::{ determine_video_seek_strategy, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy, }; use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}}; 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, 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(), 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(), 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, } /// 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, }, } /// 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 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 subtitles: vec![], series_id: None, // Not available from video-only request server_id: None, // Not available from video-only request }) } /// 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()) }; 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())?; // Verify the file actually exists on disk if let Some(ref file_path) = path { if std::path::Path::new(file_path).exists() { Ok(path) } else { warn!("[Player] Download entry exists in DB but file not found: {}", file_path); Ok(None) } } else { Ok(None) } } /// 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