Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
469959b
refactor(linux): rewrite Wayland renderer with RAII, drop driver work…
MaxMB15 Apr 14, 2026
0955e99
fix(linux): resolve PlatformRenderer Send+Sync bounds and disjoint-ca…
MaxMB15 Apr 14, 2026
2bc9f8e
fix(linux): transport raw pointers as usize across main-thread dispatch
MaxMB15 Apr 14, 2026
d1db336
fix(linux): build Inner with Arc::new_cyclic so update callback has a…
MaxMB15 Apr 14, 2026
1da7723
diag(mpv): log audio output setup and effective ao once playback starts
MaxMB15 Apr 14, 2026
1014dd2
fix(linux): force audio on in mpv options, ignore user config file
MaxMB15 Apr 14, 2026
8877231
fix(linux): drop input-* mpv options that caused init error -7
MaxMB15 Apr 14, 2026
d9624dd
fix(linux): move audio config from init options to post-init properties
MaxMB15 Apr 14, 2026
48e9ec6
fix(linux): restore config=no, add self-healing aid=auto recovery
MaxMB15 Apr 14, 2026
0b809b1
fix(linux): diagnose missing AO drivers, fail build without audio deps
MaxMB15 Apr 14, 2026
b8ccfa9
audio fix attempt
MaxMB15 Apr 17, 2026
05da2b6
add audio diagnostics
MaxMB15 Apr 17, 2026
148531b
revert ao=debug log level now that audio is working
MaxMB15 Apr 17, 2026
f485a5c
fix(linux): force GDK_BACKEND=wayland in AppImage on Wayland sessions
MaxMB15 Apr 17, 2026
be25c0b
fix(linux): unconditionally override GDK_BACKEND for Wayland sessions
MaxMB15 Apr 17, 2026
033b1e5
fix(linux): fix AppImage black screen on Wayland
MaxMB15 Apr 17, 2026
16d9d27
fix(linux): keyboard focus, resize GL errors, stop crash
MaxMB15 Apr 17, 2026
ae17bbd
fix(linux): improved focus, resize stability, and stop safety
MaxMB15 Apr 17, 2026
ed429bb
fix(linux): track surface size internally to prevent stale EGL queries
MaxMB15 Apr 17, 2026
effb2ae
fix(linux): debounce EGL window resize to prevent texture corruption
MaxMB15 Apr 17, 2026
9cf3ebf
fix resize corruption: glFinish + error drain instead of debounce
MaxMB15 Apr 17, 2026
7a72eb2
fix resize corruption, stop flicker, and splash screen race
MaxMB15 Apr 18, 2026
da38843
fix: address PR review feedback on Linux renderer
MaxMB15 Apr 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 19 additions & 62 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,68 +5,29 @@ use mvp_core::cache::store::CacheStore;
use std::sync::Mutex;
use tauri::Manager;

/// Install a SIGSEGV/SIGABRT handler that logs context before crashing.
/// This helps diagnose GL driver crashes that produce no Rust-level output.
#[cfg(target_os = "linux")]
fn install_crash_handler() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| unsafe {
unsafe extern "C" fn crash_handler(sig: libc::c_int) {
// Write directly to stderr — no allocations, no locks (async-signal-safe).
let msg = match sig {
libc::SIGSEGV => b"[CRASH] SIGSEGV - segmentation fault in MaxVideoPlayer.\n\
This typically indicates a GPU driver crash in the EGL/OpenGL rendering pipeline.\n\
Try running with: GDK_BACKEND=x11 max-video-player\n\
Or set MVP_DISABLE_EMBEDDED_RENDERER=1 to use fallback rendering.\n" as &[u8],
libc::SIGABRT => b"[CRASH] SIGABRT - abort signal in MaxVideoPlayer.\n" as &[u8],
_ => b"[CRASH] Fatal signal in MaxVideoPlayer.\n" as &[u8],
};
libc::write(2, msg.as_ptr() as *const _, msg.len());

// SA_RESETHAND already restored default disposition; re-raise to get core dump.
libc::kill(libc::getpid(), sig);
libc::_exit(128 + sig);
}

let mut action: libc::sigaction = std::mem::zeroed();
action.sa_flags = libc::SA_RESETHAND;
action.sa_sigaction = crash_handler as *const () as usize;
libc::sigemptyset(&mut action.sa_mask);

libc::sigaction(libc::SIGSEGV, &action, std::ptr::null_mut());
libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut());
});
}

/// Work around WebKit2GTK DMABUF renderer issue that causes a black/blank
/// window in AppImage builds on some Linux configurations.
/// Only applied inside AppImage (detected via the APPIMAGE env var set by
/// the AppImage runtime) — the workaround breaks embedded video on systems
/// where the DMABUF renderer works correctly.
/// Work around WebKit2GTK's DMABUF renderer causing a blank window inside the
/// AppImage runtime on some Linux configurations. Only applied when running
/// from an AppImage (detected via `APPIMAGE`, a variable the AppImage runtime
/// sets itself — we only read it). The workaround is harmful outside the
/// AppImage, which is why it is scoped to that runtime.
#[cfg(target_os = "linux")]
fn apply_linux_workarounds() {
let is_appimage = std::env::var("APPIMAGE").is_ok();
if is_appimage && std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
tracing::info!("[Linux] AppImage detected — setting WEBKIT_DISABLE_DMABUF_RENDERER=1");
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
// Disable DMABUF only on X11 — on Wayland the DMABUF renderer is needed
// for correct compositing with the EGL video subsurface. The original
// workaround targeted blank-window bugs in some X11/AppImage configs.
if is_appimage && !is_wayland && std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
}

/// Log system display environment info for diagnostics.
#[cfg(target_os = "linux")]
fn log_display_environment() {
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "unknown".into());
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "unset".into());
let x11_display = std::env::var("DISPLAY").unwrap_or_else(|_| "unset".into());
let gdk_backend = std::env::var("GDK_BACKEND").unwrap_or_else(|_| "auto".into());
let disable_embedded = std::env::var("MVP_DISABLE_EMBEDDED_RENDERER").unwrap_or_else(|_| "0".into());
let webkit_dmabuf = std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "unset".into());

tracing::info!(
"[diagnostics] session={} wayland={} x11={} gdk_backend={} disable_embedded={} webkit_dmabuf={}",
session_type, wayland_display, x11_display, gdk_backend, disable_embedded, webkit_dmabuf
);
// The linuxdeploy-plugin-gtk AppRun hook forces GDK_BACKEND=x11 before
// our binary starts. Override it back to wayland when a Wayland session
// is available so GTK provides native wl_surface handles for embedded
// video rendering. Scoped to AppImage only — .deb/.rpm use system GTK
// which auto-detects correctly.
if std::env::var("APPIMAGE").is_ok() && std::env::var("WAYLAND_DISPLAY").is_ok() {
std::env::set_var("GDK_BACKEND", "wayland");
}
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
Expand All @@ -81,11 +42,7 @@ pub fn run() {
.init();

#[cfg(target_os = "linux")]
{
apply_linux_workarounds();
install_crash_handler();
log_display_environment();
}
apply_linux_workarounds();

tauri::Builder::default()
.plugin(tauri_plugin_os::init())
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/components/channels/CategoryManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,13 @@ export const CategoryManager = ({
.map((e) => e.sortOrder);
const newSortOrder =
siblingSortOrders.length > 0 ? Math.max(...siblingSortOrders) + 1 : 0;
await updateGroupHierarchyEntry(providerId, contentType, groupName, targetCategory, newSortOrder);
await updateGroupHierarchyEntry(
providerId,
contentType,
groupName,
targetCategory,
newSortOrder
);
await load();
onHierarchyChanged();
setMovingGroup(null);
Expand Down
5 changes: 1 addition & 4 deletions apps/desktop/src/components/channels/PinnedGroupsRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@ export const PinnedGroupsRow = ({
<ScrollArea className="w-full">
<div className="flex gap-2 pb-1">
{pinnedGroups.map((pin) => (
<div
key={pin.groupName}
className="flex items-center shrink-0 group"
>
<div key={pin.groupName} className="flex items-center shrink-0 group">
<button
onClick={() => onSelectGroup(pin.groupName)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-l-lg text-xs border transition-colors ${
Expand Down
9 changes: 4 additions & 5 deletions apps/desktop/src/components/channels/RecentlyPlayedRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,10 @@ export const RecentlyPlayedRow = ({ contentType, onPlay, channels }: RecentlyPla
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
<button
className="w-full text-left"
onClick={() => onPlay(entry)}
>
<div className="text-xs font-medium truncate pr-4">{displayName}</div>
<button className="w-full text-left" onClick={() => onPlay(entry)}>
<div className="text-xs font-medium truncate pr-4">
{displayName}
</div>
{contentType === "live" && (
<div className="text-[9px] text-primary mt-1">&#9679; LIVE</div>
)}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/components/player/VideoPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@ export const PlayerView = () => {
};
}, [mpv.firstFrameReady]);

// Auto-focus the player container so keyboard controls (space, arrows)
// work immediately without requiring a click first.
useEffect(() => {
containerRef.current?.focus();
}, []);

// Re-focus after channel load completes (currentUrl changes).
useEffect(() => {
if (mpv.state.currentUrl) {
containerRef.current?.focus();
}
}, [mpv.state.currentUrl]);

// Report video container bounds to the Rust renderer. The CSD header bar
// offset is applied on the Rust side via LinuxGlRenderer::csd_offset (x, y),
// so the frontend just sends raw getBoundingClientRect values.
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src/hooks/useChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ export const useChannelsProvider = (): ChannelsContextValue => {
setProviders(await getProviders());
} catch (e) {
setError(String(e));
} finally {
setInitialized(true);
}
}, []);

Expand Down Expand Up @@ -258,13 +256,16 @@ export const useChannelsProvider = (): ChannelsContextValue => {
[channelIndex]
);

// Initial load — ref guard prevents StrictMode double-mount from firing twice
// Initial load — ref guard prevents StrictMode double-mount from firing twice.
// Both providers and channels must load before `initialized` is set so the
// splash screen does not dismiss before the UI has data to display.
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
refreshProviders();
refreshChannels();
Promise.all([refreshProviders(), refreshChannels()]).finally(() => {
setInitialized(true);
});
}, [refreshProviders, refreshChannels]);

// Keep providers ref current so interval can read it without re-registering
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/hooks/useMpv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export const useMpv = () => {
useEffect(() => {
mpvGetState()
.then((s) => {
if (!loadingRef.current && !loadedThisMountRef.current && (s.isPlaying || s.isPaused)) {
if (
!loadingRef.current &&
!loadedThisMountRef.current &&
(s.isPlaying || s.isPaused)
) {
setFirstFrameReady(true);
}
})
Expand Down
4 changes: 1 addition & 3 deletions apps/desktop/src/hooks/useUpdateChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,7 @@ export const useUpdateChecker = (): UpdateState => {
} else if (event.event === "Progress") {
downloaded += event.data.chunkLength;
if (total) {
setProgress(
Math.min(100, Math.round((downloaded / total) * 100))
);
setProgress(Math.min(100, Math.round((downloaded / total) * 100)));
}
}
});
Expand Down
13 changes: 8 additions & 5 deletions crates/tauri-plugin-mpv/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,14 @@ fn link_libmpv() {
.join("libs")
.join("linux");
if libs_linux.join("libmpv.so").exists() {
if let Ok(abs) = libs_linux.canonicalize() {
println!("cargo:rustc-link-search=native={}", abs.display());
} else {
println!("cargo:rustc-link-search=native={}", libs_linux.display());
}
let path = libs_linux.canonicalize().unwrap_or(libs_linux.clone());
println!("cargo:rustc-link-search=native={}", path.display());
// Bake RPATH into the binary so the freshly built libmpv.so (which
// has our required AO/VO backends) is preferred over the system
// /usr/lib/x86_64-linux-gnu/libmpv.so.2 at runtime. Without this
// the dynamic loader falls back to whatever libmpv-dev installed,
// which may or may not match what we linked against.
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", path.display());
return;
}
// Fallback: system pkg-config (for development with libmpv-dev)
Expand Down
108 changes: 105 additions & 3 deletions crates/tauri-plugin-mpv/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ pub struct PlayerState {
pub struct MpvEngine {
mpv: Option<Mpv>,
current_url: Option<String>,
audio_logged_playing: bool,
}

impl MpvEngine {
pub fn new() -> Self {
Self { mpv: None, current_url: None }
Self { mpv: None, current_url: None, audio_logged_playing: false }
}

/// Create a new Mpv instance with the provided options.
Expand All @@ -31,6 +32,7 @@ impl MpvEngine {
/// before calling `loadfile`.
pub fn create(&mut self, options: &[(&str, &str)]) -> Result<&mut Mpv, String> {
self.stop();
tracing::info!("[MPV] create with options: {:?}", options);
let opts: Vec<(String, String)> = options
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
Expand All @@ -46,11 +48,105 @@ impl MpvEngine {
Ok(self.mpv.as_mut().unwrap())
}

/// Set audio properties after mpv_initialize. Options like `aid` and `mute`
/// must be set as properties (not init options) because `mpv_set_option_string`
/// rejects them with MPV_ERROR_OPTION_ERROR (-7) on many libmpv builds.
pub fn configure_audio(&self) -> Result<(), String> {
let mpv = self.mpv.as_ref().ok_or("no mpv instance")?;
if let Err(e) = mpv.set_property("aid", "auto") {
tracing::warn!("[MPV] set aid=auto failed: {e}");
}
if let Err(e) = mpv.set_property("mute", false) {
tracing::warn!("[MPV] set mute=false failed: {e}");
}
if let Err(e) = mpv.set_property("volume", 100.0_f64) {
tracing::warn!("[MPV] set volume=100 failed: {e}");
}
self.log_audio_state("after configure_audio");
Ok(())
}

/// Issue the loadfile command. Must be called AFTER render context is attached.
pub fn loadfile(&self, url: &str) -> Result<(), String> {
let mpv = self.mpv.as_ref().ok_or("no mpv instance")?;
mpv.command("loadfile", &[url, "replace"])
.map_err(|e| format!("loadfile: {}", e))
.map_err(|e| format!("loadfile: {}", e))?;
self.log_audio_state("after loadfile");
Ok(())
}

/// Log audio-related mpv properties to help diagnose "no sound" reports.
/// Called both right after loadfile (before mpv has picked an output) and
/// from a delayed check once the decoder has started.
pub fn log_audio_state(&self, stage: &str) {
let Some(ref mpv) = self.mpv else { return };
let get_str = |k: &str| {
mpv.get_property::<String>(k)
.unwrap_or_else(|_| "<unset>".to_string())
};
let get_bool = |k: &str| {
mpv.get_property::<bool>(k)
.map(|v| v.to_string())
.unwrap_or_else(|_| "<unset>".to_string())
};
let get_f64 = |k: &str| {
mpv.get_property::<f64>(k)
.map(|v| v.to_string())
.unwrap_or_else(|_| "<unset>".to_string())
};
tracing::info!(
"[MPV audio {stage}] current-ao={} audio-device={} aid={} mute={} volume={} ao-volume={} audio-codec={} audio-params={} track-count={} track-list={}",
get_str("current-ao"),
get_str("audio-device"),
get_str("aid"),
get_bool("mute"),
get_f64("volume"),
get_f64("ao-volume"),
get_str("audio-codec"),
get_str("audio-params"),
get_str("track-list/count"),
get_str("track-list"),
);
}

/// If mpv ended up with `aid=no` despite available audio tracks, force
/// `aid=auto` so the audio track gets selected. This can happen when a
/// system-wide config or internal mpv logic disables audio after our
/// initial `configure_audio()` call.
fn ensure_audio_selected(&self) {
let Some(ref mpv) = self.mpv else { return };
let aid = mpv.get_property::<String>("aid").unwrap_or_default();
if aid != "no" {
return;
}
let track_count = mpv
.get_property::<String>("track-list/count")
.and_then(|s| s.parse::<i64>().map_err(|_| libmpv2::Error::Null))
.unwrap_or(0);
if track_count == 0 {
return;
}

let current_ao = mpv
.get_property::<String>("current-ao")
.unwrap_or_default();
if current_ao.is_empty() || current_ao == "<unset>" {
tracing::error!(
"[MPV] NO AUDIO OUTPUT DRIVER — libmpv has no AO backends compiled in. \
Rebuild libmpv with audio support: \
sudo apt-get install libpulse-dev libasound2-dev libpipewire-0.3-dev && \
./scripts/build-libmpv.sh linux"
);
return;
}

tracing::warn!(
"[MPV] aid=no despite {track_count} tracks — forcing aid=auto"
);
if let Err(e) = mpv.set_property("aid", "auto") {
tracing::error!("[MPV] failed to force aid=auto: {e}");
}
self.log_audio_state("after force-aid");
}

/// Record the current URL (called by MpvState after loadfile succeeds).
Expand All @@ -65,6 +161,7 @@ impl MpvEngine {
}
self.mpv = None;
self.current_url = None;
self.audio_logged_playing = false;
}

pub fn play(&self) -> Result<(), String> {
Expand Down Expand Up @@ -151,7 +248,7 @@ impl MpvEngine {
.map_err(|e| e.to_string())
}

pub fn get_state(&self) -> PlayerState {
pub fn get_state(&mut self) -> PlayerState {
let mut state = PlayerState {
current_url: self.current_url.clone(),
volume: 100.0,
Expand All @@ -164,6 +261,11 @@ impl MpvEngine {
state.volume = mpv.get_property::<f64>("volume").unwrap_or(100.0);
state.is_playing = !state.is_paused && state.current_url.is_some();
}
if !self.audio_logged_playing && state.position > 0.0 {
self.audio_logged_playing = true;
self.log_audio_state("playing");
self.ensure_audio_selected();
}
state
}
}
Loading
Loading