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
73    // Merged fields (prefer remote session when available)
74    /// Media item from either local queue or remote session
75    pub merged_media: Option<MergedMediaItem>,
76    /// Playing state from either local player or remote session
77    pub merged_is_playing: bool,
78    /// Volume from either local player or remote session (0-1 normalized)
79    pub merged_volume: f32,
80}
81
82/// Lightweight media item for merged playback state
83/// Converts from both local MediaItem and remote NowPlayingItem
84#[derive(specta::Type, Debug, Serialize, Clone)]
85#[serde(rename_all = "camelCase")]
86pub struct MergedMediaItem {
87    pub id: String,
88    pub title: String,
89    pub artist: Option<String>,
90    pub album: Option<String>,
91    pub album_id: Option<String>,
92    pub duration: Option<f64>,
93    pub primary_image_tag: Option<String>,
94    /// Neutral image identifier — replaces `primary_image_tag` (same value).
95    pub image_id: Option<String>,
96    pub media_type: String,
97}
98
99// Convert from local MediaItem
100impl From<&crate::player::MediaItem> for MergedMediaItem {
101    fn from(item: &crate::player::MediaItem) -> Self {
102        Self {
103            id: item.id.clone(),
104            title: item.title.clone(),
105            artist: item.artist.clone(),
106            album: item.album.clone(),
107            album_id: item.album_id.clone(),
108            duration: item.duration,
109            primary_image_tag: item.primary_image_tag.clone(),
110            image_id: item.primary_image_tag.clone(),
111            media_type: match item.media_type {
112                crate::player::MediaType::Audio => "audio".to_string(),
113                crate::player::MediaType::Video => "video".to_string(),
114            },
115        }
116    }
117}
118
119// Convert from remote NowPlayingItem
120impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
121    fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
122        Self {
123            id: item.id.clone().unwrap_or_default(),
124            title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
125            artist: item
126                .album_artist
127                .clone()
128                .or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
129            album: item.album.clone(),
130            album_id: item.album_id.clone(),
131            duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
132            primary_image_tag: item.primary_image_tag.clone(),
133            image_id: item.primary_image_tag.clone(),
134            media_type: item
135                .item_type
136                .clone()
137                .unwrap_or_else(|| "audio".to_string())
138                .to_lowercase(),
139        }
140    }
141}
142
143/// Response for queue queries
144#[derive(specta::Type, Debug, Serialize)]
145#[serde(rename_all = "camelCase")]
146pub struct QueueStatus {
147    pub items: Vec<MediaItem>,
148    pub current_index: Option<usize>,
149    pub shuffle: bool,
150    pub repeat: RepeatMode,
151    pub has_next: bool,
152    pub has_previous: bool,
153}
154
155/// Request to play a single video item
156///
157/// Simplified to video playback only. Audio playback uses player_play_tracks
158/// to avoid Tauri Android serialization issues with complex objects.
159#[derive(specta::Type, Debug, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct PlayItemRequest {
162    pub id: String,
163    pub title: String,
164    pub stream_url: String,
165    /// Video codec (e.g., "h264", "hevc") for video media
166    pub video_codec: String,
167    /// Whether the video requires server-side transcoding
168    pub needs_transcoding: bool,
169    /// How this item's stream is fetched, as the backend decided it.
170    ///
171    /// Carried on the queue item so a later seek/reload does not have to guess.
172    /// `None` for items queued by a path that never negotiated (audio tracks,
173    /// direct URLs) and for anything queued before this field existed, where the
174    /// caller falls back to `needs_transcoding` — every transcode this app
175    /// requests is HLS (DR-140), so that fallback is exact rather than a guess.
176    ///
177    /// TRACES: UR-003, UR-004, UR-079 | DR-225, DR-230
178    #[serde(default)]
179    pub transport: Option<crate::repository::Transport>,
180
181    /// Optional now-playing metadata. Used by the background-audio handoff so the
182    /// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
183    /// existing video-only callers need not send them.
184    #[serde(default)]
185    pub artist: Option<String>,
186    #[serde(default)]
187    pub primary_image_tag: Option<String>,
188    #[serde(default)]
189    pub server_id: Option<String>,
190    /// Total media duration (seconds). Threaded through the background-audio
191    /// handoff so the lockscreen MediaSession advertises a real duration — a
192    /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
193    #[serde(default)]
194    pub duration_seconds: Option<f64>,
195    /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
196    /// background-audio handoff so an episode played as audio-only is still
197    /// recognised as an episode by autoplay (UR-040) and advances to the next one.
198    #[serde(default)]
199    pub item_type: Option<String>,
200    /// Series ID for TV episodes. Needed alongside `item_type` so the backend can
201    /// look up the next episode when a background-audio track ends.
202    #[serde(default)]
203    pub series_id: Option<String>,
204    /// Subtitle tracks to sideload, with URLs the frontend has already resolved.
205    ///
206    /// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
207    /// renders; mpv loads them as external subtitle files (`mpv_tracks`).
208    ///
209    /// **Order is the contract.** `player_set_subtitle_track(n)` reaches
210    /// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
211    /// *text track groups* — i.e. the position of the sideloaded configuration,
212    /// not the Jellyfin stream index (which is kept on each entry for the UI's
213    /// benefit). So `n` must be a position in this very array, and the array
214    /// must not be reordered or filtered between building it and sending it.
215    /// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
216    /// list that is sent here, for exactly this reason.
217    ///
218    /// Defaulted so the background-audio handoff and the autoplay/next-episode
219    /// callers, which have no subtitles to offer, need not send the field.
220    ///
221    /// TRACES: UR-020 | IR-016, JA-008 | UT-145
222    #[serde(default)]
223    pub subtitles: Vec<crate::player::SubtitleTrack>,
224}
225
226/// Queue context for remote transfer - what type of queue is this?
227#[derive(specta::Type, Debug, Deserialize)]
228#[serde(tag = "type", rename_all = "lowercase")]
229pub enum PlayQueueContext {
230    /// Playing from a specific album
231    Album {
232        #[serde(rename = "albumId")]
233        album_id: String,
234        #[serde(rename = "albumName")]
235        album_name: String,
236    },
237    /// Playing from a specific playlist
238    Playlist {
239        #[serde(rename = "playlistId")]
240        playlist_id: String,
241        #[serde(rename = "playlistName")]
242        playlist_name: String,
243    },
244    /// Custom queue (search results, manual queue, etc.)
245    Custom,
246}
247
248/// Request to play a queue of items
249#[derive(specta::Type, Debug, Deserialize)]
250#[serde(rename_all = "camelCase")]
251pub struct PlayQueueRequest {
252    pub items: Vec<PlayItemRequest>,
253    pub start_index: usize,
254    pub shuffle: bool,
255    /// Optional context for the queue (album, playlist, or custom)
256    /// Used for remote playback transfer
257    #[serde(default)]
258    pub context: Option<PlayQueueContext>,
259}
260
261/// Request to play a track from an album (backend fetches all tracks)
262#[derive(specta::Type, Debug, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct PlayAlbumTrackRequest {
265    pub album_id: String,
266    pub album_name: String,
267    pub track_id: String,
268    pub shuffle: bool,
269}
270
271/// Request to play tracks by ID (backend fetches metadata)
272#[derive(specta::Type, Debug, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct PlayTracksRequest {
275    pub track_ids: Vec<String>,
276    pub start_index: usize,
277    pub shuffle: bool,
278    pub context: PlayTracksContext,
279    /// Position (seconds) to resume the starting track from. Used when taking
280    /// over playback from a remote session so we don't restart from 0.
281    #[serde(default)]
282    pub start_position: Option<f64>,
283}
284
285/// Context information for track playback
286#[derive(specta::Type, Debug, Deserialize)]
287#[serde(tag = "type", rename_all = "lowercase")]
288pub enum PlayTracksContext {
289    Playlist {
290        #[serde(rename = "playlistId")]
291        playlist_id: String,
292        #[serde(rename = "playlistName")]
293        playlist_name: String,
294    },
295    Search {
296        #[serde(rename = "searchQuery")]
297        #[allow(dead_code)] // Used for deserialization, may be used later for analytics
298        search_query: String,
299    },
300    Custom {
301        #[serde(rename = "label")]
302        #[allow(dead_code)] // Used for deserialization, may be used later for UI display
303        label: Option<String>,
304    },
305}
306
307/// Response for video seek operations
308#[derive(specta::Type, Debug, Serialize)]
309#[serde(tag = "strategy", rename_all = "camelCase")]
310pub enum VideoSeekResponse {
311    /// Use native seeking (HLS or direct stream)
312    Native {
313        /// Confirmed position after seek
314        position: f64,
315    },
316}
317
318/// Response for audio track switching operations
319#[derive(specta::Type, Debug, Serialize)]
320#[serde(tag = "strategy", rename_all = "camelCase")]
321pub enum AudioTrackSwitchResponse {
322    /// Native backend handled it (Android ExoPlayer)
323    Native {
324        /// Confirmation message
325        success: bool,
326    },
327}
328
329/// Response for a mid-playback streaming-quality change.
330///
331/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
332/// has to reload anything, so no strategy branch lives in the UI.
333///
334/// TRACES: UR-074 | DR-162
335#[derive(specta::Type, Debug, Serialize)]
336#[serde(tag = "strategy", rename_all = "camelCase")]
337pub enum StreamQualityResponse {
338    /// The native backend was reloaded here; nothing left for the frontend to
339    /// *do* — but it still has to be told what was negotiated.
340    ///
341    /// This carried only a position at first, which left the picker on Android
342    /// pinned to the rendition of the *first* stream: the UI derives the rung in
343    /// force from the selection it holds, nothing replaced that selection on the
344    /// native path, and a transcode always has a rendition — so the fallback
345    /// that would have used the requested value was never reached. The stream
346    /// changed and the menu did not.
347    ///
348    /// TRACES: UR-074, UR-079 | DR-226, DR-227
349    Native {
350        /// What the backend actually opened, so the UI reflects it rather than
351        /// assuming the request was honoured verbatim.
352        selection: StreamSelection,
353        /// Position playback resumed at.
354        position: f64,
355    },
356}
357
358/// Helper function to create MediaItem from video request
359///
360/// PlayItemRequest is now video-only, so we create a video MediaItem.
361/// Audio playback uses player_play_tracks which fetches full metadata from backend.
362pub(super) async fn create_media_item(
363    req: PlayItemRequest,
364    db: Option<&DatabaseWrapper>,
365) -> Result<MediaItem, String> {
366    // For video-only requests, we use the item ID as the jellyfin ID
367    let jellyfin_id = req.id.clone();
368
369    // Check if item is downloaded locally
370    let local_path = if let Some(db_wrapper) = db {
371        check_for_local_download(db_wrapper, &jellyfin_id).await?
372    } else {
373        None
374    };
375
376    let source = if let Some(path) = local_path {
377        MediaSource::Local {
378            file_path: PathBuf::from(path),
379            jellyfin_item_id: Some(jellyfin_id.clone()),
380        }
381    } else {
382        MediaSource::Remote {
383            stream_url: req.stream_url,
384            jellyfin_item_id: jellyfin_id.clone(),
385        }
386    };
387
388    Ok(MediaItem {
389        id: req.id.clone(),
390        title: req.title.clone(),
391        name: Some(req.title.clone()),
392        artist: None,            // Not available from video-only request
393        album: None,             // Not available from video-only request
394        album_name: None,        // Not available from video-only request
395        album_id: None,          // Not available from video-only request
396        artist_items: None,      // Not available from video-only request
397        artists: None,           // Not available from video-only request
398        primary_image_tag: None, // Not available from video-only request
399        image_id: None,
400        item_type: None,   // Not available from video-only request
401        playlist_id: None, // Not available from video-only request
402        duration: None,    // Not available from video-only request
403        artwork_url: None, // Not available from video-only request
404        media_type: crate::player::MediaType::Video, // Video-only request
405        source,
406        video_codec: Some(req.video_codec),
407        needs_transcoding: req.needs_transcoding,
408        // The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-230
409        transport: req.transport,
410        video_width: None,  // Not available from video-only request
411        video_height: None, // Not available from video-only request
412        // Sideloaded subtitles, in the order the frontend sent them — that order
413        // is what `player_set_subtitle_track(n)` indexes into on Android.
414        // TRACES: UR-020 | IR-016 | UT-145
415        subtitles: req.subtitles,
416        series_id: None, // Not available from video-only request
417        server_id: None, // Not available from video-only request
418    })
419}
420
421/// Pick the source for an audio-only handoff.
422///
423/// A downloaded file wins over the audio-only stream URL. No transcode or audio
424/// extraction is involved or wanted: the native backends already play a video
425/// container without decoding its video — the Linux MPV backend is configured
426/// with `video: no`, and ExoPlayer simply has no surface to render to when the
427/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
428/// CPU and battery, need an encoder the project does not ship, and leave a
429/// second artifact to keep in step with the first.
430///
431/// TRACES: UR-071 | DR-128 | UT-119
432pub(super) fn background_audio_source(
433    local_path: Option<String>,
434    stream_url: String,
435    item_id: &str,
436) -> MediaSource {
437    match local_path {
438        Some(path) => MediaSource::Local {
439            file_path: PathBuf::from(path),
440            jellyfin_item_id: Some(item_id.to_string()),
441        },
442        None => MediaSource::Remote {
443            stream_url,
444            jellyfin_item_id: item_id.to_string(),
445        },
446    }
447}
448
449/// How a background-audio handoff must start playback, given where its audio
450/// actually begins.
451///
452/// TRACES: UR-040, UR-071 | DR-180 | UT-181
453pub(super) struct BackgroundAudioPlan {
454    /// The position the stream's own zero corresponds to, recorded as the
455    /// handoff base so later readings can be shifted back to the episode's
456    /// timeline.
457    pub base_seconds: f64,
458    /// Where to seek after loading, if the source does not already start there.
459    pub seek_to: Option<f64>,
460}
461
462/// Decide the base and the seek for a handoff at `position_seconds`.
463///
464/// The two sources start in different places. An audio-only **stream** is built
465/// with `StartTimeTicks`, so the server makes the handoff point that stream's
466/// zero: the base is the handoff position, and seeking would skip *past* the
467/// content by that much again. A downloaded **file** has no such parameter and
468/// begins at the episode's own zero, so it needs the opposite — no base, and a
469/// real seek. Treating a file like a stream is why backgrounding a downloaded
470/// episode restarted it from 0:00 while the lockscreen showed the right time.
471///
472/// TRACES: UR-040, UR-071 | DR-180 | UT-181
473pub(super) fn background_audio_plan(
474    is_local_file: bool,
475    position_seconds: f64,
476) -> BackgroundAudioPlan {
477    let position = position_seconds.max(0.0);
478
479    if is_local_file {
480        BackgroundAudioPlan {
481            base_seconds: 0.0,
482            seek_to: (position > 0.0).then_some(position),
483        }
484    } else {
485        BackgroundAudioPlan {
486            base_seconds: position,
487            seek_to: None,
488        }
489    }
490}
491
492/// Resolve the on-disk file backing a completed download, if there is one.
493///
494/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
495/// deletion, a cleared cache directory, a restored database). Every caller wants
496/// "can I play this from disk right now", so existence is checked here rather
497/// than trusted from the row.
498///
499/// Split out from [`check_for_local_download`] so the resolution is testable
500/// without a `DatabaseWrapper`, and reusable by the video path.
501///
502/// TRACES: UR-071 | DR-123 | UT-116
503pub(super) async fn resolve_local_media_path<S: DatabaseService>(
504    db_service: &Arc<S>,
505    item_id: &str,
506) -> Result<Option<String>, String> {
507    let query = Query::with_params(
508        "SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
509        vec![QueryParam::String(item_id.to_string())],
510    );
511
512    let path: Option<String> = db_service
513        .query_optional(query, |row| row.get(0))
514        .await
515        .map_err(|e| e.to_string())?;
516
517    match path {
518        Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
519        Some(file_path) => {
520            warn!(
521                "[Player] Download entry exists in DB but file not found: {}",
522                file_path
523            );
524            Ok(None)
525        }
526        None => Ok(None),
527    }
528}
529
530/// Check if an item has a completed download
531pub(super) async fn check_for_local_download(
532    db: &DatabaseWrapper,
533    item_id: &str,
534) -> Result<Option<String>, String> {
535    let db_service = {
536        let database = db.0.lock().map_err(|e| e.to_string())?;
537        Arc::new(database.service())
538    };
539
540    resolve_local_media_path(&db_service, item_id).await
541}
542
543/// The on-disk path for a downloaded item, for playback surfaces that resolve
544/// their own source rather than going through the queue.
545///
546/// The video player is the reason this exists: audio has preferred local files
547/// since queue construction, but video asks the repository for a stream URL and
548/// never consults `downloads`, so a downloaded film was still streamed — costing
549/// bandwidth that had already been spent and failing outright when offline.
550///
551/// Returns `None` when nothing is downloaded *or* the file is missing, so the
552/// caller falls back to streaming.
553///
554/// TRACES: UR-071 | DR-123 | UT-116
555#[tauri::command]
556#[specta::specta]
557pub async fn player_local_media_path(
558    db: State<'_, DatabaseWrapper>,
559    item_id: String,
560) -> Result<Option<String>, String> {
561    let db_service = {
562        let database = db.0.lock().map_err(|e| e.to_string())?;
563        Arc::new(database.service())
564    };
565
566    resolve_local_media_path(&db_service, &item_id).await
567}
568
569/// Re-point queued streaming items at completed local downloads.
570///
571/// Sources are resolved once when the queue is built, so downloads that finish
572/// while it plays (preloaded upcoming tracks) — or that existed before the
573/// connection dropped — would otherwise keep streaming. Called before advancing
574/// so the next track always prefers the on-disk copy.
575///
576/// Returns the number of items switched to a local source.
577pub(super) async fn refresh_queue_local_sources(
578    controller: &PlayerController,
579    db: &DatabaseWrapper,
580) -> Result<usize, String> {
581    // Collect remote item IDs first; the queue lock must not be held across awaits.
582    let remote_ids: Vec<String> = {
583        let queue = controller.queue();
584        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
585        queue_lock
586            .items()
587            .iter()
588            .filter_map(|item| match &item.source {
589                MediaSource::Remote {
590                    jellyfin_item_id, ..
591                } => Some(jellyfin_item_id.clone()),
592                _ => None,
593            })
594            .collect()
595    };
596
597    if remote_ids.is_empty() {
598        return Ok(0);
599    }
600
601    let mut local_paths: Vec<(String, String)> = Vec::new();
602    for id in remote_ids {
603        if let Some(path) = check_for_local_download(db, &id).await? {
604            local_paths.push((id, path));
605        }
606    }
607
608    if local_paths.is_empty() {
609        return Ok(0);
610    }
611
612    let queue = controller.queue();
613    let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
614    let mut switched = 0;
615    for item in queue_lock.items_mut() {
616        if let MediaSource::Remote {
617            jellyfin_item_id, ..
618        } = &item.source
619        {
620            if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
621                info!(
622                    "[Player] Switching queued track {} to local download: {}",
623                    id, path
624                );
625                item.source = MediaSource::Local {
626                    file_path: PathBuf::from(path),
627                    jellyfin_item_id: Some(id.clone()),
628                };
629                switched += 1;
630            }
631        }
632    }
633    Ok(switched)
634}
635
636/// Play a single media item (audio or video)
637///
638/// Accepts a PlayItemRequest with all optional fields properly defaulted.
639/// This avoids Tauri's Android serialization issues with complex objects.
640///
641/// @req: UR-003 - Play videos
642/// @req: UR-004 - Play audio uninterrupted
643/// @req: UR-005 - Control media playback (play operation)
644/// @req: DR-009 - Audio player UI
645#[tauri::command]
646#[specta::specta]
647pub async fn player_play_item(
648    player: State<'_, PlayerStateWrapper>,
649    session: State<'_, MediaSessionManagerWrapper>,
650    db: State<'_, DatabaseWrapper>,
651    item: PlayItemRequest,
652) -> Result<PlayerStatus, String> {
653    info!(
654        "player_play_item called: {} - {}",
655        item.title, item.stream_url
656    );
657
658    // A ceiling chosen from the in-player picker belongs to the playback it was
659    // chosen for. Starting a different item returns to the device default —
660    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
661    // session, which is the defect DR-226 exists to close.
662    //
663    // TRACES: UR-074, UR-079 | DR-226
664    crate::repository::online::clear_playback_quality_override();
665
666    // Create media item, checking for local download first
667    let media_item = create_media_item(item, Some(&db)).await?;
668
669    // Start appropriate session based on media type
670    {
671        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
672        match media_item.media_type {
673            MediaType::Audio => {
674                session_mgr.start_audio_session(media_item.clone());
675            }
676            MediaType::Video => {
677                // For single video items, treat as Movie (no series_id available here)
678                session_mgr.start_movie_session(media_item.clone());
679            }
680        }
681    }
682
683    let controller = player.0.lock().await;
684    // The backend always gets the stream: every video renderer is native (mpv,
685    // ExoPlayer) since the webview path was deleted (DR-235). This used to ask
686    // who would render — the webview's `<video>` played it itself, so the
687    // backend was only told about it (`set_current_item`) — and got the answer
688    // wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
689    // picture, and on Windows it loaded video into the backend while the status
690    // sent it to the element too.
691    //
692    // TRACES: UR-080 | DR-231, DR-235, DR-237
693    controller
694        .play_item(media_item)
695        .map_err(|e| e.to_string())?;
696
697    // Emit queue changed event
698    controller.emit_queue_changed();
699
700    // Emit session changed event
701    if let Some(emitter) = controller.event_emitter() {
702        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
703        emitter.emit(PlayerStatusEvent::SessionChanged {
704            session: session_mgr.current().clone(),
705        });
706    }
707
708    Ok(get_player_status(&controller))
709}
710
711/// Enter background-audio mode: hand playback of the currently-watched video off
712/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
713/// is backgrounded/locked, with no client-side video decode (UR-040).
714///
715/// `stream_url` MUST be an audio-only URL (see
716/// `get_audio_only_stream_url_for_video`). The item is created as
717/// `MediaType::Audio` so it starts an audio session and loads into the native
718/// backend with `mediaType="audio"`, replacing the video, so exactly one audio
719/// source is ever active.
720///
721/// This deliberately goes through the queue-based `play_item` path (NOT a
722/// side-channel) so end-of-track lands in `on_playback_ended`, which already
723/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
724/// The sleep-timer state is intentionally left untouched by the handoff.
725///
726/// TRACES: UR-040 | DR-052 | UT-061, IT-013
727#[tauri::command]
728#[specta::specta]
729pub async fn player_enter_background_audio(
730    player: State<'_, PlayerStateWrapper>,
731    session: State<'_, MediaSessionManagerWrapper>,
732    db: State<'_, DatabaseWrapper>,
733    item: PlayItemRequest,
734    position_seconds: f64,
735) -> Result<PlayerStatus, String> {
736    info!(
737        "player_enter_background_audio: {} @ {:.1}s",
738        item.title, position_seconds
739    );
740
741    // Prefer the downloaded file over the audio-only stream URL the frontend
742    // resolved. Handing the native backend a local video container yields
743    // audio-only playback for free — no transcode, no second artifact.
744    // TRACES: UR-071 | DR-128
745    let local_path = check_for_local_download(&db, &item.id).await?;
746    if local_path.is_some() {
747        info!(
748            "player_enter_background_audio: using downloaded file for {}",
749            item.id
750        );
751    }
752    // A downloaded file starts at the episode's zero; a stream starts at the
753    // handoff point. Only one of them has a base, and only the other needs a seek.
754    let plan = background_audio_plan(local_path.is_some(), position_seconds);
755    let source = background_audio_source(local_path, item.stream_url, &item.id);
756
757    // Build an AUDIO media item pointing at the audio-only stream. We do not use
758    // create_media_item() because that hardcodes MediaType::Video; background
759    // audio must be Audio so no video decode is started.
760    let media_item = MediaItem {
761        // Audio and direct-URL items never negotiate a transport.
762        transport: None,
763        id: item.id.clone(),
764        title: item.title.clone(),
765        name: Some(item.title.clone()),
766        artist: item.artist.clone(),
767        album: None,
768        album_name: None,
769        album_id: None,
770        artist_items: None,
771        artists: None,
772        primary_image_tag: item.primary_image_tag.clone(),
773        image_id: item.primary_image_tag.clone(),
774        // Carry episode identity so autoplay can advance to the next episode when
775        // this audio-only handoff ends while backgrounded (UR-040).
776        item_type: item.item_type.clone(),
777        playlist_id: None,
778        // Carry the real duration so the lockscreen MediaSession can draw a scrubber.
779        duration: item.duration_seconds,
780        artwork_url: None,
781        media_type: MediaType::Audio,
782        source,
783        video_codec: None,
784        needs_transcoding: false,
785        video_width: None,
786        video_height: None,
787        subtitles: vec![],
788        series_id: item.series_id.clone(),
789        server_id: item.server_id.clone(),
790    };
791
792    {
793        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
794        session_mgr.start_audio_session(media_item.clone());
795    }
796
797    // Same base offset drives the lockscreen scrubber: ExoPlayer reports position
798    // relative to the stream's StartTimeTicks zero, but the metadata duration is
799    // absolute, so shift the reported position back to absolute for the scrubber.
800    let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
801
802    let controller = player.0.lock().await;
803    // Remember where the video was: for a stream the audio's zero == this
804    // position (the URL was built with StartTimeTicks=position_seconds), so on
805    // exit we add this base to the native player's relative position to get the
806    // absolute one. The controller owns it so a backend-driven advance to the
807    // next episode clears it along with the stream it described.
808    controller.enter_background_audio(plan.base_seconds);
809    controller
810        .play_item(media_item)
811        .map_err(|e| e.to_string())?;
812    // Seek ONLY a local file. The audio-only URL already starts at the handoff
813    // position via StartTimeTicks — its timeline begins at 0 == that point — so
814    // seeking a stream would jump PAST the content by the handoff position again.
815    if let Some(seek_to) = plan.seek_to {
816        info!(
817            "player_enter_background_audio: seeking the downloaded file to {:.1}s",
818            seek_to
819        );
820        controller.seek(seek_to).map_err(|e| e.to_string())?;
821    }
822
823    controller.emit_queue_changed();
824    if let Some(emitter) = controller.event_emitter() {
825        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
826        emitter.emit(PlayerStatusEvent::SessionChanged {
827            session: session_mgr.current().clone(),
828        });
829    }
830
831    Ok(get_player_status(&controller))
832}
833
834/// Exit background-audio mode: stop the native audio player and return its final
835/// position so the frontend can reload the video there (UR-040).
836///
837/// Returns the position in seconds. The sleep timer is intentionally left
838/// untouched — if it fired while backgrounded, playback is already stopped and
839/// this simply reports the last position.
840///
841/// What playback should do now that the app is no longer visible.
842///
843/// The caller supplies only what it alone knows -- whether the per-player
844/// toggle is armed, and whether Android put the window into picture-in-picture.
845/// Everything else (what is playing, and therefore whether there is a picture to
846/// lose) is read here, because it is domain state.
847///
848/// The rule itself is in `player::background_policy`; this command is the wire.
849/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
850/// playing there is nothing to pause, and an error would make the frontend
851/// handle a case that is not a failure.
852///
853/// TRACES: UR-040, UR-041 | DR-225 | UT-212
854#[tauri::command]
855#[specta::specta]
856pub async fn player_background_action(
857    player: State<'_, PlayerStateWrapper>,
858    background_audio_armed: bool,
859    in_picture_in_picture: bool,
860) -> Result<crate::player::background_policy::BackgroundAction, String> {
861    use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
862
863    let is_video = {
864        let controller = player.0.lock().await;
865        let queue_arc = controller.queue();
866        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
867        match queue.current() {
868            Some(item) => is_video_media(item.media_type),
869            None => return Ok(BackgroundAction::KeepPlaying),
870        }
871    };
872
873    let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
874    info!(
875        "[player_background_action] video={} armed={} pip={} -> {:?}",
876        is_video, background_audio_armed, in_picture_in_picture, action
877    );
878    Ok(action)
879}
880
881/// Returns the item the native player is on and its absolute position. The
882/// item matters: an episode that ended while backgrounded has already advanced
883/// in the backend, so reloading the video the webview was mounted with would
884/// bring back the previous episode. (DR-296)
885///
886/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
887#[tauri::command]
888#[specta::specta]
889pub async fn player_exit_background_audio(
890    player: State<'_, PlayerStateWrapper>,
891) -> Result<crate::player::BackgroundAudioResume, String> {
892    let controller = player.0.lock().await;
893
894    // Read the position BEFORE clearing either base. The position tick applies the
895    // base natively, so a tick landing between "base cleared" and "position read"
896    // would hand back a relative position — the whole bug, reintroduced at the one
897    // moment it matters most. Capturing into a `let` before stop() is also the
898    // lock discipline from CLAUDE.md: never hold work across a re-entrant call.
899    // (DR-159)
900    //
901    // `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
902    // landed *yet* is the same hazard from the other side: returning to the
903    // foreground while the audio-only transcode is still opening read 0.0, and
904    // the video reloaded at StartTimeTicks=0 — the episode restarting from the
905    // beginning. Flooring at the handoff base cannot overshoot: the stream is
906    // physically incapable of being behind its own starting point. (DR-178)
907    let resume = controller.background_audio_resume();
908
909    // Now safe to tear the handoff down, native side first.
910    let _ = crate::player::set_lockscreen_position_offset(0.0);
911    controller.exit_background_audio();
912    controller.stop().map_err(|e| e.to_string())?;
913    info!(
914        "player_exit_background_audio: resuming {:?} at {:.1}s",
915        resume.item_id, resume.position_seconds
916    );
917    Ok(resume)
918}
919
920/// Play a queue of media items
921///
922/// @req: UR-004 - Play audio uninterrupted
923/// @req: UR-005 - Control media playback (queue playback)
924/// @req: UR-015 - View and manage current audio queue
925/// @req: DR-005 - Queue manager with shuffle, repeat, history
926#[tauri::command]
927#[specta::specta]
928pub async fn player_play_queue(
929    player: State<'_, PlayerStateWrapper>,
930    session: State<'_, MediaSessionManagerWrapper>,
931    db: State<'_, DatabaseWrapper>,
932    request: PlayQueueRequest,
933) -> Result<PlayerStatus, String> {
934    info!(
935        "player_play_queue called: {} items, start_index: {}, shuffle: {}",
936        request.items.len(),
937        request.start_index,
938        request.shuffle
939    );
940
941    // A ceiling chosen from the in-player picker belongs to the playback it was
942    // chosen for. Starting a different item returns to the device default —
943    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
944    // session, which is the defect DR-226 exists to close.
945    //
946    // TRACES: UR-074, UR-079 | DR-226
947    crate::repository::online::clear_playback_quality_override();
948
949    // Handle shuffle first
950    if request.shuffle {
951        let controller = player.0.lock().await;
952        controller.toggle_shuffle();
953    }
954
955    // Convert request context to internal QueueContext
956    let queue_context = match request.context {
957        Some(PlayQueueContext::Album {
958            album_id,
959            album_name,
960        }) => QueueContext::Album {
961            album_id,
962            album_name,
963        },
964        Some(PlayQueueContext::Playlist {
965            playlist_id,
966            playlist_name,
967        }) => QueueContext::Playlist {
968            playlist_id,
969            playlist_name,
970        },
971        Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
972    };
973
974    // Create media items, checking for local downloads (must not hold locks during await)
975    let mut items: Vec<MediaItem> = Vec::new();
976    for req in request.items {
977        items.push(create_media_item(req, Some(&db)).await?);
978    }
979
980    // Start appropriate session based on first item's media type
981    if let Some(first_item) = items.get(request.start_index) {
982        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
983        match first_item.media_type {
984            MediaType::Audio => {
985                session_mgr.start_audio_session(first_item.clone());
986            }
987            MediaType::Video => {
988                // Queue of videos treated as movie session (TV episodes use different flow)
989                session_mgr.start_movie_session(first_item.clone());
990            }
991        }
992    }
993
994    // Now play the queue and get status
995    let controller = player.0.lock().await;
996    info!(
997        "player_play_queue: Calling controller.play_queue with {} items at index {}",
998        items.len(),
999        request.start_index
1000    );
1001    controller
1002        .play_queue(items, request.start_index)
1003        .map_err(|e| e.to_string())?;
1004
1005    // Set the queue context for remote transfer
1006    {
1007        let queue_arc = controller.queue();
1008        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1009        queue.set_context(queue_context);
1010    }
1011
1012    // Emit queue changed event
1013    controller.emit_queue_changed();
1014
1015    // Emit session changed event
1016    if let Some(emitter) = controller.event_emitter() {
1017        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1018        emitter.emit(PlayerStatusEvent::SessionChanged {
1019            session: session_mgr.current().clone(),
1020        });
1021    }
1022
1023    Ok(get_player_status(&controller))
1024}
1025
1026#[tauri::command]
1027#[specta::specta]
1028pub async fn player_play(
1029    player: State<'_, PlayerStateWrapper>,
1030    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1031) -> Result<PlayerStatus, String> {
1032    // Check if we're in remote mode
1033    let mode = playback_mode.0.get_mode();
1034
1035    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1036        // Send play command to remote session - clone client before await
1037        let client = {
1038            let controller = player.0.lock().await;
1039            let client_arc = controller.jellyfin_client();
1040            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1041            client_opt
1042                .as_ref()
1043                .ok_or("Jellyfin client not configured")?
1044                .clone()
1045        };
1046        client.send_session_command(session_id, "Unpause").await?;
1047    } else {
1048        // Local playback
1049        let controller = player.0.lock().await;
1050        controller.play().map_err(|e| e.to_string())?;
1051    }
1052
1053    let controller = player.0.lock().await;
1054    Ok(get_player_status(&controller))
1055}
1056
1057#[tauri::command]
1058#[specta::specta]
1059pub async fn player_pause(
1060    player: State<'_, PlayerStateWrapper>,
1061    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1062) -> Result<PlayerStatus, String> {
1063    // Check if we're in remote mode
1064    let mode = playback_mode.0.get_mode();
1065
1066    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1067        // Send pause command to remote session - clone client before await
1068        let client = {
1069            let controller = player.0.lock().await;
1070            let client_arc = controller.jellyfin_client();
1071            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1072            client_opt
1073                .as_ref()
1074                .ok_or("Jellyfin client not configured")?
1075                .clone()
1076        };
1077        client.send_session_command(session_id, "Pause").await?;
1078    } else {
1079        // Local playback
1080        let controller = player.0.lock().await;
1081        controller.pause().map_err(|e| e.to_string())?;
1082    }
1083
1084    let controller = player.0.lock().await;
1085    Ok(get_player_status(&controller))
1086}
1087
1088#[tauri::command]
1089#[specta::specta]
1090pub async fn player_toggle(
1091    player: State<'_, PlayerStateWrapper>,
1092    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1093) -> Result<PlayerStatus, String> {
1094    // Check if we're in remote mode
1095    let mode = playback_mode.0.get_mode();
1096
1097    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1098        // Send toggle command to remote session - clone client before await
1099        let client = {
1100            let controller = player.0.lock().await;
1101            let client_arc = controller.jellyfin_client();
1102            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1103            client_opt
1104                .as_ref()
1105                .ok_or("Jellyfin client not configured")?
1106                .clone()
1107        };
1108        client.send_session_command(session_id, "PlayPause").await?;
1109    } else {
1110        // Local playback
1111        let controller = player.0.lock().await;
1112        controller.toggle_playback().map_err(|e| e.to_string())?;
1113    }
1114
1115    let controller = player.0.lock().await;
1116    Ok(get_player_status(&controller))
1117}
1118
1119#[tauri::command]
1120#[specta::specta]
1121pub async fn player_stop(
1122    player: State<'_, PlayerStateWrapper>,
1123    session: State<'_, MediaSessionManagerWrapper>,
1124    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1125) -> Result<PlayerStatus, String> {
1126    // Check if we're in remote mode
1127    let mode = playback_mode.0.get_mode();
1128
1129    // Stopping is a state transition worth seeing in a log. Native video is
1130    // what made its absence matter: the (since deleted) webview <video> stopped
1131    // implicitly when the component unmounted, so nothing ever had to call
1132    // this — and "never
1133    // called" and "called but the backend kept playing" look identical from
1134    // outside without it.
1135    info!("[player_stop] called (mode: {:?})", mode);
1136
1137    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1138        // Send stop command to remote session - clone client before await
1139        let client = {
1140            let controller = player.0.lock().await;
1141            let client_arc = controller.jellyfin_client();
1142            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1143            client_opt
1144                .as_ref()
1145                .ok_or("Jellyfin client not configured")?
1146                .clone()
1147        };
1148        client.send_session_command(session_id, "Stop").await?;
1149
1150        // Stopping the remote session ends the cast, so the manager returns to
1151        // Idle — same as a local stop. This is also what hands OS volume control
1152        // back to this device: set_mode releases the Android remote volume
1153        // provider on any exit from remote mode. Without it the mode stayed
1154        // Remote and the system volume slider remained stuck on the remote
1155        // session with no way back to the local speaker.
1156        playback_mode
1157            .0
1158            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1159    } else {
1160        // Local playback
1161        let controller = player.0.lock().await;
1162        controller.stop().map_err(|e| e.to_string())?;
1163
1164        // A genuine local stop returns the manager to Idle so it no longer
1165        // reports Local (or a stale Remote) — otherwise a later play/pause would
1166        // route to the wrong device.
1167        playback_mode
1168            .0
1169            .set_mode(crate::playback_mode::PlaybackMode::Idle);
1170
1171        // Handle session state based on type (local playback only)
1172        {
1173            let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1174            let current_session = session_mgr.current().clone();
1175
1176            match current_session {
1177                crate::player::MediaSessionType::Movie { .. } => {
1178                    // Movies auto-dismiss when stopped
1179                    session_mgr.movie_session_ended();
1180                }
1181                crate::player::MediaSessionType::Audio { .. } => {
1182                    // Audio persists as inactive (user can resume later)
1183                    session_mgr.audio_session_inactive();
1184                }
1185                crate::player::MediaSessionType::TvShow { .. } => {
1186                    // TV shows mark episode ended (waiting for next or dismiss)
1187                    session_mgr.tv_session_episode_ended();
1188                }
1189                crate::player::MediaSessionType::Idle => {
1190                    // Already idle, no-op
1191                }
1192            }
1193
1194            // Emit session changed event
1195            if let Some(emitter) = controller.event_emitter() {
1196                emitter.emit(PlayerStatusEvent::SessionChanged {
1197                    session: session_mgr.current().clone(),
1198                });
1199            }
1200        }
1201    }
1202
1203    let controller = player.0.lock().await;
1204    Ok(get_player_status(&controller))
1205}
1206
1207#[tauri::command]
1208#[specta::specta]
1209pub async fn player_next(
1210    player: State<'_, PlayerStateWrapper>,
1211    session: State<'_, MediaSessionManagerWrapper>,
1212    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1213    db: State<'_, DatabaseWrapper>,
1214) -> Result<PlayerStatus, String> {
1215    debug!("[player_next] Command called from frontend");
1216
1217    // Check if we're in remote mode
1218    let mode = playback_mode.0.get_mode();
1219
1220    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1221        // Send next track command to remote session - clone client before await
1222        let client = {
1223            let controller = player.0.lock().await;
1224            let client_arc = controller.jellyfin_client();
1225            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1226            client_opt
1227                .as_ref()
1228                .ok_or("Jellyfin client not configured")?
1229                .clone()
1230        };
1231        client.send_session_command(session_id, "NextTrack").await?;
1232    } else {
1233        // Local playback
1234        let controller = player.0.lock().await;
1235        // Prefer downloads that completed since the queue was built
1236        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1237            warn!("[player_next] Failed to refresh local sources: {}", e);
1238        }
1239        controller.next().map_err(|e| e.to_string())?;
1240        controller.emit_queue_changed();
1241
1242        // Update audio session track if in audio session
1243        let current_item = {
1244            let queue = controller.queue();
1245            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1246            queue_lock.current().cloned()
1247        };
1248
1249        if let Some(item) = current_item {
1250            if item.media_type == MediaType::Audio {
1251                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1252                session_mgr.update_audio_track(item);
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
1264    let controller = player.0.lock().await;
1265    Ok(get_player_status(&controller))
1266}
1267
1268#[tauri::command]
1269#[specta::specta]
1270pub async fn player_previous(
1271    player: State<'_, PlayerStateWrapper>,
1272    session: State<'_, MediaSessionManagerWrapper>,
1273    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1274    db: State<'_, DatabaseWrapper>,
1275) -> Result<PlayerStatus, String> {
1276    // Check if we're in remote mode
1277    let mode = playback_mode.0.get_mode();
1278
1279    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1280        // Send previous track command to remote session - clone client before await
1281        let client = {
1282            let controller = player.0.lock().await;
1283            let client_arc = controller.jellyfin_client();
1284            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1285            client_opt
1286                .as_ref()
1287                .ok_or("Jellyfin client not configured")?
1288                .clone()
1289        };
1290        client
1291            .send_session_command(session_id, "PreviousTrack")
1292            .await?;
1293    } else {
1294        // Local playback
1295        let controller = player.0.lock().await;
1296        // Prefer downloads that completed since the queue was built
1297        if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
1298            warn!("[player_previous] Failed to refresh local sources: {}", e);
1299        }
1300        controller.previous().map_err(|e| e.to_string())?;
1301        controller.emit_queue_changed();
1302
1303        // Update audio session track if in audio session
1304        let current_item = {
1305            let queue = controller.queue();
1306            let queue_lock = queue.lock().map_err(|e| e.to_string())?;
1307            queue_lock.current().cloned()
1308        };
1309
1310        if let Some(item) = current_item {
1311            if item.media_type == MediaType::Audio {
1312                let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
1313                session_mgr.update_audio_track(item);
1314
1315                // Emit session changed event
1316                if let Some(emitter) = controller.event_emitter() {
1317                    emitter.emit(PlayerStatusEvent::SessionChanged {
1318                        session: session_mgr.current().clone(),
1319                    });
1320                }
1321            }
1322        }
1323    }
1324
1325    let controller = player.0.lock().await;
1326    Ok(get_player_status(&controller))
1327}
1328
1329#[tauri::command]
1330#[specta::specta]
1331pub async fn player_seek(
1332    player: State<'_, PlayerStateWrapper>,
1333    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1334    position: f64,
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 seek 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        let position_ticks = (position * 10_000_000.0) as i64;
1351        client.session_seek(session_id, position_ticks).await?;
1352    } else {
1353        // Local playback. seek_absolute, not seek: the position came from the UI,
1354        // which shows the whole item, so during a background-audio handoff it has
1355        // to be resolved against the episode's timeline rather than the handoff
1356        // stream's. (DR-159)
1357        let controller = player.0.lock().await;
1358        controller.seek_absolute(position).await?;
1359    }
1360
1361    let controller = player.0.lock().await;
1362    Ok(get_player_status(&controller))
1363}
1364
1365/// Smart video seeking that decides between native and server-side seeking
1366///
1367/// This command analyzes the current video stream and automatically chooses
1368/// the best seeking strategy:
1369/// - HLS streams: Use native seeking
1370/// - Direct play streams: Use native seeking
1371/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
1372///
1373/// The backend always handles the seek itself, including re-opening a stream,
1374/// since every video renderer is native (DR-235).
1375#[tauri::command]
1376#[specta::specta]
1377pub async fn player_seek_video(
1378    player: State<'_, PlayerStateWrapper>,
1379    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1380    repository_handle: String,
1381    position: f64,
1382    media_source_id: Option<String>,
1383    audio_stream_index: Option<i32>,
1384) -> Result<VideoSeekResponse, String> {
1385    info!("[player_seek_video] Seeking to {} seconds", position);
1386
1387    // Get repository
1388    let repository = repository_manager
1389        .0
1390        .get(&repository_handle)
1391        .ok_or("Repository not found - user may need to log in")?;
1392
1393    // Get current playing item to analyze stream characteristics
1394    // Clone what we need to avoid holding locks across await points
1395    let (needs_transcoding, jellyfin_item_id, is_local) = {
1396        let controller = player.0.lock().await;
1397        let queue_arc = controller.queue();
1398        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1399
1400        let current_item = queue.current().ok_or("No item currently playing")?;
1401
1402        if current_item.media_type != MediaType::Video {
1403            return Err("Current item is not a video".to_string());
1404        }
1405
1406        let jellyfin_id = current_item
1407            .jellyfin_id()
1408            .ok_or("Current video has no Jellyfin ID")?
1409            .to_string();
1410
1411        // Neither the URL nor the item's transport is read here any more. The
1412        // strategy turns on whether the *engine* can seek a transcode in place,
1413        // which it declares for itself — so the container the stream happens to
1414        // arrive in stopped being a proxy for anything (DR-246).
1415        let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
1416
1417        (current_item.needs_transcoding, jellyfin_id, is_local_file)
1418    }; // Locks are dropped here
1419
1420    // Whether a transcode can be seeked in place is asked of the engine that is
1421    // rendering, not guessed from the URL's shape or from who is rendering.
1422    // TRACES: UR-040, UR-079 | DR-238, DR-246
1423    let seeks_transcoded_in_place = {
1424        let controller = player.0.lock().await;
1425        controller.capabilities().seeks_transcoded_in_place
1426    };
1427    let strategy =
1428        determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
1429
1430    info!(
1431        "[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
1432         needs_transcoding={}, strategy={:?}",
1433        is_local, seeks_transcoded_in_place, needs_transcoding, strategy
1434    );
1435
1436    match strategy {
1437        VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
1438            // Local files and native backend streams - call backend.seek()
1439            info!("[player_seek_video] Using backend native seek");
1440            let controller = player.0.lock().await;
1441            controller.seek(position).map_err(|e| e.to_string())?;
1442            Ok(VideoSeekResponse::Native { position })
1443        }
1444        VideoSeekStrategy::BackendReloadStream => {
1445            // Transcoded non-HLS with native backend - backend handles stream reload
1446            info!("[player_seek_video] Backend reload stream - requesting new stream URL");
1447
1448            let selection = repository
1449                .get_stream_selection(
1450                    &jellyfin_item_id,
1451                    media_source_id.as_deref(),
1452                    audio_stream_index,
1453                )
1454                .await
1455                .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1456            let new_url = selection.url.clone();
1457
1458            info!("[player_seek_video] Got new selection, handling reload internally");
1459
1460            // Stop current playback
1461            {
1462                let controller = player.0.lock().await;
1463                controller.stop().map_err(|e| e.to_string())?;
1464            }
1465
1466            // Update the stream URL in the queue
1467            {
1468                let controller = player.0.lock().await;
1469                let queue_arc = controller.queue();
1470                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1471
1472                if !queue.update_current_stream_url(new_url.clone()) {
1473                    return Err("Failed to update stream URL in queue".to_string());
1474                }
1475            }
1476
1477            // Reload the player with the updated item from queue
1478            {
1479                let controller = player.0.lock().await;
1480                let queue_arc = controller.queue();
1481                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1482
1483                if let Some(updated_item) = queue.current() {
1484                    controller
1485                        .load_and_play(updated_item)
1486                        .map_err(|e| e.to_string())?;
1487                } else {
1488                    return Err("No current item after URL update".to_string());
1489                }
1490
1491                // The re-opened stream begins at zero — the position cannot ride
1492                // along in the URL without 400ing every segment (DR-181) — so the
1493                // seek that the reload was asked for happens here.
1494                controller.seek(position).map_err(|e| e.to_string())?;
1495            }
1496
1497            info!(
1498                "[player_seek_video] Stream reloaded successfully at position {}",
1499                position
1500            );
1501
1502            Ok(VideoSeekResponse::Native { position })
1503        }
1504    }
1505}
1506
1507/// Switch audio track.
1508/// Note: Frontend should handle saving series preferences after this command succeeds
1509///
1510/// What decides the route is **whether the stream in front of the engine
1511/// carries the requested track at all** — see
1512/// [`determine_audio_track_switch_strategy`]:
1513///
1514/// - A native backend playing a **direct play** holds the source file with
1515///   every track in it, so ExoPlayer selects in place by track-group index.
1516/// - A native backend playing a **transcode** does not. Jellyfin builds a
1517///   transcode around one `AudioStreamIndex`, so the alternate tracks are not
1518///   in the stream; the switch has to re-open it, which this command does
1519///   itself and resumes at `current_position`.
1520///
1521/// That last case is a bug fix, and it was the common case on Android: any
1522/// source whose default audio codec the device cannot decode is transcoded, so
1523/// ExoPlayer saw `Audio tracks: 1` while the menu listed every track in the
1524/// file. The old code called `setAudioTrack(n)` regardless, which indexes
1525/// ExoPlayer's audio track *groups*, found nothing at `n`, warned `Invalid
1526/// audio track index` and dropped the request — the default track just kept
1527/// playing, with nothing in the UI saying so.
1528///
1529/// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
1530/// the file's audio tracks), and re-opens a transcode through the same path.
1531///
1532/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
1533#[tauri::command]
1534#[specta::specta]
1535// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1536// input. Folding the rest into a struct would change the IPC contract and the
1537// generated TypeScript for no readability gain.
1538#[allow(clippy::too_many_arguments)]
1539pub async fn player_switch_audio_track(
1540    player: State<'_, PlayerStateWrapper>,
1541    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1542    repository_handle: String,
1543    stream_index: i32,
1544    array_index: i32,
1545    current_position: Option<f64>,
1546    media_source_id: Option<String>,
1547) -> Result<AudioTrackSwitchResponse, String> {
1548    info!(
1549        "[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
1550        stream_index, array_index
1551    );
1552
1553    // Read what the engine is playing before deciding anything — including
1554    // where it is, which has to be captured before the stop below wipes it.
1555    // Locks are dropped at the end of this block so none is held across an
1556    // await.
1557    let (jellyfin_item_id, needs_transcoding, engine_position) = {
1558        let controller = player.0.lock().await;
1559        let engine_position = controller.absolute_position();
1560        let queue_arc = controller.queue();
1561        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1562
1563        let current_item = queue.current().ok_or("No item currently playing")?;
1564
1565        (
1566            current_item
1567                .jellyfin_id()
1568                .ok_or("Current item has no Jellyfin ID")?
1569                .to_string(),
1570            current_item.needs_transcoding,
1571            engine_position,
1572        )
1573    };
1574
1575    let strategy = determine_audio_track_switch_strategy(needs_transcoding);
1576
1577    info!(
1578        "[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
1579        needs_transcoding, strategy
1580    );
1581
1582    if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
1583        // A direct play: the engine holds the source file, every track included.
1584        let controller = player.0.lock().await;
1585        controller
1586            .set_audio_track(array_index)
1587            .map_err(|e| e.to_string())?;
1588
1589        return Ok(AudioTrackSwitchResponse::Native { success: true });
1590    }
1591
1592    // Both reload strategies need a stream built around the chosen track.
1593    let repository = repository_manager
1594        .0
1595        .get(&repository_handle)
1596        .ok_or("Repository not found - user may need to log in")?;
1597
1598    // Select a stream carrying the chosen audio track. It starts at zero —
1599    // an HLS playlist cannot carry a position (DR-181) — so the position is
1600    // restored by seeking afterwards.
1601    //
1602    // Pinning a track is itself a reason the source cannot be direct-played:
1603    // the file has one default track and the viewer asked for another, so
1604    // the negotiation returns a transcode. That decision lives in
1605    // `decide_playback_kind`, not here.
1606    let selection = repository
1607        .get_stream_selection(
1608            &jellyfin_item_id,
1609            media_source_id.as_deref(),
1610            Some(stream_index),
1611        )
1612        .await
1613        .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1614
1615    // The caller's position if it has one, the engine's otherwise. The native
1616    // path has no `<video>` element to read, so it sends none — and defaulting
1617    // that to zero re-opened the stream at the start of the film.
1618    let position = crate::player::track_switch::resume_position(current_position, engine_position);
1619
1620    match strategy {
1621        AudioTrackSwitchStrategy::BackendReloadStream => {
1622            // The native backend re-opens its own stream, the same sequence the
1623            // transcoded seek and quality change use: stop, repoint the queue
1624            // entry at the new URL, load, then seek back to where the viewer
1625            // was. Nothing is left for the frontend to do.
1626            let new_url = selection.url.clone();
1627
1628            {
1629                let controller = player.0.lock().await;
1630                controller.stop().map_err(|e| e.to_string())?;
1631            }
1632
1633            {
1634                let controller = player.0.lock().await;
1635                let queue_arc = controller.queue();
1636                let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1637
1638                if !queue.update_current_stream_url(new_url) {
1639                    return Err("Failed to update stream URL in queue".to_string());
1640                }
1641            }
1642
1643            {
1644                let controller = player.0.lock().await;
1645                let queue_arc = controller.queue();
1646                let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1647
1648                let updated_item = queue
1649                    .current()
1650                    .ok_or("No current item after URL update")?
1651                    .clone();
1652                drop(queue);
1653
1654                controller
1655                    .load_and_play(&updated_item)
1656                    .map_err(|e| e.to_string())?;
1657                controller.seek(position).map_err(|e| e.to_string())?;
1658            }
1659
1660            info!(
1661                "[player_switch_audio_track] Re-opened the stream on audio stream {} and resumed at {}",
1662                stream_index, position
1663            );
1664
1665            Ok(AudioTrackSwitchResponse::Native { success: true })
1666        }
1667        // Handled above, before the stream was negotiated.
1668        AudioTrackSwitchStrategy::BackendSelectInPlace => {
1669            Ok(AudioTrackSwitchResponse::Native { success: true })
1670        }
1671    }
1672}
1673
1674/// Change the bandwidth ceiling of the video that is playing *right now*.
1675///
1676/// A cap is a property of the stream the server is producing, so unlike a volume
1677/// change it cannot be applied to a stream already in flight — the stream has to
1678/// be re-opened at the new quality and resumed at the current position. That is
1679/// the same reload the transcoded-seek and audio-track paths use, done here.
1680///
1681/// The change applies to **this playback only**. The in-player picker is a
1682/// "this film, this connection" control and its doc has always said so, but it
1683/// used to be implemented by writing the process-wide ceiling — so choosing
1684/// 2 Mbps to get one awkward film moving silently capped every video played
1685/// afterwards for the rest of the process, with the Settings screen still
1686/// showing the old value and nothing in the UI admitting the change. It now
1687/// sets a per-playback override that the next item clears; the durable default
1688/// belongs to Settings, and `player_set_video_settings` is the one that writes
1689/// to the database.
1690///
1691/// TRACES: UR-074, UR-079 | DR-162, DR-226
1692#[tauri::command]
1693#[specta::specta]
1694// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
1695// input. Folding the rest into a struct would change the IPC contract and the
1696// generated TypeScript for no readability gain.
1697#[allow(clippy::too_many_arguments)]
1698pub async fn player_set_stream_quality(
1699    player: State<'_, PlayerStateWrapper>,
1700    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
1701    repository_handle: String,
1702    quality: crate::settings::StreamingQuality,
1703    current_position: Option<f64>,
1704    media_source_id: Option<String>,
1705    audio_stream_index: Option<i32>,
1706) -> Result<StreamQualityResponse, String> {
1707    info!(
1708        "[player_set_stream_quality] Switching to {} (position: {:?})",
1709        quality.label(),
1710        current_position
1711    );
1712
1713    let repository = repository_manager
1714        .0
1715        .get(&repository_handle)
1716        .ok_or("Repository not found - user may need to log in")?;
1717
1718    let jellyfin_item_id = {
1719        let controller = player.0.lock().await;
1720        let queue_arc = controller.queue();
1721        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1722
1723        let current_item = queue.current().ok_or("No item currently playing")?;
1724
1725        if current_item.media_type != MediaType::Video {
1726            return Err("Current item is not a video".to_string());
1727        }
1728
1729        current_item
1730            .jellyfin_id()
1731            .ok_or("Current item has no Jellyfin ID")?
1732            .to_string()
1733    };
1734
1735    // Set the ceiling *before* negotiating — the negotiation and every URL
1736    // builder resolve through `effective_streaming_quality`, and they have to
1737    // agree or the cap leaks (a negotiation authorising a direct play the URL
1738    // builder then never gets to constrain).
1739    //
1740    // Deliberately the *override*, not the device default: see the doc above.
1741    // TRACES: UR-074, UR-079 | DR-226
1742    crate::repository::online::set_playback_quality_override(quality);
1743
1744    // Where to resume. `current_position` is the *element's* clock, which only
1745    // the webview path has — on a native backend there is no `<video>` and the
1746    // frontend correctly sends null, so trusting it there resumed every quality
1747    // change from zero.
1748    //
1749    // The player is the authority on position (it is the authority on all
1750    // playback state); asking the DOM for it and falling back to 0 inverted
1751    // that. Fall back to what the controller reports instead.
1752    //
1753    // TRACES: UR-005, UR-074 | DR-226
1754    // The guard is bound inside the arm's block so it is dropped before the
1755    // reload below takes the same lock. This codebase has been bitten by a
1756    // MutexGuard living longer than the expression that produced it.
1757    let position = match current_position {
1758        Some(p) => p,
1759        None => {
1760            let controller = player.0.lock().await;
1761            controller.absolute_position()
1762        }
1763    };
1764    let selection = repository
1765        .get_stream_selection(
1766            &jellyfin_item_id,
1767            media_source_id.as_deref(),
1768            audio_stream_index,
1769        )
1770        .await
1771        .map_err(|e| format!("Failed to select a stream: {:?}", e))?;
1772    let new_url = selection.url.clone();
1773
1774    // The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
1775    // new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
1776    // The re-opened stream begins at zero (an HLS playlist cannot carry a start
1777    // position without 400ing every segment — DR-181), so it is seeked back to
1778    // where the picture was.
1779    {
1780        let controller = player.0.lock().await;
1781        controller.stop().map_err(|e| e.to_string())?;
1782
1783        let queue_arc = controller.queue();
1784        {
1785            let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
1786            if !queue.update_current_stream_url(new_url.clone()) {
1787                return Err("Failed to update stream URL in queue".to_string());
1788            }
1789        }
1790
1791        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1792        let updated_item = queue.current().ok_or("No current item after URL update")?;
1793        controller
1794            .load_and_play(updated_item)
1795            .map_err(|e| e.to_string())?;
1796        if position > 0.0 {
1797            controller.seek(position).map_err(|e| e.to_string())?;
1798        }
1799    }
1800
1801    Ok(StreamQualityResponse::Native {
1802        selection,
1803        position,
1804    })
1805}
1806
1807/// Set the active audio track on a native backend directly.
1808///
1809/// TRACES: UR-021 | IR-019, DR-024
1810#[tauri::command]
1811#[specta::specta]
1812pub async fn player_set_audio_track(
1813    player: State<'_, PlayerStateWrapper>,
1814    stream_index: i32,
1815) -> Result<PlayerStatus, String> {
1816    let controller = player.0.lock().await;
1817    controller
1818        .set_audio_track(stream_index)
1819        .map_err(|e| e.to_string())?;
1820    Ok(get_player_status(&controller))
1821}
1822
1823/// Set (or clear, with `None`) the active subtitle track on a native backend.
1824///
1825/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
1826/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
1827/// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
1828/// list, loaded as external subtitle files (`mpv_tracks`).
1829///
1830/// TRACES: UR-020 | IR-018, DR-023
1831#[tauri::command]
1832#[specta::specta]
1833pub async fn player_set_subtitle_track(
1834    player: State<'_, PlayerStateWrapper>,
1835    stream_index: Option<i32>,
1836) -> Result<PlayerStatus, String> {
1837    let controller = player.0.lock().await;
1838    controller
1839        .set_subtitle_track(stream_index)
1840        .map_err(|e| e.to_string())?;
1841    Ok(get_player_status(&controller))
1842}
1843
1844/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
1845/// works in.
1846///
1847/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
1848/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
1849/// comparison downstream, so a backend clamp cannot catch it either. It is
1850/// treated as "no volume asked for" and floored to 0.0.
1851///
1852/// TRACES: DR-212 | UT-206
1853fn normalize_volume(volume: f32) -> f32 {
1854    if volume.is_nan() {
1855        0.0
1856    } else {
1857        volume.clamp(0.0, 1.0)
1858    }
1859}
1860
1861#[tauri::command]
1862#[specta::specta]
1863pub async fn player_set_volume(
1864    player: State<'_, PlayerStateWrapper>,
1865    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1866    volume: f32,
1867) -> Result<PlayerStatus, String> {
1868    // Clamp at the boundary as well as in each backend: the remote branch below
1869    // never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
1870    // hand the server i32::MAX as a volume percentage.
1871    // TRACES: DR-212 | UT-206
1872    let volume = normalize_volume(volume);
1873
1874    // Check if we're in remote mode
1875    let mode = playback_mode.0.get_mode();
1876
1877    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1878        // Send volume command to remote session - clone client before await
1879        let client = {
1880            let controller = player.0.lock().await;
1881            let client_arc = controller.jellyfin_client();
1882            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1883            client_opt
1884                .as_ref()
1885                .ok_or("Jellyfin client not configured")?
1886                .clone()
1887        };
1888        // Convert 0-1 range to 0-100 for Jellyfin API
1889        let volume_percent = (volume * 100.0) as i32;
1890        client
1891            .session_set_volume(session_id, volume_percent)
1892            .await?;
1893    } else {
1894        // Local playback
1895        let controller = player.0.lock().await;
1896        controller.set_volume(volume).map_err(|e| e.to_string())?;
1897    }
1898
1899    let controller = player.0.lock().await;
1900    Ok(get_player_status(&controller))
1901}
1902
1903#[tauri::command]
1904#[specta::specta]
1905pub async fn player_toggle_mute(
1906    player: State<'_, PlayerStateWrapper>,
1907    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1908) -> Result<PlayerStatus, String> {
1909    // Check if we're in remote mode
1910    let mode = playback_mode.0.get_mode();
1911
1912    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1913        // Send toggle mute command to remote session - clone client before await
1914        let client = {
1915            let controller = player.0.lock().await;
1916            let client_arc = controller.jellyfin_client();
1917            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1918            client_opt
1919                .as_ref()
1920                .ok_or("Jellyfin client not configured")?
1921                .clone()
1922        };
1923        client
1924            .send_session_command(session_id, "ToggleMute")
1925            .await?;
1926    } else {
1927        // Local playback
1928        // TODO: Implement toggle_mute in PlayerController
1929        // let controller = player.0.lock().await;
1930        // controller.toggle_mute().map_err(|e| e.to_string())?;
1931    }
1932
1933    let controller = player.0.lock().await;
1934    Ok(get_player_status(&controller))
1935}
1936
1937#[tauri::command]
1938#[specta::specta]
1939pub async fn player_toggle_shuffle(
1940    player: State<'_, PlayerStateWrapper>,
1941) -> Result<QueueStatus, String> {
1942    let controller = player.0.lock().await;
1943    controller.toggle_shuffle();
1944    controller.emit_queue_changed();
1945    Ok(get_queue_status(&controller))
1946}
1947
1948#[tauri::command]
1949#[specta::specta]
1950pub async fn player_cycle_repeat(
1951    player: State<'_, PlayerStateWrapper>,
1952) -> Result<QueueStatus, String> {
1953    let controller = player.0.lock().await;
1954    controller.cycle_repeat();
1955    controller.emit_queue_changed();
1956    Ok(get_queue_status(&controller))
1957}
1958
1959#[tauri::command]
1960#[specta::specta]
1961pub async fn player_get_status(
1962    player: State<'_, PlayerStateWrapper>,
1963    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
1964) -> Result<PlayerStatus, String> {
1965    let mode = playback_mode.0.get_mode();
1966
1967    // Get base local status
1968    let controller = player.0.lock().await;
1969    let mut status = get_player_status(&controller);
1970
1971    // Get local media from queue
1972    let local_media = {
1973        let queue_arc = controller.queue();
1974        let queue = queue_arc.lock().map_err(|e| e.to_string())?;
1975        queue.current().map(MergedMediaItem::from)
1976    };
1977
1978    let local_is_playing = status.state.is_playing();
1979    let local_volume = status.volume;
1980
1981    // If in remote mode, fetch session and merge state
1982    if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
1983        let client = {
1984            let client_arc = controller.jellyfin_client();
1985            let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
1986            client_opt
1987                .as_ref()
1988                .ok_or("Jellyfin client not configured")?
1989                .clone()
1990        };
1991
1992        drop(controller); // Release lock before async call
1993
1994        match client.get_session(&session_id).await {
1995            Ok(Some(session)) => {
1996                log::info!("[PlayerCommands] Merging remote session state");
1997
1998                // Merge media item
1999                status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
2000
2001                // Merge isPlaying (NOT isPaused!)
2002                status.merged_is_playing = session
2003                    .play_state
2004                    .as_ref()
2005                    .map(|ps| !ps.is_paused.unwrap_or(true))
2006                    .unwrap_or(false);
2007
2008                // Merge position (convert ticks to seconds)
2009                status.position = session
2010                    .play_state
2011                    .as_ref()
2012                    .and_then(|ps| ps.position_ticks)
2013                    .map(|ticks| ticks as f64 / 10_000_000.0)
2014                    .unwrap_or(0.0);
2015
2016                // Merge duration (convert ticks to seconds)
2017                status.duration = session
2018                    .now_playing_item
2019                    .as_ref()
2020                    .and_then(|item| item.run_time_ticks)
2021                    .map(|ticks| ticks as f64 / 10_000_000.0);
2022
2023                // Merge volume (convert 0-100 → 0-1)
2024                status.merged_volume = session
2025                    .play_state
2026                    .as_ref()
2027                    .and_then(|ps| ps.volume_level)
2028                    .map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
2029                    .unwrap_or(1.0);
2030
2031                return Ok(status);
2032            }
2033            Ok(None) => {
2034                log::warn!("[PlayerCommands] Remote session not found, using local state");
2035            }
2036            Err(e) => {
2037                log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
2038            }
2039        }
2040    }
2041
2042    // Local playback or fallback
2043    status.merged_media = local_media;
2044    status.merged_is_playing = local_is_playing;
2045    status.merged_volume = local_volume;
2046
2047    Ok(status)
2048}
2049
2050#[tauri::command]
2051#[specta::specta]
2052pub async fn player_get_queue(
2053    player: State<'_, PlayerStateWrapper>,
2054) -> Result<QueueStatus, String> {
2055    let controller = player.0.lock().await;
2056    Ok(get_queue_status(&controller))
2057}
2058
2059/// What playback facilities this platform's backend actually provides.
2060///
2061/// The frontend is presentation-only and must not re-derive backend facts from
2062/// `navigator.userAgent` — that sniffing was a second copy of the same platform
2063/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
2064/// single source of truth; the frontend consumes them.
2065///
2066/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2067#[derive(specta::Type, Debug, Serialize)]
2068#[serde(rename_all = "camelCase")]
2069pub struct PlaybackCapabilities {
2070    /// True when audio is rendered by a webview `<audio>` element rather than a
2071    /// native backend. Native audio exists on Linux and Windows (mpv) and
2072    /// Android (ExoPlayer); only an unported desktop uses the webview.
2073    ///
2074    /// Video has no counterpart: it is always drawn by the native backend, behind
2075    /// the transparent webview (DR-235) — there is no webview video renderer
2076    /// left to report.
2077    pub uses_webview_audio: bool,
2078}
2079
2080/// Report this platform's playback capabilities to the frontend.
2081///
2082/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
2083#[tauri::command]
2084#[specta::specta]
2085pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
2086    // Mirrors the cfg gates the backends themselves are built under: mpv on
2087    // Linux and Windows (DR-237), ExoPlayer on Android.
2088    let native_audio = cfg!(any(
2089        target_os = "android",
2090        target_os = "linux",
2091        target_os = "windows"
2092    ));
2093
2094    Ok(PlaybackCapabilities {
2095        uses_webview_audio: !native_audio,
2096    })
2097}
2098
2099pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
2100    PlayerStatus {
2101        state: controller.state(),
2102        // The position on the item's timeline, whichever path is rendering it —
2103        // the native backend reads 0 for a handoff that has not ticked yet.
2104        // TRACES: UR-005 | DR-178
2105        position: controller.absolute_position(),
2106        duration: controller.duration(),
2107        volume: controller.volume(),
2108        muted: controller.muted(),
2109        shuffle: controller.is_shuffle(),
2110        repeat: controller.repeat_mode(),
2111
2112        // Merged fields initialized to defaults (will be set by player_get_status)
2113        merged_media: None,
2114        merged_is_playing: false,
2115        merged_volume: controller.volume(),
2116    }
2117}
2118
2119pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
2120    let queue = controller.queue();
2121    let queue_lock = queue.lock_safe();
2122
2123    QueueStatus {
2124        items: queue_lock.items().to_vec(),
2125        current_index: queue_lock.current_index(),
2126        shuffle: queue_lock.is_shuffle(),
2127        repeat: queue_lock.repeat_mode(),
2128        has_next: queue_lock.has_next(),
2129        has_previous: queue_lock.has_previous(),
2130    }
2131}
2132
2133/// Start a freshly-built queue on the active remote session.
2134///
2135/// Used by the "play tracks"/"play album track" commands when we're in remote
2136/// mode: instead of starting local MPV playback, we cast the selected tracks to
2137/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
2138/// play_on_session call, but for a brand-new selection (so there's no resume
2139/// position - playback starts from the chosen track's beginning).
2140///
2141/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
2142/// the start index is adjusted to the remaining Jellyfin items. Returns an error
2143/// if the selected track itself has no Jellyfin ID.
2144async fn play_selection_on_remote(
2145    controller: &PlayerController,
2146    session_id: &str,
2147    media_items: &[MediaItem],
2148    start_index: usize,
2149) -> Result<(), String> {
2150    // Collect Jellyfin IDs, tracking where the selected track lands after any
2151    // local-only items are dropped.
2152    let mut jellyfin_ids: Vec<String> = Vec::new();
2153    let mut adjusted_index: Option<usize> = None;
2154    for (i, item) in media_items.iter().enumerate() {
2155        if let Some(id) = item.jellyfin_id() {
2156            if i == start_index {
2157                adjusted_index = Some(jellyfin_ids.len());
2158            }
2159            jellyfin_ids.push(id.to_string());
2160        }
2161    }
2162
2163    let start_index =
2164        adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
2165
2166    if jellyfin_ids.is_empty() {
2167        return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
2168    }
2169
2170    let client = {
2171        let client_arc = controller.jellyfin_client();
2172        let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
2173        client_opt
2174            .as_ref()
2175            .ok_or("Jellyfin client not configured")?
2176            .clone()
2177    };
2178
2179    // Fresh selection: start from the beginning of the chosen track.
2180    client
2181        .play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
2182        .await
2183        .map_err(|e| format!("Failed to start playback on remote session: {}", e))
2184}
2185
2186/// Play a track from an album - backend fetches all album tracks and builds queue
2187#[tauri::command]
2188#[specta::specta]
2189pub async fn player_play_album_track(
2190    player: State<'_, PlayerStateWrapper>,
2191    session: State<'_, MediaSessionManagerWrapper>,
2192    db: State<'_, DatabaseWrapper>,
2193    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2194    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2195    repository_handle: String,
2196    request: PlayAlbumTrackRequest,
2197) -> Result<PlayerStatus, String> {
2198    info!(
2199        "player_play_album_track called: album_id={}, track_id={}, shuffle={}",
2200        request.album_id, request.track_id, request.shuffle
2201    );
2202
2203    // Get repository (hybrid - supports offline/online)
2204    let repository = repository_manager
2205        .0
2206        .get(&repository_handle)
2207        .ok_or("Repository not found - user may need to log in")?;
2208
2209    // Fetch all tracks from the album via hybrid repository
2210    info!(
2211        "Fetching tracks for album {} via repository",
2212        request.album_id
2213    );
2214    let album_items = repository
2215        .get_items(
2216            &request.album_id,
2217            Some(GetItemsOptions {
2218                limit: Some(1000),
2219                fields: Some(vec![
2220                    "PrimaryImageAspectRatio".to_string(),
2221                    "Overview".to_string(),
2222                    "MediaStreams".to_string(),
2223                ]),
2224                ..Default::default()
2225            }),
2226        )
2227        .await
2228        .map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
2229
2230    info!("Found {} items in album", album_items.items.len());
2231
2232    // Filter to only Audio items and sort by index
2233    let mut tracks: Vec<_> = album_items
2234        .items
2235        .into_iter()
2236        .filter(|item| item.item_type == "Audio")
2237        .collect();
2238    tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
2239
2240    info!("Found {} audio tracks in album", tracks.len());
2241
2242    if tracks.is_empty() {
2243        return Err("No audio tracks found in album".to_string());
2244    }
2245
2246    // Debug: Log all track IDs and their indices
2247    info!("Album has {} tracks after sorting:", tracks.len());
2248    for (idx, track) in tracks.iter().enumerate() {
2249        info!("  [{}] {} (ID: {})", idx, track.name, track.id);
2250    }
2251
2252    // Validate the requested track exists in the album (its position in the
2253    // final queue is computed after building, since offline tracks are skipped).
2254    info!("Looking for track_id: {}", request.track_id);
2255    let album_index = tracks
2256        .iter()
2257        .position(|t| t.id == request.track_id)
2258        .ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
2259
2260    info!(
2261        "Track {} is at index {} in album",
2262        request.track_id, album_index
2263    );
2264
2265    // Convert tracks to MediaItems
2266    let mut media_items = Vec::new();
2267    for track in tracks {
2268        // Check for local download first
2269        let jellyfin_id = &track.id;
2270        let local_path = check_for_local_download(&db, jellyfin_id).await?;
2271
2272        let source = if let Some(path) = local_path {
2273            MediaSource::Local {
2274                file_path: PathBuf::from(path),
2275                jellyfin_item_id: Some(jellyfin_id.clone()),
2276            }
2277        } else {
2278            // Non-downloaded track: needs a stream URL from the server. When the
2279            // server is unreachable (offline), skip this track rather than failing
2280            // the whole album — downloaded tracks must still be playable.
2281            match repository.get_audio_stream_url(&track.id).await {
2282                Ok(stream_url) => MediaSource::Remote {
2283                    stream_url,
2284                    jellyfin_item_id: jellyfin_id.clone(),
2285                },
2286                Err(e) => {
2287                    warn!(
2288                        "[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
2289                        track.name, track.id, e
2290                    );
2291                    continue;
2292                }
2293            }
2294        };
2295
2296        let primary_image_tag_for_url = track.primary_image_tag.clone();
2297        let media_item = MediaItem {
2298            // Audio and direct-URL items never negotiate a transport.
2299            transport: None,
2300            id: track.id.clone(),
2301            title: track.name.clone(),
2302            name: Some(track.name.clone()), // Frontend compatibility
2303            artist: track
2304                .album_artist
2305                .clone()
2306                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2307            album: Some(request.album_name.clone()),
2308            album_name: Some(request.album_name.clone()), // Frontend compatibility
2309            album_id: Some(request.album_id.clone()),
2310            artist_items: track.artist_items.clone(), // For clickable artist links
2311            artists: track.artists.clone(),           // Fallback artist info
2312            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2313            image_id: track.primary_image_tag.clone(),
2314            item_type: Some(track.item_type.clone()), // Frontend compatibility
2315            playlist_id: None,
2316            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2317            artwork_url: primary_image_tag_for_url.map(|tag| {
2318                repository.get_image_url(
2319                    &request.album_id,
2320                    ImageType::Primary,
2321                    Some(ImageOptions {
2322                        max_width: Some(300),
2323                        tag: Some(tag),
2324                        ..Default::default()
2325                    }),
2326                )
2327            }),
2328            media_type: MediaType::Audio,
2329            source,
2330            video_codec: None,
2331            needs_transcoding: false,
2332            video_width: None,
2333            video_height: None,
2334            subtitles: vec![],
2335            series_id: None,
2336            server_id: None,
2337        };
2338
2339        media_items.push(media_item);
2340    }
2341
2342    if media_items.is_empty() {
2343        return Err("No playable tracks available (offline and nothing downloaded)".to_string());
2344    }
2345
2346    // Tracks with no local download and no reachable server were skipped above,
2347    // so positions shifted. Re-locate the requested track in the built queue.
2348    // If the tapped track itself was skipped, fall back to the first item.
2349    let start_index = media_items
2350        .iter()
2351        .position(|item| item.id == request.track_id)
2352        .unwrap_or(0);
2353
2354    info!(
2355        "Built queue with {} media items, starting at index {}",
2356        media_items.len(),
2357        start_index
2358    );
2359
2360    // Handle shuffle before setting queue
2361    if request.shuffle {
2362        let controller = player.0.lock().await;
2363        controller.toggle_shuffle();
2364    }
2365
2366    // Start audio session with the first item
2367    if let Some(first_item) = media_items.get(start_index) {
2368        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2369        session_mgr.start_audio_session(first_item.clone());
2370    }
2371
2372    let controller = player.0.lock().await;
2373
2374    // When controlling a remote session, cast the selection there instead of
2375    // starting local MPV playback. We still load the queue locally (below) so
2376    // the queue/context stay in sync for the UI and for transferring back.
2377    let remote_session = match playback_mode.0.get_mode() {
2378        crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
2379        _ => None,
2380    };
2381
2382    if let Some(session_id) = &remote_session {
2383        play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
2384        controller
2385            .set_queue(media_items, start_index)
2386            .map_err(|e| e.to_string())?;
2387    } else {
2388        // Local playback is now authoritative (see player_play_tracks); set it
2389        // before starting so the mode-changed event precedes the state events.
2390        playback_mode
2391            .0
2392            .set_mode(crate::playback_mode::PlaybackMode::Local);
2393        controller
2394            .play_queue(media_items, start_index)
2395            .map_err(|e| e.to_string())?;
2396    }
2397
2398    // Set the queue context for remote transfer
2399    {
2400        let queue_arc = controller.queue();
2401        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2402        queue.set_context(QueueContext::Album {
2403            album_id: request.album_id.clone(),
2404            album_name: request.album_name.clone(),
2405        });
2406    }
2407
2408    // Emit queue changed event
2409    controller.emit_queue_changed();
2410
2411    // Log final queue state
2412    {
2413        let queue_arc = controller.queue();
2414        let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
2415        info!(
2416            "player_play_album_track: Queue now has {} items, current_index: {:?}",
2417            queue_lock.items().len(),
2418            queue_lock.current_index()
2419        );
2420    }
2421
2422    // Emit session changed event
2423    if let Some(emitter) = controller.event_emitter() {
2424        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2425        emitter.emit(PlayerStatusEvent::SessionChanged {
2426            session: session_mgr.current().clone(),
2427        });
2428    }
2429
2430    Ok(get_player_status(&controller))
2431}
2432
2433/// Play tracks by ID - backend fetches all metadata
2434#[tauri::command]
2435#[specta::specta]
2436pub async fn player_play_tracks(
2437    player: State<'_, PlayerStateWrapper>,
2438    session: State<'_, MediaSessionManagerWrapper>,
2439    db: State<'_, DatabaseWrapper>,
2440    repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
2441    playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
2442    repository_handle: String,
2443    request: PlayTracksRequest,
2444) -> Result<PlayerStatus, String> {
2445    // A ceiling chosen from the in-player picker belongs to the playback it was
2446    // chosen for. Starting a different item returns to the device default —
2447    // otherwise "2 Mbps, just for this one film" quietly governs the rest of the
2448    // session, which is the defect DR-226 exists to close.
2449    //
2450    // TRACES: UR-074, UR-079 | DR-226
2451    crate::repository::online::clear_playback_quality_override();
2452
2453    info!(
2454        "player_play_tracks called: {} tracks, start_index={}, shuffle={}",
2455        request.track_ids.len(),
2456        request.start_index,
2457        request.shuffle
2458    );
2459
2460    // Validate input
2461    if request.track_ids.is_empty() {
2462        return Err("No tracks provided".to_string());
2463    }
2464
2465    // Get repository
2466    let repository = repository_manager
2467        .0
2468        .get(&repository_handle)
2469        .ok_or("Repository not found - user may need to log in")?;
2470
2471    // Fetch metadata for all tracks
2472    let mut media_items = Vec::new();
2473    for track_id in &request.track_ids {
2474        // Fetch track metadata from repository
2475        let track = repository
2476            .get_item(track_id)
2477            .await
2478            .map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
2479
2480        // Check for local download
2481        let local_path = check_for_local_download(&db, track_id).await?;
2482
2483        // Build MediaSource
2484        let source = if let Some(path) = local_path {
2485            MediaSource::Local {
2486                file_path: PathBuf::from(path),
2487                jellyfin_item_id: Some(track.id.clone()),
2488            }
2489        } else {
2490            let stream_url = repository
2491                .get_audio_stream_url(track_id)
2492                .await
2493                .map_err(|e| format!("Failed to get stream URL: {}", e))?;
2494
2495            MediaSource::Remote {
2496                stream_url,
2497                jellyfin_item_id: track.id.clone(),
2498            }
2499        };
2500
2501        // Transform to MediaItem with frontend-compatible fields
2502        let primary_image_tag_for_url = track.primary_image_tag.clone();
2503        let media_item = MediaItem {
2504            // Audio and direct-URL items never negotiate a transport.
2505            transport: None,
2506            id: track.id.clone(),
2507            title: track.name.clone(),
2508            name: Some(track.name.clone()), // Frontend compatibility
2509            artist: track
2510                .album_artist
2511                .clone()
2512                .or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
2513            album: track.album_name.clone(),
2514            album_name: track.album_name.clone(), // Frontend compatibility
2515            album_id: track.album_id.clone(),
2516            artist_items: track.artist_items.clone(), // For clickable artist links
2517            artists: track.artists.clone(),           // Fallback artist info
2518            primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
2519            image_id: track.primary_image_tag.clone(),
2520            item_type: Some(track.item_type.clone()), // Frontend compatibility
2521            playlist_id: None,                        // Set based on context below
2522            duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
2523            artwork_url: primary_image_tag_for_url.and_then(|tag| {
2524                track.album_id.as_ref().map(|album_id| {
2525                    repository.get_image_url(
2526                        album_id,
2527                        ImageType::Primary,
2528                        Some(ImageOptions {
2529                            max_width: Some(300),
2530                            tag: Some(tag),
2531                            ..Default::default()
2532                        }),
2533                    )
2534                })
2535            }),
2536            media_type: MediaType::Audio,
2537            source,
2538            video_codec: None,
2539            needs_transcoding: false,
2540            video_width: None,
2541            video_height: None,
2542            subtitles: vec![],
2543            series_id: None,
2544            server_id: None,
2545        };
2546
2547        media_items.push(media_item);
2548    }
2549
2550    info!("Built queue with {} media items", media_items.len());
2551
2552    // Map context and set playlist_id
2553    let queue_context = match request.context {
2554        PlayTracksContext::Playlist {
2555            playlist_id,
2556            playlist_name,
2557        } => {
2558            for item in &mut media_items {
2559                item.playlist_id = Some(playlist_id.clone());
2560            }
2561            QueueContext::Playlist {
2562                playlist_id,
2563                playlist_name,
2564            }
2565        }
2566        PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
2567    };
2568
2569    // Handle shuffle
2570    if request.shuffle {
2571        let controller = player.0.lock().await;
2572        controller.toggle_shuffle();
2573    }
2574
2575    // Start session
2576    if let Some(first_item) = media_items.get(request.start_index) {
2577        let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2578        session_mgr.start_audio_session(first_item.clone());
2579    }
2580
2581    let controller = player.0.lock().await;
2582
2583    // When controlling a remote session, cast the selection there instead of
2584    // starting local MPV playback. Skip this while a transfer is in flight: the
2585    // transfer-to-local path calls this command to load the queue locally and
2586    // the mode is still Remote until the transfer completes - routing it back to
2587    // the remote would undo the transfer.
2588    let remote_session = match playback_mode.0.get_mode() {
2589        crate::playback_mode::PlaybackMode::Remote { session_id }
2590            if !playback_mode.0.is_transferring() =>
2591        {
2592            Some(session_id)
2593        }
2594        _ => None,
2595    };
2596
2597    if let Some(session_id) = &remote_session {
2598        play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
2599            .await?;
2600        controller
2601            .set_queue(media_items, request.start_index)
2602            .map_err(|e| e.to_string())?;
2603    } else {
2604        // Starting local playback makes Local the authoritative mode. Without
2605        // this, a prior Remote mode lingers in the manager and later play/pause
2606        // commands route back to the (stopped) remote session. Set it BEFORE
2607        // starting playback so the PlaybackModeChanged event reaches the frontend
2608        // ahead of the state_changed events it will emit — otherwise the frontend
2609        // (still thinking it's remote) filters those state events out. Skip during
2610        // a transfer: transfer_to_local drives the mode itself once complete.
2611        if !playback_mode.0.is_transferring() {
2612            playback_mode
2613                .0
2614                .set_mode(crate::playback_mode::PlaybackMode::Local);
2615        }
2616        controller
2617            .play_queue_from(media_items, request.start_index, request.start_position)
2618            .map_err(|e| e.to_string())?;
2619    }
2620
2621    // Set queue context
2622    {
2623        let queue_arc = controller.queue();
2624        let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
2625        queue.set_context(queue_context);
2626    }
2627
2628    // Emit events
2629    controller.emit_queue_changed();
2630    if let Some(emitter) = controller.event_emitter() {
2631        let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
2632        emitter.emit(PlayerStatusEvent::SessionChanged {
2633            session: session_mgr.current().clone(),
2634        });
2635    }
2636
2637    info!("player_play_tracks completed successfully");
2638    Ok(get_player_status(&controller))
2639}
2640
2641/// Response for preload operation
2642#[derive(specta::Type, Debug, Serialize)]
2643#[serde(rename_all = "camelCase")]
2644pub struct PreloadResult {
2645    /// Number of tracks queued for preload
2646    pub queued_count: usize,
2647    /// Number of tracks already downloaded
2648    pub already_downloaded: usize,
2649    /// Number of tracks skipped (no jellyfin ID or other reasons)
2650    pub skipped: usize,
2651}
2652
2653/// Preload upcoming tracks from the queue
2654/// This queues background downloads for the next N tracks that aren't already downloaded
2655#[tauri::command]
2656#[specta::specta]
2657pub async fn player_preload_upcoming(
2658    player: State<'_, PlayerStateWrapper>,
2659    db: State<'_, DatabaseWrapper>,
2660    smart_cache: State<'_, SmartCacheWrapper>,
2661    download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
2662    app: tauri::AppHandle,
2663    user_id: String,
2664    _download_base_path: String,
2665) -> Result<PreloadResult, String> {
2666    // The pump only starts rows that carry both a stream URL and a target dir,
2667    // so resolve the same storage root the user-initiated download paths use
2668    // (storage_get_path = the database's parent directory).
2669    let (db_service, target_dir) = {
2670        let database = db.0.lock().map_err(|e| e.to_string())?;
2671        let target_dir = database
2672            .path()
2673            .parent()
2674            .ok_or_else(|| "Database path has no parent directory".to_string())?
2675            .to_string_lossy()
2676            .to_string();
2677        (Arc::new(database.service()), target_dir)
2678    };
2679
2680    // Get cache settings
2681    let precache_count = {
2682        let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2683        if !cache.should_precache_queue() {
2684            return Ok(PreloadResult {
2685                queued_count: 0,
2686                already_downloaded: 0,
2687                skipped: 0,
2688            });
2689        }
2690        cache.queue_precache_count()
2691    };
2692
2693    // Get upcoming items from queue
2694    let upcoming_items: Vec<MediaItem> = {
2695        let controller = player.0.lock().await;
2696        let queue = controller.queue();
2697        let queue_lock = queue.lock().map_err(|e| e.to_string())?;
2698        queue_lock
2699            .get_upcoming(precache_count)
2700            .into_iter()
2701            .cloned()
2702            .collect()
2703    };
2704
2705    if upcoming_items.is_empty() {
2706        return Ok(PreloadResult {
2707            queued_count: 0,
2708            already_downloaded: 0,
2709            skipped: 0,
2710        });
2711    }
2712
2713    let mut queued_count = 0;
2714    let mut already_downloaded = 0;
2715    let mut skipped = 0;
2716
2717    // Process each upcoming item
2718    for item in upcoming_items {
2719        // Only process items with Remote source (not already local). The
2720        // source already carries the resolved stream URL — reuse it so the
2721        // pump can start the download without any extra resolution step.
2722        let (jellyfin_id, stream_url) = match &item.source {
2723            MediaSource::Remote {
2724                jellyfin_item_id,
2725                stream_url,
2726            } => (jellyfin_item_id.clone(), stream_url.clone()),
2727            MediaSource::Local { .. } => {
2728                already_downloaded += 1;
2729                continue;
2730            }
2731            MediaSource::DirectUrl { .. } => {
2732                skipped += 1;
2733                continue;
2734            }
2735        };
2736
2737        // Check if already downloaded or actively in flight. Stale pending rows
2738        // without a stream URL are NOT skipped here — the upsert below heals
2739        // them so the pump can finally start them.
2740        let query = Query::with_params(
2741            "SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
2742             AND (status IN ('completed', 'downloading')
2743                  OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
2744            vec![
2745                QueryParam::String(jellyfin_id.clone()),
2746                QueryParam::String(user_id.clone()),
2747            ],
2748        );
2749
2750        let is_downloaded: Option<String> = db_service
2751            .query_optional(query, |row| row.get(0))
2752            .await
2753            .map_err(|e| e.to_string())?;
2754
2755        if is_downloaded.is_some() {
2756            already_downloaded += 1;
2757            continue;
2758        }
2759
2760        // Queue for download with low priority (preload priority = -100) so
2761        // user-initiated downloads always win a pump slot first.
2762        let album_dir = item
2763            .album
2764            .as_deref()
2765            .filter(|a| !a.is_empty())
2766            .unwrap_or("Unknown Album");
2767        let file_path = format!(
2768            "downloads/{}/{}.mp3",
2769            sanitize_filename(album_dir),
2770            sanitize_filename(&item.title)
2771        );
2772
2773        // Insert with the stream URL + target dir the pump needs to start it.
2774        // On conflict, heal pre-existing rows that were queued without a URL
2775        // (they could never start) instead of leaving them stuck.
2776        let insert_query = Query::with_params(
2777            "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)
2778             VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
2779             ON CONFLICT(item_id, user_id) DO UPDATE SET
2780                 stream_url = excluded.stream_url,
2781                 target_dir = excluded.target_dir
2782             WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
2783            vec![
2784                QueryParam::String(jellyfin_id),
2785                QueryParam::String(user_id.clone()),
2786                QueryParam::String(file_path),
2787                QueryParam::String(item.title.clone()),
2788                item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2789                item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
2790                QueryParam::String(stream_url),
2791                QueryParam::String(target_dir.clone()),
2792            ],
2793        );
2794
2795        match db_service.execute(insert_query).await {
2796            Ok(rows) if rows > 0 => {
2797                info!(
2798                    "[Preload] Queued download for: {} - {}",
2799                    item.artist.as_deref().unwrap_or("Unknown"),
2800                    item.title
2801                );
2802                queued_count += 1;
2803            }
2804            Ok(_) => {
2805                // Row already exists (conflict), count as already queued
2806                already_downloaded += 1;
2807            }
2808            Err(e) => {
2809                error!("[Preload] Failed to queue {}: {}", item.title, e);
2810                skipped += 1;
2811            }
2812        }
2813    }
2814
2815    // Kick the pump so the queued preloads actually start; without this they'd
2816    // only begin once some other download activity pumps the queue.
2817    if queued_count > 0 {
2818        let active_downloads = {
2819            let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2820            manager.get_active_downloads()
2821        };
2822        crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
2823    }
2824
2825    info!(
2826        "[Preload] Result: queued={}, already_downloaded={}, skipped={}",
2827        queued_count, already_downloaded, skipped
2828    );
2829
2830    Ok(PreloadResult {
2831        queued_count,
2832        already_downloaded,
2833        skipped,
2834    })
2835}
2836
2837/// Sanitize filename by removing invalid characters
2838fn sanitize_filename(name: &str) -> String {
2839    name.chars()
2840        .map(|c| match c {
2841            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
2842            _ => c,
2843        })
2844        .collect()
2845}
2846
2847/// Update SmartCache configuration
2848#[tauri::command]
2849#[specta::specta]
2850pub async fn player_set_cache_config(
2851    smart_cache: State<'_, SmartCacheWrapper>,
2852    config: CacheConfig,
2853) -> Result<(), String> {
2854    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2855    cache.update_config(config);
2856    Ok(())
2857}
2858
2859/// Get current SmartCache configuration
2860#[tauri::command]
2861#[specta::specta]
2862pub async fn player_get_cache_config(
2863    smart_cache: State<'_, SmartCacheWrapper>,
2864) -> Result<CacheConfig, String> {
2865    let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
2866    Ok(cache.get_config().unwrap_or_default())
2867}
2868
2869/// Configure Jellyfin API client for automatic playback reporting
2870#[tauri::command]
2871#[specta::specta]
2872pub async fn player_configure_jellyfin(
2873    player: State<'_, PlayerStateWrapper>,
2874    db: State<'_, DatabaseWrapper>,
2875    server_url: String,
2876    access_token: String,
2877    user_id: String,
2878    device_id: String,
2879) -> Result<(), String> {
2880    log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
2881
2882    let config = JellyfinConfig {
2883        server_url,
2884        access_token,
2885        device_id,
2886    };
2887
2888    // Legacy client (used for remote session control / casting).
2889    let client = JellyfinClient::new(config.clone())?;
2890
2891    // Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
2892    // actually report through. Without this, Start/Progress/Stopped never reach
2893    // Jellyfin, so playback position never syncs and you can't resume on another
2894    // device. The reporter shares the player controller's Arc, so populating it
2895    // here lights up reporting on both desktop and Android, on every auth path
2896    // that configures the player (login / restore / reauth).
2897    let db_service = {
2898        let database = db.0.lock().map_err(|e| e.to_string())?;
2899        Arc::new(database.service())
2900    };
2901    let reporter_client = JellyfinClient::new(config)?;
2902    let reporter = crate::playback_reporting::PlaybackReporter::new(
2903        db_service,
2904        Arc::new(TokioMutex::new(Some(reporter_client))),
2905        user_id,
2906    );
2907
2908    let controller = player.0.lock().await;
2909    controller.set_jellyfin_client(Some(client));
2910    controller.set_playback_reporter(Some(reporter)).await;
2911
2912    log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
2913    Ok(())
2914}
2915
2916/// Disable Jellyfin automatic playback reporting
2917#[tauri::command]
2918#[specta::specta]
2919pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
2920    log::info!("[PlayerCommand] Disabling Jellyfin client");
2921
2922    let controller = player.0.lock().await;
2923    controller.set_jellyfin_client(None);
2924    controller.set_playback_reporter(None).await;
2925
2926    log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
2927    Ok(())
2928}
2929
2930#[cfg(test)]
2931mod tests {
2932    use crate::utils::lock::MutexSafe;
2933
2934    /// Video always goes to the backend. `player_play_item` once decided per
2935    /// platform whether the backend or the webview's `<video>` would render,
2936    /// and each wrong answer was silence (Linux, once mpv drew the picture) or a
2937    /// soundtrack decoded twice (Windows). With the webview video path deleted
2938    /// there is no second renderer to route to, and the queue-only branch is
2939    /// gone with it.
2940    ///
2941    /// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
2942    #[test]
2943    fn test_video_always_goes_to_the_backend() {
2944        let src = include_str!("mod.rs");
2945        let play_item = src
2946            .split("pub async fn player_play_item(")
2947            .nth(1)
2948            .and_then(|rest| rest.split("\n}\n").next())
2949            .expect("player_play_item exists");
2950        assert!(play_item.contains(".play_item(media_item)"));
2951        assert!(
2952            !play_item.contains(".set_current_item("),
2953            "player_play_item must not keep video from the backend"
2954        );
2955    }
2956
2957    /// Only audio can still be the webview's, and only on a desktop with no mpv.
2958    ///
2959    /// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
2960    #[tokio::test]
2961    async fn test_every_shipped_platform_plays_audio_natively() {
2962        let caps = super::player_get_capabilities().await.unwrap();
2963        if cfg!(any(
2964            target_os = "linux",
2965            target_os = "windows",
2966            target_os = "android"
2967        )) {
2968            assert!(!caps.uses_webview_audio);
2969        }
2970    }
2971
2972    /// UT-206 — the volume the command hands on is always a real number in
2973    /// 0.0..=1.0.
2974    ///
2975    /// Every backend clamps for itself, but the remote branch of
2976    /// `player_set_volume` reaches no backend at all: it does
2977    /// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
2978    /// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
2979    /// not enough — it has to be tested for.
2980    ///
2981    /// TRACES: DR-212 | UT-206
2982    #[test]
2983    fn test_normalize_volume_clamps_and_rejects_nan() {
2984        use super::normalize_volume;
2985
2986        // In-range values pass through untouched.
2987        assert_eq!(normalize_volume(0.0), 0.0);
2988        assert_eq!(normalize_volume(0.5), 0.5);
2989        assert_eq!(normalize_volume(1.0), 1.0);
2990
2991        // Out of range clamps to the same 0.0..=1.0 the backends use.
2992        assert_eq!(normalize_volume(-0.5), 0.0);
2993        assert_eq!(normalize_volume(42.0), 1.0);
2994        assert_eq!(normalize_volume(f32::INFINITY), 1.0);
2995        assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
2996
2997        // NaN is not a volume; it must not reach the Jellyfin percentage
2998        // conversion or a backend.
2999        let from_nan = normalize_volume(f32::NAN);
3000        assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
3001        assert_eq!(from_nan, 0.0);
3002
3003        // Whatever comes out survives the remote branch's percentage cast.
3004        for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
3005            let percent = (normalize_volume(input) * 100.0) as i32;
3006            assert!((0..=100).contains(&percent), "input {input} gave {percent}");
3007        }
3008    }
3009
3010    /// The subtitle list the frontend resolved must survive the IPC hop and end
3011    /// up on the `MediaItem` the native backend loads.
3012    ///
3013    /// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
3014    /// then dropped it on the floor — `PlayItemRequest` had no field to put it
3015    /// in — so `create_media_item` always produced `subtitles: vec![]`,
3016    /// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
3017    /// `MediaItem` with zero `SubtitleConfiguration`s. Every later
3018    /// `setSubtitleTrack(n)` then found no text track groups and logged
3019    /// "Invalid subtitle track index".
3020    ///
3021    /// The payload below is exactly what the frontend sends: camelCase for the
3022    /// top-level command params (Tauri v2 converts them), and the subtitle
3023    /// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
3024    ///
3025    /// TRACES: UR-020 | IR-016 | UT-145
3026    #[tokio::test]
3027    async fn test_play_item_request_carries_subtitles_into_media_item() {
3028        use super::{create_media_item, PlayItemRequest};
3029
3030        let payload = serde_json::json!({
3031            "id": "ep-1",
3032            "title": "Pilot",
3033            "streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
3034            "videoCodec": "h264",
3035            "needsTranscoding": false,
3036            "subtitles": [
3037                {
3038                    "index": 2,
3039                    "url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
3040                    "language": "eng",
3041                    "label": "English (SRT)",
3042                    "mime_type": "text/vtt"
3043                },
3044                {
3045                    "index": 3,
3046                    "url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
3047                    "language": null,
3048                    "label": null,
3049                    "mime_type": "text/vtt"
3050                }
3051            ]
3052        });
3053
3054        let req: PlayItemRequest =
3055            serde_json::from_value(payload).expect("frontend payload must deserialize");
3056        assert_eq!(
3057            req.subtitles.len(),
3058            2,
3059            "PlayItemRequest must carry the subtitle tracks, not silently ignore them"
3060        );
3061
3062        let media = create_media_item(req, None).await.unwrap();
3063        assert_eq!(
3064            media.subtitles.len(),
3065            2,
3066            "create_media_item must thread the tracks onto the MediaItem the backend loads"
3067        );
3068        assert_eq!(media.subtitles[0].index, 2);
3069        assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
3070        assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
3071        assert_eq!(media.subtitles[0].mime_type, "text/vtt");
3072        // Order is the contract: `player_set_subtitle_track(n)` is a position in
3073        // this list (see the note on `PlayItemRequest::subtitles`).
3074        assert_eq!(media.subtitles[1].index, 3);
3075        assert!(media.subtitles[1].language.is_none());
3076    }
3077
3078    /// A request without subtitles must still deserialize — the field is
3079    /// defaulted so the background-audio handoff and the autoplay/next-episode
3080    /// callers keep compiling and sending what they always sent.
3081    ///
3082    /// TRACES: UR-020 | IR-016 | UT-145
3083    #[tokio::test]
3084    async fn test_play_item_request_without_subtitles_defaults_to_empty() {
3085        use super::{create_media_item, PlayItemRequest};
3086
3087        let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
3088            "id": "movie-1",
3089            "title": "Movie",
3090            "streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
3091            "videoCodec": "h264",
3092            "needsTranscoding": false
3093        }))
3094        .expect("a subtitle-less payload must still deserialize");
3095
3096        assert!(req.subtitles.is_empty());
3097        assert!(create_media_item(req, None)
3098            .await
3099            .unwrap()
3100            .subtitles
3101            .is_empty());
3102    }
3103
3104    /// The JSON handed to Kotlin over JNI must use the keys
3105    /// `JellyTauPlayer.load()` actually reads.
3106    ///
3107    /// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
3108    /// house IPC rule) is to camelCase nested structs too — but
3109    /// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
3110    /// the field would not fail to compile or fail the IPC; it would silently
3111    /// fall back to the default MIME type for every track, so this is asserted
3112    /// on the exact bytes `android/mod.rs` sends.
3113    ///
3114    /// TRACES: UR-020 | IR-016, JA-008 | UT-146
3115    #[test]
3116    fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
3117        use crate::player::media::SubtitleTrack;
3118
3119        let subtitles = vec![SubtitleTrack {
3120            index: 2,
3121            url: "https://jelly.example/subs.vtt".to_string(),
3122            language: Some("eng".to_string()),
3123            label: Some("English".to_string()),
3124            mime_type: "text/vtt".to_string(),
3125        }];
3126
3127        // Exactly what player/android/mod.rs passes to loadWithMetadata.
3128        let json = serde_json::to_string(&subtitles).unwrap();
3129        let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
3130        let obj = parsed[0].as_object().unwrap();
3131
3132        for key in ["url", "language", "label", "mime_type"] {
3133            assert!(
3134                obj.contains_key(key),
3135                "JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
3136                obj.keys().collect::<Vec<_>>()
3137            );
3138        }
3139        assert!(
3140            !obj.contains_key("mimeType"),
3141            "camelCasing mime_type silently drops every track's MIME type on Android"
3142        );
3143    }
3144
3145    /// The audio-only handoff must play a downloaded file when there is one,
3146    /// rather than fetching an audio-only stream for media already on disk.
3147    ///
3148    /// TRACES: UR-071 | DR-128 | UT-119
3149    #[test]
3150    fn test_background_audio_source_prefers_local_file() {
3151        use super::background_audio_source;
3152        use crate::player::MediaSource;
3153        use std::path::PathBuf;
3154
3155        let local = background_audio_source(
3156            Some("/downloads/ep1.mkv".to_string()),
3157            "https://server/audio-only".to_string(),
3158            "ep-1",
3159        );
3160        match local {
3161            MediaSource::Local {
3162                file_path,
3163                jellyfin_item_id,
3164            } => {
3165                assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
3166                // The Jellyfin id must survive so progress still syncs back.
3167                assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
3168            }
3169            other => panic!("expected a local source, got {:?}", other),
3170        }
3171
3172        let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
3173        match remote {
3174            MediaSource::Remote {
3175                stream_url,
3176                jellyfin_item_id,
3177            } => {
3178                assert_eq!(stream_url, "https://server/audio-only");
3179                assert_eq!(jellyfin_item_id, "ep-1");
3180            }
3181            other => panic!("expected a remote source, got {:?}", other),
3182        }
3183    }
3184
3185    /// The two sources start in different places, so the handoff cannot treat
3186    /// them alike.
3187    ///
3188    /// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
3189    /// the handoff point that stream's zero: the base is the handoff position and
3190    /// seeking would jump past the content. A *downloaded file* has no such
3191    /// parameter — it starts at the episode's own zero — so basing it at the
3192    /// handoff position claims 18 minutes of audio that is about to play from the
3193    /// beginning. That is the downloaded-episode version of "it restarts when the
3194    /// screen sleeps", and it needs the opposite treatment: no base, and a seek.
3195    ///
3196    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3197    #[test]
3198    fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
3199        use super::background_audio_plan;
3200
3201        let local = background_audio_plan(true, 1104.0);
3202        assert_eq!(local.base_seconds, 0.0);
3203        assert_eq!(local.seek_to, Some(1104.0));
3204
3205        let streamed = background_audio_plan(false, 1104.0);
3206        assert_eq!(streamed.base_seconds, 1104.0);
3207        assert_eq!(
3208            streamed.seek_to, None,
3209            "the URL already starts at the handoff point; seeking again skips past it"
3210        );
3211    }
3212
3213    /// Handing off at the very start has nothing to seek to and nothing to base:
3214    /// both sources are already where they need to be.
3215    ///
3216    /// TRACES: UR-040, UR-071 | DR-180 | UT-181
3217    #[test]
3218    fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
3219        use super::background_audio_plan;
3220
3221        for local in [true, false] {
3222            let plan = background_audio_plan(local, 0.0);
3223            assert_eq!(plan.base_seconds, 0.0);
3224            assert_eq!(plan.seek_to, None);
3225        }
3226    }
3227
3228    /// A downloaded item must resolve to its file, and a `downloads` row whose
3229    /// file has gone must resolve to `None` so the caller falls back to
3230    /// streaming instead of handing the player a path that cannot be opened.
3231    ///
3232    /// TRACES: UR-071 | DR-123 | UT-116
3233    #[tokio::test]
3234    async fn test_resolve_local_media_path() {
3235        use super::resolve_local_media_path;
3236        use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
3237        use rusqlite::Connection;
3238        use std::sync::{Arc, Mutex};
3239
3240        let conn = Connection::open_in_memory().unwrap();
3241        conn.execute(
3242            "CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
3243            [],
3244        )
3245        .unwrap();
3246        let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
3247
3248        // A real file on disk, so the existence check passes.
3249        let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
3250        std::fs::write(&present, b"x").unwrap();
3251        let present_str = present.to_string_lossy().to_string();
3252
3253        for (item, status, path) in [
3254            ("downloaded", "completed", present_str.as_str()),
3255            ("still-going", "downloading", present_str.as_str()),
3256            (
3257                "file-gone",
3258                "completed",
3259                "/nonexistent/jellytau/missing.mp4",
3260            ),
3261        ] {
3262            db_service
3263                .execute(Query::with_params(
3264                    "INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
3265                    vec![
3266                        crate::storage::db_service::QueryParam::String(item.to_string()),
3267                        crate::storage::db_service::QueryParam::String(status.to_string()),
3268                        crate::storage::db_service::QueryParam::String(path.to_string()),
3269                    ],
3270                ))
3271                .await
3272                .unwrap();
3273        }
3274
3275        assert_eq!(
3276            resolve_local_media_path(&db_service, "downloaded")
3277                .await
3278                .unwrap()
3279                .as_deref(),
3280            Some(present_str.as_str()),
3281            "a completed download with its file present must resolve"
3282        );
3283
3284        assert_eq!(
3285            resolve_local_media_path(&db_service, "still-going")
3286                .await
3287                .unwrap(),
3288            None,
3289            "an in-progress download is not playable from disk"
3290        );
3291
3292        assert_eq!(
3293            resolve_local_media_path(&db_service, "file-gone")
3294                .await
3295                .unwrap(),
3296            None,
3297            "a row whose file has gone must fall back to streaming, not hand over a dead path"
3298        );
3299
3300        assert_eq!(
3301            resolve_local_media_path(&db_service, "never-heard-of-it")
3302                .await
3303                .unwrap(),
3304            None
3305        );
3306
3307        let _ = std::fs::remove_file(&present);
3308    }
3309
3310    /// Queue items enqueued as Remote must flip to Local once a completed
3311    /// download exists on disk — this is what makes preloaded tracks (and
3312    /// offline playback after a connection drop) actually use the cache.
3313    #[tokio::test]
3314    async fn test_refresh_queue_local_sources_switches_completed_downloads() {
3315        use super::{refresh_queue_local_sources, DatabaseWrapper};
3316        use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
3317        use crate::storage::Database;
3318        use std::sync::Mutex;
3319
3320        // A real file on disk for the completed download; a missing file for
3321        // the second entry to prove nonexistent files are not switched.
3322        let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
3323        std::fs::create_dir_all(&dir).unwrap();
3324        let existing = dir.join("track-a.mp3");
3325        std::fs::write(&existing, b"audio").unwrap();
3326        let missing = dir.join("track-b-missing.mp3");
3327        let _ = std::fs::remove_file(&missing);
3328
3329        let database = Database::open_in_memory().unwrap();
3330        {
3331            let conn = database.connection();
3332            let conn = conn.lock_safe();
3333            conn.execute_batch(&format!(
3334                r#"
3335                INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
3336                INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
3337                INSERT INTO downloads (item_id, user_id, file_path, status)
3338                    VALUES ('track-a', 'user1', '{}', 'completed');
3339                INSERT INTO downloads (item_id, user_id, file_path, status)
3340                    VALUES ('track-b', 'user1', '{}', 'completed');
3341                "#,
3342                existing.display(),
3343                missing.display()
3344            ))
3345            .unwrap();
3346        }
3347        let db = DatabaseWrapper(Mutex::new(database));
3348
3349        let make_item = |id: &str| MediaItem {
3350            // Audio and direct-URL items never negotiate a transport.
3351            transport: None,
3352            id: id.to_string(),
3353            title: id.to_string(),
3354            name: None,
3355            artist: None,
3356            album: None,
3357            album_name: None,
3358            album_id: None,
3359            artist_items: None,
3360            artists: None,
3361            primary_image_tag: None,
3362            image_id: None,
3363            item_type: None,
3364            playlist_id: None,
3365            duration: None,
3366            artwork_url: None,
3367            media_type: MediaType::Audio,
3368            source: MediaSource::Remote {
3369                stream_url: format!("http://test/Audio/{}/stream", id),
3370                jellyfin_item_id: id.to_string(),
3371            },
3372            video_codec: None,
3373            needs_transcoding: false,
3374            video_width: None,
3375            video_height: None,
3376            subtitles: vec![],
3377            series_id: None,
3378            server_id: None,
3379        };
3380
3381        let controller = PlayerController::default();
3382        controller
3383            .set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
3384            .unwrap();
3385
3386        let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
3387        assert_eq!(switched, 1, "only the download whose file exists switches");
3388
3389        let queue = controller.queue();
3390        let queue_lock = queue.lock_safe();
3391        match &queue_lock.items()[0].source {
3392            MediaSource::Local {
3393                file_path,
3394                jellyfin_item_id,
3395            } => {
3396                assert_eq!(file_path, &existing);
3397                assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
3398            }
3399            other => panic!("track-a should be local, got {:?}", other),
3400        }
3401        assert!(
3402            matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
3403            "track-b's file is missing, it must stay remote"
3404        );
3405    }
3406
3407    /// Test track index finding in album
3408    /// This reproduces the bug where clicking songs 1-5 always played song 13
3409    #[test]
3410    fn test_find_track_index_in_album() {
3411        // Create mock album tracks
3412        #[derive(Clone)]
3413        struct MockTrack {
3414            id: String,
3415            name: String,
3416            index_number: Option<i32>,
3417        }
3418
3419        let mut tracks = [
3420            MockTrack {
3421                id: "track1".to_string(),
3422                name: "Song 1".to_string(),
3423                index_number: Some(1),
3424            },
3425            MockTrack {
3426                id: "track2".to_string(),
3427                name: "Song 2".to_string(),
3428                index_number: Some(2),
3429            },
3430            MockTrack {
3431                id: "track3".to_string(),
3432                name: "Song 3".to_string(),
3433                index_number: Some(3),
3434            },
3435            MockTrack {
3436                id: "track4".to_string(),
3437                name: "Song 4".to_string(),
3438                index_number: Some(4),
3439            },
3440            MockTrack {
3441                id: "track5".to_string(),
3442                name: "Song 5".to_string(),
3443                index_number: Some(5),
3444            },
3445        ];
3446
3447        // Sort by index (same as the real code does)
3448        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3449
3450        // Test finding track 1 (should be index 0)
3451        let index1 = tracks.iter().position(|t| t.id == "track1");
3452        assert_eq!(index1, Some(0), "Track 1 should be at index 0");
3453        assert_eq!(
3454            tracks[0].name, "Song 1",
3455            "Track at index 0 should carry its name"
3456        );
3457
3458        // Test finding track 3 (should be index 2)
3459        let index3 = tracks.iter().position(|t| t.id == "track3");
3460        assert_eq!(index3, Some(2), "Track 3 should be at index 2");
3461
3462        // Test finding track 5 (should be index 4)
3463        let index5 = tracks.iter().position(|t| t.id == "track5");
3464        assert_eq!(index5, Some(4), "Track 5 should be at index 4");
3465
3466        // Test finding non-existent track
3467        let index_none = tracks.iter().position(|t| t.id == "nonexistent");
3468        assert_eq!(index_none, None, "Non-existent track should return None");
3469    }
3470
3471    /// Test that track order is preserved when iterating
3472    #[test]
3473    fn test_track_iteration_order() {
3474        // Simulate the loop that builds MediaItems
3475        let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
3476
3477        // Find where "id3" is in the original list
3478        let target_index = track_ids.iter().position(|&id| id == "id3");
3479        assert_eq!(
3480            target_index,
3481            Some(2),
3482            "id3 should be at index 2 in original list"
3483        );
3484
3485        // Simulate building the MediaItems vector
3486        let mut media_items = Vec::new();
3487        for id in &track_ids {
3488            media_items.push(id.to_string());
3489        }
3490
3491        // Verify the order is preserved
3492        assert_eq!(media_items.len(), 5);
3493        assert_eq!(media_items[0], "id1");
3494        assert_eq!(media_items[2], "id3");
3495        assert_eq!(media_items[4], "id5");
3496
3497        // The start_index found earlier should still be valid
3498        assert_eq!(media_items[target_index.unwrap()], "id3");
3499    }
3500
3501    /// Test album track sorting behavior
3502    #[test]
3503    fn test_album_track_sorting() {
3504        #[derive(Clone, Debug)]
3505        struct MockTrack {
3506            id: String,
3507            name: String,
3508            index_number: Option<i32>,
3509        }
3510
3511        // Create tracks in random order (not sorted)
3512        let mut tracks = [
3513            MockTrack {
3514                id: "id5".to_string(),
3515                name: "Track 5".to_string(),
3516                index_number: Some(5),
3517            },
3518            MockTrack {
3519                id: "id1".to_string(),
3520                name: "Track 1".to_string(),
3521                index_number: Some(1),
3522            },
3523            MockTrack {
3524                id: "id3".to_string(),
3525                name: "Track 3".to_string(),
3526                index_number: Some(3),
3527            },
3528            MockTrack {
3529                id: "id2".to_string(),
3530                name: "Track 2".to_string(),
3531                index_number: Some(2),
3532            },
3533            MockTrack {
3534                id: "id4".to_string(),
3535                name: "Track 4".to_string(),
3536                index_number: Some(4),
3537            },
3538        ];
3539
3540        // User clicks track "id1" before sorting - what index is it?
3541        let requested_track_id = "id1";
3542
3543        // Sort the tracks (same as real code)
3544        tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
3545
3546        // Now find the index AFTER sorting
3547        let start_index = tracks.iter().position(|t| t.id == requested_track_id);
3548
3549        // Track "id1" should be at index 0 after sorting
3550        assert_eq!(
3551            start_index,
3552            Some(0),
3553            "After sorting, track id1 should be at index 0"
3554        );
3555
3556        // Verify all tracks are in correct order
3557        assert_eq!(tracks[0].id, "id1");
3558        assert_eq!(
3559            tracks[0].name, "Track 1",
3560            "Sorted track should retain its name"
3561        );
3562        assert_eq!(tracks[1].id, "id2");
3563        assert_eq!(tracks[2].id, "id3");
3564        assert_eq!(tracks[3].id, "id4");
3565        assert_eq!(tracks[4].id, "id5");
3566    }
3567}