1mod queue;
6mod remote;
7mod session;
8mod settings;
9mod timers;
10pub use queue::*;
11pub use remote::*;
12pub use session::*;
13pub use settings::*;
14pub use timers::*;
15
16use crate::utils::lock::MutexSafe;
17use log::{debug, error, info, warn};
18use serde::{Deserialize, Serialize};
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21use tauri::State;
22use tokio::sync::Mutex as TokioMutex;
23
24use super::DatabaseWrapper;
25use crate::download::cache::{CacheConfig, SmartCache};
26use crate::jellyfin::{JellyfinClient, JellyfinConfig};
27use crate::player::{
28 determine_audio_track_switch_strategy, determine_video_seek_strategy, AudioTrackSwitchStrategy,
29 MediaItem, MediaSessionManager, MediaSource, MediaType, PlayerController, PlayerState,
30 PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
31};
32use crate::repository::{
33 types::{GetItemsOptions, ImageOptions, ImageType},
34 MediaRepository, StreamSelection,
35};
36use crate::settings::VideoSettings;
37use crate::storage::db_service::{DatabaseService, Query, QueryParam};
38
39pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
41
42pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
50
51pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
55
56pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
60
61#[derive(specta::Type, Debug, Serialize)]
63#[serde(rename_all = "camelCase")]
64pub struct PlayerStatus {
65 pub state: PlayerState,
66 pub position: f64,
67 pub duration: Option<f64>,
68 pub volume: f32,
69 pub muted: bool,
70 pub shuffle: bool,
71 pub repeat: RepeatMode,
72 pub backend: VideoBackend,
74 pub use_html5_element: bool,
76
77 pub merged_media: Option<MergedMediaItem>,
80 pub merged_is_playing: bool,
82 pub merged_volume: f32,
84}
85
86#[derive(specta::Type, Debug, Serialize, Clone)]
89#[serde(rename_all = "camelCase")]
90pub struct MergedMediaItem {
91 pub id: String,
92 pub title: String,
93 pub artist: Option<String>,
94 pub album: Option<String>,
95 pub album_id: Option<String>,
96 pub duration: Option<f64>,
97 pub primary_image_tag: Option<String>,
98 pub image_id: Option<String>,
100 pub media_type: String,
101}
102
103impl From<&crate::player::MediaItem> for MergedMediaItem {
105 fn from(item: &crate::player::MediaItem) -> Self {
106 Self {
107 id: item.id.clone(),
108 title: item.title.clone(),
109 artist: item.artist.clone(),
110 album: item.album.clone(),
111 album_id: item.album_id.clone(),
112 duration: item.duration,
113 primary_image_tag: item.primary_image_tag.clone(),
114 image_id: item.primary_image_tag.clone(),
115 media_type: match item.media_type {
116 crate::player::MediaType::Audio => "audio".to_string(),
117 crate::player::MediaType::Video => "video".to_string(),
118 },
119 }
120 }
121}
122
123impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
125 fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
126 Self {
127 id: item.id.clone().unwrap_or_default(),
128 title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
129 artist: item
130 .album_artist
131 .clone()
132 .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
133 album: item.album.clone(),
134 album_id: item.album_id.clone(),
135 duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
136 primary_image_tag: item.primary_image_tag.clone(),
137 image_id: item.primary_image_tag.clone(),
138 media_type: item
139 .item_type
140 .clone()
141 .unwrap_or_else(|| "audio".to_string())
142 .to_lowercase(),
143 }
144 }
145}
146
147#[derive(specta::Type, Debug, Serialize)]
149#[serde(rename_all = "camelCase")]
150pub struct QueueStatus {
151 pub items: Vec<MediaItem>,
152 pub current_index: Option<usize>,
153 pub shuffle: bool,
154 pub repeat: RepeatMode,
155 pub has_next: bool,
156 pub has_previous: bool,
157}
158
159#[derive(specta::Type, Debug, Serialize)]
161#[serde(rename_all = "lowercase")]
162pub enum VideoBackend {
163 Native,
165 Html5,
167}
168
169#[derive(specta::Type, Debug, Deserialize)]
174#[serde(rename_all = "camelCase")]
175pub struct PlayItemRequest {
176 pub id: String,
177 pub title: String,
178 pub stream_url: String,
179 pub video_codec: String,
181 pub needs_transcoding: bool,
183 #[serde(default)]
193 pub transport: Option<crate::repository::Transport>,
194
195 #[serde(default)]
199 pub artist: Option<String>,
200 #[serde(default)]
201 pub primary_image_tag: Option<String>,
202 #[serde(default)]
203 pub server_id: Option<String>,
204 #[serde(default)]
208 pub duration_seconds: Option<f64>,
209 #[serde(default)]
213 pub item_type: Option<String>,
214 #[serde(default)]
217 pub series_id: Option<String>,
218 #[serde(default)]
238 pub subtitles: Vec<crate::player::SubtitleTrack>,
239}
240
241#[derive(specta::Type, Debug, Deserialize)]
243#[serde(tag = "type", rename_all = "lowercase")]
244pub enum PlayQueueContext {
245 Album {
247 #[serde(rename = "albumId")]
248 album_id: String,
249 #[serde(rename = "albumName")]
250 album_name: String,
251 },
252 Playlist {
254 #[serde(rename = "playlistId")]
255 playlist_id: String,
256 #[serde(rename = "playlistName")]
257 playlist_name: String,
258 },
259 Custom,
261}
262
263#[derive(specta::Type, Debug, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub struct PlayQueueRequest {
267 pub items: Vec<PlayItemRequest>,
268 pub start_index: usize,
269 pub shuffle: bool,
270 #[serde(default)]
273 pub context: Option<PlayQueueContext>,
274}
275
276#[derive(specta::Type, Debug, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct PlayAlbumTrackRequest {
280 pub album_id: String,
281 pub album_name: String,
282 pub track_id: String,
283 pub shuffle: bool,
284}
285
286#[derive(specta::Type, Debug, Deserialize)]
288#[serde(rename_all = "camelCase")]
289pub struct PlayTracksRequest {
290 pub track_ids: Vec<String>,
291 pub start_index: usize,
292 pub shuffle: bool,
293 pub context: PlayTracksContext,
294 #[serde(default)]
297 pub start_position: Option<f64>,
298}
299
300#[derive(specta::Type, Debug, Deserialize)]
302#[serde(tag = "type", rename_all = "lowercase")]
303pub enum PlayTracksContext {
304 Playlist {
305 #[serde(rename = "playlistId")]
306 playlist_id: String,
307 #[serde(rename = "playlistName")]
308 playlist_name: String,
309 },
310 Search {
311 #[serde(rename = "searchQuery")]
312 #[allow(dead_code)] search_query: String,
314 },
315 Custom {
316 #[serde(rename = "label")]
317 #[allow(dead_code)] label: Option<String>,
319 },
320}
321
322#[derive(specta::Type, Debug, Serialize)]
324#[serde(tag = "strategy", rename_all = "camelCase")]
325pub enum VideoSeekResponse {
326 Native {
328 position: f64,
330 },
331 ReloadStream {
333 selection: StreamSelection,
337 seek_offset: f64,
343 },
344}
345
346#[derive(specta::Type, Debug, Serialize)]
348#[serde(tag = "strategy", rename_all = "camelCase")]
349pub enum AudioTrackSwitchResponse {
350 Native {
352 success: bool,
354 },
355 ReloadStream {
357 selection: StreamSelection,
359 position: f64,
361 },
362}
363
364#[derive(specta::Type, Debug, Serialize)]
371#[serde(tag = "strategy", rename_all = "camelCase")]
372pub enum StreamQualityResponse {
373 Native {
385 selection: StreamSelection,
388 position: f64,
390 },
391 ReloadStream {
393 selection: StreamSelection,
398 position: f64,
400 },
401}
402
403pub(super) async fn create_media_item(
408 req: PlayItemRequest,
409 db: Option<&DatabaseWrapper>,
410) -> Result<MediaItem, String> {
411 let jellyfin_id = req.id.clone();
413
414 let local_path = if let Some(db_wrapper) = db {
416 check_for_local_download(db_wrapper, &jellyfin_id).await?
417 } else {
418 None
419 };
420
421 let source = if let Some(path) = local_path {
422 MediaSource::Local {
423 file_path: PathBuf::from(path),
424 jellyfin_item_id: Some(jellyfin_id.clone()),
425 }
426 } else {
427 MediaSource::Remote {
428 stream_url: req.stream_url,
429 jellyfin_item_id: jellyfin_id.clone(),
430 }
431 };
432
433 Ok(MediaItem {
434 id: req.id.clone(),
435 title: req.title.clone(),
436 name: Some(req.title.clone()),
437 artist: None, album: None, album_name: None, album_id: None, artist_items: None, artists: None, primary_image_tag: None, image_id: None,
445 item_type: None, playlist_id: None, duration: None, artwork_url: None, media_type: crate::player::MediaType::Video, source,
451 video_codec: Some(req.video_codec),
452 needs_transcoding: req.needs_transcoding,
453 transport: req.transport,
455 video_width: None, video_height: None, subtitles: req.subtitles,
461 series_id: None, server_id: None, })
464}
465
466pub(super) fn background_audio_source(
478 local_path: Option<String>,
479 stream_url: String,
480 item_id: &str,
481) -> MediaSource {
482 match local_path {
483 Some(path) => MediaSource::Local {
484 file_path: PathBuf::from(path),
485 jellyfin_item_id: Some(item_id.to_string()),
486 },
487 None => MediaSource::Remote {
488 stream_url,
489 jellyfin_item_id: item_id.to_string(),
490 },
491 }
492}
493
494pub(super) struct BackgroundAudioPlan {
499 pub base_seconds: f64,
503 pub seek_to: Option<f64>,
505}
506
507pub(super) fn background_audio_plan(
519 is_local_file: bool,
520 position_seconds: f64,
521) -> BackgroundAudioPlan {
522 let position = position_seconds.max(0.0);
523
524 if is_local_file {
525 BackgroundAudioPlan {
526 base_seconds: 0.0,
527 seek_to: (position > 0.0).then_some(position),
528 }
529 } else {
530 BackgroundAudioPlan {
531 base_seconds: position,
532 seek_to: None,
533 }
534 }
535}
536
537pub(super) async fn resolve_local_media_path<S: DatabaseService>(
549 db_service: &Arc<S>,
550 item_id: &str,
551) -> Result<Option<String>, String> {
552 let query = Query::with_params(
553 "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
554 vec![QueryParam::String(item_id.to_string())],
555 );
556
557 let path: Option<String> = db_service
558 .query_optional(query, |row| row.get(0))
559 .await
560 .map_err(|e| e.to_string())?;
561
562 match path {
563 Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
564 Some(file_path) => {
565 warn!(
566 "[Player] Download entry exists in DB but file not found: {}",
567 file_path
568 );
569 Ok(None)
570 }
571 None => Ok(None),
572 }
573}
574
575pub(super) async fn check_for_local_download(
577 db: &DatabaseWrapper,
578 item_id: &str,
579) -> Result<Option<String>, String> {
580 let db_service = {
581 let database = db.0.lock().map_err(|e| e.to_string())?;
582 Arc::new(database.service())
583 };
584
585 resolve_local_media_path(&db_service, item_id).await
586}
587
588#[tauri::command]
601#[specta::specta]
602pub async fn player_local_media_path(
603 db: State<'_, DatabaseWrapper>,
604 item_id: String,
605) -> Result<Option<String>, String> {
606 let db_service = {
607 let database = db.0.lock().map_err(|e| e.to_string())?;
608 Arc::new(database.service())
609 };
610
611 resolve_local_media_path(&db_service, &item_id).await
612}
613
614pub(super) async fn refresh_queue_local_sources(
623 controller: &PlayerController,
624 db: &DatabaseWrapper,
625) -> Result<usize, String> {
626 let remote_ids: Vec<String> = {
628 let queue = controller.queue();
629 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
630 queue_lock
631 .items()
632 .iter()
633 .filter_map(|item| match &item.source {
634 MediaSource::Remote {
635 jellyfin_item_id, ..
636 } => Some(jellyfin_item_id.clone()),
637 _ => None,
638 })
639 .collect()
640 };
641
642 if remote_ids.is_empty() {
643 return Ok(0);
644 }
645
646 let mut local_paths: Vec<(String, String)> = Vec::new();
647 for id in remote_ids {
648 if let Some(path) = check_for_local_download(db, &id).await? {
649 local_paths.push((id, path));
650 }
651 }
652
653 if local_paths.is_empty() {
654 return Ok(0);
655 }
656
657 let queue = controller.queue();
658 let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
659 let mut switched = 0;
660 for item in queue_lock.items_mut() {
661 if let MediaSource::Remote {
662 jellyfin_item_id, ..
663 } = &item.source
664 {
665 if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
666 info!(
667 "[Player] Switching queued track {} to local download: {}",
668 id, path
669 );
670 item.source = MediaSource::Local {
671 file_path: PathBuf::from(path),
672 jellyfin_item_id: Some(id.clone()),
673 };
674 switched += 1;
675 }
676 }
677 }
678 Ok(switched)
679}
680
681#[tauri::command]
691#[specta::specta]
692pub async fn player_play_item(
693 player: State<'_, PlayerStateWrapper>,
694 session: State<'_, MediaSessionManagerWrapper>,
695 db: State<'_, DatabaseWrapper>,
696 item: PlayItemRequest,
697) -> Result<PlayerStatus, String> {
698 info!(
699 "player_play_item called: {} - {}",
700 item.title, item.stream_url
701 );
702
703 crate::repository::online::clear_playback_quality_override();
710
711 let media_item = create_media_item(item, Some(&db)).await?;
713
714 {
716 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
717 match media_item.media_type {
718 MediaType::Audio => {
719 session_mgr.start_audio_session(media_item.clone());
720 }
721 MediaType::Video => {
722 session_mgr.start_movie_session(media_item.clone());
724 }
725 }
726 }
727
728 let controller = player.0.lock().await;
729 let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
746 if renders_natively {
747 controller
748 .play_item(media_item)
749 .map_err(|e| e.to_string())?;
750 } else {
751 controller
754 .set_current_item(media_item)
755 .map_err(|e| e.to_string())?;
756 }
757
758 controller.emit_queue_changed();
760
761 if let Some(emitter) = controller.event_emitter() {
763 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
764 emitter.emit(PlayerStatusEvent::SessionChanged {
765 session: session_mgr.current().clone(),
766 });
767 }
768
769 Ok(get_player_status(&controller))
770}
771
772#[tauri::command]
789#[specta::specta]
790pub async fn player_enter_background_audio(
791 player: State<'_, PlayerStateWrapper>,
792 session: State<'_, MediaSessionManagerWrapper>,
793 db: State<'_, DatabaseWrapper>,
794 item: PlayItemRequest,
795 position_seconds: f64,
796) -> Result<PlayerStatus, String> {
797 info!(
798 "player_enter_background_audio: {} @ {:.1}s",
799 item.title, position_seconds
800 );
801
802 let local_path = check_for_local_download(&db, &item.id).await?;
807 if local_path.is_some() {
808 info!(
809 "player_enter_background_audio: using downloaded file for {}",
810 item.id
811 );
812 }
813 let plan = background_audio_plan(local_path.is_some(), position_seconds);
816 let source = background_audio_source(local_path, item.stream_url, &item.id);
817
818 let media_item = MediaItem {
822 transport: None,
824 id: item.id.clone(),
825 title: item.title.clone(),
826 name: Some(item.title.clone()),
827 artist: item.artist.clone(),
828 album: None,
829 album_name: None,
830 album_id: None,
831 artist_items: None,
832 artists: None,
833 primary_image_tag: item.primary_image_tag.clone(),
834 image_id: item.primary_image_tag.clone(),
835 item_type: item.item_type.clone(),
838 playlist_id: None,
839 duration: item.duration_seconds,
841 artwork_url: None,
842 media_type: MediaType::Audio,
843 source,
844 video_codec: None,
845 needs_transcoding: false,
846 video_width: None,
847 video_height: None,
848 subtitles: vec![],
849 series_id: item.series_id.clone(),
850 server_id: item.server_id.clone(),
851 };
852
853 {
854 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
855 session_mgr.start_audio_session(media_item.clone());
856 }
857
858 let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
862
863 let controller = player.0.lock().await;
864 controller.enter_background_audio(plan.base_seconds);
870 controller
871 .play_item(media_item)
872 .map_err(|e| e.to_string())?;
873 if let Some(seek_to) = plan.seek_to {
877 info!(
878 "player_enter_background_audio: seeking the downloaded file to {:.1}s",
879 seek_to
880 );
881 controller.seek(seek_to).map_err(|e| e.to_string())?;
882 }
883
884 controller.emit_queue_changed();
885 if let Some(emitter) = controller.event_emitter() {
886 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
887 emitter.emit(PlayerStatusEvent::SessionChanged {
888 session: session_mgr.current().clone(),
889 });
890 }
891
892 Ok(get_player_status(&controller))
893}
894
895#[tauri::command]
916#[specta::specta]
917pub async fn player_background_action(
918 player: State<'_, PlayerStateWrapper>,
919 background_audio_armed: bool,
920 in_picture_in_picture: bool,
921) -> Result<crate::player::background_policy::BackgroundAction, String> {
922 use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
923
924 let is_video = {
925 let controller = player.0.lock().await;
926 let queue_arc = controller.queue();
927 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
928 match queue.current() {
929 Some(item) => is_video_media(item.media_type),
930 None => return Ok(BackgroundAction::KeepPlaying),
931 }
932 };
933
934 let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
935 info!(
936 "[player_background_action] video={} armed={} pip={} -> {:?}",
937 is_video, background_audio_armed, in_picture_in_picture, action
938 );
939 Ok(action)
940}
941
942#[tauri::command]
949#[specta::specta]
950pub async fn player_exit_background_audio(
951 player: State<'_, PlayerStateWrapper>,
952) -> Result<crate::player::BackgroundAudioResume, String> {
953 let controller = player.0.lock().await;
954
955 let resume = controller.background_audio_resume();
969
970 let _ = crate::player::set_lockscreen_position_offset(0.0);
972 controller.exit_background_audio();
973 controller.stop().map_err(|e| e.to_string())?;
974 info!(
975 "player_exit_background_audio: resuming {:?} at {:.1}s",
976 resume.item_id, resume.position_seconds
977 );
978 Ok(resume)
979}
980
981#[tauri::command]
988#[specta::specta]
989pub async fn player_play_queue(
990 player: State<'_, PlayerStateWrapper>,
991 session: State<'_, MediaSessionManagerWrapper>,
992 db: State<'_, DatabaseWrapper>,
993 request: PlayQueueRequest,
994) -> Result<PlayerStatus, String> {
995 info!(
996 "player_play_queue called: {} items, start_index: {}, shuffle: {}",
997 request.items.len(),
998 request.start_index,
999 request.shuffle
1000 );
1001
1002 crate::repository::online::clear_playback_quality_override();
1009
1010 if request.shuffle {
1012 let controller = player.0.lock().await;
1013 controller.toggle_shuffle();
1014 }
1015
1016 let queue_context = match request.context {
1018 Some(PlayQueueContext::Album {
1019 album_id,
1020 album_name,
1021 }) => QueueContext::Album {
1022 album_id,
1023 album_name,
1024 },
1025 Some(PlayQueueContext::Playlist {
1026 playlist_id,
1027 playlist_name,
1028 }) => QueueContext::Playlist {
1029 playlist_id,
1030 playlist_name,
1031 },
1032 Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
1033 };
1034
1035 let mut items: Vec<MediaItem> = Vec::new();
1037 for req in request.items {
1038 items.push(create_media_item(req, Some(&db)).await?);
1039 }
1040
1041 if let Some(first_item) = items.get(request.start_index) {
1043 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1044 match first_item.media_type {
1045 MediaType::Audio => {
1046 session_mgr.start_audio_session(first_item.clone());
1047 }
1048 MediaType::Video => {
1049 session_mgr.start_movie_session(first_item.clone());
1051 }
1052 }
1053 }
1054
1055 let controller = player.0.lock().await;
1057 info!(
1058 "player_play_queue: Calling controller.play_queue with {} items at index {}",
1059 items.len(),
1060 request.start_index
1061 );
1062 controller
1063 .play_queue(items, request.start_index)
1064 .map_err(|e| e.to_string())?;
1065
1066 {
1068 let queue_arc = controller.queue();
1069 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1070 queue.set_context(queue_context);
1071 }
1072
1073 controller.emit_queue_changed();
1075
1076 if let Some(emitter) = controller.event_emitter() {
1078 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1079 emitter.emit(PlayerStatusEvent::SessionChanged {
1080 session: session_mgr.current().clone(),
1081 });
1082 }
1083
1084 Ok(get_player_status(&controller))
1085}
1086
1087#[tauri::command]
1088#[specta::specta]
1089pub async fn player_play(
1090 player: State<'_, PlayerStateWrapper>,
1091 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1092) -> Result<PlayerStatus, String> {
1093 let mode = playback_mode.0.get_mode();
1095
1096 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1097 let client = {
1099 let controller = player.0.lock().await;
1100 let client_arc = controller.jellyfin_client();
1101 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1102 client_opt
1103 .as_ref()
1104 .ok_or("Jellyfin client not configured")?
1105 .clone()
1106 };
1107 client.send_session_command(session_id, "Unpause").await?;
1108 } else {
1109 let controller = player.0.lock().await;
1111 controller.play().map_err(|e| e.to_string())?;
1112 }
1113
1114 let controller = player.0.lock().await;
1115 Ok(get_player_status(&controller))
1116}
1117
1118#[tauri::command]
1119#[specta::specta]
1120pub async fn player_pause(
1121 player: State<'_, PlayerStateWrapper>,
1122 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1123) -> Result<PlayerStatus, String> {
1124 let mode = playback_mode.0.get_mode();
1126
1127 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1128 let client = {
1130 let controller = player.0.lock().await;
1131 let client_arc = controller.jellyfin_client();
1132 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1133 client_opt
1134 .as_ref()
1135 .ok_or("Jellyfin client not configured")?
1136 .clone()
1137 };
1138 client.send_session_command(session_id, "Pause").await?;
1139 } else {
1140 let controller = player.0.lock().await;
1142 controller.pause().map_err(|e| e.to_string())?;
1143 }
1144
1145 let controller = player.0.lock().await;
1146 Ok(get_player_status(&controller))
1147}
1148
1149#[tauri::command]
1150#[specta::specta]
1151pub async fn player_toggle(
1152 player: State<'_, PlayerStateWrapper>,
1153 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1154) -> Result<PlayerStatus, String> {
1155 let mode = playback_mode.0.get_mode();
1157
1158 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1159 let client = {
1161 let controller = player.0.lock().await;
1162 let client_arc = controller.jellyfin_client();
1163 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1164 client_opt
1165 .as_ref()
1166 .ok_or("Jellyfin client not configured")?
1167 .clone()
1168 };
1169 client.send_session_command(session_id, "PlayPause").await?;
1170 } else {
1171 let controller = player.0.lock().await;
1173 controller.toggle_playback().map_err(|e| e.to_string())?;
1174 }
1175
1176 let controller = player.0.lock().await;
1177 Ok(get_player_status(&controller))
1178}
1179
1180#[tauri::command]
1181#[specta::specta]
1182pub async fn player_stop(
1183 player: State<'_, PlayerStateWrapper>,
1184 session: State<'_, MediaSessionManagerWrapper>,
1185 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1186) -> Result<PlayerStatus, String> {
1187 let mode = playback_mode.0.get_mode();
1189
1190 info!("[player_stop] called (mode: {:?})", mode);
1196
1197 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1198 let client = {
1200 let controller = player.0.lock().await;
1201 let client_arc = controller.jellyfin_client();
1202 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1203 client_opt
1204 .as_ref()
1205 .ok_or("Jellyfin client not configured")?
1206 .clone()
1207 };
1208 client.send_session_command(session_id, "Stop").await?;
1209
1210 playback_mode
1217 .0
1218 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1219 } else {
1220 let controller = player.0.lock().await;
1222 controller.stop().map_err(|e| e.to_string())?;
1223
1224 playback_mode
1228 .0
1229 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1230
1231 {
1233 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1234 let current_session = session_mgr.current().clone();
1235
1236 match current_session {
1237 crate::player::MediaSessionType::Movie { .. } => {
1238 session_mgr.movie_session_ended();
1240 }
1241 crate::player::MediaSessionType::Audio { .. } => {
1242 session_mgr.audio_session_inactive();
1244 }
1245 crate::player::MediaSessionType::TvShow { .. } => {
1246 session_mgr.tv_session_episode_ended();
1248 }
1249 crate::player::MediaSessionType::Idle => {
1250 }
1252 }
1253
1254 if let Some(emitter) = controller.event_emitter() {
1256 emitter.emit(PlayerStatusEvent::SessionChanged {
1257 session: session_mgr.current().clone(),
1258 });
1259 }
1260 }
1261 }
1262
1263 let controller = player.0.lock().await;
1264 Ok(get_player_status(&controller))
1265}
1266
1267#[tauri::command]
1268#[specta::specta]
1269pub async fn player_next(
1270 player: State<'_, PlayerStateWrapper>,
1271 session: State<'_, MediaSessionManagerWrapper>,
1272 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1273 db: State<'_, DatabaseWrapper>,
1274) -> Result<PlayerStatus, String> {
1275 debug!("[player_next] Command called from frontend");
1276
1277 let mode = playback_mode.0.get_mode();
1279
1280 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1281 let client = {
1283 let controller = player.0.lock().await;
1284 let client_arc = controller.jellyfin_client();
1285 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1286 client_opt
1287 .as_ref()
1288 .ok_or("Jellyfin client not configured")?
1289 .clone()
1290 };
1291 client.send_session_command(session_id, "NextTrack").await?;
1292 } else {
1293 let controller = player.0.lock().await;
1295 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1297 warn!("[player_next] Failed to refresh local sources: {}", e);
1298 }
1299 controller.next().map_err(|e| e.to_string())?;
1300 controller.emit_queue_changed();
1301
1302 let current_item = {
1304 let queue = controller.queue();
1305 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1306 queue_lock.current().cloned()
1307 };
1308
1309 if let Some(item) = current_item {
1310 if item.media_type == MediaType::Audio {
1311 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1312 session_mgr.update_audio_track(item);
1313
1314 if let Some(emitter) = controller.event_emitter() {
1316 emitter.emit(PlayerStatusEvent::SessionChanged {
1317 session: session_mgr.current().clone(),
1318 });
1319 }
1320 }
1321 }
1322 }
1323
1324 let controller = player.0.lock().await;
1325 Ok(get_player_status(&controller))
1326}
1327
1328#[tauri::command]
1329#[specta::specta]
1330pub async fn player_previous(
1331 player: State<'_, PlayerStateWrapper>,
1332 session: State<'_, MediaSessionManagerWrapper>,
1333 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1334 db: State<'_, DatabaseWrapper>,
1335) -> Result<PlayerStatus, String> {
1336 let mode = playback_mode.0.get_mode();
1338
1339 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1340 let client = {
1342 let controller = player.0.lock().await;
1343 let client_arc = controller.jellyfin_client();
1344 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1345 client_opt
1346 .as_ref()
1347 .ok_or("Jellyfin client not configured")?
1348 .clone()
1349 };
1350 client
1351 .send_session_command(session_id, "PreviousTrack")
1352 .await?;
1353 } else {
1354 let controller = player.0.lock().await;
1356 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1358 warn!("[player_previous] Failed to refresh local sources: {}", e);
1359 }
1360 controller.previous().map_err(|e| e.to_string())?;
1361 controller.emit_queue_changed();
1362
1363 let current_item = {
1365 let queue = controller.queue();
1366 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1367 queue_lock.current().cloned()
1368 };
1369
1370 if let Some(item) = current_item {
1371 if item.media_type == MediaType::Audio {
1372 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1373 session_mgr.update_audio_track(item);
1374
1375 if let Some(emitter) = controller.event_emitter() {
1377 emitter.emit(PlayerStatusEvent::SessionChanged {
1378 session: session_mgr.current().clone(),
1379 });
1380 }
1381 }
1382 }
1383 }
1384
1385 let controller = player.0.lock().await;
1386 Ok(get_player_status(&controller))
1387}
1388
1389#[tauri::command]
1390#[specta::specta]
1391pub async fn player_seek(
1392 player: State<'_, PlayerStateWrapper>,
1393 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1394 position: f64,
1395) -> Result<PlayerStatus, String> {
1396 let mode = playback_mode.0.get_mode();
1398
1399 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1400 let client = {
1402 let controller = player.0.lock().await;
1403 let client_arc = controller.jellyfin_client();
1404 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1405 client_opt
1406 .as_ref()
1407 .ok_or("Jellyfin client not configured")?
1408 .clone()
1409 };
1410 let position_ticks = (position * 10_000_000.0) as i64;
1411 client.session_seek(session_id, position_ticks).await?;
1412 } else {
1413 let controller = player.0.lock().await;
1418 controller.seek_absolute(position).await?;
1419 }
1420
1421 let controller = player.0.lock().await;
1422 Ok(get_player_status(&controller))
1423}
1424
1425#[tauri::command]
1436#[specta::specta]
1437pub async fn player_seek_video(
1438 player: State<'_, PlayerStateWrapper>,
1439 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1440 repository_handle: String,
1441 position: f64,
1442 media_source_id: Option<String>,
1443 audio_stream_index: Option<i32>,
1444 use_html5: bool,
1445) -> Result<VideoSeekResponse, String> {
1446 info!(
1447 "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1448 position, use_html5
1449 );
1450
1451 let repository = repository_manager
1453 .0
1454 .get(&repository_handle)
1455 .ok_or("Repository not found - user may need to log in")?;
1456
1457 let (needs_transcoding, jellyfin_item_id, is_local) = {
1460 let controller = player.0.lock().await;
1461 let queue_arc = controller.queue();
1462 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1463
1464 let current_item = queue.current().ok_or("No item currently playing")?;
1465
1466 if current_item.media_type != MediaType::Video {
1467 return Err("Current item is not a video".to_string());
1468 }
1469
1470 let jellyfin_id = current_item
1471 .jellyfin_id()
1472 .ok_or("Current video has no Jellyfin ID")?
1473 .to_string();
1474
1475 let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1480
1481 (current_item.needs_transcoding, jellyfin_id, is_local_file)
1482 }; let seeks_transcoded_in_place = {
1488 let controller = player.0.lock().await;
1489 controller.capabilities().seeks_transcoded_in_place
1490 };
1491 let strategy = determine_video_seek_strategy(
1492 is_local,
1493 seeks_transcoded_in_place,
1494 needs_transcoding,
1495 use_html5,
1496 );
1497
1498 info!(
1499 "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1500 needs_transcoding={}, use_html5={}, strategy={:?}",
1501 is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
1502 );
1503
1504 match strategy {
1505 VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1506 info!("[player_seek_video] Using backend native seek");
1508 let controller = player.0.lock().await;
1509 controller.seek(position).map_err(|e| e.to_string())?;
1510 Ok(VideoSeekResponse::Native { position })
1511 }
1512 VideoSeekStrategy::Html5NativeSeek => {
1513 info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1516 Ok(VideoSeekResponse::Native { position })
1517 }
1518 VideoSeekStrategy::Html5ReloadStream => {
1519 info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1521
1522 let selection = repository
1523 .get_stream_selection(
1524 &jellyfin_item_id,
1525 media_source_id.as_deref(),
1526 audio_stream_index,
1527 )
1528 .await
1529 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1530
1531 info!(
1532 "[player_seek_video] Selected {:?} over {:?} for position {}",
1533 selection.playback_kind, selection.transport, position
1534 );
1535
1536 Ok(VideoSeekResponse::ReloadStream {
1537 selection,
1538 seek_offset: position,
1539 })
1540 }
1541 VideoSeekStrategy::BackendReloadStream => {
1542 info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1544
1545 let selection = repository
1546 .get_stream_selection(
1547 &jellyfin_item_id,
1548 media_source_id.as_deref(),
1549 audio_stream_index,
1550 )
1551 .await
1552 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1553 let new_url = selection.url.clone();
1554
1555 info!("[player_seek_video] Got new selection, handling reload internally");
1556
1557 {
1559 let controller = player.0.lock().await;
1560 controller.stop().map_err(|e| e.to_string())?;
1561 }
1562
1563 {
1565 let controller = player.0.lock().await;
1566 let queue_arc = controller.queue();
1567 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1568
1569 if !queue.update_current_stream_url(new_url.clone()) {
1570 return Err("Failed to update stream URL in queue".to_string());
1571 }
1572 }
1573
1574 {
1576 let controller = player.0.lock().await;
1577 let queue_arc = controller.queue();
1578 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1579
1580 if let Some(updated_item) = queue.current() {
1581 controller
1582 .load_and_play(updated_item)
1583 .map_err(|e| e.to_string())?;
1584 } else {
1585 return Err("No current item after URL update".to_string());
1586 }
1587
1588 controller.seek(position).map_err(|e| e.to_string())?;
1592 }
1593
1594 info!(
1595 "[player_seek_video] Stream reloaded successfully at position {}",
1596 position
1597 );
1598
1599 Ok(VideoSeekResponse::Native { position })
1600 }
1601 }
1602}
1603
1604#[tauri::command]
1636#[specta::specta]
1637#[allow(clippy::too_many_arguments)]
1641pub async fn player_switch_audio_track(
1642 player: State<'_, PlayerStateWrapper>,
1643 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1644 repository_handle: String,
1645 stream_index: i32,
1646 array_index: i32,
1647 use_html5: bool,
1648 current_position: Option<f64>,
1649 media_source_id: Option<String>,
1650) -> Result<AudioTrackSwitchResponse, String> {
1651 info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1652 stream_index, array_index, use_html5);
1653
1654 let (jellyfin_item_id, needs_transcoding, engine_position) = {
1659 let controller = player.0.lock().await;
1660 let engine_position = controller.absolute_position();
1661 let queue_arc = controller.queue();
1662 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1663
1664 let current_item = queue.current().ok_or("No item currently playing")?;
1665
1666 (
1667 current_item
1668 .jellyfin_id()
1669 .ok_or("Current item has no Jellyfin ID")?
1670 .to_string(),
1671 current_item.needs_transcoding,
1672 engine_position,
1673 )
1674 };
1675
1676 let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
1677
1678 info!(
1679 "[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
1680 needs_transcoding, use_html5, strategy
1681 );
1682
1683 if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
1684 let controller = player.0.lock().await;
1686 controller
1687 .set_audio_track(array_index)
1688 .map_err(|e| e.to_string())?;
1689
1690 return Ok(AudioTrackSwitchResponse::Native { success: true });
1691 }
1692
1693 let repository = repository_manager
1695 .0
1696 .get(&repository_handle)
1697 .ok_or("Repository not found - user may need to log in")?;
1698
1699 let selection = repository
1708 .get_stream_selection(
1709 &jellyfin_item_id,
1710 media_source_id.as_deref(),
1711 Some(stream_index),
1712 )
1713 .await
1714 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1715
1716 let position = crate::player::track_switch::resume_position(current_position, engine_position);
1720
1721 match strategy {
1722 AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
1723 selection,
1724 position,
1725 }),
1726 AudioTrackSwitchStrategy::BackendReloadStream => {
1727 let new_url = selection.url.clone();
1732
1733 {
1734 let controller = player.0.lock().await;
1735 controller.stop().map_err(|e| e.to_string())?;
1736 }
1737
1738 {
1739 let controller = player.0.lock().await;
1740 let queue_arc = controller.queue();
1741 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1742
1743 if !queue.update_current_stream_url(new_url) {
1744 return Err("Failed to update stream URL in queue".to_string());
1745 }
1746 }
1747
1748 {
1749 let controller = player.0.lock().await;
1750 let queue_arc = controller.queue();
1751 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1752
1753 let updated_item = queue
1754 .current()
1755 .ok_or("No current item after URL update")?
1756 .clone();
1757 drop(queue);
1758
1759 controller
1760 .load_and_play(&updated_item)
1761 .map_err(|e| e.to_string())?;
1762 controller.seek(position).map_err(|e| e.to_string())?;
1763 }
1764
1765 info!(
1766 "[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
1767 stream_index, position
1768 );
1769
1770 Ok(AudioTrackSwitchResponse::Native { success: true })
1771 }
1772 AudioTrackSwitchStrategy::BackendSelectInPlace => {
1774 Ok(AudioTrackSwitchResponse::Native { success: true })
1775 }
1776 }
1777}
1778
1779#[tauri::command]
1800#[specta::specta]
1801#[allow(clippy::too_many_arguments)]
1805pub async fn player_set_stream_quality(
1806 player: State<'_, PlayerStateWrapper>,
1807 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1808 repository_handle: String,
1809 quality: crate::settings::StreamingQuality,
1810 use_html5: bool,
1811 current_position: Option<f64>,
1812 media_source_id: Option<String>,
1813 audio_stream_index: Option<i32>,
1814) -> Result<StreamQualityResponse, String> {
1815 info!(
1816 "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1817 quality.label(),
1818 use_html5,
1819 current_position
1820 );
1821
1822 let repository = repository_manager
1823 .0
1824 .get(&repository_handle)
1825 .ok_or("Repository not found - user may need to log in")?;
1826
1827 let jellyfin_item_id = {
1828 let controller = player.0.lock().await;
1829 let queue_arc = controller.queue();
1830 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1831
1832 let current_item = queue.current().ok_or("No item currently playing")?;
1833
1834 if current_item.media_type != MediaType::Video {
1835 return Err("Current item is not a video".to_string());
1836 }
1837
1838 current_item
1839 .jellyfin_id()
1840 .ok_or("Current item has no Jellyfin ID")?
1841 .to_string()
1842 };
1843
1844 crate::repository::online::set_playback_quality_override(quality);
1852
1853 let position = match current_position {
1867 Some(p) => p,
1868 None => {
1869 let controller = player.0.lock().await;
1870 controller.absolute_position()
1871 }
1872 };
1873 let selection = repository
1874 .get_stream_selection(
1875 &jellyfin_item_id,
1876 media_source_id.as_deref(),
1877 audio_stream_index,
1878 )
1879 .await
1880 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1881 let new_url = selection.url.clone();
1882
1883 if use_html5 {
1884 return Ok(StreamQualityResponse::ReloadStream {
1885 selection,
1886 position,
1887 });
1888 }
1889
1890 {
1896 let controller = player.0.lock().await;
1897 controller.stop().map_err(|e| e.to_string())?;
1898
1899 let queue_arc = controller.queue();
1900 {
1901 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1902 if !queue.update_current_stream_url(new_url.clone()) {
1903 return Err("Failed to update stream URL in queue".to_string());
1904 }
1905 }
1906
1907 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1908 let updated_item = queue.current().ok_or("No current item after URL update")?;
1909 controller
1910 .load_and_play(updated_item)
1911 .map_err(|e| e.to_string())?;
1912 if position > 0.0 {
1913 controller.seek(position).map_err(|e| e.to_string())?;
1914 }
1915 }
1916
1917 Ok(StreamQualityResponse::Native {
1918 selection,
1919 position,
1920 })
1921}
1922
1923#[tauri::command]
1927#[specta::specta]
1928pub async fn player_set_audio_track(
1929 player: State<'_, PlayerStateWrapper>,
1930 stream_index: i32,
1931) -> Result<PlayerStatus, String> {
1932 let controller = player.0.lock().await;
1933 controller
1934 .set_audio_track(stream_index)
1935 .map_err(|e| e.to_string())?;
1936 Ok(get_player_status(&controller))
1937}
1938
1939#[tauri::command]
1948#[specta::specta]
1949pub async fn player_set_subtitle_track(
1950 player: State<'_, PlayerStateWrapper>,
1951 stream_index: Option<i32>,
1952) -> Result<PlayerStatus, String> {
1953 let controller = player.0.lock().await;
1954 controller
1955 .set_subtitle_track(stream_index)
1956 .map_err(|e| e.to_string())?;
1957 Ok(get_player_status(&controller))
1958}
1959
1960fn normalize_volume(volume: f32) -> f32 {
1970 if volume.is_nan() {
1971 0.0
1972 } else {
1973 volume.clamp(0.0, 1.0)
1974 }
1975}
1976
1977#[tauri::command]
1978#[specta::specta]
1979pub async fn player_set_volume(
1980 player: State<'_, PlayerStateWrapper>,
1981 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1982 volume: f32,
1983) -> Result<PlayerStatus, String> {
1984 let volume = normalize_volume(volume);
1989
1990 let mode = playback_mode.0.get_mode();
1992
1993 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1994 let client = {
1996 let controller = player.0.lock().await;
1997 let client_arc = controller.jellyfin_client();
1998 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1999 client_opt
2000 .as_ref()
2001 .ok_or("Jellyfin client not configured")?
2002 .clone()
2003 };
2004 let volume_percent = (volume * 100.0) as i32;
2006 client
2007 .session_set_volume(session_id, volume_percent)
2008 .await?;
2009 } else {
2010 let controller = player.0.lock().await;
2012 controller.set_volume(volume).map_err(|e| e.to_string())?;
2013 }
2014
2015 let controller = player.0.lock().await;
2016 Ok(get_player_status(&controller))
2017}
2018
2019#[tauri::command]
2020#[specta::specta]
2021pub async fn player_toggle_mute(
2022 player: State<'_, PlayerStateWrapper>,
2023 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2024) -> Result<PlayerStatus, String> {
2025 let mode = playback_mode.0.get_mode();
2027
2028 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2029 let client = {
2031 let controller = player.0.lock().await;
2032 let client_arc = controller.jellyfin_client();
2033 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2034 client_opt
2035 .as_ref()
2036 .ok_or("Jellyfin client not configured")?
2037 .clone()
2038 };
2039 client
2040 .send_session_command(session_id, "ToggleMute")
2041 .await?;
2042 } else {
2043 }
2048
2049 let controller = player.0.lock().await;
2050 Ok(get_player_status(&controller))
2051}
2052
2053#[tauri::command]
2054#[specta::specta]
2055pub async fn player_toggle_shuffle(
2056 player: State<'_, PlayerStateWrapper>,
2057) -> Result<QueueStatus, String> {
2058 let controller = player.0.lock().await;
2059 controller.toggle_shuffle();
2060 controller.emit_queue_changed();
2061 Ok(get_queue_status(&controller))
2062}
2063
2064#[tauri::command]
2065#[specta::specta]
2066pub async fn player_cycle_repeat(
2067 player: State<'_, PlayerStateWrapper>,
2068) -> Result<QueueStatus, String> {
2069 let controller = player.0.lock().await;
2070 controller.cycle_repeat();
2071 controller.emit_queue_changed();
2072 Ok(get_queue_status(&controller))
2073}
2074
2075#[tauri::command]
2076#[specta::specta]
2077pub async fn player_get_status(
2078 player: State<'_, PlayerStateWrapper>,
2079 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2080) -> Result<PlayerStatus, String> {
2081 let mode = playback_mode.0.get_mode();
2082
2083 let controller = player.0.lock().await;
2085 let mut status = get_player_status(&controller);
2086
2087 let local_media = {
2089 let queue_arc = controller.queue();
2090 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
2091 queue.current().map(MergedMediaItem::from)
2092 };
2093
2094 let local_is_playing = status.state.is_playing();
2095 let local_volume = status.volume;
2096
2097 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2099 let client = {
2100 let client_arc = controller.jellyfin_client();
2101 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2102 client_opt
2103 .as_ref()
2104 .ok_or("Jellyfin client not configured")?
2105 .clone()
2106 };
2107
2108 drop(controller); match client.get_session(&session_id).await {
2111 Ok(Some(session)) => {
2112 log::info!("[PlayerCommands] Merging remote session state");
2113
2114 status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2116
2117 status.merged_is_playing = session
2119 .play_state
2120 .as_ref()
2121 .map(|ps| !ps.is_paused.unwrap_or(true))
2122 .unwrap_or(false);
2123
2124 status.position = session
2126 .play_state
2127 .as_ref()
2128 .and_then(|ps| ps.position_ticks)
2129 .map(|ticks| ticks as f64 / 10_000_000.0)
2130 .unwrap_or(0.0);
2131
2132 status.duration = session
2134 .now_playing_item
2135 .as_ref()
2136 .and_then(|item| item.run_time_ticks)
2137 .map(|ticks| ticks as f64 / 10_000_000.0);
2138
2139 status.merged_volume = session
2141 .play_state
2142 .as_ref()
2143 .and_then(|ps| ps.volume_level)
2144 .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2145 .unwrap_or(1.0);
2146
2147 return Ok(status);
2148 }
2149 Ok(None) => {
2150 log::warn!("[PlayerCommands] Remote session not found, using local state");
2151 }
2152 Err(e) => {
2153 log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2154 }
2155 }
2156 }
2157
2158 status.merged_media = local_media;
2160 status.merged_is_playing = local_is_playing;
2161 status.merged_volume = local_volume;
2162
2163 Ok(status)
2164}
2165
2166#[tauri::command]
2167#[specta::specta]
2168pub async fn player_get_queue(
2169 player: State<'_, PlayerStateWrapper>,
2170) -> Result<QueueStatus, String> {
2171 let controller = player.0.lock().await;
2172 Ok(get_queue_status(&controller))
2173}
2174
2175#[derive(specta::Type, Debug, Serialize)]
2184#[serde(rename_all = "camelCase")]
2185pub struct PlaybackCapabilities {
2186 pub uses_webview_audio: bool,
2190 pub supports_native_video: bool,
2195 pub webview_video_fallback: bool,
2199}
2200
2201pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
2213 !is_android && native_video_enabled
2214}
2215
2216#[tauri::command]
2220#[specta::specta]
2221pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2222 let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2224
2225 Ok(PlaybackCapabilities {
2226 uses_webview_audio: !native_audio,
2227 supports_native_video: cfg!(target_os = "android")
2229 || crate::player::native_video::enabled(),
2230 webview_video_fallback: webview_video_fallback(
2232 cfg!(target_os = "android"),
2233 crate::player::native_video::enabled(),
2234 ),
2235 })
2236}
2237
2238pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2239 let (backend, use_html5_element) = if cfg!(target_os = "android") {
2241 (VideoBackend::Native, false)
2243 } else if crate::player::native_video::enabled() {
2244 (VideoBackend::Native, false)
2248 } else {
2249 (VideoBackend::Html5, true)
2251 };
2252
2253 PlayerStatus {
2254 state: controller.state(),
2255 position: controller.absolute_position(),
2260 duration: controller.duration(),
2261 volume: controller.volume(),
2262 muted: controller.muted(),
2263 shuffle: controller.is_shuffle(),
2264 repeat: controller.repeat_mode(),
2265 backend,
2266 use_html5_element,
2267
2268 merged_media: None,
2270 merged_is_playing: false,
2271 merged_volume: controller.volume(),
2272 }
2273}
2274
2275pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2276 let queue = controller.queue();
2277 let queue_lock = queue.lock_safe();
2278
2279 QueueStatus {
2280 items: queue_lock.items().to_vec(),
2281 current_index: queue_lock.current_index(),
2282 shuffle: queue_lock.is_shuffle(),
2283 repeat: queue_lock.repeat_mode(),
2284 has_next: queue_lock.has_next(),
2285 has_previous: queue_lock.has_previous(),
2286 }
2287}
2288
2289async fn play_selection_on_remote(
2301 controller: &PlayerController,
2302 session_id: &str,
2303 media_items: &[MediaItem],
2304 start_index: usize,
2305) -> Result<(), String> {
2306 let mut jellyfin_ids: Vec<String> = Vec::new();
2309 let mut adjusted_index: Option<usize> = None;
2310 for (i, item) in media_items.iter().enumerate() {
2311 if let Some(id) = item.jellyfin_id() {
2312 if i == start_index {
2313 adjusted_index = Some(jellyfin_ids.len());
2314 }
2315 jellyfin_ids.push(id.to_string());
2316 }
2317 }
2318
2319 let start_index =
2320 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2321
2322 if jellyfin_ids.is_empty() {
2323 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2324 }
2325
2326 let client = {
2327 let client_arc = controller.jellyfin_client();
2328 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2329 client_opt
2330 .as_ref()
2331 .ok_or("Jellyfin client not configured")?
2332 .clone()
2333 };
2334
2335 client
2337 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2338 .await
2339 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2340}
2341
2342#[tauri::command]
2344#[specta::specta]
2345pub async fn player_play_album_track(
2346 player: State<'_, PlayerStateWrapper>,
2347 session: State<'_, MediaSessionManagerWrapper>,
2348 db: State<'_, DatabaseWrapper>,
2349 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2350 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2351 repository_handle: String,
2352 request: PlayAlbumTrackRequest,
2353) -> Result<PlayerStatus, String> {
2354 info!(
2355 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2356 request.album_id, request.track_id, request.shuffle
2357 );
2358
2359 let repository = repository_manager
2361 .0
2362 .get(&repository_handle)
2363 .ok_or("Repository not found - user may need to log in")?;
2364
2365 info!(
2367 "Fetching tracks for album {} via repository",
2368 request.album_id
2369 );
2370 let album_items = repository
2371 .get_items(
2372 &request.album_id,
2373 Some(GetItemsOptions {
2374 limit: Some(1000),
2375 fields: Some(vec![
2376 "PrimaryImageAspectRatio".to_string(),
2377 "Overview".to_string(),
2378 "MediaStreams".to_string(),
2379 ]),
2380 ..Default::default()
2381 }),
2382 )
2383 .await
2384 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2385
2386 info!("Found {} items in album", album_items.items.len());
2387
2388 let mut tracks: Vec<_> = album_items
2390 .items
2391 .into_iter()
2392 .filter(|item| item.item_type == "Audio")
2393 .collect();
2394 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2395
2396 info!("Found {} audio tracks in album", tracks.len());
2397
2398 if tracks.is_empty() {
2399 return Err("No audio tracks found in album".to_string());
2400 }
2401
2402 info!("Album has {} tracks after sorting:", tracks.len());
2404 for (idx, track) in tracks.iter().enumerate() {
2405 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2406 }
2407
2408 info!("Looking for track_id: {}", request.track_id);
2411 let album_index = tracks
2412 .iter()
2413 .position(|t| t.id == request.track_id)
2414 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2415
2416 info!(
2417 "Track {} is at index {} in album",
2418 request.track_id, album_index
2419 );
2420
2421 let mut media_items = Vec::new();
2423 for track in tracks {
2424 let jellyfin_id = &track.id;
2426 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2427
2428 let source = if let Some(path) = local_path {
2429 MediaSource::Local {
2430 file_path: PathBuf::from(path),
2431 jellyfin_item_id: Some(jellyfin_id.clone()),
2432 }
2433 } else {
2434 match repository.get_audio_stream_url(&track.id).await {
2438 Ok(stream_url) => MediaSource::Remote {
2439 stream_url,
2440 jellyfin_item_id: jellyfin_id.clone(),
2441 },
2442 Err(e) => {
2443 warn!(
2444 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2445 track.name, track.id, e
2446 );
2447 continue;
2448 }
2449 }
2450 };
2451
2452 let primary_image_tag_for_url = track.primary_image_tag.clone();
2453 let media_item = MediaItem {
2454 transport: None,
2456 id: track.id.clone(),
2457 title: track.name.clone(),
2458 name: Some(track.name.clone()), artist: track
2460 .album_artist
2461 .clone()
2462 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2463 album: Some(request.album_name.clone()),
2464 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2466 artist_items: track.artist_items.clone(), artists: track.artists.clone(), primary_image_tag: track.primary_image_tag.clone(), image_id: track.primary_image_tag.clone(),
2470 item_type: Some(track.item_type.clone()), playlist_id: None,
2472 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2473 artwork_url: primary_image_tag_for_url.map(|tag| {
2474 repository.get_image_url(
2475 &request.album_id,
2476 ImageType::Primary,
2477 Some(ImageOptions {
2478 max_width: Some(300),
2479 tag: Some(tag),
2480 ..Default::default()
2481 }),
2482 )
2483 }),
2484 media_type: MediaType::Audio,
2485 source,
2486 video_codec: None,
2487 needs_transcoding: false,
2488 video_width: None,
2489 video_height: None,
2490 subtitles: vec![],
2491 series_id: None,
2492 server_id: None,
2493 };
2494
2495 media_items.push(media_item);
2496 }
2497
2498 if media_items.is_empty() {
2499 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2500 }
2501
2502 let start_index = media_items
2506 .iter()
2507 .position(|item| item.id == request.track_id)
2508 .unwrap_or(0);
2509
2510 info!(
2511 "Built queue with {} media items, starting at index {}",
2512 media_items.len(),
2513 start_index
2514 );
2515
2516 if request.shuffle {
2518 let controller = player.0.lock().await;
2519 controller.toggle_shuffle();
2520 }
2521
2522 if let Some(first_item) = media_items.get(start_index) {
2524 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2525 session_mgr.start_audio_session(first_item.clone());
2526 }
2527
2528 let controller = player.0.lock().await;
2529
2530 let remote_session = match playback_mode.0.get_mode() {
2534 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2535 _ => None,
2536 };
2537
2538 if let Some(session_id) = &remote_session {
2539 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2540 controller
2541 .set_queue(media_items, start_index)
2542 .map_err(|e| e.to_string())?;
2543 } else {
2544 playback_mode
2547 .0
2548 .set_mode(crate::playback_mode::PlaybackMode::Local);
2549 controller
2550 .play_queue(media_items, start_index)
2551 .map_err(|e| e.to_string())?;
2552 }
2553
2554 {
2556 let queue_arc = controller.queue();
2557 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2558 queue.set_context(QueueContext::Album {
2559 album_id: request.album_id.clone(),
2560 album_name: request.album_name.clone(),
2561 });
2562 }
2563
2564 controller.emit_queue_changed();
2566
2567 {
2569 let queue_arc = controller.queue();
2570 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2571 info!(
2572 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2573 queue_lock.items().len(),
2574 queue_lock.current_index()
2575 );
2576 }
2577
2578 if let Some(emitter) = controller.event_emitter() {
2580 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2581 emitter.emit(PlayerStatusEvent::SessionChanged {
2582 session: session_mgr.current().clone(),
2583 });
2584 }
2585
2586 Ok(get_player_status(&controller))
2587}
2588
2589#[tauri::command]
2591#[specta::specta]
2592pub async fn player_play_tracks(
2593 player: State<'_, PlayerStateWrapper>,
2594 session: State<'_, MediaSessionManagerWrapper>,
2595 db: State<'_, DatabaseWrapper>,
2596 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2597 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2598 repository_handle: String,
2599 request: PlayTracksRequest,
2600) -> Result<PlayerStatus, String> {
2601 crate::repository::online::clear_playback_quality_override();
2608
2609 info!(
2610 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2611 request.track_ids.len(),
2612 request.start_index,
2613 request.shuffle
2614 );
2615
2616 if request.track_ids.is_empty() {
2618 return Err("No tracks provided".to_string());
2619 }
2620
2621 let repository = repository_manager
2623 .0
2624 .get(&repository_handle)
2625 .ok_or("Repository not found - user may need to log in")?;
2626
2627 let mut media_items = Vec::new();
2629 for track_id in &request.track_ids {
2630 let track = repository
2632 .get_item(track_id)
2633 .await
2634 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2635
2636 let local_path = check_for_local_download(&db, track_id).await?;
2638
2639 let source = if let Some(path) = local_path {
2641 MediaSource::Local {
2642 file_path: PathBuf::from(path),
2643 jellyfin_item_id: Some(track.id.clone()),
2644 }
2645 } else {
2646 let stream_url = repository
2647 .get_audio_stream_url(track_id)
2648 .await
2649 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2650
2651 MediaSource::Remote {
2652 stream_url,
2653 jellyfin_item_id: track.id.clone(),
2654 }
2655 };
2656
2657 let primary_image_tag_for_url = track.primary_image_tag.clone();
2659 let media_item = MediaItem {
2660 transport: None,
2662 id: track.id.clone(),
2663 title: track.name.clone(),
2664 name: Some(track.name.clone()), artist: track
2666 .album_artist
2667 .clone()
2668 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2669 album: track.album_name.clone(),
2670 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2672 artist_items: track.artist_items.clone(), artists: track.artists.clone(), primary_image_tag: track.primary_image_tag.clone(), image_id: track.primary_image_tag.clone(),
2676 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2679 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2680 track.album_id.as_ref().map(|album_id| {
2681 repository.get_image_url(
2682 album_id,
2683 ImageType::Primary,
2684 Some(ImageOptions {
2685 max_width: Some(300),
2686 tag: Some(tag),
2687 ..Default::default()
2688 }),
2689 )
2690 })
2691 }),
2692 media_type: MediaType::Audio,
2693 source,
2694 video_codec: None,
2695 needs_transcoding: false,
2696 video_width: None,
2697 video_height: None,
2698 subtitles: vec![],
2699 series_id: None,
2700 server_id: None,
2701 };
2702
2703 media_items.push(media_item);
2704 }
2705
2706 info!("Built queue with {} media items", media_items.len());
2707
2708 let queue_context = match request.context {
2710 PlayTracksContext::Playlist {
2711 playlist_id,
2712 playlist_name,
2713 } => {
2714 for item in &mut media_items {
2715 item.playlist_id = Some(playlist_id.clone());
2716 }
2717 QueueContext::Playlist {
2718 playlist_id,
2719 playlist_name,
2720 }
2721 }
2722 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2723 };
2724
2725 if request.shuffle {
2727 let controller = player.0.lock().await;
2728 controller.toggle_shuffle();
2729 }
2730
2731 if let Some(first_item) = media_items.get(request.start_index) {
2733 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2734 session_mgr.start_audio_session(first_item.clone());
2735 }
2736
2737 let controller = player.0.lock().await;
2738
2739 let remote_session = match playback_mode.0.get_mode() {
2745 crate::playback_mode::PlaybackMode::Remote { session_id }
2746 if !playback_mode.0.is_transferring() =>
2747 {
2748 Some(session_id)
2749 }
2750 _ => None,
2751 };
2752
2753 if let Some(session_id) = &remote_session {
2754 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2755 .await?;
2756 controller
2757 .set_queue(media_items, request.start_index)
2758 .map_err(|e| e.to_string())?;
2759 } else {
2760 if !playback_mode.0.is_transferring() {
2768 playback_mode
2769 .0
2770 .set_mode(crate::playback_mode::PlaybackMode::Local);
2771 }
2772 controller
2773 .play_queue_from(media_items, request.start_index, request.start_position)
2774 .map_err(|e| e.to_string())?;
2775 }
2776
2777 {
2779 let queue_arc = controller.queue();
2780 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2781 queue.set_context(queue_context);
2782 }
2783
2784 controller.emit_queue_changed();
2786 if let Some(emitter) = controller.event_emitter() {
2787 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2788 emitter.emit(PlayerStatusEvent::SessionChanged {
2789 session: session_mgr.current().clone(),
2790 });
2791 }
2792
2793 info!("player_play_tracks completed successfully");
2794 Ok(get_player_status(&controller))
2795}
2796
2797#[derive(specta::Type, Debug, Serialize)]
2799#[serde(rename_all = "camelCase")]
2800pub struct PreloadResult {
2801 pub queued_count: usize,
2803 pub already_downloaded: usize,
2805 pub skipped: usize,
2807}
2808
2809#[tauri::command]
2812#[specta::specta]
2813pub async fn player_preload_upcoming(
2814 player: State<'_, PlayerStateWrapper>,
2815 db: State<'_, DatabaseWrapper>,
2816 smart_cache: State<'_, SmartCacheWrapper>,
2817 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2818 app: tauri::AppHandle,
2819 user_id: String,
2820 _download_base_path: String,
2821) -> Result<PreloadResult, String> {
2822 let (db_service, target_dir) = {
2826 let database = db.0.lock().map_err(|e| e.to_string())?;
2827 let target_dir = database
2828 .path()
2829 .parent()
2830 .ok_or_else(|| "Database path has no parent directory".to_string())?
2831 .to_string_lossy()
2832 .to_string();
2833 (Arc::new(database.service()), target_dir)
2834 };
2835
2836 let precache_count = {
2838 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2839 if !cache.should_precache_queue() {
2840 return Ok(PreloadResult {
2841 queued_count: 0,
2842 already_downloaded: 0,
2843 skipped: 0,
2844 });
2845 }
2846 cache.queue_precache_count()
2847 };
2848
2849 let upcoming_items: Vec<MediaItem> = {
2851 let controller = player.0.lock().await;
2852 let queue = controller.queue();
2853 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2854 queue_lock
2855 .get_upcoming(precache_count)
2856 .into_iter()
2857 .cloned()
2858 .collect()
2859 };
2860
2861 if upcoming_items.is_empty() {
2862 return Ok(PreloadResult {
2863 queued_count: 0,
2864 already_downloaded: 0,
2865 skipped: 0,
2866 });
2867 }
2868
2869 let mut queued_count = 0;
2870 let mut already_downloaded = 0;
2871 let mut skipped = 0;
2872
2873 for item in upcoming_items {
2875 let (jellyfin_id, stream_url) = match &item.source {
2879 MediaSource::Remote {
2880 jellyfin_item_id,
2881 stream_url,
2882 } => (jellyfin_item_id.clone(), stream_url.clone()),
2883 MediaSource::Local { .. } => {
2884 already_downloaded += 1;
2885 continue;
2886 }
2887 MediaSource::DirectUrl { .. } => {
2888 skipped += 1;
2889 continue;
2890 }
2891 };
2892
2893 let query = Query::with_params(
2897 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2898 AND (status IN ('completed', 'downloading')
2899 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2900 vec![
2901 QueryParam::String(jellyfin_id.clone()),
2902 QueryParam::String(user_id.clone()),
2903 ],
2904 );
2905
2906 let is_downloaded: Option<String> = db_service
2907 .query_optional(query, |row| row.get(0))
2908 .await
2909 .map_err(|e| e.to_string())?;
2910
2911 if is_downloaded.is_some() {
2912 already_downloaded += 1;
2913 continue;
2914 }
2915
2916 let album_dir = item
2919 .album
2920 .as_deref()
2921 .filter(|a| !a.is_empty())
2922 .unwrap_or("Unknown Album");
2923 let file_path = format!(
2924 "downloads/{}/{}.mp3",
2925 sanitize_filename(album_dir),
2926 sanitize_filename(&item.title)
2927 );
2928
2929 let insert_query = Query::with_params(
2933 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
2934 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2935 ON CONFLICT(item_id, user_id) DO UPDATE SET
2936 stream_url = excluded.stream_url,
2937 target_dir = excluded.target_dir
2938 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2939 vec![
2940 QueryParam::String(jellyfin_id),
2941 QueryParam::String(user_id.clone()),
2942 QueryParam::String(file_path),
2943 QueryParam::String(item.title.clone()),
2944 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2945 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2946 QueryParam::String(stream_url),
2947 QueryParam::String(target_dir.clone()),
2948 ],
2949 );
2950
2951 match db_service.execute(insert_query).await {
2952 Ok(rows) if rows > 0 => {
2953 info!(
2954 "[Preload] Queued download for: {} - {}",
2955 item.artist.as_deref().unwrap_or("Unknown"),
2956 item.title
2957 );
2958 queued_count += 1;
2959 }
2960 Ok(_) => {
2961 already_downloaded += 1;
2963 }
2964 Err(e) => {
2965 error!("[Preload] Failed to queue {}: {}", item.title, e);
2966 skipped += 1;
2967 }
2968 }
2969 }
2970
2971 if queued_count > 0 {
2974 let active_downloads = {
2975 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2976 manager.get_active_downloads()
2977 };
2978 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2979 }
2980
2981 info!(
2982 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2983 queued_count, already_downloaded, skipped
2984 );
2985
2986 Ok(PreloadResult {
2987 queued_count,
2988 already_downloaded,
2989 skipped,
2990 })
2991}
2992
2993fn sanitize_filename(name: &str) -> String {
2995 name.chars()
2996 .map(|c| match c {
2997 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2998 _ => c,
2999 })
3000 .collect()
3001}
3002
3003#[tauri::command]
3005#[specta::specta]
3006pub async fn player_set_cache_config(
3007 smart_cache: State<'_, SmartCacheWrapper>,
3008 config: CacheConfig,
3009) -> Result<(), String> {
3010 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3011 cache.update_config(config);
3012 Ok(())
3013}
3014
3015#[tauri::command]
3017#[specta::specta]
3018pub async fn player_get_cache_config(
3019 smart_cache: State<'_, SmartCacheWrapper>,
3020) -> Result<CacheConfig, String> {
3021 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3022 Ok(cache.get_config().unwrap_or_default())
3023}
3024
3025#[tauri::command]
3027#[specta::specta]
3028pub async fn player_configure_jellyfin(
3029 player: State<'_, PlayerStateWrapper>,
3030 db: State<'_, DatabaseWrapper>,
3031 server_url: String,
3032 access_token: String,
3033 user_id: String,
3034 device_id: String,
3035) -> Result<(), String> {
3036 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
3037
3038 let config = JellyfinConfig {
3039 server_url,
3040 access_token,
3041 device_id,
3042 };
3043
3044 let client = JellyfinClient::new(config.clone())?;
3046
3047 let db_service = {
3054 let database = db.0.lock().map_err(|e| e.to_string())?;
3055 Arc::new(database.service())
3056 };
3057 let reporter_client = JellyfinClient::new(config)?;
3058 let reporter = crate::playback_reporting::PlaybackReporter::new(
3059 db_service,
3060 Arc::new(TokioMutex::new(Some(reporter_client))),
3061 user_id,
3062 );
3063
3064 let controller = player.0.lock().await;
3065 controller.set_jellyfin_client(Some(client));
3066 controller.set_playback_reporter(Some(reporter)).await;
3067
3068 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
3069 Ok(())
3070}
3071
3072#[tauri::command]
3074#[specta::specta]
3075pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
3076 log::info!("[PlayerCommand] Disabling Jellyfin client");
3077
3078 let controller = player.0.lock().await;
3079 controller.set_jellyfin_client(None);
3080 controller.set_playback_reporter(None).await;
3081
3082 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
3083 Ok(())
3084}
3085
3086#[cfg(test)]
3087mod tests {
3088 use crate::utils::lock::MutexSafe;
3089
3090 #[test]
3100 fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
3101 use super::webview_video_fallback;
3102
3103 assert!(
3104 !webview_video_fallback(true, false),
3105 "Android: ExoPlayer is the only video renderer"
3106 );
3107 assert!(
3108 !webview_video_fallback(true, true),
3109 "Android never falls back, whatever else is switched on"
3110 );
3111 assert!(
3112 webview_video_fallback(false, true),
3113 "Linux with mpv native video: the webview is the fallback"
3114 );
3115 assert!(
3116 !webview_video_fallback(false, false),
3117 "the webview is the only renderer; nothing to fall back from"
3118 );
3119 }
3120
3121 #[test]
3132 fn test_normalize_volume_clamps_and_rejects_nan() {
3133 use super::normalize_volume;
3134
3135 assert_eq!(normalize_volume(0.0), 0.0);
3137 assert_eq!(normalize_volume(0.5), 0.5);
3138 assert_eq!(normalize_volume(1.0), 1.0);
3139
3140 assert_eq!(normalize_volume(-0.5), 0.0);
3142 assert_eq!(normalize_volume(42.0), 1.0);
3143 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
3144 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
3145
3146 let from_nan = normalize_volume(f32::NAN);
3149 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3150 assert_eq!(from_nan, 0.0);
3151
3152 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3154 let percent = (normalize_volume(input) * 100.0) as i32;
3155 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3156 }
3157 }
3158
3159 #[tokio::test]
3176 async fn test_play_item_request_carries_subtitles_into_media_item() {
3177 use super::{create_media_item, PlayItemRequest};
3178
3179 let payload = serde_json::json!({
3180 "id": "ep-1",
3181 "title": "Pilot",
3182 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3183 "videoCodec": "h264",
3184 "needsTranscoding": false,
3185 "subtitles": [
3186 {
3187 "index": 2,
3188 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3189 "language": "eng",
3190 "label": "English (SRT)",
3191 "mime_type": "text/vtt"
3192 },
3193 {
3194 "index": 3,
3195 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3196 "language": null,
3197 "label": null,
3198 "mime_type": "text/vtt"
3199 }
3200 ]
3201 });
3202
3203 let req: PlayItemRequest =
3204 serde_json::from_value(payload).expect("frontend payload must deserialize");
3205 assert_eq!(
3206 req.subtitles.len(),
3207 2,
3208 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3209 );
3210
3211 let media = create_media_item(req, None).await.unwrap();
3212 assert_eq!(
3213 media.subtitles.len(),
3214 2,
3215 "create_media_item must thread the tracks onto the MediaItem the backend loads"
3216 );
3217 assert_eq!(media.subtitles[0].index, 2);
3218 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3219 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3220 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3221 assert_eq!(media.subtitles[1].index, 3);
3224 assert!(media.subtitles[1].language.is_none());
3225 }
3226
3227 #[tokio::test]
3233 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3234 use super::{create_media_item, PlayItemRequest};
3235
3236 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3237 "id": "movie-1",
3238 "title": "Movie",
3239 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3240 "videoCodec": "h264",
3241 "needsTranscoding": false
3242 }))
3243 .expect("a subtitle-less payload must still deserialize");
3244
3245 assert!(req.subtitles.is_empty());
3246 assert!(create_media_item(req, None)
3247 .await
3248 .unwrap()
3249 .subtitles
3250 .is_empty());
3251 }
3252
3253 #[test]
3265 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3266 use crate::player::media::SubtitleTrack;
3267
3268 let subtitles = vec![SubtitleTrack {
3269 index: 2,
3270 url: "https://jelly.example/subs.vtt".to_string(),
3271 language: Some("eng".to_string()),
3272 label: Some("English".to_string()),
3273 mime_type: "text/vtt".to_string(),
3274 }];
3275
3276 let json = serde_json::to_string(&subtitles).unwrap();
3278 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3279 let obj = parsed[0].as_object().unwrap();
3280
3281 for key in ["url", "language", "label", "mime_type"] {
3282 assert!(
3283 obj.contains_key(key),
3284 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3285 obj.keys().collect::<Vec<_>>()
3286 );
3287 }
3288 assert!(
3289 !obj.contains_key("mimeType"),
3290 "camelCasing mime_type silently drops every track's MIME type on Android"
3291 );
3292 }
3293
3294 #[test]
3299 fn test_background_audio_source_prefers_local_file() {
3300 use super::background_audio_source;
3301 use crate::player::MediaSource;
3302 use std::path::PathBuf;
3303
3304 let local = background_audio_source(
3305 Some("/downloads/ep1.mkv".to_string()),
3306 "https://server/audio-only".to_string(),
3307 "ep-1",
3308 );
3309 match local {
3310 MediaSource::Local {
3311 file_path,
3312 jellyfin_item_id,
3313 } => {
3314 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3315 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3317 }
3318 other => panic!("expected a local source, got {:?}", other),
3319 }
3320
3321 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3322 match remote {
3323 MediaSource::Remote {
3324 stream_url,
3325 jellyfin_item_id,
3326 } => {
3327 assert_eq!(stream_url, "https://server/audio-only");
3328 assert_eq!(jellyfin_item_id, "ep-1");
3329 }
3330 other => panic!("expected a remote source, got {:?}", other),
3331 }
3332 }
3333
3334 #[test]
3347 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3348 use super::background_audio_plan;
3349
3350 let local = background_audio_plan(true, 1104.0);
3351 assert_eq!(local.base_seconds, 0.0);
3352 assert_eq!(local.seek_to, Some(1104.0));
3353
3354 let streamed = background_audio_plan(false, 1104.0);
3355 assert_eq!(streamed.base_seconds, 1104.0);
3356 assert_eq!(
3357 streamed.seek_to, None,
3358 "the URL already starts at the handoff point; seeking again skips past it"
3359 );
3360 }
3361
3362 #[test]
3367 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3368 use super::background_audio_plan;
3369
3370 for local in [true, false] {
3371 let plan = background_audio_plan(local, 0.0);
3372 assert_eq!(plan.base_seconds, 0.0);
3373 assert_eq!(plan.seek_to, None);
3374 }
3375 }
3376
3377 #[tokio::test]
3383 async fn test_resolve_local_media_path() {
3384 use super::resolve_local_media_path;
3385 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3386 use rusqlite::Connection;
3387 use std::sync::{Arc, Mutex};
3388
3389 let conn = Connection::open_in_memory().unwrap();
3390 conn.execute(
3391 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3392 [],
3393 )
3394 .unwrap();
3395 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3396
3397 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3399 std::fs::write(&present, b"x").unwrap();
3400 let present_str = present.to_string_lossy().to_string();
3401
3402 for (item, status, path) in [
3403 ("downloaded", "completed", present_str.as_str()),
3404 ("still-going", "downloading", present_str.as_str()),
3405 (
3406 "file-gone",
3407 "completed",
3408 "/nonexistent/jellytau/missing.mp4",
3409 ),
3410 ] {
3411 db_service
3412 .execute(Query::with_params(
3413 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3414 vec![
3415 crate::storage::db_service::QueryParam::String(item.to_string()),
3416 crate::storage::db_service::QueryParam::String(status.to_string()),
3417 crate::storage::db_service::QueryParam::String(path.to_string()),
3418 ],
3419 ))
3420 .await
3421 .unwrap();
3422 }
3423
3424 assert_eq!(
3425 resolve_local_media_path(&db_service, "downloaded")
3426 .await
3427 .unwrap()
3428 .as_deref(),
3429 Some(present_str.as_str()),
3430 "a completed download with its file present must resolve"
3431 );
3432
3433 assert_eq!(
3434 resolve_local_media_path(&db_service, "still-going")
3435 .await
3436 .unwrap(),
3437 None,
3438 "an in-progress download is not playable from disk"
3439 );
3440
3441 assert_eq!(
3442 resolve_local_media_path(&db_service, "file-gone")
3443 .await
3444 .unwrap(),
3445 None,
3446 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3447 );
3448
3449 assert_eq!(
3450 resolve_local_media_path(&db_service, "never-heard-of-it")
3451 .await
3452 .unwrap(),
3453 None
3454 );
3455
3456 let _ = std::fs::remove_file(&present);
3457 }
3458
3459 #[tokio::test]
3463 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3464 use super::{refresh_queue_local_sources, DatabaseWrapper};
3465 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3466 use crate::storage::Database;
3467 use std::sync::Mutex;
3468
3469 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3472 std::fs::create_dir_all(&dir).unwrap();
3473 let existing = dir.join("track-a.mp3");
3474 std::fs::write(&existing, b"audio").unwrap();
3475 let missing = dir.join("track-b-missing.mp3");
3476 let _ = std::fs::remove_file(&missing);
3477
3478 let database = Database::open_in_memory().unwrap();
3479 {
3480 let conn = database.connection();
3481 let conn = conn.lock_safe();
3482 conn.execute_batch(&format!(
3483 r#"
3484 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3485 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3486 INSERT INTO downloads (item_id, user_id, file_path, status)
3487 VALUES ('track-a', 'user1', '{}', 'completed');
3488 INSERT INTO downloads (item_id, user_id, file_path, status)
3489 VALUES ('track-b', 'user1', '{}', 'completed');
3490 "#,
3491 existing.display(),
3492 missing.display()
3493 ))
3494 .unwrap();
3495 }
3496 let db = DatabaseWrapper(Mutex::new(database));
3497
3498 let make_item = |id: &str| MediaItem {
3499 transport: None,
3501 id: id.to_string(),
3502 title: id.to_string(),
3503 name: None,
3504 artist: None,
3505 album: None,
3506 album_name: None,
3507 album_id: None,
3508 artist_items: None,
3509 artists: None,
3510 primary_image_tag: None,
3511 image_id: None,
3512 item_type: None,
3513 playlist_id: None,
3514 duration: None,
3515 artwork_url: None,
3516 media_type: MediaType::Audio,
3517 source: MediaSource::Remote {
3518 stream_url: format!("http://test/Audio/{}/stream", id),
3519 jellyfin_item_id: id.to_string(),
3520 },
3521 video_codec: None,
3522 needs_transcoding: false,
3523 video_width: None,
3524 video_height: None,
3525 subtitles: vec![],
3526 series_id: None,
3527 server_id: None,
3528 };
3529
3530 let controller = PlayerController::default();
3531 controller
3532 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3533 .unwrap();
3534
3535 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3536 assert_eq!(switched, 1, "only the download whose file exists switches");
3537
3538 let queue = controller.queue();
3539 let queue_lock = queue.lock_safe();
3540 match &queue_lock.items()[0].source {
3541 MediaSource::Local {
3542 file_path,
3543 jellyfin_item_id,
3544 } => {
3545 assert_eq!(file_path, &existing);
3546 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3547 }
3548 other => panic!("track-a should be local, got {:?}", other),
3549 }
3550 assert!(
3551 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3552 "track-b's file is missing, it must stay remote"
3553 );
3554 }
3555
3556 #[test]
3559 fn test_find_track_index_in_album() {
3560 #[derive(Clone)]
3562 struct MockTrack {
3563 id: String,
3564 name: String,
3565 index_number: Option<i32>,
3566 }
3567
3568 let mut tracks = [
3569 MockTrack {
3570 id: "track1".to_string(),
3571 name: "Song 1".to_string(),
3572 index_number: Some(1),
3573 },
3574 MockTrack {
3575 id: "track2".to_string(),
3576 name: "Song 2".to_string(),
3577 index_number: Some(2),
3578 },
3579 MockTrack {
3580 id: "track3".to_string(),
3581 name: "Song 3".to_string(),
3582 index_number: Some(3),
3583 },
3584 MockTrack {
3585 id: "track4".to_string(),
3586 name: "Song 4".to_string(),
3587 index_number: Some(4),
3588 },
3589 MockTrack {
3590 id: "track5".to_string(),
3591 name: "Song 5".to_string(),
3592 index_number: Some(5),
3593 },
3594 ];
3595
3596 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3598
3599 let index1 = tracks.iter().position(|t| t.id == "track1");
3601 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3602 assert_eq!(
3603 tracks[0].name, "Song 1",
3604 "Track at index 0 should carry its name"
3605 );
3606
3607 let index3 = tracks.iter().position(|t| t.id == "track3");
3609 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3610
3611 let index5 = tracks.iter().position(|t| t.id == "track5");
3613 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3614
3615 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3617 assert_eq!(index_none, None, "Non-existent track should return None");
3618 }
3619
3620 #[test]
3622 fn test_track_iteration_order() {
3623 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3625
3626 let target_index = track_ids.iter().position(|&id| id == "id3");
3628 assert_eq!(
3629 target_index,
3630 Some(2),
3631 "id3 should be at index 2 in original list"
3632 );
3633
3634 let mut media_items = Vec::new();
3636 for id in &track_ids {
3637 media_items.push(id.to_string());
3638 }
3639
3640 assert_eq!(media_items.len(), 5);
3642 assert_eq!(media_items[0], "id1");
3643 assert_eq!(media_items[2], "id3");
3644 assert_eq!(media_items[4], "id5");
3645
3646 assert_eq!(media_items[target_index.unwrap()], "id3");
3648 }
3649
3650 #[test]
3652 fn test_album_track_sorting() {
3653 #[derive(Clone, Debug)]
3654 struct MockTrack {
3655 id: String,
3656 name: String,
3657 index_number: Option<i32>,
3658 }
3659
3660 let mut tracks = [
3662 MockTrack {
3663 id: "id5".to_string(),
3664 name: "Track 5".to_string(),
3665 index_number: Some(5),
3666 },
3667 MockTrack {
3668 id: "id1".to_string(),
3669 name: "Track 1".to_string(),
3670 index_number: Some(1),
3671 },
3672 MockTrack {
3673 id: "id3".to_string(),
3674 name: "Track 3".to_string(),
3675 index_number: Some(3),
3676 },
3677 MockTrack {
3678 id: "id2".to_string(),
3679 name: "Track 2".to_string(),
3680 index_number: Some(2),
3681 },
3682 MockTrack {
3683 id: "id4".to_string(),
3684 name: "Track 4".to_string(),
3685 index_number: Some(4),
3686 },
3687 ];
3688
3689 let requested_track_id = "id1";
3691
3692 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3694
3695 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3697
3698 assert_eq!(
3700 start_index,
3701 Some(0),
3702 "After sorting, track id1 should be at index 0"
3703 );
3704
3705 assert_eq!(tracks[0].id, "id1");
3707 assert_eq!(
3708 tracks[0].name, "Track 1",
3709 "Sorted track should retain its name"
3710 );
3711 assert_eq!(tracks[1].id, "id2");
3712 assert_eq!(tracks[2].id, "id3");
3713 assert_eq!(tracks[3].id, "id4");
3714 assert_eq!(tracks[4].id, "id5");
3715 }
3716}