Skip to content

Commit d944051

Browse files
authored
Merge pull request #61 from MaxMB15/feature/linux-wayland-bundled-render
Feature/linux wayland bundled render
2 parents 5a84567 + da38843 commit d944051

15 files changed

Lines changed: 951 additions & 1511 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,68 +5,29 @@ use mvp_core::cache::store::CacheStore;
55
use std::sync::Mutex;
66
use tauri::Manager;
77

8-
/// Install a SIGSEGV/SIGABRT handler that logs context before crashing.
9-
/// This helps diagnose GL driver crashes that produce no Rust-level output.
10-
#[cfg(target_os = "linux")]
11-
fn install_crash_handler() {
12-
use std::sync::Once;
13-
static ONCE: Once = Once::new();
14-
ONCE.call_once(|| unsafe {
15-
unsafe extern "C" fn crash_handler(sig: libc::c_int) {
16-
// Write directly to stderr — no allocations, no locks (async-signal-safe).
17-
let msg = match sig {
18-
libc::SIGSEGV => b"[CRASH] SIGSEGV - segmentation fault in MaxVideoPlayer.\n\
19-
This typically indicates a GPU driver crash in the EGL/OpenGL rendering pipeline.\n\
20-
Try running with: GDK_BACKEND=x11 max-video-player\n\
21-
Or set MVP_DISABLE_EMBEDDED_RENDERER=1 to use fallback rendering.\n" as &[u8],
22-
libc::SIGABRT => b"[CRASH] SIGABRT - abort signal in MaxVideoPlayer.\n" as &[u8],
23-
_ => b"[CRASH] Fatal signal in MaxVideoPlayer.\n" as &[u8],
24-
};
25-
libc::write(2, msg.as_ptr() as *const _, msg.len());
26-
27-
// SA_RESETHAND already restored default disposition; re-raise to get core dump.
28-
libc::kill(libc::getpid(), sig);
29-
libc::_exit(128 + sig);
30-
}
31-
32-
let mut action: libc::sigaction = std::mem::zeroed();
33-
action.sa_flags = libc::SA_RESETHAND;
34-
action.sa_sigaction = crash_handler as *const () as usize;
35-
libc::sigemptyset(&mut action.sa_mask);
36-
37-
libc::sigaction(libc::SIGSEGV, &action, std::ptr::null_mut());
38-
libc::sigaction(libc::SIGABRT, &action, std::ptr::null_mut());
39-
});
40-
}
41-
42-
/// Work around WebKit2GTK DMABUF renderer issue that causes a black/blank
43-
/// window in AppImage builds on some Linux configurations.
44-
/// Only applied inside AppImage (detected via the APPIMAGE env var set by
45-
/// the AppImage runtime) — the workaround breaks embedded video on systems
46-
/// where the DMABUF renderer works correctly.
8+
/// Work around WebKit2GTK's DMABUF renderer causing a blank window inside the
9+
/// AppImage runtime on some Linux configurations. Only applied when running
10+
/// from an AppImage (detected via `APPIMAGE`, a variable the AppImage runtime
11+
/// sets itself — we only read it). The workaround is harmful outside the
12+
/// AppImage, which is why it is scoped to that runtime.
4713
#[cfg(target_os = "linux")]
4814
fn apply_linux_workarounds() {
4915
let is_appimage = std::env::var("APPIMAGE").is_ok();
50-
if is_appimage && std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
51-
tracing::info!("[Linux] AppImage detected — setting WEBKIT_DISABLE_DMABUF_RENDERER=1");
16+
let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
17+
// Disable DMABUF only on X11 — on Wayland the DMABUF renderer is needed
18+
// for correct compositing with the EGL video subsurface. The original
19+
// workaround targeted blank-window bugs in some X11/AppImage configs.
20+
if is_appimage && !is_wayland && std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
5221
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
5322
}
54-
}
55-
56-
/// Log system display environment info for diagnostics.
57-
#[cfg(target_os = "linux")]
58-
fn log_display_environment() {
59-
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "unknown".into());
60-
let wayland_display = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "unset".into());
61-
let x11_display = std::env::var("DISPLAY").unwrap_or_else(|_| "unset".into());
62-
let gdk_backend = std::env::var("GDK_BACKEND").unwrap_or_else(|_| "auto".into());
63-
let disable_embedded = std::env::var("MVP_DISABLE_EMBEDDED_RENDERER").unwrap_or_else(|_| "0".into());
64-
let webkit_dmabuf = std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").unwrap_or_else(|_| "unset".into());
65-
66-
tracing::info!(
67-
"[diagnostics] session={} wayland={} x11={} gdk_backend={} disable_embedded={} webkit_dmabuf={}",
68-
session_type, wayland_display, x11_display, gdk_backend, disable_embedded, webkit_dmabuf
69-
);
23+
// The linuxdeploy-plugin-gtk AppRun hook forces GDK_BACKEND=x11 before
24+
// our binary starts. Override it back to wayland when a Wayland session
25+
// is available so GTK provides native wl_surface handles for embedded
26+
// video rendering. Scoped to AppImage only — .deb/.rpm use system GTK
27+
// which auto-detects correctly.
28+
if std::env::var("APPIMAGE").is_ok() && std::env::var("WAYLAND_DISPLAY").is_ok() {
29+
std::env::set_var("GDK_BACKEND", "wayland");
30+
}
7031
}
7132

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

8344
#[cfg(target_os = "linux")]
84-
{
85-
apply_linux_workarounds();
86-
install_crash_handler();
87-
log_display_environment();
88-
}
45+
apply_linux_workarounds();
8946

9047
tauri::Builder::default()
9148
.plugin(tauri_plugin_os::init())

apps/desktop/src/components/channels/CategoryManager.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,13 @@ export const CategoryManager = ({
234234
.map((e) => e.sortOrder);
235235
const newSortOrder =
236236
siblingSortOrders.length > 0 ? Math.max(...siblingSortOrders) + 1 : 0;
237-
await updateGroupHierarchyEntry(providerId, contentType, groupName, targetCategory, newSortOrder);
237+
await updateGroupHierarchyEntry(
238+
providerId,
239+
contentType,
240+
groupName,
241+
targetCategory,
242+
newSortOrder
243+
);
238244
await load();
239245
onHierarchyChanged();
240246
setMovingGroup(null);

apps/desktop/src/components/channels/PinnedGroupsRow.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,7 @@ export const PinnedGroupsRow = ({
3030
<ScrollArea className="w-full">
3131
<div className="flex gap-2 pb-1">
3232
{pinnedGroups.map((pin) => (
33-
<div
34-
key={pin.groupName}
35-
className="flex items-center shrink-0 group"
36-
>
33+
<div key={pin.groupName} className="flex items-center shrink-0 group">
3734
<button
3835
onClick={() => onSelectGroup(pin.groupName)}
3936
className={`flex items-center gap-2 px-3 py-1.5 rounded-l-lg text-xs border transition-colors ${

apps/desktop/src/components/channels/RecentlyPlayedRow.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,10 @@ export const RecentlyPlayedRow = ({ contentType, onPlay, channels }: RecentlyPla
103103
>
104104
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
105105
</button>
106-
<button
107-
className="w-full text-left"
108-
onClick={() => onPlay(entry)}
109-
>
110-
<div className="text-xs font-medium truncate pr-4">{displayName}</div>
106+
<button className="w-full text-left" onClick={() => onPlay(entry)}>
107+
<div className="text-xs font-medium truncate pr-4">
108+
{displayName}
109+
</div>
111110
{contentType === "live" && (
112111
<div className="text-[9px] text-primary mt-1">&#9679; LIVE</div>
113112
)}

apps/desktop/src/components/player/VideoPlayer.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@ export const PlayerView = () => {
107107
};
108108
}, [mpv.firstFrameReady]);
109109

110+
// Auto-focus the player container so keyboard controls (space, arrows)
111+
// work immediately without requiring a click first.
112+
useEffect(() => {
113+
containerRef.current?.focus();
114+
}, []);
115+
116+
// Re-focus after channel load completes (currentUrl changes).
117+
useEffect(() => {
118+
if (mpv.state.currentUrl) {
119+
containerRef.current?.focus();
120+
}
121+
}, [mpv.state.currentUrl]);
122+
110123
// Report video container bounds to the Rust renderer. The CSD header bar
111124
// offset is applied on the Rust side via LinuxGlRenderer::csd_offset (x, y),
112125
// so the frontend just sends raw getBoundingClientRect values.

apps/desktop/src/hooks/useChannels.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,6 @@ export const useChannelsProvider = (): ChannelsContextValue => {
141141
setProviders(await getProviders());
142142
} catch (e) {
143143
setError(String(e));
144-
} finally {
145-
setInitialized(true);
146144
}
147145
}, []);
148146

@@ -258,13 +256,16 @@ export const useChannelsProvider = (): ChannelsContextValue => {
258256
[channelIndex]
259257
);
260258

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

270271
// Keep providers ref current so interval can read it without re-registering

apps/desktop/src/hooks/useMpv.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ export const useMpv = () => {
5858
useEffect(() => {
5959
mpvGetState()
6060
.then((s) => {
61-
if (!loadingRef.current && !loadedThisMountRef.current && (s.isPlaying || s.isPaused)) {
61+
if (
62+
!loadingRef.current &&
63+
!loadedThisMountRef.current &&
64+
(s.isPlaying || s.isPaused)
65+
) {
6266
setFirstFrameReady(true);
6367
}
6468
})

apps/desktop/src/hooks/useUpdateChecker.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,7 @@ export const useUpdateChecker = (): UpdateState => {
115115
} else if (event.event === "Progress") {
116116
downloaded += event.data.chunkLength;
117117
if (total) {
118-
setProgress(
119-
Math.min(100, Math.round((downloaded / total) * 100))
120-
);
118+
setProgress(Math.min(100, Math.round((downloaded / total) * 100)));
121119
}
122120
}
123121
});

crates/tauri-plugin-mpv/build.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ fn link_libmpv() {
6969
.join("libs")
7070
.join("linux");
7171
if libs_linux.join("libmpv.so").exists() {
72-
if let Ok(abs) = libs_linux.canonicalize() {
73-
println!("cargo:rustc-link-search=native={}", abs.display());
74-
} else {
75-
println!("cargo:rustc-link-search=native={}", libs_linux.display());
76-
}
72+
let path = libs_linux.canonicalize().unwrap_or(libs_linux.clone());
73+
println!("cargo:rustc-link-search=native={}", path.display());
74+
// Bake RPATH into the binary so the freshly built libmpv.so (which
75+
// has our required AO/VO backends) is preferred over the system
76+
// /usr/lib/x86_64-linux-gnu/libmpv.so.2 at runtime. Without this
77+
// the dynamic loader falls back to whatever libmpv-dev installed,
78+
// which may or may not match what we linked against.
79+
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", path.display());
7780
return;
7881
}
7982
// Fallback: system pkg-config (for development with libmpv-dev)

crates/tauri-plugin-mpv/src/engine.rs

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ pub struct PlayerState {
1818
pub struct MpvEngine {
1919
mpv: Option<Mpv>,
2020
current_url: Option<String>,
21+
audio_logged_playing: bool,
2122
}
2223

2324
impl MpvEngine {
2425
pub fn new() -> Self {
25-
Self { mpv: None, current_url: None }
26+
Self { mpv: None, current_url: None, audio_logged_playing: false }
2627
}
2728

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

51+
/// Set audio properties after mpv_initialize. Options like `aid` and `mute`
52+
/// must be set as properties (not init options) because `mpv_set_option_string`
53+
/// rejects them with MPV_ERROR_OPTION_ERROR (-7) on many libmpv builds.
54+
pub fn configure_audio(&self) -> Result<(), String> {
55+
let mpv = self.mpv.as_ref().ok_or("no mpv instance")?;
56+
if let Err(e) = mpv.set_property("aid", "auto") {
57+
tracing::warn!("[MPV] set aid=auto failed: {e}");
58+
}
59+
if let Err(e) = mpv.set_property("mute", false) {
60+
tracing::warn!("[MPV] set mute=false failed: {e}");
61+
}
62+
if let Err(e) = mpv.set_property("volume", 100.0_f64) {
63+
tracing::warn!("[MPV] set volume=100 failed: {e}");
64+
}
65+
self.log_audio_state("after configure_audio");
66+
Ok(())
67+
}
68+
4969
/// Issue the loadfile command. Must be called AFTER render context is attached.
5070
pub fn loadfile(&self, url: &str) -> Result<(), String> {
5171
let mpv = self.mpv.as_ref().ok_or("no mpv instance")?;
5272
mpv.command("loadfile", &[url, "replace"])
53-
.map_err(|e| format!("loadfile: {}", e))
73+
.map_err(|e| format!("loadfile: {}", e))?;
74+
self.log_audio_state("after loadfile");
75+
Ok(())
76+
}
77+
78+
/// Log audio-related mpv properties to help diagnose "no sound" reports.
79+
/// Called both right after loadfile (before mpv has picked an output) and
80+
/// from a delayed check once the decoder has started.
81+
pub fn log_audio_state(&self, stage: &str) {
82+
let Some(ref mpv) = self.mpv else { return };
83+
let get_str = |k: &str| {
84+
mpv.get_property::<String>(k)
85+
.unwrap_or_else(|_| "<unset>".to_string())
86+
};
87+
let get_bool = |k: &str| {
88+
mpv.get_property::<bool>(k)
89+
.map(|v| v.to_string())
90+
.unwrap_or_else(|_| "<unset>".to_string())
91+
};
92+
let get_f64 = |k: &str| {
93+
mpv.get_property::<f64>(k)
94+
.map(|v| v.to_string())
95+
.unwrap_or_else(|_| "<unset>".to_string())
96+
};
97+
tracing::info!(
98+
"[MPV audio {stage}] current-ao={} audio-device={} aid={} mute={} volume={} ao-volume={} audio-codec={} audio-params={} track-count={} track-list={}",
99+
get_str("current-ao"),
100+
get_str("audio-device"),
101+
get_str("aid"),
102+
get_bool("mute"),
103+
get_f64("volume"),
104+
get_f64("ao-volume"),
105+
get_str("audio-codec"),
106+
get_str("audio-params"),
107+
get_str("track-list/count"),
108+
get_str("track-list"),
109+
);
110+
}
111+
112+
/// If mpv ended up with `aid=no` despite available audio tracks, force
113+
/// `aid=auto` so the audio track gets selected. This can happen when a
114+
/// system-wide config or internal mpv logic disables audio after our
115+
/// initial `configure_audio()` call.
116+
fn ensure_audio_selected(&self) {
117+
let Some(ref mpv) = self.mpv else { return };
118+
let aid = mpv.get_property::<String>("aid").unwrap_or_default();
119+
if aid != "no" {
120+
return;
121+
}
122+
let track_count = mpv
123+
.get_property::<String>("track-list/count")
124+
.and_then(|s| s.parse::<i64>().map_err(|_| libmpv2::Error::Null))
125+
.unwrap_or(0);
126+
if track_count == 0 {
127+
return;
128+
}
129+
130+
let current_ao = mpv
131+
.get_property::<String>("current-ao")
132+
.unwrap_or_default();
133+
if current_ao.is_empty() || current_ao == "<unset>" {
134+
tracing::error!(
135+
"[MPV] NO AUDIO OUTPUT DRIVER — libmpv has no AO backends compiled in. \
136+
Rebuild libmpv with audio support: \
137+
sudo apt-get install libpulse-dev libasound2-dev libpipewire-0.3-dev && \
138+
./scripts/build-libmpv.sh linux"
139+
);
140+
return;
141+
}
142+
143+
tracing::warn!(
144+
"[MPV] aid=no despite {track_count} tracks — forcing aid=auto"
145+
);
146+
if let Err(e) = mpv.set_property("aid", "auto") {
147+
tracing::error!("[MPV] failed to force aid=auto: {e}");
148+
}
149+
self.log_audio_state("after force-aid");
54150
}
55151

56152
/// Record the current URL (called by MpvState after loadfile succeeds).
@@ -65,6 +161,7 @@ impl MpvEngine {
65161
}
66162
self.mpv = None;
67163
self.current_url = None;
164+
self.audio_logged_playing = false;
68165
}
69166

70167
pub fn play(&self) -> Result<(), String> {
@@ -151,7 +248,7 @@ impl MpvEngine {
151248
.map_err(|e| e.to_string())
152249
}
153250

154-
pub fn get_state(&self) -> PlayerState {
251+
pub fn get_state(&mut self) -> PlayerState {
155252
let mut state = PlayerState {
156253
current_url: self.current_url.clone(),
157254
volume: 100.0,
@@ -164,6 +261,11 @@ impl MpvEngine {
164261
state.volume = mpv.get_property::<f64>("volume").unwrap_or(100.0);
165262
state.is_playing = !state.is_paused && state.current_url.is_some();
166263
}
264+
if !self.audio_logged_playing && state.position > 0.0 {
265+
self.audio_logged_playing = true;
266+
self.log_audio_state("playing");
267+
self.ensure_audio_selected();
268+
}
167269
state
168270
}
169271
}

0 commit comments

Comments
 (0)