mirror of
https://github.com/FluxaMedia/fluxa-desktop.git
synced 2026-08-09 16:37:30 +00:00
974 lines
41 KiB
Rust
974 lines
41 KiB
Rust
// Linux native player surface — libmpv OpenGL render API via GLX/EGL.
|
|
//
|
|
// Architecture:
|
|
// • A GtkGLArea is placed BEHIND the WebView inside a GtkOverlay.
|
|
// • The WebView stays visible with a transparent background (Tauri transparent=true).
|
|
// • The React overlay (ReactPlayerOverlay.tsx) renders on top and handles all
|
|
// player controls — no GTK controls are built here.
|
|
// • On Load/Hide we emit native-player-show / native-player-hide so the React
|
|
// overlay activates / deactivates, mirroring the Windows and macOS surfaces.
|
|
|
|
use crate::linux_vulkan::{NativeSurface, VulkanContext};
|
|
use crate::mpv_render::VulkanTargetImage;
|
|
use crate::DesktopState;
|
|
use fluxa_core::FluxaCore;
|
|
use glib::ControlFlow;
|
|
use gtk::prelude::*;
|
|
use webkit2gtk::WebViewExt;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
|
use std::sync::{mpsc, Arc};
|
|
use std::time::{Duration, Instant};
|
|
use tauri::{AppHandle, Emitter, Manager};
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum RenderBackend {
|
|
OpenGl,
|
|
Vulkan,
|
|
}
|
|
|
|
fn read_render_backend(app: &AppHandle) -> RenderBackend {
|
|
let state = app.state::<DesktopState>();
|
|
match crate::storage::read_pref_field(state, "renderBackend").as_deref() {
|
|
Some("vulkan") => RenderBackend::Vulkan,
|
|
_ => RenderBackend::OpenGl,
|
|
}
|
|
}
|
|
|
|
enum VulkanSurfaceHandle {
|
|
Wayland(crate::linux_wayland_subsurface::VideoSubsurface),
|
|
X11(gdk::Window),
|
|
}
|
|
|
|
impl VulkanSurfaceHandle {
|
|
fn hide(&self) {
|
|
match self {
|
|
Self::Wayland(sub) => sub.hide(),
|
|
Self::X11(window) => window.hide(),
|
|
}
|
|
}
|
|
|
|
fn show(&self) {
|
|
match self {
|
|
Self::Wayland(_) => {}
|
|
Self::X11(window) => {
|
|
window.show_unraised();
|
|
window.lower();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resize(&self, width: i32, height: i32) {
|
|
if let Self::X11(window) = self {
|
|
window.move_resize(0, 0, width, height);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct VulkanShared {
|
|
width: AtomicI32,
|
|
height: AtomicI32,
|
|
hdr: AtomicBool,
|
|
mpv_context_ready: AtomicBool,
|
|
}
|
|
|
|
struct VulkanState {
|
|
shared: Arc<VulkanShared>,
|
|
surface_handle: VulkanSurfaceHandle,
|
|
}
|
|
|
|
const VULKAN_CONTEXT_PENDING: &str = "vulkan render context pending";
|
|
|
|
const PRESENT_WATCHDOG_INTERVAL: Duration = Duration::from_millis(200);
|
|
|
|
fn spawn_vulkan_render_thread(app: AppHandle, mut ctx: VulkanContext, shared: Arc<VulkanShared>) {
|
|
std::thread::Builder::new()
|
|
.name("vulkan-player-render".into())
|
|
.spawn(move || {
|
|
let mut last_present = Instant::now();
|
|
loop {
|
|
let w = shared.width.load(Ordering::Acquire);
|
|
let h = shared.height.load(Ordering::Acquire);
|
|
if w > 1 && h > 1 {
|
|
if let Err(e) = ctx.resize(w, h) {
|
|
log::warn!("linux_player_surface: Vulkan resize failed: {e}");
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
continue;
|
|
}
|
|
}
|
|
let forced = last_present.elapsed() >= PRESENT_WATCHDOG_INTERVAL;
|
|
shared.hdr.store(ctx.is_hdr(), Ordering::Release);
|
|
|
|
let state = app.state::<DesktopState>();
|
|
let mut wire_error = false;
|
|
let ready = {
|
|
let Ok(mut guard) = state.player_renderer.try_lock() else {
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
continue;
|
|
};
|
|
match guard.as_mut() {
|
|
None => {
|
|
shared.mpv_context_ready.store(false, Ordering::Release);
|
|
false
|
|
}
|
|
Some(renderer) => {
|
|
if renderer.needs_vulkan_context() {
|
|
shared.mpv_context_ready.store(false, Ordering::Release);
|
|
let (instance, phys_device, device, queue_index, queue_count, get_proc_addr) =
|
|
ctx.device_handles();
|
|
let ext_ptrs = ctx.enabled_device_extension_ptrs();
|
|
match renderer.create_vulkan_context(
|
|
instance,
|
|
phys_device,
|
|
device,
|
|
queue_index,
|
|
queue_count,
|
|
get_proc_addr,
|
|
&ext_ptrs,
|
|
) {
|
|
Ok(()) => shared.mpv_context_ready.store(true, Ordering::Release),
|
|
Err(e) => {
|
|
log::error!("linux_player_surface: mpv Vulkan context failed: {e}");
|
|
wire_error = true;
|
|
}
|
|
}
|
|
} else {
|
|
shared.mpv_context_ready.store(true, Ordering::Release);
|
|
}
|
|
!wire_error && (forced || renderer.vulkan_frame_ready())
|
|
}
|
|
}
|
|
};
|
|
if wire_error {
|
|
std::thread::sleep(Duration::from_millis(250));
|
|
continue;
|
|
}
|
|
if !ready {
|
|
std::thread::sleep(Duration::from_millis(4));
|
|
continue;
|
|
}
|
|
|
|
let image_usage = ctx.image_usage();
|
|
let result = ctx.render_and_present(|image, format, iw, ih, wait_semaphore, signal_semaphore| {
|
|
let mut guard = state
|
|
.player_renderer
|
|
.lock()
|
|
.map_err(|_| "player renderer lock poisoned".to_string())?;
|
|
let renderer = guard
|
|
.as_mut()
|
|
.ok_or_else(|| "player renderer destroyed".to_string())?;
|
|
let mut target = VulkanTargetImage {
|
|
image,
|
|
format,
|
|
w: iw as i32,
|
|
h: ih as i32,
|
|
usage: image_usage,
|
|
layout: 0,
|
|
wait_semaphore,
|
|
signal_semaphore,
|
|
};
|
|
renderer.render_vulkan_frame(&mut target).map(|_| target.layout)
|
|
});
|
|
match result {
|
|
Ok(()) => {
|
|
last_present = Instant::now();
|
|
if let Ok(mut guard) = state.player_renderer.lock() {
|
|
if let Some(r) = guard.as_mut() {
|
|
r.report_swap();
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("linux_player_surface: Vulkan render failed: {e}");
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.expect("failed to spawn vulkan render thread");
|
|
}
|
|
|
|
fn create_vulkan_surface(
|
|
anchor_widget: &impl IsA<gtk::Widget>,
|
|
width: i32,
|
|
height: i32,
|
|
) -> Result<(VulkanSurfaceHandle, NativeSurface), String> {
|
|
use glib::translate::ToGlibPtr;
|
|
|
|
let toplevel = anchor_widget
|
|
.toplevel()
|
|
.ok_or("Vulkan surface setup: widget has no toplevel ancestor")?;
|
|
let toplevel_window: gtk::Window = toplevel
|
|
.downcast()
|
|
.map_err(|_| "Vulkan surface setup: toplevel ancestor is not a gtk::Window".to_string())?;
|
|
let parent_gdk_window = toplevel_window
|
|
.window()
|
|
.ok_or("Vulkan surface setup: toplevel window is not realized yet")?;
|
|
|
|
if let Ok(wl_parent) = parent_gdk_window.clone().downcast::<gdkwayland::WaylandWindow>() {
|
|
let parent_wl_surface = unsafe {
|
|
gdkwayland::ffi::gdk_wayland_window_get_wl_surface(wl_parent.to_glib_none().0)
|
|
};
|
|
let display = gdk::Display::default().ok_or("Vulkan surface setup: no default display")?;
|
|
let wayland_display: gdkwayland::WaylandDisplay = display
|
|
.downcast()
|
|
.map_err(|_| "Vulkan surface setup: default display is not Wayland".to_string())?;
|
|
let wl_display = unsafe {
|
|
gdkwayland::ffi::gdk_wayland_display_get_wl_display(wayland_display.to_glib_none().0)
|
|
};
|
|
let wl_compositor = unsafe {
|
|
gdkwayland::ffi::gdk_wayland_display_get_wl_compositor(wayland_display.to_glib_none().0)
|
|
};
|
|
if parent_wl_surface.is_null() || wl_display.is_null() || wl_compositor.is_null() {
|
|
return Err("Vulkan surface setup: could not resolve Wayland handles".into());
|
|
}
|
|
log::debug!("linux_player_surface: creating subsurface with parent_wl_surface={parent_wl_surface:p}");
|
|
let subsurface = crate::linux_wayland_subsurface::VideoSubsurface::new(
|
|
wl_display as *mut _,
|
|
wl_compositor as *mut _,
|
|
parent_wl_surface as *mut _,
|
|
)?;
|
|
let native_surface = NativeSurface::Wayland {
|
|
display: wl_display as *mut _,
|
|
surface: subsurface.wl_surface(),
|
|
};
|
|
return Ok((VulkanSurfaceHandle::Wayland(subsurface), native_surface));
|
|
}
|
|
|
|
let attrs = gdk::WindowAttr {
|
|
window_type: gdk::WindowType::Child,
|
|
wclass: gdk::WindowWindowClass::InputOutput,
|
|
x: Some(0),
|
|
y: Some(0),
|
|
width: width.max(2),
|
|
height: height.max(2),
|
|
..Default::default()
|
|
};
|
|
let window = gdk::Window::new(Some(&parent_gdk_window), &attrs);
|
|
window.show_unraised();
|
|
window.lower();
|
|
let native_surface = native_surface_for_gdk_window(&window)
|
|
.ok_or("Vulkan surface setup: could not resolve a native surface for the X11 window")?;
|
|
Ok((VulkanSurfaceHandle::X11(window), native_surface))
|
|
}
|
|
|
|
fn log_current_toplevel_wl_surface(gl_area: >k::GLArea, label: &str) {
|
|
use glib::translate::ToGlibPtr;
|
|
let Some(toplevel) = gl_area.toplevel() else { return };
|
|
let Ok(toplevel_window) = toplevel.downcast::<gtk::Window>() else { return };
|
|
let Some(gdk_window) = toplevel_window.window() else { return };
|
|
let Ok(wl_window) = gdk_window.downcast::<gdkwayland::WaylandWindow>() else { return };
|
|
let wl_surface = unsafe { gdkwayland::ffi::gdk_wayland_window_get_wl_surface(wl_window.to_glib_none().0) };
|
|
log::debug!("linux_player_surface: {label} current toplevel wl_surface={wl_surface:p}");
|
|
}
|
|
|
|
fn reassert_webview_transparency(webview_widget: >k::Widget) {
|
|
if let Ok(webview) = webview_widget.clone().downcast::<webkit2gtk::WebView>() {
|
|
webview.set_background_color(&gdk::RGBA::new(0.0, 0.0, 0.0, 1.0 / 255.0));
|
|
}
|
|
}
|
|
|
|
fn vulkan_surface_size(gl_area: >k::GLArea, webview_widget: >k::Widget) -> (i32, i32) {
|
|
let scale = webview_widget.scale_factor().max(1);
|
|
let (w, h) = gl_area
|
|
.toplevel()
|
|
.map(|t| (t.allocated_width(), t.allocated_height()))
|
|
.unwrap_or_else(|| (webview_widget.allocated_width(), webview_widget.allocated_height()));
|
|
(w.max(2) * scale, h.max(2) * scale)
|
|
}
|
|
|
|
fn native_surface_for_gdk_window(window: &gdk::Window) -> Option<NativeSurface> {
|
|
use glib::translate::ToGlibPtr;
|
|
|
|
if let Ok(wl_window) = window.clone().downcast::<gdkwayland::WaylandWindow>() {
|
|
let wl_surface =
|
|
unsafe { gdkwayland::ffi::gdk_wayland_window_get_wl_surface(wl_window.to_glib_none().0) };
|
|
let display = gdk::Display::default()?;
|
|
let wayland_display: gdkwayland::WaylandDisplay = display.downcast().ok()?;
|
|
let wl_display = unsafe {
|
|
gdkwayland::ffi::gdk_wayland_display_get_wl_display(wayland_display.to_glib_none().0)
|
|
};
|
|
if wl_surface.is_null() || wl_display.is_null() {
|
|
return None;
|
|
}
|
|
return Some(NativeSurface::Wayland {
|
|
display: wl_display as *mut _,
|
|
surface: wl_surface as *mut _,
|
|
});
|
|
}
|
|
if let Ok(x11_window) = window.clone().downcast::<gdkx11::X11Window>() {
|
|
let xid = unsafe { gdkx11::ffi::gdk_x11_window_get_xid(x11_window.to_glib_none().0) };
|
|
let display = gdk::Display::default()?;
|
|
let x11_display: gdkx11::X11Display = display.downcast().ok()?;
|
|
let xdisplay = unsafe { gdkx11::ffi::gdk_x11_display_get_xdisplay(x11_display.to_glib_none().0) };
|
|
if xdisplay.is_null() {
|
|
return None;
|
|
}
|
|
return Some(NativeSurface::Xlib {
|
|
display: xdisplay as *mut _,
|
|
window: xid as u64,
|
|
});
|
|
}
|
|
None
|
|
}
|
|
|
|
// Chapter / skip-segment utilities (polled by player_get_playback_info)
|
|
|
|
struct Chapter {
|
|
title: String,
|
|
start_ms: i64,
|
|
}
|
|
|
|
fn read_mpv_chapters(renderer: &crate::mpv_render::MpvRenderer) -> Vec<Chapter> {
|
|
let count: usize = renderer
|
|
.query_property("chapters")
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0);
|
|
if count == 0 {
|
|
return vec![];
|
|
}
|
|
(0..count)
|
|
.filter_map(|i| {
|
|
let title = renderer
|
|
.query_property(&format!("chapter-list/{i}/title"))
|
|
.unwrap_or_default();
|
|
let start_secs: f64 = renderer
|
|
.query_property(&format!("chapter-list/{i}/time"))
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0.0);
|
|
Some(Chapter {
|
|
title,
|
|
start_ms: (start_secs * 1000.0).round() as i64,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn chapters_to_json(chapters: &[Chapter]) -> String {
|
|
let arr: Vec<serde_json::Value> = chapters
|
|
.iter()
|
|
.map(|c| serde_json::json!({ "title": c.title, "startTime": c.start_ms }))
|
|
.collect();
|
|
serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
|
|
}
|
|
|
|
// Surface commands
|
|
|
|
enum SurfaceCommand {
|
|
Load {
|
|
url: String,
|
|
start_at: Option<u64>,
|
|
},
|
|
Hide,
|
|
ShowLoading {
|
|
title: String,
|
|
episode_title: Option<String>,
|
|
},
|
|
SetTitle {
|
|
title: String,
|
|
episode_title: Option<String>,
|
|
},
|
|
SetArtwork {
|
|
title: String,
|
|
episode_title: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct NativePlayerSurface {
|
|
sender: mpsc::Sender<SurfaceCommand>,
|
|
}
|
|
|
|
impl NativePlayerSurface {
|
|
pub fn load(
|
|
&self,
|
|
url: String,
|
|
start_at: Option<u64>,
|
|
_total_duration: Option<u64>,
|
|
) -> Result<(), String> {
|
|
self.sender
|
|
.send(SurfaceCommand::Load { url, start_at })
|
|
.map_err(|e| format!("native player surface is not available: {e}"))
|
|
}
|
|
|
|
pub fn hide(&self) {
|
|
let _ = self.sender.send(SurfaceCommand::Hide);
|
|
}
|
|
|
|
pub fn show_loading(&self, title: String, episode_title: Option<String>) {
|
|
let _ = self.sender.send(SurfaceCommand::ShowLoading {
|
|
title,
|
|
episode_title,
|
|
});
|
|
}
|
|
|
|
pub fn set_title(&self, title: String, episode_title: Option<String>) {
|
|
let _ = self.sender.send(SurfaceCommand::SetTitle {
|
|
title,
|
|
episode_title,
|
|
});
|
|
}
|
|
|
|
pub fn set_artwork(
|
|
&self,
|
|
title: String,
|
|
episode_title: Option<String>,
|
|
_background: Option<(Vec<u8>, i32, i32)>,
|
|
_logo: Option<(Vec<u8>, i32, i32)>,
|
|
) {
|
|
let _ = self.sender.send(SurfaceCommand::SetArtwork {
|
|
title,
|
|
episode_title,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Install
|
|
|
|
pub fn install(app_handle: AppHandle) -> Result<NativePlayerSurface, String> {
|
|
let (sender, receiver) = mpsc::channel();
|
|
let (setup_tx, setup_rx) = mpsc::channel::<Result<(), String>>();
|
|
|
|
let backend = read_render_backend(&app_handle);
|
|
log::info!(
|
|
"linux_player_surface: experimental render backend = {}",
|
|
match backend {
|
|
RenderBackend::OpenGl => "opengl",
|
|
RenderBackend::Vulkan => "vulkan",
|
|
}
|
|
);
|
|
|
|
let window = app_handle
|
|
.get_webview_window("main")
|
|
.ok_or_else(|| "main webview window was not found".to_string())?;
|
|
|
|
window
|
|
.with_webview(move |platform_webview| {
|
|
let webview_widget = platform_webview.inner().upcast::<gtk::Widget>();
|
|
let Some(parent_widget) = webview_widget.parent() else {
|
|
let _ = setup_tx.send(Err("WebView parent widget not found — cannot attach player surface".to_string()));
|
|
return;
|
|
};
|
|
let Ok(parent_box) = parent_widget.downcast::<gtk::Box>() else {
|
|
let _ = setup_tx.send(Err(
|
|
"WebView parent is not a GTK Box; widget hierarchy may differ on this Tauri build".to_string(),
|
|
));
|
|
return;
|
|
};
|
|
|
|
// GLArea for mpv OpenGL rendering — initially hidden, shown only when playing.
|
|
let gl_area = gtk::GLArea::new();
|
|
gl_area.set_hexpand(true);
|
|
gl_area.set_vexpand(true);
|
|
gl_area.set_has_alpha(false);
|
|
gl_area.set_auto_render(false);
|
|
gl_area.set_required_version(3, 2);
|
|
// no_show_all prevents parent show_all() from revealing the video prematurely.
|
|
gl_area.set_no_show_all(true);
|
|
|
|
// Wrap in a GtkOverlay: GLArea (base/background) + WebView (overlay/foreground).
|
|
// The WebView is transparent when the player is active (see App.tsx + transparent=true
|
|
// in tauri.conf.json), so the video shows through from the GLArea behind it.
|
|
let video_overlay = gtk::Overlay::new();
|
|
video_overlay.set_hexpand(true);
|
|
video_overlay.set_vexpand(true);
|
|
|
|
webview_widget.set_hexpand(true);
|
|
webview_widget.set_vexpand(true);
|
|
|
|
// Reparent: move the WebView from parent_box into the overlay (on top of GLArea).
|
|
parent_box.remove(&webview_widget);
|
|
video_overlay.add(&gl_area);
|
|
video_overlay.add_overlay(&webview_widget);
|
|
webview_widget.set_halign(gtk::Align::Fill);
|
|
webview_widget.set_valign(gtk::Align::Fill);
|
|
parent_box.pack_start(&video_overlay, true, true, 0);
|
|
parent_box.show_all();
|
|
webview_widget.show();
|
|
webview_widget.queue_resize();
|
|
webview_widget.queue_draw();
|
|
reassert_webview_transparency(&webview_widget);
|
|
video_overlay.queue_resize();
|
|
video_overlay.queue_draw();
|
|
// gl_area stays hidden (no_show_all) until the first Load command.
|
|
|
|
// Render callback: called by GTK when gl_area.queue_render() is invoked.
|
|
if backend == RenderBackend::OpenGl {
|
|
let render_app = app_handle.clone();
|
|
gl_area.connect_render(move |area, _ctx| {
|
|
let state = render_app.state::<DesktopState>();
|
|
if state.pending_hide.load(std::sync::atomic::Ordering::Acquire) {
|
|
// Returning Proceed (unhandled) with auto_render=false skips GTK's
|
|
// gdk_cairo_draw_from_gl readback path — prevents a multi-second freeze
|
|
// that occurs on some composited systems when the last video frame is read.
|
|
area.set_auto_render(false);
|
|
return glib::Propagation::Proceed;
|
|
}
|
|
area.make_current();
|
|
if area.error().is_some() {
|
|
return glib::Propagation::Stop;
|
|
}
|
|
let scale = area.scale_factor().max(1);
|
|
let w = area.allocated_width().max(2) * scale;
|
|
let h = area.allocated_height().max(2) * scale;
|
|
let Ok(mut renderer) = state.player_renderer.try_lock() else {
|
|
return glib::Propagation::Stop;
|
|
};
|
|
if let Some(r) = renderer.as_mut() {
|
|
if let Err(e) = r.render_opengl_frame(w, h) {
|
|
log::warn!("mpv OpenGL render failed: {e}");
|
|
}
|
|
r.report_swap();
|
|
}
|
|
glib::Propagation::Stop
|
|
});
|
|
}
|
|
|
|
let _ = setup_tx.send(Ok(()));
|
|
|
|
let command_app = app_handle.clone();
|
|
let command_gl_area = gl_area.clone();
|
|
let command_webview_widget = webview_widget.clone();
|
|
// Shared with the GdkFrameClock tick callback below, both on the GTK main thread.
|
|
let visible = std::rc::Rc::new(std::cell::Cell::new(false));
|
|
let tick_visible = visible.clone();
|
|
let tick_gl_area = gl_area.clone();
|
|
gl_area.add_tick_callback(move |_area, _frame_clock| {
|
|
if tick_visible.get() && backend == RenderBackend::OpenGl {
|
|
tick_gl_area.queue_render();
|
|
}
|
|
glib::ControlFlow::Continue
|
|
});
|
|
let vulkan_state: Rc<RefCell<Option<VulkanState>>> = Rc::new(RefCell::new(None));
|
|
let mut pending_load: Option<(String, Option<u64>)> = None;
|
|
let mut pending_load_retries: u32 = 0;
|
|
let mut latch_grace_ticks: u32 = 0;
|
|
let mut chapters_native_loaded = false;
|
|
let mut transparency_reassert_ticks: u32 = 0;
|
|
let mut screenshot_countdown_ticks: i32 = -1;
|
|
let mut screenshot_seq: u32 = 0;
|
|
|
|
glib::timeout_add_local(Duration::from_millis(16), move || {
|
|
// Drain surface commands
|
|
while let Ok(command) = receiver.try_recv() {
|
|
match command {
|
|
SurfaceCommand::Load { url, start_at } => {
|
|
command_app.state::<DesktopState>().pending_hide
|
|
.store(false, std::sync::atomic::Ordering::Release);
|
|
*command_app.state::<DesktopState>().eof_next_fired.lock().unwrap() = false;
|
|
*command_app.state::<DesktopState>().chapters_json.lock().unwrap() = None;
|
|
if backend == RenderBackend::OpenGl {
|
|
command_gl_area.set_auto_render(true);
|
|
command_gl_area.show();
|
|
} else if let Some(state) = vulkan_state.borrow().as_ref() {
|
|
state.surface_handle.show();
|
|
}
|
|
visible.set(true);
|
|
latch_grace_ticks = 10;
|
|
chapters_native_loaded = false;
|
|
pending_load = Some((url, start_at));
|
|
pending_load_retries = 0;
|
|
let emit_result = command_app.emit("native-player-show", ());
|
|
log::info!("linux_player_surface: emitted native-player-show, result={emit_result:?}");
|
|
}
|
|
SurfaceCommand::Hide => {
|
|
visible.set(false);
|
|
pending_load = None;
|
|
pending_load_retries = 0;
|
|
command_app.state::<DesktopState>().pending_hide
|
|
.store(true, std::sync::atomic::Ordering::Release);
|
|
if backend == RenderBackend::OpenGl {
|
|
command_gl_area.set_auto_render(false);
|
|
command_gl_area.hide();
|
|
} else if let Some(state) = vulkan_state.borrow().as_ref() {
|
|
state.surface_handle.hide();
|
|
}
|
|
let _ = command_app.emit("native-player-hide", ());
|
|
if let Ok(guard) = command_app.state::<DesktopState>().player_renderer.try_lock() {
|
|
if let Some(r) = guard.as_ref() {
|
|
let _ = r.command_string("stop");
|
|
}
|
|
}
|
|
}
|
|
SurfaceCommand::ShowLoading { title, episode_title } => {
|
|
command_app.state::<DesktopState>().pending_hide
|
|
.store(false, std::sync::atomic::Ordering::Release);
|
|
let _ = command_app.emit(
|
|
"native-player-title",
|
|
serde_json::json!({ "title": title, "episodeTitle": episode_title }),
|
|
);
|
|
}
|
|
SurfaceCommand::SetTitle { title, episode_title } => {
|
|
let _ = command_app.emit(
|
|
"native-player-title",
|
|
serde_json::json!({ "title": title, "episodeTitle": episode_title }),
|
|
);
|
|
}
|
|
SurfaceCommand::SetArtwork { title, episode_title } => {
|
|
// Artwork display is handled by PlayerLoadingOverlay in the WebView.
|
|
// We just forward the title so ReactPlayerOverlay can show it.
|
|
let _ = command_app.emit(
|
|
"native-player-title",
|
|
serde_json::json!({ "title": title, "episodeTitle": episode_title }),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Warm up the render context before first play. Blocking work here (idle,
|
|
// no user waiting) is better than doing it inside prepare_and_load when the
|
|
// loading screen is shown.
|
|
let warmup_ready = match backend {
|
|
RenderBackend::OpenGl => command_gl_area.is_realized(),
|
|
RenderBackend::Vulkan => command_gl_area
|
|
.toplevel()
|
|
.map(|t| t.is_realized())
|
|
.unwrap_or(false),
|
|
};
|
|
if pending_load.is_none() && warmup_ready {
|
|
match backend {
|
|
RenderBackend::OpenGl => {
|
|
if let Ok(mut guard) = command_app.state::<DesktopState>().player_renderer.try_lock() {
|
|
if let Some(renderer) = guard.as_mut() {
|
|
if renderer.needs_opengl_context() {
|
|
command_gl_area.make_current();
|
|
if command_gl_area.error().is_none() {
|
|
let _ = renderer.prepare_opengl_context();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
RenderBackend::Vulkan => {
|
|
if vulkan_state.borrow().is_none() {
|
|
let (w, h) = vulkan_surface_size(&command_gl_area, &command_webview_widget);
|
|
match create_vulkan_surface(&command_gl_area, w, h) {
|
|
Ok((surface_handle, native_surface)) => {
|
|
match VulkanContext::new(native_surface, w, h) {
|
|
Ok(ctx) => {
|
|
let shared = Arc::new(VulkanShared {
|
|
width: AtomicI32::new(w),
|
|
height: AtomicI32::new(h),
|
|
hdr: AtomicBool::new(false),
|
|
mpv_context_ready: AtomicBool::new(false),
|
|
});
|
|
spawn_vulkan_render_thread(
|
|
command_app.clone(),
|
|
ctx,
|
|
shared.clone(),
|
|
);
|
|
*vulkan_state.borrow_mut() =
|
|
Some(VulkanState { shared, surface_handle })
|
|
}
|
|
Err(e) => log::error!(
|
|
"linux_player_surface: Vulkan context creation failed: {e}"
|
|
),
|
|
}
|
|
}
|
|
Err(e) => log::error!("linux_player_surface: {e}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Retry deferred load
|
|
if let Some((ref url, start_at)) = pending_load.clone() {
|
|
let state = command_app.state::<DesktopState>();
|
|
if state.player_renderer.try_lock().is_ok() {
|
|
match prepare_and_load(&command_app, &command_gl_area, backend, &vulkan_state, url, start_at) {
|
|
Ok(()) => {
|
|
pending_load = None;
|
|
latch_grace_ticks = 10;
|
|
let hide_pending = command_app.state::<DesktopState>()
|
|
.pending_hide.load(std::sync::atomic::Ordering::Acquire);
|
|
if hide_pending {
|
|
visible.set(false);
|
|
command_gl_area.hide();
|
|
}
|
|
}
|
|
Err(e) if e == VULKAN_CONTEXT_PENDING => {
|
|
pending_load_retries += 1;
|
|
if pending_load_retries > 300 {
|
|
pending_load = None;
|
|
visible.set(false);
|
|
command_gl_area.hide();
|
|
let _ = command_app.emit("native-player-error", e);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
pending_load = None;
|
|
visible.set(false);
|
|
command_gl_area.hide();
|
|
log::warn!("native player load failed: {e}");
|
|
let _ = command_app.emit("native-player-error", e);
|
|
}
|
|
}
|
|
} else {
|
|
pending_load_retries += 1;
|
|
if pending_load_retries > 300 {
|
|
pending_load = None;
|
|
visible.set(false);
|
|
command_gl_area.hide();
|
|
let _ = command_app.emit("native-player-error", "player renderer busy".to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Per-frame tasks while playing
|
|
if visible.get() {
|
|
let in_grace = if latch_grace_ticks > 0 {
|
|
latch_grace_ticks -= 1;
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
|
|
// Read native chapters from mpv once playback has started.
|
|
// Stored in DesktopState so player_get_playback_info() can return them
|
|
// to the React overlay for chapter-segmented seekbar rendering.
|
|
if !in_grace && !chapters_native_loaded {
|
|
let state = command_app.state::<DesktopState>();
|
|
let pos = state.player_renderer.try_lock().ok()
|
|
.and_then(|g| g.as_ref()
|
|
.and_then(|r| r.query_property("time-pos")
|
|
.and_then(|v| v.trim().parse::<f64>().ok())))
|
|
.unwrap_or(0.0);
|
|
if pos > 0.05 {
|
|
chapters_native_loaded = true;
|
|
let chapters_already_set = state.chapters_json.lock().unwrap().is_some();
|
|
if !chapters_already_set {
|
|
if let Ok(guard) = state.player_renderer.try_lock() {
|
|
if let Some(renderer) = guard.as_ref() {
|
|
let native = read_mpv_chapters(renderer);
|
|
if !native.is_empty() {
|
|
let chapters_json = chapters_to_json(&native);
|
|
*state.chapters_json.lock().unwrap() =
|
|
Some(chapters_json.clone());
|
|
let skip_already_set =
|
|
state.skip_segments_json.lock().unwrap().is_some();
|
|
let chapter_skip_enabled =
|
|
*state.use_chapter_skip.lock().unwrap();
|
|
if !skip_already_set && chapter_skip_enabled {
|
|
let derived = FluxaCore::chapter_skip_segments_json(&chapters_json);
|
|
if derived != "[]" {
|
|
*state.skip_segments_json.lock().unwrap() = Some(derived);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if backend == RenderBackend::Vulkan {
|
|
let (w, h) = vulkan_surface_size(&command_gl_area, &command_webview_widget);
|
|
if let Some(state) = vulkan_state.borrow().as_ref() {
|
|
state.surface_handle.resize(w, h);
|
|
let prev_w = state.shared.width.swap(w, Ordering::AcqRel);
|
|
let prev_h = state.shared.height.swap(h, Ordering::AcqRel);
|
|
transparency_reassert_ticks += 1;
|
|
if prev_w != w || prev_h != h || transparency_reassert_ticks >= 60 {
|
|
transparency_reassert_ticks = 0;
|
|
reassert_webview_transparency(&command_webview_widget);
|
|
}
|
|
if prev_w != w || prev_h != h {
|
|
log_current_toplevel_wl_surface(&command_gl_area, "resize");
|
|
screenshot_countdown_ticks = 30;
|
|
}
|
|
}
|
|
}
|
|
|
|
if screenshot_countdown_ticks >= 0 {
|
|
screenshot_countdown_ticks -= 1;
|
|
if screenshot_countdown_ticks == 0 {
|
|
if let Ok(guard) = command_app.state::<DesktopState>().player_renderer.try_lock() {
|
|
if let Some(r) = guard.as_ref() {
|
|
screenshot_seq += 1;
|
|
let path = format!("/tmp/fluxa_mpv_screenshot_{screenshot_seq}.png");
|
|
let _ = r.command_string(&format!("screenshot-to-file \"{path}\""));
|
|
log::debug!("linux_player_surface: mpv screenshot-to-file requested at {path}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
check_player_events(&command_app);
|
|
}
|
|
|
|
ControlFlow::Continue
|
|
});
|
|
})
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
setup_rx
|
|
.recv_timeout(Duration::from_secs(5))
|
|
.map_err(|_| "native player surface setup timed out".to_string())
|
|
.and_then(|r| r)
|
|
.map(|()| NativePlayerSurface { sender })
|
|
}
|
|
|
|
// Helpers
|
|
|
|
fn prepare_and_load(
|
|
app_handle: &AppHandle,
|
|
gl_area: >k::GLArea,
|
|
backend: RenderBackend,
|
|
vulkan_state: &Rc<RefCell<Option<VulkanState>>>,
|
|
url: &str,
|
|
start_at: Option<u64>,
|
|
) -> Result<(), String> {
|
|
if backend == RenderBackend::OpenGl && !gl_area.is_realized() {
|
|
gl_area.realize();
|
|
}
|
|
|
|
let state = app_handle.state::<DesktopState>();
|
|
let mut renderer = state
|
|
.player_renderer
|
|
.try_lock()
|
|
.map_err(|_| "player renderer busy — load deferred".to_string())?;
|
|
if renderer.is_none() {
|
|
*renderer = Some(crate::mpv_render::MpvRenderer::new()?);
|
|
}
|
|
let renderer = renderer
|
|
.as_mut()
|
|
.ok_or_else(|| "player renderer is not initialized".to_string())?;
|
|
|
|
match backend {
|
|
RenderBackend::OpenGl => {
|
|
gl_area.make_current();
|
|
if let Some(error) = gl_area.error() {
|
|
return Err(format!("OpenGL player surface context error: {error}"));
|
|
}
|
|
renderer.prepare_opengl_context()?;
|
|
}
|
|
RenderBackend::Vulkan => {
|
|
let shared = vulkan_state
|
|
.borrow()
|
|
.as_ref()
|
|
.map(|vs| vs.shared.clone())
|
|
.ok_or_else(|| VULKAN_CONTEXT_PENDING.to_string())?;
|
|
if renderer.needs_vulkan_context() && !shared.mpv_context_ready.load(Ordering::Acquire) {
|
|
return Err(VULKAN_CONTEXT_PENDING.to_string());
|
|
}
|
|
if shared.hdr.load(Ordering::Acquire) {
|
|
let _ = renderer.set_option("target-trc", "linear");
|
|
let _ = renderer.set_option("target-prim", "bt.709");
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(fps) = monitor_refresh_fps(gl_area) {
|
|
if let Err(e) = renderer.set_option("display-fps-override", &format!("{fps:.3}")) {
|
|
log::warn!("failed to set display-fps-override: {e}");
|
|
}
|
|
}
|
|
if let Some(icc) = query_x11_icc_profile() {
|
|
if let Err(e) = renderer.set_icc_profile(&icc) {
|
|
log::warn!("failed to set ICC profile: {e}");
|
|
}
|
|
}
|
|
renderer.load(url, start_at)
|
|
}
|
|
|
|
fn monitor_refresh_fps(gl_area: >k::GLArea) -> Option<f64> {
|
|
let window = gl_area.window()?;
|
|
let monitor = gl_area.display().monitor_at_window(&window)?;
|
|
let mhz = monitor.refresh_rate();
|
|
(mhz > 0).then(|| f64::from(mhz) / 1000.0)
|
|
}
|
|
|
|
// X11-only; no equivalent mechanism under Wayland.
|
|
fn query_x11_icc_profile() -> Option<Vec<u8>> {
|
|
use gdkx11::x11::xlib;
|
|
use glib::translate::ToGlibPtr;
|
|
use std::ffi::CString;
|
|
|
|
let display = gdk::Display::default()?;
|
|
let x11_display: gdkx11::X11Display = display.downcast().ok()?;
|
|
let xdisplay =
|
|
unsafe { gdkx11::ffi::gdk_x11_display_get_xdisplay(x11_display.to_glib_none().0) };
|
|
if xdisplay.is_null() {
|
|
return None;
|
|
}
|
|
|
|
unsafe {
|
|
let root = xlib::XDefaultRootWindow(xdisplay);
|
|
let atom_name = CString::new("_ICC_PROFILE").ok()?;
|
|
let atom = xlib::XInternAtom(xdisplay, atom_name.as_ptr(), 1 /* only_if_exists */);
|
|
if atom == 0 {
|
|
return None;
|
|
}
|
|
|
|
let mut actual_type: xlib::Atom = 0;
|
|
let mut actual_format: i32 = 0;
|
|
let mut nitems: std::os::raw::c_ulong = 0;
|
|
let mut bytes_after: std::os::raw::c_ulong = 0;
|
|
let mut prop: *mut std::os::raw::c_uchar = std::ptr::null_mut();
|
|
let result = xlib::XGetWindowProperty(
|
|
xdisplay,
|
|
root,
|
|
atom,
|
|
0,
|
|
i64::MAX / 4,
|
|
0,
|
|
xlib::AnyPropertyType as std::os::raw::c_ulong,
|
|
&mut actual_type,
|
|
&mut actual_format,
|
|
&mut nitems,
|
|
&mut bytes_after,
|
|
&mut prop,
|
|
);
|
|
if result != 0 || prop.is_null() || nitems == 0 {
|
|
return None;
|
|
}
|
|
let data = std::slice::from_raw_parts(prop, nitems as usize).to_vec();
|
|
xlib::XFree(prop as *mut std::os::raw::c_void);
|
|
Some(data)
|
|
}
|
|
}
|
|
|
|
fn check_player_events(app: &AppHandle) {
|
|
let state = app.state::<DesktopState>();
|
|
let (events, eof) = {
|
|
let Ok(mut renderer) = state.player_renderer.try_lock() else {
|
|
return;
|
|
};
|
|
let Some(r) = renderer.as_mut() else {
|
|
return;
|
|
};
|
|
let events = r.poll_events();
|
|
let eof = r.query_property("eof-reached").as_deref() == Some("yes");
|
|
(events, eof)
|
|
};
|
|
for event in events {
|
|
let crate::mpv_render::PlayerEvent::EndFile { eof: _, error } = event;
|
|
if let Some(message) = error {
|
|
log::error!("linux player surface: stream failed to play: {message}");
|
|
let _ = app.emit("native-player-error", message);
|
|
}
|
|
}
|
|
if !eof {
|
|
let mut fired = state.eof_next_fired.lock().unwrap();
|
|
if *fired {
|
|
*fired = false;
|
|
}
|
|
return;
|
|
}
|
|
let mut fired = state.eof_next_fired.lock().unwrap();
|
|
if *fired {
|
|
return;
|
|
}
|
|
*fired = true;
|
|
drop(fired);
|
|
|
|
let next_sub = state.next_ep_subtitle.lock().unwrap().clone();
|
|
let auto_play = *state.auto_play_next_episode.lock().unwrap();
|
|
if FluxaCore::should_play_next_episode(!next_sub.is_empty(), auto_play) {
|
|
let _ = app.emit("native-player-next-episode", ());
|
|
} else {
|
|
let _ = app.emit("native-player-close-requested", ());
|
|
}
|
|
}
|