Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
3501 lines
131 KiB
Rust
3501 lines
131 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.
|
|
Native {
|
|
/// 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);
|
|
|
|
let position = current_position.unwrap_or(0.0);
|
|
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 { 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");
|
|
}
|
|
}
|