`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero. Most were mechanical — needless borrows, `assert_eq!` against a bool literal, `vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only to index — and were applied with `clippy --fix`, then reviewed line by line. That review caught one auto-fix that was *not* semantically neutral: dropping the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned directly above `SERVICE_NAME`, which would have silently cfg'd the constant out of every non-Linux build. Removed the stray attribute with the import. Where a lint asked for a risky change rather than a better one, it is suppressed with a comment saying why: - `too_many_arguments` on five `#[tauri::command]` handlers and `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>` injection, and a parameter struct would change the IPC contract and the generated TypeScript for no readability gain. - `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are serde + specta wire types emitted a handful of times a second, never bulk allocated; boxing would have to stay invisible to the generated bindings while every match arm gained a deref. - `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE` flag, and the await it spans *is* the critical section. Each `#[tokio::test]` gets its own single-threaded runtime, so this is not the production deadlock class the lint targets; restructuring would reintroduce the flag race. Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it is now `into_media_item`; the five-tuple episode row in the download commands has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches `name: "pause"` instead of guarding on it. Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`, completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be in test modules — production code was already clean — so this is consistency rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose: those tests deliberately poison a mutex to prove the helpers recover from it. Pure refactoring: all 698 tests still pass.
387 lines
14 KiB
Rust
387 lines
14 KiB
Rust
//! Player events for frontend communication via Tauri events.
|
|
//!
|
|
//! These events are emitted from the player backend to notify the frontend
|
|
//! of playback state changes, position updates, etc.
|
|
//!
|
|
//! 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;
|
|
use tauri::AppHandle;
|
|
use tauri_specta::Event;
|
|
|
|
use super::{MediaSessionType, SleepTimerMode};
|
|
|
|
/// Events emitted by the player backend to the frontend via Tauri events.
|
|
///
|
|
/// These are distinct from `PlayerEvent` in state.rs, which handles internal
|
|
/// state machine transitions.
|
|
///
|
|
/// TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
|
|
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
|
|
// NOTE: fields are intentionally snake_case on the wire. specta generates the
|
|
// TypeScript bindings with snake_case field names (it does not apply serde's
|
|
// `rename_all_fields`), so adding `rename_all_fields = "camelCase"` here makes
|
|
// serde emit camelCase payloads that no longer match the generated schema —
|
|
// tauri-specta then silently drops those events (e.g. state_changed,
|
|
// queue_changed never reach the frontend, so the mini player never appears).
|
|
// Keep serde and specta agreeing: snake_case fields, snake_case variant tags.
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
// `ShowNextEpisodePopup` carries two `MediaItem`s, so it dwarfs the small
|
|
// position/state variants. Boxing them is rejected deliberately: this is a
|
|
// serde + specta wire type whose generated TypeScript must not shift, and the
|
|
// events are emitted a few times a second at most — never bulk-allocated — so
|
|
// the size difference costs nothing measurable.
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum PlayerStatusEvent {
|
|
/// Playback position updated (emitted periodically during playback)
|
|
PositionUpdate {
|
|
/// Current position in seconds
|
|
position: f64,
|
|
/// Total duration in seconds
|
|
duration: f64,
|
|
},
|
|
/// Player state changed
|
|
StateChanged {
|
|
/// New state: "playing", "paused", "stopped", "loading", "idle"
|
|
state: String,
|
|
/// ID of the current media item, if any
|
|
media_id: Option<String>,
|
|
},
|
|
/// Media has finished loading and is ready to play
|
|
MediaLoaded {
|
|
/// Total duration in seconds
|
|
duration: f64,
|
|
},
|
|
/// Playback has ended naturally (reached end of media)
|
|
PlaybackEnded,
|
|
/// Buffering state changed
|
|
Buffering {
|
|
/// Buffering progress (0-100)
|
|
percent: u8,
|
|
},
|
|
/// An error occurred during playback
|
|
Error {
|
|
/// Error message
|
|
message: String,
|
|
/// Whether the error is recoverable
|
|
recoverable: bool,
|
|
},
|
|
/// Volume changed
|
|
VolumeChanged {
|
|
/// New volume level (0.0-1.0)
|
|
volume: f32,
|
|
/// Whether audio is muted
|
|
muted: bool,
|
|
},
|
|
/// Sleep timer state changed
|
|
SleepTimerChanged {
|
|
/// Sleep timer mode
|
|
mode: SleepTimerMode,
|
|
/// Remaining seconds (for time-based timer)
|
|
remaining_seconds: u32,
|
|
},
|
|
/// Time-based sleep timer expired: playback must stop. The backend stops
|
|
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
|
|
/// webview outside the backend's control — the frontend pauses it on this
|
|
/// event.
|
|
SleepTimerExpired,
|
|
/// Show next episode popup with countdown
|
|
ShowNextEpisodePopup {
|
|
/// Current episode that just finished
|
|
current_episode: crate::repository::types::MediaItem,
|
|
/// Next episode to play
|
|
next_episode: crate::repository::types::MediaItem,
|
|
/// Countdown duration in seconds
|
|
countdown_seconds: u32,
|
|
/// Whether to auto-advance when countdown reaches 0
|
|
auto_advance: bool,
|
|
},
|
|
/// Countdown tick (emitted every second during autoplay countdown)
|
|
CountdownTick {
|
|
/// Remaining seconds in countdown
|
|
remaining_seconds: u32,
|
|
},
|
|
/// Queue changed (items added, removed, reordered, or playback mode changed)
|
|
QueueChanged {
|
|
/// All items in the queue
|
|
items: Vec<crate::player::media::MediaItem>,
|
|
/// Current item index
|
|
current_index: Option<usize>,
|
|
/// Whether shuffle is enabled
|
|
shuffle: bool,
|
|
/// Current repeat mode
|
|
repeat: crate::player::queue::RepeatMode,
|
|
/// Whether there's a next track available
|
|
has_next: bool,
|
|
/// Whether there's a previous track available
|
|
has_previous: bool,
|
|
},
|
|
/// Media session changed (activity context changed: Audio/Movie/TvShow/Idle)
|
|
SessionChanged {
|
|
/// Current session state
|
|
session: MediaSessionType,
|
|
},
|
|
/// Remote sessions updated (for cast/remote control UI)
|
|
SessionsUpdated {
|
|
/// All active controllable sessions from Jellyfin
|
|
sessions: Vec<crate::jellyfin::client::SessionInfo>,
|
|
},
|
|
/// The authoritative playback mode changed in the Rust backend.
|
|
///
|
|
/// The Rust `PlaybackModeManager` is the single source of truth for which
|
|
/// device playback commands route to (local vs a remote session). The
|
|
/// frontend keeps a mirror store for the UI; without this event that mirror
|
|
/// drifts out of sync (e.g. a mode transition happens inside a transfer or a
|
|
/// local stop that the frontend never learns about), and controls then route
|
|
/// to the wrong device — the classic "it keeps playing on the remote" bug.
|
|
/// The frontend reconciles its store to this payload whenever it fires.
|
|
PlaybackModeChanged {
|
|
/// New mode: "local", "remote", or "idle".
|
|
mode: String,
|
|
/// Session id when `mode == "remote"`, otherwise `None`.
|
|
session_id: Option<String>,
|
|
},
|
|
/// The user asked to disconnect from the remote session and resume locally.
|
|
///
|
|
/// Emitted when the lockscreen Stop button is pressed while casting. The
|
|
/// frontend owns the two-step remote->local transfer (it must reload the
|
|
/// media item locally), so the native side only signals intent here.
|
|
RemoteDisconnectRequested,
|
|
/// Backend-originated control command targeting the active frontend player
|
|
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
|
|
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
|
|
/// or remote so they can pause/play/seek/stop the webview element.
|
|
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
|
ControlCommand {
|
|
/// One of: "play", "pause", "stop", "seek".
|
|
action: String,
|
|
/// Target position in seconds (only meaningful for "seek").
|
|
position: Option<f64>,
|
|
},
|
|
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
|
///
|
|
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
|
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
|
/// element in the webview, mirroring how all video already renders through
|
|
/// the webview `<video>`. The element then reports its state/position back
|
|
/// through the `player_report_*` commands, so the Rust controller stays the
|
|
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
|
/// via `ControlCommand`.
|
|
WebviewAudioLoad {
|
|
/// Stream URL for the `<audio>` element to play.
|
|
url: String,
|
|
/// Jellyfin item id, used as the media_id when reporting state back.
|
|
media_id: Option<String>,
|
|
/// Resume position in seconds (0 = start from the beginning).
|
|
position: f64,
|
|
/// Whether to begin playing immediately after loading.
|
|
autoplay: bool,
|
|
},
|
|
}
|
|
|
|
/// Trait for emitting player events to the frontend.
|
|
///
|
|
/// This abstraction allows backends to emit events without depending
|
|
/// directly on Tauri, making them easier to test.
|
|
pub trait PlayerEventEmitter: Send + Sync {
|
|
/// Emit a player status event to the frontend
|
|
fn emit(&self, event: PlayerStatusEvent);
|
|
}
|
|
|
|
/// Tauri-based implementation of PlayerEventEmitter.
|
|
///
|
|
/// Uses Tauri's `AppHandle::emit()` to broadcast events to all windows.
|
|
pub struct TauriEventEmitter {
|
|
app_handle: AppHandle,
|
|
}
|
|
|
|
impl TauriEventEmitter {
|
|
/// Create a new TauriEventEmitter with the given app handle.
|
|
pub fn new(app_handle: AppHandle) -> Self {
|
|
Self { app_handle }
|
|
}
|
|
}
|
|
|
|
impl PlayerEventEmitter for TauriEventEmitter {
|
|
fn emit(&self, event: PlayerStatusEvent) {
|
|
// Emitted via the tauri-specta Event trait so the payload shape and event
|
|
// name match the generated TypeScript bindings (events.playerStatusEvent).
|
|
if let Err(e) = Event::emit(&event, &self.app_handle) {
|
|
error!("Failed to emit player event: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Thread-safe wrapper for event emitters.
|
|
#[allow(dead_code)]
|
|
pub type SharedEventEmitter = Arc<dyn PlayerEventEmitter>;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Mutex;
|
|
use std::thread;
|
|
|
|
/// Test event emitter that captures events for verification
|
|
pub struct TestEventEmitter {
|
|
events: Mutex<Vec<PlayerStatusEvent>>,
|
|
}
|
|
|
|
impl TestEventEmitter {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
events: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
pub fn events(&self) -> Vec<PlayerStatusEvent> {
|
|
self.events.lock_safe().clone()
|
|
}
|
|
}
|
|
|
|
impl PlayerEventEmitter for TestEventEmitter {
|
|
fn emit(&self, event: PlayerStatusEvent) {
|
|
self.events.lock_safe().push(event);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_position_update_serialization() {
|
|
let event = PlayerStatusEvent::PositionUpdate {
|
|
position: 30.5,
|
|
duration: 180.0,
|
|
};
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("position_update"));
|
|
assert!(json.contains("30.5"));
|
|
assert!(json.contains("180"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_changed_serialization() {
|
|
let event = PlayerStatusEvent::StateChanged {
|
|
state: "playing".to_string(),
|
|
media_id: Some("test-id-123".to_string()),
|
|
};
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("state_changed"));
|
|
assert!(json.contains("playing"));
|
|
assert!(json.contains("test-id-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_changed_no_media_id() {
|
|
let event = PlayerStatusEvent::StateChanged {
|
|
state: "idle".to_string(),
|
|
media_id: None,
|
|
};
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("state_changed"));
|
|
assert!(json.contains("idle"));
|
|
assert!(json.contains("null"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_media_loaded_serialization() {
|
|
let event = PlayerStatusEvent::MediaLoaded { duration: 245.5 };
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("media_loaded"));
|
|
assert!(json.contains("245.5"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_playback_ended_serialization() {
|
|
let event = PlayerStatusEvent::PlaybackEnded;
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("playback_ended"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_buffering_serialization() {
|
|
let event = PlayerStatusEvent::Buffering { percent: 75 };
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("buffering"));
|
|
assert!(json.contains("75"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_serialization() {
|
|
let event = PlayerStatusEvent::Error {
|
|
message: "Failed to load media".to_string(),
|
|
recoverable: true,
|
|
};
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("error"));
|
|
assert!(json.contains("Failed to load media"));
|
|
assert!(json.contains("true"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_volume_changed_serialization() {
|
|
let event = PlayerStatusEvent::VolumeChanged {
|
|
volume: 0.75,
|
|
muted: false,
|
|
};
|
|
let json = serde_json::to_string(&event).unwrap();
|
|
assert!(json.contains("volume_changed"));
|
|
assert!(json.contains("0.75"));
|
|
assert!(json.contains("false"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_emitter_captures_events() {
|
|
let emitter = TestEventEmitter::new();
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
assert_eq!(emitter.events().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_emitter_multiple_events() {
|
|
let emitter = TestEventEmitter::new();
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
|
position: 10.0,
|
|
duration: 100.0,
|
|
});
|
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
|
state: "paused".to_string(),
|
|
media_id: None,
|
|
});
|
|
assert_eq!(emitter.events().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_event_emitter_thread_safety() {
|
|
let emitter = Arc::new(TestEventEmitter::new());
|
|
let mut handles = vec![];
|
|
|
|
for i in 0..10 {
|
|
let emitter_clone = Arc::clone(&emitter);
|
|
let handle = thread::spawn(move || {
|
|
emitter_clone.emit(PlayerStatusEvent::PositionUpdate {
|
|
position: i as f64,
|
|
duration: 100.0,
|
|
});
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
for handle in handles {
|
|
handle.join().unwrap();
|
|
}
|
|
|
|
assert_eq!(emitter.events().len(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shared_event_emitter() {
|
|
let emitter: SharedEventEmitter = Arc::new(TestEventEmitter::new());
|
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
|
// Verify it compiles and works as a trait object
|
|
}
|
|
}
|