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}
2191
2192#[tauri::command]
2196#[specta::specta]
2197pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2198 let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2200
2201 Ok(PlaybackCapabilities {
2202 uses_webview_audio: !native_audio,
2203 supports_native_video: cfg!(target_os = "android")
2205 || crate::player::native_video::enabled(),
2206 })
2207}
2208
2209pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2210 let (backend, use_html5_element) = if cfg!(target_os = "android") {
2212 (VideoBackend::Native, false)
2214 } else if crate::player::native_video::enabled() {
2215 (VideoBackend::Native, false)
2219 } else {
2220 (VideoBackend::Html5, true)
2222 };
2223
2224 PlayerStatus {
2225 state: controller.state(),
2226 position: controller.absolute_position(),
2231 duration: controller.duration(),
2232 volume: controller.volume(),
2233 muted: controller.muted(),
2234 shuffle: controller.is_shuffle(),
2235 repeat: controller.repeat_mode(),
2236 backend,
2237 use_html5_element,
2238
2239 merged_media: None,
2241 merged_is_playing: false,
2242 merged_volume: controller.volume(),
2243 }
2244}
2245
2246pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2247 let queue = controller.queue();
2248 let queue_lock = queue.lock_safe();
2249
2250 QueueStatus {
2251 items: queue_lock.items().to_vec(),
2252 current_index: queue_lock.current_index(),
2253 shuffle: queue_lock.is_shuffle(),
2254 repeat: queue_lock.repeat_mode(),
2255 has_next: queue_lock.has_next(),
2256 has_previous: queue_lock.has_previous(),
2257 }
2258}
2259
2260async fn play_selection_on_remote(
2272 controller: &PlayerController,
2273 session_id: &str,
2274 media_items: &[MediaItem],
2275 start_index: usize,
2276) -> Result<(), String> {
2277 let mut jellyfin_ids: Vec<String> = Vec::new();
2280 let mut adjusted_index: Option<usize> = None;
2281 for (i, item) in media_items.iter().enumerate() {
2282 if let Some(id) = item.jellyfin_id() {
2283 if i == start_index {
2284 adjusted_index = Some(jellyfin_ids.len());
2285 }
2286 jellyfin_ids.push(id.to_string());
2287 }
2288 }
2289
2290 let start_index =
2291 adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2292
2293 if jellyfin_ids.is_empty() {
2294 return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2295 }
2296
2297 let client = {
2298 let client_arc = controller.jellyfin_client();
2299 let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2300 client_opt
2301 .as_ref()
2302 .ok_or("Jellyfin client not configured")?
2303 .clone()
2304 };
2305
2306 client
2308 .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2309 .await
2310 .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2311}
2312
2313#[tauri::command]
2315#[specta::specta]
2316pub async fn player_play_album_track(
2317 player: State<'_, PlayerStateWrapper>,
2318 session: State<'_, MediaSessionManagerWrapper>,
2319 db: State<'_, DatabaseWrapper>,
2320 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2321 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2322 repository_handle: String,
2323 request: PlayAlbumTrackRequest,
2324) -> Result<PlayerStatus, String> {
2325 info!(
2326 "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2327 request.album_id, request.track_id, request.shuffle
2328 );
2329
2330 let repository = repository_manager
2332 .0
2333 .get(&repository_handle)
2334 .ok_or("Repository not found - user may need to log in")?;
2335
2336 info!(
2338 "Fetching tracks for album {} via repository",
2339 request.album_id
2340 );
2341 let album_items = repository
2342 .get_items(
2343 &request.album_id,
2344 Some(GetItemsOptions {
2345 limit: Some(1000),
2346 fields: Some(vec![
2347 "PrimaryImageAspectRatio".to_string(),
2348 "Overview".to_string(),
2349 "MediaStreams".to_string(),
2350 ]),
2351 ..Default::default()
2352 }),
2353 )
2354 .await
2355 .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2356
2357 info!("Found {} items in album", album_items.items.len());
2358
2359 let mut tracks: Vec<_> = album_items
2361 .items
2362 .into_iter()
2363 .filter(|item| item.item_type == "Audio")
2364 .collect();
2365 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2366
2367 info!("Found {} audio tracks in album", tracks.len());
2368
2369 if tracks.is_empty() {
2370 return Err("No audio tracks found in album".to_string());
2371 }
2372
2373 info!("Album has {} tracks after sorting:", tracks.len());
2375 for (idx, track) in tracks.iter().enumerate() {
2376 info!(" [{}] {} (ID: {})", idx, track.name, track.id);
2377 }
2378
2379 info!("Looking for track_id: {}", request.track_id);
2382 let album_index = tracks
2383 .iter()
2384 .position(|t| t.id == request.track_id)
2385 .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2386
2387 info!(
2388 "Track {} is at index {} in album",
2389 request.track_id, album_index
2390 );
2391
2392 let mut media_items = Vec::new();
2394 for track in tracks {
2395 let jellyfin_id = &track.id;
2397 let local_path = check_for_local_download(&db, jellyfin_id).await?;
2398
2399 let source = if let Some(path) = local_path {
2400 MediaSource::Local {
2401 file_path: PathBuf::from(path),
2402 jellyfin_item_id: Some(jellyfin_id.clone()),
2403 }
2404 } else {
2405 match repository.get_audio_stream_url(&track.id).await {
2409 Ok(stream_url) => MediaSource::Remote {
2410 stream_url,
2411 jellyfin_item_id: jellyfin_id.clone(),
2412 },
2413 Err(e) => {
2414 warn!(
2415 "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2416 track.name, track.id, e
2417 );
2418 continue;
2419 }
2420 }
2421 };
2422
2423 let primary_image_tag_for_url = track.primary_image_tag.clone();
2424 let media_item = MediaItem {
2425 transport: None,
2427 id: track.id.clone(),
2428 title: track.name.clone(),
2429 name: Some(track.name.clone()), artist: track
2431 .album_artist
2432 .clone()
2433 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2434 album: Some(request.album_name.clone()),
2435 album_name: Some(request.album_name.clone()), album_id: Some(request.album_id.clone()),
2437 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(),
2441 item_type: Some(track.item_type.clone()), playlist_id: None,
2443 duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2444 artwork_url: primary_image_tag_for_url.map(|tag| {
2445 repository.get_image_url(
2446 &request.album_id,
2447 ImageType::Primary,
2448 Some(ImageOptions {
2449 max_width: Some(300),
2450 tag: Some(tag),
2451 ..Default::default()
2452 }),
2453 )
2454 }),
2455 media_type: MediaType::Audio,
2456 source,
2457 video_codec: None,
2458 needs_transcoding: false,
2459 video_width: None,
2460 video_height: None,
2461 subtitles: vec![],
2462 series_id: None,
2463 server_id: None,
2464 };
2465
2466 media_items.push(media_item);
2467 }
2468
2469 if media_items.is_empty() {
2470 return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2471 }
2472
2473 let start_index = media_items
2477 .iter()
2478 .position(|item| item.id == request.track_id)
2479 .unwrap_or(0);
2480
2481 info!(
2482 "Built queue with {} media items, starting at index {}",
2483 media_items.len(),
2484 start_index
2485 );
2486
2487 if request.shuffle {
2489 let controller = player.0.lock().await;
2490 controller.toggle_shuffle();
2491 }
2492
2493 if let Some(first_item) = media_items.get(start_index) {
2495 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2496 session_mgr.start_audio_session(first_item.clone());
2497 }
2498
2499 let controller = player.0.lock().await;
2500
2501 let remote_session = match playback_mode.0.get_mode() {
2505 crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2506 _ => None,
2507 };
2508
2509 if let Some(session_id) = &remote_session {
2510 play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2511 controller
2512 .set_queue(media_items, start_index)
2513 .map_err(|e| e.to_string())?;
2514 } else {
2515 playback_mode
2518 .0
2519 .set_mode(crate::playback_mode::PlaybackMode::Local);
2520 controller
2521 .play_queue(media_items, start_index)
2522 .map_err(|e| e.to_string())?;
2523 }
2524
2525 {
2527 let queue_arc = controller.queue();
2528 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2529 queue.set_context(QueueContext::Album {
2530 album_id: request.album_id.clone(),
2531 album_name: request.album_name.clone(),
2532 });
2533 }
2534
2535 controller.emit_queue_changed();
2537
2538 {
2540 let queue_arc = controller.queue();
2541 let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2542 info!(
2543 "player_play_album_track: Queue now has {} items, current_index: {:?}",
2544 queue_lock.items().len(),
2545 queue_lock.current_index()
2546 );
2547 }
2548
2549 if let Some(emitter) = controller.event_emitter() {
2551 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2552 emitter.emit(PlayerStatusEvent::SessionChanged {
2553 session: session_mgr.current().clone(),
2554 });
2555 }
2556
2557 Ok(get_player_status(&controller))
2558}
2559
2560#[tauri::command]
2562#[specta::specta]
2563pub async fn player_play_tracks(
2564 player: State<'_, PlayerStateWrapper>,
2565 session: State<'_, MediaSessionManagerWrapper>,
2566 db: State<'_, DatabaseWrapper>,
2567 repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2568 playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2569 repository_handle: String,
2570 request: PlayTracksRequest,
2571) -> Result<PlayerStatus, String> {
2572 crate::repository::online::clear_playback_quality_override();
2579
2580 info!(
2581 "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2582 request.track_ids.len(),
2583 request.start_index,
2584 request.shuffle
2585 );
2586
2587 if request.track_ids.is_empty() {
2589 return Err("No tracks provided".to_string());
2590 }
2591
2592 let repository = repository_manager
2594 .0
2595 .get(&repository_handle)
2596 .ok_or("Repository not found - user may need to log in")?;
2597
2598 let mut media_items = Vec::new();
2600 for track_id in &request.track_ids {
2601 let track = repository
2603 .get_item(track_id)
2604 .await
2605 .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2606
2607 let local_path = check_for_local_download(&db, track_id).await?;
2609
2610 let source = if let Some(path) = local_path {
2612 MediaSource::Local {
2613 file_path: PathBuf::from(path),
2614 jellyfin_item_id: Some(track.id.clone()),
2615 }
2616 } else {
2617 let stream_url = repository
2618 .get_audio_stream_url(track_id)
2619 .await
2620 .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2621
2622 MediaSource::Remote {
2623 stream_url,
2624 jellyfin_item_id: track.id.clone(),
2625 }
2626 };
2627
2628 let primary_image_tag_for_url = track.primary_image_tag.clone();
2630 let media_item = MediaItem {
2631 transport: None,
2633 id: track.id.clone(),
2634 title: track.name.clone(),
2635 name: Some(track.name.clone()), artist: track
2637 .album_artist
2638 .clone()
2639 .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2640 album: track.album_name.clone(),
2641 album_name: track.album_name.clone(), album_id: track.album_id.clone(),
2643 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(),
2647 item_type: Some(track.item_type.clone()), playlist_id: None, duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2650 artwork_url: primary_image_tag_for_url.and_then(|tag| {
2651 track.album_id.as_ref().map(|album_id| {
2652 repository.get_image_url(
2653 album_id,
2654 ImageType::Primary,
2655 Some(ImageOptions {
2656 max_width: Some(300),
2657 tag: Some(tag),
2658 ..Default::default()
2659 }),
2660 )
2661 })
2662 }),
2663 media_type: MediaType::Audio,
2664 source,
2665 video_codec: None,
2666 needs_transcoding: false,
2667 video_width: None,
2668 video_height: None,
2669 subtitles: vec![],
2670 series_id: None,
2671 server_id: None,
2672 };
2673
2674 media_items.push(media_item);
2675 }
2676
2677 info!("Built queue with {} media items", media_items.len());
2678
2679 let queue_context = match request.context {
2681 PlayTracksContext::Playlist {
2682 playlist_id,
2683 playlist_name,
2684 } => {
2685 for item in &mut media_items {
2686 item.playlist_id = Some(playlist_id.clone());
2687 }
2688 QueueContext::Playlist {
2689 playlist_id,
2690 playlist_name,
2691 }
2692 }
2693 PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2694 };
2695
2696 if request.shuffle {
2698 let controller = player.0.lock().await;
2699 controller.toggle_shuffle();
2700 }
2701
2702 if let Some(first_item) = media_items.get(request.start_index) {
2704 let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2705 session_mgr.start_audio_session(first_item.clone());
2706 }
2707
2708 let controller = player.0.lock().await;
2709
2710 let remote_session = match playback_mode.0.get_mode() {
2716 crate::playback_mode::PlaybackMode::Remote { session_id }
2717 if !playback_mode.0.is_transferring() =>
2718 {
2719 Some(session_id)
2720 }
2721 _ => None,
2722 };
2723
2724 if let Some(session_id) = &remote_session {
2725 play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2726 .await?;
2727 controller
2728 .set_queue(media_items, request.start_index)
2729 .map_err(|e| e.to_string())?;
2730 } else {
2731 if !playback_mode.0.is_transferring() {
2739 playback_mode
2740 .0
2741 .set_mode(crate::playback_mode::PlaybackMode::Local);
2742 }
2743 controller
2744 .play_queue_from(media_items, request.start_index, request.start_position)
2745 .map_err(|e| e.to_string())?;
2746 }
2747
2748 {
2750 let queue_arc = controller.queue();
2751 let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2752 queue.set_context(queue_context);
2753 }
2754
2755 controller.emit_queue_changed();
2757 if let Some(emitter) = controller.event_emitter() {
2758 let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2759 emitter.emit(PlayerStatusEvent::SessionChanged {
2760 session: session_mgr.current().clone(),
2761 });
2762 }
2763
2764 info!("player_play_tracks completed successfully");
2765 Ok(get_player_status(&controller))
2766}
2767
2768#[derive(specta::Type, Debug, Serialize)]
2770#[serde(rename_all = "camelCase")]
2771pub struct PreloadResult {
2772 pub queued_count: usize,
2774 pub already_downloaded: usize,
2776 pub skipped: usize,
2778}
2779
2780#[tauri::command]
2783#[specta::specta]
2784pub async fn player_preload_upcoming(
2785 player: State<'_, PlayerStateWrapper>,
2786 db: State<'_, DatabaseWrapper>,
2787 smart_cache: State<'_, SmartCacheWrapper>,
2788 download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2789 app: tauri::AppHandle,
2790 user_id: String,
2791 _download_base_path: String,
2792) -> Result<PreloadResult, String> {
2793 let (db_service, target_dir) = {
2797 let database = db.0.lock().map_err(|e| e.to_string())?;
2798 let target_dir = database
2799 .path()
2800 .parent()
2801 .ok_or_else(|| "Database path has no parent directory".to_string())?
2802 .to_string_lossy()
2803 .to_string();
2804 (Arc::new(database.service()), target_dir)
2805 };
2806
2807 let precache_count = {
2809 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2810 if !cache.should_precache_queue() {
2811 return Ok(PreloadResult {
2812 queued_count: 0,
2813 already_downloaded: 0,
2814 skipped: 0,
2815 });
2816 }
2817 cache.queue_precache_count()
2818 };
2819
2820 let upcoming_items: Vec<MediaItem> = {
2822 let controller = player.0.lock().await;
2823 let queue = controller.queue();
2824 let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2825 queue_lock
2826 .get_upcoming(precache_count)
2827 .into_iter()
2828 .cloned()
2829 .collect()
2830 };
2831
2832 if upcoming_items.is_empty() {
2833 return Ok(PreloadResult {
2834 queued_count: 0,
2835 already_downloaded: 0,
2836 skipped: 0,
2837 });
2838 }
2839
2840 let mut queued_count = 0;
2841 let mut already_downloaded = 0;
2842 let mut skipped = 0;
2843
2844 for item in upcoming_items {
2846 let (jellyfin_id, stream_url) = match &item.source {
2850 MediaSource::Remote {
2851 jellyfin_item_id,
2852 stream_url,
2853 } => (jellyfin_item_id.clone(), stream_url.clone()),
2854 MediaSource::Local { .. } => {
2855 already_downloaded += 1;
2856 continue;
2857 }
2858 MediaSource::DirectUrl { .. } => {
2859 skipped += 1;
2860 continue;
2861 }
2862 };
2863
2864 let query = Query::with_params(
2868 "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2869 AND (status IN ('completed', 'downloading')
2870 OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2871 vec![
2872 QueryParam::String(jellyfin_id.clone()),
2873 QueryParam::String(user_id.clone()),
2874 ],
2875 );
2876
2877 let is_downloaded: Option<String> = db_service
2878 .query_optional(query, |row| row.get(0))
2879 .await
2880 .map_err(|e| e.to_string())?;
2881
2882 if is_downloaded.is_some() {
2883 already_downloaded += 1;
2884 continue;
2885 }
2886
2887 let album_dir = item
2890 .album
2891 .as_deref()
2892 .filter(|a| !a.is_empty())
2893 .unwrap_or("Unknown Album");
2894 let file_path = format!(
2895 "downloads/{}/{}.mp3",
2896 sanitize_filename(album_dir),
2897 sanitize_filename(&item.title)
2898 );
2899
2900 let insert_query = Query::with_params(
2904 "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)
2905 VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2906 ON CONFLICT(item_id, user_id) DO UPDATE SET
2907 stream_url = excluded.stream_url,
2908 target_dir = excluded.target_dir
2909 WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2910 vec![
2911 QueryParam::String(jellyfin_id),
2912 QueryParam::String(user_id.clone()),
2913 QueryParam::String(file_path),
2914 QueryParam::String(item.title.clone()),
2915 item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2916 item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2917 QueryParam::String(stream_url),
2918 QueryParam::String(target_dir.clone()),
2919 ],
2920 );
2921
2922 match db_service.execute(insert_query).await {
2923 Ok(rows) if rows > 0 => {
2924 info!(
2925 "[Preload] Queued download for: {} - {}",
2926 item.artist.as_deref().unwrap_or("Unknown"),
2927 item.title
2928 );
2929 queued_count += 1;
2930 }
2931 Ok(_) => {
2932 already_downloaded += 1;
2934 }
2935 Err(e) => {
2936 error!("[Preload] Failed to queue {}: {}", item.title, e);
2937 skipped += 1;
2938 }
2939 }
2940 }
2941
2942 if queued_count > 0 {
2945 let active_downloads = {
2946 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2947 manager.get_active_downloads()
2948 };
2949 crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2950 }
2951
2952 info!(
2953 "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2954 queued_count, already_downloaded, skipped
2955 );
2956
2957 Ok(PreloadResult {
2958 queued_count,
2959 already_downloaded,
2960 skipped,
2961 })
2962}
2963
2964fn sanitize_filename(name: &str) -> String {
2966 name.chars()
2967 .map(|c| match c {
2968 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2969 _ => c,
2970 })
2971 .collect()
2972}
2973
2974#[tauri::command]
2976#[specta::specta]
2977pub async fn player_set_cache_config(
2978 smart_cache: State<'_, SmartCacheWrapper>,
2979 config: CacheConfig,
2980) -> Result<(), String> {
2981 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2982 cache.update_config(config);
2983 Ok(())
2984}
2985
2986#[tauri::command]
2988#[specta::specta]
2989pub async fn player_get_cache_config(
2990 smart_cache: State<'_, SmartCacheWrapper>,
2991) -> Result<CacheConfig, String> {
2992 let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2993 Ok(cache.get_config().unwrap_or_default())
2994}
2995
2996#[tauri::command]
2998#[specta::specta]
2999pub async fn player_configure_jellyfin(
3000 player: State<'_, PlayerStateWrapper>,
3001 db: State<'_, DatabaseWrapper>,
3002 server_url: String,
3003 access_token: String,
3004 user_id: String,
3005 device_id: String,
3006) -> Result<(), String> {
3007 log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
3008
3009 let config = JellyfinConfig {
3010 server_url,
3011 access_token,
3012 device_id,
3013 };
3014
3015 let client = JellyfinClient::new(config.clone())?;
3017
3018 let db_service = {
3025 let database = db.0.lock().map_err(|e| e.to_string())?;
3026 Arc::new(database.service())
3027 };
3028 let reporter_client = JellyfinClient::new(config)?;
3029 let reporter = crate::playback_reporting::PlaybackReporter::new(
3030 db_service,
3031 Arc::new(TokioMutex::new(Some(reporter_client))),
3032 user_id,
3033 );
3034
3035 let controller = player.0.lock().await;
3036 controller.set_jellyfin_client(Some(client));
3037 controller.set_playback_reporter(Some(reporter)).await;
3038
3039 log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
3040 Ok(())
3041}
3042
3043#[tauri::command]
3045#[specta::specta]
3046pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
3047 log::info!("[PlayerCommand] Disabling Jellyfin client");
3048
3049 let controller = player.0.lock().await;
3050 controller.set_jellyfin_client(None);
3051 controller.set_playback_reporter(None).await;
3052
3053 log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
3054 Ok(())
3055}
3056
3057#[cfg(test)]
3058mod tests {
3059 use crate::utils::lock::MutexSafe;
3060
3061 #[test]
3072 fn test_normalize_volume_clamps_and_rejects_nan() {
3073 use super::normalize_volume;
3074
3075 assert_eq!(normalize_volume(0.0), 0.0);
3077 assert_eq!(normalize_volume(0.5), 0.5);
3078 assert_eq!(normalize_volume(1.0), 1.0);
3079
3080 assert_eq!(normalize_volume(-0.5), 0.0);
3082 assert_eq!(normalize_volume(42.0), 1.0);
3083 assert_eq!(normalize_volume(f32::INFINITY), 1.0);
3084 assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
3085
3086 let from_nan = normalize_volume(f32::NAN);
3089 assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3090 assert_eq!(from_nan, 0.0);
3091
3092 for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3094 let percent = (normalize_volume(input) * 100.0) as i32;
3095 assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3096 }
3097 }
3098
3099 #[tokio::test]
3116 async fn test_play_item_request_carries_subtitles_into_media_item() {
3117 use super::{create_media_item, PlayItemRequest};
3118
3119 let payload = serde_json::json!({
3120 "id": "ep-1",
3121 "title": "Pilot",
3122 "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3123 "videoCodec": "h264",
3124 "needsTranscoding": false,
3125 "subtitles": [
3126 {
3127 "index": 2,
3128 "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3129 "language": "eng",
3130 "label": "English (SRT)",
3131 "mime_type": "text/vtt"
3132 },
3133 {
3134 "index": 3,
3135 "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3136 "language": null,
3137 "label": null,
3138 "mime_type": "text/vtt"
3139 }
3140 ]
3141 });
3142
3143 let req: PlayItemRequest =
3144 serde_json::from_value(payload).expect("frontend payload must deserialize");
3145 assert_eq!(
3146 req.subtitles.len(),
3147 2,
3148 "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3149 );
3150
3151 let media = create_media_item(req, None).await.unwrap();
3152 assert_eq!(
3153 media.subtitles.len(),
3154 2,
3155 "create_media_item must thread the tracks onto the MediaItem the backend loads"
3156 );
3157 assert_eq!(media.subtitles[0].index, 2);
3158 assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3159 assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3160 assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3161 assert_eq!(media.subtitles[1].index, 3);
3164 assert!(media.subtitles[1].language.is_none());
3165 }
3166
3167 #[tokio::test]
3173 async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3174 use super::{create_media_item, PlayItemRequest};
3175
3176 let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3177 "id": "movie-1",
3178 "title": "Movie",
3179 "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3180 "videoCodec": "h264",
3181 "needsTranscoding": false
3182 }))
3183 .expect("a subtitle-less payload must still deserialize");
3184
3185 assert!(req.subtitles.is_empty());
3186 assert!(create_media_item(req, None)
3187 .await
3188 .unwrap()
3189 .subtitles
3190 .is_empty());
3191 }
3192
3193 #[test]
3205 fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3206 use crate::player::media::SubtitleTrack;
3207
3208 let subtitles = vec![SubtitleTrack {
3209 index: 2,
3210 url: "https://jelly.example/subs.vtt".to_string(),
3211 language: Some("eng".to_string()),
3212 label: Some("English".to_string()),
3213 mime_type: "text/vtt".to_string(),
3214 }];
3215
3216 let json = serde_json::to_string(&subtitles).unwrap();
3218 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3219 let obj = parsed[0].as_object().unwrap();
3220
3221 for key in ["url", "language", "label", "mime_type"] {
3222 assert!(
3223 obj.contains_key(key),
3224 "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3225 obj.keys().collect::<Vec<_>>()
3226 );
3227 }
3228 assert!(
3229 !obj.contains_key("mimeType"),
3230 "camelCasing mime_type silently drops every track's MIME type on Android"
3231 );
3232 }
3233
3234 #[test]
3239 fn test_background_audio_source_prefers_local_file() {
3240 use super::background_audio_source;
3241 use crate::player::MediaSource;
3242 use std::path::PathBuf;
3243
3244 let local = background_audio_source(
3245 Some("/downloads/ep1.mkv".to_string()),
3246 "https://server/audio-only".to_string(),
3247 "ep-1",
3248 );
3249 match local {
3250 MediaSource::Local {
3251 file_path,
3252 jellyfin_item_id,
3253 } => {
3254 assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3255 assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3257 }
3258 other => panic!("expected a local source, got {:?}", other),
3259 }
3260
3261 let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3262 match remote {
3263 MediaSource::Remote {
3264 stream_url,
3265 jellyfin_item_id,
3266 } => {
3267 assert_eq!(stream_url, "https://server/audio-only");
3268 assert_eq!(jellyfin_item_id, "ep-1");
3269 }
3270 other => panic!("expected a remote source, got {:?}", other),
3271 }
3272 }
3273
3274 #[test]
3287 fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3288 use super::background_audio_plan;
3289
3290 let local = background_audio_plan(true, 1104.0);
3291 assert_eq!(local.base_seconds, 0.0);
3292 assert_eq!(local.seek_to, Some(1104.0));
3293
3294 let streamed = background_audio_plan(false, 1104.0);
3295 assert_eq!(streamed.base_seconds, 1104.0);
3296 assert_eq!(
3297 streamed.seek_to, None,
3298 "the URL already starts at the handoff point; seeking again skips past it"
3299 );
3300 }
3301
3302 #[test]
3307 fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3308 use super::background_audio_plan;
3309
3310 for local in [true, false] {
3311 let plan = background_audio_plan(local, 0.0);
3312 assert_eq!(plan.base_seconds, 0.0);
3313 assert_eq!(plan.seek_to, None);
3314 }
3315 }
3316
3317 #[tokio::test]
3323 async fn test_resolve_local_media_path() {
3324 use super::resolve_local_media_path;
3325 use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3326 use rusqlite::Connection;
3327 use std::sync::{Arc, Mutex};
3328
3329 let conn = Connection::open_in_memory().unwrap();
3330 conn.execute(
3331 "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3332 [],
3333 )
3334 .unwrap();
3335 let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3336
3337 let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3339 std::fs::write(&present, b"x").unwrap();
3340 let present_str = present.to_string_lossy().to_string();
3341
3342 for (item, status, path) in [
3343 ("downloaded", "completed", present_str.as_str()),
3344 ("still-going", "downloading", present_str.as_str()),
3345 (
3346 "file-gone",
3347 "completed",
3348 "/nonexistent/jellytau/missing.mp4",
3349 ),
3350 ] {
3351 db_service
3352 .execute(Query::with_params(
3353 "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3354 vec![
3355 crate::storage::db_service::QueryParam::String(item.to_string()),
3356 crate::storage::db_service::QueryParam::String(status.to_string()),
3357 crate::storage::db_service::QueryParam::String(path.to_string()),
3358 ],
3359 ))
3360 .await
3361 .unwrap();
3362 }
3363
3364 assert_eq!(
3365 resolve_local_media_path(&db_service, "downloaded")
3366 .await
3367 .unwrap()
3368 .as_deref(),
3369 Some(present_str.as_str()),
3370 "a completed download with its file present must resolve"
3371 );
3372
3373 assert_eq!(
3374 resolve_local_media_path(&db_service, "still-going")
3375 .await
3376 .unwrap(),
3377 None,
3378 "an in-progress download is not playable from disk"
3379 );
3380
3381 assert_eq!(
3382 resolve_local_media_path(&db_service, "file-gone")
3383 .await
3384 .unwrap(),
3385 None,
3386 "a row whose file has gone must fall back to streaming, not hand over a dead path"
3387 );
3388
3389 assert_eq!(
3390 resolve_local_media_path(&db_service, "never-heard-of-it")
3391 .await
3392 .unwrap(),
3393 None
3394 );
3395
3396 let _ = std::fs::remove_file(&present);
3397 }
3398
3399 #[tokio::test]
3403 async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3404 use super::{refresh_queue_local_sources, DatabaseWrapper};
3405 use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3406 use crate::storage::Database;
3407 use std::sync::Mutex;
3408
3409 let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3412 std::fs::create_dir_all(&dir).unwrap();
3413 let existing = dir.join("track-a.mp3");
3414 std::fs::write(&existing, b"audio").unwrap();
3415 let missing = dir.join("track-b-missing.mp3");
3416 let _ = std::fs::remove_file(&missing);
3417
3418 let database = Database::open_in_memory().unwrap();
3419 {
3420 let conn = database.connection();
3421 let conn = conn.lock_safe();
3422 conn.execute_batch(&format!(
3423 r#"
3424 INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3425 INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3426 INSERT INTO downloads (item_id, user_id, file_path, status)
3427 VALUES ('track-a', 'user1', '{}', 'completed');
3428 INSERT INTO downloads (item_id, user_id, file_path, status)
3429 VALUES ('track-b', 'user1', '{}', 'completed');
3430 "#,
3431 existing.display(),
3432 missing.display()
3433 ))
3434 .unwrap();
3435 }
3436 let db = DatabaseWrapper(Mutex::new(database));
3437
3438 let make_item = |id: &str| MediaItem {
3439 transport: None,
3441 id: id.to_string(),
3442 title: id.to_string(),
3443 name: None,
3444 artist: None,
3445 album: None,
3446 album_name: None,
3447 album_id: None,
3448 artist_items: None,
3449 artists: None,
3450 primary_image_tag: None,
3451 image_id: None,
3452 item_type: None,
3453 playlist_id: None,
3454 duration: None,
3455 artwork_url: None,
3456 media_type: MediaType::Audio,
3457 source: MediaSource::Remote {
3458 stream_url: format!("http://test/Audio/{}/stream", id),
3459 jellyfin_item_id: id.to_string(),
3460 },
3461 video_codec: None,
3462 needs_transcoding: false,
3463 video_width: None,
3464 video_height: None,
3465 subtitles: vec![],
3466 series_id: None,
3467 server_id: None,
3468 };
3469
3470 let controller = PlayerController::default();
3471 controller
3472 .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3473 .unwrap();
3474
3475 let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3476 assert_eq!(switched, 1, "only the download whose file exists switches");
3477
3478 let queue = controller.queue();
3479 let queue_lock = queue.lock_safe();
3480 match &queue_lock.items()[0].source {
3481 MediaSource::Local {
3482 file_path,
3483 jellyfin_item_id,
3484 } => {
3485 assert_eq!(file_path, &existing);
3486 assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3487 }
3488 other => panic!("track-a should be local, got {:?}", other),
3489 }
3490 assert!(
3491 matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3492 "track-b's file is missing, it must stay remote"
3493 );
3494 }
3495
3496 #[test]
3499 fn test_find_track_index_in_album() {
3500 #[derive(Clone)]
3502 struct MockTrack {
3503 id: String,
3504 name: String,
3505 index_number: Option<i32>,
3506 }
3507
3508 let mut tracks = [
3509 MockTrack {
3510 id: "track1".to_string(),
3511 name: "Song 1".to_string(),
3512 index_number: Some(1),
3513 },
3514 MockTrack {
3515 id: "track2".to_string(),
3516 name: "Song 2".to_string(),
3517 index_number: Some(2),
3518 },
3519 MockTrack {
3520 id: "track3".to_string(),
3521 name: "Song 3".to_string(),
3522 index_number: Some(3),
3523 },
3524 MockTrack {
3525 id: "track4".to_string(),
3526 name: "Song 4".to_string(),
3527 index_number: Some(4),
3528 },
3529 MockTrack {
3530 id: "track5".to_string(),
3531 name: "Song 5".to_string(),
3532 index_number: Some(5),
3533 },
3534 ];
3535
3536 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3538
3539 let index1 = tracks.iter().position(|t| t.id == "track1");
3541 assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3542 assert_eq!(
3543 tracks[0].name, "Song 1",
3544 "Track at index 0 should carry its name"
3545 );
3546
3547 let index3 = tracks.iter().position(|t| t.id == "track3");
3549 assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3550
3551 let index5 = tracks.iter().position(|t| t.id == "track5");
3553 assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3554
3555 let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3557 assert_eq!(index_none, None, "Non-existent track should return None");
3558 }
3559
3560 #[test]
3562 fn test_track_iteration_order() {
3563 let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3565
3566 let target_index = track_ids.iter().position(|&id| id == "id3");
3568 assert_eq!(
3569 target_index,
3570 Some(2),
3571 "id3 should be at index 2 in original list"
3572 );
3573
3574 let mut media_items = Vec::new();
3576 for id in &track_ids {
3577 media_items.push(id.to_string());
3578 }
3579
3580 assert_eq!(media_items.len(), 5);
3582 assert_eq!(media_items[0], "id1");
3583 assert_eq!(media_items[2], "id3");
3584 assert_eq!(media_items[4], "id5");
3585
3586 assert_eq!(media_items[target_index.unwrap()], "id3");
3588 }
3589
3590 #[test]
3592 fn test_album_track_sorting() {
3593 #[derive(Clone, Debug)]
3594 struct MockTrack {
3595 id: String,
3596 name: String,
3597 index_number: Option<i32>,
3598 }
3599
3600 let mut tracks = [
3602 MockTrack {
3603 id: "id5".to_string(),
3604 name: "Track 5".to_string(),
3605 index_number: Some(5),
3606 },
3607 MockTrack {
3608 id: "id1".to_string(),
3609 name: "Track 1".to_string(),
3610 index_number: Some(1),
3611 },
3612 MockTrack {
3613 id: "id3".to_string(),
3614 name: "Track 3".to_string(),
3615 index_number: Some(3),
3616 },
3617 MockTrack {
3618 id: "id2".to_string(),
3619 name: "Track 2".to_string(),
3620 index_number: Some(2),
3621 },
3622 MockTrack {
3623 id: "id4".to_string(),
3624 name: "Track 4".to_string(),
3625 index_number: Some(4),
3626 },
3627 ];
3628
3629 let requested_track_id = "id1";
3631
3632 tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3634
3635 let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3637
3638 assert_eq!(
3640 start_index,
3641 Some(0),
3642 "After sorting, track id1 should be at index 0"
3643 );
3644
3645 assert_eq!(tracks[0].id, "id1");
3647 assert_eq!(
3648 tracks[0].name, "Track 1",
3649 "Sorted track should retain its name"
3650 );
3651 assert_eq!(tracks[1].id, "id2");
3652 assert_eq!(tracks[2].id, "id3");
3653 assert_eq!(tracks[3].id, "id4");
3654 assert_eq!(tracks[4].id, "id5");
3655 }
3656}