All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
2200 lines
81 KiB
Rust
2200 lines
81 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 tokio::sync::Mutex as TokioMutex;
|
|
use tauri::State;
|
|
|
|
use super::DatabaseWrapper;
|
|
use crate::download::cache::{CacheConfig, SmartCache};
|
|
use crate::jellyfin::{JellyfinClient, JellyfinConfig};
|
|
use crate::player::{
|
|
determine_video_seek_strategy, MediaItem, MediaSource, MediaType,
|
|
MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode,
|
|
VideoSeekStrategy,
|
|
};
|
|
use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}};
|
|
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>,
|
|
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(),
|
|
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(),
|
|
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,
|
|
}
|
|
|
|
/// 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 {
|
|
/// New stream URL starting at seek position
|
|
new_url: String,
|
|
/// Position offset to track (for display purposes)
|
|
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 {
|
|
/// New stream URL with selected audio track
|
|
new_url: String,
|
|
/// Current 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
|
|
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,
|
|
video_width: None, // Not available from video-only request
|
|
video_height: None, // Not available from video-only request
|
|
subtitles: vec![],
|
|
series_id: None, // Not available from video-only request
|
|
server_id: None, // Not available from video-only request
|
|
})
|
|
}
|
|
|
|
/// 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())
|
|
};
|
|
|
|
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())?;
|
|
|
|
// Verify the file actually exists on disk
|
|
if let Some(ref file_path) = path {
|
|
if std::path::Path::new(file_path).exists() {
|
|
Ok(path)
|
|
} else {
|
|
warn!("[Player] Download entry exists in DB but file not found: {}", file_path);
|
|
Ok(None)
|
|
}
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// 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))
|
|
}
|
|
|
|
/// 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);
|
|
|
|
// 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?;
|
|
} else {
|
|
// Local playback
|
|
let controller = player.0.lock().await;
|
|
controller.stop().map_err(|e| e.to_string())?;
|
|
|
|
// 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
|
|
let controller = player.0.lock().await;
|
|
controller.seek(position).map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
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 (.m3u8): 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, stream_url, is_local) = {
|
|
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();
|
|
|
|
let (stream_url, is_local_file) = match ¤t_item.source {
|
|
MediaSource::Remote { stream_url, .. } => (stream_url.clone(), false),
|
|
MediaSource::Local { .. } => (String::new(), true),
|
|
MediaSource::DirectUrl { url } => (url.clone(), false),
|
|
};
|
|
|
|
let needs_trans = current_item.needs_transcoding;
|
|
(needs_trans, jellyfin_id, stream_url, is_local_file)
|
|
}; // Locks are dropped here
|
|
|
|
// Determine seek strategy using the testable helper function
|
|
let is_hls = stream_url.contains(".m3u8");
|
|
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 new_url = repository.get_video_stream_url(
|
|
&jellyfin_item_id,
|
|
media_source_id.as_deref(),
|
|
Some(position),
|
|
audio_stream_index,
|
|
).await
|
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
|
|
|
info!("[player_seek_video] Got new stream URL for position {}", position);
|
|
|
|
Ok(VideoSeekResponse::ReloadStream {
|
|
new_url,
|
|
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 new_url = repository.get_video_stream_url(
|
|
&jellyfin_item_id,
|
|
media_source_id.as_deref(),
|
|
Some(position),
|
|
audio_stream_index,
|
|
).await
|
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
|
|
|
info!("[player_seek_video] Got new stream URL, 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());
|
|
}
|
|
}
|
|
|
|
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
|
|
#[tauri::command]
|
|
#[specta::specta]
|
|
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()
|
|
};
|
|
|
|
// Get new stream URL with selected audio track
|
|
let new_url = repository.get_video_stream_url(
|
|
&jellyfin_item_id,
|
|
media_source_id.as_deref(),
|
|
current_position,
|
|
Some(stream_index),
|
|
).await
|
|
.map_err(|e| format!("Failed to get video stream URL: {:?}", e))?;
|
|
|
|
Ok(AudioTrackSwitchResponse::ReloadStream {
|
|
new_url,
|
|
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 })
|
|
}
|
|
}
|
|
|
|
#[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))
|
|
}
|
|
|
|
#[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))
|
|
}
|
|
|
|
#[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> {
|
|
// 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(|item| MergedMediaItem::from(item))
|
|
};
|
|
|
|
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(|item| MergedMediaItem::from(item));
|
|
|
|
// 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))
|
|
}
|
|
|
|
|
|
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(),
|
|
position: controller.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);
|
|
}
|
|
|
|
// Find the index of the requested track
|
|
info!("Looking for track_id: {}", request.track_id);
|
|
let start_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, start_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 {
|
|
// Get stream URL from repository (works online/offline)
|
|
let stream_url = repository.get_audio_stream_url(&track.id).await
|
|
.map_err(|e| format!("Failed to get stream URL for {}: {}", track.name, e))?;
|
|
|
|
MediaSource::Remote {
|
|
stream_url,
|
|
jellyfin_item_id: jellyfin_id.clone(),
|
|
}
|
|
};
|
|
|
|
let primary_image_tag_for_url = track.primary_image_tag.clone();
|
|
let media_item = MediaItem {
|
|
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
|
|
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);
|
|
}
|
|
|
|
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 {
|
|
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> {
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
/// 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().unwrap();
|
|
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 {
|
|
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,
|
|
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().unwrap();
|
|
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 = vec![
|
|
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 = vec![
|
|
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");
|
|
}
|
|
}
|