diff --git a/src-tauri/src/libvlc_render.rs b/src-tauri/src/libvlc_render.rs index c401b0e..e5a1b8a 100644 --- a/src-tauri/src/libvlc_render.rs +++ b/src-tauri/src/libvlc_render.rs @@ -546,6 +546,7 @@ impl PlaybackEngine for LibvlcPlayer { cache_buffering_state: None, file_format: None, frames_rendered: 0, + first_frame_presented: has_video && state == LIBVLC_PLAYING, has_video_track: has_video, track_list_ready: state == LIBVLC_PLAYING || state == LIBVLC_PAUSED || length_ms > 0, resuming: false, diff --git a/src-tauri/src/mpv_render.rs b/src-tauri/src/mpv_render.rs index b7fc526..e3d8342 100644 --- a/src-tauri/src/mpv_render.rs +++ b/src-tauri/src/mpv_render.rs @@ -197,6 +197,7 @@ pub struct MpvRenderer { loaded: bool, log_ring: std::collections::VecDeque<(c_int, String)>, frames_rendered: u64, + first_frame_presented: bool, pending_unpause: bool, pending_seek_seconds: Option, waiting_for_seek_restart: bool, @@ -264,6 +265,7 @@ pub struct PlayerStatus { pub cache_buffering_state: Option, pub file_format: Option, pub frames_rendered: u64, + pub first_frame_presented: bool, pub has_video_track: bool, pub track_list_ready: bool, pub resuming: bool, @@ -358,6 +360,7 @@ impl MpvRenderer { loaded: false, log_ring: std::collections::VecDeque::new(), frames_rendered: 0, + first_frame_presented: false, pending_unpause: false, pending_seek_seconds: None, waiting_for_seek_restart: false, @@ -437,6 +440,7 @@ impl MpvRenderer { loaded: false, log_ring: std::collections::VecDeque::new(), frames_rendered: 0, + first_frame_presented: false, pending_unpause: false, pending_seek_seconds: None, waiting_for_seek_restart: false, @@ -482,6 +486,7 @@ impl MpvRenderer { self.current_url = Some(url.to_string()); self.log_ring.clear(); self.frames_rendered = 0; + self.first_frame_presented = false; self.pending_unpause = true; self.pending_seek_seconds = start_at.filter(|&s| s > 0).map(|s| s as f64); self.waiting_for_seek_restart = false; @@ -510,10 +515,28 @@ impl MpvRenderer { Ok(()) } + pub fn first_frame_presented(&self) -> bool { + self.first_frame_presented + } + pub fn seek_to(&self, time_pos: f64) -> Result<(), String> { self.command_string(&format!("seek {time_pos:.3} absolute+exact")) } + /// Dispatch a command originating from the UI. Startup resume/unpause work + /// must never run later and overwrite an explicit user action. + pub fn user_command(&mut self, command: &str) -> Result<(), String> { + let command = command.trim(); + if command == "cycle pause" || command.starts_with("set pause ") { + self.pending_unpause = false; + } + if command.starts_with("seek ") || command.starts_with("set time-pos ") { + self.pending_seek_seconds = None; + self.waiting_for_seek_restart = false; + } + self.command_string(command) + } + pub fn render_thumbnail(&mut self, width: i32, height: i32) -> Result, String> { if !self.loaded { return Err("not loaded".to_string()); @@ -801,6 +824,9 @@ impl MpvRenderer { unsafe { (self.api.mpv_render_context_report_swap)(self.render_context); } + if self.frame_ready_to_restore_audio { + self.first_frame_presented = true; + } self.restore_audio_after_first_presented_frame(); } @@ -1075,6 +1101,7 @@ impl MpvRenderer { cache_buffering_state: self.get_string_property("cache-buffering-state"), file_format: self.get_string_property("file-format"), frames_rendered: self.frames_rendered, + first_frame_presented: self.first_frame_presented, has_video_track, track_list_ready, resuming: self.pending_seek_seconds.is_some() || self.waiting_for_seek_restart, diff --git a/src-tauri/src/playback_engine.rs b/src-tauri/src/playback_engine.rs index 71c7cd0..7528ca6 100644 --- a/src-tauri/src/playback_engine.rs +++ b/src-tauri/src/playback_engine.rs @@ -2,6 +2,9 @@ use crate::mpv_render::{MpvRenderer, PlayerEvent, PlayerStatus, PlayerTrackOptio pub trait PlaybackEngine: Send { fn load(&mut self, url: &str, start_at: Option) -> Result<(), String>; + fn user_command(&mut self, command: &str) -> Result<(), String> { + self.command_string(command) + } fn command_string(&self, command: &str) -> Result<(), String>; fn command_args(&self, args: &[&str]) -> Result<(), String>; fn apply_options(&self, options: &[(String, String)]) -> Result<(), String>; @@ -21,6 +24,9 @@ impl PlaybackEngine for MpvRenderer { fn command_string(&self, command: &str) -> Result<(), String> { MpvRenderer::command_string(self, command) } + fn user_command(&mut self, command: &str) -> Result<(), String> { + MpvRenderer::user_command(self, command) + } fn command_args(&self, args: &[&str]) -> Result<(), String> { MpvRenderer::command_args(self, args) } diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs index 97c9ddc..4bda6f2 100644 --- a/src-tauri/src/player.rs +++ b/src-tauri/src/player.rs @@ -119,6 +119,39 @@ where Err("player renderer busy".to_string()) } +fn with_renderer_retry_mut( + state: &DesktopState, + attempts: usize, + mut f: F, +) -> Result, String> +where + F: FnMut(&mut dyn PlaybackEngine) -> Result, +{ + let engine = *state.active_player_engine.lock().unwrap(); + for _ in 0..attempts { + match engine { + PlayerEngine::Mpv => { + if let Ok(mut guard) = state.player_renderer.try_lock() { + if let Some(renderer) = guard.as_mut() { + return f(renderer).map(Some); + } + return Ok(None); + } + } + PlayerEngine::Vlc => { + if let Ok(mut guard) = state.player_renderer_vlc.try_lock() { + if let Some(renderer) = guard.as_mut() { + return f(renderer).map(Some); + } + return Ok(None); + } + } + } + std::thread::sleep(Duration::from_millis(5)); + } + Err("player renderer busy".to_string()) +} + #[tauri::command] pub async fn player_init(app: AppHandle, state: State<'_, DesktopState>) -> Result<(), String> { log::info!("player_init: start"); @@ -546,11 +579,17 @@ pub fn player_get_anime4k_enabled(state: State) -> bool { } #[tauri::command] -pub fn player_command(state: State, command: String) -> Result<(), String> { +pub fn player_command(_app: AppHandle, state: State, command: String) -> Result<(), String> { if command == "stop" { *state.eof_next_fired.lock().unwrap() = true; } - match with_renderer_retry(&state, 60, |renderer| renderer.command_string(&command)) { + #[cfg(target_os = "windows")] + if *state.active_player_engine.lock().unwrap() == PlayerEngine::Mpv { + if let Some(surface) = state.native_player_surface.lock().unwrap().as_ref() { + return surface.command(command); + } + } + match with_renderer_retry_mut(&state, 60, |renderer| renderer.user_command(&command)) { Ok(Some(())) => Ok(()), Ok(None) => Err("player renderer is not initialized".to_string()), Err(e) => Err(e), @@ -674,6 +713,12 @@ pub fn player_status( } } } + #[cfg(target_os = "windows")] + if *state.active_player_engine.lock().unwrap() == PlayerEngine::Mpv { + if let Some(surface) = state.native_player_surface.lock().unwrap().as_ref() { + return surface.status(); + } + } match with_renderer_retry(&state, 80, |renderer| Ok(renderer.status())) { Ok(Some(status)) => Ok(status), Ok(None) => Err("player renderer is not initialized".to_string()), diff --git a/src-tauri/src/windows_player_surface.rs b/src-tauri/src/windows_player_surface.rs index 1b78f9c..01eaf9d 100644 --- a/src-tauri/src/windows_player_surface.rs +++ b/src-tauri/src/windows_player_surface.rs @@ -276,6 +276,8 @@ enum SurfaceCommand { total_duration: Option, }, Hide, + PlayerCommand(String), + Status(mpsc::Sender), SetCursorVisible(bool), ShowLoading { title: String, @@ -316,6 +318,20 @@ impl NativePlayerSurface { pub fn hide(&self) { let _ = self.sender.send(SurfaceCommand::Hide); } + pub fn command(&self, command: String) -> Result<(), String> { + self.sender + .send(SurfaceCommand::PlayerCommand(command)) + .map_err(|e| format!("surface unavailable: {e}")) + } + pub fn status(&self) -> Result { + let (sender, receiver) = mpsc::channel(); + self.sender + .send(SurfaceCommand::Status(sender)) + .map_err(|e| format!("surface unavailable: {e}"))?; + receiver + .recv_timeout(Duration::from_millis(250)) + .map_err(|e| format!("player status unavailable: {e}")) + } pub fn set_cursor_visible(&self, visible: bool) { let _ = self.sender.send(SurfaceCommand::SetCursorVisible(visible)); } @@ -620,6 +636,22 @@ fn spawn_install_thread( unsafe { ShowWindow(child_hwnd, SW_HIDE) }; } } + SurfaceCommand::PlayerCommand(command) => { + let state = app.state::(); + let mut renderer = state.player_renderer.lock().unwrap(); + if let Some(r) = renderer.as_mut() { + if let Err(error) = r.user_command(&command) { + log::warn!("player surface: command failed ({command}): {error}"); + } + } + } + SurfaceCommand::Status(sender) => { + let state = app.state::(); + let renderer = state.player_renderer.lock().unwrap(); + if let Some(r) = renderer.as_ref() { + let _ = sender.send(r.status()); + } + } SurfaceCommand::Hide => { visible = false; unsafe { diff --git a/src/components/ReactPlayerOverlay.tsx b/src/components/ReactPlayerOverlay.tsx index 7b93523..38d3850 100644 --- a/src/components/ReactPlayerOverlay.tsx +++ b/src/components/ReactPlayerOverlay.tsx @@ -310,7 +310,6 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in position > 0.15; if (!firstFrameFiredRef.current && onFirstFrame && hasRenderedVideo) { firstFrameFiredRef.current = true; - sendCmd('set pause no'); onFirstFrame(); } } @@ -513,16 +512,22 @@ export function ReactPlayerOverlay({ closePlayer, onFirstFrame, initialTitle, in const hasVideoDimensions = (parseFloat(status.width ?? '0') || 0) > 0 && (parseFloat(status.height ?? '0') || 0) > 0; - const playbackAdvancing = + const renderedVideo = status.loaded && - status.pause !== 'yes' && + status.hasVideoTrack && + hasVideoDimensions && + status.voConfigured === 'yes' && + status.framesRendered >= 2 && status.pausedForCache !== 'yes' && pos > 0.15; - const renderedVideo = status.hasVideoTrack && hasVideoDimensions && status.voConfigured === 'yes' && status.framesRendered >= 2 && playbackAdvancing; - const activeAudioOnlyPlayback = status.trackListReady && !status.hasVideoTrack && playbackAdvancing; - if (playbackUrl && status.path === playbackUrl && !status.resuming && (renderedVideo || activeAudioOnlyPlayback)) { + const activeAudioOnlyPlayback = + status.loaded && + status.trackListReady && + !status.hasVideoTrack && + status.pausedForCache !== 'yes' && + pos > 0.05; + if (renderedVideo || activeAudioOnlyPlayback) { firstFrameFiredRef.current = true; - sendCmd('set pause no'); onFirstFrame(); } } diff --git a/src/core/mpvPlayer.ts b/src/core/mpvPlayer.ts index 64bb31f..438d1e5 100644 --- a/src/core/mpvPlayer.ts +++ b/src/core/mpvPlayer.ts @@ -61,6 +61,7 @@ export type EmbeddedMpvStatus = { cacheBufferingState?: string | null; fileFormat?: string | null; framesRendered: number; + firstFramePresented: boolean; hasVideoTrack: boolean; trackListReady: boolean; resuming: boolean; diff --git a/src/core/playerStatusStore.ts b/src/core/playerStatusStore.ts index caefc7d..fe3243a 100644 --- a/src/core/playerStatusStore.ts +++ b/src/core/playerStatusStore.ts @@ -34,7 +34,7 @@ export function subscribePlayerStatus(listener: Listener): () => void { listeners.add(listener); if (timer === null) { void poll(); - timer = window.setInterval(() => { void poll(); }, 500); + timer = window.setInterval(() => { void poll(); }, 100); } return () => { listeners.delete(listener);