fix(windows): synchronize native player controls and status

This commit is contained in:
KhooLy 2026-07-26 14:48:01 +03:00
parent 64025232f9
commit 52284c53f8
8 changed files with 127 additions and 10 deletions

View file

@ -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,

View file

@ -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<f64>,
waiting_for_seek_restart: bool,
@ -264,6 +265,7 @@ pub struct PlayerStatus {
pub cache_buffering_state: Option<String>,
pub file_format: Option<String>,
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<Vec<u8>, 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,

View file

@ -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<u64>) -> 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)
}

View file

@ -119,6 +119,39 @@ where
Err("player renderer busy".to_string())
}
fn with_renderer_retry_mut<T, F>(
state: &DesktopState,
attempts: usize,
mut f: F,
) -> Result<Option<T>, String>
where
F: FnMut(&mut dyn PlaybackEngine) -> Result<T, String>,
{
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<DesktopState>) -> bool {
}
#[tauri::command]
pub fn player_command(state: State<DesktopState>, command: String) -> Result<(), String> {
pub fn player_command(_app: AppHandle, state: State<DesktopState>, 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()),

View file

@ -276,6 +276,8 @@ enum SurfaceCommand {
total_duration: Option<u64>,
},
Hide,
PlayerCommand(String),
Status(mpsc::Sender<crate::mpv_render::PlayerStatus>),
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<crate::mpv_render::PlayerStatus, String> {
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::<DesktopState>();
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::<DesktopState>();
let renderer = state.player_renderer.lock().unwrap();
if let Some(r) = renderer.as_ref() {
let _ = sender.send(r.status());
}
}
SurfaceCommand::Hide => {
visible = false;
unsafe {

View file

@ -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();
}
}

View file

@ -61,6 +61,7 @@ export type EmbeddedMpvStatus = {
cacheBufferingState?: string | null;
fileFormat?: string | null;
framesRendered: number;
firstFramePresented: boolean;
hasVideoTrack: boolean;
trackListReady: boolean;
resuming: boolean;

View file

@ -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);