Architecture remediation A/B/F: poison-tolerant locks, graceful backend init, doc fixes

Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
  poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
  sites with _safe variants across 14 files, eliminating the player
  crash-cascade class. Tokio async mutexes are unchanged.

Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
  it falls back to NullBackend and emits a backend-init-failed event so the UI
  can show "playback unavailable" instead of the app crashing. Fatal DB-setup
  panics are kept.

Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
  reflect reality (~20.5k non-test frontend) and document the events+polling
  hybrid plus the new locking/backend-init behavior.
This commit is contained in:
2026-06-20 16:03:54 +02:00
parent 0738ef10ec
commit 6866f03c55
18 changed files with 345 additions and 178 deletions
+14 -13
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
@@ -303,7 +304,7 @@ impl PlayerBackend for ExoPlayerBackend {
// Update local state
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.current_media = Some(media.clone());
state.state = PlayerState::Loading {
media: media.clone(),
@@ -430,7 +431,7 @@ impl PlayerBackend for ExoPlayerBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.state = PlayerState::Idle;
state.is_loaded = false;
state.current_media = None;
@@ -479,24 +480,24 @@ impl PlayerBackend for ExoPlayerBackend {
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
self.shared_state.lock().unwrap().volume = clamped;
self.shared_state.lock_safe().volume = clamped;
Ok(())
}
fn position(&self) -> f64 {
self.shared_state.lock().unwrap().position
self.shared_state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.shared_state.lock().unwrap().duration
self.shared_state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.shared_state.lock().unwrap().state.clone()
self.shared_state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.shared_state.lock().unwrap().volume
self.shared_state.lock_safe().volume
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
@@ -564,7 +565,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock().unwrap();
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
@@ -606,7 +607,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock().unwrap();
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
@@ -651,7 +652,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock().unwrap();
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
@@ -692,7 +693,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
@@ -702,7 +703,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
let queue = ctrl.queue.lock_safe();
format!("current_index={:?}, len={}", queue.current_index(), queue.items().len())
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
@@ -811,7 +812,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock().unwrap().volume = volume;
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {
+5 -3
View File
@@ -5,6 +5,8 @@
//!
//! 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 serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -168,17 +170,17 @@ mod tests {
}
pub fn events(&self) -> Vec<PlayerStatusEvent> {
self.events.lock().unwrap().clone()
self.events.lock_safe().clone()
}
pub fn clear(&self) {
self.events.lock().unwrap().clear();
self.events.lock_safe().clear();
}
}
impl PlayerEventEmitter for TestEventEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
self.events.lock_safe().push(event);
}
}
+77 -76
View File
@@ -44,6 +44,7 @@ pub use android::{
set_media_command_handler, set_remote_volume_handler, get_detected_codecs,
};
use crate::utils::lock::MutexSafe;
use log::{debug, error, warn};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -121,7 +122,7 @@ impl PlayerController {
/// Configure the Jellyfin API client for automatic playback reporting
pub fn set_jellyfin_client(&self, client: Option<JellyfinClient>) {
let mut jellyfin = self.jellyfin_client.lock().unwrap();
let mut jellyfin = self.jellyfin_client.lock_safe();
*jellyfin = client;
log::info!("[PlayerController] Jellyfin client configured: {}", jellyfin.is_some());
}
@@ -157,24 +158,24 @@ impl PlayerController {
/// Set the end reason for the next playback end event
fn set_end_reason(&self, reason: EndReason) {
log::debug!("[PlayerController] Setting end reason: {:?}", reason);
*self.end_reason.lock().unwrap() = Some(reason);
*self.end_reason.lock_safe() = Some(reason);
}
/// Get and clear the current end reason
fn take_end_reason(&self) -> Option<EndReason> {
self.end_reason.lock().unwrap().take()
self.end_reason.lock_safe().take()
}
/// Increment autoplay episode counter. Returns true if limit is reached.
fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock().unwrap().max_episodes;
let max = self.autoplay_settings.lock_safe().max_episodes;
if max == 0 {
// Unlimited
return false;
}
let mut count = self.autoplay_episode_count.lock().unwrap();
let mut count = self.autoplay_episode_count.lock_safe();
*count += 1;
debug!("[PlayerController] Autoplay episode count: {}/{}", *count, max);
@@ -183,7 +184,7 @@ impl PlayerController {
/// Reset autoplay episode counter (called on manual play actions)
fn reset_autoplay_count(&self) {
let mut count = self.autoplay_episode_count.lock().unwrap();
let mut count = self.autoplay_episode_count.lock_safe();
if *count > 0 {
debug!("[PlayerController] Resetting autoplay episode counter (was {})", *count);
}
@@ -199,7 +200,7 @@ impl PlayerController {
// Update queue with this single item
{
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item.clone()], 0);
}
@@ -217,7 +218,7 @@ impl PlayerController {
// Set end reason to NewTrackLoaded to prevent autoplay when MPV ends current track
self.set_end_reason(EndReason::NewTrackLoaded);
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.load(item)?;
backend.play()?;
drop(backend);
@@ -297,12 +298,12 @@ impl PlayerController {
self.reset_autoplay_count();
{
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.set_queue(items, start_index);
}
// Play the current item (without modifying the queue we just set)
if let Some(item) = self.queue.lock().unwrap().current().cloned() {
if let Some(item) = self.queue.lock_safe().current().cloned() {
self.load_and_play(&item)?;
}
@@ -312,19 +313,19 @@ impl PlayerController {
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
backend.pause()
} else {
@@ -339,16 +340,16 @@ impl PlayerController {
// Get current playback info before stopping
let jellyfin_id = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.current().and_then(|item| item.jellyfin_id().map(|s| s.to_string()))
};
let position_ticks = {
let backend = self.backend.lock().unwrap();
let backend = self.backend.lock_safe();
(backend.position() * 10_000_000.0) as i64
};
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.stop()?;
drop(backend);
@@ -412,7 +413,7 @@ impl PlayerController {
self.reset_autoplay_count();
let next_item = {
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.next().cloned()
};
@@ -435,7 +436,7 @@ impl PlayerController {
self.reset_autoplay_count();
// If we're more than 3 seconds in, restart current track
{
let backend = self.backend.lock().unwrap();
let backend = self.backend.lock_safe();
if backend.position() > 3.0 {
debug!("[PlayerController] previous: restarting current track (position > 3s)");
drop(backend);
@@ -444,7 +445,7 @@ impl PlayerController {
}
let prev_item = {
let mut queue = self.queue.lock().unwrap();
let mut queue = self.queue.lock_safe();
queue.previous().cloned()
};
@@ -459,40 +460,40 @@ impl PlayerController {
/// Seek to a position in seconds
pub fn seek(&self, position: f64) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.seek(position)
}
/// Set volume (0.0 - 1.0)
pub fn set_volume(&self, volume: f32) -> Result<(), PlayerError> {
self.backend.lock().unwrap().set_volume(volume)
self.backend.lock_safe().set_volume(volume)
}
/// Set the active audio track by stream index
pub fn set_audio_track(&self, stream_index: i32) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.set_audio_track(stream_index)
}
/// Set the active subtitle track by stream index (None to disable subtitles)
pub fn set_subtitle_track(&self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let mut backend = self.backend.lock().unwrap();
let mut backend = self.backend.lock_safe();
backend.set_subtitle_track(stream_index)
}
/// Get current state
pub fn state(&self) -> PlayerState {
self.backend.lock().unwrap().state()
self.backend.lock_safe().state()
}
/// Get current position
pub fn position(&self) -> f64 {
self.backend.lock().unwrap().position()
self.backend.lock_safe().position()
}
/// Get duration
pub fn duration(&self) -> Option<f64> {
self.backend.lock().unwrap().duration()
self.backend.lock_safe().duration()
}
/// Get queue reference
@@ -502,27 +503,27 @@ impl PlayerController {
/// Toggle shuffle
pub fn toggle_shuffle(&self) {
self.queue.lock().unwrap().toggle_shuffle();
self.queue.lock_safe().toggle_shuffle();
}
/// Cycle repeat mode
pub fn cycle_repeat(&self) {
self.queue.lock().unwrap().cycle_repeat();
self.queue.lock_safe().cycle_repeat();
}
/// Check if shuffle is enabled
pub fn is_shuffle(&self) -> bool {
self.queue.lock().unwrap().is_shuffle()
self.queue.lock_safe().is_shuffle()
}
/// Get repeat mode
pub fn repeat_mode(&self) -> RepeatMode {
self.queue.lock().unwrap().repeat_mode()
self.queue.lock_safe().repeat_mode()
}
/// Get current volume (0.0 - 1.0)
pub fn volume(&self) -> f32 {
self.backend.lock().unwrap().volume()
self.backend.lock_safe().volume()
}
/// Check if muted
@@ -532,35 +533,35 @@ impl PlayerController {
/// Set audio settings (crossfade, gapless, normalization)
pub fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
self.backend.lock().unwrap().set_audio_settings(settings)
self.backend.lock_safe().set_audio_settings(settings)
}
/// Get current audio settings
pub fn audio_settings(&self) -> AudioSettings {
self.backend.lock().unwrap().audio_settings()
self.backend.lock_safe().audio_settings()
}
// ===== Sleep Timer Methods =====
/// Set the event emitter for notifications
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
let mut event_emitter = self.event_emitter.lock().unwrap();
let mut event_emitter = self.event_emitter.lock_safe();
*event_emitter = Some(emitter);
}
/// Get the event emitter
pub fn event_emitter(&self) -> Option<Arc<dyn PlayerEventEmitter>> {
self.event_emitter.lock().unwrap().clone()
self.event_emitter.lock_safe().clone()
}
/// Get sleep timer state
pub fn sleep_timer_state(&self) -> SleepTimerState {
self.sleep_timer.lock().unwrap().clone()
self.sleep_timer.lock_safe().clone()
}
/// Set sleep timer mode (in-memory only, not persisted)
pub fn set_sleep_timer(&self, mode: SleepTimerMode) {
let mut timer = self.sleep_timer.lock().unwrap();
let mut timer = self.sleep_timer.lock_safe();
timer.mode = mode.clone();
if let SleepTimerMode::Time { end_time } = mode {
let now = chrono::Utc::now().timestamp_millis();
@@ -589,7 +590,7 @@ impl PlayerController {
loop {
std::thread::sleep(Duration::from_secs(1));
let mut timer = sleep_timer.lock().unwrap();
let mut timer = sleep_timer.lock_safe();
if timer.is_active() {
timer.update_remaining_seconds();
@@ -599,7 +600,7 @@ impl PlayerController {
timer.cancel();
// Emit cancelled state
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: SleepTimerMode::Off,
remaining_seconds: 0,
@@ -608,14 +609,14 @@ impl PlayerController {
drop(timer);
// Stop the backend
if let Err(e) = backend.lock().unwrap().stop() {
if let Err(e) = backend.lock_safe().stop() {
error!("[SleepTimer] Failed to stop playback: {}", e);
}
continue;
}
// Emit update event
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode.clone(),
remaining_seconds: timer.remaining_seconds,
@@ -629,9 +630,9 @@ impl PlayerController {
/// Emit sleep timer changed event to frontend
fn emit_sleep_timer_changed(&self) {
let timer = self.sleep_timer.lock().unwrap().clone();
let timer = self.sleep_timer.lock_safe().clone();
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
mode: timer.mode,
remaining_seconds: timer.remaining_seconds,
@@ -641,12 +642,12 @@ impl PlayerController {
/// Emit queue changed event to frontend
pub fn emit_queue_changed(&self) {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
debug!("PlayerController::emit_queue_changed() - Emitting queue with {} items, current_index: {:?}",
queue.items().len(), queue.current_index());
if let Some(emitter) = self.event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::QueueChanged {
items: queue.items().to_vec(),
current_index: queue.current_index(),
@@ -664,19 +665,19 @@ impl PlayerController {
/// Get autoplay settings
pub fn autoplay_settings(&self) -> AutoplaySettings {
self.autoplay_settings.lock().unwrap().clone()
self.autoplay_settings.lock_safe().clone()
}
/// Set autoplay settings (in-memory only, persistence handled by command layer)
pub fn set_autoplay_settings(&self, settings: AutoplaySettings) {
let validated = settings.with_validated_countdown();
*self.autoplay_settings.lock().unwrap() = validated;
*self.autoplay_settings.lock_safe() = validated;
}
/// Cancel active autoplay countdown
pub fn cancel_autoplay_countdown(&self) {
if let Some(cancel_flag) = self.countdown_cancel.lock().unwrap().as_ref() {
*cancel_flag.lock().unwrap() = true;
if let Some(cancel_flag) = self.countdown_cancel.lock_safe().as_ref() {
*cancel_flag.lock_safe() = true;
}
}
@@ -719,7 +720,7 @@ impl PlayerController {
}
let current_item = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.current().cloned()
};
@@ -729,7 +730,7 @@ impl PlayerController {
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock().unwrap();
let timer = self.sleep_timer.lock_safe();
timer.mode.clone()
};
@@ -739,14 +740,14 @@ impl PlayerController {
let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at track boundary");
self.sleep_timer.lock().unwrap().cancel();
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
// Stop at end of track
self.sleep_timer.lock().unwrap().cancel();
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
@@ -756,7 +757,7 @@ impl PlayerController {
&& self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
@@ -773,7 +774,7 @@ impl PlayerController {
// 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.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
let repo = self.repository.lock().unwrap().clone();
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
self.fetch_next_episode_for_item(jellyfin_id, repo).await?
@@ -782,7 +783,7 @@ impl PlayerController {
None
};
if let Some(next_ep) = next_ep_result {
let settings = self.autoplay_settings.lock().unwrap().clone();
let settings = self.autoplay_settings.lock_safe().clone();
// Check if auto-play episode limit is reached
let limit_reached = self.increment_autoplay_count();
@@ -803,7 +804,7 @@ impl PlayerController {
// For audio/movies, check if there's a next track in the queue
let has_next = {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock_safe();
queue.has_next()
};
@@ -837,7 +838,7 @@ impl PlayerController {
// Check sleep timer state
let timer_mode = {
let timer = self.sleep_timer.lock().unwrap();
let timer = self.sleep_timer.lock_safe();
timer.mode.clone()
};
@@ -846,18 +847,18 @@ impl PlayerController {
let now = chrono::Utc::now().timestamp_millis();
if now >= *end_time {
debug!("[PlayerController] Time-based sleep timer expired at video end");
self.sleep_timer.lock().unwrap().cancel();
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
}
SleepTimerMode::EndOfTrack => {
self.sleep_timer.lock().unwrap().cancel();
self.sleep_timer.lock_safe().cancel();
self.emit_sleep_timer_changed();
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
let should_stop = self.sleep_timer.lock().unwrap().decrement_episode();
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
self.emit_sleep_timer_changed();
if should_stop {
return Ok(AutoplayDecision::Stop);
@@ -868,7 +869,7 @@ impl PlayerController {
// Fetch next episode for the video that just ended
if let Some(next_ep) = self.fetch_next_episode_for_item(item_id, &repo).await? {
let settings = self.autoplay_settings.lock().unwrap().clone();
let settings = self.autoplay_settings.lock_safe().clone();
let limit_reached = self.increment_autoplay_count();
if limit_reached {
@@ -961,7 +962,7 @@ impl PlayerController {
pub fn start_autoplay_countdown(&self, _next_item: crate::repository::types::MediaItem, countdown_seconds: u32) {
// Create cancellation flag
let cancel_flag = Arc::new(Mutex::new(false));
*self.countdown_cancel.lock().unwrap() = Some(cancel_flag.clone());
*self.countdown_cancel.lock_safe() = Some(cancel_flag.clone());
let event_emitter = self.event_emitter.clone();
@@ -972,7 +973,7 @@ impl PlayerController {
std::thread::sleep(Duration::from_secs(1));
// Check cancellation
if *cancel_flag.lock().unwrap() {
if *cancel_flag.lock_safe() {
log::info!("[PlayerController] Autoplay countdown cancelled");
return;
}
@@ -980,7 +981,7 @@ impl PlayerController {
remaining -= 1;
// Emit countdown tick event
if let Some(emitter) = event_emitter.lock().unwrap().as_ref() {
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::CountdownTick {
remaining_seconds: remaining,
});
@@ -1081,7 +1082,7 @@ mod tests {
// Verify initial state
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_0", "Current item should be item_0");
@@ -1093,7 +1094,7 @@ mod tests {
// Verify queue is intact and index advanced
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_1", "Current item should be item_1");
@@ -1112,7 +1113,7 @@ mod tests {
// Verify queue still intact and index advanced again
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_2", "Current item should be item_2");
@@ -1125,7 +1126,7 @@ mod tests {
// Verify we're at the last item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_4", "Current item should be item_4");
@@ -1147,7 +1148,7 @@ mod tests {
// Verify we're at the last item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(2), "Should be at last item");
}
@@ -1158,7 +1159,7 @@ mod tests {
// Verify queue is still intact
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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
// or become None (depending on implementation)
@@ -1187,7 +1188,7 @@ mod tests {
// Verify we wrapped to the first item
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_0", "Should be back at item_0");
@@ -1206,7 +1207,7 @@ mod tests {
// Verify starting position
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
assert_eq!(queue_lock.current_index(), Some(3), "Should start at index 3");
}
@@ -1216,7 +1217,7 @@ mod tests {
// Verify queue is intact and index moved back
{
let queue = controller.queue();
let queue_lock = queue.lock().unwrap();
let queue_lock = queue.lock_safe();
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().unwrap().id, "item_2", "Current item should be item_2");
@@ -1385,7 +1386,7 @@ mod tests {
// Set sleep timer to end of track
{
let mut timer = controller.sleep_timer.lock().unwrap();
let mut timer = controller.sleep_timer.lock_safe();
timer.mode = SleepTimerMode::EndOfTrack;
}
@@ -1400,7 +1401,7 @@ mod tests {
// Verify timer was cancelled
{
let timer = controller.sleep_timer.lock().unwrap();
let timer = controller.sleep_timer.lock_safe();
assert!(
matches!(timer.mode, SleepTimerMode::Off),
"Sleep timer should be cancelled after EndOfTrack"
+9 -8
View File
@@ -1,3 +1,4 @@
use crate::utils::lock::MutexSafe;
use log::{debug, error, info, warn};
use super::backend::{PlayerBackend, PlayerError};
use super::events::{PlayerEventEmitter, PlayerStatusEvent};
@@ -190,7 +191,7 @@ impl MpvBackend {
libmpv::events::Event::PlaybackRestart => {
debug!("[MpvBackend] Playback started/resumed");
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
@@ -202,7 +203,7 @@ impl MpvBackend {
libmpv::events::Event::PropertyChange { name, .. } if name == "pause" => {
// Handle pause state changes
if let Ok(is_paused) = mpv.get_property::<bool>("pause") {
let media_id = state.lock().unwrap().current_media.as_ref().map(|m| m.id.clone());
let media_id = state.lock_safe().current_media.as_ref().map(|m| m.id.clone());
if let Some(emitter) = &event_emitter {
emitter.emit(PlayerStatusEvent::StateChanged {
@@ -308,7 +309,7 @@ impl MpvBackend {
if !is_paused {
// Throttled progress reporting (every 30s)
let jellyfin_id = {
let state = state_for_position.lock().unwrap();
let state = state_for_position.lock_safe();
state.current_media.as_ref()
.and_then(|m| m.jellyfin_id().map(|s| s.to_string()))
};
@@ -376,7 +377,7 @@ impl PlayerBackend for MpvBackend {
// Update state
{
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.current_media = Some(media.clone());
}
@@ -422,7 +423,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to stop: {:?}", e),
})?;
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.current_media = None;
Ok(())
@@ -460,7 +461,7 @@ impl PlayerBackend for MpvBackend {
message: format!("Failed to set volume: {:?}", e),
})?;
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock_safe();
state.volume = clamped;
Ok(())
@@ -480,7 +481,7 @@ impl PlayerBackend for MpvBackend {
}
fn state(&self) -> PlayerState {
let state = self.state.lock().unwrap();
let state = self.state.lock_safe();
if let Some(ref media) = state.current_media {
let is_paused = self.mpv.get_property::<bool>("pause").unwrap_or(true);
@@ -506,7 +507,7 @@ impl PlayerBackend for MpvBackend {
}
fn volume(&self) -> f32 {
let state = self.state.lock().unwrap();
let state = self.state.lock_safe();
state.volume
}