Compare commits

..

No commits in common. "46b9d328abfec582d3ca26c67d5adc47d0858c86" and "0738ef10ecb0d45f4a88f7667b1c03da22db3750" have entirely different histories.

22 changed files with 315 additions and 502 deletions

View File

@ -35,8 +35,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libsoup-3.0-dev \ libsoup-3.0-dev \
librsvg2-dev \ librsvg2-dev \
libayatana-appindicator3-dev \ libayatana-appindicator3-dev \
# mpv player library (linked via libmpv-sys)
libmpv-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource # Install Node.js 20.x from NodeSource

View File

@ -33,8 +33,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libsoup-3.0-dev \ libsoup-3.0-dev \
librsvg2-dev \ librsvg2-dev \
libayatana-appindicator3-dev \ libayatana-appindicator3-dev \
# mpv player library (linked via libmpv-sys)
libmpv-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Node.js 20.x from NodeSource # Install Node.js 20.x from NodeSource

View File

@ -2,21 +2,19 @@
This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust. This document describes the current architecture of JellyTau, a cross-platform Jellyfin client built with Tauri, SvelteKit, and Rust.
**Last Updated:** 2026-06-20 **Last Updated:** 2026-03-01
## Architecture Overview ## Architecture Overview
JellyTau uses a client-server architecture: business logic lives in a comprehensive Rust backend, while a UI-rich Svelte frontend handles presentation and interaction. JellyTau uses a modern client-server architecture with a thin Svelte UI layer and comprehensive Rust backend:
### Architecture Principles ### Architecture Principles
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety. - **Thin UI Layer**: TypeScript reduced from ~3,300 to ~800 lines
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines. - **Business Logic in Rust**: Performance, reliability, type safety
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`). - **Event-Driven**: Rust emits events, TypeScript listens and updates UI
- **Handle-Based Resources**: UUID handles for stateful Rust objects. - **Handle-Based Resources**: UUID handles for stateful Rust objects
- **Cache-First**: Parallel queries with intelligent fallback. - **Cache-First**: Parallel queries with intelligent fallback
- **Poison-tolerant locking**: Shared `std::sync` state is accessed via the `MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned lock instead of cascading a panic across the player.
- **Graceful backend init**: If a native player backend (MPV/ExoPlayer) fails to initialize, the app falls back to a no-op backend and emits a `backend-init-failed` event rather than crashing.
```mermaid ```mermaid
flowchart TB flowchart TB
@ -192,11 +190,11 @@ src/lib/
4. **Database Service** - Async wrapper preventing UI freezing 4. **Database Service** - Async wrapper preventing UI freezing
5. **Playback Mode** (303 lines) - Local/remote transfer coordination 5. **Playback Mode** (303 lines) - Local/remote transfer coordination
**Svelte/TypeScript frontend (~20.5k non-test lines, plus ~9.6k test lines):** **TypeScript Layer (now ~800 lines, down from ~3,300):**
- Components + routes (~14.6k lines) — UI and presentation - Svelte stores (reactive wrappers)
- Stores (~3.4k lines) — reactive state that invokes Rust commands and listens for events - Type definitions
- api / services / utils (~2.4k lines) — typed clients, event listeners, conversion helpers - UI event handling
- Tauri command invocation
The frontend is genuinely UI-heavy; business decisions live in Rust, but the UI owns layout, navigation, and interaction state. - Event listeners for Rust events
**Total Commands:** 90+ Tauri commands across 14 command modules **Total Commands:** 90+ Tauri commands across 14 command modules

View File

@ -14,11 +14,6 @@ edition = "2021"
name = "jellytau_lib" name = "jellytau_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
# Keep debug info minimal to reduce target/ size in CI (line numbers in
# backtraces are preserved; the bulky full debuginfo is dropped).
[profile.dev]
debug = "line-tables-only"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }

View File

@ -1,7 +1,5 @@
//! Tauri commands for download operations //! Tauri commands for download operations
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::State; use tauri::State;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
@ -1579,7 +1577,7 @@ mod tests {
fn setup_test_db() -> Database { fn setup_test_db() -> Database {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create server // Create server
conn.execute( conn.execute(
@ -1622,7 +1620,7 @@ mod tests {
fn test_download_item_returns_correct_id_on_insert() { fn test_download_item_returns_correct_id_on_insert() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Insert a new download // Insert a new download
conn.execute( conn.execute(
@ -1660,7 +1658,7 @@ mod tests {
fn test_download_item_upsert_returns_correct_id() { fn test_download_item_upsert_returns_correct_id() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// First insert // First insert
conn.execute( conn.execute(
@ -1735,7 +1733,7 @@ mod tests {
// to get the correct download ID, regardless of whether INSERT or UPDATE occurred. // to get the correct download ID, regardless of whether INSERT or UPDATE occurred.
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// First insert // First insert
conn.execute( conn.execute(
@ -1783,7 +1781,7 @@ mod tests {
fn test_download_album_returns_correct_ids() { fn test_download_album_returns_correct_ids() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Insert downloads for multiple items (simulating album download) // Insert downloads for multiple items (simulating album download)
let track_ids = vec!["item1", "item2", "item3"]; let track_ids = vec!["item1", "item2", "item3"];
@ -1829,7 +1827,7 @@ mod tests {
fn test_download_album_upsert_returns_correct_ids() { fn test_download_album_upsert_returns_correct_ids() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// First download attempt - insert all // First download attempt - insert all
let track_ids = vec!["item1", "item2", "item3"]; let track_ids = vec!["item1", "item2", "item3"];
@ -1889,7 +1887,7 @@ mod tests {
fn test_get_downloads_returns_correct_metadata() { fn test_get_downloads_returns_correct_metadata() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Insert a download // Insert a download
conn.execute( conn.execute(
@ -1934,7 +1932,7 @@ mod tests {
fn test_download_status_transitions() { fn test_download_status_transitions() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Insert pending download // Insert pending download
conn.execute( conn.execute(
@ -1980,7 +1978,7 @@ mod tests {
fn test_download_progress_updates() { fn test_download_progress_updates() {
let db = setup_test_db(); let db = setup_test_db();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
conn.execute( conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size) "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)

View File

@ -1,6 +1,5 @@
//! 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 //! 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
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
@ -12,9 +11,8 @@ use super::DatabaseWrapper;
use crate::download::cache::{CacheConfig, SmartCache}; use crate::download::cache::{CacheConfig, SmartCache};
use crate::jellyfin::{JellyfinClient, JellyfinConfig}; use crate::jellyfin::{JellyfinClient, JellyfinConfig};
use crate::player::{ use crate::player::{
determine_video_seek_strategy, AutoplaySettings, MediaItem, MediaSource, MediaType, AutoplaySettings, MediaItem, MediaSource, MediaType, MediaSessionManager, PlayerController,
MediaSessionManager, PlayerController, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, PlayerState, PlayerStatusEvent, QueueContext, RepeatMode, SleepTimerMode, SleepTimerState,
SleepTimerMode, SleepTimerState, VideoSeekStrategy,
}; };
use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}}; use crate::repository::{MediaRepository, types::{GetItemsOptions, ImageOptions, ImageType}};
use crate::settings::{AudioSettings, VideoSettings}; use crate::settings::{AudioSettings, VideoSettings};
@ -253,6 +251,61 @@ pub enum VideoSeekResponse {
}, },
} }
/// Internal enum for seek strategy decision (used for testing)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
BackendReloadStream,
}
/// Determine the video seek strategy based on stream characteristics
///
/// This is a pure function extracted for testability.
///
/// # Arguments
/// * `is_local` - Whether the file is a local download
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
is_hls: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
return VideoSeekStrategy::LocalNativeSeek;
}
// HLS streams and direct play (non-transcoded) support native seeking
if is_hls || !needs_transcoding {
if use_html5 {
// HTML5 backend - frontend handles seeking via videoElement.currentTime
// We don't call backend.seek() because video is in HTML5 element, not in MPV
VideoSeekStrategy::Html5NativeSeek
} else {
// Native backend (MPV) - backend handles seeking
VideoSeekStrategy::BackendNativeSeek
}
} else {
// Transcoded non-HLS streams need server-side seek (reload from new position)
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
}
}
/// Response for audio track switching operations /// Response for audio track switching operations
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[serde(tag = "strategy", rename_all = "camelCase")] #[serde(tag = "strategy", rename_all = "camelCase")]
@ -1238,7 +1291,7 @@ fn get_player_status(controller: &PlayerController) -> PlayerStatus {
fn get_queue_status(controller: &PlayerController) -> QueueStatus { fn get_queue_status(controller: &PlayerController) -> QueueStatus {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
QueueStatus { QueueStatus {
items: queue_lock.items().to_vec(), items: queue_lock.items().to_vec(),
@ -2480,6 +2533,86 @@ pub async fn player_dismiss_session(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
/// Test video seek strategy for local files
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek
);
}
/// Test video seek strategy for HLS streams
#[test]
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// HLS with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
/// Test track index finding in album /// Test track index finding in album
/// This reproduces the bug where clicking songs 1-5 always played song 13 /// This reproduces the bug where clicking songs 1-5 always played song 13
#[test] #[test]

View File

@ -3,7 +3,6 @@
//! //!
//! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031 //! TRACES: UR-007, UR-035, UR-036 | JA-004, JA-005, JA-029, JA-030, JA-031
use crate::utils::lock::MutexSafe;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -27,17 +26,17 @@ impl RepositoryManager {
} }
pub fn create(&self, handle: String, repository: HybridRepository) { pub fn create(&self, handle: String, repository: HybridRepository) {
let mut repos = self.repositories.lock_safe(); let mut repos = self.repositories.lock().unwrap();
repos.insert(handle, Arc::new(repository)); repos.insert(handle, Arc::new(repository));
} }
pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> { pub fn get(&self, handle: &str) -> Option<Arc<HybridRepository>> {
let repos = self.repositories.lock_safe(); let repos = self.repositories.lock().unwrap();
repos.get(handle).cloned() repos.get(handle).cloned()
} }
pub fn destroy(&self, handle: &str) { pub fn destroy(&self, handle: &str) {
let mut repos = self.repositories.lock_safe(); let mut repos = self.repositories.lock().unwrap();
repos.remove(handle); repos.remove(handle);
} }
} }

View File

@ -1,7 +1,5 @@
//! Smart caching engine for predictive downloads //! Smart caching engine for predictive downloads
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::{debug, info}; use log::{debug, info};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -311,7 +309,7 @@ mod tests {
// Add some downloads // Add some downloads
{ {
let conn_guard = conn_arc.lock_safe(); let conn_guard = conn_arc.lock().unwrap();
conn_guard.execute( conn_guard.execute(
"INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)", "INSERT INTO downloads (user_id, status, file_size) VALUES ('user1', 'completed', 600)",
[], [],

View File

@ -10,7 +10,6 @@ pub mod cache;
pub mod events; pub mod events;
pub mod worker; pub mod worker;
use crate::utils::lock::MutexSafe;
use std::path::PathBuf; use std::path::PathBuf;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -46,18 +45,18 @@ impl DownloadManager {
/// Check if a new download can be started based on concurrent limit /// Check if a new download can be started based on concurrent limit
pub fn can_start_download(&self) -> bool { pub fn can_start_download(&self) -> bool {
let active = self.active_downloads.lock_safe(); let active = self.active_downloads.lock().unwrap();
active.len() < self.max_concurrent active.len() < self.max_concurrent
} }
/// Get the number of currently active downloads /// Get the number of currently active downloads
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.active_downloads.lock_safe().len() self.active_downloads.lock().unwrap().len()
} }
/// Register a download as active /// Register a download as active
pub fn register_download(&self, download_id: i64) -> bool { pub fn register_download(&self, download_id: i64) -> bool {
let mut active = self.active_downloads.lock_safe(); let mut active = self.active_downloads.lock().unwrap();
if active.len() >= self.max_concurrent { if active.len() >= self.max_concurrent {
return false; return false;
} }
@ -66,7 +65,7 @@ impl DownloadManager {
/// Unregister a download when it completes or fails /// Unregister a download when it completes or fails
pub fn unregister_download(&self, download_id: i64) { pub fn unregister_download(&self, download_id: i64) {
let mut active = self.active_downloads.lock_safe(); let mut active = self.active_downloads.lock().unwrap();
active.remove(&download_id); active.remove(&download_id);
} }

View File

@ -16,7 +16,7 @@ mod utils;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use tauri::{Emitter, Manager}; use tauri::Manager;
use log::{error, info}; use log::{error, info};
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use log::warn; use log::warn;
@ -120,9 +120,8 @@ use download::cache::{CacheConfig as SmartCacheConfig, SmartCache};
use download::DownloadManager; use download::DownloadManager;
use jellyfin::{HttpClient, HttpConfig}; use jellyfin::{HttpClient, HttpConfig};
use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter}; use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmitter};
// NullBackend is used both for platforms without a native backend AND as a graceful // NullBackend fallback for platforms without native backends (not Linux or Android)
// fallback when a native backend (MPV/ExoPlayer) fails to initialize, so the app can #[cfg(not(any(target_os = "linux", target_os = "android")))]
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend; use player::NullBackend;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -226,39 +225,13 @@ impl RemoteVolumeHandler for RemoteVolumeSessionHandler {
} }
} }
/// Payload emitted to the frontend when a native player backend fails to
/// initialize and the app falls back to a no-op backend.
#[derive(Clone, serde::Serialize)]
struct BackendInitError {
platform: &'static str,
backend: &'static str,
message: String,
}
/// Log a backend-initialization failure and notify the frontend, so the UI can
/// surface "playback unavailable" instead of the app hard-crashing.
fn emit_backend_init_failed(app_handle: &tauri::AppHandle, backend: &'static str, message: String) {
error!(
"[INIT] Player backend '{}' failed to initialize: {}. Falling back to NullBackend (playback disabled).",
backend, message
);
let _ = app_handle.emit(
"backend-init-failed",
BackendInitError {
platform: std::env::consts::OS,
backend,
message,
},
);
}
/// Create the appropriate player backend for the current platform. /// Create the appropriate player backend for the current platform.
fn create_player_backend( fn create_player_backend(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>, playback_reporter: Arc<tokio::sync::Mutex<Option<playback_reporting::PlaybackReporter>>>,
position_throttler: Arc<playback_reporting::EventThrottler>, position_throttler: Arc<playback_reporting::EventThrottler>,
) -> Box<dyn PlayerBackend> { ) -> Box<dyn PlayerBackend> {
let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle.clone())); let _event_emitter = Arc::new(TauriEventEmitter::new(app_handle));
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
@ -282,21 +255,17 @@ fn create_player_backend(
return Box::new(backend); return Box::new(backend);
} }
Err(e) => { Err(e) => {
// Degrade gracefully instead of crashing the app. panic!("FATAL: Failed to initialize ExoPlayer backend on Android: {}. This is a critical error - playback will not work.", e);
emit_backend_init_failed(&app_handle, "exoplayer", e.to_string());
return Box::new(NullBackend::new());
} }
} }
} }
Err(e) => { Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("attach JNI thread failed: {}", e)); panic!("FATAL: Failed to attach JNI thread on Android: {}. This is a critical error - playback will not work.", e);
return Box::new(NullBackend::new());
} }
} }
} }
Err(e) => { Err(e) => {
emit_backend_init_failed(&app_handle, "exoplayer", format!("create JavaVM failed: {}", e)); panic!("FATAL: Failed to create JavaVM on Android: {}. This is a critical error - playback will not work.", e);
return Box::new(NullBackend::new());
} }
} }
} }
@ -329,11 +298,7 @@ fn create_player_backend(
error!("\nAudio playback will NOT work until this is fixed."); error!("\nAudio playback will NOT work until this is fixed.");
error!("========================================\n"); error!("========================================\n");
// Degrade gracefully: launch with a no-op backend so the user can panic!("Cannot start application: MPV backend initialization failed. See error message above.");
// still browse the library and manage downloads, and the frontend
// can show a "playback unavailable" notice via this event.
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
return Box::new(NullBackend::new());
} }
} }
} }

View File

@ -1,4 +1,3 @@
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, error, info}; use log::{debug, error, info};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::{ use std::sync::{
@ -44,13 +43,13 @@ impl PlaybackModeManager {
/// Get current playback mode /// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode { pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone() self.current_mode.read().unwrap().clone()
} }
/// Set playback mode (internal use) /// Set playback mode (internal use)
pub fn set_mode(&self, mode: PlaybackMode) { pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode); log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe(); let mut current = self.current_mode.write().unwrap();
*current = mode; *current = mode;
} }
@ -194,7 +193,7 @@ impl PlaybackModeManager {
debug!("[PlaybackMode] Player controller lock acquired"); debug!("[PlaybackMode] Player controller lock acquired");
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock().unwrap();
let state = player.state(); let state = player.state();
let original_index = queue.current_index().unwrap_or(0); let original_index = queue.current_index().unwrap_or(0);
@ -400,7 +399,7 @@ impl PlaybackModeManager {
// Log queue state BEFORE stop // Log queue state BEFORE stop
{ {
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock().unwrap();
info!( info!(
"[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}", "[PlaybackMode] BEFORE STOP: Queue has {} items, current_index={:?}",
queue.items().len(), queue.items().len(),
@ -413,7 +412,7 @@ impl PlaybackModeManager {
// Log queue state AFTER stop (should be unchanged) // Log queue state AFTER stop (should be unchanged)
{ {
let queue_arc = player.queue(); let queue_arc = player.queue();
let queue = queue_arc.lock_safe(); let queue = queue_arc.lock().unwrap();
info!( info!(
"[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}", "[PlaybackMode] AFTER STOP: Queue has {} items, current_index={:?}",
queue.items().len(), queue.items().len(),

View File

@ -5,7 +5,6 @@
#![allow(dead_code)] #![allow(dead_code)]
use crate::utils::lock::MutexSafe;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -35,7 +34,7 @@ impl EventThrottler {
/// Checks if enough time has elapsed since the last report for this item /// Checks if enough time has elapsed since the last report for this item
pub fn should_report(&self, item_id: &str) -> bool { pub fn should_report(&self, item_id: &str) -> bool {
let last_times = self.last_report_time.lock_safe(); let last_times = self.last_report_time.lock().unwrap();
if let Some(last_time) = last_times.get(item_id) { if let Some(last_time) = last_times.get(item_id) {
let elapsed = last_time.elapsed(); let elapsed = last_time.elapsed();
@ -55,7 +54,7 @@ impl EventThrottler {
/// Marks the item as reported at the current time /// Marks the item as reported at the current time
pub fn mark_reported(&self, item_id: &str) { pub fn mark_reported(&self, item_id: &str) {
let mut last_times = self.last_report_time.lock_safe(); let mut last_times = self.last_report_time.lock().unwrap();
last_times.insert(item_id.to_string(), Instant::now()); last_times.insert(item_id.to_string(), Instant::now());
log::debug!( log::debug!(
@ -67,14 +66,14 @@ impl EventThrottler {
/// Clears all tracked report times /// Clears all tracked report times
pub fn clear(&self) { pub fn clear(&self) {
let mut last_times = self.last_report_time.lock_safe(); let mut last_times = self.last_report_time.lock().unwrap();
last_times.clear(); last_times.clear();
log::debug!("[EventThrottler] Cleared all tracked report times"); log::debug!("[EventThrottler] Cleared all tracked report times");
} }
/// Removes a specific item from tracking /// Removes a specific item from tracking
pub fn clear_item(&self, item_id: &str) { pub fn clear_item(&self, item_id: &str) {
let mut last_times = self.last_report_time.lock_safe(); let mut last_times = self.last_report_time.lock().unwrap();
last_times.remove(item_id); last_times.remove(item_id);
log::debug!("[EventThrottler] Cleared tracking for {}", item_id); log::debug!("[EventThrottler] Cleared tracking for {}", item_id);
} }

View File

@ -3,7 +3,6 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer //! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code. //! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex as TokioMutex;
use log::debug; use log::debug;
@ -304,7 +303,7 @@ impl PlayerBackend for ExoPlayerBackend {
// Update local state // Update local state
{ {
let mut state = self.shared_state.lock_safe(); let mut state = self.shared_state.lock().unwrap();
state.current_media = Some(media.clone()); state.current_media = Some(media.clone());
state.state = PlayerState::Loading { state.state = PlayerState::Loading {
media: media.clone(), media: media.clone(),
@ -431,7 +430,7 @@ impl PlayerBackend for ExoPlayerBackend {
fn stop(&mut self) -> Result<(), PlayerError> { fn stop(&mut self) -> Result<(), PlayerError> {
{ {
let mut state = self.shared_state.lock_safe(); let mut state = self.shared_state.lock().unwrap();
state.state = PlayerState::Idle; state.state = PlayerState::Idle;
state.is_loaded = false; state.is_loaded = false;
state.current_media = None; state.current_media = None;
@ -480,24 +479,24 @@ impl PlayerBackend for ExoPlayerBackend {
) )
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?; .map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
self.shared_state.lock_safe().volume = clamped; self.shared_state.lock().unwrap().volume = clamped;
Ok(()) Ok(())
} }
fn position(&self) -> f64 { fn position(&self) -> f64 {
self.shared_state.lock_safe().position self.shared_state.lock().unwrap().position
} }
fn duration(&self) -> Option<f64> { fn duration(&self) -> Option<f64> {
self.shared_state.lock_safe().duration self.shared_state.lock().unwrap().duration
} }
fn state(&self) -> PlayerState { fn state(&self) -> PlayerState {
self.shared_state.lock_safe().state.clone() self.shared_state.lock().unwrap().state.clone()
} }
fn volume(&self) -> f32 { fn volume(&self) -> f32 {
self.shared_state.lock_safe().volume self.shared_state.lock().unwrap().volume
} }
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> { fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
@ -565,7 +564,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update state and get the preserved duration to emit // Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() { let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe(); let mut state = state.lock().unwrap();
state.position = position; state.position = position;
if duration > 0.0 { if duration > 0.0 {
state.duration = Some(duration); state.duration = Some(duration);
@ -607,7 +606,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update shared state // Update shared state
if let Some(shared) = SHARED_STATE.get() { if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe(); let mut shared = shared.lock().unwrap();
if let Some(media) = shared.current_media.clone() { if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0); let duration = shared.duration.unwrap_or(0.0);
let position = shared.position; let position = shared.position;
@ -652,7 +651,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
duration: jdouble, duration: jdouble,
) { ) {
if let Some(state) = SHARED_STATE.get() { if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe(); let mut state = state.lock().unwrap();
state.duration = Some(duration); state.duration = Some(duration);
state.is_loaded = true; state.is_loaded = true;
} }
@ -693,7 +692,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing // Log queue state before advancing
let queue_info = { let queue_info = {
let queue = ctrl.queue.lock_safe(); let queue = ctrl.queue.lock().unwrap();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len()) format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
}; };
log::debug!("[Autoplay] Queue state before next(): {}", queue_info); log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@ -703,7 +702,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
log::info!("[Autoplay] Successfully advanced to next track"); log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing // Log queue state after advancing
let queue_info = { let queue_info = {
let queue = ctrl.queue.lock_safe(); let queue = ctrl.queue.lock().unwrap();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len()) format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
}; };
log::debug!("[Autoplay] Queue state after next(): {}", queue_info); log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@ -812,7 +811,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
muted: jboolean, muted: jboolean,
) { ) {
if let Some(state) = SHARED_STATE.get() { if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume; state.lock().unwrap().volume = volume;
} }
if let Some(emitter) = EVENT_EMITTER.get() { if let Some(emitter) = EVENT_EMITTER.get() {

View File

@ -5,8 +5,6 @@
//! //!
//! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047 //! TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use log::error; use log::error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
@ -170,17 +168,17 @@ mod tests {
} }
pub fn events(&self) -> Vec<PlayerStatusEvent> { pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock_safe().clone() self.events.lock().unwrap().clone()
} }
pub fn clear(&self) { pub fn clear(&self) {
self.events.lock_safe().clear(); self.events.lock().unwrap().clear();
} }
} }
impl PlayerEventEmitter for TestEventEmitter { impl PlayerEventEmitter for TestEventEmitter {
fn emit(&self, event: PlayerStatusEvent) { fn emit(&self, event: PlayerStatusEvent) {
self.events.lock_safe().push(event); self.events.lock().unwrap().push(event);
} }
} }

View File

@ -7,7 +7,6 @@ pub mod backend;
pub mod events; pub mod events;
pub mod media; pub mod media;
pub mod queue; pub mod queue;
pub mod seek;
pub mod session; pub mod session;
pub mod sleep_timer; pub mod sleep_timer;
pub mod state; pub mod state;
@ -28,7 +27,6 @@ pub use backend::{NullBackend, PlayerBackend, PlayerError};
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter}; pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
pub use media::{MediaItem, MediaSource, MediaType, QueueContext}; pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
pub use queue::{QueueManager, RepeatMode}; pub use queue::{QueueManager, RepeatMode};
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType}; pub use session::{MediaSessionManager, MediaSessionType};
pub use sleep_timer::{SleepTimerMode, SleepTimerState}; pub use sleep_timer::{SleepTimerMode, SleepTimerState};
pub use state::{EndReason, PlayerState}; pub use state::{EndReason, PlayerState};
@ -46,7 +44,6 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs, set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
}; };
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn}; use log::{debug, error, warn};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@ -124,7 +121,7 @@ impl PlayerController {
/// Configure the Jellyfin API client for automatic playback reporting /// Configure the Jellyfin API client for automatic playback reporting
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) { pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock_safe(); let mut jellyfin = self.jellyfin_client.lock().unwrap();
*jellyfin = client; *jellyfin = client;
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some()); log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
} }
@ -160,24 +157,24 @@ impl PlayerController {
/// Set the end reason for the next playback end event /// Set the end reason for the next playback end event
fn set_end_reason(&self, reason: EndReason) { fn set_end_reason(&self, reason: EndReason) {
log::debug!("[PlayerController] Setting end reason: {:?}", reason); log::debug!("[PlayerController] Setting end reason: {:?}", reason);
*self.end_reason.lock_safe() = Some(reason); *self.end_reason.lock().unwrap() = Some(reason);
} }
/// Get and clear the current end reason /// Get and clear the current end reason
fn take_end_reason(&self) -> Option<EndReason> { fn take_end_reason(&self) -> Option<EndReason> {
self.end_reason.lock_safe().take() self.end_reason.lock().unwrap().take()
} }
/// Increment autoplay episode counter. Returns true if limit is reached. /// Increment autoplay episode counter. Returns true if limit is reached.
fn increment_autoplay_count(&self) -> bool { fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock_safe().max_episodes; let max = self.autoplay_settings.lock().unwrap().max_episodes;
if max == 0 { if max == 0 {
// Unlimited // Unlimited
return false; return false;
} }
let mut count = self.autoplay_episode_count.lock_safe(); let mut count = self.autoplay_episode_count.lock().unwrap();
*count += 1; *count += 1;
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max); debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
@ -186,7 +183,7 @@ impl PlayerController {
/// Reset autoplay episode counter (called on manual play actions) /// Reset autoplay episode counter (called on manual play actions)
fn reset_autoplay_count(&self) { fn reset_autoplay_count(&self) {
let mut count = self.autoplay_episode_count.lock_safe(); let mut count = self.autoplay_episode_count.lock().unwrap();
if *count > 0 { if *count > 0 {
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count); debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
} }
@ -202,7 +199,7 @@ impl PlayerController {
// Update queue with this single item // Update queue with this single item
{ {
let mut queue = self.queue.lock_safe(); let mut queue = self.queue.lock().unwrap();
queue.set_queue(vec![item.clone()], 0); queue.set_queue(vec![item.clone()], 0);
} }
@ -220,7 +217,7 @@ impl PlayerController {
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track // Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
self.set_end_reason(EndReason::NewTrackLoaded); self.set_end_reason(EndReason::NewTrackLoaded);
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.load(item)?; backend.load(item)?;
backend.play()?; backend.play()?;
drop(backend); drop(backend);
@ -300,12 +297,12 @@ impl PlayerController {
self.reset_autoplay_count(); self.reset_autoplay_count();
{ {
let mut queue = self.queue.lock_safe(); let mut queue = self.queue.lock().unwrap();
queue.set_queue(items, start_index); queue.set_queue(items, start_index);
} }
// Play the current item (without modifying the queue we just set) // Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock_safe().current().cloned() { if let Some(item) = self.queue.lock().unwrap().current().cloned() {
self.load_and_play(&item)?; self.load_and_play(&item)?;
} }
@ -315,19 +312,19 @@ impl PlayerController {
/// Play/resume playback /// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> { pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play"); debug!("[PlayerController] play");
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.play() backend.play()
} }
/// Pause playback /// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> { pub fn pause(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.pause() backend.pause()
} }
/// Toggle play/pause /// Toggle play/pause
pub fn toggle_playback(&self) -> Result<(), PlayerError> { pub fn toggle_playback(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
if backend.state().is_playing() { if backend.state().is_playing() {
backend.pause() backend.pause()
} else { } else {
@ -342,16 +339,16 @@ impl PlayerController {
// Get current playback info before stopping // Get current playback info before stopping
let jellyfin_id = { let jellyfin_id = {
let queue = self.queue.lock_safe(); let queue = self.queue.lock().unwrap();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string())) queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
}; };
let position_ticks = { let position_ticks = {
let backend = self.backend.lock_safe(); let backend = self.backend.lock().unwrap();
(backend.position() * 10_000_000.0) as i64 (backend.position() * 10_000_000.0) as i64
}; };
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.stop()?; backend.stop()?;
drop(backend); drop(backend);
@ -415,7 +412,7 @@ impl PlayerController {
self.reset_autoplay_count(); self.reset_autoplay_count();
let next_item = { let next_item = {
let mut queue = self.queue.lock_safe(); let mut queue = self.queue.lock().unwrap();
queue.next().cloned() queue.next().cloned()
}; };
@ -438,7 +435,7 @@ impl PlayerController {
self.reset_autoplay_count(); self.reset_autoplay_count();
// If we're more than 3 seconds in, restart current track // If we're more than 3 seconds in, restart current track
{ {
let backend = self.backend.lock_safe(); let backend = self.backend.lock().unwrap();
if backend.position() > 3.0 { if backend.position() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)"); debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend); drop(backend);
@ -447,7 +444,7 @@ impl PlayerController {
} }
let prev_item = { let prev_item = {
let mut queue = self.queue.lock_safe(); let mut queue = self.queue.lock().unwrap();
queue.previous().cloned() queue.previous().cloned()
}; };
@ -462,40 +459,40 @@ impl PlayerController {
/// Seek to a position in seconds /// Seek to a position in seconds
pub fn seek(&self, position: f64) -> Result<(), PlayerError> { pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.seek(position) backend.seek(position)
} }
/// Set volume (0.0 - 1.0) /// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> { pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock_safe().set_volume(volume) self.backend.lock().unwrap().set_volume(volume)
} }
/// Set the active audio track by stream index /// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> { pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.set_audio_track(stream_index) backend.set_audio_track(stream_index)
} }
/// Set the active subtitle track by stream index (None to disable subtitles) /// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> { pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock_safe(); let mut backend = self.backend.lock().unwrap();
backend.set_subtitle_track(stream_index) backend.set_subtitle_track(stream_index)
} }
/// Get current state /// Get current state
pub fn state(&self) -> PlayerState { pub fn state(&self) -> PlayerState {
self.backend.lock_safe().state() self.backend.lock().unwrap().state()
} }
/// Get current position /// Get current position
pub fn position(&self) -> f64 { pub fn position(&self) -> f64 {
self.backend.lock_safe().position() self.backend.lock().unwrap().position()
} }
/// Get duration /// Get duration
pub fn duration(&self) -> Option<f64> { pub fn duration(&self) -> Option<f64> {
self.backend.lock_safe().duration() self.backend.lock().unwrap().duration()
} }
/// Get queue reference /// Get queue reference
@ -505,27 +502,27 @@ impl PlayerController {
/// Toggle shuffle /// Toggle shuffle
pub fn toggle_shuffle(&self) { pub fn toggle_shuffle(&self) {
self.queue.lock_safe().toggle_shuffle(); self.queue.lock().unwrap().toggle_shuffle();
} }
/// Cycle repeat mode /// Cycle repeat mode
pub fn cycle_repeat(&self) { pub fn cycle_repeat(&self) {
self.queue.lock_safe().cycle_repeat(); self.queue.lock().unwrap().cycle_repeat();
} }
/// Check if shuffle is enabled /// Check if shuffle is enabled
pub fn is_shuffle(&self) -> bool { pub fn is_shuffle(&self) -> bool {
self.queue.lock_safe().is_shuffle() self.queue.lock().unwrap().is_shuffle()
} }
/// Get repeat mode /// Get repeat mode
pub fn repeat_mode(&self) -> RepeatMode { pub fn repeat_mode(&self) -> RepeatMode {
self.queue.lock_safe().repeat_mode() self.queue.lock().unwrap().repeat_mode()
} }
/// Get current volume (0.0 - 1.0) /// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 { pub fn volume(&self) -> f32 {
self.backend.lock_safe().volume() self.backend.lock().unwrap().volume()
} }
/// Check if muted /// Check if muted
@ -535,35 +532,35 @@ impl PlayerController {
/// Set audio settings (crossfade, gapless, normalization) /// Set audio settings (crossfade, gapless, normalization)
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
self.backend.lock_safe().set_audio_settings(settings) self.backend.lock().unwrap().set_audio_settings(settings)
} }
/// Get current audio settings /// Get current audio settings
pub fn audio_settings(&self) -> AudioSettings { pub fn audio_settings(&self) -> AudioSettings {
self.backend.lock_safe().audio_settings() self.backend.lock().unwrap().audio_settings()
} }
// ===== Sleep Timer Methods ===== // ===== Sleep Timer Methods =====
/// Set the event emitter for notifications /// Set the event emitter for notifications
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) { pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
let mut event_emitter = self.event_emitter.lock_safe(); let mut event_emitter = self.event_emitter.lock().unwrap();
*event_emitter = Some(emitter); *event_emitter = Some(emitter);
} }
/// Get the event emitter /// Get the event emitter
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> { pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
self.event_emitter.lock_safe().clone() self.event_emitter.lock().unwrap().clone()
} }
/// Get sleep timer state /// Get sleep timer state
pub fn sleep_timer_state(&self) -> SleepTimerState { pub fn sleep_timer_state(&self) -> SleepTimerState {
self.sleep_timer.lock_safe().clone() self.sleep_timer.lock().unwrap().clone()
} }
/// Set sleep timer mode (in-memory only, not persisted) /// Set sleep timer mode (in-memory only, not persisted)
pub fn set_sleep_timer(&self, mode: SleepTimerMode) { pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
let mut timer = self.sleep_timer.lock_safe(); let mut timer = self.sleep_timer.lock().unwrap();
timer.mode = mode.clone(); timer.mode = mode.clone();
if let SleepTimerMode::Time { end_time } = mode { if let SleepTimerMode::Time { end_time } = mode {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
@ -592,7 +589,7 @@ impl PlayerController {
loop { loop {
std::thread::sleep(Duration::from_secs(1)); std::thread::sleep(Duration::from_secs(1));
let mut timer = sleep_timer.lock_safe(); let mut timer = sleep_timer.lock().unwrap();
if timer.is_active() { if timer.is_active() {
timer.update_remaining_seconds(); timer.update_remaining_seconds();
@ -602,7 +599,7 @@ impl PlayerController {
timer.cancel(); timer.cancel();
// Emit cancelled state // Emit cancelled state
if let Some(emitter) = event_emitter.lock_safe().as_ref() { if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged { emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: SleepTimerMode::Off, mode: SleepTimerMode::Off,
remaining_seconds: 0, remaining_seconds: 0,
@ -611,14 +608,14 @@ impl PlayerController {
drop(timer); drop(timer);
// Stop the backend // Stop the backend
if let Err(e) = backend.lock_safe().stop() { if let Err(e) = backend.lock().unwrap().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e); error!("[SleepTimer] Failed to stop playback: {}", e);
} }
continue; continue;
} }
// Emit update event // Emit update event
if let Some(emitter) = event_emitter.lock_safe().as_ref() { if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged { emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode.clone(), mode: timer.mode.clone(),
remaining_seconds: timer.remaining_seconds, remaining_seconds: timer.remaining_seconds,
@ -632,9 +629,9 @@ impl PlayerController {
/// Emit sleep timer changed event to frontend /// Emit sleep timer changed event to frontend
fn emit_sleep_timer_changed(&self) { fn emit_sleep_timer_changed(&self) {
let timer = self.sleep_timer.lock_safe().clone(); let timer = self.sleep_timer.lock().unwrap().clone();
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged { emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode, mode: timer.mode,
remaining_seconds: timer.remaining_seconds, remaining_seconds: timer.remaining_seconds,
@ -644,12 +641,12 @@ impl PlayerController {
/// Emit queue changed event to frontend /// Emit queue changed event to frontend
pub fn emit_queue_changed(&self) { pub fn emit_queue_changed(&self) {
let queue = self.queue.lock_safe(); let queue = self.queue.lock().unwrap();
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}", debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
queue.items().len(), queue.current_index()); queue.items().len(), queue.current_index());
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() { if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::QueueChanged { emitter.emit(PlayerStatusEvent::QueueChanged {
items: queue.items().to_vec(), items: queue.items().to_vec(),
current_index: queue.current_index(), current_index: queue.current_index(),
@ -667,19 +664,19 @@ impl PlayerController {
/// Get autoplay settings /// Get autoplay settings
pub fn autoplay_settings(&self) -> AutoplaySettings { pub fn autoplay_settings(&self) -> AutoplaySettings {
self.autoplay_settings.lock_safe().clone() self.autoplay_settings.lock().unwrap().clone()
} }
/// Set autoplay settings (in-memory only, persistence handled by command layer) /// Set autoplay settings (in-memory only, persistence handled by command layer)
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) { pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
let validated = settings.with_validated_countdown(); let validated = settings.with_validated_countdown();
*self.autoplay_settings.lock_safe() = validated; *self.autoplay_settings.lock().unwrap() = validated;
} }
/// Cancel active autoplay countdown /// Cancel active autoplay countdown
pub fn cancel_autoplay_countdown(&self) { pub fn cancel_autoplay_countdown(&self) {
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() { if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
*cancel_flag.lock_safe() = true; *cancel_flag.lock().unwrap() = true;
} }
} }
@ -722,7 +719,7 @@ impl PlayerController {
} }
let current_item = { let current_item = {
let queue = self.queue.lock_safe(); let queue = self.queue.lock().unwrap();
queue.current().cloned() queue.current().cloned()
}; };
@ -732,7 +729,7 @@ impl PlayerController {
// Check sleep timer state // Check sleep timer state
let timer_mode = { let timer_mode = {
let timer = self.sleep_timer.lock_safe(); let timer = self.sleep_timer.lock().unwrap();
timer.mode.clone() timer.mode.clone()
}; };
@ -742,14 +739,14 @@ impl PlayerController {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time { if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at track boundary"); debug!("[PlayerController] Time-based sleep timer expired at track boundary");
self.sleep_timer.lock_safe().cancel(); self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
} }
} }
SleepTimerMode::EndOfTrack => { SleepTimerMode::EndOfTrack => {
// Stop at end of track // Stop at end of track
self.sleep_timer.lock_safe().cancel(); self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
} }
@ -759,7 +756,7 @@ impl PlayerController {
&& self.is_episode_item(&current).await; && self.is_episode_item(&current).await;
if is_episode { if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode(); let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
if should_stop { if should_stop {
@ -776,7 +773,7 @@ impl PlayerController {
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended). // Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue. // It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await { if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
let repo = self.repository.lock_safe().clone(); let repo = self.repository.lock().unwrap().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id); let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo { let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await? self.fetch_next_episode_for_item(jellyfin_id, repo).await?
@ -785,7 +782,7 @@ impl PlayerController {
None None
}; };
if let Some(next_ep) = next_ep_result { if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock_safe().clone(); let settings = self.autoplay_settings.lock().unwrap().clone();
// Check if auto-play episode limit is reached // Check if auto-play episode limit is reached
let limit_reached = self.increment_autoplay_count(); let limit_reached = self.increment_autoplay_count();
@ -806,7 +803,7 @@ impl PlayerController {
// For audio/movies, check if there's a next track in the queue // For audio/movies, check if there's a next track in the queue
let has_next = { let has_next = {
let queue = self.queue.lock_safe(); let queue = self.queue.lock().unwrap();
queue.has_next() queue.has_next()
}; };
@ -840,7 +837,7 @@ impl PlayerController {
// Check sleep timer state // Check sleep timer state
let timer_mode = { let timer_mode = {
let timer = self.sleep_timer.lock_safe(); let timer = self.sleep_timer.lock().unwrap();
timer.mode.clone() timer.mode.clone()
}; };
@ -849,18 +846,18 @@ impl PlayerController {
let now = chrono::Utc::now().timestamp_millis(); let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time { if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at video end"); debug!("[PlayerController] Time-based sleep timer expired at video end");
self.sleep_timer.lock_safe().cancel(); self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
} }
} }
SleepTimerMode::EndOfTrack => { SleepTimerMode::EndOfTrack => {
self.sleep_timer.lock_safe().cancel(); self.sleep_timer.lock().unwrap().cancel();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
} }
SleepTimerMode::Episodes { .. } => { SleepTimerMode::Episodes { .. } => {
let should_stop = self.sleep_timer.lock_safe().decrement_episode(); let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
self.emit_sleep_timer_changed(); self.emit_sleep_timer_changed();
if should_stop { if should_stop {
return Ok(AutoplayDecision::Stop); return Ok(AutoplayDecision::Stop);
@ -871,7 +868,7 @@ impl PlayerController {
// Fetch next episode for the video that just ended // Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? { if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
let settings = self.autoplay_settings.lock_safe().clone(); let settings = self.autoplay_settings.lock().unwrap().clone();
let limit_reached = self.increment_autoplay_count(); let limit_reached = self.increment_autoplay_count();
if limit_reached { if limit_reached {
@ -964,7 +961,7 @@ impl PlayerController {
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) { pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
// Create cancellation flag // Create cancellation flag
let cancel_flag = Arc::new(Mutex::new(false)); let cancel_flag = Arc::new(Mutex::new(false));
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone()); *self.countdown_cancel.lock().unwrap() = Some(cancel_flag.clone());
let event_emitter = self.event_emitter.clone(); let event_emitter = self.event_emitter.clone();
@ -975,7 +972,7 @@ impl PlayerController {
std::thread::sleep(Duration::from_secs(1)); std::thread::sleep(Duration::from_secs(1));
// Check cancellation // Check cancellation
if *cancel_flag.lock_safe() { if *cancel_flag.lock().unwrap() {
log::info!("[PlayerController] Autoplay countdown cancelled"); log::info!("[PlayerController] Autoplay countdown cancelled");
return; return;
} }
@ -983,7 +980,7 @@ impl PlayerController {
remaining -= 1; remaining -= 1;
// Emit countdown tick event // Emit countdown tick event
if let Some(emitter) = event_emitter.lock_safe().as_ref() { if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
emitter.emit(PlayerStatusEvent::CountdownTick { emitter.emit(PlayerStatusEvent::CountdownTick {
remaining_seconds: remaining, remaining_seconds: remaining,
}); });
@ -1084,7 +1081,7 @@ mod tests {
// Verify initial state // Verify initial state
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items"); assert_eq!(queue_lock.items().len(), 5, "Queue should have 5 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0"); assert_eq!(queue_lock.current_index(), Some(0), "Should start at index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0"); assert_eq!(queue_lock.current().unwrap().id, "item_0", "Current item should be item_0");
@ -1096,7 +1093,7 @@ mod tests {
// Verify queue is intact and index advanced // Verify queue is intact and index advanced
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip"); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after skip");
assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1"); assert_eq!(queue_lock.current_index(), Some(1), "Index should advance to 1");
assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1"); assert_eq!(queue_lock.current().unwrap().id, "item_1", "Current item should be item_1");
@ -1115,7 +1112,7 @@ mod tests {
// Verify queue still intact and index advanced again // Verify queue still intact and index advanced again
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip"); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after second skip");
assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2"); assert_eq!(queue_lock.current_index(), Some(2), "Index should advance to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2"); assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
@ -1128,7 +1125,7 @@ mod tests {
// Verify we're at the last item // Verify we're at the last item
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end"); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items at end");
assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)"); assert_eq!(queue_lock.current_index(), Some(4), "Index should be at last item (4)");
assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4"); assert_eq!(queue_lock.current().unwrap().id, "item_4", "Current item should be item_4");
@ -1150,7 +1147,7 @@ mod tests {
// Verify we're at the last item // Verify we're at the last item
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item"); assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
} }
@ -1161,7 +1158,7 @@ mod tests {
// Verify queue is still intact // Verify queue is still intact
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end"); assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items after skip at end");
// When we skip past the end, the queue index should stay at the last item // When we skip past the end, the queue index should stay at the last item
// or become None (depending on implementation) // or become None (depending on implementation)
@ -1190,7 +1187,7 @@ mod tests {
// Verify we wrapped to the first item // Verify we wrapped to the first item
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items"); assert_eq!(queue_lock.items().len(), 3, "Queue should still have 3 items");
assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0"); assert_eq!(queue_lock.current_index(), Some(0), "Should wrap to index 0");
assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0"); assert_eq!(queue_lock.current().unwrap().id, "item_0", "Should be back at item_0");
@ -1209,7 +1206,7 @@ mod tests {
// Verify starting position // Verify starting position
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3"); assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
} }
@ -1219,7 +1216,7 @@ mod tests {
// Verify queue is intact and index moved back // Verify queue is intact and index moved back
{ {
let queue = controller.queue(); let queue = controller.queue();
let queue_lock = queue.lock_safe(); let queue_lock = queue.lock().unwrap();
assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous"); assert_eq!(queue_lock.items().len(), 5, "Queue should still have 5 items after previous");
assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2"); assert_eq!(queue_lock.current_index(), Some(2), "Index should move to 2");
assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2"); assert_eq!(queue_lock.current().unwrap().id, "item_2", "Current item should be item_2");
@ -1388,7 +1385,7 @@ mod tests {
// Set sleep timer to end of track // Set sleep timer to end of track
{ {
let mut timer = controller.sleep_timer.lock_safe(); let mut timer = controller.sleep_timer.lock().unwrap();
timer.mode = SleepTimerMode::EndOfTrack; timer.mode = SleepTimerMode::EndOfTrack;
} }
@ -1403,7 +1400,7 @@ mod tests {
// Verify timer was cancelled // Verify timer was cancelled
{ {
let timer = controller.sleep_timer.lock_safe(); let timer = controller.sleep_timer.lock().unwrap();
assert!( assert!(
matches!(timer.mode, SleepTimerMode::Off), matches!(timer.mode, SleepTimerMode::Off),
"Sleep timer should be cancelled after EndOfTrack" "Sleep timer should be cancelled after EndOfTrack"

View File

@ -1,4 +1,3 @@
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError}; use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent}; use super::events::{PlayerEventEmitter, PlayerStatusEvent};
@ -191,7 +190,7 @@ impl MpvBackend {
libmpv::events::Event::PlaybackRestart => { libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed"); debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone()); let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter { if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged { emitter.emit(PlayerStatusEvent::StateChanged {
@ -203,7 +202,7 @@ impl MpvBackend {
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => { libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
// Handle pause state changes // Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") { if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone()); let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter { if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged { emitter.emit(PlayerStatusEvent::StateChanged {
@ -309,7 +308,7 @@ impl MpvBackend {
if !is_paused { if !is_paused {
// Throttled progress reporting (every 30s) // Throttled progress reporting (every 30s)
let jellyfin_id = { let jellyfin_id = {
let state = state_for_position.lock_safe(); let state = state_for_position.lock().unwrap();
state.current_media.as_ref() state.current_media.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string())) .and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
}; };
@ -377,7 +376,7 @@ impl PlayerBackend for MpvBackend {
// Update state // Update state
{ {
let mut state = self.state.lock_safe(); let mut state = self.state.lock().unwrap();
state.current_media = Some(media.clone()); state.current_media = Some(media.clone());
} }
@ -423,7 +422,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to stop: {:?}", e), message: format!("Failed to stop: {:?}", e),
})?; })?;
let mut state = self.state.lock_safe(); let mut state = self.state.lock().unwrap();
state.current_media = None; state.current_media = None;
Ok(()) Ok(())
@ -461,7 +460,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to set volume: {:?}", e), message: format!("Failed to set volume: {:?}", e),
})?; })?;
let mut state = self.state.lock_safe(); let mut state = self.state.lock().unwrap();
state.volume = clamped; state.volume = clamped;
Ok(()) Ok(())
@ -481,7 +480,7 @@ impl PlayerBackend for MpvBackend {
} }
fn state(&self) -> PlayerState { fn state(&self) -> PlayerState {
let state = self.state.lock_safe(); let state = self.state.lock().unwrap();
if let Some(ref media) = state.current_media { if let Some(ref media) = state.current_media {
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true); let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
@ -507,7 +506,7 @@ impl PlayerBackend for MpvBackend {
} }
fn volume(&self) -> f32 { fn volume(&self) -> f32 {
let state = self.state.lock_safe(); let state = self.state.lock().unwrap();
state.volume state.volume
} }

View File

@ -1,143 +0,0 @@
//! Video seek strategy decision logic.
//!
//! This is pure logic, extracted from the command layer so it can be unit-tested
//! in the player core. The `player_seek_video` command translates the resulting
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
BackendReloadStream,
}
/// Determine the video seek strategy based on stream characteristics.
///
/// This is a pure function extracted for testability.
///
/// # Arguments
/// * `is_local` - Whether the file is a local download
/// * `is_hls` - Whether the stream URL contains ".m3u8" (HLS stream)
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
is_hls: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
return VideoSeekStrategy::LocalNativeSeek;
}
// HLS streams and direct play (non-transcoded) support native seeking
if is_hls || !needs_transcoding {
if use_html5 {
// HTML5 backend - frontend handles seeking via videoElement.currentTime
// We don't call backend.seek() because video is in HTML5 element, not in MPV
VideoSeekStrategy::Html5NativeSeek
} else {
// Native backend (MPV) - backend handles seeking
VideoSeekStrategy::BackendNativeSeek
}
} else {
// Transcoded non-HLS streams need server-side seek (reload from new position)
if use_html5 {
VideoSeekStrategy::Html5ReloadStream
} else {
VideoSeekStrategy::BackendReloadStream
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Test video seek strategy for local files
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek
);
}
/// Test video seek strategy for HLS streams
#[test]
fn test_seek_strategy_hls_stream() {
// HLS with HTML5 - frontend handles seek, don't call backend
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// HLS with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// HLS even with needs_transcoding flag - still native seek (HLS supports it)
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
}

View File

@ -5,8 +5,6 @@
// @req: DR-012 - Local database for media metadata cache // @req: DR-012 - Local database for media metadata cache
// @req: DR-013 - Repository pattern for online/offline data access // @req: DR-013 - Repository pattern for online/offline data access
#[cfg(test)]
use crate::utils::lock::MutexSafe;
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
@ -548,16 +546,16 @@ mod tests {
} }
fn get_query_count(&self) -> usize { fn get_query_count(&self) -> usize {
*self.query_count.lock_safe() *self.query_count.lock().unwrap()
} }
fn get_save_count(&self) -> usize { fn get_save_count(&self) -> usize {
*self.save_count.lock_safe() *self.save_count.lock().unwrap()
} }
async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> { async fn save_to_cache(&self, _parent_id: &str, items: &[MediaItem]) -> Result<usize, RepoError> {
*self.save_count.lock_safe() += 1; *self.save_count.lock().unwrap() += 1;
*self.items.lock_safe() = items.to_vec(); *self.items.lock().unwrap() = items.to_vec();
Ok(items.len()) Ok(items.len())
} }
} }
@ -569,8 +567,8 @@ mod tests {
} }
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> { async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
*self.query_count.lock_safe() += 1; *self.query_count.lock().unwrap() += 1;
let items = self.items.lock_safe().clone(); let items = self.items.lock().unwrap().clone();
let count = items.len(); let count = items.len();
Ok(SearchResult { Ok(SearchResult {
items, items,
@ -717,7 +715,7 @@ mod tests {
} }
fn get_query_count(&self) -> usize { fn get_query_count(&self) -> usize {
*self.query_count.lock_safe() *self.query_count.lock().unwrap()
} }
} }
@ -728,7 +726,7 @@ mod tests {
} }
async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> { async fn get_items(&self, _parent_id: &str, _options: Option<GetItemsOptions>) -> Result<SearchResult, RepoError> {
*self.query_count.lock_safe() += 1; *self.query_count.lock().unwrap() += 1;
Ok(SearchResult { Ok(SearchResult {
items: self.items.clone(), items: self.items.clone(),
total_record_count: self.items.len(), total_record_count: self.items.len(),

View File

@ -5,7 +5,6 @@
//! //!
//! TRACES: UR-010 | JA-021 //! TRACES: UR-010 | JA-021
use crate::utils::lock::{MutexSafe, RwLockSafe};
use log::{debug, info, warn}; use log::{debug, info, warn};
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@ -61,7 +60,7 @@ impl SessionPollerManager {
/// Set event emitter for broadcasting session updates /// Set event emitter for broadcasting session updates
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) { pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter); *self.event_emitter.lock().unwrap() = Some(emitter);
} }
/// Start the background polling thread /// Start the background polling thread
@ -89,7 +88,7 @@ impl SessionPollerManager {
// Calculate poll interval based on mode and hint // Calculate poll interval based on mode and hint
let new_interval = Self::calculate_interval( let new_interval = Self::calculate_interval(
&mode_manager.get_mode(), &mode_manager.get_mode(),
*hint.read_safe(), *hint.read().unwrap(),
); );
interval_ms.store(new_interval, Ordering::Relaxed); interval_ms.store(new_interval, Ordering::Relaxed);
@ -98,7 +97,7 @@ impl SessionPollerManager {
// Fetch sessions // Fetch sessions
let sessions_result = rt.block_on(async { let sessions_result = rt.block_on(async {
let client_opt = client.lock_safe().clone(); let client_opt = client.lock().unwrap().clone();
match client_opt { match client_opt {
Some(c) => c.get_sessions().await, Some(c) => c.get_sessions().await,
None => { None => {
@ -112,7 +111,7 @@ impl SessionPollerManager {
match sessions_result { match sessions_result {
Ok(sessions) => { Ok(sessions) => {
debug!("[SessionPoller] Fetched {} sessions", sessions.len()); debug!("[SessionPoller] Fetched {} sessions", sessions.len());
if let Some(em) = emitter.lock_safe().as_ref() { if let Some(em) = emitter.lock().unwrap().as_ref() {
em.emit(crate::player::PlayerStatusEvent::SessionsUpdated { em.emit(crate::player::PlayerStatusEvent::SessionsUpdated {
sessions, sessions,
}); });
@ -130,7 +129,7 @@ impl SessionPollerManager {
info!("[SessionPoller] Polling thread stopped"); info!("[SessionPoller] Polling thread stopped");
}); });
*self.thread_handle.lock_safe() = Some(handle); *self.thread_handle.lock().unwrap() = Some(handle);
} }
/// Stop the polling thread /// Stop the polling thread
@ -139,7 +138,7 @@ impl SessionPollerManager {
self.is_running.store(false, Ordering::Relaxed); self.is_running.store(false, Ordering::Relaxed);
// Join the thread if possible (don't block indefinitely) // Join the thread if possible (don't block indefinitely)
if let Some(handle) = self.thread_handle.lock_safe().take() { if let Some(handle) = self.thread_handle.lock().unwrap().take() {
let _ = handle.join(); let _ = handle.join();
} }
} }
@ -147,7 +146,7 @@ impl SessionPollerManager {
/// Set UI hint for polling frequency adjustment /// Set UI hint for polling frequency adjustment
pub fn set_polling_hint(&self, hint: PollingHint) { pub fn set_polling_hint(&self, hint: PollingHint) {
debug!("[SessionPoller] Setting polling hint: {:?}", hint); debug!("[SessionPoller] Setting polling hint: {:?}", hint);
*self.current_hint.write_safe() = hint; *self.current_hint.write().unwrap() = hint;
} }
/// Calculate polling interval based on mode and hint /// Calculate polling interval based on mode and hint
@ -166,7 +165,7 @@ impl SessionPollerManager {
/// Manually trigger a poll (for frontend refresh button) /// Manually trigger a poll (for frontend refresh button)
pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> { pub async fn poll_now(&self) -> Result<Vec<crate::jellyfin::client::SessionInfo>, String> {
let client = self.jellyfin_client.lock_safe().clone() let client = self.jellyfin_client.lock().unwrap().clone()
.ok_or("Jellyfin client not configured")?; .ok_or("Jellyfin client not configured")?;
client.get_sessions().await client.get_sessions().await

View File

@ -7,7 +7,6 @@ pub mod db_service;
pub mod models; pub mod models;
pub mod schema; pub mod schema;
use crate::utils::lock::MutexSafe;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -78,7 +77,7 @@ impl Database {
/// Run all pending migrations /// Run all pending migrations
pub fn migrate(&self) -> SqliteResult<()> { pub fn migrate(&self) -> SqliteResult<()> {
info!("Starting database migrations..."); info!("Starting database migrations...");
let conn = self.conn.lock_safe(); let conn = self.conn.lock().unwrap();
// Create migrations table if it doesn't exist // Create migrations table if it doesn't exist
debug!("Creating _migrations table if it doesn't exist..."); debug!("Creating _migrations table if it doesn't exist...");
@ -173,7 +172,7 @@ mod tests {
fn test_migrations_run() { fn test_migrations_run() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Check that tables exist // Check that tables exist
let mut stmt = conn let mut stmt = conn
@ -187,7 +186,7 @@ mod tests {
fn test_all_tables_created() { fn test_all_tables_created() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
let expected_tables = [ let expected_tables = [
"servers", "servers",
@ -219,7 +218,7 @@ mod tests {
fn test_fts_table_created() { fn test_fts_table_created() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
let exists: Option<String> = conn let exists: Option<String> = conn
.query_row( .query_row(
@ -235,7 +234,7 @@ mod tests {
fn test_server_crud() { fn test_server_crud() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Insert a server // Insert a server
conn.execute( conn.execute(
@ -283,7 +282,7 @@ mod tests {
fn test_user_crud() { fn test_user_crud() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create a server first (foreign key) // Create a server first (foreign key)
conn.execute( conn.execute(
@ -330,7 +329,7 @@ mod tests {
fn test_cascade_delete_server_removes_users() { fn test_cascade_delete_server_removes_users() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create server and user // Create server and user
conn.execute( conn.execute(
@ -368,7 +367,7 @@ mod tests {
fn test_item_insert_and_fts_search() { fn test_item_insert_and_fts_search() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create server first // Create server first
conn.execute( conn.execute(
@ -425,7 +424,7 @@ mod tests {
fn test_user_data_playback_position() { fn test_user_data_playback_position() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Setup: server, user, item // Setup: server, user, item
conn.execute( conn.execute(
@ -488,7 +487,7 @@ mod tests {
fn test_sync_queue_operations() { fn test_sync_queue_operations() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Setup // Setup
conn.execute( conn.execute(
@ -553,7 +552,7 @@ mod tests {
fn test_downloads_table() { fn test_downloads_table() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Setup // Setup
conn.execute( conn.execute(
@ -628,7 +627,7 @@ mod tests {
// Tables should still exist // Tables should still exist
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
let count: i32 = conn let count: i32 = conn
.query_row( .query_row(
@ -644,7 +643,7 @@ mod tests {
fn test_global_active_user_deactivation() { fn test_global_active_user_deactivation() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create two servers // Create two servers
conn.execute( conn.execute(
@ -714,7 +713,7 @@ mod tests {
fn test_active_session_query_ordering() { fn test_active_session_query_ordering() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();
let conn = db.connection(); let conn = db.connection();
let conn = conn.lock_safe(); let conn = conn.lock().unwrap();
// Create server // Create server
conn.execute( conn.execute(

View File

@ -1,111 +0,0 @@
//! Poison-tolerant locking helpers.
//!
//! `std::sync::Mutex` and `RwLock` become *poisoned* if a thread panics while
//! holding the guard. After that, every `.lock().unwrap()` / `.read().unwrap()`
//! / `.write().unwrap()` panics as well — so a single failure can cascade into
//! an unrecoverable crash. That is a real risk for stateful subsystems like the
//! player, whose locks are touched from many background threads (the MPV event
//! loop, sleep/autoplay timers, JNI callbacks, the session poller).
//!
//! These extension traits recover the guard from a poisoned lock instead of
//! panicking. The data behind a poisoned lock may be in an unexpected state,
//! but for this application's state (queues, settings, flags) recovering and
//! continuing is far preferable to taking down playback entirely.
//!
//! Use `lock_safe()` / `read_safe()` / `write_safe()` in place of
//! `.lock().unwrap()` / `.read().unwrap()` / `.write().unwrap()`.
//!
//! Note: these apply only to `std::sync` primitives. `tokio::sync::Mutex` does
//! not poison, so async code keeps using `.lock().await` unchanged.
use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
/// Poison-tolerant locking for [`std::sync::Mutex`].
pub trait MutexSafe<T: ?Sized> {
/// Lock the mutex, recovering the inner guard if the lock was poisoned.
fn lock_safe(&self) -> MutexGuard<'_, T>;
}
impl<T: ?Sized> MutexSafe<T> for Mutex<T> {
fn lock_safe(&self) -> MutexGuard<'_, T> {
self.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Poison-tolerant locking for [`std::sync::RwLock`].
pub trait RwLockSafe<T: ?Sized> {
/// Acquire a read guard, recovering it if the lock was poisoned.
fn read_safe(&self) -> RwLockReadGuard<'_, T>;
/// Acquire a write guard, recovering it if the lock was poisoned.
fn write_safe(&self) -> RwLockWriteGuard<'_, T>;
}
impl<T: ?Sized> RwLockSafe<T> for RwLock<T> {
fn read_safe(&self) -> RwLockReadGuard<'_, T> {
self.read().unwrap_or_else(PoisonError::into_inner)
}
fn write_safe(&self) -> RwLockWriteGuard<'_, T> {
self.write().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
/// Run `f` with the panic hook suppressed so deliberately-poisoning panics
/// don't spam the test output.
fn without_panic_noise<R>(f: impl FnOnce() -> R) -> R {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result = f();
std::panic::set_hook(prev);
result
}
#[test]
fn lock_safe_recovers_from_poison() {
let mutex = Arc::new(Mutex::new(0u32));
let poisoner = Arc::clone(&mutex);
without_panic_noise(|| {
let _ = thread::spawn(move || {
let mut guard = poisoner.lock().unwrap();
*guard = 42;
panic!("poison the mutex while holding the guard");
})
.join();
});
// The mutex is now poisoned: the normal path would panic.
assert!(mutex.lock().is_err());
// lock_safe recovers the guard with the last written value intact.
let guard = mutex.lock_safe();
assert_eq!(*guard, 42);
}
#[test]
fn rwlock_safe_recovers_from_poison() {
let lock = Arc::new(RwLock::new(0u32));
let poisoner = Arc::clone(&lock);
without_panic_noise(|| {
let _ = thread::spawn(move || {
let mut guard = poisoner.write().unwrap();
*guard = 7;
panic!("poison the rwlock while holding the write guard");
})
.join();
});
assert!(lock.write().is_err());
assert_eq!(*lock.read_safe(), 7);
*lock.write_safe() = 9;
assert_eq!(*lock.read_safe(), 9);
}
}

View File

@ -1,2 +1 @@
pub mod conversions; pub mod conversions;
pub mod lock;