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/// Returns the item the native player is on and its absolute position. The
943/// item matters: an episode that ended while backgrounded has already advanced
944/// in the backend, so reloading the video the webview was mounted with would
945/// bring back the previous episode. (DR-296)
946///
947/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
948#[tauri::command]
949#[specta::specta]
950pub async fn player_exit_background_audio(
951    player: State<'_, PlayerStateWrapper>,
952) -> Result<crate::player::BackgroundAudioResume, String> {
953    let controller = player.0.lock().await;
954
955    // Read the position BEFORE clearing either base. The position tick applies the
956    // base natively, so a tick landing between "base cleared" and "position read"
957    // would hand back a relative position — the whole bug, reintroduced at the one
958    // moment it matters most. Capturing into a `let` before stop() is also the
959    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
960    // (DR-159)
961    //
962    // `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
963    // landed *yet* is the same hazard from the other side: returning to the
964    // foreground while the audio-only transcode is still opening read 0.0, and
965    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
966    // beginning. Flooring at the handoff base cannot overshoot: the stream is
967    // physically incapable of being behind its own starting point. (DR-178)
968    let resume = controller.background_audio_resume();
969
970    // Now safe to tear the handoff down, native side first.
971    let _ = crate::player::set_lockscreen_position_offset(0.0);
972    controller.exit_background_audio();
973    controller.stop().map_err(|e| e.to_string())?;
974    info!(
975        "player_exit_background_audio: resuming {:?} at {:.1}s",
976        resume.item_id, resume.position_seconds
977    );
978    Ok(resume)
979}
980
981/// Play a queue of media items
982///
983/// @req: UR-004 - Play audio uninterrupted
984/// @req: UR-005 - Control media playback (queue playback)
985/// @req: UR-015 - View and manage current audio queue
986/// @req: DR-005 - Queue manager with shuffle, repeat, history
987#[tauri::command]
988#[specta::specta]
989pub async fn player_play_queue(
990    player: State<'_, PlayerStateWrapper>,
991    session: State<'_, MediaSessionManagerWrapper>,
992    db: State<'_, DatabaseWrapper>,
993    request: PlayQueueRequest,
994) -> Result<PlayerStatus, String> {
995    info!(
996        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
997        request.items.len(),
998        request.start_index,
999        request.shuffle
1000    );
1001
1002    // A ceiling chosen from the in-player picker belongs to the playback it was
1003    // chosen for. Starting a different item returns to the device default —
1004    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
1005    // session, which is the defect DR-226 exists to close.
1006    //
1007    // TRACES: UR-074, UR-079 | DR-226
1008    crate::repository::online::clear_playback_quality_override();
1009
1010    // Handle shuffle first
1011    if request.shuffle {
1012        let controller = player.0.lock().await;
1013        controller.toggle_shuffle();
1014    }
1015
1016    // Convert request context to internal QueueContext
1017    let queue_context = match request.context {
1018        Some(PlayQueueContext::Album {
1019            album_id,
1020            album_name,
1021        }) => QueueContext::Album {
1022            album_id,
1023            album_name,
1024        },
1025        Some(PlayQueueContext::Playlist {
1026            playlist_id,
1027            playlist_name,
1028        }) => QueueContext::Playlist {
1029            playlist_id,
1030            playlist_name,
1031        },
1032        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
1033    };
1034
1035    // Create media items, checking for local downloads (must not hold locks during await)
1036    let mut items: Vec<MediaItem> = Vec::new();
1037    for req in request.items {
1038        items.push(create_media_item(req, Some(&db)).await?);
1039    }
1040
1041    // Start appropriate session based on first item's media type
1042    if let Some(first_item) = items.get(request.start_index) {
1043        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1044        match first_item.media_type {
1045            MediaType::Audio => {
1046                session_mgr.start_audio_session(first_item.clone());
1047            }
1048            MediaType::Video => {
1049                // Queue of videos treated as movie session (TV episodes use different flow)
1050                session_mgr.start_movie_session(first_item.clone());
1051            }
1052        }
1053    }
1054
1055    // Now play the queue and get status
1056    let controller = player.0.lock().await;
1057    info!(
1058        "player_play_queue: Calling controller.play_queue with {} items at index {}",
1059        items.len(),
1060        request.start_index
1061    );
1062    controller
1063        .play_queue(items, request.start_index)
1064        .map_err(|e| e.to_string())?;
1065
1066    // Set the queue context for remote transfer
1067    {
1068        let queue_arc = controller.queue();
1069        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1070        queue.set_context(queue_context);
1071    }
1072
1073    // Emit queue changed event
1074    controller.emit_queue_changed();
1075
1076    // Emit session changed event
1077    if let Some(emitter) = controller.event_emitter() {
1078        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1079        emitter.emit(PlayerStatusEvent::SessionChanged {
1080            session: session_mgr.current().clone(),
1081        });
1082    }
1083
1084    Ok(get_player_status(&controller))
1085}
1086
1087#[tauri::command]
1088#[specta::specta]
1089pub async fn player_play(
1090    player: State<'_, PlayerStateWrapper>,
1091    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1092) -> Result<PlayerStatus, String> {
1093    // Check if we're in remote mode
1094    let mode = playback_mode.0.get_mode();
1095
1096    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1097        // Send play command to remote session - clone client before await
1098        let client = {
1099            let controller = player.0.lock().await;
1100            let client_arc = controller.jellyfin_client();
1101            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1102            client_opt
1103                .as_ref()
1104                .ok_or("Jellyfin client not configured")?
1105                .clone()
1106        };
1107        client.send_session_command(session_id, "Unpause").await?;
1108    } else {
1109        // Local playback
1110        let controller = player.0.lock().await;
1111        controller.play().map_err(|e| e.to_string())?;
1112    }
1113
1114    let controller = player.0.lock().await;
1115    Ok(get_player_status(&controller))
1116}
1117
1118#[tauri::command]
1119#[specta::specta]
1120pub async fn player_pause(
1121    player: State<'_, PlayerStateWrapper>,
1122    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1123) -> Result<PlayerStatus, String> {
1124    // Check if we're in remote mode
1125    let mode = playback_mode.0.get_mode();
1126
1127    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1128        // Send pause command to remote session - clone client before await
1129        let client = {
1130            let controller = player.0.lock().await;
1131            let client_arc = controller.jellyfin_client();
1132            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1133            client_opt
1134                .as_ref()
1135                .ok_or("Jellyfin client not configured")?
1136                .clone()
1137        };
1138        client.send_session_command(session_id, "Pause").await?;
1139    } else {
1140        // Local playback
1141        let controller = player.0.lock().await;
1142        controller.pause().map_err(|e| e.to_string())?;
1143    }
1144
1145    let controller = player.0.lock().await;
1146    Ok(get_player_status(&controller))
1147}
1148
1149#[tauri::command]
1150#[specta::specta]
1151pub async fn player_toggle(
1152    player: State<'_, PlayerStateWrapper>,
1153    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1154) -> Result<PlayerStatus, String> {
1155    // Check if we're in remote mode
1156    let mode = playback_mode.0.get_mode();
1157
1158    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1159        // Send toggle command to remote session - clone client before await
1160        let client = {
1161            let controller = player.0.lock().await;
1162            let client_arc = controller.jellyfin_client();
1163            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1164            client_opt
1165                .as_ref()
1166                .ok_or("Jellyfin client not configured")?
1167                .clone()
1168        };
1169        client.send_session_command(session_id, "PlayPause").await?;
1170    } else {
1171        // Local playback
1172        let controller = player.0.lock().await;
1173        controller.toggle_playback().map_err(|e| e.to_string())?;
1174    }
1175
1176    let controller = player.0.lock().await;
1177    Ok(get_player_status(&controller))
1178}
1179
1180#[tauri::command]
1181#[specta::specta]
1182pub async fn player_stop(
1183    player: State<'_, PlayerStateWrapper>,
1184    session: State<'_, MediaSessionManagerWrapper>,
1185    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1186) -> Result<PlayerStatus, String> {
1187    // Check if we're in remote mode
1188    let mode = playback_mode.0.get_mode();
1189
1190    // Stopping is a state transition worth seeing in a log. Native video is
1191    // what made its absence matter: the webview <video> stopped implicitly when
1192    // the component unmounted, so nothing ever had to call this — and "never
1193    // called" and "called but the backend kept playing" look identical from
1194    // outside without it.
1195    info!("[player_stop] called (mode: {:?})", mode);
1196
1197    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1198        // Send stop command to remote session - clone client before await
1199        let client = {
1200            let controller = player.0.lock().await;
1201            let client_arc = controller.jellyfin_client();
1202            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1203            client_opt
1204                .as_ref()
1205                .ok_or("Jellyfin client not configured")?
1206                .clone()
1207        };
1208        client.send_session_command(session_id, "Stop").await?;
1209
1210        // Stopping the remote session ends the cast, so the manager returns to
1211        // Idle — same as a local stop. This is also what hands OS volume control
1212        // back to this device: set_mode releases the Android remote volume
1213        // provider on any exit from remote mode. Without it the mode stayed
1214        // Remote and the system volume slider remained stuck on the remote
1215        // session with no way back to the local speaker.
1216        playback_mode
1217            .0
1218            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1219    } else {
1220        // Local playback
1221        let controller = player.0.lock().await;
1222        controller.stop().map_err(|e| e.to_string())?;
1223
1224        // A genuine local stop returns the manager to Idle so it no longer
1225        // reports Local (or a stale Remote) — otherwise a later play/pause would
1226        // route to the wrong device.
1227        playback_mode
1228            .0
1229            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1230
1231        // Handle session state based on type (local playback only)
1232        {
1233            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1234            let current_session = session_mgr.current().clone();
1235
1236            match current_session {
1237                crate::player::MediaSessionType::Movie { .. } => {
1238                    // Movies auto-dismiss when stopped
1239                    session_mgr.movie_session_ended();
1240                }
1241                crate::player::MediaSessionType::Audio { .. } => {
1242                    // Audio persists as inactive (user can resume later)
1243                    session_mgr.audio_session_inactive();
1244                }
1245                crate::player::MediaSessionType::TvShow { .. } => {
1246                    // TV shows mark episode ended (waiting for next or dismiss)
1247                    session_mgr.tv_session_episode_ended();
1248                }
1249                crate::player::MediaSessionType::Idle => {
1250                    // Already idle, no-op
1251                }
1252            }
1253
1254            // Emit session changed event
1255            if let Some(emitter) = controller.event_emitter() {
1256                emitter.emit(PlayerStatusEvent::SessionChanged {
1257                    session: session_mgr.current().clone(),
1258                });
1259            }
1260        }
1261    }
1262
1263    let controller = player.0.lock().await;
1264    Ok(get_player_status(&controller))
1265}
1266
1267#[tauri::command]
1268#[specta::specta]
1269pub async fn player_next(
1270    player: State<'_, PlayerStateWrapper>,
1271    session: State<'_, MediaSessionManagerWrapper>,
1272    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1273    db: State<'_, DatabaseWrapper>,
1274) -> Result<PlayerStatus, String> {
1275    debug!("[player_next] Command called from frontend");
1276
1277    // Check if we're in remote mode
1278    let mode = playback_mode.0.get_mode();
1279
1280    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1281        // Send next track command to remote session - clone client before await
1282        let client = {
1283            let controller = player.0.lock().await;
1284            let client_arc = controller.jellyfin_client();
1285            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1286            client_opt
1287                .as_ref()
1288                .ok_or("Jellyfin client not configured")?
1289                .clone()
1290        };
1291        client.send_session_command(session_id, "NextTrack").await?;
1292    } else {
1293        // Local playback
1294        let controller = player.0.lock().await;
1295        // Prefer downloads that completed since the queue was built
1296        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1297            warn!("[player_next] Failed to refresh local sources: {}", e);
1298        }
1299        controller.next().map_err(|e| e.to_string())?;
1300        controller.emit_queue_changed();
1301
1302        // Update audio session track if in audio session
1303        let current_item = {
1304            let queue = controller.queue();
1305            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1306            queue_lock.current().cloned()
1307        };
1308
1309        if let Some(item) = current_item {
1310            if item.media_type == MediaType::Audio {
1311                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1312                session_mgr.update_audio_track(item);
1313
1314                // Emit session changed event
1315                if let Some(emitter) = controller.event_emitter() {
1316                    emitter.emit(PlayerStatusEvent::SessionChanged {
1317                        session: session_mgr.current().clone(),
1318                    });
1319                }
1320            }
1321        }
1322    }
1323
1324    let controller = player.0.lock().await;
1325    Ok(get_player_status(&controller))
1326}
1327
1328#[tauri::command]
1329#[specta::specta]
1330pub async fn player_previous(
1331    player: State<'_, PlayerStateWrapper>,
1332    session: State<'_, MediaSessionManagerWrapper>,
1333    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1334    db: State<'_, DatabaseWrapper>,
1335) -> Result<PlayerStatus, String> {
1336    // Check if we're in remote mode
1337    let mode = playback_mode.0.get_mode();
1338
1339    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1340        // Send previous track command to remote session - clone client before await
1341        let client = {
1342            let controller = player.0.lock().await;
1343            let client_arc = controller.jellyfin_client();
1344            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1345            client_opt
1346                .as_ref()
1347                .ok_or("Jellyfin client not configured")?
1348                .clone()
1349        };
1350        client
1351            .send_session_command(session_id, "PreviousTrack")
1352            .await?;
1353    } else {
1354        // Local playback
1355        let controller = player.0.lock().await;
1356        // Prefer downloads that completed since the queue was built
1357        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1358            warn!("[player_previous] Failed to refresh local sources: {}", e);
1359        }
1360        controller.previous().map_err(|e| e.to_string())?;
1361        controller.emit_queue_changed();
1362
1363        // Update audio session track if in audio session
1364        let current_item = {
1365            let queue = controller.queue();
1366            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1367            queue_lock.current().cloned()
1368        };
1369
1370        if let Some(item) = current_item {
1371            if item.media_type == MediaType::Audio {
1372                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1373                session_mgr.update_audio_track(item);
1374
1375                // Emit session changed event
1376                if let Some(emitter) = controller.event_emitter() {
1377                    emitter.emit(PlayerStatusEvent::SessionChanged {
1378                        session: session_mgr.current().clone(),
1379                    });
1380                }
1381            }
1382        }
1383    }
1384
1385    let controller = player.0.lock().await;
1386    Ok(get_player_status(&controller))
1387}
1388
1389#[tauri::command]
1390#[specta::specta]
1391pub async fn player_seek(
1392    player: State<'_, PlayerStateWrapper>,
1393    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1394    position: f64,
1395) -> Result<PlayerStatus, String> {
1396    // Check if we're in remote mode
1397    let mode = playback_mode.0.get_mode();
1398
1399    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1400        // Send seek command to remote session - clone client before await
1401        let client = {
1402            let controller = player.0.lock().await;
1403            let client_arc = controller.jellyfin_client();
1404            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1405            client_opt
1406                .as_ref()
1407                .ok_or("Jellyfin client not configured")?
1408                .clone()
1409        };
1410        let position_ticks = (position * 10_000_000.0) as i64;
1411        client.session_seek(session_id, position_ticks).await?;
1412    } else {
1413        // Local playback. seek_absolute, not seek: the position came from the UI,
1414        // which shows the whole item, so during a background-audio handoff it has
1415        // to be resolved against the episode's timeline rather than the handoff
1416        // stream's. (DR-159)
1417        let controller = player.0.lock().await;
1418        controller.seek_absolute(position).await?;
1419    }
1420
1421    let controller = player.0.lock().await;
1422    Ok(get_player_status(&controller))
1423}
1424
1425/// Smart video seeking that decides between native and server-side seeking
1426///
1427/// This command analyzes the current video stream and automatically chooses
1428/// the best seeking strategy:
1429/// - HLS streams: Use native seeking
1430/// - Direct play streams: Use native seeking
1431/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
1432///
1433/// For native (non-HTML5) backends, this command handles the entire stream reload
1434/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
1435#[tauri::command]
1436#[specta::specta]
1437pub async fn player_seek_video(
1438    player: State<'_, PlayerStateWrapper>,
1439    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1440    repository_handle: String,
1441    position: f64,
1442    media_source_id: Option<String>,
1443    audio_stream_index: Option<i32>,
1444    use_html5: bool,
1445) -> Result<VideoSeekResponse, String> {
1446    info!(
1447        "[player_seek_video] Seeking to {} seconds (use_html5: {})",
1448        position, use_html5
1449    );
1450
1451    // Get repository
1452    let repository = repository_manager
1453        .0
1454        .get(&repository_handle)
1455        .ok_or("Repository not found - user may need to log in")?;
1456
1457    // Get current playing item to analyze stream characteristics
1458    // Clone what we need to avoid holding locks across await points
1459    let (needs_transcoding, jellyfin_item_id, is_local) = {
1460        let controller = player.0.lock().await;
1461        let queue_arc = controller.queue();
1462        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1463
1464        let current_item = queue.current().ok_or("No item currently playing")?;
1465
1466        if current_item.media_type != MediaType::Video {
1467            return Err("Current item is not a video".to_string());
1468        }
1469
1470        let jellyfin_id = current_item
1471            .jellyfin_id()
1472            .ok_or("Current video has no Jellyfin ID")?
1473            .to_string();
1474
1475        // Neither the URL nor the item's transport is read here any more. The
1476        // strategy turns on whether the *engine* can seek a transcode in place,
1477        // which it declares for itself — so the container the stream happens to
1478        // arrive in stopped being a proxy for anything (DR-246).
1479        let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1480
1481        (current_item.needs_transcoding, jellyfin_id, is_local_file)
1482    }; // Locks are dropped here
1483
1484    // Whether a transcode can be seeked in place is asked of the engine that is
1485    // rendering, not guessed from the URL's shape or from who is rendering.
1486    // TRACES: UR-040, UR-079 | DR-238, DR-246
1487    let seeks_transcoded_in_place = {
1488        let controller = player.0.lock().await;
1489        controller.capabilities().seeks_transcoded_in_place
1490    };
1491    let strategy = determine_video_seek_strategy(
1492        is_local,
1493        seeks_transcoded_in_place,
1494        needs_transcoding,
1495        use_html5,
1496    );
1497
1498    info!(
1499        "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1500         needs_transcoding={}, use_html5={}, strategy={:?}",
1501        is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
1502    );
1503
1504    match strategy {
1505        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1506            // Local files and native backend streams - call backend.seek()
1507            info!("[player_seek_video] Using backend native seek");
1508            let controller = player.0.lock().await;
1509            controller.seek(position).map_err(|e| e.to_string())?;
1510            Ok(VideoSeekResponse::Native { position })
1511        }
1512        VideoSeekStrategy::Html5NativeSeek => {
1513            // HTML5 backend with HLS or direct play - frontend handles seeking
1514            // We don't call backend.seek() because video is in HTML5 element, not in MPV
1515            info!("[player_seek_video] HTML5 native seek - returning position for frontend");
1516            Ok(VideoSeekResponse::Native { position })
1517        }
1518        VideoSeekStrategy::Html5ReloadStream => {
1519            // Transcoded non-HLS with HTML5 - frontend handles stream reload
1520            info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
1521
1522            let selection = repository
1523                .get_stream_selection(
1524                    &jellyfin_item_id,
1525                    media_source_id.as_deref(),
1526                    audio_stream_index,
1527                )
1528                .await
1529                .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1530
1531            info!(
1532                "[player_seek_video] Selected {:?} over {:?} for position {}",
1533                selection.playback_kind, selection.transport, position
1534            );
1535
1536            Ok(VideoSeekResponse::ReloadStream {
1537                selection,
1538                seek_offset: position,
1539            })
1540        }
1541        VideoSeekStrategy::BackendReloadStream => {
1542            // Transcoded non-HLS with native backend - backend handles stream reload
1543            info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1544
1545            let selection = repository
1546                .get_stream_selection(
1547                    &jellyfin_item_id,
1548                    media_source_id.as_deref(),
1549                    audio_stream_index,
1550                )
1551                .await
1552                .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1553            let new_url = selection.url.clone();
1554
1555            info!("[player_seek_video] Got new selection, handling reload internally");
1556
1557            // Stop current playback
1558            {
1559                let controller = player.0.lock().await;
1560                controller.stop().map_err(|e| e.to_string())?;
1561            }
1562
1563            // Update the stream URL in the queue
1564            {
1565                let controller = player.0.lock().await;
1566                let queue_arc = controller.queue();
1567                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1568
1569                if !queue.update_current_stream_url(new_url.clone()) {
1570                    return Err("Failed to update stream URL in queue".to_string());
1571                }
1572            }
1573
1574            // Reload the player with the updated item from queue
1575            {
1576                let controller = player.0.lock().await;
1577                let queue_arc = controller.queue();
1578                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1579
1580                if let Some(updated_item) = queue.current() {
1581                    controller
1582                        .load_and_play(updated_item)
1583                        .map_err(|e| e.to_string())?;
1584                } else {
1585                    return Err("No current item after URL update".to_string());
1586                }
1587
1588                // The re-opened stream begins at zero — the position cannot ride
1589                // along in the URL without 400ing every segment (DR-181) — so the
1590                // seek that the reload was asked for happens here.
1591                controller.seek(position).map_err(|e| e.to_string())?;
1592            }
1593
1594            info!(
1595                "[player_seek_video] Stream reloaded successfully at position {}",
1596                position
1597            );
1598
1599            Ok(VideoSeekResponse::Native { position })
1600        }
1601    }
1602}
1603
1604/// Switch audio track.
1605/// Note: Frontend should handle saving series preferences after this command succeeds
1606///
1607/// What decides the route is **whether the stream in front of the engine
1608/// carries the requested track at all** — see
1609/// [`determine_audio_track_switch_strategy`]:
1610///
1611/// - An HTML5 `<video>` element has no track-selection API, so the stream is
1612///   always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
1613///   the reloaded element back to `position`.
1614/// - A native backend playing a **direct play** holds the source file with
1615///   every track in it, so ExoPlayer selects in place by track-group index.
1616/// - A native backend playing a **transcode** does not. Jellyfin builds a
1617///   transcode around one `AudioStreamIndex`, so the alternate tracks are not
1618///   in the stream; the switch has to re-open it, which this command does
1619///   itself and resumes at `current_position`.
1620///
1621/// That last case is a bug fix, and it was the common case on Android: any
1622/// source whose default audio codec the device cannot decode is transcoded, so
1623/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
1624/// file. The old code called `setAudioTrack(n)` regardless, which indexes
1625/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
1626/// audio track index` and dropped the request — the default track just kept
1627/// playing, with nothing in the UI saying so.
1628///
1629/// libmpv implements neither selection nor reload here — it is the audio-only
1630/// backend and leaves `PlayerBackend::set_audio_track` at its
1631/// `not_implemented()` default, which is why IR-019 is met by these paths
1632/// rather than by MPV.
1633///
1634/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
1635#[tauri::command]
1636#[specta::specta]
1637// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1638// input. Folding the rest into a struct would change the IPC contract and the
1639// generated TypeScript for no readability gain.
1640#[allow(clippy::too_many_arguments)]
1641pub async fn player_switch_audio_track(
1642    player: State<'_, PlayerStateWrapper>,
1643    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1644    repository_handle: String,
1645    stream_index: i32,
1646    array_index: i32,
1647    use_html5: bool,
1648    current_position: Option<f64>,
1649    media_source_id: Option<String>,
1650) -> Result<AudioTrackSwitchResponse, String> {
1651    info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
1652        stream_index, array_index, use_html5);
1653
1654    // Read what the engine is playing before deciding anything — including
1655    // where it is, which has to be captured before the stop below wipes it.
1656    // Locks are dropped at the end of this block so none is held across an
1657    // await.
1658    let (jellyfin_item_id, needs_transcoding, engine_position) = {
1659        let controller = player.0.lock().await;
1660        let engine_position = controller.absolute_position();
1661        let queue_arc = controller.queue();
1662        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1663
1664        let current_item = queue.current().ok_or("No item currently playing")?;
1665
1666        (
1667            current_item
1668                .jellyfin_id()
1669                .ok_or("Current item has no Jellyfin ID")?
1670                .to_string(),
1671            current_item.needs_transcoding,
1672            engine_position,
1673        )
1674    };
1675
1676    let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
1677
1678    info!(
1679        "[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
1680        needs_transcoding, use_html5, strategy
1681    );
1682
1683    if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
1684        // A direct play: the engine holds the source file, every track included.
1685        let controller = player.0.lock().await;
1686        controller
1687            .set_audio_track(array_index)
1688            .map_err(|e| e.to_string())?;
1689
1690        return Ok(AudioTrackSwitchResponse::Native { success: true });
1691    }
1692
1693    // Both reload strategies need a stream built around the chosen track.
1694    let repository = repository_manager
1695        .0
1696        .get(&repository_handle)
1697        .ok_or("Repository not found - user may need to log in")?;
1698
1699    // Select a stream carrying the chosen audio track. It starts at zero —
1700    // an HLS playlist cannot carry a position (DR-181) — so the position is
1701    // restored by seeking afterwards, here or in the frontend.
1702    //
1703    // Pinning a track is itself a reason the source cannot be direct-played:
1704    // the file has one default track and the viewer asked for another, so
1705    // the negotiation returns a transcode. That decision lives in
1706    // `decide_playback_kind`, not here.
1707    let selection = repository
1708        .get_stream_selection(
1709            &jellyfin_item_id,
1710            media_source_id.as_deref(),
1711            Some(stream_index),
1712        )
1713        .await
1714        .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1715
1716    // The caller's position if it has one, the engine's otherwise. The native
1717    // path has no `<video>` element to read, so it sends none — and defaulting
1718    // that to zero re-opened the stream at the start of the film.
1719    let position = crate::player::track_switch::resume_position(current_position, engine_position);
1720
1721    match strategy {
1722        AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
1723            selection,
1724            position,
1725        }),
1726        AudioTrackSwitchStrategy::BackendReloadStream => {
1727            // The native backend re-opens its own stream, the same sequence the
1728            // transcoded seek and quality change use: stop, repoint the queue
1729            // entry at the new URL, load, then seek back to where the viewer
1730            // was. Nothing is left for the frontend to do.
1731            let new_url = selection.url.clone();
1732
1733            {
1734                let controller = player.0.lock().await;
1735                controller.stop().map_err(|e| e.to_string())?;
1736            }
1737
1738            {
1739                let controller = player.0.lock().await;
1740                let queue_arc = controller.queue();
1741                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1742
1743                if !queue.update_current_stream_url(new_url) {
1744                    return Err("Failed to update stream URL in queue".to_string());
1745                }
1746            }
1747
1748            {
1749                let controller = player.0.lock().await;
1750                let queue_arc = controller.queue();
1751                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1752
1753                let updated_item = queue
1754                    .current()
1755                    .ok_or("No current item after URL update")?
1756                    .clone();
1757                drop(queue);
1758
1759                controller
1760                    .load_and_play(&updated_item)
1761                    .map_err(|e| e.to_string())?;
1762                controller.seek(position).map_err(|e| e.to_string())?;
1763            }
1764
1765            info!(
1766                "[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
1767                stream_index, position
1768            );
1769
1770            Ok(AudioTrackSwitchResponse::Native { success: true })
1771        }
1772        // Handled above, before the stream was negotiated.
1773        AudioTrackSwitchStrategy::BackendSelectInPlace => {
1774            Ok(AudioTrackSwitchResponse::Native { success: true })
1775        }
1776    }
1777}
1778
1779/// Change the bandwidth ceiling of the video that is playing *right now*.
1780///
1781/// A cap is a property of the stream the server is producing, so unlike a volume
1782/// change it cannot be applied to a stream already in flight — the stream has to
1783/// be re-opened at the new quality and resumed at the current position. That is
1784/// the same reload the transcoded-seek and audio-track paths use, and the same
1785/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
1786/// native backend is reloaded here.
1787///
1788/// The change applies to **this playback only**. The in-player picker is a
1789/// "this film, this connection" control and its doc has always said so, but it
1790/// used to be implemented by writing the process-wide ceiling — so choosing
1791/// 2 Mbps to get one awkward film moving silently capped every video played
1792/// afterwards for the rest of the process, with the Settings screen still
1793/// showing the old value and nothing in the UI admitting the change. It now
1794/// sets a per-playback override that the next item clears; the durable default
1795/// belongs to Settings, and `player_set_video_settings` is the one that writes
1796/// to the database.
1797///
1798/// TRACES: UR-074, UR-079 | DR-162, DR-226
1799#[tauri::command]
1800#[specta::specta]
1801// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1802// input. Folding the rest into a struct would change the IPC contract and the
1803// generated TypeScript for no readability gain.
1804#[allow(clippy::too_many_arguments)]
1805pub async fn player_set_stream_quality(
1806    player: State<'_, PlayerStateWrapper>,
1807    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1808    repository_handle: String,
1809    quality: crate::settings::StreamingQuality,
1810    use_html5: bool,
1811    current_position: Option<f64>,
1812    media_source_id: Option<String>,
1813    audio_stream_index: Option<i32>,
1814) -> Result<StreamQualityResponse, String> {
1815    info!(
1816        "[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
1817        quality.label(),
1818        use_html5,
1819        current_position
1820    );
1821
1822    let repository = repository_manager
1823        .0
1824        .get(&repository_handle)
1825        .ok_or("Repository not found - user may need to log in")?;
1826
1827    let jellyfin_item_id = {
1828        let controller = player.0.lock().await;
1829        let queue_arc = controller.queue();
1830        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1831
1832        let current_item = queue.current().ok_or("No item currently playing")?;
1833
1834        if current_item.media_type != MediaType::Video {
1835            return Err("Current item is not a video".to_string());
1836        }
1837
1838        current_item
1839            .jellyfin_id()
1840            .ok_or("Current item has no Jellyfin ID")?
1841            .to_string()
1842    };
1843
1844    // Set the ceiling *before* negotiating — the negotiation and every URL
1845    // builder resolve through `effective_streaming_quality`, and they have to
1846    // agree or the cap leaks (a negotiation authorising a direct play the URL
1847    // builder then never gets to constrain).
1848    //
1849    // Deliberately the *override*, not the device default: see the doc above.
1850    // TRACES: UR-074, UR-079 | DR-226
1851    crate::repository::online::set_playback_quality_override(quality);
1852
1853    // Where to resume. `current_position` is the *element's* clock, which only
1854    // the webview path has — on a native backend there is no `<video>` and the
1855    // frontend correctly sends null, so trusting it there resumed every quality
1856    // change from zero.
1857    //
1858    // The player is the authority on position (it is the authority on all
1859    // playback state); asking the DOM for it and falling back to 0 inverted
1860    // that. Fall back to what the controller reports instead.
1861    //
1862    // TRACES: UR-005, UR-074 | DR-226
1863    // The guard is bound inside the arm's block so it is dropped before the
1864    // reload below takes the same lock. This codebase has been bitten by a
1865    // MutexGuard living longer than the expression that produced it.
1866    let position = match current_position {
1867        Some(p) => p,
1868        None => {
1869            let controller = player.0.lock().await;
1870            controller.absolute_position()
1871        }
1872    };
1873    let selection = repository
1874        .get_stream_selection(
1875            &jellyfin_item_id,
1876            media_source_id.as_deref(),
1877            audio_stream_index,
1878        )
1879        .await
1880        .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1881    let new_url = selection.url.clone();
1882
1883    if use_html5 {
1884        return Ok(StreamQualityResponse::ReloadStream {
1885            selection,
1886            position,
1887        });
1888    }
1889
1890    // Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
1891    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
1892    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
1893    // position without 400ing every segment — DR-181), so it is seeked back to
1894    // where the picture was.
1895    {
1896        let controller = player.0.lock().await;
1897        controller.stop().map_err(|e| e.to_string())?;
1898
1899        let queue_arc = controller.queue();
1900        {
1901            let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1902            if !queue.update_current_stream_url(new_url.clone()) {
1903                return Err("Failed to update stream URL in queue".to_string());
1904            }
1905        }
1906
1907        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1908        let updated_item = queue.current().ok_or("No current item after URL update")?;
1909        controller
1910            .load_and_play(updated_item)
1911            .map_err(|e| e.to_string())?;
1912        if position > 0.0 {
1913            controller.seek(position).map_err(|e| e.to_string())?;
1914        }
1915    }
1916
1917    Ok(StreamQualityResponse::Native {
1918        selection,
1919        position,
1920    })
1921}
1922
1923/// Set the active audio track on a native backend directly.
1924///
1925/// TRACES: UR-021 | IR-019, DR-024
1926#[tauri::command]
1927#[specta::specta]
1928pub async fn player_set_audio_track(
1929    player: State<'_, PlayerStateWrapper>,
1930    stream_index: i32,
1931) -> Result<PlayerStatus, String> {
1932    let controller = player.0.lock().await;
1933    controller
1934        .set_audio_track(stream_index)
1935        .map_err(|e| e.to_string())?;
1936    Ok(get_player_status(&controller))
1937}
1938
1939/// Set (or clear, with `None`) the active subtitle track on a native backend.
1940///
1941/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
1942/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
1943/// index. The HTML5 path never reaches here; it toggles its own `<track>`
1944/// children. libmpv implements neither, leaving the trait default in place.
1945///
1946/// TRACES: UR-020 | IR-018, DR-023
1947#[tauri::command]
1948#[specta::specta]
1949pub async fn player_set_subtitle_track(
1950    player: State<'_, PlayerStateWrapper>,
1951    stream_index: Option<i32>,
1952) -> Result<PlayerStatus, String> {
1953    let controller = player.0.lock().await;
1954    controller
1955        .set_subtitle_track(stream_index)
1956        .map_err(|e| e.to_string())?;
1957    Ok(get_player_status(&controller))
1958}
1959
1960/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
1961/// works in.
1962///
1963/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
1964/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
1965/// comparison downstream, so a backend clamp cannot catch it either. It is
1966/// treated as "no volume asked for" and floored to 0.0.
1967///
1968/// TRACES: DR-212 | UT-206
1969fn normalize_volume(volume: f32) -> f32 {
1970    if volume.is_nan() {
1971        0.0
1972    } else {
1973        volume.clamp(0.0, 1.0)
1974    }
1975}
1976
1977#[tauri::command]
1978#[specta::specta]
1979pub async fn player_set_volume(
1980    player: State<'_, PlayerStateWrapper>,
1981    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1982    volume: f32,
1983) -> Result<PlayerStatus, String> {
1984    // Clamp at the boundary as well as in each backend: the remote branch below
1985    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
1986    // hand the server i32::MAX as a volume percentage.
1987    // TRACES: DR-212 | UT-206
1988    let volume = normalize_volume(volume);
1989
1990    // Check if we're in remote mode
1991    let mode = playback_mode.0.get_mode();
1992
1993    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1994        // Send volume command to remote session - clone client before await
1995        let client = {
1996            let controller = player.0.lock().await;
1997            let client_arc = controller.jellyfin_client();
1998            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1999            client_opt
2000                .as_ref()
2001                .ok_or("Jellyfin client not configured")?
2002                .clone()
2003        };
2004        // Convert 0-1 range to 0-100 for Jellyfin API
2005        let volume_percent = (volume * 100.0) as i32;
2006        client
2007            .session_set_volume(session_id, volume_percent)
2008            .await?;
2009    } else {
2010        // Local playback
2011        let controller = player.0.lock().await;
2012        controller.set_volume(volume).map_err(|e| e.to_string())?;
2013    }
2014
2015    let controller = player.0.lock().await;
2016    Ok(get_player_status(&controller))
2017}
2018
2019#[tauri::command]
2020#[specta::specta]
2021pub async fn player_toggle_mute(
2022    player: State<'_, PlayerStateWrapper>,
2023    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2024) -> Result<PlayerStatus, String> {
2025    // Check if we're in remote mode
2026    let mode = playback_mode.0.get_mode();
2027
2028    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2029        // Send toggle mute command to remote session - clone client before await
2030        let client = {
2031            let controller = player.0.lock().await;
2032            let client_arc = controller.jellyfin_client();
2033            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2034            client_opt
2035                .as_ref()
2036                .ok_or("Jellyfin client not configured")?
2037                .clone()
2038        };
2039        client
2040            .send_session_command(session_id, "ToggleMute")
2041            .await?;
2042    } else {
2043        // Local playback
2044        // TODO: Implement toggle_mute in PlayerController
2045        // let controller = player.0.lock().await;
2046        // controller.toggle_mute().map_err(|e| e.to_string())?;
2047    }
2048
2049    let controller = player.0.lock().await;
2050    Ok(get_player_status(&controller))
2051}
2052
2053#[tauri::command]
2054#[specta::specta]
2055pub async fn player_toggle_shuffle(
2056    player: State<'_, PlayerStateWrapper>,
2057) -> Result<QueueStatus, String> {
2058    let controller = player.0.lock().await;
2059    controller.toggle_shuffle();
2060    controller.emit_queue_changed();
2061    Ok(get_queue_status(&controller))
2062}
2063
2064#[tauri::command]
2065#[specta::specta]
2066pub async fn player_cycle_repeat(
2067    player: State<'_, PlayerStateWrapper>,
2068) -> Result<QueueStatus, String> {
2069    let controller = player.0.lock().await;
2070    controller.cycle_repeat();
2071    controller.emit_queue_changed();
2072    Ok(get_queue_status(&controller))
2073}
2074
2075#[tauri::command]
2076#[specta::specta]
2077pub async fn player_get_status(
2078    player: State<'_, PlayerStateWrapper>,
2079    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2080) -> Result<PlayerStatus, String> {
2081    let mode = playback_mode.0.get_mode();
2082
2083    // Get base local status
2084    let controller = player.0.lock().await;
2085    let mut status = get_player_status(&controller);
2086
2087    // Get local media from queue
2088    let local_media = {
2089        let queue_arc = controller.queue();
2090        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
2091        queue.current().map(MergedMediaItem::from)
2092    };
2093
2094    let local_is_playing = status.state.is_playing();
2095    let local_volume = status.volume;
2096
2097    // If in remote mode, fetch session and merge state
2098    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
2099        let client = {
2100            let client_arc = controller.jellyfin_client();
2101            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2102            client_opt
2103                .as_ref()
2104                .ok_or("Jellyfin client not configured")?
2105                .clone()
2106        };
2107
2108        drop(controller); // Release lock before async call
2109
2110        match client.get_session(&session_id).await {
2111            Ok(Some(session)) => {
2112                log::info!("[PlayerCommands] Merging remote session state");
2113
2114                // Merge media item
2115                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2116
2117                // Merge isPlaying (NOT isPaused!)
2118                status.merged_is_playing = session
2119                    .play_state
2120                    .as_ref()
2121                    .map(|ps| !ps.is_paused.unwrap_or(true))
2122                    .unwrap_or(false);
2123
2124                // Merge position (convert ticks to seconds)
2125                status.position = session
2126                    .play_state
2127                    .as_ref()
2128                    .and_then(|ps| ps.position_ticks)
2129                    .map(|ticks| ticks as f64 / 10_000_000.0)
2130                    .unwrap_or(0.0);
2131
2132                // Merge duration (convert ticks to seconds)
2133                status.duration = session
2134                    .now_playing_item
2135                    .as_ref()
2136                    .and_then(|item| item.run_time_ticks)
2137                    .map(|ticks| ticks as f64 / 10_000_000.0);
2138
2139                // Merge volume (convert 0-100 → 0-1)
2140                status.merged_volume = session
2141                    .play_state
2142                    .as_ref()
2143                    .and_then(|ps| ps.volume_level)
2144                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2145                    .unwrap_or(1.0);
2146
2147                return Ok(status);
2148            }
2149            Ok(None) => {
2150                log::warn!("[PlayerCommands] Remote session not found, using local state");
2151            }
2152            Err(e) => {
2153                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2154            }
2155        }
2156    }
2157
2158    // Local playback or fallback
2159    status.merged_media = local_media;
2160    status.merged_is_playing = local_is_playing;
2161    status.merged_volume = local_volume;
2162
2163    Ok(status)
2164}
2165
2166#[tauri::command]
2167#[specta::specta]
2168pub async fn player_get_queue(
2169    player: State<'_, PlayerStateWrapper>,
2170) -> Result<QueueStatus, String> {
2171    let controller = player.0.lock().await;
2172    Ok(get_queue_status(&controller))
2173}
2174
2175/// What playback facilities this platform's backend actually provides.
2176///
2177/// The frontend is presentation-only and must not re-derive backend facts from
2178/// `navigator.userAgent` — that sniffing was a second copy of the same platform
2179/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
2180/// single source of truth; the frontend consumes them.
2181///
2182/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2183#[derive(specta::Type, Debug, Serialize)]
2184#[serde(rename_all = "camelCase")]
2185pub struct PlaybackCapabilities {
2186    /// True when audio is rendered by a webview `<audio>` element rather than a
2187    /// native backend. Native audio exists on Linux (mpv) and Android
2188    /// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
2189    pub uses_webview_audio: bool,
2190    /// True when video can be rendered by a native surface composited *behind*
2191    /// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
2192    /// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
2193    /// compositing), so it stays on the HTML5 element.
2194    pub supports_native_video: bool,
2195    /// True when the user may send video to the webview element instead of the
2196    /// native renderer — the frontend offers the switch only then, and honours
2197    /// the stored preference only then. See [`webview_video_fallback`].
2198    pub webview_video_fallback: bool,
2199}
2200
2201/// Whether the user may send video to the webview `<video>` element instead of
2202/// the native renderer.
2203///
2204/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
2205/// the untouched source file (DR-293), and the webview decodes none of the
2206/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
2207/// the fallback would be a silent film. Beside mpv's native video on Linux the
2208/// webview is still the tested fallback; everywhere else it is the only
2209/// renderer and there is nothing to switch.
2210///
2211/// TRACES: UR-003, UR-071 | DR-293 | UT-259
2212pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
2213    !is_android && native_video_enabled
2214}
2215
2216/// Report this platform's playback capabilities to the frontend.
2217///
2218/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2219#[tauri::command]
2220#[specta::specta]
2221pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2222    // Mirrors the cfg gates the backends themselves are built under.
2223    let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
2224
2225    Ok(PlaybackCapabilities {
2226        uses_webview_audio: !native_audio,
2227        // TRACES: UR-080 | DR-235
2228        supports_native_video: cfg!(target_os = "android")
2229            || crate::player::native_video::enabled(),
2230        // TRACES: UR-003, UR-071 | DR-293
2231        webview_video_fallback: webview_video_fallback(
2232            cfg!(target_os = "android"),
2233            crate::player::native_video::enabled(),
2234        ),
2235    })
2236}
2237
2238pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2239    // Determine backend at compile time based on platform
2240    let (backend, use_html5_element) = if cfg!(target_os = "android") {
2241        // Android uses ExoPlayer native backend
2242        (VideoBackend::Native, false)
2243    } else if crate::player::native_video::enabled() {
2244        // mpv draws the picture on this desktop; the frontend must not also
2245        // load it into a <video> element or the stream decodes twice and the
2246        // two fight over the audio. TRACES: UR-080 | DR-235
2247        (VideoBackend::Native, false)
2248    } else {
2249        // Linux and other platforms use HTML5 video element in frontend
2250        (VideoBackend::Html5, true)
2251    };
2252
2253    PlayerStatus {
2254        state: controller.state(),
2255        // The position on the item's timeline, whichever of the three paths is
2256        // rendering it — the native backend answers for only one of them, and
2257        // reads 0 for webview video and for a handoff that has not ticked yet.
2258        // TRACES: UR-005 | DR-178
2259        position: controller.absolute_position(),
2260        duration: controller.duration(),
2261        volume: controller.volume(),
2262        muted: controller.muted(),
2263        shuffle: controller.is_shuffle(),
2264        repeat: controller.repeat_mode(),
2265        backend,
2266        use_html5_element,
2267
2268        // Merged fields initialized to defaults (will be set by player_get_status)
2269        merged_media: None,
2270        merged_is_playing: false,
2271        merged_volume: controller.volume(),
2272    }
2273}
2274
2275pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2276    let queue = controller.queue();
2277    let queue_lock = queue.lock_safe();
2278
2279    QueueStatus {
2280        items: queue_lock.items().to_vec(),
2281        current_index: queue_lock.current_index(),
2282        shuffle: queue_lock.is_shuffle(),
2283        repeat: queue_lock.repeat_mode(),
2284        has_next: queue_lock.has_next(),
2285        has_previous: queue_lock.has_previous(),
2286    }
2287}
2288
2289/// Start a freshly-built queue on the active remote session.
2290///
2291/// Used by the "play tracks"/"play album track" commands when we're in remote
2292/// mode: instead of starting local MPV playback, we cast the selected tracks to
2293/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
2294/// play_on_session call, but for a brand-new selection (so there's no resume
2295/// position - playback starts from the chosen track's beginning).
2296///
2297/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
2298/// the start index is adjusted to the remaining Jellyfin items. Returns an error
2299/// if the selected track itself has no Jellyfin ID.
2300async fn play_selection_on_remote(
2301    controller: &PlayerController,
2302    session_id: &str,
2303    media_items: &[MediaItem],
2304    start_index: usize,
2305) -> Result<(), String> {
2306    // Collect Jellyfin IDs, tracking where the selected track lands after any
2307    // local-only items are dropped.
2308    let mut jellyfin_ids: Vec<String> = Vec::new();
2309    let mut adjusted_index: Option<usize> = None;
2310    for (i, item) in media_items.iter().enumerate() {
2311        if let Some(id) = item.jellyfin_id() {
2312            if i == start_index {
2313                adjusted_index = Some(jellyfin_ids.len());
2314            }
2315            jellyfin_ids.push(id.to_string());
2316        }
2317    }
2318
2319    let start_index =
2320        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2321
2322    if jellyfin_ids.is_empty() {
2323        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2324    }
2325
2326    let client = {
2327        let client_arc = controller.jellyfin_client();
2328        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2329        client_opt
2330            .as_ref()
2331            .ok_or("Jellyfin client not configured")?
2332            .clone()
2333    };
2334
2335    // Fresh selection: start from the beginning of the chosen track.
2336    client
2337        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2338        .await
2339        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2340}
2341
2342/// Play a track from an album - backend fetches all album tracks and builds queue
2343#[tauri::command]
2344#[specta::specta]
2345pub async fn player_play_album_track(
2346    player: State<'_, PlayerStateWrapper>,
2347    session: State<'_, MediaSessionManagerWrapper>,
2348    db: State<'_, DatabaseWrapper>,
2349    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2350    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2351    repository_handle: String,
2352    request: PlayAlbumTrackRequest,
2353) -> Result<PlayerStatus, String> {
2354    info!(
2355        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2356        request.album_id, request.track_id, request.shuffle
2357    );
2358
2359    // Get repository (hybrid - supports offline/online)
2360    let repository = repository_manager
2361        .0
2362        .get(&repository_handle)
2363        .ok_or("Repository not found - user may need to log in")?;
2364
2365    // Fetch all tracks from the album via hybrid repository
2366    info!(
2367        "Fetching tracks for album {} via repository",
2368        request.album_id
2369    );
2370    let album_items = repository
2371        .get_items(
2372            &request.album_id,
2373            Some(GetItemsOptions {
2374                limit: Some(1000),
2375                fields: Some(vec![
2376                    "PrimaryImageAspectRatio".to_string(),
2377                    "Overview".to_string(),
2378                    "MediaStreams".to_string(),
2379                ]),
2380                ..Default::default()
2381            }),
2382        )
2383        .await
2384        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2385
2386    info!("Found {} items in album", album_items.items.len());
2387
2388    // Filter to only Audio items and sort by index
2389    let mut tracks: Vec<_> = album_items
2390        .items
2391        .into_iter()
2392        .filter(|item| item.item_type == "Audio")
2393        .collect();
2394    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2395
2396    info!("Found {} audio tracks in album", tracks.len());
2397
2398    if tracks.is_empty() {
2399        return Err("No audio tracks found in album".to_string());
2400    }
2401
2402    // Debug: Log all track IDs and their indices
2403    info!("Album has {} tracks after sorting:", tracks.len());
2404    for (idx, track) in tracks.iter().enumerate() {
2405        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
2406    }
2407
2408    // Validate the requested track exists in the album (its position in the
2409    // final queue is computed after building, since offline tracks are skipped).
2410    info!("Looking for track_id: {}", request.track_id);
2411    let album_index = tracks
2412        .iter()
2413        .position(|t| t.id == request.track_id)
2414        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2415
2416    info!(
2417        "Track {} is at index {} in album",
2418        request.track_id, album_index
2419    );
2420
2421    // Convert tracks to MediaItems
2422    let mut media_items = Vec::new();
2423    for track in tracks {
2424        // Check for local download first
2425        let jellyfin_id = &track.id;
2426        let local_path = check_for_local_download(&db, jellyfin_id).await?;
2427
2428        let source = if let Some(path) = local_path {
2429            MediaSource::Local {
2430                file_path: PathBuf::from(path),
2431                jellyfin_item_id: Some(jellyfin_id.clone()),
2432            }
2433        } else {
2434            // Non-downloaded track: needs a stream URL from the server. When the
2435            // server is unreachable (offline), skip this track rather than failing
2436            // the whole album — downloaded tracks must still be playable.
2437            match repository.get_audio_stream_url(&track.id).await {
2438                Ok(stream_url) => MediaSource::Remote {
2439                    stream_url,
2440                    jellyfin_item_id: jellyfin_id.clone(),
2441                },
2442                Err(e) => {
2443                    warn!(
2444                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2445                        track.name, track.id, e
2446                    );
2447                    continue;
2448                }
2449            }
2450        };
2451
2452        let primary_image_tag_for_url = track.primary_image_tag.clone();
2453        let media_item = MediaItem {
2454            // Audio and direct-URL items never negotiate a transport.
2455            transport: None,
2456            id: track.id.clone(),
2457            title: track.name.clone(),
2458            name: Some(track.name.clone()), // Frontend compatibility
2459            artist: track
2460                .album_artist
2461                .clone()
2462                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2463            album: Some(request.album_name.clone()),
2464            album_name: Some(request.album_name.clone()), // Frontend compatibility
2465            album_id: Some(request.album_id.clone()),
2466            artist_items: track.artist_items.clone(), // For clickable artist links
2467            artists: track.artists.clone(),           // Fallback artist info
2468            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2469            image_id: track.primary_image_tag.clone(),
2470            item_type: Some(track.item_type.clone()), // Frontend compatibility
2471            playlist_id: None,
2472            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2473            artwork_url: primary_image_tag_for_url.map(|tag| {
2474                repository.get_image_url(
2475                    &request.album_id,
2476                    ImageType::Primary,
2477                    Some(ImageOptions {
2478                        max_width: Some(300),
2479                        tag: Some(tag),
2480                        ..Default::default()
2481                    }),
2482                )
2483            }),
2484            media_type: MediaType::Audio,
2485            source,
2486            video_codec: None,
2487            needs_transcoding: false,
2488            video_width: None,
2489            video_height: None,
2490            subtitles: vec![],
2491            series_id: None,
2492            server_id: None,
2493        };
2494
2495        media_items.push(media_item);
2496    }
2497
2498    if media_items.is_empty() {
2499        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2500    }
2501
2502    // Tracks with no local download and no reachable server were skipped above,
2503    // so positions shifted. Re-locate the requested track in the built queue.
2504    // If the tapped track itself was skipped, fall back to the first item.
2505    let start_index = media_items
2506        .iter()
2507        .position(|item| item.id == request.track_id)
2508        .unwrap_or(0);
2509
2510    info!(
2511        "Built queue with {} media items, starting at index {}",
2512        media_items.len(),
2513        start_index
2514    );
2515
2516    // Handle shuffle before setting queue
2517    if request.shuffle {
2518        let controller = player.0.lock().await;
2519        controller.toggle_shuffle();
2520    }
2521
2522    // Start audio session with the first item
2523    if let Some(first_item) = media_items.get(start_index) {
2524        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2525        session_mgr.start_audio_session(first_item.clone());
2526    }
2527
2528    let controller = player.0.lock().await;
2529
2530    // When controlling a remote session, cast the selection there instead of
2531    // starting local MPV playback. We still load the queue locally (below) so
2532    // the queue/context stay in sync for the UI and for transferring back.
2533    let remote_session = match playback_mode.0.get_mode() {
2534        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2535        _ => None,
2536    };
2537
2538    if let Some(session_id) = &remote_session {
2539        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2540        controller
2541            .set_queue(media_items, start_index)
2542            .map_err(|e| e.to_string())?;
2543    } else {
2544        // Local playback is now authoritative (see player_play_tracks); set it
2545        // before starting so the mode-changed event precedes the state events.
2546        playback_mode
2547            .0
2548            .set_mode(crate::playback_mode::PlaybackMode::Local);
2549        controller
2550            .play_queue(media_items, start_index)
2551            .map_err(|e| e.to_string())?;
2552    }
2553
2554    // Set the queue context for remote transfer
2555    {
2556        let queue_arc = controller.queue();
2557        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2558        queue.set_context(QueueContext::Album {
2559            album_id: request.album_id.clone(),
2560            album_name: request.album_name.clone(),
2561        });
2562    }
2563
2564    // Emit queue changed event
2565    controller.emit_queue_changed();
2566
2567    // Log final queue state
2568    {
2569        let queue_arc = controller.queue();
2570        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2571        info!(
2572            "player_play_album_track: Queue now has {} items, current_index: {:?}",
2573            queue_lock.items().len(),
2574            queue_lock.current_index()
2575        );
2576    }
2577
2578    // Emit session changed event
2579    if let Some(emitter) = controller.event_emitter() {
2580        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2581        emitter.emit(PlayerStatusEvent::SessionChanged {
2582            session: session_mgr.current().clone(),
2583        });
2584    }
2585
2586    Ok(get_player_status(&controller))
2587}
2588
2589/// Play tracks by ID - backend fetches all metadata
2590#[tauri::command]
2591#[specta::specta]
2592pub async fn player_play_tracks(
2593    player: State<'_, PlayerStateWrapper>,
2594    session: State<'_, MediaSessionManagerWrapper>,
2595    db: State<'_, DatabaseWrapper>,
2596    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2597    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2598    repository_handle: String,
2599    request: PlayTracksRequest,
2600) -> Result<PlayerStatus, String> {
2601    // A ceiling chosen from the in-player picker belongs to the playback it was
2602    // chosen for. Starting a different item returns to the device default —
2603    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
2604    // session, which is the defect DR-226 exists to close.
2605    //
2606    // TRACES: UR-074, UR-079 | DR-226
2607    crate::repository::online::clear_playback_quality_override();
2608
2609    info!(
2610        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2611        request.track_ids.len(),
2612        request.start_index,
2613        request.shuffle
2614    );
2615
2616    // Validate input
2617    if request.track_ids.is_empty() {
2618        return Err("No tracks provided".to_string());
2619    }
2620
2621    // Get repository
2622    let repository = repository_manager
2623        .0
2624        .get(&repository_handle)
2625        .ok_or("Repository not found - user may need to log in")?;
2626
2627    // Fetch metadata for all tracks
2628    let mut media_items = Vec::new();
2629    for track_id in &request.track_ids {
2630        // Fetch track metadata from repository
2631        let track = repository
2632            .get_item(track_id)
2633            .await
2634            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2635
2636        // Check for local download
2637        let local_path = check_for_local_download(&db, track_id).await?;
2638
2639        // Build MediaSource
2640        let source = if let Some(path) = local_path {
2641            MediaSource::Local {
2642                file_path: PathBuf::from(path),
2643                jellyfin_item_id: Some(track.id.clone()),
2644            }
2645        } else {
2646            let stream_url = repository
2647                .get_audio_stream_url(track_id)
2648                .await
2649                .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2650
2651            MediaSource::Remote {
2652                stream_url,
2653                jellyfin_item_id: track.id.clone(),
2654            }
2655        };
2656
2657        // Transform to MediaItem with frontend-compatible fields
2658        let primary_image_tag_for_url = track.primary_image_tag.clone();
2659        let media_item = MediaItem {
2660            // Audio and direct-URL items never negotiate a transport.
2661            transport: None,
2662            id: track.id.clone(),
2663            title: track.name.clone(),
2664            name: Some(track.name.clone()), // Frontend compatibility
2665            artist: track
2666                .album_artist
2667                .clone()
2668                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2669            album: track.album_name.clone(),
2670            album_name: track.album_name.clone(), // Frontend compatibility
2671            album_id: track.album_id.clone(),
2672            artist_items: track.artist_items.clone(), // For clickable artist links
2673            artists: track.artists.clone(),           // Fallback artist info
2674            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2675            image_id: track.primary_image_tag.clone(),
2676            item_type: Some(track.item_type.clone()), // Frontend compatibility
2677            playlist_id: None,                        // Set based on context below
2678            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2679            artwork_url: primary_image_tag_for_url.and_then(|tag| {
2680                track.album_id.as_ref().map(|album_id| {
2681                    repository.get_image_url(
2682                        album_id,
2683                        ImageType::Primary,
2684                        Some(ImageOptions {
2685                            max_width: Some(300),
2686                            tag: Some(tag),
2687                            ..Default::default()
2688                        }),
2689                    )
2690                })
2691            }),
2692            media_type: MediaType::Audio,
2693            source,
2694            video_codec: None,
2695            needs_transcoding: false,
2696            video_width: None,
2697            video_height: None,
2698            subtitles: vec![],
2699            series_id: None,
2700            server_id: None,
2701        };
2702
2703        media_items.push(media_item);
2704    }
2705
2706    info!("Built queue with {} media items", media_items.len());
2707
2708    // Map context and set playlist_id
2709    let queue_context = match request.context {
2710        PlayTracksContext::Playlist {
2711            playlist_id,
2712            playlist_name,
2713        } => {
2714            for item in &mut media_items {
2715                item.playlist_id = Some(playlist_id.clone());
2716            }
2717            QueueContext::Playlist {
2718                playlist_id,
2719                playlist_name,
2720            }
2721        }
2722        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2723    };
2724
2725    // Handle shuffle
2726    if request.shuffle {
2727        let controller = player.0.lock().await;
2728        controller.toggle_shuffle();
2729    }
2730
2731    // Start session
2732    if let Some(first_item) = media_items.get(request.start_index) {
2733        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2734        session_mgr.start_audio_session(first_item.clone());
2735    }
2736
2737    let controller = player.0.lock().await;
2738
2739    // When controlling a remote session, cast the selection there instead of
2740    // starting local MPV playback. Skip this while a transfer is in flight: the
2741    // transfer-to-local path calls this command to load the queue locally and
2742    // the mode is still Remote until the transfer completes - routing it back to
2743    // the remote would undo the transfer.
2744    let remote_session = match playback_mode.0.get_mode() {
2745        crate::playback_mode::PlaybackMode::Remote { session_id }
2746            if !playback_mode.0.is_transferring() =>
2747        {
2748            Some(session_id)
2749        }
2750        _ => None,
2751    };
2752
2753    if let Some(session_id) = &remote_session {
2754        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2755            .await?;
2756        controller
2757            .set_queue(media_items, request.start_index)
2758            .map_err(|e| e.to_string())?;
2759    } else {
2760        // Starting local playback makes Local the authoritative mode. Without
2761        // this, a prior Remote mode lingers in the manager and later play/pause
2762        // commands route back to the (stopped) remote session. Set it BEFORE
2763        // starting playback so the PlaybackModeChanged event reaches the frontend
2764        // ahead of the state_changed events it will emit — otherwise the frontend
2765        // (still thinking it's remote) filters those state events out. Skip during
2766        // a transfer: transfer_to_local drives the mode itself once complete.
2767        if !playback_mode.0.is_transferring() {
2768            playback_mode
2769                .0
2770                .set_mode(crate::playback_mode::PlaybackMode::Local);
2771        }
2772        controller
2773            .play_queue_from(media_items, request.start_index, request.start_position)
2774            .map_err(|e| e.to_string())?;
2775    }
2776
2777    // Set queue context
2778    {
2779        let queue_arc = controller.queue();
2780        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2781        queue.set_context(queue_context);
2782    }
2783
2784    // Emit events
2785    controller.emit_queue_changed();
2786    if let Some(emitter) = controller.event_emitter() {
2787        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2788        emitter.emit(PlayerStatusEvent::SessionChanged {
2789            session: session_mgr.current().clone(),
2790        });
2791    }
2792
2793    info!("player_play_tracks completed successfully");
2794    Ok(get_player_status(&controller))
2795}
2796
2797/// Response for preload operation
2798#[derive(specta::Type, Debug, Serialize)]
2799#[serde(rename_all = "camelCase")]
2800pub struct PreloadResult {
2801    /// Number of tracks queued for preload
2802    pub queued_count: usize,
2803    /// Number of tracks already downloaded
2804    pub already_downloaded: usize,
2805    /// Number of tracks skipped (no jellyfin ID or other reasons)
2806    pub skipped: usize,
2807}
2808
2809/// Preload upcoming tracks from the queue
2810/// This queues background downloads for the next N tracks that aren't already downloaded
2811#[tauri::command]
2812#[specta::specta]
2813pub async fn player_preload_upcoming(
2814    player: State<'_, PlayerStateWrapper>,
2815    db: State<'_, DatabaseWrapper>,
2816    smart_cache: State<'_, SmartCacheWrapper>,
2817    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2818    app: tauri::AppHandle,
2819    user_id: String,
2820    _download_base_path: String,
2821) -> Result<PreloadResult, String> {
2822    // The pump only starts rows that carry both a stream URL and a target dir,
2823    // so resolve the same storage root the user-initiated download paths use
2824    // (storage_get_path = the database's parent directory).
2825    let (db_service, target_dir) = {
2826        let database = db.0.lock().map_err(|e| e.to_string())?;
2827        let target_dir = database
2828            .path()
2829            .parent()
2830            .ok_or_else(|| "Database path has no parent directory".to_string())?
2831            .to_string_lossy()
2832            .to_string();
2833        (Arc::new(database.service()), target_dir)
2834    };
2835
2836    // Get cache settings
2837    let precache_count = {
2838        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2839        if !cache.should_precache_queue() {
2840            return Ok(PreloadResult {
2841                queued_count: 0,
2842                already_downloaded: 0,
2843                skipped: 0,
2844            });
2845        }
2846        cache.queue_precache_count()
2847    };
2848
2849    // Get upcoming items from queue
2850    let upcoming_items: Vec<MediaItem> = {
2851        let controller = player.0.lock().await;
2852        let queue = controller.queue();
2853        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2854        queue_lock
2855            .get_upcoming(precache_count)
2856            .into_iter()
2857            .cloned()
2858            .collect()
2859    };
2860
2861    if upcoming_items.is_empty() {
2862        return Ok(PreloadResult {
2863            queued_count: 0,
2864            already_downloaded: 0,
2865            skipped: 0,
2866        });
2867    }
2868
2869    let mut queued_count = 0;
2870    let mut already_downloaded = 0;
2871    let mut skipped = 0;
2872
2873    // Process each upcoming item
2874    for item in upcoming_items {
2875        // Only process items with Remote source (not already local). The
2876        // source already carries the resolved stream URL — reuse it so the
2877        // pump can start the download without any extra resolution step.
2878        let (jellyfin_id, stream_url) = match &item.source {
2879            MediaSource::Remote {
2880                jellyfin_item_id,
2881                stream_url,
2882            } => (jellyfin_item_id.clone(), stream_url.clone()),
2883            MediaSource::Local { .. } => {
2884                already_downloaded += 1;
2885                continue;
2886            }
2887            MediaSource::DirectUrl { .. } => {
2888                skipped += 1;
2889                continue;
2890            }
2891        };
2892
2893        // Check if already downloaded or actively in flight. Stale pending rows
2894        // without a stream URL are NOT skipped here — the upsert below heals
2895        // them so the pump can finally start them.
2896        let query = Query::with_params(
2897            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2898             AND (status IN ('completed', 'downloading')
2899                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2900            vec![
2901                QueryParam::String(jellyfin_id.clone()),
2902                QueryParam::String(user_id.clone()),
2903            ],
2904        );
2905
2906        let is_downloaded: Option<String> = db_service
2907            .query_optional(query, |row| row.get(0))
2908            .await
2909            .map_err(|e| e.to_string())?;
2910
2911        if is_downloaded.is_some() {
2912            already_downloaded += 1;
2913            continue;
2914        }
2915
2916        // Queue for download with low priority (preload priority = -100) so
2917        // user-initiated downloads always win a pump slot first.
2918        let album_dir = item
2919            .album
2920            .as_deref()
2921            .filter(|a| !a.is_empty())
2922            .unwrap_or("Unknown Album");
2923        let file_path = format!(
2924            "downloads/{}/{}.mp3",
2925            sanitize_filename(album_dir),
2926            sanitize_filename(&item.title)
2927        );
2928
2929        // Insert with the stream URL + target dir the pump needs to start it.
2930        // On conflict, heal pre-existing rows that were queued without a URL
2931        // (they could never start) instead of leaving them stuck.
2932        let insert_query = Query::with_params(
2933            "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, download_source, media_type, stream_url, target_dir)
2934             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2935             ON CONFLICT(item_id, user_id) DO UPDATE SET
2936                 stream_url = excluded.stream_url,
2937                 target_dir = excluded.target_dir
2938             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2939            vec![
2940                QueryParam::String(jellyfin_id),
2941                QueryParam::String(user_id.clone()),
2942                QueryParam::String(file_path),
2943                QueryParam::String(item.title.clone()),
2944                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2945                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2946                QueryParam::String(stream_url),
2947                QueryParam::String(target_dir.clone()),
2948            ],
2949        );
2950
2951        match db_service.execute(insert_query).await {
2952            Ok(rows) if rows > 0 => {
2953                info!(
2954                    "[Preload] Queued download for: {} - {}",
2955                    item.artist.as_deref().unwrap_or("Unknown"),
2956                    item.title
2957                );
2958                queued_count += 1;
2959            }
2960            Ok(_) => {
2961                // Row already exists (conflict), count as already queued
2962                already_downloaded += 1;
2963            }
2964            Err(e) => {
2965                error!("[Preload] Failed to queue {}: {}", item.title, e);
2966                skipped += 1;
2967            }
2968        }
2969    }
2970
2971    // Kick the pump so the queued preloads actually start; without this they'd
2972    // only begin once some other download activity pumps the queue.
2973    if queued_count > 0 {
2974        let active_downloads = {
2975            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2976            manager.get_active_downloads()
2977        };
2978        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2979    }
2980
2981    info!(
2982        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2983        queued_count, already_downloaded, skipped
2984    );
2985
2986    Ok(PreloadResult {
2987        queued_count,
2988        already_downloaded,
2989        skipped,
2990    })
2991}
2992
2993/// Sanitize filename by removing invalid characters
2994fn sanitize_filename(name: &str) -> String {
2995    name.chars()
2996        .map(|c| match c {
2997            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2998            _ => c,
2999        })
3000        .collect()
3001}
3002
3003/// Update SmartCache configuration
3004#[tauri::command]
3005#[specta::specta]
3006pub async fn player_set_cache_config(
3007    smart_cache: State<'_, SmartCacheWrapper>,
3008    config: CacheConfig,
3009) -> Result<(), String> {
3010    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3011    cache.update_config(config);
3012    Ok(())
3013}
3014
3015/// Get current SmartCache configuration
3016#[tauri::command]
3017#[specta::specta]
3018pub async fn player_get_cache_config(
3019    smart_cache: State<'_, SmartCacheWrapper>,
3020) -> Result<CacheConfig, String> {
3021    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
3022    Ok(cache.get_config().unwrap_or_default())
3023}
3024
3025/// Configure Jellyfin API client for automatic playback reporting
3026#[tauri::command]
3027#[specta::specta]
3028pub async fn player_configure_jellyfin(
3029    player: State<'_, PlayerStateWrapper>,
3030    db: State<'_, DatabaseWrapper>,
3031    server_url: String,
3032    access_token: String,
3033    user_id: String,
3034    device_id: String,
3035) -> Result<(), String> {
3036    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
3037
3038    let config = JellyfinConfig {
3039        server_url,
3040        access_token,
3041        device_id,
3042    };
3043
3044    // Legacy client (used for remote session control / casting).
3045    let client = JellyfinClient::new(config.clone())?;
3046
3047    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
3048    // actually report through. Without this, Start/Progress/Stopped never reach
3049    // Jellyfin, so playback position never syncs and you can't resume on another
3050    // device. The reporter shares the player controller's Arc, so populating it
3051    // here lights up reporting on both desktop and Android, on every auth path
3052    // that configures the player (login / restore / reauth).
3053    let db_service = {
3054        let database = db.0.lock().map_err(|e| e.to_string())?;
3055        Arc::new(database.service())
3056    };
3057    let reporter_client = JellyfinClient::new(config)?;
3058    let reporter = crate::playback_reporting::PlaybackReporter::new(
3059        db_service,
3060        Arc::new(TokioMutex::new(Some(reporter_client))),
3061        user_id,
3062    );
3063
3064    let controller = player.0.lock().await;
3065    controller.set_jellyfin_client(Some(client));
3066    controller.set_playback_reporter(Some(reporter)).await;
3067
3068    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
3069    Ok(())
3070}
3071
3072/// Disable Jellyfin automatic playback reporting
3073#[tauri::command]
3074#[specta::specta]
3075pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
3076    log::info!("[PlayerCommand] Disabling Jellyfin client");
3077
3078    let controller = player.0.lock().await;
3079    controller.set_jellyfin_client(None);
3080    controller.set_playback_reporter(None).await;
3081
3082    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
3083    Ok(())
3084}
3085
3086#[cfg(test)]
3087mod tests {
3088    use crate::utils::lock::MutexSafe;
3089
3090    /// Android has one video renderer, ExoPlayer. The webview element could only
3091    /// be reached by the user switching native video off, and a file downloaded
3092    /// as the untouched original — AC-3 audio included — plays silent there,
3093    /// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
3094    /// the webview is still the tested fallback, so the switch stays there;
3095    /// everywhere else the webview is the only renderer and there is nothing to
3096    /// switch.
3097    ///
3098    /// TRACES: UR-003, UR-071 | DR-293 | UT-259
3099    #[test]
3100    fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
3101        use super::webview_video_fallback;
3102
3103        assert!(
3104            !webview_video_fallback(true, false),
3105            "Android: ExoPlayer is the only video renderer"
3106        );
3107        assert!(
3108            !webview_video_fallback(true, true),
3109            "Android never falls back, whatever else is switched on"
3110        );
3111        assert!(
3112            webview_video_fallback(false, true),
3113            "Linux with mpv native video: the webview is the fallback"
3114        );
3115        assert!(
3116            !webview_video_fallback(false, false),
3117            "the webview is the only renderer; nothing to fall back from"
3118        );
3119    }
3120
3121    /// UT-206 — the volume the command hands on is always a real number in
3122    /// 0.0..=1.0.
3123    ///
3124    /// Every backend clamps for itself, but the remote branch of
3125    /// `player_set_volume` reaches no backend at all: it does
3126    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
3127    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
3128    /// not enough — it has to be tested for.
3129    ///
3130    /// TRACES: DR-212 | UT-206
3131    #[test]
3132    fn test_normalize_volume_clamps_and_rejects_nan() {
3133        use super::normalize_volume;
3134
3135        // In-range values pass through untouched.
3136        assert_eq!(normalize_volume(0.0), 0.0);
3137        assert_eq!(normalize_volume(0.5), 0.5);
3138        assert_eq!(normalize_volume(1.0), 1.0);
3139
3140        // Out of range clamps to the same 0.0..=1.0 the backends use.
3141        assert_eq!(normalize_volume(-0.5), 0.0);
3142        assert_eq!(normalize_volume(42.0), 1.0);
3143        assert_eq!(normalize_volume(f32::INFINITY), 1.0);
3144        assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
3145
3146        // NaN is not a volume; it must not reach the Jellyfin percentage
3147        // conversion or a backend.
3148        let from_nan = normalize_volume(f32::NAN);
3149        assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3150        assert_eq!(from_nan, 0.0);
3151
3152        // Whatever comes out survives the remote branch's percentage cast.
3153        for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3154            let percent = (normalize_volume(input) * 100.0) as i32;
3155            assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3156        }
3157    }
3158
3159    /// The subtitle list the frontend resolved must survive the IPC hop and end
3160    /// up on the `MediaItem` the native backend loads.
3161    ///
3162    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
3163    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
3164    /// in — so `create_media_item` always produced `subtitles: vec![]`,
3165    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
3166    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
3167    /// `setSubtitleTrack(n)` then found no text track groups and logged
3168    /// "Invalid subtitle track index".
3169    ///
3170    /// The payload below is exactly what the frontend sends: camelCase for the
3171    /// top-level command params (Tauri v2 converts them), and the subtitle
3172    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
3173    ///
3174    /// TRACES: UR-020 | IR-016 | UT-145
3175    #[tokio::test]
3176    async fn test_play_item_request_carries_subtitles_into_media_item() {
3177        use super::{create_media_item, PlayItemRequest};
3178
3179        let payload = serde_json::json!({
3180            "id": "ep-1",
3181            "title": "Pilot",
3182            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3183            "videoCodec": "h264",
3184            "needsTranscoding": false,
3185            "subtitles": [
3186                {
3187                    "index": 2,
3188                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3189                    "language": "eng",
3190                    "label": "English (SRT)",
3191                    "mime_type": "text/vtt"
3192                },
3193                {
3194                    "index": 3,
3195                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3196                    "language": null,
3197                    "label": null,
3198                    "mime_type": "text/vtt"
3199                }
3200            ]
3201        });
3202
3203        let req: PlayItemRequest =
3204            serde_json::from_value(payload).expect("frontend payload must deserialize");
3205        assert_eq!(
3206            req.subtitles.len(),
3207            2,
3208            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3209        );
3210
3211        let media = create_media_item(req, None).await.unwrap();
3212        assert_eq!(
3213            media.subtitles.len(),
3214            2,
3215            "create_media_item must thread the tracks onto the MediaItem the backend loads"
3216        );
3217        assert_eq!(media.subtitles[0].index, 2);
3218        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3219        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3220        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3221        // Order is the contract: `player_set_subtitle_track(n)` is a position in
3222        // this list (see the note on `PlayItemRequest::subtitles`).
3223        assert_eq!(media.subtitles[1].index, 3);
3224        assert!(media.subtitles[1].language.is_none());
3225    }
3226
3227    /// A request without subtitles must still deserialize — the field is
3228    /// defaulted so the background-audio handoff and the autoplay/next-episode
3229    /// callers keep compiling and sending what they always sent.
3230    ///
3231    /// TRACES: UR-020 | IR-016 | UT-145
3232    #[tokio::test]
3233    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3234        use super::{create_media_item, PlayItemRequest};
3235
3236        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3237            "id": "movie-1",
3238            "title": "Movie",
3239            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3240            "videoCodec": "h264",
3241            "needsTranscoding": false
3242        }))
3243        .expect("a subtitle-less payload must still deserialize");
3244
3245        assert!(req.subtitles.is_empty());
3246        assert!(create_media_item(req, None)
3247            .await
3248            .unwrap()
3249            .subtitles
3250            .is_empty());
3251    }
3252
3253    /// The JSON handed to Kotlin over JNI must use the keys
3254    /// `JellyTauPlayer.load()` actually reads.
3255    ///
3256    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
3257    /// house IPC rule) is to camelCase nested structs too — but
3258    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
3259    /// the field would not fail to compile or fail the IPC; it would silently
3260    /// fall back to the default MIME type for every track, so this is asserted
3261    /// on the exact bytes `android/mod.rs` sends.
3262    ///
3263    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
3264    #[test]
3265    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3266        use crate::player::media::SubtitleTrack;
3267
3268        let subtitles = vec![SubtitleTrack {
3269            index: 2,
3270            url: "https://jelly.example/subs.vtt".to_string(),
3271            language: Some("eng".to_string()),
3272            label: Some("English".to_string()),
3273            mime_type: "text/vtt".to_string(),
3274        }];
3275
3276        // Exactly what player/android/mod.rs passes to loadWithMetadata.
3277        let json = serde_json::to_string(&subtitles).unwrap();
3278        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3279        let obj = parsed[0].as_object().unwrap();
3280
3281        for key in ["url", "language", "label", "mime_type"] {
3282            assert!(
3283                obj.contains_key(key),
3284                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3285                obj.keys().collect::<Vec<_>>()
3286            );
3287        }
3288        assert!(
3289            !obj.contains_key("mimeType"),
3290            "camelCasing mime_type silently drops every track's MIME type on Android"
3291        );
3292    }
3293
3294    /// The audio-only handoff must play a downloaded file when there is one,
3295    /// rather than fetching an audio-only stream for media already on disk.
3296    ///
3297    /// TRACES: UR-071 | DR-128 | UT-119
3298    #[test]
3299    fn test_background_audio_source_prefers_local_file() {
3300        use super::background_audio_source;
3301        use crate::player::MediaSource;
3302        use std::path::PathBuf;
3303
3304        let local = background_audio_source(
3305            Some("/downloads/ep1.mkv".to_string()),
3306            "https://server/audio-only".to_string(),
3307            "ep-1",
3308        );
3309        match local {
3310            MediaSource::Local {
3311                file_path,
3312                jellyfin_item_id,
3313            } => {
3314                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3315                // The Jellyfin id must survive so progress still syncs back.
3316                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3317            }
3318            other => panic!("expected a local source, got {:?}", other),
3319        }
3320
3321        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3322        match remote {
3323            MediaSource::Remote {
3324                stream_url,
3325                jellyfin_item_id,
3326            } => {
3327                assert_eq!(stream_url, "https://server/audio-only");
3328                assert_eq!(jellyfin_item_id, "ep-1");
3329            }
3330            other => panic!("expected a remote source, got {:?}", other),
3331        }
3332    }
3333
3334    /// The two sources start in different places, so the handoff cannot treat
3335    /// them alike.
3336    ///
3337    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
3338    /// the handoff point that stream's zero: the base is the handoff position and
3339    /// seeking would jump past the content. A *downloaded file* has no such
3340    /// parameter — it starts at the episode's own zero — so basing it at the
3341    /// handoff position claims 18 minutes of audio that is about to play from the
3342    /// beginning. That is the downloaded-episode version of "it restarts when the
3343    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
3344    ///
3345    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3346    #[test]
3347    fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3348        use super::background_audio_plan;
3349
3350        let local = background_audio_plan(true, 1104.0);
3351        assert_eq!(local.base_seconds, 0.0);
3352        assert_eq!(local.seek_to, Some(1104.0));
3353
3354        let streamed = background_audio_plan(false, 1104.0);
3355        assert_eq!(streamed.base_seconds, 1104.0);
3356        assert_eq!(
3357            streamed.seek_to, None,
3358            "the URL already starts at the handoff point; seeking again skips past it"
3359        );
3360    }
3361
3362    /// Handing off at the very start has nothing to seek to and nothing to base:
3363    /// both sources are already where they need to be.
3364    ///
3365    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3366    #[test]
3367    fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3368        use super::background_audio_plan;
3369
3370        for local in [true, false] {
3371            let plan = background_audio_plan(local, 0.0);
3372            assert_eq!(plan.base_seconds, 0.0);
3373            assert_eq!(plan.seek_to, None);
3374        }
3375    }
3376
3377    /// A downloaded item must resolve to its file, and a `downloads` row whose
3378    /// file has gone must resolve to `None` so the caller falls back to
3379    /// streaming instead of handing the player a path that cannot be opened.
3380    ///
3381    /// TRACES: UR-071 | DR-123 | UT-116
3382    #[tokio::test]
3383    async fn test_resolve_local_media_path() {
3384        use super::resolve_local_media_path;
3385        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3386        use rusqlite::Connection;
3387        use std::sync::{Arc, Mutex};
3388
3389        let conn = Connection::open_in_memory().unwrap();
3390        conn.execute(
3391            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3392            [],
3393        )
3394        .unwrap();
3395        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3396
3397        // A real file on disk, so the existence check passes.
3398        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3399        std::fs::write(&present, b"x").unwrap();
3400        let present_str = present.to_string_lossy().to_string();
3401
3402        for (item, status, path) in [
3403            ("downloaded", "completed", present_str.as_str()),
3404            ("still-going", "downloading", present_str.as_str()),
3405            (
3406                "file-gone",
3407                "completed",
3408                "/nonexistent/jellytau/missing.mp4",
3409            ),
3410        ] {
3411            db_service
3412                .execute(Query::with_params(
3413                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3414                    vec![
3415                        crate::storage::db_service::QueryParam::String(item.to_string()),
3416                        crate::storage::db_service::QueryParam::String(status.to_string()),
3417                        crate::storage::db_service::QueryParam::String(path.to_string()),
3418                    ],
3419                ))
3420                .await
3421                .unwrap();
3422        }
3423
3424        assert_eq!(
3425            resolve_local_media_path(&db_service, "downloaded")
3426                .await
3427                .unwrap()
3428                .as_deref(),
3429            Some(present_str.as_str()),
3430            "a completed download with its file present must resolve"
3431        );
3432
3433        assert_eq!(
3434            resolve_local_media_path(&db_service, "still-going")
3435                .await
3436                .unwrap(),
3437            None,
3438            "an in-progress download is not playable from disk"
3439        );
3440
3441        assert_eq!(
3442            resolve_local_media_path(&db_service, "file-gone")
3443                .await
3444                .unwrap(),
3445            None,
3446            "a row whose file has gone must fall back to streaming, not hand over a dead path"
3447        );
3448
3449        assert_eq!(
3450            resolve_local_media_path(&db_service, "never-heard-of-it")
3451                .await
3452                .unwrap(),
3453            None
3454        );
3455
3456        let _ = std::fs::remove_file(&present);
3457    }
3458
3459    /// Queue items enqueued as Remote must flip to Local once a completed
3460    /// download exists on disk — this is what makes preloaded tracks (and
3461    /// offline playback after a connection drop) actually use the cache.
3462    #[tokio::test]
3463    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3464        use super::{refresh_queue_local_sources, DatabaseWrapper};
3465        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3466        use crate::storage::Database;
3467        use std::sync::Mutex;
3468
3469        // A real file on disk for the completed download; a missing file for
3470        // the second entry to prove nonexistent files are not switched.
3471        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3472        std::fs::create_dir_all(&dir).unwrap();
3473        let existing = dir.join("track-a.mp3");
3474        std::fs::write(&existing, b"audio").unwrap();
3475        let missing = dir.join("track-b-missing.mp3");
3476        let _ = std::fs::remove_file(&missing);
3477
3478        let database = Database::open_in_memory().unwrap();
3479        {
3480            let conn = database.connection();
3481            let conn = conn.lock_safe();
3482            conn.execute_batch(&format!(
3483                r#"
3484                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3485                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3486                INSERT INTO downloads (item_id, user_id, file_path, status)
3487                    VALUES ('track-a', 'user1', '{}', 'completed');
3488                INSERT INTO downloads (item_id, user_id, file_path, status)
3489                    VALUES ('track-b', 'user1', '{}', 'completed');
3490                "#,
3491                existing.display(),
3492                missing.display()
3493            ))
3494            .unwrap();
3495        }
3496        let db = DatabaseWrapper(Mutex::new(database));
3497
3498        let make_item = |id: &str| MediaItem {
3499            // Audio and direct-URL items never negotiate a transport.
3500            transport: None,
3501            id: id.to_string(),
3502            title: id.to_string(),
3503            name: None,
3504            artist: None,
3505            album: None,
3506            album_name: None,
3507            album_id: None,
3508            artist_items: None,
3509            artists: None,
3510            primary_image_tag: None,
3511            image_id: None,
3512            item_type: None,
3513            playlist_id: None,
3514            duration: None,
3515            artwork_url: None,
3516            media_type: MediaType::Audio,
3517            source: MediaSource::Remote {
3518                stream_url: format!("http://test/Audio/{}/stream", id),
3519                jellyfin_item_id: id.to_string(),
3520            },
3521            video_codec: None,
3522            needs_transcoding: false,
3523            video_width: None,
3524            video_height: None,
3525            subtitles: vec![],
3526            series_id: None,
3527            server_id: None,
3528        };
3529
3530        let controller = PlayerController::default();
3531        controller
3532            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3533            .unwrap();
3534
3535        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3536        assert_eq!(switched, 1, "only the download whose file exists switches");
3537
3538        let queue = controller.queue();
3539        let queue_lock = queue.lock_safe();
3540        match &queue_lock.items()[0].source {
3541            MediaSource::Local {
3542                file_path,
3543                jellyfin_item_id,
3544            } => {
3545                assert_eq!(file_path, &existing);
3546                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3547            }
3548            other => panic!("track-a should be local, got {:?}", other),
3549        }
3550        assert!(
3551            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3552            "track-b's file is missing, it must stay remote"
3553        );
3554    }
3555
3556    /// Test track index finding in album
3557    /// This reproduces the bug where clicking songs 1-5 always played song 13
3558    #[test]
3559    fn test_find_track_index_in_album() {
3560        // Create mock album tracks
3561        #[derive(Clone)]
3562        struct MockTrack {
3563            id: String,
3564            name: String,
3565            index_number: Option<i32>,
3566        }
3567
3568        let mut tracks = [
3569            MockTrack {
3570                id: "track1".to_string(),
3571                name: "Song 1".to_string(),
3572                index_number: Some(1),
3573            },
3574            MockTrack {
3575                id: "track2".to_string(),
3576                name: "Song 2".to_string(),
3577                index_number: Some(2),
3578            },
3579            MockTrack {
3580                id: "track3".to_string(),
3581                name: "Song 3".to_string(),
3582                index_number: Some(3),
3583            },
3584            MockTrack {
3585                id: "track4".to_string(),
3586                name: "Song 4".to_string(),
3587                index_number: Some(4),
3588            },
3589            MockTrack {
3590                id: "track5".to_string(),
3591                name: "Song 5".to_string(),
3592                index_number: Some(5),
3593            },
3594        ];
3595
3596        // Sort by index (same as the real code does)
3597        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3598
3599        // Test finding track 1 (should be index 0)
3600        let index1 = tracks.iter().position(|t| t.id == "track1");
3601        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3602        assert_eq!(
3603            tracks[0].name, "Song 1",
3604            "Track at index 0 should carry its name"
3605        );
3606
3607        // Test finding track 3 (should be index 2)
3608        let index3 = tracks.iter().position(|t| t.id == "track3");
3609        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3610
3611        // Test finding track 5 (should be index 4)
3612        let index5 = tracks.iter().position(|t| t.id == "track5");
3613        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3614
3615        // Test finding non-existent track
3616        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3617        assert_eq!(index_none, None, "Non-existent track should return None");
3618    }
3619
3620    /// Test that track order is preserved when iterating
3621    #[test]
3622    fn test_track_iteration_order() {
3623        // Simulate the loop that builds MediaItems
3624        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3625
3626        // Find where "id3" is in the original list
3627        let target_index = track_ids.iter().position(|&id| id == "id3");
3628        assert_eq!(
3629            target_index,
3630            Some(2),
3631            "id3 should be at index 2 in original list"
3632        );
3633
3634        // Simulate building the MediaItems vector
3635        let mut media_items = Vec::new();
3636        for id in &track_ids {
3637            media_items.push(id.to_string());
3638        }
3639
3640        // Verify the order is preserved
3641        assert_eq!(media_items.len(), 5);
3642        assert_eq!(media_items[0], "id1");
3643        assert_eq!(media_items[2], "id3");
3644        assert_eq!(media_items[4], "id5");
3645
3646        // The start_index found earlier should still be valid
3647        assert_eq!(media_items[target_index.unwrap()], "id3");
3648    }
3649
3650    /// Test album track sorting behavior
3651    #[test]
3652    fn test_album_track_sorting() {
3653        #[derive(Clone, Debug)]
3654        struct MockTrack {
3655            id: String,
3656            name: String,
3657            index_number: Option<i32>,
3658        }
3659
3660        // Create tracks in random order (not sorted)
3661        let mut tracks = [
3662            MockTrack {
3663                id: "id5".to_string(),
3664                name: "Track 5".to_string(),
3665                index_number: Some(5),
3666            },
3667            MockTrack {
3668                id: "id1".to_string(),
3669                name: "Track 1".to_string(),
3670                index_number: Some(1),
3671            },
3672            MockTrack {
3673                id: "id3".to_string(),
3674                name: "Track 3".to_string(),
3675                index_number: Some(3),
3676            },
3677            MockTrack {
3678                id: "id2".to_string(),
3679                name: "Track 2".to_string(),
3680                index_number: Some(2),
3681            },
3682            MockTrack {
3683                id: "id4".to_string(),
3684                name: "Track 4".to_string(),
3685                index_number: Some(4),
3686            },
3687        ];
3688
3689        // User clicks track "id1" before sorting - what index is it?
3690        let requested_track_id = "id1";
3691
3692        // Sort the tracks (same as real code)
3693        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3694
3695        // Now find the index AFTER sorting
3696        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3697
3698        // Track "id1" should be at index 0 after sorting
3699        assert_eq!(
3700            start_index,
3701            Some(0),
3702            "After sorting, track id1 should be at index 0"
3703        );
3704
3705        // Verify all tracks are in correct order
3706        assert_eq!(tracks[0].id, "id1");
3707        assert_eq!(
3708            tracks[0].name, "Track 1",
3709            "Sorted track should retain its name"
3710        );
3711        assert_eq!(tracks[1].id, "id2");
3712        assert_eq!(tracks[2].id, "id3");
3713        assert_eq!(tracks[3].id, "id4");
3714        assert_eq!(tracks[4].id, "id5");
3715    }
3716}