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
🏗️ 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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user