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]
944#[specta::specta]
945pub async fn player_exit_background_audio(
946 player: State<'_, PlayerStateWrapper>,
947) -> Result<f64, String> {
948 let controller = player.0.lock().await;
949
950 let absolute = controller.absolute_position();
964
965 let _ = crate::player::set_lockscreen_position_offset(0.0);
967 controller.exit_background_audio();
968 controller.stop().map_err(|e| e.to_string())?;
969 info!(
970 "player_exit_background_audio: resuming the video at {:.1}s",
971 absolute
972 );
973 Ok(absolute)
974}
975
976#[tauri::command]
983#[specta::specta]
984pub async fn player_play_queue(
985 player: State<'_, PlayerStateWrapper>,
986 session: State<'_, MediaSessionManagerWrapper>,
987 db: State<'_, DatabaseWrapper>,
988 request: PlayQueueRequest,
989) -> Result<PlayerStatus, String> {
990 info!(
991 "player_play_queue called: {} items, start_index: {}, shuffle: {}",
992 request.items.len(),
993 request.start_index,
994 request.shuffle
995 );
996
997 crate::repository::online::clear_playback_quality_override();
1004
1005 if request.shuffle {
1007 let controller = player.0.lock().await;
1008 controller.toggle_shuffle();
1009 }
1010
1011 let queue_context = match request.context {
1013 Some(PlayQueueContext::Album {
1014 album_id,
1015 album_name,
1016 }) => QueueContext::Album {
1017 album_id,
1018 album_name,
1019 },
1020 Some(PlayQueueContext::Playlist {
1021 playlist_id,
1022 playlist_name,
1023 }) => QueueContext::Playlist {
1024 playlist_id,
1025 playlist_name,
1026 },
1027 Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
1028 };
1029
1030 let mut items: Vec<MediaItem> = Vec::new();
1032 for req in request.items {
1033 items.push(create_media_item(req, Some(&db)).await?);
1034 }
1035
1036 if let Some(first_item) = items.get(request.start_index) {
1038 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1039 match first_item.media_type {
1040 MediaType::Audio => {
1041 session_mgr.start_audio_session(first_item.clone());
1042 }
1043 MediaType::Video => {
1044 session_mgr.start_movie_session(first_item.clone());
1046 }
1047 }
1048 }
1049
1050 let controller = player.0.lock().await;
1052 info!(
1053 "player_play_queue: Calling controller.play_queue with {} items at index {}",
1054 items.len(),
1055 request.start_index
1056 );
1057 controller
1058 .play_queue(items, request.start_index)
1059 .map_err(|e| e.to_string())?;
1060
1061 {
1063 let queue_arc = controller.queue();
1064 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1065 queue.set_context(queue_context);
1066 }
1067
1068 controller.emit_queue_changed();
1070
1071 if let Some(emitter) = controller.event_emitter() {
1073 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1074 emitter.emit(PlayerStatusEvent::SessionChanged {
1075 session: session_mgr.current().clone(),
1076 });
1077 }
1078
1079 Ok(get_player_status(&controller))
1080}
1081
1082#[tauri::command]
1083#[specta::specta]
1084pub async fn player_play(
1085 player: State<'_, PlayerStateWrapper>,
1086 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1087) -> Result<PlayerStatus, String> {
1088 let mode = playback_mode.0.get_mode();
1090
1091 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1092 let client = {
1094 let controller = player.0.lock().await;
1095 let client_arc = controller.jellyfin_client();
1096 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1097 client_opt
1098 .as_ref()
1099 .ok_or("Jellyfin client not configured")?
1100 .clone()
1101 };
1102 client.send_session_command(session_id, "Unpause").await?;
1103 } else {
1104 let controller = player.0.lock().await;
1106 controller.play().map_err(|e| e.to_string())?;
1107 }
1108
1109 let controller = player.0.lock().await;
1110 Ok(get_player_status(&controller))
1111}
1112
1113#[tauri::command]
1114#[specta::specta]
1115pub async fn player_pause(
1116 player: State<'_, PlayerStateWrapper>,
1117 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1118) -> Result<PlayerStatus, String> {
1119 let mode = playback_mode.0.get_mode();
1121
1122 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1123 let client = {
1125 let controller = player.0.lock().await;
1126 let client_arc = controller.jellyfin_client();
1127 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1128 client_opt
1129 .as_ref()
1130 .ok_or("Jellyfin client not configured")?
1131 .clone()
1132 };
1133 client.send_session_command(session_id, "Pause").await?;
1134 } else {
1135 let controller = player.0.lock().await;
1137 controller.pause().map_err(|e| e.to_string())?;
1138 }
1139
1140 let controller = player.0.lock().await;
1141 Ok(get_player_status(&controller))
1142}
1143
1144#[tauri::command]
1145#[specta::specta]
1146pub async fn player_toggle(
1147 player: State<'_, PlayerStateWrapper>,
1148 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1149) -> Result<PlayerStatus, String> {
1150 let mode = playback_mode.0.get_mode();
1152
1153 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1154 let client = {
1156 let controller = player.0.lock().await;
1157 let client_arc = controller.jellyfin_client();
1158 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1159 client_opt
1160 .as_ref()
1161 .ok_or("Jellyfin client not configured")?
1162 .clone()
1163 };
1164 client.send_session_command(session_id, "PlayPause").await?;
1165 } else {
1166 let controller = player.0.lock().await;
1168 controller.toggle_playback().map_err(|e| e.to_string())?;
1169 }
1170
1171 let controller = player.0.lock().await;
1172 Ok(get_player_status(&controller))
1173}
1174
1175#[tauri::command]
1176#[specta::specta]
1177pub async fn player_stop(
1178 player: State<'_, PlayerStateWrapper>,
1179 session: State<'_, MediaSessionManagerWrapper>,
1180 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1181) -> Result<PlayerStatus, String> {
1182 let mode = playback_mode.0.get_mode();
1184
1185 info!("[player_stop] called (mode: {:?})", mode);
1191
1192 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1193 let client = {
1195 let controller = player.0.lock().await;
1196 let client_arc = controller.jellyfin_client();
1197 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1198 client_opt
1199 .as_ref()
1200 .ok_or("Jellyfin client not configured")?
1201 .clone()
1202 };
1203 client.send_session_command(session_id, "Stop").await?;
1204
1205 playback_mode
1212 .0
1213 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1214 } else {
1215 let controller = player.0.lock().await;
1217 controller.stop().map_err(|e| e.to_string())?;
1218
1219 playback_mode
1223 .0
1224 .set_mode(crate::playback_mode::PlaybackMode::Idle);
1225
1226 {
1228 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1229 let current_session = session_mgr.current().clone();
1230
1231 match current_session {
1232 crate::player::MediaSessionType::Movie { .. } => {
1233 session_mgr.movie_session_ended();
1235 }
1236 crate::player::MediaSessionType::Audio { .. } => {
1237 session_mgr.audio_session_inactive();
1239 }
1240 crate::player::MediaSessionType::TvShow { .. } => {
1241 session_mgr.tv_session_episode_ended();
1243 }
1244 crate::player::MediaSessionType::Idle => {
1245 }
1247 }
1248
1249 if let Some(emitter) = controller.event_emitter() {
1251 emitter.emit(PlayerStatusEvent::SessionChanged {
1252 session: session_mgr.current().clone(),
1253 });
1254 }
1255 }
1256 }
1257
1258 let controller = player.0.lock().await;
1259 Ok(get_player_status(&controller))
1260}
1261
1262#[tauri::command]
1263#[specta::specta]
1264pub async fn player_next(
1265 player: State<'_, PlayerStateWrapper>,
1266 session: State<'_, MediaSessionManagerWrapper>,
1267 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1268 db: State<'_, DatabaseWrapper>,
1269) -> Result<PlayerStatus, String> {
1270 debug!("[player_next] Command called from frontend");
1271
1272 let mode = playback_mode.0.get_mode();
1274
1275 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1276 let client = {
1278 let controller = player.0.lock().await;
1279 let client_arc = controller.jellyfin_client();
1280 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1281 client_opt
1282 .as_ref()
1283 .ok_or("Jellyfin client not configured")?
1284 .clone()
1285 };
1286 client.send_session_command(session_id, "NextTrack").await?;
1287 } else {
1288 let controller = player.0.lock().await;
1290 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1292 warn!("[player_next] Failed to refresh local sources: {}", e);
1293 }
1294 controller.next().map_err(|e| e.to_string())?;
1295 controller.emit_queue_changed();
1296
1297 let current_item = {
1299 let queue = controller.queue();
1300 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1301 queue_lock.current().cloned()
1302 };
1303
1304 if let Some(item) = current_item {
1305 if item.media_type == MediaType::Audio {
1306 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1307 session_mgr.update_audio_track(item);
1308
1309 if let Some(emitter) = controller.event_emitter() {
1311 emitter.emit(PlayerStatusEvent::SessionChanged {
1312 session: session_mgr.current().clone(),
1313 });
1314 }
1315 }
1316 }
1317 }
1318
1319 let controller = player.0.lock().await;
1320 Ok(get_player_status(&controller))
1321}
1322
1323#[tauri::command]
1324#[specta::specta]
1325pub async fn player_previous(
1326 player: State<'_, PlayerStateWrapper>,
1327 session: State<'_, MediaSessionManagerWrapper>,
1328 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1329 db: State<'_, DatabaseWrapper>,
1330) -> Result<PlayerStatus, String> {
1331 let mode = playback_mode.0.get_mode();
1333
1334 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1335 let client = {
1337 let controller = player.0.lock().await;
1338 let client_arc = controller.jellyfin_client();
1339 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1340 client_opt
1341 .as_ref()
1342 .ok_or("Jellyfin client not configured")?
1343 .clone()
1344 };
1345 client
1346 .send_session_command(session_id, "PreviousTrack")
1347 .await?;
1348 } else {
1349 let controller = player.0.lock().await;
1351 if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1353 warn!("[player_previous] Failed to refresh local sources: {}", e);
1354 }
1355 controller.previous().map_err(|e| e.to_string())?;
1356 controller.emit_queue_changed();
1357
1358 let current_item = {
1360 let queue = controller.queue();
1361 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1362 queue_lock.current().cloned()
1363 };
1364
1365 if let Some(item) = current_item {
1366 if item.media_type == MediaType::Audio {
1367 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1368 session_mgr.update_audio_track(item);
1369
1370 if let Some(emitter) = controller.event_emitter() {
1372 emitter.emit(PlayerStatusEvent::SessionChanged {
1373 session: session_mgr.current().clone(),
1374 });
1375 }
1376 }
1377 }
1378 }
1379
1380 let controller = player.0.lock().await;
1381 Ok(get_player_status(&controller))
1382}
1383
1384#[tauri::command]
1385#[specta::specta]
1386pub async fn player_seek(
1387 player: State<'_, PlayerStateWrapper>,
1388 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1389 position: f64,
1390) -> Result<PlayerStatus, String> {
1391 let mode = playback_mode.0.get_mode();
1393
1394 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1395 let client = {
1397 let controller = player.0.lock().await;
1398 let client_arc = controller.jellyfin_client();
1399 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1400 client_opt
1401 .as_ref()
1402 .ok_or("Jellyfin client not configured")?
1403 .clone()
1404 };
1405 let position_ticks = (position * 10_000_000.0) as i64;
1406 client.session_seek(session_id, position_ticks).await?;
1407 } else {
1408 let controller = player.0.lock().await;
1413 controller.seek_absolute(position).await?;
1414 }
1415
1416 let controller = player.0.lock().await;
1417 Ok(get_player_status(&controller))
1418}
1419
1420#[tauri::command]
1431#[specta::specta]
1432pub async fn player_seek_video(
1433 player: State<'_, PlayerStateWrapper>,
1434 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1435 repository_handle: String,
1436 position: f64,
1437 media_source_id: Option<String>,
1438 audio_stream_index: Option<i32>,
1439 use_html5: bool,
1440) -> Result<VideoSeekResponse, String> {
1441 info!(
1442 "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1443 position, use_html5
1444 );
1445
1446 let repository = repository_manager
1448 .0
1449 .get(&repository_handle)
1450 .ok_or("Repository not found - user may need to log in")?;
1451
1452 let (needs_transcoding, jellyfin_item_id, is_local) = {
1455 let controller = player.0.lock().await;
1456 let queue_arc = controller.queue();
1457 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1458
1459 let current_item = queue.current().ok_or("No item currently playing")?;
1460
1461 if current_item.media_type != MediaType::Video {
1462 return Err("Current item is not a video".to_string());
1463 }
1464
1465 let jellyfin_id = current_item
1466 .jellyfin_id()
1467 .ok_or("Current video has no Jellyfin ID")?
1468 .to_string();
1469
1470 let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1475
1476 (current_item.needs_transcoding, jellyfin_id, is_local_file)
1477 }; let seeks_transcoded_in_place = {
1483 let controller = player.0.lock().await;
1484 controller.capabilities().seeks_transcoded_in_place
1485 };
1486 let strategy = determine_video_seek_strategy(
1487 is_local,
1488 seeks_transcoded_in_place,
1489 needs_transcoding,
1490 use_html5,
1491 );
1492
1493 info!(
1494 "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1495 needs_transcoding={}, use_html5={}, strategy={:?}",
1496 is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
1497 );
1498
1499 match strategy {
1500 VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1501 info!("[player_seek_video] Using backend native seek");
1503 let controller = player.0.lock().await;
1504 controller.seek(position).map_err(|e| e.to_string())?;
1505 Ok(VideoSeekResponse::Native { position })
1506 }
1507 VideoSeekStrategy::Html5NativeSeek => {
1508 info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1511 Ok(VideoSeekResponse::Native { position })
1512 }
1513 VideoSeekStrategy::Html5ReloadStream => {
1514 info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1516
1517 let selection = repository
1518 .get_stream_selection(
1519 &jellyfin_item_id,
1520 media_source_id.as_deref(),
1521 audio_stream_index,
1522 )
1523 .await
1524 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1525
1526 info!(
1527 "[player_seek_video] Selected {:?} over {:?} for position {}",
1528 selection.playback_kind, selection.transport, position
1529 );
1530
1531 Ok(VideoSeekResponse::ReloadStream {
1532 selection,
1533 seek_offset: position,
1534 })
1535 }
1536 VideoSeekStrategy::BackendReloadStream => {
1537 info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1539
1540 let selection = repository
1541 .get_stream_selection(
1542 &jellyfin_item_id,
1543 media_source_id.as_deref(),
1544 audio_stream_index,
1545 )
1546 .await
1547 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1548 let new_url = selection.url.clone();
1549
1550 info!("[player_seek_video] Got new selection, handling reload internally");
1551
1552 {
1554 let controller = player.0.lock().await;
1555 controller.stop().map_err(|e| e.to_string())?;
1556 }
1557
1558 {
1560 let controller = player.0.lock().await;
1561 let queue_arc = controller.queue();
1562 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1563
1564 if !queue.update_current_stream_url(new_url.clone()) {
1565 return Err("Failed to update stream URL in queue".to_string());
1566 }
1567 }
1568
1569 {
1571 let controller = player.0.lock().await;
1572 let queue_arc = controller.queue();
1573 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1574
1575 if let Some(updated_item) = queue.current() {
1576 controller
1577 .load_and_play(updated_item)
1578 .map_err(|e| e.to_string())?;
1579 } else {
1580 return Err("No current item after URL update".to_string());
1581 }
1582
1583 controller.seek(position).map_err(|e| e.to_string())?;
1587 }
1588
1589 info!(
1590 "[player_seek_video] Stream reloaded successfully at position {}",
1591 position
1592 );
1593
1594 Ok(VideoSeekResponse::Native { position })
1595 }
1596 }
1597}
1598
1599#[tauri::command]
1631#[specta::specta]
1632#[allow(clippy::too_many_arguments)]
1636pub async fn player_switch_audio_track(
1637 player: State<'_, PlayerStateWrapper>,
1638 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1639 repository_handle: String,
1640 stream_index: i32,
1641 array_index: i32,
1642 use_html5: bool,
1643 current_position: Option<f64>,
1644 media_source_id: Option<String>,
1645) -> Result<AudioTrackSwitchResponse, String> {
1646 info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1647 stream_index, array_index, use_html5);
1648
1649 let (jellyfin_item_id, needs_transcoding, engine_position) = {
1654 let controller = player.0.lock().await;
1655 let engine_position = controller.absolute_position();
1656 let queue_arc = controller.queue();
1657 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1658
1659 let current_item = queue.current().ok_or("No item currently playing")?;
1660
1661 (
1662 current_item
1663 .jellyfin_id()
1664 .ok_or("Current item has no Jellyfin ID")?
1665 .to_string(),
1666 current_item.needs_transcoding,
1667 engine_position,
1668 )
1669 };
1670
1671 let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
1672
1673 info!(
1674 "[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
1675 needs_transcoding, use_html5, strategy
1676 );
1677
1678 if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
1679 let controller = player.0.lock().await;
1681 controller
1682 .set_audio_track(array_index)
1683 .map_err(|e| e.to_string())?;
1684
1685 return Ok(AudioTrackSwitchResponse::Native { success: true });
1686 }
1687
1688 let repository = repository_manager
1690 .0
1691 .get(&repository_handle)
1692 .ok_or("Repository not found - user may need to log in")?;
1693
1694 let selection = repository
1703 .get_stream_selection(
1704 &jellyfin_item_id,
1705 media_source_id.as_deref(),
1706 Some(stream_index),
1707 )
1708 .await
1709 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1710
1711 let position = crate::player::track_switch::resume_position(current_position, engine_position);
1715
1716 match strategy {
1717 AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
1718 selection,
1719 position,
1720 }),
1721 AudioTrackSwitchStrategy::BackendReloadStream => {
1722 let new_url = selection.url.clone();
1727
1728 {
1729 let controller = player.0.lock().await;
1730 controller.stop().map_err(|e| e.to_string())?;
1731 }
1732
1733 {
1734 let controller = player.0.lock().await;
1735 let queue_arc = controller.queue();
1736 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1737
1738 if !queue.update_current_stream_url(new_url) {
1739 return Err("Failed to update stream URL in queue".to_string());
1740 }
1741 }
1742
1743 {
1744 let controller = player.0.lock().await;
1745 let queue_arc = controller.queue();
1746 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1747
1748 let updated_item = queue
1749 .current()
1750 .ok_or("No current item after URL update")?
1751 .clone();
1752 drop(queue);
1753
1754 controller
1755 .load_and_play(&updated_item)
1756 .map_err(|e| e.to_string())?;
1757 controller.seek(position).map_err(|e| e.to_string())?;
1758 }
1759
1760 info!(
1761 "[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
1762 stream_index, position
1763 );
1764
1765 Ok(AudioTrackSwitchResponse::Native { success: true })
1766 }
1767 AudioTrackSwitchStrategy::BackendSelectInPlace => {
1769 Ok(AudioTrackSwitchResponse::Native { success: true })
1770 }
1771 }
1772}
1773
1774#[tauri::command]
1795#[specta::specta]
1796#[allow(clippy::too_many_arguments)]
1800pub async fn player_set_stream_quality(
1801 player: State<'_, PlayerStateWrapper>,
1802 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1803 repository_handle: String,
1804 quality: crate::settings::StreamingQuality,
1805 use_html5: bool,
1806 current_position: Option<f64>,
1807 media_source_id: Option<String>,
1808 audio_stream_index: Option<i32>,
1809) -> Result<StreamQualityResponse, String> {
1810 info!(
1811 "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1812 quality.label(),
1813 use_html5,
1814 current_position
1815 );
1816
1817 let repository = repository_manager
1818 .0
1819 .get(&repository_handle)
1820 .ok_or("Repository not found - user may need to log in")?;
1821
1822 let jellyfin_item_id = {
1823 let controller = player.0.lock().await;
1824 let queue_arc = controller.queue();
1825 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1826
1827 let current_item = queue.current().ok_or("No item currently playing")?;
1828
1829 if current_item.media_type != MediaType::Video {
1830 return Err("Current item is not a video".to_string());
1831 }
1832
1833 current_item
1834 .jellyfin_id()
1835 .ok_or("Current item has no Jellyfin ID")?
1836 .to_string()
1837 };
1838
1839 crate::repository::online::set_playback_quality_override(quality);
1847
1848 let position = match current_position {
1862 Some(p) => p,
1863 None => {
1864 let controller = player.0.lock().await;
1865 controller.absolute_position()
1866 }
1867 };
1868 let selection = repository
1869 .get_stream_selection(
1870 &jellyfin_item_id,
1871 media_source_id.as_deref(),
1872 audio_stream_index,
1873 )
1874 .await
1875 .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1876 let new_url = selection.url.clone();
1877
1878 if use_html5 {
1879 return Ok(StreamQualityResponse::ReloadStream {
1880 selection,
1881 position,
1882 });
1883 }
1884
1885 {
1891 let controller = player.0.lock().await;
1892 controller.stop().map_err(|e| e.to_string())?;
1893
1894 let queue_arc = controller.queue();
1895 {
1896 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1897 if !queue.update_current_stream_url(new_url.clone()) {
1898 return Err("Failed to update stream URL in queue".to_string());
1899 }
1900 }
1901
1902 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1903 let updated_item = queue.current().ok_or("No current item after URL update")?;
1904 controller
1905 .load_and_play(updated_item)
1906 .map_err(|e| e.to_string())?;
1907 if position > 0.0 {
1908 controller.seek(position).map_err(|e| e.to_string())?;
1909 }
1910 }
1911
1912 Ok(StreamQualityResponse::Native {
1913 selection,
1914 position,
1915 })
1916}
1917
1918#[tauri::command]
1922#[specta::specta]
1923pub async fn player_set_audio_track(
1924 player: State<'_, PlayerStateWrapper>,
1925 stream_index: i32,
1926) -> Result<PlayerStatus, String> {
1927 let controller = player.0.lock().await;
1928 controller
1929 .set_audio_track(stream_index)
1930 .map_err(|e| e.to_string())?;
1931 Ok(get_player_status(&controller))
1932}
1933
1934#[tauri::command]
1943#[specta::specta]
1944pub async fn player_set_subtitle_track(
1945 player: State<'_, PlayerStateWrapper>,
1946 stream_index: Option<i32>,
1947) -> Result<PlayerStatus, String> {
1948 let controller = player.0.lock().await;
1949 controller
1950 .set_subtitle_track(stream_index)
1951 .map_err(|e| e.to_string())?;
1952 Ok(get_player_status(&controller))
1953}
1954
1955fn normalize_volume(volume: f32) -> f32 {
1965 if volume.is_nan() {
1966 0.0
1967 } else {
1968 volume.clamp(0.0, 1.0)
1969 }
1970}
1971
1972#[tauri::command]
1973#[specta::specta]
1974pub async fn player_set_volume(
1975 player: State<'_, PlayerStateWrapper>,
1976 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1977 volume: f32,
1978) -> Result<PlayerStatus, String> {
1979 let volume = normalize_volume(volume);
1984
1985 let mode = playback_mode.0.get_mode();
1987
1988 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1989 let client = {
1991 let controller = player.0.lock().await;
1992 let client_arc = controller.jellyfin_client();
1993 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1994 client_opt
1995 .as_ref()
1996 .ok_or("Jellyfin client not configured")?
1997 .clone()
1998 };
1999 let volume_percent = (volume * 100.0) as i32;
2001 client
2002 .session_set_volume(session_id, volume_percent)
2003 .await?;
2004 } else {
2005 let controller = player.0.lock().await;
2007 controller.set_volume(volume).map_err(|e| e.to_string())?;
2008 }
2009
2010 let controller = player.0.lock().await;
2011 Ok(get_player_status(&controller))
2012}
2013
2014#[tauri::command]
2015#[specta::specta]
2016pub async fn player_toggle_mute(
2017 player: State<'_, PlayerStateWrapper>,
2018 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2019) -> Result<PlayerStatus, String> {
2020 let mode = playback_mode.0.get_mode();
2022
2023 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2024 let client = {
2026 let controller = player.0.lock().await;
2027 let client_arc = controller.jellyfin_client();
2028 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2029 client_opt
2030 .as_ref()
2031 .ok_or("Jellyfin client not configured")?
2032 .clone()
2033 };
2034 client
2035 .send_session_command(session_id, "ToggleMute")
2036 .await?;
2037 } else {
2038 }
2043
2044 let controller = player.0.lock().await;
2045 Ok(get_player_status(&controller))
2046}
2047
2048#[tauri::command]
2049#[specta::specta]
2050pub async fn player_toggle_shuffle(
2051 player: State<'_, PlayerStateWrapper>,
2052) -> Result<QueueStatus, String> {
2053 let controller = player.0.lock().await;
2054 controller.toggle_shuffle();
2055 controller.emit_queue_changed();
2056 Ok(get_queue_status(&controller))
2057}
2058
2059#[tauri::command]
2060#[specta::specta]
2061pub async fn player_cycle_repeat(
2062 player: State<'_, PlayerStateWrapper>,
2063) -> Result<QueueStatus, String> {
2064 let controller = player.0.lock().await;
2065 controller.cycle_repeat();
2066 controller.emit_queue_changed();
2067 Ok(get_queue_status(&controller))
2068}
2069
2070#[tauri::command]
2071#[specta::specta]
2072pub async fn player_get_status(
2073 player: State<'_, PlayerStateWrapper>,
2074 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2075) -> Result<PlayerStatus, String> {
2076 let mode = playback_mode.0.get_mode();
2077
2078 let controller = player.0.lock().await;
2080 let mut status = get_player_status(&controller);
2081
2082 let local_media = {
2084 let queue_arc = controller.queue();
2085 let queue = queue_arc.lock().map_err(|e| e.to_string())?;
2086 queue.current().map(MergedMediaItem::from)
2087 };
2088
2089 let local_is_playing = status.state.is_playing();
2090 let local_volume = status.volume;
2091
2092 if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2094 let client = {
2095 let client_arc = controller.jellyfin_client();
2096 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2097 client_opt
2098 .as_ref()
2099 .ok_or("Jellyfin client not configured")?
2100 .clone()
2101 };
2102
2103 drop(controller); match client.get_session(&session_id).await {
2106 Ok(Some(session)) => {
2107 log::info!("[PlayerCommands] Merging remote session state");
2108
2109 status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2111
2112 status.merged_is_playing = session
2114 .play_state
2115 .as_ref()
2116 .map(|ps| !ps.is_paused.unwrap_or(true))
2117 .unwrap_or(false);
2118
2119 status.position = session
2121 .play_state
2122 .as_ref()
2123 .and_then(|ps| ps.position_ticks)
2124 .map(|ticks| ticks as f64 / 10_000_000.0)
2125 .unwrap_or(0.0);
2126
2127 status.duration = session
2129 .now_playing_item
2130 .as_ref()
2131 .and_then(|item| item.run_time_ticks)
2132 .map(|ticks| ticks as f64 / 10_000_000.0);
2133
2134 status.merged_volume = session
2136 .play_state
2137 .as_ref()
2138 .and_then(|ps| ps.volume_level)
2139 .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2140 .unwrap_or(1.0);
2141
2142 return Ok(status);
2143 }
2144 Ok(None) => {
2145 log::warn!("[PlayerCommands] Remote session not found, using local state");
2146 }
2147 Err(e) => {
2148 log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2149 }
2150 }
2151 }
2152
2153 status.merged_media = local_media;
2155 status.merged_is_playing = local_is_playing;
2156 status.merged_volume = local_volume;
2157
2158 Ok(status)
2159}
2160
2161#[tauri::command]
2162#[specta::specta]
2163pub async fn player_get_queue(
2164 player: State<'_, PlayerStateWrapper>,
2165) -> Result<QueueStatus, String> {
2166 let controller = player.0.lock().await;
2167 Ok(get_queue_status(&controller))
2168}
2169
2170#[derive(specta::Type, Debug, Serialize)]
2179#[serde(rename_all = "camelCase")]
2180pub struct PlaybackCapabilities {
2181 pub uses_webview_audio: bool,
2185 pub supports_native_video: bool,
2190 pub webview_video_fallback: bool,
2194}
2195
2196pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
2208 !is_android && native_video_enabled
2209}
2210
2211#[tauri::command]
2215#[specta::specta]
2216pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2217 let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2219
2220 Ok(PlaybackCapabilities {
2221 uses_webview_audio: !native_audio,
2222 supports_native_video: cfg!(target_os = "android")
2224 || crate::player::native_video::enabled(),
2225 webview_video_fallback: webview_video_fallback(
2227 cfg!(target_os = "android"),
2228 crate::player::native_video::enabled(),
2229 ),
2230 })
2231}
2232
2233pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2234 let (backend, use_html5_element) = if cfg!(target_os = "android") {
2236 (VideoBackend::Native, false)
2238 } else if crate::player::native_video::enabled() {
2239 (VideoBackend::Native, false)
2243 } else {
2244 (VideoBackend::Html5, true)
2246 };
2247
2248 PlayerStatus {
2249 state: controller.state(),
2250 position: controller.absolute_position(),
2255 duration: controller.duration(),
2256 volume: controller.volume(),
2257 muted: controller.muted(),
2258 shuffle: controller.is_shuffle(),
2259 repeat: controller.repeat_mode(),
2260 backend,
2261 use_html5_element,
2262
2263 merged_media: None,
2265 merged_is_playing: false,
2266 merged_volume: controller.volume(),
2267 }
2268}
2269
2270pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2271 let queue = controller.queue();
2272 let queue_lock = queue.lock_safe();
2273
2274 QueueStatus {
2275 items: queue_lock.items().to_vec(),
2276 current_index: queue_lock.current_index(),
2277 shuffle: queue_lock.is_shuffle(),
2278 repeat: queue_lock.repeat_mode(),
2279 has_next: queue_lock.has_next(),
2280 has_previous: queue_lock.has_previous(),
2281 }
2282}
2283
2284async fn play_selection_on_remote(
2296 controller: &PlayerController,
2297 session_id: &str,
2298 media_items: &[MediaItem],
2299 start_index: usize,
2300) -> Result<(), String> {
2301 let mut jellyfin_ids: Vec<String> = Vec::new();
2304 let mut adjusted_index: Option<usize> = None;
2305 for (i, item) in media_items.iter().enumerate() {
2306 if let Some(id) = item.jellyfin_id() {
2307 if i == start_index {
2308 adjusted_index = Some(jellyfin_ids.len());
2309 }
2310 jellyfin_ids.push(id.to_string());
2311 }
2312 }
2313
2314 let start_index =
2315 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2316
2317 if jellyfin_ids.is_empty() {
2318 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2319 }
2320
2321 let client = {
2322 let client_arc = controller.jellyfin_client();
2323 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2324 client_opt
2325 .as_ref()
2326 .ok_or("Jellyfin client not configured")?
2327 .clone()
2328 };
2329
2330 client
2332 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2333 .await
2334 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2335}
2336
2337#[tauri::command]
2339#[specta::specta]
2340pub async fn player_play_album_track(
2341 player: State<'_, PlayerStateWrapper>,
2342 session: State<'_, MediaSessionManagerWrapper>,
2343 db: State<'_, DatabaseWrapper>,
2344 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2345 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2346 repository_handle: String,
2347 request: PlayAlbumTrackRequest,
2348) -> Result<PlayerStatus, String> {
2349 info!(
2350 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2351 request.album_id, request.track_id, request.shuffle
2352 );
2353
2354 let repository = repository_manager
2356 .0
2357 .get(&repository_handle)
2358 .ok_or("Repository not found - user may need to log in")?;
2359
2360 info!(
2362 "Fetching tracks for album {} via repository",
2363 request.album_id
2364 );
2365 let album_items = repository
2366 .get_items(
2367 &request.album_id,
2368 Some(GetItemsOptions {
2369 limit: Some(1000),
2370 fields: Some(vec![
2371 "PrimaryImageAspectRatio".to_string(),
2372 "Overview".to_string(),
2373 "MediaStreams".to_string(),
2374 ]),
2375 ..Default::default()
2376 }),
2377 )
2378 .await
2379 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2380
2381 info!("Found {} items in album", album_items.items.len());
2382
2383 let mut tracks: Vec<_> = album_items
2385 .items
2386 .into_iter()
2387 .filter(|item| item.item_type == "Audio")
2388 .collect();
2389 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2390
2391 info!("Found {} audio tracks in album", tracks.len());
2392
2393 if tracks.is_empty() {
2394 return Err("No audio tracks found in album".to_string());
2395 }
2396
2397 info!("Album has {} tracks after sorting:", tracks.len());
2399 for (idx, track) in tracks.iter().enumerate() {
2400 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2401 }
2402
2403 info!("Looking for track_id: {}", request.track_id);
2406 let album_index = tracks
2407 .iter()
2408 .position(|t| t.id == request.track_id)
2409 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2410
2411 info!(
2412 "Track {} is at index {} in album",
2413 request.track_id, album_index
2414 );
2415
2416 let mut media_items = Vec::new();
2418 for track in tracks {
2419 let jellyfin_id = &track.id;
2421 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2422
2423 let source = if let Some(path) = local_path {
2424 MediaSource::Local {
2425 file_path: PathBuf::from(path),
2426 jellyfin_item_id: Some(jellyfin_id.clone()),
2427 }
2428 } else {
2429 match repository.get_audio_stream_url(&track.id).await {
2433 Ok(stream_url) => MediaSource::Remote {
2434 stream_url,
2435 jellyfin_item_id: jellyfin_id.clone(),
2436 },
2437 Err(e) => {
2438 warn!(
2439 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2440 track.name, track.id, e
2441 );
2442 continue;
2443 }
2444 }
2445 };
2446
2447 let primary_image_tag_for_url = track.primary_image_tag.clone();
2448 let media_item = MediaItem {
2449 transport: None,
2451 id: track.id.clone(),
2452 title: track.name.clone(),
2453 name: Some(track.name.clone()), artist: track
2455 .album_artist
2456 .clone()
2457 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2458 album: Some(request.album_name.clone()),
2459 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2461 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(),
2465 item_type: Some(track.item_type.clone()), playlist_id: None,
2467 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2468 artwork_url: primary_image_tag_for_url.map(|tag| {
2469 repository.get_image_url(
2470 &request.album_id,
2471 ImageType::Primary,
2472 Some(ImageOptions {
2473 max_width: Some(300),
2474 tag: Some(tag),
2475 ..Default::default()
2476 }),
2477 )
2478 }),
2479 media_type: MediaType::Audio,
2480 source,
2481 video_codec: None,
2482 needs_transcoding: false,
2483 video_width: None,
2484 video_height: None,
2485 subtitles: vec![],
2486 series_id: None,
2487 server_id: None,
2488 };
2489
2490 media_items.push(media_item);
2491 }
2492
2493 if media_items.is_empty() {
2494 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2495 }
2496
2497 let start_index = media_items
2501 .iter()
2502 .position(|item| item.id == request.track_id)
2503 .unwrap_or(0);
2504
2505 info!(
2506 "Built queue with {} media items, starting at index {}",
2507 media_items.len(),
2508 start_index
2509 );
2510
2511 if request.shuffle {
2513 let controller = player.0.lock().await;
2514 controller.toggle_shuffle();
2515 }
2516
2517 if let Some(first_item) = media_items.get(start_index) {
2519 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2520 session_mgr.start_audio_session(first_item.clone());
2521 }
2522
2523 let controller = player.0.lock().await;
2524
2525 let remote_session = match playback_mode.0.get_mode() {
2529 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2530 _ => None,
2531 };
2532
2533 if let Some(session_id) = &remote_session {
2534 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2535 controller
2536 .set_queue(media_items, start_index)
2537 .map_err(|e| e.to_string())?;
2538 } else {
2539 playback_mode
2542 .0
2543 .set_mode(crate::playback_mode::PlaybackMode::Local);
2544 controller
2545 .play_queue(media_items, start_index)
2546 .map_err(|e| e.to_string())?;
2547 }
2548
2549 {
2551 let queue_arc = controller.queue();
2552 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2553 queue.set_context(QueueContext::Album {
2554 album_id: request.album_id.clone(),
2555 album_name: request.album_name.clone(),
2556 });
2557 }
2558
2559 controller.emit_queue_changed();
2561
2562 {
2564 let queue_arc = controller.queue();
2565 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2566 info!(
2567 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2568 queue_lock.items().len(),
2569 queue_lock.current_index()
2570 );
2571 }
2572
2573 if let Some(emitter) = controller.event_emitter() {
2575 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2576 emitter.emit(PlayerStatusEvent::SessionChanged {
2577 session: session_mgr.current().clone(),
2578 });
2579 }
2580
2581 Ok(get_player_status(&controller))
2582}
2583
2584#[tauri::command]
2586#[specta::specta]
2587pub async fn player_play_tracks(
2588 player: State<'_, PlayerStateWrapper>,
2589 session: State<'_, MediaSessionManagerWrapper>,
2590 db: State<'_, DatabaseWrapper>,
2591 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2592 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2593 repository_handle: String,
2594 request: PlayTracksRequest,
2595) -> Result<PlayerStatus, String> {
2596 crate::repository::online::clear_playback_quality_override();
2603
2604 info!(
2605 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2606 request.track_ids.len(),
2607 request.start_index,
2608 request.shuffle
2609 );
2610
2611 if request.track_ids.is_empty() {
2613 return Err("No tracks provided".to_string());
2614 }
2615
2616 let repository = repository_manager
2618 .0
2619 .get(&repository_handle)
2620 .ok_or("Repository not found - user may need to log in")?;
2621
2622 let mut media_items = Vec::new();
2624 for track_id in &request.track_ids {
2625 let track = repository
2627 .get_item(track_id)
2628 .await
2629 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2630
2631 let local_path = check_for_local_download(&db, track_id).await?;
2633
2634 let source = if let Some(path) = local_path {
2636 MediaSource::Local {
2637 file_path: PathBuf::from(path),
2638 jellyfin_item_id: Some(track.id.clone()),
2639 }
2640 } else {
2641 let stream_url = repository
2642 .get_audio_stream_url(track_id)
2643 .await
2644 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2645
2646 MediaSource::Remote {
2647 stream_url,
2648 jellyfin_item_id: track.id.clone(),
2649 }
2650 };
2651
2652 let primary_image_tag_for_url = track.primary_image_tag.clone();
2654 let media_item = MediaItem {
2655 transport: None,
2657 id: track.id.clone(),
2658 title: track.name.clone(),
2659 name: Some(track.name.clone()), artist: track
2661 .album_artist
2662 .clone()
2663 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2664 album: track.album_name.clone(),
2665 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2667 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(),
2671 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2674 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2675 track.album_id.as_ref().map(|album_id| {
2676 repository.get_image_url(
2677 album_id,
2678 ImageType::Primary,
2679 Some(ImageOptions {
2680 max_width: Some(300),
2681 tag: Some(tag),
2682 ..Default::default()
2683 }),
2684 )
2685 })
2686 }),
2687 media_type: MediaType::Audio,
2688 source,
2689 video_codec: None,
2690 needs_transcoding: false,
2691 video_width: None,
2692 video_height: None,
2693 subtitles: vec![],
2694 series_id: None,
2695 server_id: None,
2696 };
2697
2698 media_items.push(media_item);
2699 }
2700
2701 info!("Built queue with {} media items", media_items.len());
2702
2703 let queue_context = match request.context {
2705 PlayTracksContext::Playlist {
2706 playlist_id,
2707 playlist_name,
2708 } => {
2709 for item in &mut media_items {
2710 item.playlist_id = Some(playlist_id.clone());
2711 }
2712 QueueContext::Playlist {
2713 playlist_id,
2714 playlist_name,
2715 }
2716 }
2717 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2718 };
2719
2720 if request.shuffle {
2722 let controller = player.0.lock().await;
2723 controller.toggle_shuffle();
2724 }
2725
2726 if let Some(first_item) = media_items.get(request.start_index) {
2728 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2729 session_mgr.start_audio_session(first_item.clone());
2730 }
2731
2732 let controller = player.0.lock().await;
2733
2734 let remote_session = match playback_mode.0.get_mode() {
2740 crate::playback_mode::PlaybackMode::Remote { session_id }
2741 if !playback_mode.0.is_transferring() =>
2742 {
2743 Some(session_id)
2744 }
2745 _ => None,
2746 };
2747
2748 if let Some(session_id) = &remote_session {
2749 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2750 .await?;
2751 controller
2752 .set_queue(media_items, request.start_index)
2753 .map_err(|e| e.to_string())?;
2754 } else {
2755 if !playback_mode.0.is_transferring() {
2763 playback_mode
2764 .0
2765 .set_mode(crate::playback_mode::PlaybackMode::Local);
2766 }
2767 controller
2768 .play_queue_from(media_items, request.start_index, request.start_position)
2769 .map_err(|e| e.to_string())?;
2770 }
2771
2772 {
2774 let queue_arc = controller.queue();
2775 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2776 queue.set_context(queue_context);
2777 }
2778
2779 controller.emit_queue_changed();
2781 if let Some(emitter) = controller.event_emitter() {
2782 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2783 emitter.emit(PlayerStatusEvent::SessionChanged {
2784 session: session_mgr.current().clone(),
2785 });
2786 }
2787
2788 info!("player_play_tracks completed successfully");
2789 Ok(get_player_status(&controller))
2790}
2791
2792#[derive(specta::Type, Debug, Serialize)]
2794#[serde(rename_all = "camelCase")]
2795pub struct PreloadResult {
2796 pub queued_count: usize,
2798 pub already_downloaded: usize,
2800 pub skipped: usize,
2802}
2803
2804#[tauri::command]
2807#[specta::specta]
2808pub async fn player_preload_upcoming(
2809 player: State<'_, PlayerStateWrapper>,
2810 db: State<'_, DatabaseWrapper>,
2811 smart_cache: State<'_, SmartCacheWrapper>,
2812 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2813 app: tauri::AppHandle,
2814 user_id: String,
2815 _download_base_path: String,
2816) -> Result<PreloadResult, String> {
2817 let (db_service, target_dir) = {
2821 let database = db.0.lock().map_err(|e| e.to_string())?;
2822 let target_dir = database
2823 .path()
2824 .parent()
2825 .ok_or_else(|| "Database path has no parent directory".to_string())?
2826 .to_string_lossy()
2827 .to_string();
2828 (Arc::new(database.service()), target_dir)
2829 };
2830
2831 let precache_count = {
2833 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2834 if !cache.should_precache_queue() {
2835 return Ok(PreloadResult {
2836 queued_count: 0,
2837 already_downloaded: 0,
2838 skipped: 0,
2839 });
2840 }
2841 cache.queue_precache_count()
2842 };
2843
2844 let upcoming_items: Vec<MediaItem> = {
2846 let controller = player.0.lock().await;
2847 let queue = controller.queue();
2848 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2849 queue_lock
2850 .get_upcoming(precache_count)
2851 .into_iter()
2852 .cloned()
2853 .collect()
2854 };
2855
2856 if upcoming_items.is_empty() {
2857 return Ok(PreloadResult {
2858 queued_count: 0,
2859 already_downloaded: 0,
2860 skipped: 0,
2861 });
2862 }
2863
2864 let mut queued_count = 0;
2865 let mut already_downloaded = 0;
2866 let mut skipped = 0;
2867
2868 for item in upcoming_items {
2870 let (jellyfin_id, stream_url) = match &item.source {
2874 MediaSource::Remote {
2875 jellyfin_item_id,
2876 stream_url,
2877 } => (jellyfin_item_id.clone(), stream_url.clone()),
2878 MediaSource::Local { .. } => {
2879 already_downloaded += 1;
2880 continue;
2881 }
2882 MediaSource::DirectUrl { .. } => {
2883 skipped += 1;
2884 continue;
2885 }
2886 };
2887
2888 let query = Query::with_params(
2892 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2893 AND (status IN ('completed', 'downloading')
2894 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2895 vec![
2896 QueryParam::String(jellyfin_id.clone()),
2897 QueryParam::String(user_id.clone()),
2898 ],
2899 );
2900
2901 let is_downloaded: Option<String> = db_service
2902 .query_optional(query, |row| row.get(0))
2903 .await
2904 .map_err(|e| e.to_string())?;
2905
2906 if is_downloaded.is_some() {
2907 already_downloaded += 1;
2908 continue;
2909 }
2910
2911 let album_dir = item
2914 .album
2915 .as_deref()
2916 .filter(|a| !a.is_empty())
2917 .unwrap_or("Unknown Album");
2918 let file_path = format!(
2919 "downloads/{}/{}.mp3",
2920 sanitize_filename(album_dir),
2921 sanitize_filename(&item.title)
2922 );
2923
2924 let insert_query = Query::with_params(
2928 "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)
2929 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2930 ON CONFLICT(item_id, user_id) DO UPDATE SET
2931 stream_url = excluded.stream_url,
2932 target_dir = excluded.target_dir
2933 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2934 vec![
2935 QueryParam::String(jellyfin_id),
2936 QueryParam::String(user_id.clone()),
2937 QueryParam::String(file_path),
2938 QueryParam::String(item.title.clone()),
2939 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2940 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2941 QueryParam::String(stream_url),
2942 QueryParam::String(target_dir.clone()),
2943 ],
2944 );
2945
2946 match db_service.execute(insert_query).await {
2947 Ok(rows) if rows > 0 => {
2948 info!(
2949 "[Preload] Queued download for: {} - {}",
2950 item.artist.as_deref().unwrap_or("Unknown"),
2951 item.title
2952 );
2953 queued_count += 1;
2954 }
2955 Ok(_) => {
2956 already_downloaded += 1;
2958 }
2959 Err(e) => {
2960 error!("[Preload] Failed to queue {}: {}", item.title, e);
2961 skipped += 1;
2962 }
2963 }
2964 }
2965
2966 if queued_count > 0 {
2969 let active_downloads = {
2970 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2971 manager.get_active_downloads()
2972 };
2973 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2974 }
2975
2976 info!(
2977 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2978 queued_count, already_downloaded, skipped
2979 );
2980
2981 Ok(PreloadResult {
2982 queued_count,
2983 already_downloaded,
2984 skipped,
2985 })
2986}
2987
2988fn sanitize_filename(name: &str) -> String {
2990 name.chars()
2991 .map(|c| match c {
2992 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2993 _ => c,
2994 })
2995 .collect()
2996}
2997
2998#[tauri::command]
3000#[specta::specta]
3001pub async fn player_set_cache_config(
3002 smart_cache: State<'_, SmartCacheWrapper>,
3003 config: CacheConfig,
3004) -> Result<(), String> {
3005 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3006 cache.update_config(config);
3007 Ok(())
3008}
3009
3010#[tauri::command]
3012#[specta::specta]
3013pub async fn player_get_cache_config(
3014 smart_cache: State<'_, SmartCacheWrapper>,
3015) -> Result<CacheConfig, String> {
3016 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3017 Ok(cache.get_config().unwrap_or_default())
3018}
3019
3020#[tauri::command]
3022#[specta::specta]
3023pub async fn player_configure_jellyfin(
3024 player: State<'_, PlayerStateWrapper>,
3025 db: State<'_, DatabaseWrapper>,
3026 server_url: String,
3027 access_token: String,
3028 user_id: String,
3029 device_id: String,
3030) -> Result<(), String> {
3031 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
3032
3033 let config = JellyfinConfig {
3034 server_url,
3035 access_token,
3036 device_id,
3037 };
3038
3039 let client = JellyfinClient::new(config.clone())?;
3041
3042 let db_service = {
3049 let database = db.0.lock().map_err(|e| e.to_string())?;
3050 Arc::new(database.service())
3051 };
3052 let reporter_client = JellyfinClient::new(config)?;
3053 let reporter = crate::playback_reporting::PlaybackReporter::new(
3054 db_service,
3055 Arc::new(TokioMutex::new(Some(reporter_client))),
3056 user_id,
3057 );
3058
3059 let controller = player.0.lock().await;
3060 controller.set_jellyfin_client(Some(client));
3061 controller.set_playback_reporter(Some(reporter)).await;
3062
3063 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
3064 Ok(())
3065}
3066
3067#[tauri::command]
3069#[specta::specta]
3070pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
3071 log::info!("[PlayerCommand] Disabling Jellyfin client");
3072
3073 let controller = player.0.lock().await;
3074 controller.set_jellyfin_client(None);
3075 controller.set_playback_reporter(None).await;
3076
3077 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
3078 Ok(())
3079}
3080
3081#[cfg(test)]
3082mod tests {
3083 use crate::utils::lock::MutexSafe;
3084
3085 #[test]
3095 fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
3096 use super::webview_video_fallback;
3097
3098 assert!(
3099 !webview_video_fallback(true, false),
3100 "Android: ExoPlayer is the only video renderer"
3101 );
3102 assert!(
3103 !webview_video_fallback(true, true),
3104 "Android never falls back, whatever else is switched on"
3105 );
3106 assert!(
3107 webview_video_fallback(false, true),
3108 "Linux with mpv native video: the webview is the fallback"
3109 );
3110 assert!(
3111 !webview_video_fallback(false, false),
3112 "the webview is the only renderer; nothing to fall back from"
3113 );
3114 }
3115
3116 #[test]
3127 fn test_normalize_volume_clamps_and_rejects_nan() {
3128 use super::normalize_volume;
3129
3130 assert_eq!(normalize_volume(0.0), 0.0);
3132 assert_eq!(normalize_volume(0.5), 0.5);
3133 assert_eq!(normalize_volume(1.0), 1.0);
3134
3135 assert_eq!(normalize_volume(-0.5), 0.0);
3137 assert_eq!(normalize_volume(42.0), 1.0);
3138 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
3139 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
3140
3141 let from_nan = normalize_volume(f32::NAN);
3144 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3145 assert_eq!(from_nan, 0.0);
3146
3147 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3149 let percent = (normalize_volume(input) * 100.0) as i32;
3150 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3151 }
3152 }
3153
3154 #[tokio::test]
3171 async fn test_play_item_request_carries_subtitles_into_media_item() {
3172 use super::{create_media_item, PlayItemRequest};
3173
3174 let payload = serde_json::json!({
3175 "id": "ep-1",
3176 "title": "Pilot",
3177 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3178 "videoCodec": "h264",
3179 "needsTranscoding": false,
3180 "subtitles": [
3181 {
3182 "index": 2,
3183 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3184 "language": "eng",
3185 "label": "English (SRT)",
3186 "mime_type": "text/vtt"
3187 },
3188 {
3189 "index": 3,
3190 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3191 "language": null,
3192 "label": null,
3193 "mime_type": "text/vtt"
3194 }
3195 ]
3196 });
3197
3198 let req: PlayItemRequest =
3199 serde_json::from_value(payload).expect("frontend payload must deserialize");
3200 assert_eq!(
3201 req.subtitles.len(),
3202 2,
3203 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3204 );
3205
3206 let media = create_media_item(req, None).await.unwrap();
3207 assert_eq!(
3208 media.subtitles.len(),
3209 2,
3210 "create_media_item must thread the tracks onto the MediaItem the backend loads"
3211 );
3212 assert_eq!(media.subtitles[0].index, 2);
3213 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3214 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3215 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3216 assert_eq!(media.subtitles[1].index, 3);
3219 assert!(media.subtitles[1].language.is_none());
3220 }
3221
3222 #[tokio::test]
3228 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3229 use super::{create_media_item, PlayItemRequest};
3230
3231 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3232 "id": "movie-1",
3233 "title": "Movie",
3234 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3235 "videoCodec": "h264",
3236 "needsTranscoding": false
3237 }))
3238 .expect("a subtitle-less payload must still deserialize");
3239
3240 assert!(req.subtitles.is_empty());
3241 assert!(create_media_item(req, None)
3242 .await
3243 .unwrap()
3244 .subtitles
3245 .is_empty());
3246 }
3247
3248 #[test]
3260 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3261 use crate::player::media::SubtitleTrack;
3262
3263 let subtitles = vec![SubtitleTrack {
3264 index: 2,
3265 url: "https://jelly.example/subs.vtt".to_string(),
3266 language: Some("eng".to_string()),
3267 label: Some("English".to_string()),
3268 mime_type: "text/vtt".to_string(),
3269 }];
3270
3271 let json = serde_json::to_string(&subtitles).unwrap();
3273 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3274 let obj = parsed[0].as_object().unwrap();
3275
3276 for key in ["url", "language", "label", "mime_type"] {
3277 assert!(
3278 obj.contains_key(key),
3279 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3280 obj.keys().collect::<Vec<_>>()
3281 );
3282 }
3283 assert!(
3284 !obj.contains_key("mimeType"),
3285 "camelCasing mime_type silently drops every track's MIME type on Android"
3286 );
3287 }
3288
3289 #[test]
3294 fn test_background_audio_source_prefers_local_file() {
3295 use super::background_audio_source;
3296 use crate::player::MediaSource;
3297 use std::path::PathBuf;
3298
3299 let local = background_audio_source(
3300 Some("/downloads/ep1.mkv".to_string()),
3301 "https://server/audio-only".to_string(),
3302 "ep-1",
3303 );
3304 match local {
3305 MediaSource::Local {
3306 file_path,
3307 jellyfin_item_id,
3308 } => {
3309 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3310 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3312 }
3313 other => panic!("expected a local source, got {:?}", other),
3314 }
3315
3316 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3317 match remote {
3318 MediaSource::Remote {
3319 stream_url,
3320 jellyfin_item_id,
3321 } => {
3322 assert_eq!(stream_url, "https://server/audio-only");
3323 assert_eq!(jellyfin_item_id, "ep-1");
3324 }
3325 other => panic!("expected a remote source, got {:?}", other),
3326 }
3327 }
3328
3329 #[test]
3342 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3343 use super::background_audio_plan;
3344
3345 let local = background_audio_plan(true, 1104.0);
3346 assert_eq!(local.base_seconds, 0.0);
3347 assert_eq!(local.seek_to, Some(1104.0));
3348
3349 let streamed = background_audio_plan(false, 1104.0);
3350 assert_eq!(streamed.base_seconds, 1104.0);
3351 assert_eq!(
3352 streamed.seek_to, None,
3353 "the URL already starts at the handoff point; seeking again skips past it"
3354 );
3355 }
3356
3357 #[test]
3362 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3363 use super::background_audio_plan;
3364
3365 for local in [true, false] {
3366 let plan = background_audio_plan(local, 0.0);
3367 assert_eq!(plan.base_seconds, 0.0);
3368 assert_eq!(plan.seek_to, None);
3369 }
3370 }
3371
3372 #[tokio::test]
3378 async fn test_resolve_local_media_path() {
3379 use super::resolve_local_media_path;
3380 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3381 use rusqlite::Connection;
3382 use std::sync::{Arc, Mutex};
3383
3384 let conn = Connection::open_in_memory().unwrap();
3385 conn.execute(
3386 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3387 [],
3388 )
3389 .unwrap();
3390 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3391
3392 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3394 std::fs::write(&present, b"x").unwrap();
3395 let present_str = present.to_string_lossy().to_string();
3396
3397 for (item, status, path) in [
3398 ("downloaded", "completed", present_str.as_str()),
3399 ("still-going", "downloading", present_str.as_str()),
3400 (
3401 "file-gone",
3402 "completed",
3403 "/nonexistent/jellytau/missing.mp4",
3404 ),
3405 ] {
3406 db_service
3407 .execute(Query::with_params(
3408 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3409 vec![
3410 crate::storage::db_service::QueryParam::String(item.to_string()),
3411 crate::storage::db_service::QueryParam::String(status.to_string()),
3412 crate::storage::db_service::QueryParam::String(path.to_string()),
3413 ],
3414 ))
3415 .await
3416 .unwrap();
3417 }
3418
3419 assert_eq!(
3420 resolve_local_media_path(&db_service, "downloaded")
3421 .await
3422 .unwrap()
3423 .as_deref(),
3424 Some(present_str.as_str()),
3425 "a completed download with its file present must resolve"
3426 );
3427
3428 assert_eq!(
3429 resolve_local_media_path(&db_service, "still-going")
3430 .await
3431 .unwrap(),
3432 None,
3433 "an in-progress download is not playable from disk"
3434 );
3435
3436 assert_eq!(
3437 resolve_local_media_path(&db_service, "file-gone")
3438 .await
3439 .unwrap(),
3440 None,
3441 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3442 );
3443
3444 assert_eq!(
3445 resolve_local_media_path(&db_service, "never-heard-of-it")
3446 .await
3447 .unwrap(),
3448 None
3449 );
3450
3451 let _ = std::fs::remove_file(&present);
3452 }
3453
3454 #[tokio::test]
3458 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3459 use super::{refresh_queue_local_sources, DatabaseWrapper};
3460 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3461 use crate::storage::Database;
3462 use std::sync::Mutex;
3463
3464 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3467 std::fs::create_dir_all(&dir).unwrap();
3468 let existing = dir.join("track-a.mp3");
3469 std::fs::write(&existing, b"audio").unwrap();
3470 let missing = dir.join("track-b-missing.mp3");
3471 let _ = std::fs::remove_file(&missing);
3472
3473 let database = Database::open_in_memory().unwrap();
3474 {
3475 let conn = database.connection();
3476 let conn = conn.lock_safe();
3477 conn.execute_batch(&format!(
3478 r#"
3479 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3480 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3481 INSERT INTO downloads (item_id, user_id, file_path, status)
3482 VALUES ('track-a', 'user1', '{}', 'completed');
3483 INSERT INTO downloads (item_id, user_id, file_path, status)
3484 VALUES ('track-b', 'user1', '{}', 'completed');
3485 "#,
3486 existing.display(),
3487 missing.display()
3488 ))
3489 .unwrap();
3490 }
3491 let db = DatabaseWrapper(Mutex::new(database));
3492
3493 let make_item = |id: &str| MediaItem {
3494 transport: None,
3496 id: id.to_string(),
3497 title: id.to_string(),
3498 name: None,
3499 artist: None,
3500 album: None,
3501 album_name: None,
3502 album_id: None,
3503 artist_items: None,
3504 artists: None,
3505 primary_image_tag: None,
3506 image_id: None,
3507 item_type: None,
3508 playlist_id: None,
3509 duration: None,
3510 artwork_url: None,
3511 media_type: MediaType::Audio,
3512 source: MediaSource::Remote {
3513 stream_url: format!("http://test/Audio/{}/stream", id),
3514 jellyfin_item_id: id.to_string(),
3515 },
3516 video_codec: None,
3517 needs_transcoding: false,
3518 video_width: None,
3519 video_height: None,
3520 subtitles: vec![],
3521 series_id: None,
3522 server_id: None,
3523 };
3524
3525 let controller = PlayerController::default();
3526 controller
3527 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3528 .unwrap();
3529
3530 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3531 assert_eq!(switched, 1, "only the download whose file exists switches");
3532
3533 let queue = controller.queue();
3534 let queue_lock = queue.lock_safe();
3535 match &queue_lock.items()[0].source {
3536 MediaSource::Local {
3537 file_path,
3538 jellyfin_item_id,
3539 } => {
3540 assert_eq!(file_path, &existing);
3541 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3542 }
3543 other => panic!("track-a should be local, got {:?}", other),
3544 }
3545 assert!(
3546 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3547 "track-b's file is missing, it must stay remote"
3548 );
3549 }
3550
3551 #[test]
3554 fn test_find_track_index_in_album() {
3555 #[derive(Clone)]
3557 struct MockTrack {
3558 id: String,
3559 name: String,
3560 index_number: Option<i32>,
3561 }
3562
3563 let mut tracks = [
3564 MockTrack {
3565 id: "track1".to_string(),
3566 name: "Song 1".to_string(),
3567 index_number: Some(1),
3568 },
3569 MockTrack {
3570 id: "track2".to_string(),
3571 name: "Song 2".to_string(),
3572 index_number: Some(2),
3573 },
3574 MockTrack {
3575 id: "track3".to_string(),
3576 name: "Song 3".to_string(),
3577 index_number: Some(3),
3578 },
3579 MockTrack {
3580 id: "track4".to_string(),
3581 name: "Song 4".to_string(),
3582 index_number: Some(4),
3583 },
3584 MockTrack {
3585 id: "track5".to_string(),
3586 name: "Song 5".to_string(),
3587 index_number: Some(5),
3588 },
3589 ];
3590
3591 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3593
3594 let index1 = tracks.iter().position(|t| t.id == "track1");
3596 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3597 assert_eq!(
3598 tracks[0].name, "Song 1",
3599 "Track at index 0 should carry its name"
3600 );
3601
3602 let index3 = tracks.iter().position(|t| t.id == "track3");
3604 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3605
3606 let index5 = tracks.iter().position(|t| t.id == "track5");
3608 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3609
3610 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3612 assert_eq!(index_none, None, "Non-existent track should return None");
3613 }
3614
3615 #[test]
3617 fn test_track_iteration_order() {
3618 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3620
3621 let target_index = track_ids.iter().position(|&id| id == "id3");
3623 assert_eq!(
3624 target_index,
3625 Some(2),
3626 "id3 should be at index 2 in original list"
3627 );
3628
3629 let mut media_items = Vec::new();
3631 for id in &track_ids {
3632 media_items.push(id.to_string());
3633 }
3634
3635 assert_eq!(media_items.len(), 5);
3637 assert_eq!(media_items[0], "id1");
3638 assert_eq!(media_items[2], "id3");
3639 assert_eq!(media_items[4], "id5");
3640
3641 assert_eq!(media_items[target_index.unwrap()], "id3");
3643 }
3644
3645 #[test]
3647 fn test_album_track_sorting() {
3648 #[derive(Clone, Debug)]
3649 struct MockTrack {
3650 id: String,
3651 name: String,
3652 index_number: Option<i32>,
3653 }
3654
3655 let mut tracks = [
3657 MockTrack {
3658 id: "id5".to_string(),
3659 name: "Track 5".to_string(),
3660 index_number: Some(5),
3661 },
3662 MockTrack {
3663 id: "id1".to_string(),
3664 name: "Track 1".to_string(),
3665 index_number: Some(1),
3666 },
3667 MockTrack {
3668 id: "id3".to_string(),
3669 name: "Track 3".to_string(),
3670 index_number: Some(3),
3671 },
3672 MockTrack {
3673 id: "id2".to_string(),
3674 name: "Track 2".to_string(),
3675 index_number: Some(2),
3676 },
3677 MockTrack {
3678 id: "id4".to_string(),
3679 name: "Track 4".to_string(),
3680 index_number: Some(4),
3681 },
3682 ];
3683
3684 let requested_track_id = "id1";
3686
3687 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3689
3690 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3692
3693 assert_eq!(
3695 start_index,
3696 Some(0),
3697 "After sorting, track id1 should be at index 0"
3698 );
3699
3700 assert_eq!(tracks[0].id, "id1");
3702 assert_eq!(
3703 tracks[0].name, "Track 1",
3704 "Sorted track should retain its name"
3705 );
3706 assert_eq!(tracks[1].id, "id2");
3707 assert_eq!(tracks[2].id, "id3");
3708 assert_eq!(tracks[3].id, "id4");
3709 assert_eq!(tracks[4].id, "id5");
3710 }
3711}