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