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_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
29 PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
30};
31use crate::repository::{
32 types::{GetItemsOptions, ImageOptions, ImageType},
33 MediaRepository, StreamSelection,
34};
35use crate::settings::VideoSettings;
36use crate::storage::db_service::{DatabaseService, Query, QueryParam};
37
38pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
40
41pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
49
50pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
54
55pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
59
60#[derive(specta::Type, Debug, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub struct PlayerStatus {
64 pub state: PlayerState,
65 pub position: f64,
66 pub duration: Option<f64>,
67 pub volume: f32,
68 pub muted: bool,
69 pub shuffle: bool,
70 pub repeat: RepeatMode,
71 pub backend: VideoBackend,
73 pub use_html5_element: bool,
75
76 pub merged_media: Option<MergedMediaItem>,
79 pub merged_is_playing: bool,
81 pub merged_volume: f32,
83}
84
85#[derive(specta::Type, Debug, Serialize, Clone)]
88#[serde(rename_all = "camelCase")]
89pub struct MergedMediaItem {
90 pub id: String,
91 pub title: String,
92 pub artist: Option<String>,
93 pub album: Option<String>,
94 pub album_id: Option<String>,
95 pub duration: Option<f64>,
96 pub primary_image_tag: Option<String>,
97 pub image_id: Option<String>,
99 pub media_type: String,
100}
101
102impl From<&crate::player::MediaItem> for MergedMediaItem {
104 fn from(item: &crate::player::MediaItem) -> Self {
105 Self {
106 id: item.id.clone(),
107 title: item.title.clone(),
108 artist: item.artist.clone(),
109 album: item.album.clone(),
110 album_id: item.album_id.clone(),
111 duration: item.duration,
112 primary_image_tag: item.primary_image_tag.clone(),
113 image_id: item.primary_image_tag.clone(),
114 media_type: match item.media_type {
115 crate::player::MediaType::Audio => "audio".to_string(),
116 crate::player::MediaType::Video => "video".to_string(),
117 },
118 }
119 }
120}
121
122impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
124 fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
125 Self {
126 id: item.id.clone().unwrap_or_default(),
127 title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
128 artist: item
129 .album_artist
130 .clone()
131 .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
132 album: item.album.clone(),
133 album_id: item.album_id.clone(),
134 duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
135 primary_image_tag: item.primary_image_tag.clone(),
136 image_id: item.primary_image_tag.clone(),
137 media_type: item
138 .item_type
139 .clone()
140 .unwrap_or_else(|| "audio".to_string())
141 .to_lowercase(),
142 }
143 }
144}
145
146#[derive(specta::Type, Debug, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct QueueStatus {
150 pub items: Vec<MediaItem>,
151 pub current_index: Option<usize>,
152 pub shuffle: bool,
153 pub repeat: RepeatMode,
154 pub has_next: bool,
155 pub has_previous: bool,
156}
157
158#[derive(specta::Type, Debug, Serialize)]
160#[serde(rename_all = "lowercase")]
161pub enum VideoBackend {
162 Native,
164 Html5,
166}
167
168#[derive(specta::Type, Debug, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct PlayItemRequest {
175 pub id: String,
176 pub title: String,
177 pub stream_url: String,
178 pub video_codec: String,
180 pub needs_transcoding: bool,
182 #[serde(default)]
192 pub transport: Option<crate::repository::Transport>,
193
194 #[serde(default)]
198 pub artist: Option<String>,
199 #[serde(default)]
200 pub primary_image_tag: Option<String>,
201 #[serde(default)]
202 pub server_id: Option<String>,
203 #[serde(default)]
207 pub duration_seconds: Option<f64>,
208 #[serde(default)]
212 pub item_type: Option<String>,
213 #[serde(default)]
216 pub series_id: Option<String>,
217 #[serde(default)]
237 pub subtitles: Vec<crate::player::SubtitleTrack>,
238}
239
240#[derive(specta::Type, Debug, Deserialize)]
242#[serde(tag = "type", rename_all = "lowercase")]
243pub enum PlayQueueContext {
244 Album {
246 #[serde(rename = "albumId")]
247 album_id: String,
248 #[serde(rename = "albumName")]
249 album_name: String,
250 },
251 Playlist {
253 #[serde(rename = "playlistId")]
254 playlist_id: String,
255 #[serde(rename = "playlistName")]
256 playlist_name: String,
257 },
258 Custom,
260}
261
262#[derive(specta::Type, Debug, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct PlayQueueRequest {
266 pub items: Vec<PlayItemRequest>,
267 pub start_index: usize,
268 pub shuffle: bool,
269 #[serde(default)]
272 pub context: Option<PlayQueueContext>,
273}
274
275#[derive(specta::Type, Debug, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct PlayAlbumTrackRequest {
279 pub album_id: String,
280 pub album_name: String,
281 pub track_id: String,
282 pub shuffle: bool,
283}
284
285#[derive(specta::Type, Debug, Deserialize)]
287#[serde(rename_all = "camelCase")]
288pub struct PlayTracksRequest {
289 pub track_ids: Vec<String>,
290 pub start_index: usize,
291 pub shuffle: bool,
292 pub context: PlayTracksContext,
293 #[serde(default)]
296 pub start_position: Option<f64>,
297}
298
299#[derive(specta::Type, Debug, Deserialize)]
301#[serde(tag = "type", rename_all = "lowercase")]
302pub enum PlayTracksContext {
303 Playlist {
304 #[serde(rename = "playlistId")]
305 playlist_id: String,
306 #[serde(rename = "playlistName")]
307 playlist_name: String,
308 },
309 Search {
310 #[serde(rename = "searchQuery")]
311 #[allow(dead_code)] search_query: String,
313 },
314 Custom {
315 #[serde(rename = "label")]
316 #[allow(dead_code)] label: Option<String>,
318 },
319}
320
321#[derive(specta::Type, Debug, Serialize)]
323#[serde(tag = "strategy", rename_all = "camelCase")]
324pub enum VideoSeekResponse {
325 Native {
327 position: f64,
329 },
330 ReloadStream {
332 selection: StreamSelection,
336 seek_offset: f64,
342 },
343}
344
345#[derive(specta::Type, Debug, Serialize)]
347#[serde(tag = "strategy", rename_all = "camelCase")]
348pub enum AudioTrackSwitchResponse {
349 Native {
351 success: bool,
353 },
354 ReloadStream {
356 selection: StreamSelection,
358 position: f64,
360 },
361}
362
363#[derive(specta::Type, Debug, Serialize)]
370#[serde(tag = "strategy", rename_all = "camelCase")]
371pub enum StreamQualityResponse {
372 Native {
384 selection: StreamSelection,
387 position: f64,
389 },
390 ReloadStream {
392 selection: StreamSelection,
397 position: f64,
399 },
400}
401
402pub(super) async fn create_media_item(
407 req: PlayItemRequest,
408 db: Option<&DatabaseWrapper>,
409) -> Result<MediaItem, String> {
410 let jellyfin_id = req.id.clone();
412
413 let local_path = if let Some(db_wrapper) = db {
415 check_for_local_download(db_wrapper, &jellyfin_id).await?
416 } else {
417 None
418 };
419
420 let source = if let Some(path) = local_path {
421 MediaSource::Local {
422 file_path: PathBuf::from(path),
423 jellyfin_item_id: Some(jellyfin_id.clone()),
424 }
425 } else {
426 MediaSource::Remote {
427 stream_url: req.stream_url,
428 jellyfin_item_id: jellyfin_id.clone(),
429 }
430 };
431
432 Ok(MediaItem {
433 id: req.id.clone(),
434 title: req.title.clone(),
435 name: Some(req.title.clone()),
436 artist: None, album: None, album_name: None, album_id: None, artist_items: None, artists: None, primary_image_tag: None, image_id: None,
444 item_type: None, playlist_id: None, duration: None, artwork_url: None, media_type: crate::player::MediaType::Video, source,
450 video_codec: Some(req.video_codec),
451 needs_transcoding: req.needs_transcoding,
452 transport: req.transport,
454 video_width: None, video_height: None, subtitles: req.subtitles,
460 series_id: None, server_id: None, })
463}
464
465pub(super) fn background_audio_source(
477 local_path: Option<String>,
478 stream_url: String,
479 item_id: &str,
480) -> MediaSource {
481 match local_path {
482 Some(path) => MediaSource::Local {
483 file_path: PathBuf::from(path),
484 jellyfin_item_id: Some(item_id.to_string()),
485 },
486 None => MediaSource::Remote {
487 stream_url,
488 jellyfin_item_id: item_id.to_string(),
489 },
490 }
491}
492
493pub(super) struct BackgroundAudioPlan {
498 pub base_seconds: f64,
502 pub seek_to: Option<f64>,
504}
505
506pub(super) fn background_audio_plan(
518 is_local_file: bool,
519 position_seconds: f64,
520) -> BackgroundAudioPlan {
521 let position = position_seconds.max(0.0);
522
523 if is_local_file {
524 BackgroundAudioPlan {
525 base_seconds: 0.0,
526 seek_to: (position > 0.0).then_some(position),
527 }
528 } else {
529 BackgroundAudioPlan {
530 base_seconds: position,
531 seek_to: None,
532 }
533 }
534}
535
536pub(super) async fn resolve_local_media_path<S: DatabaseService>(
548 db_service: &Arc<S>,
549 item_id: &str,
550) -> Result<Option<String>, String> {
551 let query = Query::with_params(
552 "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
553 vec![QueryParam::String(item_id.to_string())],
554 );
555
556 let path: Option<String> = db_service
557 .query_optional(query, |row| row.get(0))
558 .await
559 .map_err(|e| e.to_string())?;
560
561 match path {
562 Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
563 Some(file_path) => {
564 warn!(
565 "[Player] Download entry exists in DB but file not found: {}",
566 file_path
567 );
568 Ok(None)
569 }
570 None => Ok(None),
571 }
572}
573
574pub(super) async fn check_for_local_download(
576 db: &DatabaseWrapper,
577 item_id: &str,
578) -> Result<Option<String>, String> {
579 let db_service = {
580 let database = db.0.lock().map_err(|e| e.to_string())?;
581 Arc::new(database.service())
582 };
583
584 resolve_local_media_path(&db_service, item_id).await
585}
586
587#[tauri::command]
600#[specta::specta]
601pub async fn player_local_media_path(
602 db: State<'_, DatabaseWrapper>,
603 item_id: String,
604) -> Result<Option<String>, String> {
605 let db_service = {
606 let database = db.0.lock().map_err(|e| e.to_string())?;
607 Arc::new(database.service())
608 };
609
610 resolve_local_media_path(&db_service, &item_id).await
611}
612
613pub(super) async fn refresh_queue_local_sources(
622 controller: &PlayerController,
623 db: &DatabaseWrapper,
624) -> Result<usize, String> {
625 let remote_ids: Vec<String> = {
627 let queue = controller.queue();
628 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
629 queue_lock
630 .items()
631 .iter()
632 .filter_map(|item| match &item.source {
633 MediaSource::Remote {
634 jellyfin_item_id, ..
635 } => Some(jellyfin_item_id.clone()),
636 _ => None,
637 })
638 .collect()
639 };
640
641 if remote_ids.is_empty() {
642 return Ok(0);
643 }
644
645 let mut local_paths: Vec<(String, String)> = Vec::new();
646 for id in remote_ids {
647 if let Some(path) = check_for_local_download(db, &id).await? {
648 local_paths.push((id, path));
649 }
650 }
651
652 if local_paths.is_empty() {
653 return Ok(0);
654 }
655
656 let queue = controller.queue();
657 let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
658 let mut switched = 0;
659 for item in queue_lock.items_mut() {
660 if let MediaSource::Remote {
661 jellyfin_item_id, ..
662 } = &item.source
663 {
664 if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
665 info!(
666 "[Player] Switching queued track {} to local download: {}",
667 id, path
668 );
669 item.source = MediaSource::Local {
670 file_path: PathBuf::from(path),
671 jellyfin_item_id: Some(id.clone()),
672 };
673 switched += 1;
674 }
675 }
676 }
677 Ok(switched)
678}
679
680#[tauri::command]
690#[specta::specta]
691pub async fn player_play_item(
692 player: State<'_, PlayerStateWrapper>,
693 session: State<'_, MediaSessionManagerWrapper>,
694 db: State<'_, DatabaseWrapper>,
695 item: PlayItemRequest,
696) -> Result<PlayerStatus, String> {
697 info!(
698 "player_play_item called: {} - {}",
699 item.title, item.stream_url
700 );
701
702 crate::repository::online::clear_playback_quality_override();
709
710 let media_item = create_media_item(item, Some(&db)).await?;
712
713 {
715 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
716 match media_item.media_type {
717 MediaType::Audio => {
718 session_mgr.start_audio_session(media_item.clone());
719 }
720 MediaType::Video => {
721 session_mgr.start_movie_session(media_item.clone());
723 }
724 }
725 }
726
727 let controller = player.0.lock().await;
728 let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
745 if renders_natively {
746 controller
747 .play_item(media_item)
748 .map_err(|e| e.to_string())?;
749 } else {
750 controller
753 .set_current_item(media_item)
754 .map_err(|e| e.to_string())?;
755 }
756
757 controller.emit_queue_changed();
759
760 if let Some(emitter) = controller.event_emitter() {
762 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
763 emitter.emit(PlayerStatusEvent::SessionChanged {
764 session: session_mgr.current().clone(),
765 });
766 }
767
768 Ok(get_player_status(&controller))
769}
770
771#[tauri::command]
788#[specta::specta]
789pub async fn player_enter_background_audio(
790 player: State<'_, PlayerStateWrapper>,
791 session: State<'_, MediaSessionManagerWrapper>,
792 db: State<'_, DatabaseWrapper>,
793 item: PlayItemRequest,
794 position_seconds: f64,
795) -> Result<PlayerStatus, String> {
796 info!(
797 "player_enter_background_audio: {} @ {:.1}s",
798 item.title, position_seconds
799 );
800
801 let local_path = check_for_local_download(&db, &item.id).await?;
806 if local_path.is_some() {
807 info!(
808 "player_enter_background_audio: using downloaded file for {}",
809 item.id
810 );
811 }
812 let plan = background_audio_plan(local_path.is_some(), position_seconds);
815 let source = background_audio_source(local_path, item.stream_url, &item.id);
816
817 let media_item = MediaItem {
821 transport: None,
823 id: item.id.clone(),
824 title: item.title.clone(),
825 name: Some(item.title.clone()),
826 artist: item.artist.clone(),
827 album: None,
828 album_name: None,
829 album_id: None,
830 artist_items: None,
831 artists: None,
832 primary_image_tag: item.primary_image_tag.clone(),
833 image_id: item.primary_image_tag.clone(),
834 item_type: item.item_type.clone(),
837 playlist_id: None,
838 duration: item.duration_seconds,
840 artwork_url: None,
841 media_type: MediaType::Audio,
842 source,
843 video_codec: None,
844 needs_transcoding: false,
845 video_width: None,
846 video_height: None,
847 subtitles: vec![],
848 series_id: item.series_id.clone(),
849 server_id: item.server_id.clone(),
850 };
851
852 {
853 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
854 session_mgr.start_audio_session(media_item.clone());
855 }
856
857 let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
861
862 let controller = player.0.lock().await;
863 controller.enter_background_audio(plan.base_seconds);
869 controller
870 .play_item(media_item)
871 .map_err(|e| e.to_string())?;
872 if let Some(seek_to) = plan.seek_to {
876 info!(
877 "player_enter_background_audio: seeking the downloaded file to {:.1}s",
878 seek_to
879 );
880 controller.seek(seek_to).map_err(|e| e.to_string())?;
881 }
882
883 controller.emit_queue_changed();
884 if let Some(emitter) = controller.event_emitter() {
885 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
886 emitter.emit(PlayerStatusEvent::SessionChanged {
887 session: session_mgr.current().clone(),
888 });
889 }
890
891 Ok(get_player_status(&controller))
892}
893
894#[tauri::command]
915#[specta::specta]
916pub async fn player_background_action(
917 player: State<'_, PlayerStateWrapper>,
918 background_audio_armed: bool,
919 in_picture_in_picture: bool,
920) -> Result<crate::player::background_policy::BackgroundAction, String> {
921 use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
922
923 let is_video = {
924 let controller = player.0.lock().await;
925 let queue_arc = controller.queue();
926 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
927 match queue.current() {
928 Some(item) => is_video_media(item.media_type),
929 None => return Ok(BackgroundAction::KeepPlaying),
930 }
931 };
932
933 let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
934 info!(
935 "[player_background_action] video={} armed={} pip={} -> {:?}",
936 is_video, background_audio_armed, in_picture_in_picture, action
937 );
938 Ok(action)
939}
940
941#[tauri::command]
943#[specta::specta]
944pub async fn player_exit_background_audio(
945 player: State<'_, PlayerStateWrapper>,
946) -> Result<f64, String> {
947 let controller = player.0.lock().await;
948
949 let absolute = controller.absolute_position();
963
964 let _ = crate::player::set_lockscreen_position_offset(0.0);
966 controller.exit_background_audio();
967 controller.stop().map_err(|e| e.to_string())?;
968 info!(
969 "player_exit_background_audio: resuming the video at {:.1}s",
970 absolute
971 );
972 Ok(absolute)
973}
974
975#[tauri::command]
982#[specta::specta]
983pub async fn player_play_queue(
984 player: State<'_, PlayerStateWrapper>,
985 session: State<'_, MediaSessionManagerWrapper>,
986 db: State<'_, DatabaseWrapper>,
987 request: PlayQueueRequest,
988) -> Result<PlayerStatus, String> {
989 info!(
990 "player_play_queue called: {} items, start_index: {}, shuffle: {}",
991 request.items.len(),
992 request.start_index,
993 request.shuffle
994 );
995
996 crate::repository::online::clear_playback_quality_override();
1003
1004 if request.shuffle {
1006 let controller = player.0.lock().await;
1007 controller.toggle_shuffle();
1008 }
1009
1010 let queue_context = match request.context {
1012 Some(PlayQueueContext::Album {
1013 album_id,
1014 album_name,
1015 }) => QueueContext::Album {
1016 album_id,
1017 album_name,
1018 },
1019 Some(PlayQueueContext::Playlist {
1020 playlist_id,
1021 playlist_name,
1022 }) => QueueContext::Playlist {
1023 playlist_id,
1024 playlist_name,
1025 },
1026 Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
1027 };
1028
1029 let mut items: Vec<MediaItem> = Vec::new();
1031 for req in request.items {
1032 items.push(create_media_item(req, Some(&db)).await?);
1033 }
1034
1035 if let Some(first_item) = items.get(request.start_index) {
1037 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1038 match first_item.media_type {
1039 MediaType::Audio => {
1040 session_mgr.start_audio_session(first_item.clone());
1041 }
1042 MediaType::Video => {
1043 session_mgr.start_movie_session(first_item.clone());
1045 }
1046 }
1047 }
1048
1049 let controller = player.0.lock().await;
1051 info!(
1052 "player_play_queue: Calling controller.play_queue with {} items at index {}",
1053 items.len(),
1054 request.start_index
1055 );
1056 controller
1057 .play_queue(items, request.start_index)
1058 .map_err(|e| e.to_string())?;
1059
1060 {
1062 let queue_arc = controller.queue();
1063 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1064 queue.set_context(queue_context);
1065 }
1066
1067 controller.emit_queue_changed();
1069
1070 if let Some(emitter) = controller.event_emitter() {
1072 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1073 emitter.emit(PlayerStatusEvent::SessionChanged {
1074 session: session_mgr.current().clone(),
1075 });
1076 }
1077
1078 Ok(get_player_status(&controller))
1079}
1080
1081#[tauri::command]
1082#[specta::specta]
1083pub async fn player_play(
1084 player: State<'_, PlayerStateWrapper>,
1085 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1086) -> Result<PlayerStatus, String> {
1087 let mode = playback_mode.0.get_mode();
1089
1090 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1091 let client = {
1093 let controller = player.0.lock().await;
1094 let client_arc = controller.jellyfin_client();
1095 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1096 client_opt
1097 .as_ref()
1098 .ok_or("Jellyfin client not configured")?
1099 .clone()
1100 };
1101 client.send_session_command(session_id, "Unpause").await?;
1102 } else {
1103 let controller = player.0.lock().await;
1105 controller.play().map_err(|e| e.to_string())?;
1106 }
1107
1108 let controller = player.0.lock().await;
1109 Ok(get_player_status(&controller))
1110}
1111
1112#[tauri::command]
1113#[specta::specta]
1114pub async fn player_pause(
1115 player: State<'_, PlayerStateWrapper>,
1116 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1117) -> Result<PlayerStatus, String> {
1118 let mode = playback_mode.0.get_mode();
1120
1121 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1122 let client = {
1124 let controller = player.0.lock().await;
1125 let client_arc = controller.jellyfin_client();
1126 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1127 client_opt
1128 .as_ref()
1129 .ok_or("Jellyfin client not configured")?
1130 .clone()
1131 };
1132 client.send_session_command(session_id, "Pause").await?;
1133 } else {
1134 let controller = player.0.lock().await;
1136 controller.pause().map_err(|e| e.to_string())?;
1137 }
1138
1139 let controller = player.0.lock().await;
1140 Ok(get_player_status(&controller))
1141}
1142
1143#[tauri::command]
1144#[specta::specta]
1145pub async fn player_toggle(
1146 player: State<'_, PlayerStateWrapper>,
1147 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1148) -> Result<PlayerStatus, String> {
1149 let mode = playback_mode.0.get_mode();
1151
1152 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1153 let client = {
1155 let controller = player.0.lock().await;
1156 let client_arc = controller.jellyfin_client();
1157 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1158 client_opt
1159 .as_ref()
1160 .ok_or("Jellyfin client not configured")?
1161 .clone()
1162 };
1163 client.send_session_command(session_id, "PlayPause").await?;
1164 } else {
1165 let controller = player.0.lock().await;
1167 controller.toggle_playback().map_err(|e| e.to_string())?;
1168 }
1169
1170 let controller = player.0.lock().await;
1171 Ok(get_player_status(&controller))
1172}
1173
1174#[tauri::command]
1175#[specta::specta]
1176pub async fn player_stop(
1177 player: State<'_, PlayerStateWrapper>,
1178 session: State<'_, MediaSessionManagerWrapper>,
1179 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1180) -> Result<PlayerStatus, String> {
1181 let mode = playback_mode.0.get_mode();
1183
1184 info!("[player_stop] called (mode: {:?})", mode);
1190
1191 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1192 let client = {
1194 let controller = player.0.lock().await;
1195 let client_arc = controller.jellyfin_client();
1196 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1197 client_opt
1198 .as_ref()
1199 .ok_or("Jellyfin client not configured")?
1200 .clone()
1201 };
1202 client.send_session_command(session_id, "Stop").await?;
1203
1204 playback_mode
1211 .0
1212 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1213 } else {
1214 let controller = player.0.lock().await;
1216 controller.stop().map_err(|e| e.to_string())?;
1217
1218 playback_mode
1222 .0
1223 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1224
1225 {
1227 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1228 let current_session = session_mgr.current().clone();
1229
1230 match current_session {
1231 crate::player::MediaSessionType::Movie { .. } => {
1232 session_mgr.movie_session_ended();
1234 }
1235 crate::player::MediaSessionType::Audio { .. } => {
1236 session_mgr.audio_session_inactive();
1238 }
1239 crate::player::MediaSessionType::TvShow { .. } => {
1240 session_mgr.tv_session_episode_ended();
1242 }
1243 crate::player::MediaSessionType::Idle => {
1244 }
1246 }
1247
1248 if let Some(emitter) = controller.event_emitter() {
1250 emitter.emit(PlayerStatusEvent::SessionChanged {
1251 session: session_mgr.current().clone(),
1252 });
1253 }
1254 }
1255 }
1256
1257 let controller = player.0.lock().await;
1258 Ok(get_player_status(&controller))
1259}
1260
1261#[tauri::command]
1262#[specta::specta]
1263pub async fn player_next(
1264 player: State<'_, PlayerStateWrapper>,
1265 session: State<'_, MediaSessionManagerWrapper>,
1266 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1267 db: State<'_, DatabaseWrapper>,
1268) -> Result<PlayerStatus, String> {
1269 debug!("[player_next] Command called from frontend");
1270
1271 let mode = playback_mode.0.get_mode();
1273
1274 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1275 let client = {
1277 let controller = player.0.lock().await;
1278 let client_arc = controller.jellyfin_client();
1279 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1280 client_opt
1281 .as_ref()
1282 .ok_or("Jellyfin client not configured")?
1283 .clone()
1284 };
1285 client.send_session_command(session_id, "NextTrack").await?;
1286 } else {
1287 let controller = player.0.lock().await;
1289 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1291 warn!("[player_next] Failed to refresh local sources: {}", e);
1292 }
1293 controller.next().map_err(|e| e.to_string())?;
1294 controller.emit_queue_changed();
1295
1296 let current_item = {
1298 let queue = controller.queue();
1299 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1300 queue_lock.current().cloned()
1301 };
1302
1303 if let Some(item) = current_item {
1304 if item.media_type == MediaType::Audio {
1305 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1306 session_mgr.update_audio_track(item);
1307
1308 if let Some(emitter) = controller.event_emitter() {
1310 emitter.emit(PlayerStatusEvent::SessionChanged {
1311 session: session_mgr.current().clone(),
1312 });
1313 }
1314 }
1315 }
1316 }
1317
1318 let controller = player.0.lock().await;
1319 Ok(get_player_status(&controller))
1320}
1321
1322#[tauri::command]
1323#[specta::specta]
1324pub async fn player_previous(
1325 player: State<'_, PlayerStateWrapper>,
1326 session: State<'_, MediaSessionManagerWrapper>,
1327 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1328 db: State<'_, DatabaseWrapper>,
1329) -> Result<PlayerStatus, String> {
1330 let mode = playback_mode.0.get_mode();
1332
1333 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1334 let client = {
1336 let controller = player.0.lock().await;
1337 let client_arc = controller.jellyfin_client();
1338 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1339 client_opt
1340 .as_ref()
1341 .ok_or("Jellyfin client not configured")?
1342 .clone()
1343 };
1344 client
1345 .send_session_command(session_id, "PreviousTrack")
1346 .await?;
1347 } else {
1348 let controller = player.0.lock().await;
1350 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1352 warn!("[player_previous] Failed to refresh local sources: {}", e);
1353 }
1354 controller.previous().map_err(|e| e.to_string())?;
1355 controller.emit_queue_changed();
1356
1357 let current_item = {
1359 let queue = controller.queue();
1360 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1361 queue_lock.current().cloned()
1362 };
1363
1364 if let Some(item) = current_item {
1365 if item.media_type == MediaType::Audio {
1366 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1367 session_mgr.update_audio_track(item);
1368
1369 if let Some(emitter) = controller.event_emitter() {
1371 emitter.emit(PlayerStatusEvent::SessionChanged {
1372 session: session_mgr.current().clone(),
1373 });
1374 }
1375 }
1376 }
1377 }
1378
1379 let controller = player.0.lock().await;
1380 Ok(get_player_status(&controller))
1381}
1382
1383#[tauri::command]
1384#[specta::specta]
1385pub async fn player_seek(
1386 player: State<'_, PlayerStateWrapper>,
1387 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1388 position: f64,
1389) -> Result<PlayerStatus, String> {
1390 let mode = playback_mode.0.get_mode();
1392
1393 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1394 let client = {
1396 let controller = player.0.lock().await;
1397 let client_arc = controller.jellyfin_client();
1398 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1399 client_opt
1400 .as_ref()
1401 .ok_or("Jellyfin client not configured")?
1402 .clone()
1403 };
1404 let position_ticks = (position * 10_000_000.0) as i64;
1405 client.session_seek(session_id, position_ticks).await?;
1406 } else {
1407 let controller = player.0.lock().await;
1412 controller.seek_absolute(position).await?;
1413 }
1414
1415 let controller = player.0.lock().await;
1416 Ok(get_player_status(&controller))
1417}
1418
1419#[tauri::command]
1430#[specta::specta]
1431pub async fn player_seek_video(
1432 player: State<'_, PlayerStateWrapper>,
1433 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1434 repository_handle: String,
1435 position: f64,
1436 media_source_id: Option<String>,
1437 audio_stream_index: Option<i32>,
1438 use_html5: bool,
1439) -> Result<VideoSeekResponse, String> {
1440 info!(
1441 "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1442 position, use_html5
1443 );
1444
1445 let repository = repository_manager
1447 .0
1448 .get(&repository_handle)
1449 .ok_or("Repository not found - user may need to log in")?;
1450
1451 let (needs_transcoding, jellyfin_item_id, is_local) = {
1454 let controller = player.0.lock().await;
1455 let queue_arc = controller.queue();
1456 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1457
1458 let current_item = queue.current().ok_or("No item currently playing")?;
1459
1460 if current_item.media_type != MediaType::Video {
1461 return Err("Current item is not a video".to_string());
1462 }
1463
1464 let jellyfin_id = current_item
1465 .jellyfin_id()
1466 .ok_or("Current video has no Jellyfin ID")?
1467 .to_string();
1468
1469 let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1474
1475 (current_item.needs_transcoding, jellyfin_id, is_local_file)
1476 }; let seeks_transcoded_in_place = {
1482 let controller = player.0.lock().await;
1483 controller.capabilities().seeks_transcoded_in_place
1484 };
1485 let strategy = determine_video_seek_strategy(
1486 is_local,
1487 seeks_transcoded_in_place,
1488 needs_transcoding,
1489 use_html5,
1490 );
1491
1492 info!(
1493 "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1494 needs_transcoding={}, use_html5={}, strategy={:?}",
1495 is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
1496 );
1497
1498 match strategy {
1499 VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1500 info!("[player_seek_video] Using backend native seek");
1502 let controller = player.0.lock().await;
1503 controller.seek(position).map_err(|e| e.to_string())?;
1504 Ok(VideoSeekResponse::Native { position })
1505 }
1506 VideoSeekStrategy::Html5NativeSeek => {
1507 info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1510 Ok(VideoSeekResponse::Native { position })
1511 }
1512 VideoSeekStrategy::Html5ReloadStream => {
1513 info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1515
1516 let selection = repository
1517 .get_stream_selection(
1518 &jellyfin_item_id,
1519 media_source_id.as_deref(),
1520 audio_stream_index,
1521 )
1522 .await
1523 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1524
1525 info!(
1526 "[player_seek_video] Selected {:?} over {:?} for position {}",
1527 selection.playback_kind, selection.transport, position
1528 );
1529
1530 Ok(VideoSeekResponse::ReloadStream {
1531 selection,
1532 seek_offset: position,
1533 })
1534 }
1535 VideoSeekStrategy::BackendReloadStream => {
1536 info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1538
1539 let selection = repository
1540 .get_stream_selection(
1541 &jellyfin_item_id,
1542 media_source_id.as_deref(),
1543 audio_stream_index,
1544 )
1545 .await
1546 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1547 let new_url = selection.url.clone();
1548
1549 info!("[player_seek_video] Got new selection, handling reload internally");
1550
1551 {
1553 let controller = player.0.lock().await;
1554 controller.stop().map_err(|e| e.to_string())?;
1555 }
1556
1557 {
1559 let controller = player.0.lock().await;
1560 let queue_arc = controller.queue();
1561 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1562
1563 if !queue.update_current_stream_url(new_url.clone()) {
1564 return Err("Failed to update stream URL in queue".to_string());
1565 }
1566 }
1567
1568 {
1570 let controller = player.0.lock().await;
1571 let queue_arc = controller.queue();
1572 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1573
1574 if let Some(updated_item) = queue.current() {
1575 controller
1576 .load_and_play(updated_item)
1577 .map_err(|e| e.to_string())?;
1578 } else {
1579 return Err("No current item after URL update".to_string());
1580 }
1581
1582 controller.seek(position).map_err(|e| e.to_string())?;
1586 }
1587
1588 info!(
1589 "[player_seek_video] Stream reloaded successfully at position {}",
1590 position
1591 );
1592
1593 Ok(VideoSeekResponse::Native { position })
1594 }
1595 }
1596}
1597
1598#[tauri::command]
1611#[specta::specta]
1612#[allow(clippy::too_many_arguments)]
1616pub async fn player_switch_audio_track(
1617 player: State<'_, PlayerStateWrapper>,
1618 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1619 repository_handle: String,
1620 stream_index: i32,
1621 array_index: i32,
1622 use_html5: bool,
1623 current_position: Option<f64>,
1624 media_source_id: Option<String>,
1625) -> Result<AudioTrackSwitchResponse, String> {
1626 info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1627 stream_index, array_index, use_html5);
1628
1629 if use_html5 {
1630 let repository = repository_manager
1632 .0
1633 .get(&repository_handle)
1634 .ok_or("Repository not found - user may need to log in")?;
1635
1636 let jellyfin_item_id = {
1638 let controller = player.0.lock().await;
1639 let queue_arc = controller.queue();
1640 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1641
1642 let current_item = queue.current().ok_or("No item currently playing")?;
1643
1644 current_item
1645 .jellyfin_id()
1646 .ok_or("Current item has no Jellyfin ID")?
1647 .to_string()
1648 };
1649
1650 let selection = repository
1659 .get_stream_selection(
1660 &jellyfin_item_id,
1661 media_source_id.as_deref(),
1662 Some(stream_index),
1663 )
1664 .await
1665 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1666
1667 Ok(AudioTrackSwitchResponse::ReloadStream {
1668 selection,
1669 position: current_position.unwrap_or(0.0),
1670 })
1671 } else {
1672 let controller = player.0.lock().await;
1674 controller
1675 .set_audio_track(array_index)
1676 .map_err(|e| e.to_string())?;
1677
1678 Ok(AudioTrackSwitchResponse::Native { success: true })
1679 }
1680}
1681
1682#[tauri::command]
1703#[specta::specta]
1704#[allow(clippy::too_many_arguments)]
1708pub async fn player_set_stream_quality(
1709 player: State<'_, PlayerStateWrapper>,
1710 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1711 repository_handle: String,
1712 quality: crate::settings::StreamingQuality,
1713 use_html5: bool,
1714 current_position: Option<f64>,
1715 media_source_id: Option<String>,
1716 audio_stream_index: Option<i32>,
1717) -> Result<StreamQualityResponse, String> {
1718 info!(
1719 "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1720 quality.label(),
1721 use_html5,
1722 current_position
1723 );
1724
1725 let repository = repository_manager
1726 .0
1727 .get(&repository_handle)
1728 .ok_or("Repository not found - user may need to log in")?;
1729
1730 let jellyfin_item_id = {
1731 let controller = player.0.lock().await;
1732 let queue_arc = controller.queue();
1733 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1734
1735 let current_item = queue.current().ok_or("No item currently playing")?;
1736
1737 if current_item.media_type != MediaType::Video {
1738 return Err("Current item is not a video".to_string());
1739 }
1740
1741 current_item
1742 .jellyfin_id()
1743 .ok_or("Current item has no Jellyfin ID")?
1744 .to_string()
1745 };
1746
1747 crate::repository::online::set_playback_quality_override(quality);
1755
1756 let position = match current_position {
1770 Some(p) => p,
1771 None => {
1772 let controller = player.0.lock().await;
1773 controller.absolute_position()
1774 }
1775 };
1776 let selection = repository
1777 .get_stream_selection(
1778 &jellyfin_item_id,
1779 media_source_id.as_deref(),
1780 audio_stream_index,
1781 )
1782 .await
1783 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1784 let new_url = selection.url.clone();
1785
1786 if use_html5 {
1787 return Ok(StreamQualityResponse::ReloadStream {
1788 selection,
1789 position,
1790 });
1791 }
1792
1793 {
1799 let controller = player.0.lock().await;
1800 controller.stop().map_err(|e| e.to_string())?;
1801
1802 let queue_arc = controller.queue();
1803 {
1804 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1805 if !queue.update_current_stream_url(new_url.clone()) {
1806 return Err("Failed to update stream URL in queue".to_string());
1807 }
1808 }
1809
1810 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1811 let updated_item = queue.current().ok_or("No current item after URL update")?;
1812 controller
1813 .load_and_play(updated_item)
1814 .map_err(|e| e.to_string())?;
1815 if position > 0.0 {
1816 controller.seek(position).map_err(|e| e.to_string())?;
1817 }
1818 }
1819
1820 Ok(StreamQualityResponse::Native {
1821 selection,
1822 position,
1823 })
1824}
1825
1826#[tauri::command]
1830#[specta::specta]
1831pub async fn player_set_audio_track(
1832 player: State<'_, PlayerStateWrapper>,
1833 stream_index: i32,
1834) -> Result<PlayerStatus, String> {
1835 let controller = player.0.lock().await;
1836 controller
1837 .set_audio_track(stream_index)
1838 .map_err(|e| e.to_string())?;
1839 Ok(get_player_status(&controller))
1840}
1841
1842#[tauri::command]
1851#[specta::specta]
1852pub async fn player_set_subtitle_track(
1853 player: State<'_, PlayerStateWrapper>,
1854 stream_index: Option<i32>,
1855) -> Result<PlayerStatus, String> {
1856 let controller = player.0.lock().await;
1857 controller
1858 .set_subtitle_track(stream_index)
1859 .map_err(|e| e.to_string())?;
1860 Ok(get_player_status(&controller))
1861}
1862
1863fn normalize_volume(volume: f32) -> f32 {
1873 if volume.is_nan() {
1874 0.0
1875 } else {
1876 volume.clamp(0.0, 1.0)
1877 }
1878}
1879
1880#[tauri::command]
1881#[specta::specta]
1882pub async fn player_set_volume(
1883 player: State<'_, PlayerStateWrapper>,
1884 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1885 volume: f32,
1886) -> Result<PlayerStatus, String> {
1887 let volume = normalize_volume(volume);
1892
1893 let mode = playback_mode.0.get_mode();
1895
1896 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1897 let client = {
1899 let controller = player.0.lock().await;
1900 let client_arc = controller.jellyfin_client();
1901 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1902 client_opt
1903 .as_ref()
1904 .ok_or("Jellyfin client not configured")?
1905 .clone()
1906 };
1907 let volume_percent = (volume * 100.0) as i32;
1909 client
1910 .session_set_volume(session_id, volume_percent)
1911 .await?;
1912 } else {
1913 let controller = player.0.lock().await;
1915 controller.set_volume(volume).map_err(|e| e.to_string())?;
1916 }
1917
1918 let controller = player.0.lock().await;
1919 Ok(get_player_status(&controller))
1920}
1921
1922#[tauri::command]
1923#[specta::specta]
1924pub async fn player_toggle_mute(
1925 player: State<'_, PlayerStateWrapper>,
1926 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1927) -> Result<PlayerStatus, String> {
1928 let mode = playback_mode.0.get_mode();
1930
1931 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1932 let client = {
1934 let controller = player.0.lock().await;
1935 let client_arc = controller.jellyfin_client();
1936 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1937 client_opt
1938 .as_ref()
1939 .ok_or("Jellyfin client not configured")?
1940 .clone()
1941 };
1942 client
1943 .send_session_command(session_id, "ToggleMute")
1944 .await?;
1945 } else {
1946 }
1951
1952 let controller = player.0.lock().await;
1953 Ok(get_player_status(&controller))
1954}
1955
1956#[tauri::command]
1957#[specta::specta]
1958pub async fn player_toggle_shuffle(
1959 player: State<'_, PlayerStateWrapper>,
1960) -> Result<QueueStatus, String> {
1961 let controller = player.0.lock().await;
1962 controller.toggle_shuffle();
1963 controller.emit_queue_changed();
1964 Ok(get_queue_status(&controller))
1965}
1966
1967#[tauri::command]
1968#[specta::specta]
1969pub async fn player_cycle_repeat(
1970 player: State<'_, PlayerStateWrapper>,
1971) -> Result<QueueStatus, String> {
1972 let controller = player.0.lock().await;
1973 controller.cycle_repeat();
1974 controller.emit_queue_changed();
1975 Ok(get_queue_status(&controller))
1976}
1977
1978#[tauri::command]
1979#[specta::specta]
1980pub async fn player_get_status(
1981 player: State<'_, PlayerStateWrapper>,
1982 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1983) -> Result<PlayerStatus, String> {
1984 let mode = playback_mode.0.get_mode();
1985
1986 let controller = player.0.lock().await;
1988 let mut status = get_player_status(&controller);
1989
1990 let local_media = {
1992 let queue_arc = controller.queue();
1993 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1994 queue.current().map(MergedMediaItem::from)
1995 };
1996
1997 let local_is_playing = status.state.is_playing();
1998 let local_volume = status.volume;
1999
2000 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2002 let client = {
2003 let client_arc = controller.jellyfin_client();
2004 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2005 client_opt
2006 .as_ref()
2007 .ok_or("Jellyfin client not configured")?
2008 .clone()
2009 };
2010
2011 drop(controller); match client.get_session(&session_id).await {
2014 Ok(Some(session)) => {
2015 log::info!("[PlayerCommands] Merging remote session state");
2016
2017 status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2019
2020 status.merged_is_playing = session
2022 .play_state
2023 .as_ref()
2024 .map(|ps| !ps.is_paused.unwrap_or(true))
2025 .unwrap_or(false);
2026
2027 status.position = session
2029 .play_state
2030 .as_ref()
2031 .and_then(|ps| ps.position_ticks)
2032 .map(|ticks| ticks as f64 / 10_000_000.0)
2033 .unwrap_or(0.0);
2034
2035 status.duration = session
2037 .now_playing_item
2038 .as_ref()
2039 .and_then(|item| item.run_time_ticks)
2040 .map(|ticks| ticks as f64 / 10_000_000.0);
2041
2042 status.merged_volume = session
2044 .play_state
2045 .as_ref()
2046 .and_then(|ps| ps.volume_level)
2047 .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2048 .unwrap_or(1.0);
2049
2050 return Ok(status);
2051 }
2052 Ok(None) => {
2053 log::warn!("[PlayerCommands] Remote session not found, using local state");
2054 }
2055 Err(e) => {
2056 log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2057 }
2058 }
2059 }
2060
2061 status.merged_media = local_media;
2063 status.merged_is_playing = local_is_playing;
2064 status.merged_volume = local_volume;
2065
2066 Ok(status)
2067}
2068
2069#[tauri::command]
2070#[specta::specta]
2071pub async fn player_get_queue(
2072 player: State<'_, PlayerStateWrapper>,
2073) -> Result<QueueStatus, String> {
2074 let controller = player.0.lock().await;
2075 Ok(get_queue_status(&controller))
2076}
2077
2078#[derive(specta::Type, Debug, Serialize)]
2087#[serde(rename_all = "camelCase")]
2088pub struct PlaybackCapabilities {
2089 pub uses_webview_audio: bool,
2093 pub supports_native_video: bool,
2098}
2099
2100#[tauri::command]
2104#[specta::specta]
2105pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2106 let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2108
2109 Ok(PlaybackCapabilities {
2110 uses_webview_audio: !native_audio,
2111 supports_native_video: cfg!(target_os = "android")
2113 || crate::player::native_video::enabled(),
2114 })
2115}
2116
2117pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2118 let (backend, use_html5_element) = if cfg!(target_os = "android") {
2120 (VideoBackend::Native, false)
2122 } else if crate::player::native_video::enabled() {
2123 (VideoBackend::Native, false)
2127 } else {
2128 (VideoBackend::Html5, true)
2130 };
2131
2132 PlayerStatus {
2133 state: controller.state(),
2134 position: controller.absolute_position(),
2139 duration: controller.duration(),
2140 volume: controller.volume(),
2141 muted: controller.muted(),
2142 shuffle: controller.is_shuffle(),
2143 repeat: controller.repeat_mode(),
2144 backend,
2145 use_html5_element,
2146
2147 merged_media: None,
2149 merged_is_playing: false,
2150 merged_volume: controller.volume(),
2151 }
2152}
2153
2154pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2155 let queue = controller.queue();
2156 let queue_lock = queue.lock_safe();
2157
2158 QueueStatus {
2159 items: queue_lock.items().to_vec(),
2160 current_index: queue_lock.current_index(),
2161 shuffle: queue_lock.is_shuffle(),
2162 repeat: queue_lock.repeat_mode(),
2163 has_next: queue_lock.has_next(),
2164 has_previous: queue_lock.has_previous(),
2165 }
2166}
2167
2168async fn play_selection_on_remote(
2180 controller: &PlayerController,
2181 session_id: &str,
2182 media_items: &[MediaItem],
2183 start_index: usize,
2184) -> Result<(), String> {
2185 let mut jellyfin_ids: Vec<String> = Vec::new();
2188 let mut adjusted_index: Option<usize> = None;
2189 for (i, item) in media_items.iter().enumerate() {
2190 if let Some(id) = item.jellyfin_id() {
2191 if i == start_index {
2192 adjusted_index = Some(jellyfin_ids.len());
2193 }
2194 jellyfin_ids.push(id.to_string());
2195 }
2196 }
2197
2198 let start_index =
2199 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2200
2201 if jellyfin_ids.is_empty() {
2202 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2203 }
2204
2205 let client = {
2206 let client_arc = controller.jellyfin_client();
2207 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2208 client_opt
2209 .as_ref()
2210 .ok_or("Jellyfin client not configured")?
2211 .clone()
2212 };
2213
2214 client
2216 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2217 .await
2218 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2219}
2220
2221#[tauri::command]
2223#[specta::specta]
2224pub async fn player_play_album_track(
2225 player: State<'_, PlayerStateWrapper>,
2226 session: State<'_, MediaSessionManagerWrapper>,
2227 db: State<'_, DatabaseWrapper>,
2228 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2229 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2230 repository_handle: String,
2231 request: PlayAlbumTrackRequest,
2232) -> Result<PlayerStatus, String> {
2233 info!(
2234 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2235 request.album_id, request.track_id, request.shuffle
2236 );
2237
2238 let repository = repository_manager
2240 .0
2241 .get(&repository_handle)
2242 .ok_or("Repository not found - user may need to log in")?;
2243
2244 info!(
2246 "Fetching tracks for album {} via repository",
2247 request.album_id
2248 );
2249 let album_items = repository
2250 .get_items(
2251 &request.album_id,
2252 Some(GetItemsOptions {
2253 limit: Some(1000),
2254 fields: Some(vec![
2255 "PrimaryImageAspectRatio".to_string(),
2256 "Overview".to_string(),
2257 "MediaStreams".to_string(),
2258 ]),
2259 ..Default::default()
2260 }),
2261 )
2262 .await
2263 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2264
2265 info!("Found {} items in album", album_items.items.len());
2266
2267 let mut tracks: Vec<_> = album_items
2269 .items
2270 .into_iter()
2271 .filter(|item| item.item_type == "Audio")
2272 .collect();
2273 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2274
2275 info!("Found {} audio tracks in album", tracks.len());
2276
2277 if tracks.is_empty() {
2278 return Err("No audio tracks found in album".to_string());
2279 }
2280
2281 info!("Album has {} tracks after sorting:", tracks.len());
2283 for (idx, track) in tracks.iter().enumerate() {
2284 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2285 }
2286
2287 info!("Looking for track_id: {}", request.track_id);
2290 let album_index = tracks
2291 .iter()
2292 .position(|t| t.id == request.track_id)
2293 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2294
2295 info!(
2296 "Track {} is at index {} in album",
2297 request.track_id, album_index
2298 );
2299
2300 let mut media_items = Vec::new();
2302 for track in tracks {
2303 let jellyfin_id = &track.id;
2305 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2306
2307 let source = if let Some(path) = local_path {
2308 MediaSource::Local {
2309 file_path: PathBuf::from(path),
2310 jellyfin_item_id: Some(jellyfin_id.clone()),
2311 }
2312 } else {
2313 match repository.get_audio_stream_url(&track.id).await {
2317 Ok(stream_url) => MediaSource::Remote {
2318 stream_url,
2319 jellyfin_item_id: jellyfin_id.clone(),
2320 },
2321 Err(e) => {
2322 warn!(
2323 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2324 track.name, track.id, e
2325 );
2326 continue;
2327 }
2328 }
2329 };
2330
2331 let primary_image_tag_for_url = track.primary_image_tag.clone();
2332 let media_item = MediaItem {
2333 transport: None,
2335 id: track.id.clone(),
2336 title: track.name.clone(),
2337 name: Some(track.name.clone()), artist: track
2339 .album_artist
2340 .clone()
2341 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2342 album: Some(request.album_name.clone()),
2343 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2345 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(),
2349 item_type: Some(track.item_type.clone()), playlist_id: None,
2351 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2352 artwork_url: primary_image_tag_for_url.map(|tag| {
2353 repository.get_image_url(
2354 &request.album_id,
2355 ImageType::Primary,
2356 Some(ImageOptions {
2357 max_width: Some(300),
2358 tag: Some(tag),
2359 ..Default::default()
2360 }),
2361 )
2362 }),
2363 media_type: MediaType::Audio,
2364 source,
2365 video_codec: None,
2366 needs_transcoding: false,
2367 video_width: None,
2368 video_height: None,
2369 subtitles: vec![],
2370 series_id: None,
2371 server_id: None,
2372 };
2373
2374 media_items.push(media_item);
2375 }
2376
2377 if media_items.is_empty() {
2378 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2379 }
2380
2381 let start_index = media_items
2385 .iter()
2386 .position(|item| item.id == request.track_id)
2387 .unwrap_or(0);
2388
2389 info!(
2390 "Built queue with {} media items, starting at index {}",
2391 media_items.len(),
2392 start_index
2393 );
2394
2395 if request.shuffle {
2397 let controller = player.0.lock().await;
2398 controller.toggle_shuffle();
2399 }
2400
2401 if let Some(first_item) = media_items.get(start_index) {
2403 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2404 session_mgr.start_audio_session(first_item.clone());
2405 }
2406
2407 let controller = player.0.lock().await;
2408
2409 let remote_session = match playback_mode.0.get_mode() {
2413 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2414 _ => None,
2415 };
2416
2417 if let Some(session_id) = &remote_session {
2418 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2419 controller
2420 .set_queue(media_items, start_index)
2421 .map_err(|e| e.to_string())?;
2422 } else {
2423 playback_mode
2426 .0
2427 .set_mode(crate::playback_mode::PlaybackMode::Local);
2428 controller
2429 .play_queue(media_items, start_index)
2430 .map_err(|e| e.to_string())?;
2431 }
2432
2433 {
2435 let queue_arc = controller.queue();
2436 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2437 queue.set_context(QueueContext::Album {
2438 album_id: request.album_id.clone(),
2439 album_name: request.album_name.clone(),
2440 });
2441 }
2442
2443 controller.emit_queue_changed();
2445
2446 {
2448 let queue_arc = controller.queue();
2449 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2450 info!(
2451 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2452 queue_lock.items().len(),
2453 queue_lock.current_index()
2454 );
2455 }
2456
2457 if let Some(emitter) = controller.event_emitter() {
2459 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2460 emitter.emit(PlayerStatusEvent::SessionChanged {
2461 session: session_mgr.current().clone(),
2462 });
2463 }
2464
2465 Ok(get_player_status(&controller))
2466}
2467
2468#[tauri::command]
2470#[specta::specta]
2471pub async fn player_play_tracks(
2472 player: State<'_, PlayerStateWrapper>,
2473 session: State<'_, MediaSessionManagerWrapper>,
2474 db: State<'_, DatabaseWrapper>,
2475 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2476 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2477 repository_handle: String,
2478 request: PlayTracksRequest,
2479) -> Result<PlayerStatus, String> {
2480 crate::repository::online::clear_playback_quality_override();
2487
2488 info!(
2489 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2490 request.track_ids.len(),
2491 request.start_index,
2492 request.shuffle
2493 );
2494
2495 if request.track_ids.is_empty() {
2497 return Err("No tracks provided".to_string());
2498 }
2499
2500 let repository = repository_manager
2502 .0
2503 .get(&repository_handle)
2504 .ok_or("Repository not found - user may need to log in")?;
2505
2506 let mut media_items = Vec::new();
2508 for track_id in &request.track_ids {
2509 let track = repository
2511 .get_item(track_id)
2512 .await
2513 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2514
2515 let local_path = check_for_local_download(&db, track_id).await?;
2517
2518 let source = if let Some(path) = local_path {
2520 MediaSource::Local {
2521 file_path: PathBuf::from(path),
2522 jellyfin_item_id: Some(track.id.clone()),
2523 }
2524 } else {
2525 let stream_url = repository
2526 .get_audio_stream_url(track_id)
2527 .await
2528 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2529
2530 MediaSource::Remote {
2531 stream_url,
2532 jellyfin_item_id: track.id.clone(),
2533 }
2534 };
2535
2536 let primary_image_tag_for_url = track.primary_image_tag.clone();
2538 let media_item = MediaItem {
2539 transport: None,
2541 id: track.id.clone(),
2542 title: track.name.clone(),
2543 name: Some(track.name.clone()), artist: track
2545 .album_artist
2546 .clone()
2547 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2548 album: track.album_name.clone(),
2549 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2551 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(),
2555 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2558 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2559 track.album_id.as_ref().map(|album_id| {
2560 repository.get_image_url(
2561 album_id,
2562 ImageType::Primary,
2563 Some(ImageOptions {
2564 max_width: Some(300),
2565 tag: Some(tag),
2566 ..Default::default()
2567 }),
2568 )
2569 })
2570 }),
2571 media_type: MediaType::Audio,
2572 source,
2573 video_codec: None,
2574 needs_transcoding: false,
2575 video_width: None,
2576 video_height: None,
2577 subtitles: vec![],
2578 series_id: None,
2579 server_id: None,
2580 };
2581
2582 media_items.push(media_item);
2583 }
2584
2585 info!("Built queue with {} media items", media_items.len());
2586
2587 let queue_context = match request.context {
2589 PlayTracksContext::Playlist {
2590 playlist_id,
2591 playlist_name,
2592 } => {
2593 for item in &mut media_items {
2594 item.playlist_id = Some(playlist_id.clone());
2595 }
2596 QueueContext::Playlist {
2597 playlist_id,
2598 playlist_name,
2599 }
2600 }
2601 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2602 };
2603
2604 if request.shuffle {
2606 let controller = player.0.lock().await;
2607 controller.toggle_shuffle();
2608 }
2609
2610 if let Some(first_item) = media_items.get(request.start_index) {
2612 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2613 session_mgr.start_audio_session(first_item.clone());
2614 }
2615
2616 let controller = player.0.lock().await;
2617
2618 let remote_session = match playback_mode.0.get_mode() {
2624 crate::playback_mode::PlaybackMode::Remote { session_id }
2625 if !playback_mode.0.is_transferring() =>
2626 {
2627 Some(session_id)
2628 }
2629 _ => None,
2630 };
2631
2632 if let Some(session_id) = &remote_session {
2633 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2634 .await?;
2635 controller
2636 .set_queue(media_items, request.start_index)
2637 .map_err(|e| e.to_string())?;
2638 } else {
2639 if !playback_mode.0.is_transferring() {
2647 playback_mode
2648 .0
2649 .set_mode(crate::playback_mode::PlaybackMode::Local);
2650 }
2651 controller
2652 .play_queue_from(media_items, request.start_index, request.start_position)
2653 .map_err(|e| e.to_string())?;
2654 }
2655
2656 {
2658 let queue_arc = controller.queue();
2659 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2660 queue.set_context(queue_context);
2661 }
2662
2663 controller.emit_queue_changed();
2665 if let Some(emitter) = controller.event_emitter() {
2666 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2667 emitter.emit(PlayerStatusEvent::SessionChanged {
2668 session: session_mgr.current().clone(),
2669 });
2670 }
2671
2672 info!("player_play_tracks completed successfully");
2673 Ok(get_player_status(&controller))
2674}
2675
2676#[derive(specta::Type, Debug, Serialize)]
2678#[serde(rename_all = "camelCase")]
2679pub struct PreloadResult {
2680 pub queued_count: usize,
2682 pub already_downloaded: usize,
2684 pub skipped: usize,
2686}
2687
2688#[tauri::command]
2691#[specta::specta]
2692pub async fn player_preload_upcoming(
2693 player: State<'_, PlayerStateWrapper>,
2694 db: State<'_, DatabaseWrapper>,
2695 smart_cache: State<'_, SmartCacheWrapper>,
2696 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2697 app: tauri::AppHandle,
2698 user_id: String,
2699 _download_base_path: String,
2700) -> Result<PreloadResult, String> {
2701 let (db_service, target_dir) = {
2705 let database = db.0.lock().map_err(|e| e.to_string())?;
2706 let target_dir = database
2707 .path()
2708 .parent()
2709 .ok_or_else(|| "Database path has no parent directory".to_string())?
2710 .to_string_lossy()
2711 .to_string();
2712 (Arc::new(database.service()), target_dir)
2713 };
2714
2715 let precache_count = {
2717 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2718 if !cache.should_precache_queue() {
2719 return Ok(PreloadResult {
2720 queued_count: 0,
2721 already_downloaded: 0,
2722 skipped: 0,
2723 });
2724 }
2725 cache.queue_precache_count()
2726 };
2727
2728 let upcoming_items: Vec<MediaItem> = {
2730 let controller = player.0.lock().await;
2731 let queue = controller.queue();
2732 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2733 queue_lock
2734 .get_upcoming(precache_count)
2735 .into_iter()
2736 .cloned()
2737 .collect()
2738 };
2739
2740 if upcoming_items.is_empty() {
2741 return Ok(PreloadResult {
2742 queued_count: 0,
2743 already_downloaded: 0,
2744 skipped: 0,
2745 });
2746 }
2747
2748 let mut queued_count = 0;
2749 let mut already_downloaded = 0;
2750 let mut skipped = 0;
2751
2752 for item in upcoming_items {
2754 let (jellyfin_id, stream_url) = match &item.source {
2758 MediaSource::Remote {
2759 jellyfin_item_id,
2760 stream_url,
2761 } => (jellyfin_item_id.clone(), stream_url.clone()),
2762 MediaSource::Local { .. } => {
2763 already_downloaded += 1;
2764 continue;
2765 }
2766 MediaSource::DirectUrl { .. } => {
2767 skipped += 1;
2768 continue;
2769 }
2770 };
2771
2772 let query = Query::with_params(
2776 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2777 AND (status IN ('completed', 'downloading')
2778 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2779 vec![
2780 QueryParam::String(jellyfin_id.clone()),
2781 QueryParam::String(user_id.clone()),
2782 ],
2783 );
2784
2785 let is_downloaded: Option<String> = db_service
2786 .query_optional(query, |row| row.get(0))
2787 .await
2788 .map_err(|e| e.to_string())?;
2789
2790 if is_downloaded.is_some() {
2791 already_downloaded += 1;
2792 continue;
2793 }
2794
2795 let album_dir = item
2798 .album
2799 .as_deref()
2800 .filter(|a| !a.is_empty())
2801 .unwrap_or("Unknown Album");
2802 let file_path = format!(
2803 "downloads/{}/{}.mp3",
2804 sanitize_filename(album_dir),
2805 sanitize_filename(&item.title)
2806 );
2807
2808 let insert_query = Query::with_params(
2812 "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)
2813 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2814 ON CONFLICT(item_id, user_id) DO UPDATE SET
2815 stream_url = excluded.stream_url,
2816 target_dir = excluded.target_dir
2817 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2818 vec![
2819 QueryParam::String(jellyfin_id),
2820 QueryParam::String(user_id.clone()),
2821 QueryParam::String(file_path),
2822 QueryParam::String(item.title.clone()),
2823 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2824 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2825 QueryParam::String(stream_url),
2826 QueryParam::String(target_dir.clone()),
2827 ],
2828 );
2829
2830 match db_service.execute(insert_query).await {
2831 Ok(rows) if rows > 0 => {
2832 info!(
2833 "[Preload] Queued download for: {} - {}",
2834 item.artist.as_deref().unwrap_or("Unknown"),
2835 item.title
2836 );
2837 queued_count += 1;
2838 }
2839 Ok(_) => {
2840 already_downloaded += 1;
2842 }
2843 Err(e) => {
2844 error!("[Preload] Failed to queue {}: {}", item.title, e);
2845 skipped += 1;
2846 }
2847 }
2848 }
2849
2850 if queued_count > 0 {
2853 let active_downloads = {
2854 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2855 manager.get_active_downloads()
2856 };
2857 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2858 }
2859
2860 info!(
2861 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2862 queued_count, already_downloaded, skipped
2863 );
2864
2865 Ok(PreloadResult {
2866 queued_count,
2867 already_downloaded,
2868 skipped,
2869 })
2870}
2871
2872fn sanitize_filename(name: &str) -> String {
2874 name.chars()
2875 .map(|c| match c {
2876 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2877 _ => c,
2878 })
2879 .collect()
2880}
2881
2882#[tauri::command]
2884#[specta::specta]
2885pub async fn player_set_cache_config(
2886 smart_cache: State<'_, SmartCacheWrapper>,
2887 config: CacheConfig,
2888) -> Result<(), String> {
2889 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2890 cache.update_config(config);
2891 Ok(())
2892}
2893
2894#[tauri::command]
2896#[specta::specta]
2897pub async fn player_get_cache_config(
2898 smart_cache: State<'_, SmartCacheWrapper>,
2899) -> Result<CacheConfig, String> {
2900 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2901 Ok(cache.get_config().unwrap_or_default())
2902}
2903
2904#[tauri::command]
2906#[specta::specta]
2907pub async fn player_configure_jellyfin(
2908 player: State<'_, PlayerStateWrapper>,
2909 db: State<'_, DatabaseWrapper>,
2910 server_url: String,
2911 access_token: String,
2912 user_id: String,
2913 device_id: String,
2914) -> Result<(), String> {
2915 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
2916
2917 let config = JellyfinConfig {
2918 server_url,
2919 access_token,
2920 device_id,
2921 };
2922
2923 let client = JellyfinClient::new(config.clone())?;
2925
2926 let db_service = {
2933 let database = db.0.lock().map_err(|e| e.to_string())?;
2934 Arc::new(database.service())
2935 };
2936 let reporter_client = JellyfinClient::new(config)?;
2937 let reporter = crate::playback_reporting::PlaybackReporter::new(
2938 db_service,
2939 Arc::new(TokioMutex::new(Some(reporter_client))),
2940 user_id,
2941 );
2942
2943 let controller = player.0.lock().await;
2944 controller.set_jellyfin_client(Some(client));
2945 controller.set_playback_reporter(Some(reporter)).await;
2946
2947 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
2948 Ok(())
2949}
2950
2951#[tauri::command]
2953#[specta::specta]
2954pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
2955 log::info!("[PlayerCommand] Disabling Jellyfin client");
2956
2957 let controller = player.0.lock().await;
2958 controller.set_jellyfin_client(None);
2959 controller.set_playback_reporter(None).await;
2960
2961 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
2962 Ok(())
2963}
2964
2965#[cfg(test)]
2966mod tests {
2967 use crate::utils::lock::MutexSafe;
2968
2969 #[test]
2980 fn test_normalize_volume_clamps_and_rejects_nan() {
2981 use super::normalize_volume;
2982
2983 assert_eq!(normalize_volume(0.0), 0.0);
2985 assert_eq!(normalize_volume(0.5), 0.5);
2986 assert_eq!(normalize_volume(1.0), 1.0);
2987
2988 assert_eq!(normalize_volume(-0.5), 0.0);
2990 assert_eq!(normalize_volume(42.0), 1.0);
2991 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
2992 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
2993
2994 let from_nan = normalize_volume(f32::NAN);
2997 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
2998 assert_eq!(from_nan, 0.0);
2999
3000 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3002 let percent = (normalize_volume(input) * 100.0) as i32;
3003 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3004 }
3005 }
3006
3007 #[tokio::test]
3024 async fn test_play_item_request_carries_subtitles_into_media_item() {
3025 use super::{create_media_item, PlayItemRequest};
3026
3027 let payload = serde_json::json!({
3028 "id": "ep-1",
3029 "title": "Pilot",
3030 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3031 "videoCodec": "h264",
3032 "needsTranscoding": false,
3033 "subtitles": [
3034 {
3035 "index": 2,
3036 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3037 "language": "eng",
3038 "label": "English (SRT)",
3039 "mime_type": "text/vtt"
3040 },
3041 {
3042 "index": 3,
3043 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3044 "language": null,
3045 "label": null,
3046 "mime_type": "text/vtt"
3047 }
3048 ]
3049 });
3050
3051 let req: PlayItemRequest =
3052 serde_json::from_value(payload).expect("frontend payload must deserialize");
3053 assert_eq!(
3054 req.subtitles.len(),
3055 2,
3056 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3057 );
3058
3059 let media = create_media_item(req, None).await.unwrap();
3060 assert_eq!(
3061 media.subtitles.len(),
3062 2,
3063 "create_media_item must thread the tracks onto the MediaItem the backend loads"
3064 );
3065 assert_eq!(media.subtitles[0].index, 2);
3066 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3067 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3068 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3069 assert_eq!(media.subtitles[1].index, 3);
3072 assert!(media.subtitles[1].language.is_none());
3073 }
3074
3075 #[tokio::test]
3081 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3082 use super::{create_media_item, PlayItemRequest};
3083
3084 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3085 "id": "movie-1",
3086 "title": "Movie",
3087 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3088 "videoCodec": "h264",
3089 "needsTranscoding": false
3090 }))
3091 .expect("a subtitle-less payload must still deserialize");
3092
3093 assert!(req.subtitles.is_empty());
3094 assert!(create_media_item(req, None)
3095 .await
3096 .unwrap()
3097 .subtitles
3098 .is_empty());
3099 }
3100
3101 #[test]
3113 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3114 use crate::player::media::SubtitleTrack;
3115
3116 let subtitles = vec![SubtitleTrack {
3117 index: 2,
3118 url: "https://jelly.example/subs.vtt".to_string(),
3119 language: Some("eng".to_string()),
3120 label: Some("English".to_string()),
3121 mime_type: "text/vtt".to_string(),
3122 }];
3123
3124 let json = serde_json::to_string(&subtitles).unwrap();
3126 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3127 let obj = parsed[0].as_object().unwrap();
3128
3129 for key in ["url", "language", "label", "mime_type"] {
3130 assert!(
3131 obj.contains_key(key),
3132 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3133 obj.keys().collect::<Vec<_>>()
3134 );
3135 }
3136 assert!(
3137 !obj.contains_key("mimeType"),
3138 "camelCasing mime_type silently drops every track's MIME type on Android"
3139 );
3140 }
3141
3142 #[test]
3147 fn test_background_audio_source_prefers_local_file() {
3148 use super::background_audio_source;
3149 use crate::player::MediaSource;
3150 use std::path::PathBuf;
3151
3152 let local = background_audio_source(
3153 Some("/downloads/ep1.mkv".to_string()),
3154 "https://server/audio-only".to_string(),
3155 "ep-1",
3156 );
3157 match local {
3158 MediaSource::Local {
3159 file_path,
3160 jellyfin_item_id,
3161 } => {
3162 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3163 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3165 }
3166 other => panic!("expected a local source, got {:?}", other),
3167 }
3168
3169 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3170 match remote {
3171 MediaSource::Remote {
3172 stream_url,
3173 jellyfin_item_id,
3174 } => {
3175 assert_eq!(stream_url, "https://server/audio-only");
3176 assert_eq!(jellyfin_item_id, "ep-1");
3177 }
3178 other => panic!("expected a remote source, got {:?}", other),
3179 }
3180 }
3181
3182 #[test]
3195 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3196 use super::background_audio_plan;
3197
3198 let local = background_audio_plan(true, 1104.0);
3199 assert_eq!(local.base_seconds, 0.0);
3200 assert_eq!(local.seek_to, Some(1104.0));
3201
3202 let streamed = background_audio_plan(false, 1104.0);
3203 assert_eq!(streamed.base_seconds, 1104.0);
3204 assert_eq!(
3205 streamed.seek_to, None,
3206 "the URL already starts at the handoff point; seeking again skips past it"
3207 );
3208 }
3209
3210 #[test]
3215 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3216 use super::background_audio_plan;
3217
3218 for local in [true, false] {
3219 let plan = background_audio_plan(local, 0.0);
3220 assert_eq!(plan.base_seconds, 0.0);
3221 assert_eq!(plan.seek_to, None);
3222 }
3223 }
3224
3225 #[tokio::test]
3231 async fn test_resolve_local_media_path() {
3232 use super::resolve_local_media_path;
3233 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3234 use rusqlite::Connection;
3235 use std::sync::{Arc, Mutex};
3236
3237 let conn = Connection::open_in_memory().unwrap();
3238 conn.execute(
3239 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3240 [],
3241 )
3242 .unwrap();
3243 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3244
3245 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3247 std::fs::write(&present, b"x").unwrap();
3248 let present_str = present.to_string_lossy().to_string();
3249
3250 for (item, status, path) in [
3251 ("downloaded", "completed", present_str.as_str()),
3252 ("still-going", "downloading", present_str.as_str()),
3253 (
3254 "file-gone",
3255 "completed",
3256 "/nonexistent/jellytau/missing.mp4",
3257 ),
3258 ] {
3259 db_service
3260 .execute(Query::with_params(
3261 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3262 vec![
3263 crate::storage::db_service::QueryParam::String(item.to_string()),
3264 crate::storage::db_service::QueryParam::String(status.to_string()),
3265 crate::storage::db_service::QueryParam::String(path.to_string()),
3266 ],
3267 ))
3268 .await
3269 .unwrap();
3270 }
3271
3272 assert_eq!(
3273 resolve_local_media_path(&db_service, "downloaded")
3274 .await
3275 .unwrap()
3276 .as_deref(),
3277 Some(present_str.as_str()),
3278 "a completed download with its file present must resolve"
3279 );
3280
3281 assert_eq!(
3282 resolve_local_media_path(&db_service, "still-going")
3283 .await
3284 .unwrap(),
3285 None,
3286 "an in-progress download is not playable from disk"
3287 );
3288
3289 assert_eq!(
3290 resolve_local_media_path(&db_service, "file-gone")
3291 .await
3292 .unwrap(),
3293 None,
3294 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3295 );
3296
3297 assert_eq!(
3298 resolve_local_media_path(&db_service, "never-heard-of-it")
3299 .await
3300 .unwrap(),
3301 None
3302 );
3303
3304 let _ = std::fs::remove_file(&present);
3305 }
3306
3307 #[tokio::test]
3311 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3312 use super::{refresh_queue_local_sources, DatabaseWrapper};
3313 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3314 use crate::storage::Database;
3315 use std::sync::Mutex;
3316
3317 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3320 std::fs::create_dir_all(&dir).unwrap();
3321 let existing = dir.join("track-a.mp3");
3322 std::fs::write(&existing, b"audio").unwrap();
3323 let missing = dir.join("track-b-missing.mp3");
3324 let _ = std::fs::remove_file(&missing);
3325
3326 let database = Database::open_in_memory().unwrap();
3327 {
3328 let conn = database.connection();
3329 let conn = conn.lock_safe();
3330 conn.execute_batch(&format!(
3331 r#"
3332 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3333 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3334 INSERT INTO downloads (item_id, user_id, file_path, status)
3335 VALUES ('track-a', 'user1', '{}', 'completed');
3336 INSERT INTO downloads (item_id, user_id, file_path, status)
3337 VALUES ('track-b', 'user1', '{}', 'completed');
3338 "#,
3339 existing.display(),
3340 missing.display()
3341 ))
3342 .unwrap();
3343 }
3344 let db = DatabaseWrapper(Mutex::new(database));
3345
3346 let make_item = |id: &str| MediaItem {
3347 transport: None,
3349 id: id.to_string(),
3350 title: id.to_string(),
3351 name: None,
3352 artist: None,
3353 album: None,
3354 album_name: None,
3355 album_id: None,
3356 artist_items: None,
3357 artists: None,
3358 primary_image_tag: None,
3359 image_id: None,
3360 item_type: None,
3361 playlist_id: None,
3362 duration: None,
3363 artwork_url: None,
3364 media_type: MediaType::Audio,
3365 source: MediaSource::Remote {
3366 stream_url: format!("http://test/Audio/{}/stream", id),
3367 jellyfin_item_id: id.to_string(),
3368 },
3369 video_codec: None,
3370 needs_transcoding: false,
3371 video_width: None,
3372 video_height: None,
3373 subtitles: vec![],
3374 series_id: None,
3375 server_id: None,
3376 };
3377
3378 let controller = PlayerController::default();
3379 controller
3380 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3381 .unwrap();
3382
3383 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3384 assert_eq!(switched, 1, "only the download whose file exists switches");
3385
3386 let queue = controller.queue();
3387 let queue_lock = queue.lock_safe();
3388 match &queue_lock.items()[0].source {
3389 MediaSource::Local {
3390 file_path,
3391 jellyfin_item_id,
3392 } => {
3393 assert_eq!(file_path, &existing);
3394 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3395 }
3396 other => panic!("track-a should be local, got {:?}", other),
3397 }
3398 assert!(
3399 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3400 "track-b's file is missing, it must stay remote"
3401 );
3402 }
3403
3404 #[test]
3407 fn test_find_track_index_in_album() {
3408 #[derive(Clone)]
3410 struct MockTrack {
3411 id: String,
3412 name: String,
3413 index_number: Option<i32>,
3414 }
3415
3416 let mut tracks = [
3417 MockTrack {
3418 id: "track1".to_string(),
3419 name: "Song 1".to_string(),
3420 index_number: Some(1),
3421 },
3422 MockTrack {
3423 id: "track2".to_string(),
3424 name: "Song 2".to_string(),
3425 index_number: Some(2),
3426 },
3427 MockTrack {
3428 id: "track3".to_string(),
3429 name: "Song 3".to_string(),
3430 index_number: Some(3),
3431 },
3432 MockTrack {
3433 id: "track4".to_string(),
3434 name: "Song 4".to_string(),
3435 index_number: Some(4),
3436 },
3437 MockTrack {
3438 id: "track5".to_string(),
3439 name: "Song 5".to_string(),
3440 index_number: Some(5),
3441 },
3442 ];
3443
3444 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3446
3447 let index1 = tracks.iter().position(|t| t.id == "track1");
3449 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3450 assert_eq!(
3451 tracks[0].name, "Song 1",
3452 "Track at index 0 should carry its name"
3453 );
3454
3455 let index3 = tracks.iter().position(|t| t.id == "track3");
3457 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3458
3459 let index5 = tracks.iter().position(|t| t.id == "track5");
3461 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3462
3463 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3465 assert_eq!(index_none, None, "Non-existent track should return None");
3466 }
3467
3468 #[test]
3470 fn test_track_iteration_order() {
3471 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3473
3474 let target_index = track_ids.iter().position(|&id| id == "id3");
3476 assert_eq!(
3477 target_index,
3478 Some(2),
3479 "id3 should be at index 2 in original list"
3480 );
3481
3482 let mut media_items = Vec::new();
3484 for id in &track_ids {
3485 media_items.push(id.to_string());
3486 }
3487
3488 assert_eq!(media_items.len(), 5);
3490 assert_eq!(media_items[0], "id1");
3491 assert_eq!(media_items[2], "id3");
3492 assert_eq!(media_items[4], "id5");
3493
3494 assert_eq!(media_items[target_index.unwrap()], "id3");
3496 }
3497
3498 #[test]
3500 fn test_album_track_sorting() {
3501 #[derive(Clone, Debug)]
3502 struct MockTrack {
3503 id: String,
3504 name: String,
3505 index_number: Option<i32>,
3506 }
3507
3508 let mut tracks = [
3510 MockTrack {
3511 id: "id5".to_string(),
3512 name: "Track 5".to_string(),
3513 index_number: Some(5),
3514 },
3515 MockTrack {
3516 id: "id1".to_string(),
3517 name: "Track 1".to_string(),
3518 index_number: Some(1),
3519 },
3520 MockTrack {
3521 id: "id3".to_string(),
3522 name: "Track 3".to_string(),
3523 index_number: Some(3),
3524 },
3525 MockTrack {
3526 id: "id2".to_string(),
3527 name: "Track 2".to_string(),
3528 index_number: Some(2),
3529 },
3530 MockTrack {
3531 id: "id4".to_string(),
3532 name: "Track 4".to_string(),
3533 index_number: Some(4),
3534 },
3535 ];
3536
3537 let requested_track_id = "id1";
3539
3540 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3542
3543 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3545
3546 assert_eq!(
3548 start_index,
3549 Some(0),
3550 "After sorting, track id1 should be at index 0"
3551 );
3552
3553 assert_eq!(tracks[0].id, "id1");
3555 assert_eq!(
3556 tracks[0].name, "Track 1",
3557 "Sorted track should retain its name"
3558 );
3559 assert_eq!(tracks[1].id, "id2");
3560 assert_eq!(tracks[2].id, "id3");
3561 assert_eq!(tracks[3].id, "id4");
3562 assert_eq!(tracks[4].id, "id5");
3563 }
3564}