Skip to main content

jellytau_lib/commands/player/
mod.rs

1//! TRACES: UR-003, UR-004, UR-005, UR-010, UR-020, UR-021 | JA-022, JA-023, JA-024, JA-025, JA-026 | DR-001
2
3// Cohesive command clusters live in their own submodules and are re-exported so
4// the command names remain at `commands::player::*` (invoke_handler unchanged).
5mod 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
39/// SmartCache wrapper for Tauri state management
40pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
41
42/// Player state wrapper for Tauri.
43///
44/// Uses Arc to allow sharing with the MediaSession handler on Android
45/// for lockscreen control integration.
46///
47/// @req: UR-005 - Control media playback
48/// @req: DR-001 - Player state machine
49pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
50
51/// Media session manager wrapper for Tauri state management
52///
53/// @req: DR-009 - Audio player UI (mini player, full screen)
54pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
55
56/// Video settings state wrapper for Tauri
57///
58/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
59pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
60
61/// Response for player state queries
62#[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    /// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
73    pub backend: VideoBackend,
74    /// Whether frontend should render HTML5 video element
75    pub use_html5_element: bool,
76
77    // Merged fields (prefer remote session when available)
78    /// Media item from either local queue or remote session
79    pub merged_media: Option<MergedMediaItem>,
80    /// Playing state from either local player or remote session
81    pub merged_is_playing: bool,
82    /// Volume from either local player or remote session (0-1 normalized)
83    pub merged_volume: f32,
84}
85
86/// Lightweight media item for merged playback state
87/// Converts from both local MediaItem and remote NowPlayingItem
88#[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    /// Neutral image identifier — replaces `primary_image_tag` (same value).
99    pub image_id: Option<String>,
100    pub media_type: String,
101}
102
103// Convert from local MediaItem
104impl 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
123// Convert from remote NowPlayingItem
124impl 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/// Response for queue queries
148#[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/// Backend type for video playback
160#[derive(specta::Type, Debug, Serialize)]
161#[serde(rename_all = "lowercase")]
162pub enum VideoBackend {
163    /// Native backend (ExoPlayer on Android, libmpv on Linux)
164    Native,
165    /// HTML5 video element fallback
166    Html5,
167}
168
169/// Request to play a single video item
170///
171/// Simplified to video playback only. Audio playback uses player_play_tracks
172/// to avoid Tauri Android serialization issues with complex objects.
173#[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    /// Video codec (e.g., "h264", "hevc") for video media
180    pub video_codec: String,
181    /// Whether the video requires server-side transcoding
182    pub needs_transcoding: bool,
183    /// How this item's stream is fetched, as the backend decided it.
184    ///
185    /// Carried on the queue item so a later seek/reload does not have to guess.
186    /// `None` for items queued by a path that never negotiated (audio tracks,
187    /// direct URLs) and for anything queued before this field existed, where the
188    /// caller falls back to `needs_transcoding` — every transcode this app
189    /// requests is HLS (DR-140), so that fallback is exact rather than a guess.
190    ///
191    /// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
192    #[serde(default)]
193    pub transport: Option<crate::repository::Transport>,
194
195    /// Optional now-playing metadata. Used by the background-audio handoff so the
196    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
197    /// existing video-only callers need not send them.
198    #[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    /// Total media duration (seconds). Threaded through the background-audio
205    /// handoff so the lockscreen MediaSession advertises a real duration — a
206    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
207    #[serde(default)]
208    pub duration_seconds: Option<f64>,
209    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
210    /// background-audio handoff so an episode played as audio-only is still
211    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
212    #[serde(default)]
213    pub item_type: Option<String>,
214    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
215    /// look up the next episode when a background-audio track ends.
216    #[serde(default)]
217    pub series_id: Option<String>,
218    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
219    ///
220    /// Only the native backends use these: on Android they become the
221    /// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
222    /// builds its own `<track>` children instead and ignores this list.
223    ///
224    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
225    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
226    /// *text track groups* — i.e. the position of the sideloaded configuration,
227    /// not the Jellyfin stream index (which is kept on each entry for the UI's
228    /// benefit). So `n` must be a position in this very array, and the array
229    /// must not be reordered or filtered between building it and sending it.
230    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
231    /// list that is sent here, for exactly this reason.
232    ///
233    /// Defaulted so the background-audio handoff and the autoplay/next-episode
234    /// callers, which have no subtitles to offer, need not send the field.
235    ///
236    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
237    #[serde(default)]
238    pub subtitles: Vec<crate::player::SubtitleTrack>,
239}
240
241/// Queue context for remote transfer - what type of queue is this?
242#[derive(specta::Type, Debug, Deserialize)]
243#[serde(tag = "type", rename_all = "lowercase")]
244pub enum PlayQueueContext {
245    /// Playing from a specific album
246    Album {
247        #[serde(rename = "albumId")]
248        album_id: String,
249        #[serde(rename = "albumName")]
250        album_name: String,
251    },
252    /// Playing from a specific playlist
253    Playlist {
254        #[serde(rename = "playlistId")]
255        playlist_id: String,
256        #[serde(rename = "playlistName")]
257        playlist_name: String,
258    },
259    /// Custom queue (search results, manual queue, etc.)
260    Custom,
261}
262
263/// Request to play a queue of items
264#[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    /// Optional context for the queue (album, playlist, or custom)
271    /// Used for remote playback transfer
272    #[serde(default)]
273    pub context: Option<PlayQueueContext>,
274}
275
276/// Request to play a track from an album (backend fetches all tracks)
277#[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/// Request to play tracks by ID (backend fetches metadata)
287#[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    /// Position (seconds) to resume the starting track from. Used when taking
295    /// over playback from a remote session so we don't restart from 0.
296    #[serde(default)]
297    pub start_position: Option<f64>,
298}
299
300/// Context information for track playback
301#[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)] // Used for deserialization, may be used later for analytics
313        search_query: String,
314    },
315    Custom {
316        #[serde(rename = "label")]
317        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
318        label: Option<String>,
319    },
320}
321
322/// Response for video seek operations
323#[derive(specta::Type, Debug, Serialize)]
324#[serde(tag = "strategy", rename_all = "camelCase")]
325pub enum VideoSeekResponse {
326    /// Use native seeking (HLS or direct stream)
327    Native {
328        /// Confirmed position after seek
329        position: f64,
330    },
331    /// Reload stream from new position (transcoded non-HLS)
332    ReloadStream {
333        /// What to open, and how — transport included, so the frontend picks
334        /// its loader from a tagged enum rather than by searching the URL for
335        /// `.m3u8`. TRACES: UR-079 | DR-225
336        selection: StreamSelection,
337        /// `seek_offset` carries the position to RESUME AT, not a base to add to
338        /// the element's clock. The reloaded stream starts at the item's zero —
339        /// a position on an HLS playlist makes the server 400 every segment
340        /// behind it (DR-181) — so the adapter reaches the position by seeking
341        /// the element and leaves the transcode offset at zero.
342        seek_offset: f64,
343    },
344}
345
346/// Response for audio track switching operations
347#[derive(specta::Type, Debug, Serialize)]
348#[serde(tag = "strategy", rename_all = "camelCase")]
349pub enum AudioTrackSwitchResponse {
350    /// Native backend handled it (Android ExoPlayer)
351    Native {
352        /// Confirmation message
353        success: bool,
354    },
355    /// HTML5 needs to reload stream with new audio track
356    ReloadStream {
357        /// What to open, and how. TRACES: UR-079 | DR-225
358        selection: StreamSelection,
359        /// Current position to resume from
360        position: f64,
361    },
362}
363
364/// Response for a mid-playback streaming-quality change.
365///
366/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
367/// has to reload anything, so no strategy branch lives in the UI.
368///
369/// TRACES: UR-074 | DR-162
370#[derive(specta::Type, Debug, Serialize)]
371#[serde(tag = "strategy", rename_all = "camelCase")]
372pub enum StreamQualityResponse {
373    /// The native backend was reloaded here; nothing left for the frontend to
374    /// *do* — but it still has to be told what was negotiated.
375    ///
376    /// This carried only a position at first, which left the picker on Android
377    /// pinned to the rendition of the *first* stream: the UI derives the rung in
378    /// force from the selection it holds, nothing replaced that selection on the
379    /// native path, and a transcode always has a rendition — so the fallback
380    /// that would have used the requested value was never reached. The stream
381    /// changed and the menu did not.
382    ///
383    /// TRACES: UR-074, UR-079 | DR-226, DR-227
384    Native {
385        /// What the backend actually opened, so the UI reflects it rather than
386        /// assuming the request was honoured verbatim.
387        selection: StreamSelection,
388        /// Position playback resumed at.
389        position: f64,
390    },
391    /// HTML5 must reload its element with this selection.
392    ReloadStream {
393        /// What to open, and how — already negotiated against the requested
394        /// ceiling. Carries `available` too, so a picker opened after a quality
395        /// change still describes the source correctly.
396        /// TRACES: UR-070, UR-079 | DR-225, DR-227
397        selection: StreamSelection,
398        /// Position to resume from.
399        position: f64,
400    },
401}
402
403/// Helper function to create MediaItem from video request
404///
405/// PlayItemRequest is now video-only, so we create a video MediaItem.
406/// Audio playback uses player_play_tracks which fetches full metadata from backend.
407pub(super) async fn create_media_item(
408    req: PlayItemRequest,
409    db: Option<&DatabaseWrapper>,
410) -> Result<MediaItem, String> {
411    // For video-only requests, we use the item ID as the jellyfin ID
412    let jellyfin_id = req.id.clone();
413
414    // Check if item is downloaded locally
415    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,            // Not available from video-only request
438        album: None,             // Not available from video-only request
439        album_name: None,        // Not available from video-only request
440        album_id: None,          // Not available from video-only request
441        artist_items: None,      // Not available from video-only request
442        artists: None,           // Not available from video-only request
443        primary_image_tag: None, // Not available from video-only request
444        image_id: None,
445        item_type: None,   // Not available from video-only request
446        playlist_id: None, // Not available from video-only request
447        duration: None,    // Not available from video-only request
448        artwork_url: None, // Not available from video-only request
449        media_type: crate::player::MediaType::Video, // Video-only request
450        source,
451        video_codec: Some(req.video_codec),
452        needs_transcoding: req.needs_transcoding,
453        // The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-230
454        transport: req.transport,
455        video_width: None,  // Not available from video-only request
456        video_height: None, // Not available from video-only request
457        // Sideloaded subtitles, in the order the frontend sent them — that order
458        // is what `player_set_subtitle_track(n)` indexes into on Android.
459        // TRACES: UR-020 | IR-016 | UT-145
460        subtitles: req.subtitles,
461        series_id: None, // Not available from video-only request
462        server_id: None, // Not available from video-only request
463    })
464}
465
466/// Pick the source for an audio-only handoff.
467///
468/// A downloaded file wins over the audio-only stream URL. No transcode or audio
469/// extraction is involved or wanted: the native backends already play a video
470/// container without decoding its video — the Linux MPV backend is configured
471/// with `video: no`, and ExoPlayer simply has no surface to render to when the
472/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
473/// CPU and battery, need an encoder the project does not ship, and leave a
474/// second artifact to keep in step with the first.
475///
476/// TRACES: UR-071 | DR-128 | UT-119
477pub(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
494/// How a background-audio handoff must start playback, given where its audio
495/// actually begins.
496///
497/// TRACES: UR-040, UR-071 | DR-180 | UT-181
498pub(super) struct BackgroundAudioPlan {
499    /// The position the stream's own zero corresponds to, recorded as the
500    /// handoff base so later readings can be shifted back to the episode's
501    /// timeline.
502    pub base_seconds: f64,
503    /// Where to seek after loading, if the source does not already start there.
504    pub seek_to: Option<f64>,
505}
506
507/// Decide the base and the seek for a handoff at `position_seconds`.
508///
509/// The two sources start in different places. An audio-only **stream** is built
510/// with `StartTimeTicks`, so the server makes the handoff point that stream's
511/// zero: the base is the handoff position, and seeking would skip *past* the
512/// content by that much again. A downloaded **file** has no such parameter and
513/// begins at the episode's own zero, so it needs the opposite — no base, and a
514/// real seek. Treating a file like a stream is why backgrounding a downloaded
515/// episode restarted it from 0:00 while the lockscreen showed the right time.
516///
517/// TRACES: UR-040, UR-071 | DR-180 | UT-181
518pub(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
537/// Resolve the on-disk file backing a completed download, if there is one.
538///
539/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
540/// deletion, a cleared cache directory, a restored database). Every caller wants
541/// "can I play this from disk right now", so existence is checked here rather
542/// than trusted from the row.
543///
544/// Split out from [`check_for_local_download`] so the resolution is testable
545/// without a `DatabaseWrapper`, and reusable by the video path.
546///
547/// TRACES: UR-071 | DR-123 | UT-116
548pub(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
575/// Check if an item has a completed download
576pub(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/// The on-disk path for a downloaded item, for playback surfaces that resolve
589/// their own source rather than going through the queue.
590///
591/// The video player is the reason this exists: audio has preferred local files
592/// since queue construction, but video asks the repository for a stream URL and
593/// never consults `downloads`, so a downloaded film was still streamed — costing
594/// bandwidth that had already been spent and failing outright when offline.
595///
596/// Returns `None` when nothing is downloaded *or* the file is missing, so the
597/// caller falls back to streaming.
598///
599/// TRACES: UR-071 | DR-123 | UT-116
600#[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
614/// Re-point queued streaming items at completed local downloads.
615///
616/// Sources are resolved once when the queue is built, so downloads that finish
617/// while it plays (preloaded upcoming tracks) — or that existed before the
618/// connection dropped — would otherwise keep streaming. Called before advancing
619/// so the next track always prefers the on-disk copy.
620///
621/// Returns the number of items switched to a local source.
622pub(super) async fn refresh_queue_local_sources(
623    controller: &PlayerController,
624    db: &DatabaseWrapper,
625) -> Result<usize, String> {
626    // Collect remote item IDs first; the queue lock must not be held across awaits.
627    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/// Play a single media item (audio or video)
682///
683/// Accepts a PlayItemRequest with all optional fields properly defaulted.
684/// This avoids Tauri's Android serialization issues with complex objects.
685///
686/// @req: UR-003 - Play videos
687/// @req: UR-004 - Play audio uninterrupted
688/// @req: UR-005 - Control media playback (play operation)
689/// @req: DR-009 - Audio player UI
690#[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    // A ceiling chosen from the in-player picker belongs to the playback it was
704    // chosen for. Starting a different item returns to the device default —
705    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
706    // session, which is the defect DR-226 exists to close.
707    //
708    // TRACES: UR-074, UR-079 | DR-226
709    crate::repository::online::clear_playback_quality_override();
710
711    // Create media item, checking for local download first
712    let media_item = create_media_item(item, Some(&db)).await?;
713
714    // Start appropriate session based on media type
715    {
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                // For single video items, treat as Movie (no series_id available here)
723                session_mgr.start_movie_session(media_item.clone());
724            }
725        }
726    }
727
728    let controller = player.0.lock().await;
729    // Who gets the stream depends on who is going to *render* it, which is a
730    // runtime question, not a platform constant.
731    //
732    // Historically Linux video was always the webview's (`use_html5_element`),
733    // so handing the file to MPV as well would only have started a redundant
734    // decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
735    // and a queue-only path here. With mpv drawing the picture that inverts:
736    // the webview is no longer loading anything, so if this does not load the
737    // file, *nothing does*. The symptom is total silence — no picture and no
738    // audio — which reads like a broken stream rather than a stream nobody was
739    // given.
740    //
741    // This is the fifth place in this cycle where a renderer's capability was
742    // written as a compile-time platform fact. Same fix as the others: ask.
743    //
744    // TRACES: UR-080 | DR-231, DR-235
745    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        // The webview will play it; keep the queue in sync for the UI and for a
752        // remote transfer without starting a second decode.
753        controller
754            .set_current_item(media_item)
755            .map_err(|e| e.to_string())?;
756    }
757
758    // Emit queue changed event
759    controller.emit_queue_changed();
760
761    // Emit session changed event
762    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/// Enter background-audio mode: hand playback of the currently-watched video off
773/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
774/// is backgrounded/locked, with no client-side video decode (UR-040).
775///
776/// `stream_url` MUST be an audio-only URL (see
777/// `get_audio_only_stream_url_for_video`). The item is created as
778/// `MediaType::Audio` so it starts an audio session and loads into the native
779/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
780/// frontend side, so exactly one audio source is ever active.
781///
782/// This deliberately goes through the queue-based `play_item` path (NOT a
783/// side-channel) so end-of-track lands in `on_playback_ended`, which already
784/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
785/// The sleep-timer state is intentionally left untouched by the handoff.
786///
787/// TRACES: UR-040 | DR-052 | UT-061, IT-013
788#[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    // Prefer the downloaded file over the audio-only stream URL the frontend
803    // resolved. Handing the native backend a local video container yields
804    // audio-only playback for free — no transcode, no second artifact.
805    // TRACES: UR-071 | DR-128
806    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    // A downloaded file starts at the episode's zero; a stream starts at the
814    // handoff point. Only one of them has a base, and only the other needs a seek.
815    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    // Build an AUDIO media item pointing at the audio-only stream. We do not use
819    // create_media_item() because that hardcodes MediaType::Video; background
820    // audio must be Audio so no video decode is started.
821    let media_item = MediaItem {
822        // Audio and direct-URL items never negotiate a transport.
823        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        // Carry episode identity so autoplay can advance to the next episode when
836        // this audio-only handoff ends while backgrounded (UR-040).
837        item_type: item.item_type.clone(),
838        playlist_id: None,
839        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
840        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    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
859    // relative to the stream's StartTimeTicks zero, but the metadata duration is
860    // absolute, so shift the reported position back to absolute for the scrubber.
861    let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
862
863    let controller = player.0.lock().await;
864    // Remember where the video was: for a stream the audio's zero == this
865    // position (the URL was built with StartTimeTicks=position_seconds), so on
866    // exit we add this base to the native player's relative position to get the
867    // absolute one. The controller owns it so a backend-driven advance to the
868    // next episode clears it along with the stream it described.
869    controller.enter_background_audio(plan.base_seconds);
870    controller
871        .play_item(media_item)
872        .map_err(|e| e.to_string())?;
873    // Seek ONLY a local file. The audio-only URL already starts at the handoff
874    // position via StartTimeTicks — its timeline begins at 0 == that point — so
875    // seeking a stream would jump PAST the content by the handoff position again.
876    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/// Exit background-audio mode: stop the native audio player and return its final
896/// position so the frontend can reload the WebView `<video>` there (UR-040).
897///
898/// Returns the position in seconds. The sleep timer is intentionally left
899/// untouched — if it fired while backgrounded, playback is already stopped and
900/// this simply reports the last position.
901///
902/// What playback should do now that the app is no longer visible.
903///
904/// The caller supplies only what it alone knows -- whether the per-player
905/// toggle is armed, and whether Android put the window into picture-in-picture.
906/// Everything else (what is playing, and therefore whether there is a picture to
907/// lose) is read here, because it is domain state.
908///
909/// The rule itself is in `player::background_policy`; this command is the wire.
910/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
911/// playing there is nothing to pause, and an error would make the frontend
912/// handle a case that is not a failure.
913///
914/// TRACES: UR-040, UR-041 | DR-225 | UT-212
915#[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/// TRACES: UR-040 | DR-052 | UT-061, IT-013
943#[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    // Read the position BEFORE clearing either base. The position tick applies the
951    // base natively, so a tick landing between "base cleared" and "position read"
952    // would hand back a relative position — the whole bug, reintroduced at the one
953    // moment it matters most. Capturing into a `let` before stop() is also the
954    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
955    // (DR-159)
956    //
957    // `absolute_position` rather than `position`, because a tick that has not
958    // landed *yet* is the same hazard from the other side: returning to the
959    // foreground while the audio-only transcode is still opening read 0.0, and
960    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
961    // beginning. Flooring at the handoff base cannot overshoot: the stream is
962    // physically incapable of being behind its own starting point. (DR-178)
963    let absolute = controller.absolute_position();
964
965    // Now safe to tear the handoff down, native side first.
966    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/// Play a queue of media items
977///
978/// @req: UR-004 - Play audio uninterrupted
979/// @req: UR-005 - Control media playback (queue playback)
980/// @req: UR-015 - View and manage current audio queue
981/// @req: DR-005 - Queue manager with shuffle, repeat, history
982#[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    // A ceiling chosen from the in-player picker belongs to the playback it was
998    // chosen for. Starting a different item returns to the device default —
999    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
1000    // session, which is the defect DR-226 exists to close.
1001    //
1002    // TRACES: UR-074, UR-079 | DR-226
1003    crate::repository::online::clear_playback_quality_override();
1004
1005    // Handle shuffle first
1006    if request.shuffle {
1007        let controller = player.0.lock().await;
1008        controller.toggle_shuffle();
1009    }
1010
1011    // Convert request context to internal QueueContext
1012    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    // Create media items, checking for local downloads (must not hold locks during await)
1031    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    // Start appropriate session based on first item's media type
1037    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                // Queue of videos treated as movie session (TV episodes use different flow)
1045                session_mgr.start_movie_session(first_item.clone());
1046            }
1047        }
1048    }
1049
1050    // Now play the queue and get status
1051    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    // Set the queue context for remote transfer
1062    {
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    // Emit queue changed event
1069    controller.emit_queue_changed();
1070
1071    // Emit session changed event
1072    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    // Check if we're in remote mode
1089    let mode = playback_mode.0.get_mode();
1090
1091    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1092        // Send play command to remote session - clone client before await
1093        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        // Local playback
1105        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    // Check if we're in remote mode
1120    let mode = playback_mode.0.get_mode();
1121
1122    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1123        // Send pause command to remote session - clone client before await
1124        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        // Local playback
1136        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    // Check if we're in remote mode
1151    let mode = playback_mode.0.get_mode();
1152
1153    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1154        // Send toggle command to remote session - clone client before await
1155        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        // Local playback
1167        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    // Check if we're in remote mode
1183    let mode = playback_mode.0.get_mode();
1184
1185    // Stopping is a state transition worth seeing in a log. Native video is
1186    // what made its absence matter: the webview <video> stopped implicitly when
1187    // the component unmounted, so nothing ever had to call this — and "never
1188    // called" and "called but the backend kept playing" look identical from
1189    // outside without it.
1190    info!("[player_stop] called (mode: {:?})", mode);
1191
1192    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1193        // Send stop command to remote session - clone client before await
1194        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        // Stopping the remote session ends the cast, so the manager returns to
1206        // Idle — same as a local stop. This is also what hands OS volume control
1207        // back to this device: set_mode releases the Android remote volume
1208        // provider on any exit from remote mode. Without it the mode stayed
1209        // Remote and the system volume slider remained stuck on the remote
1210        // session with no way back to the local speaker.
1211        playback_mode
1212            .0
1213            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1214    } else {
1215        // Local playback
1216        let controller = player.0.lock().await;
1217        controller.stop().map_err(|e| e.to_string())?;
1218
1219        // A genuine local stop returns the manager to Idle so it no longer
1220        // reports Local (or a stale Remote) — otherwise a later play/pause would
1221        // route to the wrong device.
1222        playback_mode
1223            .0
1224            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1225
1226        // Handle session state based on type (local playback only)
1227        {
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                    // Movies auto-dismiss when stopped
1234                    session_mgr.movie_session_ended();
1235                }
1236                crate::player::MediaSessionType::Audio { .. } => {
1237                    // Audio persists as inactive (user can resume later)
1238                    session_mgr.audio_session_inactive();
1239                }
1240                crate::player::MediaSessionType::TvShow { .. } => {
1241                    // TV shows mark episode ended (waiting for next or dismiss)
1242                    session_mgr.tv_session_episode_ended();
1243                }
1244                crate::player::MediaSessionType::Idle => {
1245                    // Already idle, no-op
1246                }
1247            }
1248
1249            // Emit session changed event
1250            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    // Check if we're in remote mode
1273    let mode = playback_mode.0.get_mode();
1274
1275    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1276        // Send next track command to remote session - clone client before await
1277        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        // Local playback
1289        let controller = player.0.lock().await;
1290        // Prefer downloads that completed since the queue was built
1291        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        // Update audio session track if in audio session
1298        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                // Emit session changed event
1310                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    // Check if we're in remote mode
1332    let mode = playback_mode.0.get_mode();
1333
1334    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1335        // Send previous track command to remote session - clone client before await
1336        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        // Local playback
1350        let controller = player.0.lock().await;
1351        // Prefer downloads that completed since the queue was built
1352        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        // Update audio session track if in audio session
1359        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                // Emit session changed event
1371                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    // Check if we're in remote mode
1392    let mode = playback_mode.0.get_mode();
1393
1394    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1395        // Send seek command to remote session - clone client before await
1396        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        // Local playback. seek_absolute, not seek: the position came from the UI,
1409        // which shows the whole item, so during a background-audio handoff it has
1410        // to be resolved against the episode's timeline rather than the handoff
1411        // stream's. (DR-159)
1412        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/// Smart video seeking that decides between native and server-side seeking
1421///
1422/// This command analyzes the current video stream and automatically chooses
1423/// the best seeking strategy:
1424/// - HLS streams: Use native seeking
1425/// - Direct play streams: Use native seeking
1426/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
1427///
1428/// For native (non-HTML5) backends, this command handles the entire stream reload
1429/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
1430#[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    // Get repository
1447    let repository = repository_manager
1448        .0
1449        .get(&repository_handle)
1450        .ok_or("Repository not found - user may need to log in")?;
1451
1452    // Get current playing item to analyze stream characteristics
1453    // Clone what we need to avoid holding locks across await points
1454    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        // Neither the URL nor the item's transport is read here any more. The
1471        // strategy turns on whether the *engine* can seek a transcode in place,
1472        // which it declares for itself — so the container the stream happens to
1473        // arrive in stopped being a proxy for anything (DR-246).
1474        let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1475
1476        (current_item.needs_transcoding, jellyfin_id, is_local_file)
1477    }; // Locks are dropped here
1478
1479    // Whether a transcode can be seeked in place is asked of the engine that is
1480    // rendering, not guessed from the URL's shape or from who is rendering.
1481    // TRACES: UR-040, UR-079 | DR-238, DR-246
1482    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            // Local files and native backend streams - call backend.seek()
1502            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            // HTML5 backend with HLS or direct play - frontend handles seeking
1509            // We don't call backend.seek() because video is in HTML5 element, not in MPV
1510            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1511            Ok(VideoSeekResponse::Native { position })
1512        }
1513        VideoSeekStrategy::Html5ReloadStream => {
1514            // Transcoded non-HLS with HTML5 - frontend handles stream reload
1515            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            // Transcoded non-HLS with native backend - backend handles stream reload
1538            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            // Stop current playback
1553            {
1554                let controller = player.0.lock().await;
1555                controller.stop().map_err(|e| e.to_string())?;
1556            }
1557
1558            // Update the stream URL in the queue
1559            {
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            // Reload the player with the updated item from queue
1570            {
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                // The re-opened stream begins at zero — the position cannot ride
1584                // along in the URL without 400ing every segment (DR-181) — so the
1585                // seek that the reload was asked for happens here.
1586                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/// Switch audio track.
1600/// Note: Frontend should handle saving series preferences after this command succeeds
1601///
1602/// What decides the route is **whether the stream in front of the engine
1603/// carries the requested track at all** — see
1604/// [`determine_audio_track_switch_strategy`]:
1605///
1606/// - An HTML5 `<video>` element has no track-selection API, so the stream is
1607///   always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
1608///   the reloaded element back to `position`.
1609/// - A native backend playing a **direct play** holds the source file with
1610///   every track in it, so ExoPlayer selects in place by track-group index.
1611/// - A native backend playing a **transcode** does not. Jellyfin builds a
1612///   transcode around one `AudioStreamIndex`, so the alternate tracks are not
1613///   in the stream; the switch has to re-open it, which this command does
1614///   itself and resumes at `current_position`.
1615///
1616/// That last case is a bug fix, and it was the common case on Android: any
1617/// source whose default audio codec the device cannot decode is transcoded, so
1618/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
1619/// file. The old code called `setAudioTrack(n)` regardless, which indexes
1620/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
1621/// audio track index` and dropped the request — the default track just kept
1622/// playing, with nothing in the UI saying so.
1623///
1624/// libmpv implements neither selection nor reload here — it is the audio-only
1625/// backend and leaves `PlayerBackend::set_audio_track` at its
1626/// `not_implemented()` default, which is why IR-019 is met by these paths
1627/// rather than by MPV.
1628///
1629/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
1630#[tauri::command]
1631#[specta::specta]
1632// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1633// input. Folding the rest into a struct would change the IPC contract and the
1634// generated TypeScript for no readability gain.
1635#[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    // Read what the engine is playing before deciding anything — including
1650    // where it is, which has to be captured before the stop below wipes it.
1651    // Locks are dropped at the end of this block so none is held across an
1652    // await.
1653    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        // A direct play: the engine holds the source file, every track included.
1680        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    // Both reload strategies need a stream built around the chosen track.
1689    let repository = repository_manager
1690        .0
1691        .get(&repository_handle)
1692        .ok_or("Repository not found - user may need to log in")?;
1693
1694    // Select a stream carrying the chosen audio track. It starts at zero —
1695    // an HLS playlist cannot carry a position (DR-181) — so the position is
1696    // restored by seeking afterwards, here or in the frontend.
1697    //
1698    // Pinning a track is itself a reason the source cannot be direct-played:
1699    // the file has one default track and the viewer asked for another, so
1700    // the negotiation returns a transcode. That decision lives in
1701    // `decide_playback_kind`, not here.
1702    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    // The caller's position if it has one, the engine's otherwise. The native
1712    // path has no `<video>` element to read, so it sends none — and defaulting
1713    // that to zero re-opened the stream at the start of the film.
1714    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            // The native backend re-opens its own stream, the same sequence the
1723            // transcoded seek and quality change use: stop, repoint the queue
1724            // entry at the new URL, load, then seek back to where the viewer
1725            // was. Nothing is left for the frontend to do.
1726            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        // Handled above, before the stream was negotiated.
1768        AudioTrackSwitchStrategy::BackendSelectInPlace => {
1769            Ok(AudioTrackSwitchResponse::Native { success: true })
1770        }
1771    }
1772}
1773
1774/// Change the bandwidth ceiling of the video that is playing *right now*.
1775///
1776/// A cap is a property of the stream the server is producing, so unlike a volume
1777/// change it cannot be applied to a stream already in flight — the stream has to
1778/// be re-opened at the new quality and resumed at the current position. That is
1779/// the same reload the transcoded-seek and audio-track paths use, and the same
1780/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
1781/// native backend is reloaded here.
1782///
1783/// The change applies to **this playback only**. The in-player picker is a
1784/// "this film, this connection" control and its doc has always said so, but it
1785/// used to be implemented by writing the process-wide ceiling — so choosing
1786/// 2 Mbps to get one awkward film moving silently capped every video played
1787/// afterwards for the rest of the process, with the Settings screen still
1788/// showing the old value and nothing in the UI admitting the change. It now
1789/// sets a per-playback override that the next item clears; the durable default
1790/// belongs to Settings, and `player_set_video_settings` is the one that writes
1791/// to the database.
1792///
1793/// TRACES: UR-074, UR-079 | DR-162, DR-226
1794#[tauri::command]
1795#[specta::specta]
1796// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1797// input. Folding the rest into a struct would change the IPC contract and the
1798// generated TypeScript for no readability gain.
1799#[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    // Set the ceiling *before* negotiating — the negotiation and every URL
1840    // builder resolve through `effective_streaming_quality`, and they have to
1841    // agree or the cap leaks (a negotiation authorising a direct play the URL
1842    // builder then never gets to constrain).
1843    //
1844    // Deliberately the *override*, not the device default: see the doc above.
1845    // TRACES: UR-074, UR-079 | DR-226
1846    crate::repository::online::set_playback_quality_override(quality);
1847
1848    // Where to resume. `current_position` is the *element's* clock, which only
1849    // the webview path has — on a native backend there is no `<video>` and the
1850    // frontend correctly sends null, so trusting it there resumed every quality
1851    // change from zero.
1852    //
1853    // The player is the authority on position (it is the authority on all
1854    // playback state); asking the DOM for it and falling back to 0 inverted
1855    // that. Fall back to what the controller reports instead.
1856    //
1857    // TRACES: UR-005, UR-074 | DR-226
1858    // The guard is bound inside the arm's block so it is dropped before the
1859    // reload below takes the same lock. This codebase has been bitten by a
1860    // MutexGuard living longer than the expression that produced it.
1861    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    // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
1886    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
1887    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
1888    // position without 400ing every segment — DR-181), so it is seeked back to
1889    // where the picture was.
1890    {
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/// Set the active audio track on a native backend directly.
1919///
1920/// TRACES: UR-021 | IR-019, DR-024
1921#[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/// Set (or clear, with `None`) the active subtitle track on a native backend.
1935///
1936/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
1937/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
1938/// index. The HTML5 path never reaches here; it toggles its own `<track>`
1939/// children. libmpv implements neither, leaving the trait default in place.
1940///
1941/// TRACES: UR-020 | IR-018, DR-023
1942#[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
1955/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
1956/// works in.
1957///
1958/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
1959/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
1960/// comparison downstream, so a backend clamp cannot catch it either. It is
1961/// treated as "no volume asked for" and floored to 0.0.
1962///
1963/// TRACES: DR-212 | UT-206
1964fn 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    // Clamp at the boundary as well as in each backend: the remote branch below
1980    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
1981    // hand the server i32::MAX as a volume percentage.
1982    // TRACES: DR-212 | UT-206
1983    let volume = normalize_volume(volume);
1984
1985    // Check if we're in remote mode
1986    let mode = playback_mode.0.get_mode();
1987
1988    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1989        // Send volume command to remote session - clone client before await
1990        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        // Convert 0-1 range to 0-100 for Jellyfin API
2000        let volume_percent = (volume * 100.0) as i32;
2001        client
2002            .session_set_volume(session_id, volume_percent)
2003            .await?;
2004    } else {
2005        // Local playback
2006        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    // Check if we're in remote mode
2021    let mode = playback_mode.0.get_mode();
2022
2023    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2024        // Send toggle mute command to remote session - clone client before await
2025        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        // Local playback
2039        // TODO: Implement toggle_mute in PlayerController
2040        // let controller = player.0.lock().await;
2041        // controller.toggle_mute().map_err(|e| e.to_string())?;
2042    }
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    // Get base local status
2079    let controller = player.0.lock().await;
2080    let mut status = get_player_status(&controller);
2081
2082    // Get local media from queue
2083    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 in remote mode, fetch session and merge state
2093    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); // Release lock before async call
2104
2105        match client.get_session(&session_id).await {
2106            Ok(Some(session)) => {
2107                log::info!("[PlayerCommands] Merging remote session state");
2108
2109                // Merge media item
2110                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2111
2112                // Merge isPlaying (NOT isPaused!)
2113                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                // Merge position (convert ticks to seconds)
2120                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                // Merge duration (convert ticks to seconds)
2128                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                // Merge volume (convert 0-100 → 0-1)
2135                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    // Local playback or fallback
2154    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/// What playback facilities this platform's backend actually provides.
2171///
2172/// The frontend is presentation-only and must not re-derive backend facts from
2173/// `navigator.userAgent` — that sniffing was a second copy of the same platform
2174/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
2175/// single source of truth; the frontend consumes them.
2176///
2177/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2178#[derive(specta::Type, Debug, Serialize)]
2179#[serde(rename_all = "camelCase")]
2180pub struct PlaybackCapabilities {
2181    /// True when audio is rendered by a webview `<audio>` element rather than a
2182    /// native backend. Native audio exists on Linux (mpv) and Android
2183    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
2184    pub uses_webview_audio: bool,
2185    /// True when video can be rendered by a native surface composited *behind*
2186    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
2187    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
2188    /// compositing), so it stays on the HTML5 element.
2189    pub supports_native_video: bool,
2190}
2191
2192/// Report this platform's playback capabilities to the frontend.
2193///
2194/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2195#[tauri::command]
2196#[specta::specta]
2197pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2198    // Mirrors the cfg gates the backends themselves are built under.
2199    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2200
2201    Ok(PlaybackCapabilities {
2202        uses_webview_audio: !native_audio,
2203        // TRACES: UR-080 | DR-235
2204        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    // Determine backend at compile time based on platform
2211    let (backend, use_html5_element) = if cfg!(target_os = "android") {
2212        // Android uses ExoPlayer native backend
2213        (VideoBackend::Native, false)
2214    } else if crate::player::native_video::enabled() {
2215        // mpv draws the picture on this desktop; the frontend must not also
2216        // load it into a <video> element or the stream decodes twice and the
2217        // two fight over the audio. TRACES: UR-080 | DR-235
2218        (VideoBackend::Native, false)
2219    } else {
2220        // Linux and other platforms use HTML5 video element in frontend
2221        (VideoBackend::Html5, true)
2222    };
2223
2224    PlayerStatus {
2225        state: controller.state(),
2226        // The position on the item's timeline, whichever of the three paths is
2227        // rendering it — the native backend answers for only one of them, and
2228        // reads 0 for webview video and for a handoff that has not ticked yet.
2229        // TRACES: UR-005 | DR-178
2230        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 fields initialized to defaults (will be set by player_get_status)
2240        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
2260/// Start a freshly-built queue on the active remote session.
2261///
2262/// Used by the "play tracks"/"play album track" commands when we're in remote
2263/// mode: instead of starting local MPV playback, we cast the selected tracks to
2264/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
2265/// play_on_session call, but for a brand-new selection (so there's no resume
2266/// position - playback starts from the chosen track's beginning).
2267///
2268/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
2269/// the start index is adjusted to the remaining Jellyfin items. Returns an error
2270/// if the selected track itself has no Jellyfin ID.
2271async fn play_selection_on_remote(
2272    controller: &PlayerController,
2273    session_id: &str,
2274    media_items: &[MediaItem],
2275    start_index: usize,
2276) -> Result<(), String> {
2277    // Collect Jellyfin IDs, tracking where the selected track lands after any
2278    // local-only items are dropped.
2279    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    // Fresh selection: start from the beginning of the chosen track.
2307    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/// Play a track from an album - backend fetches all album tracks and builds queue
2314#[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    // Get repository (hybrid - supports offline/online)
2331    let repository = repository_manager
2332        .0
2333        .get(&repository_handle)
2334        .ok_or("Repository not found - user may need to log in")?;
2335
2336    // Fetch all tracks from the album via hybrid repository
2337    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    // Filter to only Audio items and sort by index
2360    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    // Debug: Log all track IDs and their indices
2374    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    // Validate the requested track exists in the album (its position in the
2380    // final queue is computed after building, since offline tracks are skipped).
2381    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    // Convert tracks to MediaItems
2393    let mut media_items = Vec::new();
2394    for track in tracks {
2395        // Check for local download first
2396        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            // Non-downloaded track: needs a stream URL from the server. When the
2406            // server is unreachable (offline), skip this track rather than failing
2407            // the whole album — downloaded tracks must still be playable.
2408            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            // Audio and direct-URL items never negotiate a transport.
2426            transport: None,
2427            id: track.id.clone(),
2428            title: track.name.clone(),
2429            name: Some(track.name.clone()), // Frontend compatibility
2430            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()), // Frontend compatibility
2436            album_id: Some(request.album_id.clone()),
2437            artist_items: track.artist_items.clone(), // For clickable artist links
2438            artists: track.artists.clone(),           // Fallback artist info
2439            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2440            image_id: track.primary_image_tag.clone(),
2441            item_type: Some(track.item_type.clone()), // Frontend compatibility
2442            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    // Tracks with no local download and no reachable server were skipped above,
2474    // so positions shifted. Re-locate the requested track in the built queue.
2475    // If the tapped track itself was skipped, fall back to the first item.
2476    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    // Handle shuffle before setting queue
2488    if request.shuffle {
2489        let controller = player.0.lock().await;
2490        controller.toggle_shuffle();
2491    }
2492
2493    // Start audio session with the first item
2494    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    // When controlling a remote session, cast the selection there instead of
2502    // starting local MPV playback. We still load the queue locally (below) so
2503    // the queue/context stay in sync for the UI and for transferring back.
2504    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        // Local playback is now authoritative (see player_play_tracks); set it
2516        // before starting so the mode-changed event precedes the state events.
2517        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    // Set the queue context for remote transfer
2526    {
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    // Emit queue changed event
2536    controller.emit_queue_changed();
2537
2538    // Log final queue state
2539    {
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    // Emit session changed event
2550    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/// Play tracks by ID - backend fetches all metadata
2561#[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    // A ceiling chosen from the in-player picker belongs to the playback it was
2573    // chosen for. Starting a different item returns to the device default —
2574    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
2575    // session, which is the defect DR-226 exists to close.
2576    //
2577    // TRACES: UR-074, UR-079 | DR-226
2578    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    // Validate input
2588    if request.track_ids.is_empty() {
2589        return Err("No tracks provided".to_string());
2590    }
2591
2592    // Get repository
2593    let repository = repository_manager
2594        .0
2595        .get(&repository_handle)
2596        .ok_or("Repository not found - user may need to log in")?;
2597
2598    // Fetch metadata for all tracks
2599    let mut media_items = Vec::new();
2600    for track_id in &request.track_ids {
2601        // Fetch track metadata from repository
2602        let track = repository
2603            .get_item(track_id)
2604            .await
2605            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2606
2607        // Check for local download
2608        let local_path = check_for_local_download(&db, track_id).await?;
2609
2610        // Build MediaSource
2611        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        // Transform to MediaItem with frontend-compatible fields
2629        let primary_image_tag_for_url = track.primary_image_tag.clone();
2630        let media_item = MediaItem {
2631            // Audio and direct-URL items never negotiate a transport.
2632            transport: None,
2633            id: track.id.clone(),
2634            title: track.name.clone(),
2635            name: Some(track.name.clone()), // Frontend compatibility
2636            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(), // Frontend compatibility
2642            album_id: track.album_id.clone(),
2643            artist_items: track.artist_items.clone(), // For clickable artist links
2644            artists: track.artists.clone(),           // Fallback artist info
2645            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2646            image_id: track.primary_image_tag.clone(),
2647            item_type: Some(track.item_type.clone()), // Frontend compatibility
2648            playlist_id: None,                        // Set based on context below
2649            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    // Map context and set playlist_id
2680    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    // Handle shuffle
2697    if request.shuffle {
2698        let controller = player.0.lock().await;
2699        controller.toggle_shuffle();
2700    }
2701
2702    // Start session
2703    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    // When controlling a remote session, cast the selection there instead of
2711    // starting local MPV playback. Skip this while a transfer is in flight: the
2712    // transfer-to-local path calls this command to load the queue locally and
2713    // the mode is still Remote until the transfer completes - routing it back to
2714    // the remote would undo the transfer.
2715    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        // Starting local playback makes Local the authoritative mode. Without
2732        // this, a prior Remote mode lingers in the manager and later play/pause
2733        // commands route back to the (stopped) remote session. Set it BEFORE
2734        // starting playback so the PlaybackModeChanged event reaches the frontend
2735        // ahead of the state_changed events it will emit — otherwise the frontend
2736        // (still thinking it's remote) filters those state events out. Skip during
2737        // a transfer: transfer_to_local drives the mode itself once complete.
2738        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    // Set queue context
2749    {
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    // Emit events
2756    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/// Response for preload operation
2769#[derive(specta::Type, Debug, Serialize)]
2770#[serde(rename_all = "camelCase")]
2771pub struct PreloadResult {
2772    /// Number of tracks queued for preload
2773    pub queued_count: usize,
2774    /// Number of tracks already downloaded
2775    pub already_downloaded: usize,
2776    /// Number of tracks skipped (no jellyfin ID or other reasons)
2777    pub skipped: usize,
2778}
2779
2780/// Preload upcoming tracks from the queue
2781/// This queues background downloads for the next N tracks that aren't already downloaded
2782#[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    // The pump only starts rows that carry both a stream URL and a target dir,
2794    // so resolve the same storage root the user-initiated download paths use
2795    // (storage_get_path = the database's parent directory).
2796    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    // Get cache settings
2808    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    // Get upcoming items from queue
2821    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    // Process each upcoming item
2845    for item in upcoming_items {
2846        // Only process items with Remote source (not already local). The
2847        // source already carries the resolved stream URL — reuse it so the
2848        // pump can start the download without any extra resolution step.
2849        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        // Check if already downloaded or actively in flight. Stale pending rows
2865        // without a stream URL are NOT skipped here — the upsert below heals
2866        // them so the pump can finally start them.
2867        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        // Queue for download with low priority (preload priority = -100) so
2888        // user-initiated downloads always win a pump slot first.
2889        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        // Insert with the stream URL + target dir the pump needs to start it.
2901        // On conflict, heal pre-existing rows that were queued without a URL
2902        // (they could never start) instead of leaving them stuck.
2903        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                // Row already exists (conflict), count as already queued
2933                already_downloaded += 1;
2934            }
2935            Err(e) => {
2936                error!("[Preload] Failed to queue {}: {}", item.title, e);
2937                skipped += 1;
2938            }
2939        }
2940    }
2941
2942    // Kick the pump so the queued preloads actually start; without this they'd
2943    // only begin once some other download activity pumps the queue.
2944    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
2964/// Sanitize filename by removing invalid characters
2965fn sanitize_filename(name: &str) -> String {
2966    name.chars()
2967        .map(|c| match c {
2968            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2969            _ => c,
2970        })
2971        .collect()
2972}
2973
2974/// Update SmartCache configuration
2975#[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/// Get current SmartCache configuration
2987#[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/// Configure Jellyfin API client for automatic playback reporting
2997#[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    // Legacy client (used for remote session control / casting).
3016    let client = JellyfinClient::new(config.clone())?;
3017
3018    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
3019    // actually report through. Without this, Start/Progress/Stopped never reach
3020    // Jellyfin, so playback position never syncs and you can't resume on another
3021    // device. The reporter shares the player controller's Arc, so populating it
3022    // here lights up reporting on both desktop and Android, on every auth path
3023    // that configures the player (login / restore / reauth).
3024    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/// Disable Jellyfin automatic playback reporting
3044#[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    /// UT-206 — the volume the command hands on is always a real number in
3062    /// 0.0..=1.0.
3063    ///
3064    /// Every backend clamps for itself, but the remote branch of
3065    /// `player_set_volume` reaches no backend at all: it does
3066    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
3067    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
3068    /// not enough — it has to be tested for.
3069    ///
3070    /// TRACES: DR-212 | UT-206
3071    #[test]
3072    fn test_normalize_volume_clamps_and_rejects_nan() {
3073        use super::normalize_volume;
3074
3075        // In-range values pass through untouched.
3076        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        // Out of range clamps to the same 0.0..=1.0 the backends use.
3081        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        // NaN is not a volume; it must not reach the Jellyfin percentage
3087        // conversion or a backend.
3088        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        // Whatever comes out survives the remote branch's percentage cast.
3093        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    /// The subtitle list the frontend resolved must survive the IPC hop and end
3100    /// up on the `MediaItem` the native backend loads.
3101    ///
3102    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
3103    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
3104    /// in — so `create_media_item` always produced `subtitles: vec![]`,
3105    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
3106    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
3107    /// `setSubtitleTrack(n)` then found no text track groups and logged
3108    /// "Invalid subtitle track index".
3109    ///
3110    /// The payload below is exactly what the frontend sends: camelCase for the
3111    /// top-level command params (Tauri v2 converts them), and the subtitle
3112    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
3113    ///
3114    /// TRACES: UR-020 | IR-016 | UT-145
3115    #[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        // Order is the contract: `player_set_subtitle_track(n)` is a position in
3162        // this list (see the note on `PlayItemRequest::subtitles`).
3163        assert_eq!(media.subtitles[1].index, 3);
3164        assert!(media.subtitles[1].language.is_none());
3165    }
3166
3167    /// A request without subtitles must still deserialize — the field is
3168    /// defaulted so the background-audio handoff and the autoplay/next-episode
3169    /// callers keep compiling and sending what they always sent.
3170    ///
3171    /// TRACES: UR-020 | IR-016 | UT-145
3172    #[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    /// The JSON handed to Kotlin over JNI must use the keys
3194    /// `JellyTauPlayer.load()` actually reads.
3195    ///
3196    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
3197    /// house IPC rule) is to camelCase nested structs too — but
3198    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
3199    /// the field would not fail to compile or fail the IPC; it would silently
3200    /// fall back to the default MIME type for every track, so this is asserted
3201    /// on the exact bytes `android/mod.rs` sends.
3202    ///
3203    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
3204    #[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        // Exactly what player/android/mod.rs passes to loadWithMetadata.
3217        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    /// The audio-only handoff must play a downloaded file when there is one,
3235    /// rather than fetching an audio-only stream for media already on disk.
3236    ///
3237    /// TRACES: UR-071 | DR-128 | UT-119
3238    #[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                // The Jellyfin id must survive so progress still syncs back.
3256                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    /// The two sources start in different places, so the handoff cannot treat
3275    /// them alike.
3276    ///
3277    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
3278    /// the handoff point that stream's zero: the base is the handoff position and
3279    /// seeking would jump past the content. A *downloaded file* has no such
3280    /// parameter — it starts at the episode's own zero — so basing it at the
3281    /// handoff position claims 18 minutes of audio that is about to play from the
3282    /// beginning. That is the downloaded-episode version of "it restarts when the
3283    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
3284    ///
3285    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3286    #[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    /// Handing off at the very start has nothing to seek to and nothing to base:
3303    /// both sources are already where they need to be.
3304    ///
3305    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3306    #[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    /// A downloaded item must resolve to its file, and a `downloads` row whose
3318    /// file has gone must resolve to `None` so the caller falls back to
3319    /// streaming instead of handing the player a path that cannot be opened.
3320    ///
3321    /// TRACES: UR-071 | DR-123 | UT-116
3322    #[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        // A real file on disk, so the existence check passes.
3338        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    /// Queue items enqueued as Remote must flip to Local once a completed
3400    /// download exists on disk — this is what makes preloaded tracks (and
3401    /// offline playback after a connection drop) actually use the cache.
3402    #[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        // A real file on disk for the completed download; a missing file for
3410        // the second entry to prove nonexistent files are not switched.
3411        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            // Audio and direct-URL items never negotiate a transport.
3440            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 track index finding in album
3497    /// This reproduces the bug where clicking songs 1-5 always played song 13
3498    #[test]
3499    fn test_find_track_index_in_album() {
3500        // Create mock album tracks
3501        #[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        // Sort by index (same as the real code does)
3537        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3538
3539        // Test finding track 1 (should be index 0)
3540        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        // Test finding track 3 (should be index 2)
3548        let index3 = tracks.iter().position(|t| t.id == "track3");
3549        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3550
3551        // Test finding track 5 (should be index 4)
3552        let index5 = tracks.iter().position(|t| t.id == "track5");
3553        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3554
3555        // Test finding non-existent track
3556        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 that track order is preserved when iterating
3561    #[test]
3562    fn test_track_iteration_order() {
3563        // Simulate the loop that builds MediaItems
3564        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3565
3566        // Find where "id3" is in the original list
3567        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        // Simulate building the MediaItems vector
3575        let mut media_items = Vec::new();
3576        for id in &track_ids {
3577            media_items.push(id.to_string());
3578        }
3579
3580        // Verify the order is preserved
3581        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        // The start_index found earlier should still be valid
3587        assert_eq!(media_items[target_index.unwrap()], "id3");
3588    }
3589
3590    /// Test album track sorting behavior
3591    #[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        // Create tracks in random order (not sorted)
3601        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        // User clicks track "id1" before sorting - what index is it?
3630        let requested_track_id = "id1";
3631
3632        // Sort the tracks (same as real code)
3633        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3634
3635        // Now find the index AFTER sorting
3636        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3637
3638        // Track "id1" should be at index 0 after sorting
3639        assert_eq!(
3640            start_index,
3641            Some(0),
3642            "After sorting, track id1 should be at index 0"
3643        );
3644
3645        // Verify all tracks are in correct order
3646        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}