Files
jellytau/src-tauri/src/commands/player/mod.rs
T
dtourolle 4f6cf22419 fix(player): tell the native backend's caller what it actually did
Two defects found by running on an Android tablet, both invisible on the
desktop, and both the same mistake: a rule written for the webview applied to a
backend that is not one.

The quality picker froze on the first stream. `StreamQualityResponse::Native`
carried only a position, so nothing replaced the selection the UI holds after a
native quality change. The picker derives the rung in force from that
selection's rendition, and a transcode always has a rendition — so the fallback
that would have used the requested value was never reached. The stream changed
and the menu did not. The native variant now carries the `StreamSelection` the
backend opened, like the HTML5 variant already did.

This was invisible on the desktop because the webview path replaces the
selection as a side effect of reloading its element. It looked correct there for
a reason that does not generalise.

A quality change restarted playback from zero. The resume position came from
`videoElement.currentTime`, which the frontend cannot supply on a native backend
— there is no `<video>` element, so it correctly sends null and the backend
substituted 0. Reading it from the DOM at all inverts the rule that the player
is the authority on playback state; the fallback now asks the controller where
it is. Captured before the negotiation round-trip, so it resumes a few hundred
milliseconds behind rather than ahead, which is the right direction to err.

Also from the tablet, and NOT fixed here because it changes playback behaviour
and deserves its own change: `audio_forces_transcode` judges against
`WEBVIEW_AUDIO_CODECS` on every platform, and `video_audio_codecs` narrows the
advertised direct-play audio set to that same webview list. On Android the
decoder is ExoPlayer. The tablet reports dts among its platform codecs, has it
stripped from the profile, and then has the webview rule force a transcode for
it. That is the third instance of a decode capability tied to the wrong
renderer, and it is what DR-233 exists to collapse — evidence now, not a design
preference.

It also corrects the record on this branch's headline number. The measured 85%
direct-play rate used a hypothetical Android profile including ac3/eac3; this
tablet's MediaCodecList reports neither, so eac3 content — about a third of the
sampled library — correctly transcodes here. 85% was the ceiling of a profile
the app does not send, on hardware that could not use it. The negotiation and
the contract are sound; the figure was not a measurement of what ships.
2026-08-22 13:45:03 +02:00

3537 lines
132 KiB
Rust

//! 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
// Cohesive command clusters live in their own submodules and are re-exported so
// the command names remain at `commands::player::*` (invoke_handler unchanged).
mod queue;
mod remote;
mod session;
mod settings;
mod timers;
pub use queue::*;
pub use remote::*;
pub use session::*;
pub use settings::*;
pub use timers::*;
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{
determine_video_seek_strategy, MediaItem, MediaSessionManager, MediaSource, MediaType,
PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, VideoSeekStrategy,
};
use crate::repository::{
types::{GetItemsOptions, ImageOptions, ImageType},
MediaRepository, StreamSelection,
};
use crate::settings::VideoSettings;
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
/// SmartCache wrapper for Tauri state management
pub struct SmartCacheWrapper(pub Mutex<SmartCache>);
/// Player state wrapper for Tauri.
///
/// Uses Arc to allow sharing with the MediaSession handler on Android
/// for lockscreen control integration.
///
/// @req: UR-005 - Control media playback
/// @req: DR-001 - Player state machine
pub struct PlayerStateWrapper(pub Arc<TokioMutex<PlayerController>>);
/// Media session manager wrapper for Tauri state management
///
/// @req: DR-009 - Audio player UI (mini player, full screen)
pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// Video settings state wrapper for Tauri
///
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayerStatus {
pub state: PlayerState,
pub position: f64,
pub duration: Option<f64>,
pub volume: f32,
pub muted: bool,
pub shuffle: bool,
pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session
pub merged_media: Option<MergedMediaItem>,
/// Playing state from either local player or remote session
pub merged_is_playing: bool,
/// Volume from either local player or remote session (0-1 normalized)
pub merged_volume: f32,
}
/// Lightweight media item for merged playback state
/// Converts from both local MediaItem and remote NowPlayingItem
#[derive(specta::Type, Debug, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MergedMediaItem {
pub id: String,
pub title: String,
pub artist: Option<String>,
pub album: Option<String>,
pub album_id: Option<String>,
pub duration: Option<f64>,
pub primary_image_tag: Option<String>,
/// Neutral image identifier — replaces `primary_image_tag` (same value).
pub image_id: Option<String>,
pub media_type: String,
}
// Convert from local MediaItem
impl From<&crate::player::MediaItem> for MergedMediaItem {
fn from(item: &crate::player::MediaItem) -> Self {
Self {
id: item.id.clone(),
title: item.title.clone(),
artist: item.artist.clone(),
album: item.album.clone(),
album_id: item.album_id.clone(),
duration: item.duration,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: match item.media_type {
crate::player::MediaType::Audio => "audio".to_string(),
crate::player::MediaType::Video => "video".to_string(),
},
}
}
}
// Convert from remote NowPlayingItem
impl From<&crate::jellyfin::client::NowPlayingItem> for MergedMediaItem {
fn from(item: &crate::jellyfin::client::NowPlayingItem) -> Self {
Self {
id: item.id.clone().unwrap_or_default(),
title: item.name.clone().unwrap_or_else(|| "Unknown".to_string()),
artist: item
.album_artist
.clone()
.or_else(|| item.artists.as_ref().and_then(|a| a.first().cloned())),
album: item.album.clone(),
album_id: item.album_id.clone(),
duration: item.run_time_ticks.map(|ticks| ticks as f64 / 10_000_000.0),
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
media_type: item
.item_type
.clone()
.unwrap_or_else(|| "audio".to_string())
.to_lowercase(),
}
}
}
/// Response for queue queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QueueStatus {
pub items: Vec<MediaItem>,
pub current_index: Option<usize>,
pub shuffle: bool,
pub repeat: RepeatMode,
pub has_next: bool,
pub has_previous: bool,
}
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
/// to avoid Tauri Android serialization issues with complex objects.
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayItemRequest {
pub id: String,
pub title: String,
pub stream_url: String,
/// Video codec (e.g., "h264", "hevc") for video media
pub video_codec: String,
/// Whether the video requires server-side transcoding
pub needs_transcoding: bool,
/// How this item's stream is fetched, as the backend decided it.
///
/// Carried on the queue item so a later seek/reload does not have to guess.
/// `None` for items queued by a path that never negotiated (audio tracks,
/// direct URLs) and for anything queued before this field existed, where the
/// caller falls back to `needs_transcoding` — every transcode this app
/// requests is HLS (DR-140), so that fallback is exact rather than a guess.
///
/// TRACES: UR-003, UR-004, UR-079 | DR-224, DR-229
#[serde(default)]
pub transport: Option<crate::repository::Transport>,
/// Optional now-playing metadata. Used by the background-audio handoff so the
/// lockscreen/miniplayer show the item (title/subtitle/artwork). Defaulted so
/// existing video-only callers need not send them.
#[serde(default)]
pub artist: Option<String>,
#[serde(default)]
pub primary_image_tag: Option<String>,
#[serde(default)]
pub server_id: Option<String>,
/// Total media duration (seconds). Threaded through the background-audio
/// handoff so the lockscreen MediaSession advertises a real duration — a
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
/// background-audio handoff so an episode played as audio-only is still
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
///
/// Only the native backends use these: on Android they become the
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
/// builds its own `<track>` children instead and ignores this list.
///
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
/// *text track groups* — i.e. the position of the sideloaded configuration,
/// not the Jellyfin stream index (which is kept on each entry for the UI's
/// benefit). So `n` must be a position in this very array, and the array
/// must not be reordered or filtered between building it and sending it.
/// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
/// list that is sent here, for exactly this reason.
///
/// Defaulted so the background-audio handoff and the autoplay/next-episode
/// callers, which have no subtitles to offer, need not send the field.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-145
#[serde(default)]
pub subtitles: Vec<crate::player::SubtitleTrack>,
}
/// Queue context for remote transfer - what type of queue is this?
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayQueueContext {
/// Playing from a specific album
Album {
#[serde(rename = "albumId")]
album_id: String,
#[serde(rename = "albumName")]
album_name: String,
},
/// Playing from a specific playlist
Playlist {
#[serde(rename = "playlistId")]
playlist_id: String,
#[serde(rename = "playlistName")]
playlist_name: String,
},
/// Custom queue (search results, manual queue, etc.)
Custom,
}
/// Request to play a queue of items
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayQueueRequest {
pub items: Vec<PlayItemRequest>,
pub start_index: usize,
pub shuffle: bool,
/// Optional context for the queue (album, playlist, or custom)
/// Used for remote playback transfer
#[serde(default)]
pub context: Option<PlayQueueContext>,
}
/// Request to play a track from an album (backend fetches all tracks)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayAlbumTrackRequest {
pub album_id: String,
pub album_name: String,
pub track_id: String,
pub shuffle: bool,
}
/// Request to play tracks by ID (backend fetches metadata)
#[derive(specta::Type, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlayTracksRequest {
pub track_ids: Vec<String>,
pub start_index: usize,
pub shuffle: bool,
pub context: PlayTracksContext,
/// Position (seconds) to resume the starting track from. Used when taking
/// over playback from a remote session so we don't restart from 0.
#[serde(default)]
pub start_position: Option<f64>,
}
/// Context information for track playback
#[derive(specta::Type, Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum PlayTracksContext {
Playlist {
#[serde(rename = "playlistId")]
playlist_id: String,
#[serde(rename = "playlistName")]
playlist_name: String,
},
Search {
#[serde(rename = "searchQuery")]
#[allow(dead_code)] // Used for deserialization, may be used later for analytics
search_query: String,
},
Custom {
#[serde(rename = "label")]
#[allow(dead_code)] // Used for deserialization, may be used later for UI display
label: Option<String>,
},
}
/// Response for video seek operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum VideoSeekResponse {
/// Use native seeking (HLS or direct stream)
Native {
/// Confirmed position after seek
position: f64,
},
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-224
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
}
/// Response for audio track switching operations
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum AudioTrackSwitchResponse {
/// Native backend handled it (Android ExoPlayer)
Native {
/// Confirmation message
success: bool,
},
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-224
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
}
/// Response for a mid-playback streaming-quality change.
///
/// Mirrors [`AudioTrackSwitchResponse`]: the backend decides whether the caller
/// has to reload anything, so no strategy branch lives in the UI.
///
/// TRACES: UR-074 | DR-162
#[derive(specta::Type, Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")]
pub enum StreamQualityResponse {
/// The native backend was reloaded here; nothing left for the frontend to
/// *do* — but it still has to be told what was negotiated.
///
/// This carried only a position at first, which left the picker on Android
/// pinned to the rendition of the *first* stream: the UI derives the rung in
/// force from the selection it holds, nothing replaced that selection on the
/// native path, and a transcode always has a rendition — so the fallback
/// that would have used the requested value was never reached. The stream
/// changed and the menu did not.
///
/// TRACES: UR-074, UR-079 | DR-225, DR-226
Native {
/// What the backend actually opened, so the UI reflects it rather than
/// assuming the request was honoured verbatim.
selection: StreamSelection,
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-224, DR-226
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request
///
/// PlayItemRequest is now video-only, so we create a video MediaItem.
/// Audio playback uses player_play_tracks which fetches full metadata from backend.
pub(super) async fn create_media_item(
req: PlayItemRequest,
db: Option<&DatabaseWrapper>,
) -> Result<MediaItem, String> {
// For video-only requests, we use the item ID as the jellyfin ID
let jellyfin_id = req.id.clone();
// Check if item is downloaded locally
let local_path = if let Some(db_wrapper) = db {
check_for_local_download(db_wrapper, &jellyfin_id).await?
} else {
None
};
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone()),
}
} else {
MediaSource::Remote {
stream_url: req.stream_url,
jellyfin_item_id: jellyfin_id.clone(),
}
};
Ok(MediaItem {
id: req.id.clone(),
title: req.title.clone(),
name: Some(req.title.clone()),
artist: None, // Not available from video-only request
album: None, // Not available from video-only request
album_name: None, // Not available from video-only request
album_id: None, // Not available from video-only request
artist_items: None, // Not available from video-only request
artists: None, // Not available from video-only request
primary_image_tag: None, // Not available from video-only request
image_id: None,
item_type: None, // Not available from video-only request
playlist_id: None, // Not available from video-only request
duration: None, // Not available from video-only request
artwork_url: None, // Not available from video-only request
media_type: crate::player::MediaType::Video, // Video-only request
source,
video_codec: Some(req.video_codec),
needs_transcoding: req.needs_transcoding,
// The caller's negotiated transport, when it had one. TRACES: UR-079 | DR-229
transport: req.transport,
video_width: None, // Not available from video-only request
video_height: None, // Not available from video-only request
// Sideloaded subtitles, in the order the frontend sent them — that order
// is what `player_set_subtitle_track(n)` indexes into on Android.
// TRACES: UR-020 | IR-016 | UT-145
subtitles: req.subtitles,
series_id: None, // Not available from video-only request
server_id: None, // Not available from video-only request
})
}
/// Pick the source for an audio-only handoff.
///
/// A downloaded file wins over the audio-only stream URL. No transcode or audio
/// extraction is involved or wanted: the native backends already play a video
/// container without decoding its video — the Linux MPV backend is configured
/// with `video: no`, and ExoPlayer simply has no surface to render to when the
/// item is `MediaType::Audio`. Producing a separate audio-only file would cost
/// CPU and battery, need an encoder the project does not ship, and leave a
/// second artifact to keep in step with the first.
///
/// TRACES: UR-071 | DR-128 | UT-119
pub(super) fn background_audio_source(
local_path: Option<String>,
stream_url: String,
item_id: &str,
) -> MediaSource {
match local_path {
Some(path) => MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(item_id.to_string()),
},
None => MediaSource::Remote {
stream_url,
jellyfin_item_id: item_id.to_string(),
},
}
}
/// How a background-audio handoff must start playback, given where its audio
/// actually begins.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) struct BackgroundAudioPlan {
/// The position the stream's own zero corresponds to, recorded as the
/// handoff base so later readings can be shifted back to the episode's
/// timeline.
pub base_seconds: f64,
/// Where to seek after loading, if the source does not already start there.
pub seek_to: Option<f64>,
}
/// Decide the base and the seek for a handoff at `position_seconds`.
///
/// The two sources start in different places. An audio-only **stream** is built
/// with `StartTimeTicks`, so the server makes the handoff point that stream's
/// zero: the base is the handoff position, and seeking would skip *past* the
/// content by that much again. A downloaded **file** has no such parameter and
/// begins at the episode's own zero, so it needs the opposite — no base, and a
/// real seek. Treating a file like a stream is why backgrounding a downloaded
/// episode restarted it from 0:00 while the lockscreen showed the right time.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
pub(super) fn background_audio_plan(
is_local_file: bool,
position_seconds: f64,
) -> BackgroundAudioPlan {
let position = position_seconds.max(0.0);
if is_local_file {
BackgroundAudioPlan {
base_seconds: 0.0,
seek_to: (position > 0.0).then_some(position),
}
} else {
BackgroundAudioPlan {
base_seconds: position,
seek_to: None,
}
}
}
/// Resolve the on-disk file backing a completed download, if there is one.
///
/// A `downloads` row is not proof of a file: it can outlive the bytes (manual
/// deletion, a cleared cache directory, a restored database). Every caller wants
/// "can I play this from disk right now", so existence is checked here rather
/// than trusted from the row.
///
/// Split out from [`check_for_local_download`] so the resolution is testable
/// without a `DatabaseWrapper`, and reusable by the video path.
///
/// TRACES: UR-071 | DR-123 | UT-116
pub(super) async fn resolve_local_media_path<S: DatabaseService>(
db_service: &Arc<S>,
item_id: &str,
) -> Result<Option<String>, String> {
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND status = 'completed' LIMIT 1",
vec![QueryParam::String(item_id.to_string())],
);
let path: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
match path {
Some(ref file_path) if std::path::Path::new(file_path).exists() => Ok(path),
Some(file_path) => {
warn!(
"[Player] Download entry exists in DB but file not found: {}",
file_path
);
Ok(None)
}
None => Ok(None),
}
}
/// Check if an item has a completed download
pub(super) async fn check_for_local_download(
db: &DatabaseWrapper,
item_id: &str,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, item_id).await
}
/// The on-disk path for a downloaded item, for playback surfaces that resolve
/// their own source rather than going through the queue.
///
/// The video player is the reason this exists: audio has preferred local files
/// since queue construction, but video asks the repository for a stream URL and
/// never consults `downloads`, so a downloaded film was still streamed — costing
/// bandwidth that had already been spent and failing outright when offline.
///
/// Returns `None` when nothing is downloaded *or* the file is missing, so the
/// caller falls back to streaming.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tauri::command]
#[specta::specta]
pub async fn player_local_media_path(
db: State<'_, DatabaseWrapper>,
item_id: String,
) -> Result<Option<String>, String> {
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
resolve_local_media_path(&db_service, &item_id).await
}
/// Re-point queued streaming items at completed local downloads.
///
/// Sources are resolved once when the queue is built, so downloads that finish
/// while it plays (preloaded upcoming tracks) — or that existed before the
/// connection dropped — would otherwise keep streaming. Called before advancing
/// so the next track always prefers the on-disk copy.
///
/// Returns the number of items switched to a local source.
pub(super) async fn refresh_queue_local_sources(
controller: &PlayerController,
db: &DatabaseWrapper,
) -> Result<usize, String> {
// Collect remote item IDs first; the queue lock must not be held across awaits.
let remote_ids: Vec<String> = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.items()
.iter()
.filter_map(|item| match &item.source {
MediaSource::Remote {
jellyfin_item_id, ..
} => Some(jellyfin_item_id.clone()),
_ => None,
})
.collect()
};
if remote_ids.is_empty() {
return Ok(0);
}
let mut local_paths: Vec<(String, String)> = Vec::new();
for id in remote_ids {
if let Some(path) = check_for_local_download(db, &id).await? {
local_paths.push((id, path));
}
}
if local_paths.is_empty() {
return Ok(0);
}
let queue = controller.queue();
let mut queue_lock = queue.lock().map_err(|e| e.to_string())?;
let mut switched = 0;
for item in queue_lock.items_mut() {
if let MediaSource::Remote {
jellyfin_item_id, ..
} = &item.source
{
if let Some((id, path)) = local_paths.iter().find(|(id, _)| id == jellyfin_item_id) {
info!(
"[Player] Switching queued track {} to local download: {}",
id, path
);
item.source = MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(id.clone()),
};
switched += 1;
}
}
}
Ok(switched)
}
/// Play a single media item (audio or video)
///
/// Accepts a PlayItemRequest with all optional fields properly defaulted.
/// This avoids Tauri's Android serialization issues with complex objects.
///
/// @req: UR-003 - Play videos
/// @req: UR-004 - Play audio uninterrupted
/// @req: UR-005 - Control media playback (play operation)
/// @req: DR-009 - Audio player UI
#[tauri::command]
#[specta::specta]
pub async fn player_play_item(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_item called: {} - {}",
item.title, item.stream_url
);
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
// Create media item, checking for local download first
let media_item = create_media_item(item, Some(&db)).await?;
// Start appropriate session based on media type
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
match media_item.media_type {
MediaType::Audio => {
session_mgr.start_audio_session(media_item.clone());
}
MediaType::Video => {
// For single video items, treat as Movie (no series_id available here)
session_mgr.start_movie_session(media_item.clone());
}
}
}
let controller = player.0.lock().await;
// On Linux, video plays in the WebKitGTK HTML5 <video> element (see
// get_player_status -> use_html5_element). The MPV backend has no embedded
// window, so loading the stream into it would only start a redundant decode
// (and the frontend would immediately stop it). Only load into the native
// backend on platforms that actually render video through it (e.g. Android).
#[cfg(not(target_os = "linux"))]
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
{
// Keep the queue in sync for UI/remote-transfer without starting MPV.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event
controller.emit_queue_changed();
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// Enter background-audio mode: hand playback of the currently-watched video off
/// to the native ExoPlayer *audio* path so the audio keeps playing while the app
/// is backgrounded/locked, with no client-side video decode (UR-040).
///
/// `stream_url` MUST be an audio-only URL (see
/// `get_audio_only_stream_url_for_video`). The item is created as
/// `MediaType::Audio` so it starts an audio session and loads into the native
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
/// frontend side, so exactly one audio source is ever active.
///
/// This deliberately goes through the queue-based `play_item` path (NOT a
/// side-channel) so end-of-track lands in `on_playback_ended`, which already
/// honors the sleep timer (Time/Episodes/EndOfTrack) and drives autoplay-next.
/// The sleep-timer state is intentionally left untouched by the handoff.
///
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
info!(
"player_enter_background_audio: {} @ {:.1}s",
item.title, position_seconds
);
// Prefer the downloaded file over the audio-only stream URL the frontend
// resolved. Handing the native backend a local video container yields
// audio-only playback for free — no transcode, no second artifact.
// TRACES: UR-071 | DR-128
let local_path = check_for_local_download(&db, &item.id).await?;
if local_path.is_some() {
info!(
"player_enter_background_audio: using downloaded file for {}",
item.id
);
}
// A downloaded file starts at the episode's zero; a stream starts at the
// handoff point. Only one of them has a base, and only the other needs a seek.
let plan = background_audio_plan(local_path.is_some(), position_seconds);
let source = background_audio_source(local_path, item.stream_url, &item.id);
// Build an AUDIO media item pointing at the audio-only stream. We do not use
// create_media_item() because that hardcodes MediaType::Video; background
// audio must be Audio so no video decode is started.
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: item.id.clone(),
title: item.title.clone(),
name: Some(item.title.clone()),
artist: item.artist.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
artwork_url: None,
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(media_item.clone());
}
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(plan.base_seconds);
let controller = player.0.lock().await;
// Remember where the video was: for a stream the audio's zero == this
// position (the URL was built with StartTimeTicks=position_seconds), so on
// exit we add this base to the native player's relative position to get the
// absolute one. The controller owns it so a backend-driven advance to the
// next episode clears it along with the stream it described.
controller.enter_background_audio(plan.base_seconds);
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
// Seek ONLY a local file. The audio-only URL already starts at the handoff
// position via StartTimeTicks — its timeline begins at 0 == that point — so
// seeking a stream would jump PAST the content by the handoff position again.
if let Some(seek_to) = plan.seek_to {
info!(
"player_enter_background_audio: seeking the downloaded file to {:.1}s",
seek_to
);
controller.seek(seek_to).map_err(|e| e.to_string())?;
}
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040).
///
/// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and
/// this simply reports the last position.
///
/// What playback should do now that the app is no longer visible.
///
/// The caller supplies only what it alone knows -- whether the per-player
/// toggle is armed, and whether Android put the window into picture-in-picture.
/// Everything else (what is playing, and therefore whether there is a picture to
/// lose) is read here, because it is domain state.
///
/// The rule itself is in `player::background_policy`; this command is the wire.
/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
/// playing there is nothing to pause, and an error would make the frontend
/// handle a case that is not a failure.
///
/// TRACES: UR-040, UR-041 | DR-224 | UT-211
#[tauri::command]
#[specta::specta]
pub async fn player_background_action(
player: State<'_, PlayerStateWrapper>,
background_audio_armed: bool,
in_picture_in_picture: bool,
) -> Result<crate::player::background_policy::BackgroundAction, String> {
use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
let is_video = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
match queue.current() {
Some(item) => is_video_media(item.media_type),
None => return Ok(BackgroundAction::KeepPlaying),
}
};
let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
info!(
"[player_background_action] video={} armed={} pip={} -> {:?}",
is_video, background_audio_armed, in_picture_in_picture, action
);
Ok(action)
}
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
let controller = player.0.lock().await;
// Read the position BEFORE clearing either base. The position tick applies the
// base natively, so a tick landing between "base cleared" and "position read"
// would hand back a relative position — the whole bug, reintroduced at the one
// moment it matters most. Capturing into a `let` before stop() is also the
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159)
//
// `absolute_position` rather than `position`, because a tick that has not
// landed *yet* is the same hazard from the other side: returning to the
// foreground while the audio-only transcode is still opening read 0.0, and
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
// beginning. Flooring at the handoff base cannot overshoot: the stream is
// physically incapable of being behind its own starting point. (DR-178)
let absolute = controller.absolute_position();
// Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0);
controller.exit_background_audio();
controller.stop().map_err(|e| e.to_string())?;
info!(
"player_exit_background_audio: resuming the video at {:.1}s",
absolute
);
Ok(absolute)
}
/// Play a queue of media items
///
/// @req: UR-004 - Play audio uninterrupted
/// @req: UR-005 - Control media playback (queue playback)
/// @req: UR-015 - View and manage current audio queue
/// @req: DR-005 - Queue manager with shuffle, repeat, history
#[tauri::command]
#[specta::specta]
pub async fn player_play_queue(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
request: PlayQueueRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_queue called: {} items, start_index: {}, shuffle: {}",
request.items.len(),
request.start_index,
request.shuffle
);
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
// Handle shuffle first
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Convert request context to internal QueueContext
let queue_context = match request.context {
Some(PlayQueueContext::Album {
album_id,
album_name,
}) => QueueContext::Album {
album_id,
album_name,
},
Some(PlayQueueContext::Playlist {
playlist_id,
playlist_name,
}) => QueueContext::Playlist {
playlist_id,
playlist_name,
},
Some(PlayQueueContext::Custom) | None => QueueContext::Custom,
};
// Create media items, checking for local downloads (must not hold locks during await)
let mut items: Vec<MediaItem> = Vec::new();
for req in request.items {
items.push(create_media_item(req, Some(&db)).await?);
}
// Start appropriate session based on first item's media type
if let Some(first_item) = items.get(request.start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
match first_item.media_type {
MediaType::Audio => {
session_mgr.start_audio_session(first_item.clone());
}
MediaType::Video => {
// Queue of videos treated as movie session (TV episodes use different flow)
session_mgr.start_movie_session(first_item.clone());
}
}
}
// Now play the queue and get status
let controller = player.0.lock().await;
info!(
"player_play_queue: Calling controller.play_queue with {} items at index {}",
items.len(),
request.start_index
);
controller
.play_queue(items, request.start_index)
.map_err(|e| e.to_string())?;
// Set the queue context for remote transfer
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(queue_context);
}
// Emit queue changed event
controller.emit_queue_changed();
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_play(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send play command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Unpause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.play().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_pause(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send pause command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Pause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.pause().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send toggle command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "PlayPause").await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.toggle_playback().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_stop(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send stop command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "Stop").await?;
// Stopping the remote session ends the cast, so the manager returns to
// Idle — same as a local stop. This is also what hands OS volume control
// back to this device: set_mode releases the Android remote volume
// provider on any exit from remote mode. Without it the mode stayed
// Remote and the system volume slider remained stuck on the remote
// session with no way back to the local speaker.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
} else {
// Local playback
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
// A genuine local stop returns the manager to Idle so it no longer
// reports Local (or a stale Remote) — otherwise a later play/pause would
// route to the wrong device.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Idle);
// Handle session state based on type (local playback only)
{
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
let current_session = session_mgr.current().clone();
match current_session {
crate::player::MediaSessionType::Movie { .. } => {
// Movies auto-dismiss when stopped
session_mgr.movie_session_ended();
}
crate::player::MediaSessionType::Audio { .. } => {
// Audio persists as inactive (user can resume later)
session_mgr.audio_session_inactive();
}
crate::player::MediaSessionType::TvShow { .. } => {
// TV shows mark episode ended (waiting for next or dismiss)
session_mgr.tv_session_episode_ended();
}
crate::player::MediaSessionType::Idle => {
// Already idle, no-op
}
}
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_next(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
debug!("[player_next] Command called from frontend");
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send next track command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client.send_session_command(session_id, "NextTrack").await?;
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_next] Failed to refresh local sources: {}", e);
}
controller.next().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
// Update audio session track if in audio session
let current_item = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.current().cloned()
};
if let Some(item) = current_item {
if item.media_type == MediaType::Audio {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.update_audio_track(item);
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_previous(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
db: State<'_, DatabaseWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send previous track command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client
.send_session_command(session_id, "PreviousTrack")
.await?;
} else {
// Local playback
let controller = player.0.lock().await;
// Prefer downloads that completed since the queue was built
if let Err(e) = refresh_queue_local_sources(&controller, &db).await {
warn!("[player_previous] Failed to refresh local sources: {}", e);
}
controller.previous().map_err(|e| e.to_string())?;
controller.emit_queue_changed();
// Update audio session track if in audio session
let current_item = {
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock.current().cloned()
};
if let Some(item) = current_item {
if item.media_type == MediaType::Audio {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.update_audio_track(item);
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
}
}
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_seek(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
position: f64,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send seek command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
let position_ticks = (position * 10_000_000.0) as i64;
client.session_seek(session_id, position_ticks).await?;
} else {
// Local playback. seek_absolute, not seek: the position came from the UI,
// which shows the whole item, so during a background-audio handoff it has
// to be resolved against the episode's timeline rather than the handoff
// stream's. (DR-159)
let controller = player.0.lock().await;
controller.seek_absolute(position).await?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
/// Smart video seeking that decides between native and server-side seeking
///
/// This command analyzes the current video stream and automatically chooses
/// the best seeking strategy:
/// - HLS streams: Use native seeking
/// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
///
/// For native (non-HTML5) backends, this command handles the entire stream reload
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
position: f64,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> {
info!(
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
// Get repository
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Get current playing item to analyze stream characteristics
// Clone what we need to avoid holding locks across await points
let (needs_transcoding, jellyfin_item_id, is_local, transport) = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
if current_item.media_type != MediaType::Video {
return Err("Current item is not a video".to_string());
}
let jellyfin_id = current_item
.jellyfin_id()
.ok_or("Current video has no Jellyfin ID")?
.to_string();
// The URL itself is no longer read here: the seek strategy now comes
// from the item's own `transport`, not from inspecting the string.
let is_local_file = matches!(current_item.source, MediaSource::Local { .. });
let needs_trans = current_item.needs_transcoding;
let transport = current_item.transport;
(needs_trans, jellyfin_id, is_local_file, transport)
}; // Locks are dropped here
// The transport comes from the backend's own decision, not from searching
// the URL for `.m3u8` — Rust built that URL and knows what it is. Items
// queued without one fall back to `needs_transcoding`, which is exact:
// every transcode this app requests is HLS (DR-140).
//
// TRACES: UR-004, UR-079 | DR-224, DR-229
let is_hls = match transport {
Some(crate::repository::Transport::Hls) => true,
Some(crate::repository::Transport::Progressive)
| Some(crate::repository::Transport::LocalFile) => false,
None => needs_transcoding,
};
let strategy = determine_video_seek_strategy(is_local, is_hls, needs_transcoding, use_html5);
info!("[player_seek_video] Stream analysis: is_local={}, is_hls={}, needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, is_hls, needs_transcoding, use_html5, strategy);
match strategy {
VideoSeekStrategy::LocalNativeSeek | VideoSeekStrategy::BackendNativeSeek => {
// Local files and native backend streams - call backend.seek()
info!("[player_seek_video] Using backend native seek");
let controller = player.0.lock().await;
controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
info!("[player_seek_video] Got new selection, handling reload internally");
// Stop current playback
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
}
// Update the stream URL in the queue
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url.clone()) {
return Err("Failed to update stream URL in queue".to_string());
}
}
// Reload the player with the updated item from queue
{
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
if let Some(updated_item) = queue.current() {
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
} else {
return Err("No current item after URL update".to_string());
}
// The re-opened stream begins at zero — the position cannot ride
// along in the URL without 400ing every segment (DR-181) — so the
// seek that the reload was asked for happens here.
controller.seek(position).map_err(|e| e.to_string())?;
}
info!(
"[player_seek_video] Stream reloaded successfully at position {}",
position
);
Ok(VideoSeekResponse::Native { position })
}
}
}
/// Switch audio track - handles both HTML5 (stream reload) and native (direct switch)
/// Note: Frontend should handle saving series preferences after this command succeeds
///
/// The split is the requirement: an HTML5 `<video>` element cannot be told to
/// change audio track, so the stream is re-opened at the chosen
/// `AudioStreamIndex` and the frontend seeks the reloaded element back to
/// `position`; a native backend (ExoPlayer) switches in place by track-group
/// index. libmpv implements neither — it is the audio-only backend here and
/// leaves `PlayerBackend::set_audio_track` at its `not_implemented()` default,
/// which is why IR-019 is met by these two paths rather than by MPV.
///
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_switch_audio_track(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
stream_index: i32,
array_index: i32,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
if use_html5 {
// HTML5 backend needs stream reload
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Get current item to find Jellyfin ID
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — and `position`
// below tells the frontend where to seek the reloaded element back to.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
// the negotiation returns a transcode. That decision lives in
// `decide_playback_kind`, not here.
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
Some(stream_index),
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position: current_position.unwrap_or(0.0),
})
} else {
// Native backend (Android ExoPlayer) - use array index
let controller = player.0.lock().await;
controller
.set_audio_track(array_index)
.map_err(|e| e.to_string())?;
Ok(AudioTrackSwitchResponse::Native { success: true })
}
}
/// Change the bandwidth ceiling of the video that is playing *right now*.
///
/// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
///
/// The change applies to **this playback only**. The in-player picker is a
/// "this film, this connection" control and its doc has always said so, but it
/// used to be implemented by writing the process-wide ceiling — so choosing
/// 2 Mbps to get one awkward film moving silently capped every video played
/// afterwards for the rest of the process, with the Settings screen still
/// showing the old value and nothing in the UI admitting the change. It now
/// sets a per-playback override that the next item clears; the durable default
/// belongs to Settings, and `player_set_video_settings` is the one that writes
/// to the database.
///
/// TRACES: UR-074, UR-079 | DR-162, DR-225
#[tauri::command]
#[specta::specta]
// Two of the eight arguments are Tauri `State<'_, _>` injections, not caller
// input. Folding the rest into a struct would change the IPC contract and the
// generated TypeScript for no readability gain.
#[allow(clippy::too_many_arguments)]
pub async fn player_set_stream_quality(
player: State<'_, PlayerStateWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
quality.label(),
use_html5,
current_position
);
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
let jellyfin_item_id = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let current_item = queue.current().ok_or("No item currently playing")?;
if current_item.media_type != MediaType::Video {
return Err("Current item is not a video".to_string());
}
current_item
.jellyfin_id()
.ok_or("Current item has no Jellyfin ID")?
.to_string()
};
// Set the ceiling *before* negotiating — the negotiation and every URL
// builder resolve through `effective_streaming_quality`, and they have to
// agree or the cap leaks (a negotiation authorising a direct play the URL
// builder then never gets to constrain).
//
// Deliberately the *override*, not the device default: see the doc above.
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::set_playback_quality_override(quality);
// Where to resume. `current_position` is the *element's* clock, which only
// the webview path has — on a native backend there is no `<video>` and the
// frontend correctly sends null, so trusting it there resumed every quality
// change from zero.
//
// The player is the authority on position (it is the authority on all
// playback state); asking the DOM for it and falling back to 0 inverted
// that. Fall back to what the controller reports instead.
//
// TRACES: UR-005, UR-074 | DR-225
// The guard is bound inside the arm's block so it is dropped before the
// reload below takes the same lock. This codebase has been bitten by a
// MutexGuard living longer than the expression that produced it.
let position = match current_position {
Some(p) => p,
None => {
let controller = player.0.lock().await;
controller.absolute_position()
}
};
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
// where the picture was.
{
let controller = player.0.lock().await;
controller.stop().map_err(|e| e.to_string())?;
let queue_arc = controller.queue();
{
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
if !queue.update_current_stream_url(new_url.clone()) {
return Err("Failed to update stream URL in queue".to_string());
}
}
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
let updated_item = queue.current().ok_or("No current item after URL update")?;
controller
.load_and_play(updated_item)
.map_err(|e| e.to_string())?;
if position > 0.0 {
controller.seek(position).map_err(|e| e.to_string())?;
}
}
Ok(StreamQualityResponse::Native {
selection,
position,
})
}
/// Set the active audio track on a native backend directly.
///
/// TRACES: UR-021 | IR-019, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_set_audio_track(
player: State<'_, PlayerStateWrapper>,
stream_index: i32,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
controller
.set_audio_track(stream_index)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
/// Set (or clear, with `None`) the active subtitle track on a native backend.
///
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
/// children. libmpv implements neither, leaving the trait default in place.
///
/// TRACES: UR-020 | IR-018, DR-023
#[tauri::command]
#[specta::specta]
pub async fn player_set_subtitle_track(
player: State<'_, PlayerStateWrapper>,
stream_index: Option<i32>,
) -> Result<PlayerStatus, String> {
let controller = player.0.lock().await;
controller
.set_subtitle_track(stream_index)
.map_err(|e| e.to_string())?;
Ok(get_player_status(&controller))
}
/// Normalise a volume arriving over IPC to the 0.0..=1.0 range every backend
/// works in.
///
/// NaN is handled before the clamp rather than by it: `f32::clamp` returns NaN
/// for a NaN input (it only panics on NaN *bounds*), and NaN then survives every
/// comparison downstream, so a backend clamp cannot catch it either. It is
/// treated as "no volume asked for" and floored to 0.0.
///
/// TRACES: DR-212 | UT-206
fn normalize_volume(volume: f32) -> f32 {
if volume.is_nan() {
0.0
} else {
volume.clamp(0.0, 1.0)
}
}
#[tauri::command]
#[specta::specta]
pub async fn player_set_volume(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
volume: f32,
) -> Result<PlayerStatus, String> {
// Clamp at the boundary as well as in each backend: the remote branch below
// never reaches a backend clamp, and `(f32::INFINITY * 100.0) as i32` would
// hand the server i32::MAX as a volume percentage.
// TRACES: DR-212 | UT-206
let volume = normalize_volume(volume);
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send volume command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Convert 0-1 range to 0-100 for Jellyfin API
let volume_percent = (volume * 100.0) as i32;
client
.session_set_volume(session_id, volume_percent)
.await?;
} else {
// Local playback
let controller = player.0.lock().await;
controller.set_volume(volume).map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle_mute(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
// Check if we're in remote mode
let mode = playback_mode.0.get_mode();
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
// Send toggle mute command to remote session - clone client before await
let client = {
let controller = player.0.lock().await;
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
client
.send_session_command(session_id, "ToggleMute")
.await?;
} else {
// Local playback
// TODO: Implement toggle_mute in PlayerController
// let controller = player.0.lock().await;
// controller.toggle_mute().map_err(|e| e.to_string())?;
}
let controller = player.0.lock().await;
Ok(get_player_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_toggle_shuffle(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
controller.toggle_shuffle();
controller.emit_queue_changed();
Ok(get_queue_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_cycle_repeat(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
controller.cycle_repeat();
controller.emit_queue_changed();
Ok(get_queue_status(&controller))
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_status(
player: State<'_, PlayerStateWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
) -> Result<PlayerStatus, String> {
let mode = playback_mode.0.get_mode();
// Get base local status
let controller = player.0.lock().await;
let mut status = get_player_status(&controller);
// Get local media from queue
let local_media = {
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.current().map(MergedMediaItem::from)
};
let local_is_playing = status.state.is_playing();
let local_volume = status.volume;
// If in remote mode, fetch session and merge state
if let crate::playback_mode::PlaybackMode::Remote { session_id } = mode {
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
drop(controller); // Release lock before async call
match client.get_session(&session_id).await {
Ok(Some(session)) => {
log::info!("[PlayerCommands] Merging remote session state");
// Merge media item
status.merged_media = session.now_playing_item.as_ref().map(MergedMediaItem::from);
// Merge isPlaying (NOT isPaused!)
status.merged_is_playing = session
.play_state
.as_ref()
.map(|ps| !ps.is_paused.unwrap_or(true))
.unwrap_or(false);
// Merge position (convert ticks to seconds)
status.position = session
.play_state
.as_ref()
.and_then(|ps| ps.position_ticks)
.map(|ticks| ticks as f64 / 10_000_000.0)
.unwrap_or(0.0);
// Merge duration (convert ticks to seconds)
status.duration = session
.now_playing_item
.as_ref()
.and_then(|item| item.run_time_ticks)
.map(|ticks| ticks as f64 / 10_000_000.0);
// Merge volume (convert 0-100 → 0-1)
status.merged_volume = session
.play_state
.as_ref()
.and_then(|ps| ps.volume_level)
.map(|vol| (vol.clamp(0, 100) as f32) / 100.0)
.unwrap_or(1.0);
return Ok(status);
}
Ok(None) => {
log::warn!("[PlayerCommands] Remote session not found, using local state");
}
Err(e) => {
log::warn!("[PlayerCommands] Failed to fetch remote session: {}", e);
}
}
}
// Local playback or fallback
status.merged_media = local_media;
status.merged_is_playing = local_is_playing;
status.merged_volume = local_volume;
Ok(status)
}
#[tauri::command]
#[specta::specta]
pub async fn player_get_queue(
player: State<'_, PlayerStateWrapper>,
) -> Result<QueueStatus, String> {
let controller = player.0.lock().await;
Ok(get_queue_status(&controller))
}
/// What playback facilities this platform's backend actually provides.
///
/// The frontend is presentation-only and must not re-derive backend facts from
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
/// single source of truth; the frontend consumes them.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities {
/// True when audio is rendered by a webview `<audio>` element rather than a
/// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind*
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool,
}
/// Report this platform's playback capabilities to the frontend.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"),
})
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
};
PlayerStatus {
state: controller.state(),
// The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(),
volume: controller.volume(),
muted: controller.muted(),
shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None,
merged_is_playing: false,
merged_volume: controller.volume(),
}
}
pub(super) fn get_queue_status(controller: &PlayerController) -> QueueStatus {
let queue = controller.queue();
let queue_lock = queue.lock_safe();
QueueStatus {
items: queue_lock.items().to_vec(),
current_index: queue_lock.current_index(),
shuffle: queue_lock.is_shuffle(),
repeat: queue_lock.repeat_mode(),
has_next: queue_lock.has_next(),
has_previous: queue_lock.has_previous(),
}
}
/// Start a freshly-built queue on the active remote session.
///
/// Used by the "play tracks"/"play album track" commands when we're in remote
/// mode: instead of starting local MPV playback, we cast the selected tracks to
/// the remote device. Mirrors PlaybackModeManager::transfer_to_remote's
/// play_on_session call, but for a brand-new selection (so there's no resume
/// position - playback starts from the chosen track's beginning).
///
/// Local-only items (no Jellyfin ID) can't be cast, so they're filtered out and
/// the start index is adjusted to the remaining Jellyfin items. Returns an error
/// if the selected track itself has no Jellyfin ID.
async fn play_selection_on_remote(
controller: &PlayerController,
session_id: &str,
media_items: &[MediaItem],
start_index: usize,
) -> Result<(), String> {
// Collect Jellyfin IDs, tracking where the selected track lands after any
// local-only items are dropped.
let mut jellyfin_ids: Vec<String> = Vec::new();
let mut adjusted_index: Option<usize> = None;
for (i, item) in media_items.iter().enumerate() {
if let Some(id) = item.jellyfin_id() {
if i == start_index {
adjusted_index = Some(jellyfin_ids.len());
}
jellyfin_ids.push(id.to_string());
}
}
let start_index =
adjusted_index.ok_or("Cannot play on remote: selected track is not from Jellyfin")?;
if jellyfin_ids.is_empty() {
return Err("Cannot play on remote: no Jellyfin tracks in selection".to_string());
}
let client = {
let client_arc = controller.jellyfin_client();
let client_opt = client_arc.lock().map_err(|e| e.to_string())?;
client_opt
.as_ref()
.ok_or("Jellyfin client not configured")?
.clone()
};
// Fresh selection: start from the beginning of the chosen track.
client
.play_on_session(session_id.to_string(), jellyfin_ids, start_index, None)
.await
.map_err(|e| format!("Failed to start playback on remote session: {}", e))
}
/// Play a track from an album - backend fetches all album tracks and builds queue
#[tauri::command]
#[specta::specta]
pub async fn player_play_album_track(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayAlbumTrackRequest,
) -> Result<PlayerStatus, String> {
info!(
"player_play_album_track called: album_id={}, track_id={}, shuffle={}",
request.album_id, request.track_id, request.shuffle
);
// Get repository (hybrid - supports offline/online)
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch all tracks from the album via hybrid repository
info!(
"Fetching tracks for album {} via repository",
request.album_id
);
let album_items = repository
.get_items(
&request.album_id,
Some(GetItemsOptions {
limit: Some(1000),
fields: Some(vec![
"PrimaryImageAspectRatio".to_string(),
"Overview".to_string(),
"MediaStreams".to_string(),
]),
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to fetch album tracks: {}", e))?;
info!("Found {} items in album", album_items.items.len());
// Filter to only Audio items and sort by index
let mut tracks: Vec<_> = album_items
.items
.into_iter()
.filter(|item| item.item_type == "Audio")
.collect();
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
info!("Found {} audio tracks in album", tracks.len());
if tracks.is_empty() {
return Err("No audio tracks found in album".to_string());
}
// Debug: Log all track IDs and their indices
info!("Album has {} tracks after sorting:", tracks.len());
for (idx, track) in tracks.iter().enumerate() {
info!(" [{}] {} (ID: {})", idx, track.name, track.id);
}
// Validate the requested track exists in the album (its position in the
// final queue is computed after building, since offline tracks are skipped).
info!("Looking for track_id: {}", request.track_id);
let album_index = tracks
.iter()
.position(|t| t.id == request.track_id)
.ok_or_else(|| format!("Track {} not found in album", request.track_id))?;
info!(
"Track {} is at index {} in album",
request.track_id, album_index
);
// Convert tracks to MediaItems
let mut media_items = Vec::new();
for track in tracks {
// Check for local download first
let jellyfin_id = &track.id;
let local_path = check_for_local_download(&db, jellyfin_id).await?;
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(jellyfin_id.clone()),
}
} else {
// Non-downloaded track: needs a stream URL from the server. When the
// server is unreachable (offline), skip this track rather than failing
// the whole album — downloaded tracks must still be playable.
match repository.get_audio_stream_url(&track.id).await {
Ok(stream_url) => MediaSource::Remote {
stream_url,
jellyfin_item_id: jellyfin_id.clone(),
},
Err(e) => {
warn!(
"[Player] Skipping track {} ({}) — no local download and stream URL unavailable: {}",
track.name, track.id, e
);
continue;
}
}
};
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: Some(request.album_name.clone()),
album_name: Some(request.album_name.clone()), // Frontend compatibility
album_id: Some(request.album_id.clone()),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None,
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.map(|tag| {
repository.get_image_url(
&request.album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
media_items.push(media_item);
}
if media_items.is_empty() {
return Err("No playable tracks available (offline and nothing downloaded)".to_string());
}
// Tracks with no local download and no reachable server were skipped above,
// so positions shifted. Re-locate the requested track in the built queue.
// If the tapped track itself was skipped, fall back to the first item.
let start_index = media_items
.iter()
.position(|item| item.id == request.track_id)
.unwrap_or(0);
info!(
"Built queue with {} media items, starting at index {}",
media_items.len(),
start_index
);
// Handle shuffle before setting queue
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Start audio session with the first item
if let Some(first_item) = media_items.get(start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(first_item.clone());
}
let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. We still load the queue locally (below) so
// the queue/context stay in sync for the UI and for transferring back.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id } => Some(session_id),
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, start_index).await?;
controller
.set_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
} else {
// Local playback is now authoritative (see player_play_tracks); set it
// before starting so the mode-changed event precedes the state events.
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Local);
controller
.play_queue(media_items, start_index)
.map_err(|e| e.to_string())?;
}
// Set the queue context for remote transfer
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(QueueContext::Album {
album_id: request.album_id.clone(),
album_name: request.album_name.clone(),
});
}
// Emit queue changed event
controller.emit_queue_changed();
// Log final queue state
{
let queue_arc = controller.queue();
let queue_lock = queue_arc.lock().map_err(|e| e.to_string())?;
info!(
"player_play_album_track: Queue now has {} items, current_index: {:?}",
queue_lock.items().len(),
queue_lock.current_index()
);
}
// Emit session changed event
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
Ok(get_player_status(&controller))
}
/// Play tracks by ID - backend fetches all metadata
#[tauri::command]
#[specta::specta]
pub async fn player_play_tracks(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
db: State<'_, DatabaseWrapper>,
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
playback_mode: State<'_, super::playback_mode::PlaybackModeManagerWrapper>,
repository_handle: String,
request: PlayTracksRequest,
) -> Result<PlayerStatus, String> {
// A ceiling chosen from the in-player picker belongs to the playback it was
// chosen for. Starting a different item returns to the device default —
// otherwise "2 Mbps, just for this one film" quietly governs the rest of the
// session, which is the defect DR-225 exists to close.
//
// TRACES: UR-074, UR-079 | DR-225
crate::repository::online::clear_playback_quality_override();
info!(
"player_play_tracks called: {} tracks, start_index={}, shuffle={}",
request.track_ids.len(),
request.start_index,
request.shuffle
);
// Validate input
if request.track_ids.is_empty() {
return Err("No tracks provided".to_string());
}
// Get repository
let repository = repository_manager
.0
.get(&repository_handle)
.ok_or("Repository not found - user may need to log in")?;
// Fetch metadata for all tracks
let mut media_items = Vec::new();
for track_id in &request.track_ids {
// Fetch track metadata from repository
let track = repository
.get_item(track_id)
.await
.map_err(|e| format!("Failed to fetch track {}: {}", track_id, e))?;
// Check for local download
let local_path = check_for_local_download(&db, track_id).await?;
// Build MediaSource
let source = if let Some(path) = local_path {
MediaSource::Local {
file_path: PathBuf::from(path),
jellyfin_item_id: Some(track.id.clone()),
}
} else {
let stream_url = repository
.get_audio_stream_url(track_id)
.await
.map_err(|e| format!("Failed to get stream URL: {}", e))?;
MediaSource::Remote {
stream_url,
jellyfin_item_id: track.id.clone(),
}
};
// Transform to MediaItem with frontend-compatible fields
let primary_image_tag_for_url = track.primary_image_tag.clone();
let media_item = MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: track.id.clone(),
title: track.name.clone(),
name: Some(track.name.clone()), // Frontend compatibility
artist: track
.album_artist
.clone()
.or_else(|| track.artists.as_ref().and_then(|a| a.first().cloned())),
album: track.album_name.clone(),
album_name: track.album_name.clone(), // Frontend compatibility
album_id: track.album_id.clone(),
artist_items: track.artist_items.clone(), // For clickable artist links
artists: track.artists.clone(), // Fallback artist info
primary_image_tag: track.primary_image_tag.clone(), // For frontend image display
image_id: track.primary_image_tag.clone(),
item_type: Some(track.item_type.clone()), // Frontend compatibility
playlist_id: None, // Set based on context below
duration: track.runtime_ticks.map(|t| t as f64 / 10_000_000.0),
artwork_url: primary_image_tag_for_url.and_then(|tag| {
track.album_id.as_ref().map(|album_id| {
repository.get_image_url(
album_id,
ImageType::Primary,
Some(ImageOptions {
max_width: Some(300),
tag: Some(tag),
..Default::default()
}),
)
})
}),
media_type: MediaType::Audio,
source,
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
media_items.push(media_item);
}
info!("Built queue with {} media items", media_items.len());
// Map context and set playlist_id
let queue_context = match request.context {
PlayTracksContext::Playlist {
playlist_id,
playlist_name,
} => {
for item in &mut media_items {
item.playlist_id = Some(playlist_id.clone());
}
QueueContext::Playlist {
playlist_id,
playlist_name,
}
}
PlayTracksContext::Search { .. } | PlayTracksContext::Custom { .. } => QueueContext::Custom,
};
// Handle shuffle
if request.shuffle {
let controller = player.0.lock().await;
controller.toggle_shuffle();
}
// Start session
if let Some(first_item) = media_items.get(request.start_index) {
let mut session_mgr = session.0.lock().map_err(|e| e.to_string())?;
session_mgr.start_audio_session(first_item.clone());
}
let controller = player.0.lock().await;
// When controlling a remote session, cast the selection there instead of
// starting local MPV playback. Skip this while a transfer is in flight: the
// transfer-to-local path calls this command to load the queue locally and
// the mode is still Remote until the transfer completes - routing it back to
// the remote would undo the transfer.
let remote_session = match playback_mode.0.get_mode() {
crate::playback_mode::PlaybackMode::Remote { session_id }
if !playback_mode.0.is_transferring() =>
{
Some(session_id)
}
_ => None,
};
if let Some(session_id) = &remote_session {
play_selection_on_remote(&controller, session_id, &media_items, request.start_index)
.await?;
controller
.set_queue(media_items, request.start_index)
.map_err(|e| e.to_string())?;
} else {
// Starting local playback makes Local the authoritative mode. Without
// this, a prior Remote mode lingers in the manager and later play/pause
// commands route back to the (stopped) remote session. Set it BEFORE
// starting playback so the PlaybackModeChanged event reaches the frontend
// ahead of the state_changed events it will emit — otherwise the frontend
// (still thinking it's remote) filters those state events out. Skip during
// a transfer: transfer_to_local drives the mode itself once complete.
if !playback_mode.0.is_transferring() {
playback_mode
.0
.set_mode(crate::playback_mode::PlaybackMode::Local);
}
controller
.play_queue_from(media_items, request.start_index, request.start_position)
.map_err(|e| e.to_string())?;
}
// Set queue context
{
let queue_arc = controller.queue();
let mut queue = queue_arc.lock().map_err(|e| e.to_string())?;
queue.set_context(queue_context);
}
// Emit events
controller.emit_queue_changed();
if let Some(emitter) = controller.event_emitter() {
let session_mgr = session.0.lock().map_err(|e| e.to_string())?;
emitter.emit(PlayerStatusEvent::SessionChanged {
session: session_mgr.current().clone(),
});
}
info!("player_play_tracks completed successfully");
Ok(get_player_status(&controller))
}
/// Response for preload operation
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PreloadResult {
/// Number of tracks queued for preload
pub queued_count: usize,
/// Number of tracks already downloaded
pub already_downloaded: usize,
/// Number of tracks skipped (no jellyfin ID or other reasons)
pub skipped: usize,
}
/// Preload upcoming tracks from the queue
/// This queues background downloads for the next N tracks that aren't already downloaded
#[tauri::command]
#[specta::specta]
pub async fn player_preload_upcoming(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
smart_cache: State<'_, SmartCacheWrapper>,
download_manager: State<'_, crate::commands::download::DownloadManagerWrapper>,
app: tauri::AppHandle,
user_id: String,
_download_base_path: String,
) -> Result<PreloadResult, String> {
// The pump only starts rows that carry both a stream URL and a target dir,
// so resolve the same storage root the user-initiated download paths use
// (storage_get_path = the database's parent directory).
let (db_service, target_dir) = {
let database = db.0.lock().map_err(|e| e.to_string())?;
let target_dir = database
.path()
.parent()
.ok_or_else(|| "Database path has no parent directory".to_string())?
.to_string_lossy()
.to_string();
(Arc::new(database.service()), target_dir)
};
// Get cache settings
let precache_count = {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
if !cache.should_precache_queue() {
return Ok(PreloadResult {
queued_count: 0,
already_downloaded: 0,
skipped: 0,
});
}
cache.queue_precache_count()
};
// Get upcoming items from queue
let upcoming_items: Vec<MediaItem> = {
let controller = player.0.lock().await;
let queue = controller.queue();
let queue_lock = queue.lock().map_err(|e| e.to_string())?;
queue_lock
.get_upcoming(precache_count)
.into_iter()
.cloned()
.collect()
};
if upcoming_items.is_empty() {
return Ok(PreloadResult {
queued_count: 0,
already_downloaded: 0,
skipped: 0,
});
}
let mut queued_count = 0;
let mut already_downloaded = 0;
let mut skipped = 0;
// Process each upcoming item
for item in upcoming_items {
// Only process items with Remote source (not already local). The
// source already carries the resolved stream URL — reuse it so the
// pump can start the download without any extra resolution step.
let (jellyfin_id, stream_url) = match &item.source {
MediaSource::Remote {
jellyfin_item_id,
stream_url,
} => (jellyfin_item_id.clone(), stream_url.clone()),
MediaSource::Local { .. } => {
already_downloaded += 1;
continue;
}
MediaSource::DirectUrl { .. } => {
skipped += 1;
continue;
}
};
// Check if already downloaded or actively in flight. Stale pending rows
// without a stream URL are NOT skipped here — the upsert below heals
// them so the pump can finally start them.
let query = Query::with_params(
"SELECT file_path FROM downloads WHERE item_id = ? AND user_id = ?
AND (status IN ('completed', 'downloading')
OR (status = 'pending' AND stream_url IS NOT NULL)) LIMIT 1",
vec![
QueryParam::String(jellyfin_id.clone()),
QueryParam::String(user_id.clone()),
],
);
let is_downloaded: Option<String> = db_service
.query_optional(query, |row| row.get(0))
.await
.map_err(|e| e.to_string())?;
if is_downloaded.is_some() {
already_downloaded += 1;
continue;
}
// Queue for download with low priority (preload priority = -100) so
// user-initiated downloads always win a pump slot first.
let album_dir = item
.album
.as_deref()
.filter(|a| !a.is_empty())
.unwrap_or("Unknown Album");
let file_path = format!(
"downloads/{}/{}.mp3",
sanitize_filename(album_dir),
sanitize_filename(&item.title)
);
// Insert with the stream URL + target dir the pump needs to start it.
// On conflict, heal pre-existing rows that were queued without a URL
// (they could never start) instead of leaving them stuck.
let insert_query = Query::with_params(
"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)
VALUES (?, ?, ?, 'pending', -100, CURRENT_TIMESTAMP, ?, ?, ?, 'auto', 'audio', ?, ?)
ON CONFLICT(item_id, user_id) DO UPDATE SET
stream_url = excluded.stream_url,
target_dir = excluded.target_dir
WHERE downloads.status = 'pending' AND downloads.stream_url IS NULL",
vec![
QueryParam::String(jellyfin_id),
QueryParam::String(user_id.clone()),
QueryParam::String(file_path),
QueryParam::String(item.title.clone()),
item.artist.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
item.album.clone().map(QueryParam::String).unwrap_or(QueryParam::Null),
QueryParam::String(stream_url),
QueryParam::String(target_dir.clone()),
],
);
match db_service.execute(insert_query).await {
Ok(rows) if rows > 0 => {
info!(
"[Preload] Queued download for: {} - {}",
item.artist.as_deref().unwrap_or("Unknown"),
item.title
);
queued_count += 1;
}
Ok(_) => {
// Row already exists (conflict), count as already queued
already_downloaded += 1;
}
Err(e) => {
error!("[Preload] Failed to queue {}: {}", item.title, e);
skipped += 1;
}
}
}
// Kick the pump so the queued preloads actually start; without this they'd
// only begin once some other download activity pumps the queue.
if queued_count > 0 {
let active_downloads = {
let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
manager.get_active_downloads()
};
crate::commands::download::pump_download_queue(app, db_service, active_downloads).await;
}
info!(
"[Preload] Result: queued={}, already_downloaded={}, skipped={}",
queued_count, already_downloaded, skipped
);
Ok(PreloadResult {
queued_count,
already_downloaded,
skipped,
})
}
/// Sanitize filename by removing invalid characters
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect()
}
/// Update SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_set_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
config: CacheConfig,
) -> Result<(), String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
cache.update_config(config);
Ok(())
}
/// Get current SmartCache configuration
#[tauri::command]
#[specta::specta]
pub async fn player_get_cache_config(
smart_cache: State<'_, SmartCacheWrapper>,
) -> Result<CacheConfig, String> {
let cache = smart_cache.0.lock().map_err(|e| e.to_string())?;
Ok(cache.get_config().unwrap_or_default())
}
/// Configure Jellyfin API client for automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_configure_jellyfin(
player: State<'_, PlayerStateWrapper>,
db: State<'_, DatabaseWrapper>,
server_url: String,
access_token: String,
user_id: String,
device_id: String,
) -> Result<(), String> {
log::info!("[PlayerCommand] Configuring Jellyfin client for playback reporting");
let config = JellyfinConfig {
server_url,
access_token,
device_id,
};
// Legacy client (used for remote session control / casting).
let client = JellyfinClient::new(config.clone())?;
// Build the PlaybackReporter the player and backends (MPV + ExoPlayer)
// actually report through. Without this, Start/Progress/Stopped never reach
// Jellyfin, so playback position never syncs and you can't resume on another
// device. The reporter shares the player controller's Arc, so populating it
// here lights up reporting on both desktop and Android, on every auth path
// that configures the player (login / restore / reauth).
let db_service = {
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let reporter_client = JellyfinClient::new(config)?;
let reporter = crate::playback_reporting::PlaybackReporter::new(
db_service,
Arc::new(TokioMutex::new(Some(reporter_client))),
user_id,
);
let controller = player.0.lock().await;
controller.set_jellyfin_client(Some(client));
controller.set_playback_reporter(Some(reporter)).await;
log::info!("[PlayerCommand] Jellyfin client and playback reporter configured successfully");
Ok(())
}
/// Disable Jellyfin automatic playback reporting
#[tauri::command]
#[specta::specta]
pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> Result<(), String> {
log::info!("[PlayerCommand] Disabling Jellyfin client");
let controller = player.0.lock().await;
controller.set_jellyfin_client(None);
controller.set_playback_reporter(None).await;
log::info!("[PlayerCommand] Jellyfin client and playback reporter disabled");
Ok(())
}
#[cfg(test)]
mod tests {
use crate::utils::lock::MutexSafe;
/// UT-206 — the volume the command hands on is always a real number in
/// 0.0..=1.0.
///
/// Every backend clamps for itself, but the remote branch of
/// `player_set_volume` reaches no backend at all: it does
/// `(volume * 100.0) as i32`, which turns infinity into `i32::MAX` and NaN
/// into 0. NaN also survives `f32::clamp` unchanged, so clamping alone is
/// not enough — it has to be tested for.
///
/// TRACES: DR-212 | UT-206
#[test]
fn test_normalize_volume_clamps_and_rejects_nan() {
use super::normalize_volume;
// In-range values pass through untouched.
assert_eq!(normalize_volume(0.0), 0.0);
assert_eq!(normalize_volume(0.5), 0.5);
assert_eq!(normalize_volume(1.0), 1.0);
// Out of range clamps to the same 0.0..=1.0 the backends use.
assert_eq!(normalize_volume(-0.5), 0.0);
assert_eq!(normalize_volume(42.0), 1.0);
assert_eq!(normalize_volume(f32::INFINITY), 1.0);
assert_eq!(normalize_volume(f32::NEG_INFINITY), 0.0);
// NaN is not a volume; it must not reach the Jellyfin percentage
// conversion or a backend.
let from_nan = normalize_volume(f32::NAN);
assert!(!from_nan.is_nan(), "NaN must not pass through the boundary");
assert_eq!(from_nan, 0.0);
// Whatever comes out survives the remote branch's percentage cast.
for input in [-1.0, 0.25, 9.0, f32::INFINITY, f32::NAN] {
let percent = (normalize_volume(input) * 100.0) as i32;
assert!((0..=100).contains(&percent), "input {input} gave {percent}");
}
}
/// The subtitle list the frontend resolved must survive the IPC hop and end
/// up on the `MediaItem` the native backend loads.
///
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
/// in — so `create_media_item` always produced `subtitles: vec![]`,
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
/// `setSubtitleTrack(n)` then found no text track groups and logged
/// "Invalid subtitle track index".
///
/// The payload below is exactly what the frontend sends: camelCase for the
/// top-level command params (Tauri v2 converts them), and the subtitle
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_carries_subtitles_into_media_item() {
use super::{create_media_item, PlayItemRequest};
let payload = serde_json::json!({
"id": "ep-1",
"title": "Pilot",
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
"videoCodec": "h264",
"needsTranscoding": false,
"subtitles": [
{
"index": 2,
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
"language": "eng",
"label": "English (SRT)",
"mime_type": "text/vtt"
},
{
"index": 3,
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
"language": null,
"label": null,
"mime_type": "text/vtt"
}
]
});
let req: PlayItemRequest =
serde_json::from_value(payload).expect("frontend payload must deserialize");
assert_eq!(
req.subtitles.len(),
2,
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
);
let media = create_media_item(req, None).await.unwrap();
assert_eq!(
media.subtitles.len(),
2,
"create_media_item must thread the tracks onto the MediaItem the backend loads"
);
assert_eq!(media.subtitles[0].index, 2);
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
// Order is the contract: `player_set_subtitle_track(n)` is a position in
// this list (see the note on `PlayItemRequest::subtitles`).
assert_eq!(media.subtitles[1].index, 3);
assert!(media.subtitles[1].language.is_none());
}
/// A request without subtitles must still deserialize — the field is
/// defaulted so the background-audio handoff and the autoplay/next-episode
/// callers keep compiling and sending what they always sent.
///
/// TRACES: UR-020 | IR-016 | UT-145
#[tokio::test]
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
use super::{create_media_item, PlayItemRequest};
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
"id": "movie-1",
"title": "Movie",
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
"videoCodec": "h264",
"needsTranscoding": false
}))
.expect("a subtitle-less payload must still deserialize");
assert!(req.subtitles.is_empty());
assert!(create_media_item(req, None)
.await
.unwrap()
.subtitles
.is_empty());
}
/// The JSON handed to Kotlin over JNI must use the keys
/// `JellyTauPlayer.load()` actually reads.
///
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
/// house IPC rule) is to camelCase nested structs too — but
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
/// the field would not fail to compile or fail the IPC; it would silently
/// fall back to the default MIME type for every track, so this is asserted
/// on the exact bytes `android/mod.rs` sends.
///
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
#[test]
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
use crate::player::media::SubtitleTrack;
let subtitles = vec![SubtitleTrack {
index: 2,
url: "https://jelly.example/subs.vtt".to_string(),
language: Some("eng".to_string()),
label: Some("English".to_string()),
mime_type: "text/vtt".to_string(),
}];
// Exactly what player/android/mod.rs passes to loadWithMetadata.
let json = serde_json::to_string(&subtitles).unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
let obj = parsed[0].as_object().unwrap();
for key in ["url", "language", "label", "mime_type"] {
assert!(
obj.contains_key(key),
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
obj.keys().collect::<Vec<_>>()
);
}
assert!(
!obj.contains_key("mimeType"),
"camelCasing mime_type silently drops every track's MIME type on Android"
);
}
/// The audio-only handoff must play a downloaded file when there is one,
/// rather than fetching an audio-only stream for media already on disk.
///
/// TRACES: UR-071 | DR-128 | UT-119
#[test]
fn test_background_audio_source_prefers_local_file() {
use super::background_audio_source;
use crate::player::MediaSource;
use std::path::PathBuf;
let local = background_audio_source(
Some("/downloads/ep1.mkv".to_string()),
"https://server/audio-only".to_string(),
"ep-1",
);
match local {
MediaSource::Local {
file_path,
jellyfin_item_id,
} => {
assert_eq!(file_path, PathBuf::from("/downloads/ep1.mkv"));
// The Jellyfin id must survive so progress still syncs back.
assert_eq!(jellyfin_item_id.as_deref(), Some("ep-1"));
}
other => panic!("expected a local source, got {:?}", other),
}
let remote = background_audio_source(None, "https://server/audio-only".to_string(), "ep-1");
match remote {
MediaSource::Remote {
stream_url,
jellyfin_item_id,
} => {
assert_eq!(stream_url, "https://server/audio-only");
assert_eq!(jellyfin_item_id, "ep-1");
}
other => panic!("expected a remote source, got {:?}", other),
}
}
/// The two sources start in different places, so the handoff cannot treat
/// them alike.
///
/// An audio-only *stream* is built with `StartTimeTicks`, so the server makes
/// the handoff point that stream's zero: the base is the handoff position and
/// seeking would jump past the content. A *downloaded file* has no such
/// parameter — it starts at the episode's own zero — so basing it at the
/// handoff position claims 18 minutes of audio that is about to play from the
/// beginning. That is the downloaded-episode version of "it restarts when the
/// screen sleeps", and it needs the opposite treatment: no base, and a seek.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_seeks_a_file_and_bases_a_stream() {
use super::background_audio_plan;
let local = background_audio_plan(true, 1104.0);
assert_eq!(local.base_seconds, 0.0);
assert_eq!(local.seek_to, Some(1104.0));
let streamed = background_audio_plan(false, 1104.0);
assert_eq!(streamed.base_seconds, 1104.0);
assert_eq!(
streamed.seek_to, None,
"the URL already starts at the handoff point; seeking again skips past it"
);
}
/// Handing off at the very start has nothing to seek to and nothing to base:
/// both sources are already where they need to be.
///
/// TRACES: UR-040, UR-071 | DR-180 | UT-181
#[test]
fn test_background_audio_plan_at_the_start_neither_seeks_nor_bases() {
use super::background_audio_plan;
for local in [true, false] {
let plan = background_audio_plan(local, 0.0);
assert_eq!(plan.base_seconds, 0.0);
assert_eq!(plan.seek_to, None);
}
}
/// A downloaded item must resolve to its file, and a `downloads` row whose
/// file has gone must resolve to `None` so the caller falls back to
/// streaming instead of handing the player a path that cannot be opened.
///
/// TRACES: UR-071 | DR-123 | UT-116
#[tokio::test]
async fn test_resolve_local_media_path() {
use super::resolve_local_media_path;
use crate::storage::db_service::{DatabaseService, Query, RusqliteService};
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
let conn = Connection::open_in_memory().unwrap();
conn.execute(
"CREATE TABLE downloads (id INTEGER PRIMARY KEY, item_id TEXT, status TEXT, file_path TEXT)",
[],
)
.unwrap();
let db_service = Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))));
// A real file on disk, so the existence check passes.
let present = std::env::temp_dir().join("jellytau-resolve-local-test.mp4");
std::fs::write(&present, b"x").unwrap();
let present_str = present.to_string_lossy().to_string();
for (item, status, path) in [
("downloaded", "completed", present_str.as_str()),
("still-going", "downloading", present_str.as_str()),
(
"file-gone",
"completed",
"/nonexistent/jellytau/missing.mp4",
),
] {
db_service
.execute(Query::with_params(
"INSERT INTO downloads (item_id, status, file_path) VALUES (?, ?, ?)",
vec![
crate::storage::db_service::QueryParam::String(item.to_string()),
crate::storage::db_service::QueryParam::String(status.to_string()),
crate::storage::db_service::QueryParam::String(path.to_string()),
],
))
.await
.unwrap();
}
assert_eq!(
resolve_local_media_path(&db_service, "downloaded")
.await
.unwrap()
.as_deref(),
Some(present_str.as_str()),
"a completed download with its file present must resolve"
);
assert_eq!(
resolve_local_media_path(&db_service, "still-going")
.await
.unwrap(),
None,
"an in-progress download is not playable from disk"
);
assert_eq!(
resolve_local_media_path(&db_service, "file-gone")
.await
.unwrap(),
None,
"a row whose file has gone must fall back to streaming, not hand over a dead path"
);
assert_eq!(
resolve_local_media_path(&db_service, "never-heard-of-it")
.await
.unwrap(),
None
);
let _ = std::fs::remove_file(&present);
}
/// Queue items enqueued as Remote must flip to Local once a completed
/// download exists on disk — this is what makes preloaded tracks (and
/// offline playback after a connection drop) actually use the cache.
#[tokio::test]
async fn test_refresh_queue_local_sources_switches_completed_downloads() {
use super::{refresh_queue_local_sources, DatabaseWrapper};
use crate::player::{MediaItem, MediaSource, MediaType, PlayerController};
use crate::storage::Database;
use std::sync::Mutex;
// A real file on disk for the completed download; a missing file for
// the second entry to prove nonexistent files are not switched.
let dir = std::env::temp_dir().join("jellytau-test-refresh-sources");
std::fs::create_dir_all(&dir).unwrap();
let existing = dir.join("track-a.mp3");
std::fs::write(&existing, b"audio").unwrap();
let missing = dir.join("track-b-missing.mp3");
let _ = std::fs::remove_file(&missing);
let database = Database::open_in_memory().unwrap();
{
let conn = database.connection();
let conn = conn.lock_safe();
conn.execute_batch(&format!(
r#"
INSERT INTO servers (id, name, url) VALUES ('srv', 'Test', 'http://test');
INSERT INTO users (id, server_id, username) VALUES ('user1', 'srv', 'tester');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-a', 'user1', '{}', 'completed');
INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('track-b', 'user1', '{}', 'completed');
"#,
existing.display(),
missing.display()
))
.unwrap();
}
let db = DatabaseWrapper(Mutex::new(database));
let make_item = |id: &str| MediaItem {
// Audio and direct-URL items never negotiate a transport.
transport: None,
id: id.to_string(),
title: id.to_string(),
name: None,
artist: None,
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: None,
image_id: None,
item_type: None,
playlist_id: None,
duration: None,
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url: format!("http://test/Audio/{}/stream", id),
jellyfin_item_id: id.to_string(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
server_id: None,
};
let controller = PlayerController::default();
controller
.set_queue(vec![make_item("track-a"), make_item("track-b")], 0)
.unwrap();
let switched = refresh_queue_local_sources(&controller, &db).await.unwrap();
assert_eq!(switched, 1, "only the download whose file exists switches");
let queue = controller.queue();
let queue_lock = queue.lock_safe();
match &queue_lock.items()[0].source {
MediaSource::Local {
file_path,
jellyfin_item_id,
} => {
assert_eq!(file_path, &existing);
assert_eq!(jellyfin_item_id.as_deref(), Some("track-a"));
}
other => panic!("track-a should be local, got {:?}", other),
}
assert!(
matches!(queue_lock.items()[1].source, MediaSource::Remote { .. }),
"track-b's file is missing, it must stay remote"
);
}
/// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13
#[test]
fn test_find_track_index_in_album() {
// Create mock album tracks
#[derive(Clone)]
struct MockTrack {
id: String,
name: String,
index_number: Option<i32>,
}
let mut tracks = [
MockTrack {
id: "track1".to_string(),
name: "Song 1".to_string(),
index_number: Some(1),
},
MockTrack {
id: "track2".to_string(),
name: "Song 2".to_string(),
index_number: Some(2),
},
MockTrack {
id: "track3".to_string(),
name: "Song 3".to_string(),
index_number: Some(3),
},
MockTrack {
id: "track4".to_string(),
name: "Song 4".to_string(),
index_number: Some(4),
},
MockTrack {
id: "track5".to_string(),
name: "Song 5".to_string(),
index_number: Some(5),
},
];
// Sort by index (same as the real code does)
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
// Test finding track 1 (should be index 0)
let index1 = tracks.iter().position(|t| t.id == "track1");
assert_eq!(index1, Some(0), "Track 1 should be at index 0");
assert_eq!(
tracks[0].name, "Song 1",
"Track at index 0 should carry its name"
);
// Test finding track 3 (should be index 2)
let index3 = tracks.iter().position(|t| t.id == "track3");
assert_eq!(index3, Some(2), "Track 3 should be at index 2");
// Test finding track 5 (should be index 4)
let index5 = tracks.iter().position(|t| t.id == "track5");
assert_eq!(index5, Some(4), "Track 5 should be at index 4");
// Test finding non-existent track
let index_none = tracks.iter().position(|t| t.id == "nonexistent");
assert_eq!(index_none, None, "Non-existent track should return None");
}
/// Test that track order is preserved when iterating
#[test]
fn test_track_iteration_order() {
// Simulate the loop that builds MediaItems
let track_ids = vec!["id1", "id2", "id3", "id4", "id5"];
// Find where "id3" is in the original list
let target_index = track_ids.iter().position(|&id| id == "id3");
assert_eq!(
target_index,
Some(2),
"id3 should be at index 2 in original list"
);
// Simulate building the MediaItems vector
let mut media_items = Vec::new();
for id in &track_ids {
media_items.push(id.to_string());
}
// Verify the order is preserved
assert_eq!(media_items.len(), 5);
assert_eq!(media_items[0], "id1");
assert_eq!(media_items[2], "id3");
assert_eq!(media_items[4], "id5");
// The start_index found earlier should still be valid
assert_eq!(media_items[target_index.unwrap()], "id3");
}
/// Test album track sorting behavior
#[test]
fn test_album_track_sorting() {
#[derive(Clone, Debug)]
struct MockTrack {
id: String,
name: String,
index_number: Option<i32>,
}
// Create tracks in random order (not sorted)
let mut tracks = [
MockTrack {
id: "id5".to_string(),
name: "Track 5".to_string(),
index_number: Some(5),
},
MockTrack {
id: "id1".to_string(),
name: "Track 1".to_string(),
index_number: Some(1),
},
MockTrack {
id: "id3".to_string(),
name: "Track 3".to_string(),
index_number: Some(3),
},
MockTrack {
id: "id2".to_string(),
name: "Track 2".to_string(),
index_number: Some(2),
},
MockTrack {
id: "id4".to_string(),
name: "Track 4".to_string(),
index_number: Some(4),
},
];
// User clicks track "id1" before sorting - what index is it?
let requested_track_id = "id1";
// Sort the tracks (same as real code)
tracks.sort_by_key(|t| t.index_number.unwrap_or(0));
// Now find the index AFTER sorting
let start_index = tracks.iter().position(|t| t.id == requested_track_id);
// Track "id1" should be at index 0 after sorting
assert_eq!(
start_index,
Some(0),
"After sorting, track id1 should be at index 0"
);
// Verify all tracks are in correct order
assert_eq!(tracks[0].id, "id1");
assert_eq!(
tracks[0].name, "Track 1",
"Sorted track should retain its name"
);
assert_eq!(tracks[1].id, "id2");
assert_eq!(tracks[2].id, "id3");
assert_eq!(tracks[3].id, "id4");
assert_eq!(tracks[4].id, "id5");
}
}