Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped

This commit is contained in:
2026-07-11 19:55:55 +02:00
parent a2cd9978f0
commit 2a1f1689b4
20 changed files with 991 additions and 995 deletions
+119 -4
View File
@@ -9,7 +9,7 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::time::{sleep, Duration};
use crate::jellyfin::JellyfinClient;
use crate::player::{PlayerController, QueueContext};
use crate::player::{PlayerController, PlayerEventEmitter, PlayerStatusEvent, QueueContext};
/// Playback mode - local device, remote session, or idle
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -48,6 +48,9 @@ pub struct PlaybackModeManager {
player_controller: Arc<TokioMutex<PlayerController>>,
current_mode: Arc<RwLock<PlaybackMode>>,
is_transferring: Arc<AtomicBool>,
/// Optional emitter used to notify the frontend when the mode changes, so its
/// mirror store stays in sync with this authoritative one. `None` in tests.
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
}
impl PlaybackModeManager {
@@ -61,19 +64,53 @@ impl PlaybackModeManager {
player_controller,
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
is_transferring: Arc::new(AtomicBool::new(false)),
event_emitter: Arc::new(Mutex::new(None)),
}
}
/// Wire the event emitter so `set_mode` notifies the frontend. Called once
/// during setup; safe to leave unset (tests do), in which case mode changes
/// simply aren't broadcast.
pub fn set_event_emitter(&self, emitter: Arc<dyn PlayerEventEmitter>) {
*self.event_emitter.lock_safe() = Some(emitter);
}
/// Get current playback mode
pub fn get_mode(&self) -> PlaybackMode {
self.current_mode.read_safe().clone()
}
/// Set playback mode (internal use)
/// Set playback mode (internal use).
///
/// Broadcasts a `PlaybackModeChanged` event when the mode actually changes so
/// the frontend's mirror store reconciles to this authoritative value. The
/// write lock is released before emitting to avoid holding it across the
/// emitter call.
pub fn set_mode(&self, mode: PlaybackMode) {
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
let mut current = self.current_mode.write_safe();
*current = mode;
let changed = {
let mut current = self.current_mode.write_safe();
let changed = *current != mode;
*current = mode.clone();
changed
};
if !changed {
return;
}
let (mode_str, session_id) = match &mode {
PlaybackMode::Local => ("local".to_string(), None),
PlaybackMode::Idle => ("idle".to_string(), None),
PlaybackMode::Remote { session_id } => ("remote".to_string(), Some(session_id.clone())),
};
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PlaybackModeChanged {
mode: mode_str,
session_id,
});
}
}
/// Check if currently transferring
@@ -702,6 +739,84 @@ mod tests {
);
}
/// Capturing emitter so we can assert what `set_mode` broadcasts.
struct CapturingEmitter {
events: Mutex<Vec<PlayerStatusEvent>>,
}
impl PlayerEventEmitter for CapturingEmitter {
fn emit(&self, event: PlayerStatusEvent) {
self.events.lock().unwrap().push(event);
}
}
fn manager_with_emitter() -> (PlaybackModeManager, Arc<CapturingEmitter>) {
let emitter = Arc::new(CapturingEmitter {
events: Mutex::new(Vec::new()),
});
let manager = PlaybackModeManager::new(
Arc::new(Mutex::new(None)),
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
);
manager.set_event_emitter(emitter.clone());
(manager, emitter)
}
/// set_mode broadcasts a PlaybackModeChanged event with the right payload so
/// the frontend can reconcile its mirror store to this authoritative one.
#[test]
fn test_set_mode_emits_change_event() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Remote {
session_id: "sess-1".to_string(),
});
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Idle);
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3, "one event per real mode change");
match &events[0] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "remote");
assert_eq!(session_id.as_deref(), Some("sess-1"));
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[1] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "local");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
match &events[2] {
PlayerStatusEvent::PlaybackModeChanged { mode, session_id } => {
assert_eq!(mode, "idle");
assert_eq!(session_id.as_deref(), None);
}
other => panic!("expected PlaybackModeChanged, got {:?}", other),
}
}
/// Setting the same mode twice must not re-emit — the frontend reconciler
/// (and the event channel) shouldn't be spammed on no-op transitions.
#[test]
fn test_set_mode_deduplicates_no_op() {
let (manager, emitter) = manager_with_emitter();
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
manager.set_mode(PlaybackMode::Local);
assert_eq!(
emitter.events.lock().unwrap().len(),
1,
"repeated identical mode set emits only once"
);
}
/// The resume position handed to a remote session is derived from a live
/// playback position. Guards the seconds->ticks conversion and the
/// at-the-start threshold (Bug: casting restarted the track from 0).