Skip to content

feat(linux): native video playback with GPU-accelerated rendering#142

Draft
skoruppa wants to merge 8 commits into
NuvioMedia:Devfrom
skoruppa:linux-gpu-player
Draft

feat(linux): native video playback with GPU-accelerated rendering#142
skoruppa wants to merge 8 commits into
NuvioMedia:Devfrom
skoruppa:linux-gpu-player

Conversation

@skoruppa

@skoruppa skoruppa commented Jun 23, 2026

Copy link
Copy Markdown
Member

PR type

  • Reproducible bug fix
  • UI glitch/bug fix
  • Behavior bug/regression fix
  • Small maintenance only, with no UI or behavior change
  • Docs accuracy fix
  • Translation/localization only
  • Approved larger or directional change

Why

Linux users cannot play video at all - the app shows "Desktop in-app playback is not available yet." This adds native libmpv playback on Linux via JNI, matching what macOS/Windows already have.

Desktop scope

Linux only. Single unified rendering path for all Linux sessions (X11 and Wayland):

EGL GBM offscreen + Compose Canvas overlay:

  1. EGL context created via GBM on /dev/dri/renderD* (enumerates render nodes for multi-GPU)
  2. GLES3 API used - NVIDIA proprietary EGL doesn't support Desktop GL with pbuffer/surfaceless contexts
  3. mpv render context created on dedicated pthread with MPV_RENDER_PARAM_DRM_DISPLAY_V2 - enables VAAPI/nvdec zero-copy DMA-BUF interop
  4. mpv decodes on GPU and renders into EGL FBO
  5. glReadPixels RGBA - JNI renderFrameBytes converts to BGRA (Skia N32) into Java ByteArray
  6. LinuxPlayerHost wraps into Skia Image, Compose Canvas draws via drawImageRect
  7. Player controls render as normal Compose UI above the Canvas (no z-ordering issues)

If EGL/GBM init fails (no GPU, missing render node, old drivers), falls back to mpv SW rendering (MPV_RENDER_API_TYPE_SW) with hwdec=auto-copy - GPU decode still works, just with a RAM copy for compositing.

Key technical decisions in player_bridge.c:

  • Render context created on dedicated pthread (not JNI/EDT) - Skia may hold EGL on another thread
  • GLES3 over Desktop GL - NVIDIA driver limitation with offscreen contexts
  • Multi-GPU support: iterates renderD128/129/130, uses first node where eglMakeCurrent succeeds
  • MPV_RENDER_PARAM_DRM_DISPLAY_V2 with render node fd lets mpv open VA display for hw decode interop
  • Render thread uses pthread_cond signaled by mpv_render_context_set_update_callback
  • On dispose: do NOT call mpv_render_context_free - crashes Gallium shared pipe_screen. Cache instance (glCache static) and reuse on next create
  • Double-buffered byte arrays in LinuxPlayerHost eliminate per-frame GC pressure

Shared code touched minimally: NativePlayerController now accepts a PlayerHost interface so it can drive both the AWT host (macOS/Windows) and the new LinuxPlayerHost without duplication. macOS/Windows paths unchanged.

Issue or approval

Addresses #27. Loosely based on ideas from #58 but rewritten from scratch for minimal diff.

UI / behavior impact

  • No UI change
  • No behavior change
  • UI changed only to fix a documented glitch/bug
  • Behavior changed only to fix a documented bug/regression
  • UI change has explicit maintainer approval
  • Behavior change has explicit maintainer approval

Policy check

  • I have read and understood CONTRIBUTING.md.
  • This PR is small, focused, and limited to one problem.
  • This PR is scoped to the desktop app, desktop packaging, desktop documentation, or shared code required for desktop behavior.
  • This PR is not cosmetic-only.
  • Any UI change fixes a linked glitch/bug and includes visual proof, or this PR has no UI change.
  • Any behavior change fixes a linked bug/regression or has explicit approval, or this PR has no behavior change.
  • This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
  • This PR does not add dependencies, architecture changes, migrations, or product-direction changes without explicit approval.
  • I listed the testing performed below.

Scope boundaries

  • macOS/Windows native bridges untouched
  • No new dependencies (uses system libmpv, libEGL, libGL, libgbm via pkg-config)

Testing

Tested personally:

  • Ubuntu 26.04, Wayland (GNOME), Intel ARL GPU
  • VAAPI hardware decode confirmed working (4K HEVC, zero-copy interop via GLES GBM)
  • Seeking, subtitle tracks, audio tracks, volume, speed control, resize modes, cursor hide all working
  • Playback smooth at 24fps with ~0 dropped frames after initial buffer

Tested by volunteer (NVIDIA RTX 4070 SUPER, KDE Plasma 6.7, driver 610.43.02):

  • Wayland session: EGL GBM + GLES - awaiting confirmation
  • X11 session: same offscreen path - controls overlay working correctly
  • SW fallback (gpuMode=0) with nvdec GPU decode confirmed working

Needs additional testing:

  • AMD GPU with VAAPI - should work (same GBM/GLES path) but untested
  • macOS regression test - verify player still works identically
  • Windows regression test - verify player still works identically

Build verification:

  • ./gradlew :composeApp:compileKotlinDesktop - passes clean
  • ./gradlew :composeApp:buildLinuxPlayerBridge - produces libplayer_bridge.so (~60KB)

Build requirements on Linux:

  • libmpv-dev (pkg-config: mpv)
  • libEGL, libGL, libgbm (Mesa)
  • gcc

Screenshots / Video

Not a UI change - player surface renders video frames using existing controls overlay.

Breaking changes

None. All new code gated behind DesktopHostOs.current == LINUX or host is LinuxPlayerHost checks. macOS/Windows paths unchanged.

Linked issues

Addresses #27 (Linux native playback).
Loosely based on #58 but rewritten from scratch for minimal diff.


@tapframe could you review this? Would appreciate regression testing on macOS and Windows to confirm nothing broke. AMD GPU and KDE Plasma testing also needed.

@skoruppa

Copy link
Copy Markdown
Member Author

@FeelThePoveR, you were active in #58, want to make some testing here as well? ;)

@aelrased

Copy link
Copy Markdown

Thank you for modifying the Projectand paying attention to Linux users

@FeelThePoveR

Copy link
Copy Markdown

Sure I can test it out.

This took me way longer than it should, but the package doesn't compile by default due to 2 issues:

  • libmpv libraries didn't get pushed to the branch
  • build steps for bridge and runtime don't seem to like the gradle configuration cache so I had to disable it in gradle.properties

After figuring both of those out the package got built.

@FeelThePoveR

FeelThePoveR commented Jun 23, 2026

Copy link
Copy Markdown

As for the actual running of the app on my KDE Plasma 6.7 desktop with Wayland session:

  • In gpuMode 2 the playback starts, but the video output isn't actually visible only the audio plays. Console keeps returning [player_bridge] renderFrameGL: eglMakeCurrent failed (error=0x3000) errors
[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM context created successfully (will activate in render thread)
[player_bridge] create: Wayland + EGL offscreen GL mode (fresh)
[player_bridge] create: GL offscreen render context created
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] create: returning handle (gpuMode=2)
[player_bridge] renderFrameGL: eglMakeCurrent failed (error=0x3000)
[player_bridge] renderFrameGL: eglMakeCurrent failed (error=0x3000)
[player_bridge] renderFrameGL: eglMakeCurrent failed (error=0x3000)

  • In gpuMode 0 the playback works fine for videos that are not HEVC encoded, for HEVC there's some laggyness but that's understandable
  • In gpuMode 1 the playback doesn't start and there's no mention of initialization start in the console output or any other errors, it looks like it fails to create the bridge silently

Zoom/Fit/Stretch doesn't do anything.

Only gpuMode 2 I tested on an actual release build the rest I just did ./gradlew run

@skoruppa skoruppa force-pushed the linux-gpu-player branch 2 times, most recently from 6b8583a to 3f728a7 Compare June 23, 2026 23:12
@skoruppa

Copy link
Copy Markdown
Member Author

@FeelThePoveR

Thanks for testing! I made some changes and let's hope the gpuMode2 now works for you ;) gpuMode 1 is for x11, so it will fail for you, and gpuMode 0 is the software fallback

Would really appreciate if you could re-test on your KDE Plasma 6.7 Wayland setup. Let me know if you still get any errors.

@FeelThePoveR

Copy link
Copy Markdown

I rebuilt the package and the gpuMode 2 still doesn't work, but now it falls back to gpuMode 0 so playback still starts.

[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM context created successfully (will activate in render thread)
[player_bridge] create: Wayland + EGL offscreen GL mode (fresh)
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] render thread: EGL context activated, creating mpv GL render context
[player_bridge] render thread: GL mpv_render_context_create FAILED
[player_bridge] create: returning handle (gpuMode=0)

For gpuMode 1 it shouldn't really matter that I'm not in X11 session. KWIN still renders Nuvio as an X11 window through XWayland. I looked it up and apparently Kotlin Multiplatform doesn't support native Wayland yet so Wayland native probably isn't possible right now without adding QT/GTK or something like that as the backend.
image
But to fully test it out I installed plasma-workspace-x11 and booted into it - the result was the same the player fails to start silently.

From other issues:

  • Switching sources during playback makes the UI not functional - you can interact with it but no actions really work. You can't seek, you can't change subs, you can't change speed etc. Going out of the player and back in fixes this.
  • Not sure how to reproduce this one, but one time I managed to get the cursor to be rendered behind the playback/UI (but above window decorations) making interaction impossible

@skoruppa skoruppa marked this pull request as draft June 24, 2026 07:03
@skoruppa

Copy link
Copy Markdown
Member Author

For gpuMode 1 it shouldn't really matter that I'm not in X11 session. KWIN still renders Nuvio as an X11 window through XWayland. I looked it up and apparently Kotlin Multiplatform doesn't support native Wayland yet so Wayland native probably isn't possible right now without adding QT/GTK or something like that as the backend.

That is not true :D

Yes, JVM runs through XWayland. But mpv's vo=gpu-next with an XWayland window ID is unreliable. It silently fails on many compositors because mpv's X11 GPU path fights with the Wayland compositor's surface management. That's exactly what you saw (no output, no errors).

I rebuilt the package and the gpuMode 2 still doesn't work, but now it falls back to gpuMode 0 so playback still starts.

Could you share your GPU info? Something like:
glxinfo | grep -i "renderer\|vendor\|version"

The GL render context creation is failing on your setup. EGL initializes fine but mpv rejects the GL context. Likely a driver/profile compatibility issue. On my Intel ARL it works with GL 4.6 Core Profile, but your GPU might need Compatibility Profile or a different GL version. What GPU/driver are you on?

I left some logs in latest changes maybe we can see what is happening on your side (both x11 and wayland)

@FeelThePoveR

Copy link
Copy Markdown

Here's the glxinfo | grep -i "renderer\|vendor\|version" output

server glx vendor string: SGI
server glx version string: 1.4
client glx vendor string: NVIDIA Corporation
client glx version string: 1.4
GLX version: 1.4
OpenGL vendor string: NVIDIA Corporation
OpenGL renderer string: NVIDIA GeForce RTX 4070 SUPER/PCIe/SSE2
OpenGL core profile version string: 4.6.0 NVIDIA 610.43.02
OpenGL core profile shading language version string: 4.60 NVIDIA
OpenGL version string: 4.6.0 NVIDIA 610.43.02
OpenGL shading language version string: 4.60 NVIDIA
OpenGL ES profile version string: OpenGL ES 3.2 NVIDIA 610.43.02
OpenGL ES profile shading language version string: OpenGL ES GLSL ES 3.20

Player bridge logs

[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM context created successfully (will activate in render thread)
[player_bridge] create: Wayland + EGL offscreen GL mode (fresh)
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: creating mpv GL render context (gbmFd=177)
[player_bridge] render thread: GL mpv_render_context_create FAILED
[player_bridge] create: returning handle (gpuMode=0)

For the X11 gpuMode 1 path - it doesn't reach any of the new logs, the last log produced is this

Debug: (StreamsRepo) Got 25 streams from AIOStreams

So still before the player initialization.

@skoruppa

Copy link
Copy Markdown
Member Author

Ahh, NVIDIA why I'm not suprised :P

I made a new push that should fix the NVIDIA Wayland issue. EGL context is now created and activated on the same render thread

For X11 - no [player_bridge] logs means the Kotlin layer never reaches native code. The X11 path waits for the AWT Canvas first paint before calling create(). Could you try resizing the window after opening the player?

@FeelThePoveR

Copy link
Copy Markdown

Hahah, yeah the Nvidia GPU for sure is the biggest regret in my current build.

For the new build on Wayland session unfortunately it still fails on the same line and falls back to gpuMode 0

[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] create: Wayland mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM context created successfully (will activate in render thread)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: creating mpv GL render context (gbmFd=178)
[player_bridge] render thread: GL mpv_render_context_create FAILED
[player_bridge] create: returning handle (gpuMode=0)

X11 side on the other hand decided to produce some logs this time (even without resizing)

[NUVIO_ATTACH] attachPendingAwt: resolving view pointer...
[NUVIO_ATTACH] attachPendingAwt FAILED: Method getWindow was not found on sun.awt.X11.XCanvasPeer.

@skoruppa

Copy link
Copy Markdown
Member Author

New push with a potential fix for NVIDIA Wayland

Also pushed a fix for X11. The getWindow method lookup now tries multiple names (getWindow, getContentWindow, getWidget) since it varies by JDK. Would be great to get X11 test results too

@FeelThePoveR

Copy link
Copy Markdown

Both Nvidia Wayland and X11 still behave in the same way.

Wayland logs:

[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] create: Wayland mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM context created successfully (surface=surfaceless)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: creating mpv GL render context (gbmFd=180)
[player_bridge] render thread: GL mpv_render_context_create FAILED
[player_bridge] create: returning handle (gpuMode=0)

X11 logs:

[NUVIO_ATTACH] attachPendingAwt: resolving view pointer...
[NUVIO_ATTACH] attachPendingAwt FAILED: Linux AWT X11 window pointer could not be resolved. Peer=sun.awt.X11.XCanvasPeer, methods tried: [getWindow, getContentWindow, getWidget]

@skoruppa

Copy link
Copy Markdown
Member Author

Try again >< maybe this time we will have different results

@FeelThePoveR

Copy link
Copy Markdown

For the Nvidia Wayland there's no change, it still falls back to gpuMode 0

[player_bridge] create() called (SW/GL offscreen mode)
[player_bridge] create: Wayland mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying EGL Device Platform
[player_bridge] EGL: trying EGL_PLATFORM_DEVICE_EXT fallback
[player_bridge] EGL: found 4 EGL devices
[player_bridge] EGL: Device[0] initialized 1.5
[player_bridge] EGL: Device[0] eglMakeCurrent failed (err=0x3000)
libEGL warning: pci id for fd 187: 10de:2783, driver (null)

pci id for fd 188: 10de:2783, driver (null)
pci id for fd 189: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
libEGL warning: pci id for fd 187: 10de:2783, driver (null)

pci id for fd 188: 10de:2783, driver (null)
pci id for fd 189: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
libEGL warning: pci id for fd 187: 10de:2783, driver (null)

[player_bridge] EGL: Device[2] initialized 1.5
[player_bridge] EGL: Device[2] eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: Device[3] initialized 1.5
[player_bridge] EGL: Device[3] eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: all Device Platform attempts failed
[player_bridge] render thread: initEGL FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

For the X11 side there's some progress the playback starts, but there's no UI and after the player loses focus it starts being drawn over the cursor

[NUVIO_ATTACH] attachPendingAwt: resolving view pointer...
[player_bridge] getX11WindowId: resolved X11 drawable=0x5400034
[NUVIO_X11] resolved via JAWT: 0x5400034
[NUVIO_ATTACH] hostViewPtr=0x5400034, calling create()
[player_bridge] create() called (GPU (gpu-next + wid) mode)
[player_bridge] create: GPU direct mode, wid=0x5400034
[player_bridge] create: GPU mode, mpv renders directly to X11 window 0x5400034
[player_bridge] create: mpv initialized (gpuMode=1)
[player_bridge] create: returning handle (gpuMode=1)
[NUVIO_ATTACH] create() returned handle=139973234736672

@skoruppa

Copy link
Copy Markdown
Member Author

I decided to get rid of gpuMode 1 - gpuMode 2 with software fallback should also work there.

Can you test both again? I want to see logs

@FeelThePoveR

Copy link
Copy Markdown

Nvidia Wayland continues to fallback to gpuMode 0

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7f4fded48810
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying shared context
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7f4fded48810 (captured from JNI thread)
libEGL warning: pci id for fd 181: 10de:2783, driver (null)

pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7f4fded48810, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

X11 side on the other hand seems to be working as expected with the gpuMode 0 fallback.

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7fe6f55ea190
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying shared context
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7fe6f55ea190 (captured from JNI thread)
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7fe6f55ea190, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

@skoruppa

Copy link
Copy Markdown
Member Author

Good. Progress. Can you check again with Wayland Nvidia?

@FeelThePoveR

Copy link
Copy Markdown

No change there unfortunately

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7ff761638860
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying shared context
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7ff761638860 (captured from JNI thread)
libEGL warning: pci id for fd 181: 10de:2783, driver (null)

pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7ff761638860, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

@skoruppa

Copy link
Copy Markdown
Member Author

Ok, next idea :D lets test again

@FeelThePoveR

Copy link
Copy Markdown

Hahah, it's still failing but there's some new info in the log, apparently NVIDIA vendor lib missing required symbols


[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7fdbbdc42a80
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying NVIDIA vendor lib
[player_bridge] EGL: loaded NVIDIA vendor library: libEGL_nvidia.so.0
[player_bridge] EGL: NVIDIA vendor lib missing required symbols
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7fdbbdc42a80 (captured from JNI thread)
libEGL warning: pci id for fd 181: 10de:2783, driver (null)

pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
pci id for fd 182: 10de:2783, driver (null)
pci id for fd 183: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7fdbbdc42a80, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

@skoruppa

Copy link
Copy Markdown
Member Author

Ok, check newest changes. Can you also run those command and give me results?

ls -la /dev/dri/render*
cat /sys/class/drm/renderD128/device/vendor
cat /sys/class/drm/renderD129/device/vendor 2>/dev/null

@FeelThePoveR

FeelThePoveR commented Jun 24, 2026

Copy link
Copy Markdown

Result is the same

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7f31633b12d0
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying NVIDIA vendor lib
[player_bridge] EGL: loaded NVIDIA vendor library: libEGL_nvidia.so.0
[player_bridge] EGL: NVIDIA vendor lib missing required symbols, trying GLVND dispatch override
[player_bridge] EGL: overriding EGL vendor to: /usr/share/glvnd/egl_vendor.d/10_nvidia.json
[player_bridge] EGL: trying render node /dev/dri/renderD128 (fd=182)
[player_bridge] EGL: NVIDIA override initialized 1.5
[player_bridge] EGL: NVIDIA override config failed
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7f31633b12d0 (captured from JNI thread)
libEGL warning: pci id for fd 184: 10de:2783, driver (null)

pci id for fd 185: 10de:2783, driver (null)
pci id for fd 186: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
pci id for fd 185: 10de:2783, driver (null)
pci id for fd 186: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7f31633b12d0, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

Command output

ls -la /dev/dri/render*
crw-rw-rw-. 1 root render 226, 128 Jun 24 09:49 /dev/dri/renderD128
crw-rw-rw-. 1 root render 226, 129 Jun 24 09:49 /dev/dri/renderD129
cat /sys/class/drm/renderD128/device/vendor
0x1002
cat /sys/class/drm/renderD129/device/vendor 2>/dev/null
0x10de

Maybe this will also help - I run eglinfo and it reports the following devices:

  • Device 0 - NVIDIA
  • Device 1 - Didn't report what it is, but it fails in the same way as the player_bridge does
  • Device 2 - Mesa radeonsi
  • Device 3 - Mesa swrast driver
Device #1:

EGL device extensions string:
    EGL_EXT_device_drm, EGL_EXT_device_drm_render_node, 
    EGL_EXT_device_persistent_id, EGL_EXT_device_query_name
Platform Device platform:
libEGL warning: pci id for fd 26: 10de:2783, driver (null)

pci id for fd 31: 10de:2783, driver (null)
pci id for fd 32: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
libEGL warning: pci id for fd 26: 10de:2783, driver (null)

pci id for fd 31: 10de:2783, driver (null)
pci id for fd 32: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
libEGL warning: pci id for fd 26: 10de:2783, driver (null)

eglinfo: eglInitialize failed

@skoruppa

Copy link
Copy Markdown
Member Author

thx for that. This helped. Can you try with new changes?

@FeelThePoveR

FeelThePoveR commented Jun 24, 2026

Copy link
Copy Markdown

A little bit of progress with this one now the override config passes, but the eglMakeCurrent still fails.
I also tried applying the Skia SOFTWARE rendering tweak from before, but the behavior didn't change.

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=0x7f19357b0fd0
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: trying render node /dev/dri/renderD128
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: pbuffer creation failed (non-fatal, will try surfaceless)
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying NVIDIA vendor lib
[player_bridge] EGL: loaded NVIDIA vendor library: libEGL_nvidia.so.0
[player_bridge] EGL: NVIDIA vendor lib missing required symbols, trying GLVND dispatch override
[player_bridge] EGL: overriding EGL vendor to: /usr/share/glvnd/egl_vendor.d/10_nvidia.json
[player_bridge] EGL: trying render node /dev/dri/renderD129 (fd=180)
[player_bridge] EGL: NVIDIA override initialized 1.5
[player_bridge] EGL: NVIDIA override eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=0x7f19357b0fd0 (captured from JNI thread)
libEGL warning: pci id for fd 188: 10de:2783, driver (null)

pci id for fd 189: 10de:2783, driver (null)
pci id for fd 190: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
pci id for fd 189: 10de:2783, driver (null)
pci id for fd 190: 10de:2783, driver (null)
libEGL warning: egl: failed to create dri2 screen
[player_bridge] EGL: shared display initialized 1.5
[player_bridge] EGL: shared context created (api=GL)
[player_bridge] EGL: shared context ready (display=0x7f19357b0fd0, surface=pbuffer)
[player_bridge] render thread: eglMakeCurrent=0 (err=0x3000)
[player_bridge] render thread: eglMakeCurrent FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)

@FeelThePoveR

FeelThePoveR commented Jun 24, 2026

Copy link
Copy Markdown

I also tried one other thing - running the app with the EGL Vendor passed in the environment
__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json ./gradlew run
This got rid of the dri2 warnings. Log from that run:

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: captured skiaDisplay=(nil)
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: trying render node /dev/dri/renderD128
[player_bridge] EGL: failed to get EGL display from GBM
[player_bridge] EGL: trying render node /dev/dri/renderD129
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM eglMakeCurrent pre-check failed (err=0x3000), trying NVIDIA vendor lib
[player_bridge] EGL: loaded NVIDIA vendor library: libEGL_nvidia.so.0
[player_bridge] EGL: NVIDIA vendor lib missing required symbols, trying GLVND dispatch override
[player_bridge] EGL: overriding EGL vendor to: /usr/share/glvnd/egl_vendor.d/10_nvidia.json
[player_bridge] EGL: trying render node /dev/dri/renderD129 (fd=187)
[player_bridge] EGL: NVIDIA override initialized 1.5
[player_bridge] EGL: NVIDIA override eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: trying shared context with Skia's EGLDisplay
[player_bridge] EGL: skiaDisplay=(nil) (captured from JNI thread)
[player_bridge] EGL: no usable display found for shared context
[player_bridge] EGL: trying EGL_PLATFORM_DEVICE_EXT fallback
[player_bridge] EGL: found 1 EGL devices
[player_bridge] EGL: Device[0] initialized 1.5
[player_bridge] EGL: Device[0] surfaceless=1 create_context=1
[player_bridge] EGL: Device[0] pbuffer created OK
[player_bridge] EGL: Device[0] eglMakeCurrent failed (surface=pbuffer, err=0x3000)
[player_bridge] EGL: Device[0] surfaceless also failed (err=0x3000)
[player_bridge] EGL: Device[0] retrying with GLES API
[player_bridge] EGL: Device[0] GLES MakeCurrent also failed (err=0x3000)
[player_bridge] EGL: all Device Platform attempts failed
[player_bridge] EGL: all render nodes exhausted
[player_bridge] render thread: initEGL FAILED, falling back to SW
[player_bridge] create: returning handle (gpuMode=0)
[player_bridge] dispose() called (gpuMode=0)

@skoruppa

Copy link
Copy Markdown
Member Author

Tested de0025f on NVIDIA (Wayland) falls back to GLX as expected and hits gpuMode=2

render thread: EGL failed, trying GLX GLX: initialized (GL=4.6.0 NVIDIA 595.80 renderer=NVIDIA GeForce RTX 4060 Ti) render thread: GL render context created successfully create: returning handle (gpuMode=2)

and how it works? Everything smooth?

@FeelThePoveR

Copy link
Copy Markdown

@FeelThePoveR yeah HDR will not work but for now if it works it is a good fallback. Can you try latest commit?

Renderer starts in gpuMode 2 successfully using the GLX fallback

[player_bridge] create() called (offscreen GL/SW mode)
[player_bridge] create: offscreen GL mode, EGL deferred to render thread
[player_bridge] create: deferring GL render context to render thread
[player_bridge] create: mpv initialized (gpuMode=2)
[player_bridge] EGL: trying render node /dev/dri/renderD128
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: trying render node /dev/dri/renderD129
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: eglMakeCurrent failed (err=0x3000)
[player_bridge] EGL: all render nodes failed
[player_bridge] render thread: EGL failed, trying GLX
[player_bridge] GLX: trying X11 offscreen context
[player_bridge] GLX: initialized (GL=4.6.0 NVIDIA 610.43.02 renderer=NVIDIA GeForce RTX 4070 SUPER/PCIe/SSE2)
[player_bridge] GLX: context ready (drmFd=195)
[player_bridge] render thread: GL=4.6.0 NVIDIA 610.43.02 renderer=NVIDIA GeForce RTX 4070 SUPER/PCIe/SSE2 (backend=GLX)
[player_bridge] render thread: creating mpv GL render context (gbmFd=195)
[player_bridge] render thread: GL render context created successfully (VAAPI should work)
[player_bridge] create: returning handle (gpuMode=2)

There's an issue with cached instance - changing sources or backing out of the player and restarting playback blacks out the video playback with that error in the console

[player_bridge] renderFrameGL: makeGLCurrent failed
[player_bridge] renderFrameGL: makeGLCurrent failed
[player_bridge] renderFrameGL: makeGLCurrent failed
[player_bridge] renderFrameGL: makeGLCurrent failed
[player_bridge] renderFrameGL: makeGLCurrent failed
[player_bridge] renderFrameGL: makeGLCurrent failed

Cache was storing EGL fields but not useGLX flag or eglSurface.
On restore, makeGLCurrent tried EGL (useGLX=0) and failed because
GLX context was the active backend.

Now caches useGLX and eglSurface alongside other GL state.
@skoruppa

Copy link
Copy Markdown
Member Author

yeah, cache reuse will not work with glx. Check now @FeelThePoveR

@FeelThePoveR

FeelThePoveR commented Jun 26, 2026

Copy link
Copy Markdown

yeah, cache reuse will not work with glx. Check now @FeelThePoveR

Now it's fine for this case. There's also a different issue where the player refuses to die when backing out of playback, after it hangs the app itself also refuses to close. Logs don't really say anything useful. I though that was an anomaly during the first test, but it happened for a second time.

The end of the log output

Debug: (NativePlayerControls) updateControls handle=139797388291680 title=The Boys pos=52343 duration=3823648 speed=1x audioLabel=Audio subsLabel=Subs fullscreen=false
Debug: (NativePlayerControls) updateControls handle=139797388291680 title=The Boys pos=52343 duration=3823648 speed=1x audioLabel=Audio subsLabel=Subs fullscreen=false
Debug: (NativePlayerControls) updateControls handle=139797388291680 title=The Boys pos=52843 duration=3823648 speed=1x audioLabel=Audio subsLabel=Subs fullscreen=false
[player_bridge] EGL: FBO resized to 3440x1412
Error: (WatchProgressRepository) Failed to push watch progress scrobble
Debug: (StreamsRepo) Skipping stream reload for unchanged request type=series id=tt1190634:5:1
[player_bridge] dispose() called (gpuMode=2)

Render thread could deadlock during dispose when mpv_command('stop') blocks
while render callback is active. Fix:
- Detach mpv render update callback before stopping playback
- Signal frameCond again after stop to wake render thread from wait
@skoruppa

Copy link
Copy Markdown
Member Author

@FeelThePoveR check now. Maybe managed to handle it

@FeelThePoveR

FeelThePoveR commented Jun 26, 2026

Copy link
Copy Markdown

@FeelThePoveR check now. Maybe managed to handle it

This seems to have fixed it, I tried for a couple of minutes and nothing so I think we should be good.

Last issue (I think :D) is that when you back out of playback the resume position doesn't sync - it only syncs once you back out of the stream list.

@FeelThePoveR

FeelThePoveR commented Jun 26, 2026

Copy link
Copy Markdown

Nevermind spoke too soon, of course I'm going to get that immediately after posting. I'll see if I can repro it reliably

@FeelThePoveR

Copy link
Copy Markdown

After trying it out for 10 more minutes I got 3 players stuck like this but there's no common pattern for this from what I can see
image

1 of those failures also showed another random issue with no reliable repro - during the second attempt my cursor started being drawn under the player for a sec until I focused on another app and then back on Nuvio

…ose deadlock

On NVIDIA/GLX, backing out of playback could hang the player ("won't die") and
then the whole app (won't close). dispose() blocks forever on pthread_join of the
render thread.

Root cause (confirmed by thread dump): dispose() sent mpv `stop` BEFORE joining the
render thread. `stop` makes mpv's core thread tear down the decoder
(vd_lavc_destroy -> vkDestroyDevice) inside the NVIDIA driver, while the render
thread still owns and is using the GLX context. Both threads then block forever
inside libnvidia-glcore (concurrent GL + Vulkan teardown on the same context), so
the render thread never exits and dispose()'s pthread_join never returns.

Fix: join the render thread FIRST. It finishes its current frame, unbinds the GL
context (glXMakeCurrent(None)) and exits, so the context is idle before mpv tears
the decoder down — no concurrent GL/VK teardown, no driver deadlock. Then stop
playback as before. Single-threaded change, GLX path only; EGL/Mesa unaffected.

Tested on Fedora/Wayland (XWayland), RTX 4060 Ti, driver 595.80: repeated
play -> back out -> replay -> back out -> close cycles, all clean
(dispose() -> "GL instance cached for reuse", app closes normally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JacobWLMS

JacobWLMS commented Jun 26, 2026

Copy link
Copy Markdown

@skoruppa Was able to reproduce the stuck-player deadlock on 60ea79a7. Tracked it down: in dispose() mpv stop is sent before the render thread is joined, so the decoder teardown races the render thread's GLX context and both hang in libnvidia-glcore. Raised a PR with a fix if you want to review. skoruppa#1

@JacobWLMS

Copy link
Copy Markdown

@skoruppa Was able to reproduce the stuck-player deadlock on 60ea79a7. Tracked it down: in dispose() mpv stop is sent before the render thread is joined, so the decoder teardown races the render thread's GLX context and both hang in libnvidia-glcore. Raised a PR with a fix if you want to review. skoruppa#1

I also can't reproduce the resume position issue after this so it's possible the dispose hang was blocking whatever process triggers the watch progress sync? Needs further testing though.

fix(linux/nvidia): join render thread before mpv stop to fix GLX dispose deadlock
@skoruppa

Copy link
Copy Markdown
Member Author

@skoruppa Was able to reproduce the stuck-player deadlock on 60ea79a7. Tracked it down: in dispose() mpv stop is sent before the render thread is joined, so the decoder teardown races the render thread's GLX context and both hang in libnvidia-glcore. Raised a PR with a fix if you want to review. skoruppa#1

Merged. @FeelThePoveR can you test as well?

@FeelThePoveR

FeelThePoveR commented Jun 26, 2026

Copy link
Copy Markdown

@skoruppa Was able to reproduce the stuck-player deadlock on 60ea79a7. Tracked it down: in dispose() mpv stop is sent before the render thread is joined, so the decoder teardown races the render thread's GLX context and both hang in libnvidia-glcore. Raised a PR with a fix if you want to review. skoruppa#1

Merged. @FeelThePoveR can you test as well?

Looks good to me I couldn't get the player to hang and the playback's good.

2 remaining issues:

  • The subtitles can get cut off by the black bars
image - Backing out of playback doesn't update the resume time (To reproduce this one you can enter the player seek to a random place, back out and go back in - the position won't align, the UI element also doesn't update with the new resume time untill you leave the list of stream sources)

- Align setResizeMode with macOS bridge (keepaspect/panscan)
- Fit: letterbox with subs in margins (sub-use-margins=yes)
- Zoom: panscan=1.0 crop to fill, subs stay in visible area
- Stretch: keepaspect=no, subs on video
- Draw full FBO in Compose Canvas (no Kotlin-side cropping)
- Remove unused videoInFbo calculation from Canvas drawing
@skoruppa

Copy link
Copy Markdown
Member Author

Subtitles fixed ;)

But this one

Backing out of playback doesn't update the resume time

I'm not sure if I understand. Tried what you wrote but everything seems fine

@FeelThePoveR

Copy link
Copy Markdown

Huh on my end it looks like this.

This is the initial resume position from previous playbacks
image

I go into the stream and resume is set correctly
image

I seek to a different position
image

And after going back out it didn't update - it's still at the old resume position (this is the issue)
image

And going back into a stream confirms that
image

But if I do the same thing and go all the way back to the home page then the resume time updates
image

I don't know if that's the same case you tried.

@skoruppa

Copy link
Copy Markdown
Member Author

Oh, now I get it, but I think this is a global bug, not related to Linux bridge ;)

@FeelThePoveR

FeelThePoveR commented Jun 26, 2026

Copy link
Copy Markdown

Ah alright, I'll probably file a separate bug after this is merged (if it's not there that is).
Would be kind of weird to report issues from code that's "technically" not in and I don't have a Windows machine at hand to check how it's like on the other side of the pond hahah.

So I think that would be it from the issues that I can see sooo I would say it's an ACK from me on NVIDIA :D

@FeelThePoveR

Copy link
Copy Markdown

Unless you want me to check out the X11 side again that is

NVIDIA uses GLX fallback anyway, so GLES was only needed for Intel/AMD
where Desktop GL works equally well (Mesa supports both). Desktop GL
gives mpv access to full GL extensions and better compatibility.

EGL path now requests GL 3.3 Core Profile with fallback to default.
@tapframe

Copy link
Copy Markdown
Member

@skoruppa I think I may have to stop this direction here.
I haven’t had time to review everything properly yet, but from what I’ve seen, the Linux branch seems to be going in the wrong direction. I don’t think we should render video frames back into Compose on Linux.
the main concern is the Compose Canvas frame path. If mpv renders offscreen and then we read frames back into a byte array / Skia image for Compose to draw, we lose the native GPU presentation path and add extra frame copies. That’s exactly the kind of rendering model I was trying to avoid after the Windows, Macos attempts.

The desktop player already has a shared web UI for controls in controls.html, and thats what macOS and Windows use through the desktop webview layer. You can check the current native implementations in player_bridge.mm for macOS and player_bridge.cpp for Windows. Native libmpv renders underneath, and the shared web UI sits above it in WKWebView/WebView2.

Linux should follow the same model too: shared controls.html in a desktop webview, with native libmpv rendering underneath it.

So I don’t want to continue with the Compose Canvas / frame-readback approach. Let’s redirect the Linux work toward the same native libmpv + desktop webview architecture later.

For now, enjoy your vacation. We can continue this when you’re back.

@materemias

Copy link
Copy Markdown
Contributor

Tested on AMD + VAAPI + KDE Plasma — the combo listed as "needs testing". 🎉 Everything works, but I hit a build-blocking bug first.

🐛 Build-blocking bug (blocks any clean checkout)

./gradlew :composeApp:run fails before launch:

A problem was found with the configuration of task ':composeApp:generateLinuxPlayerRuntimeIndex'
  - property 'runtimeDir' specifies directory '.../build/native/linux-runtime' which doesn't exist.

Root cause: the bundled-runtime dir src/desktopMain/native/linux/live/ isn't committed (expected — we use system libmpv). When it's absent, the prepareLinuxPlayerRuntime Sync task is NO-SOURCE and never creates build/native/linux-runtime, so generateLinuxPlayerRuntimeIndex's @InputDirectory hard-fails. Nobody without that uncommitted dir can build the PR.

Fix (in the shared GenerateNativeRuntimeIndexTask) — tolerate a missing/empty runtime dir and have the task create its own output:

 abstract class GenerateNativeRuntimeIndexTask : DefaultTask() {
-    @get:InputDirectory
+    @get:InputFiles
+    @get:PathSensitive(PathSensitivity.RELATIVE)
     abstract val runtimeDir: DirectoryProperty

     @get:OutputFile
     abstract val indexFile: RegularFileProperty

     @TaskAction
     fun generate() {
         val dir = runtimeDir.get().asFile
+        dir.mkdirs()
         val files = dir
             .listFiles { file -> file.isFile && file.name != indexFile.get().asFile.name }
             .orEmpty()

This keeps incremental input tracking for the Windows path (which has a populated runtime dir) while tolerating the empty case on Linux. With this, the build runs clean.

✅ AMD VAAPI test result

Hardware: AMD Radeon RX 6650 XT (navi23) · Mesa 26.1.3 · Arch · KDE Plasma 6 (Wayland)

Bridge log on playback:

[player_bridge] EGL: trying render node /dev/dri/renderD128
[player_bridge] EGL: initialized 1.5 via GBM
[player_bridge] EGL: GBM context ready (surface=window/pbuffer)
[player_bridge] render thread: GL=4.6 (Core Profile) Mesa 26.1.3 renderer=AMD Radeon RX 6650 XT (radeonsi, navi23) (backend=EGL)
[player_bridge] render thread: GL render context created successfully (VAAPI should work)
[player_bridge] create: returning handle (gpuMode=2)
[player_bridge] EGL: FBO resized to 1270x790
  • Multi-GPU node enumeration picked renderD128 (AMD) on first eglMakeCurrent
  • Full EGL GBM + Desktop GL 4.6 Core path — gpuMode=2, no SW fallback. (On AMD/Mesa, Desktop GL works fine; the GLES requirement is NVIDIA-specific, confirmed not needed here.)
  • VAAPI hardware decode + smooth playback ✅
  • Clean dispose, no crash on close — the documented Gallium pipe_screen concern did not trigger on radeonsi ✅

Happy to test further variants if useful.

@materemias

Copy link
Copy Markdown
Contributor

Followed up on my own report — pulled the branch, built it, and ran it on AMD RX 6650 XT + KDE Plasma 6 (Wayland). After the build fix above, two playback bugs surfaced. Both now fixed and verified working on this hardware.

1. Timeline click doesn't seek (only dragging works)

PlayerControls.ktonValueChangeFinished seeked to displayedPositionMs (= scrubbingPositionMs ?: playbackSnapshot.positionMs). On a single track-click the Slider fires onValueChange then onValueChangeFinished within one frame, before recomposition updates displayedPositionMs — so it seeks to the stale pre-click position. Dragging works because its many events recompose between frames. (This is common code, so it likely affects other platforms' click-to-seek too.)

@@ -43,6 +43,8 @@ import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -496,14 +498,21 @@ private fun ProgressControls(
     val audioPainter = appIconPainter(AppIconResource.PlayerAudioFilled)
 
     Column(modifier = modifier) {
+        // Track the latest value from onValueChange: on a single track-click the Slider fires
+        // onValueChange then onValueChangeFinished within one frame, before displayedPositionMs
+        // recomposes — so seeking to displayedPositionMs would use the stale (pre-click) position.
+        val pendingScrubMs = remember { mutableStateOf(displayedPositionMs) }
         Slider(
             modifier = Modifier
                 .fillMaxWidth()
                 .height(metrics.sliderTouchHeight)
                 .graphicsLayer(scaleY = metrics.sliderScaleY),
             value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
-            onValueChange = { value -> onScrubChange(value.toLong()) },
-            onValueChangeFinished = { onScrubFinished(displayedPositionMs.coerceIn(0L, durationMs)) },
+            onValueChange = { value ->
+                pendingScrubMs.value = value.toLong()
+                onScrubChange(value.toLong())
+            },
+            onValueChangeFinished = { onScrubFinished(pendingScrubMs.value.coerceIn(0L, durationMs)) },
             valueRange = 0f..durationMs.toFloat(),
         )
         Row(

2. Addon (external URL) subtitles never display

Embedded subtitles worked, but addon subtitles (OpenSubtitles/Wizdom) did nothing. The addon handler calls only setSubtitleUri(url) (no follow-up select), which maps to sub-add <url> auto. mpv's auto flag adds the track without selecting it, and with sub-auto=no it is never auto-selected — so the SRT loaded (Using subtitle decoder srt in the logs) but stayed inactive. Using select adds and activates it atomically (also avoids the async track-list race vs a separate select call).

@@ -1293,7 +1293,7 @@ JNIEXPORT void JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBr
 JNIEXPORT void JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_addSubtitleUrl(JNIEnv *env, jobject thiz, jlong hdl, jstring url) {
     (void)thiz; CreateTask *task = getTask(hdl); if (!task || !task->mpv || !url) return;
     const char *u = (*env)->GetStringUTFChars(env, url, NULL);
-    if (u) { const char *cmd[] = {"sub-add", u, "auto", NULL}; mpv_command_async(task->mpv, 0, cmd); (*env)->ReleaseStringUTFChars(env, url, u); }
+    if (u) { const char *cmd[] = {"sub-add", u, "select", NULL}; mpv_command_async(task->mpv, 0, cmd); (*env)->ReleaseStringUTFChars(env, url, u); }
 }
 
 JNIEXPORT void JNICALL Java_com_nuvio_app_features_player_desktop_NativePlayerBridge_clearExternalSubtitles(JNIEnv *env, jobject thiz, jlong hdl) {

All three fixes are on a branch if useful. Verified on AMD: hardware VAAPI playback, click-to-seek, embedded + addon subtitles all working.

@materemias

Copy link
Copy Markdown
Contributor

Heads-up on scope: of the fixes I found while testing this PR, only two are Linux-player-specific and belong here — the build fix (runtime-index dir) and the addon-subtitle autoselect fix. The other three turned out to be generic desktop/common code, so I've split them into their own PRs against Dev to keep this one focused:

So this PR can stay scoped to the two Linux items above.

@aelrased

aelrased commented Jul 2, 2026

Copy link
Copy Markdown

Alternative approach: X11 direct mode (wid) + JavaFX WebView subtitle overlay

We have been working on a different approach for Linux playback in our fork: aelrased/NuvioDesktop/tree/Native-Render

Our approach

Instead of EGL/GBM offscreen rendering, we use mpv direct mode with wid — passing the AWT Canvas X11 window ID directly to mpv via vo=gpu-next with wid=<window_id>. This lets mpv render directly into the application window using its own GPU-accelerated output path.

This works reliably on XWayland (tested on GNOME + NVIDIA driver 595.84, Intel Iris Xe + RTX 4070). The video renders directly into the AWT Canvas subsurface with full GPU decode.

Key findings

  1. vo=gpu-next with wid on XWayland works correctly — mpv handles the X11 window embedded in the Wayland compositor via XWayland
  2. Compose overlays/dialogs are invisible behind the Canvas on XWayland (the Canvas acts as the topmost X11 child window). We tried JDialog, JWindow, Compose DialogWindow — all rendered behind the video
  3. mpv built-in OSC works fine for basic controls (play/pause, seek, volume, fullscreen). We set osc=yes in the mpv config
  4. Subtitle panel needs a separate window — we use a JavaFX transparent Stage (StageStyle.TRANSPARENT) with a WebView loading the existing controls.html (the same web UI shipped for macOS/Windows). When the user clicks the subtitle button in the OSC, a Lua script intercepts it and triggers the JavaFX overlay, which opens openPlayerModal(subtitles) and hides Chrome elements
  5. Java ↔ JS events flow through window.javaFxBridge.onPlayerEvent(type, value) in controls.js, bridged to NativePlayerController.handlePlayerEvent() — the same event path used by macOS/Windows WebViews

What didn't work for us

  • EGL/GBM offscreen + glReadPixels (Skia Canvas overlay) — the approach in this PR. It seems promising for pure Wayland but has NVIDIA compatibility issues
  • Software rendering with frame copying — works but CPU-intensive
  • JWindow/JDialog/Compose DialogWindow overlays — all render behind the Canvas on XWayland

Files changed in our branch

  • player_bridge.c — X11 direct mode (wid), osc=yes, embedded Lua subtitle script, processEvents JNI polling via Swing Timer
  • JavaFXPlayerOverlay.kt — transparent Stage + WebView, JS bridge, event forwarding to native controller
  • SubtitleModalFactory.desktop.kt — uses JavaFX overlay instead of Compose SubtitleModal on Linux
  • NativePlayerController.kteventForwarder bridges JavaFX JS events into the standard player event pipeline
  • controls.jsjavaFxBridge path in send(), sends "close" on subtitle modal dismiss
  • build.gradle.kts — all JavaFX modules with Linux classifier JARs

Limitations of our approach

  • X11-only (via XWayland). Pure Wayland would need a different path
  • Separate window for subtitle panel (not integrated into the main window)
  • mpv OSC renders on top of the video but its transparency is imperfect on XWayland (black artifacts around buttons)

Potential convergence

If the EGL/GBM path in this PR gets GPU rendering working on NVIDIA, the rest of our approach (JavaFX WebView overlay for subtitle controls) could complement it — the EGL path provides the video surface, and the JavaFX overlay provides the subtitle panel on top, avoiding the layering issue entirely.

@FeelThePoveR

Copy link
Copy Markdown

@skoruppa @tapframe

I've been messing around with Webkit + MPV based player prototype (FeelThePoveR#1) and I must say NVIDIA proprietary truly sucks on Linux.

So far I think UI overlay rendering basically can't be done on it under XWayland with webkitgtk 6.0 and 4.1 (modversion 2.52.4 on both - that's what I have in Fedora repos. I've seen that 2.40 modversion may work but haven't tested it), the following attempts were made on both webkitgtk versions:

  • Normal GL/GLX/Vulkan rendering straight up doesn't work - fails on dmabuf creation,
  • Disabling dmabuf either still doesn't render anything or makes the transparency fail obscuring the mpv player
  • Disabling compositing results in the same outcome as disabling dmabuf
  • Forcing software rendering with cairo also stops the transparency from working obscuring the playback

Doing research on the web confirmed multiple NVIDIA proprietary issues exist for XWayland webkitgtk.
The only way that I could make NVIDIA work with webkitgtk was by using the Mesa Zink driver.

Not doing PR against main repos as the implementation is messy and is supposed to serve as inspiration/research before the actual implementation will be done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants