Hotaru supports two video renderers plus a web renderer and a Wallpaper
Engine scene renderer. Video renderers are selected at runtime by the
video-renderer GSettings key. Changing the key rebuilds the active
wallpaper immediately.
| Renderer | Setting value | Role |
|---|---|---|
libmpv (MpvWidget) |
mpv |
Default. Best performance; robust hardware decoding. |
GStreamer (GstGtk4Widget) |
gst-gtk4 |
Fallback; GTK-native pipeline, used when built without libmpv. |
WebKitGTK (WebWidget) |
— | Not user-selectable; used for wallpaper_type: web. |
linux-wallpaperengine (SceneWidget) |
— | Not user-selectable; renders scene-type wpe packages. |
mpv is the default because its hwdec=auto-safe reliably engages hardware
decoding across codecs, keeping CPU usage flat where the GStreamer path can
fall back to software decoding.
All renderers are GTK widgets (GObject subclasses of gtk::Box) implementing
the RendererWidget trait (renderer.rs):
trait RendererWidget: AsRef<Widget> {
fn mirror(&self, enable_graphics_offload: bool, content_fit: ContentFit) -> gtk::Box;
fn play(&self); fn pause(&self); fn stop(&self);
fn set_volume(&self, volume: i32); // 0 – 100
fn set_mute(&self, mute: bool);
fn set_content_fit(&self, fit: gtk::ContentFit);
}They are held in the Renderer enum, dispatched statically via
enum_dispatch. Renderer::with_filepath / with_uri pick the concrete
widget from WallpaperType + VideoRenderer; a build without the mpv
cargo feature transparently downgrades an mpv selection to gst-gtk4 with
a warning. A wpe wallpaper is resolved first (see below) and then routed
through the same dispatch as its underlying type.
flowchart TD
SRC["Renderer::with_filepath / with_uri / with_wpe"] --> T{wallpaper_type}
T -->|web| WEB[WebWidget]
T -->|wpe| WP{"project.json<br/>type"}
WP -->|scene| SCN[SceneWidget]
WP -->|web| WEB
WP -->|video| VR
T -->|video| VR{video-renderer setting}
VR -->|mpv| F{"built with<br/>mpv feature?"}
VR -->|gst-gtk4| GST[GstGtk4Widget]
F -->|yes| MPV[MpvWidget]
F -->|"no (warn)"| GST
A Wallpaper Engine workshop item is a directory with a project.json whose
type is scene, video, or web. Renderer::with_wpe (wpe.rs)
resolves the package and delegates by that type:
project.json type |
Renderer | Entry passed |
|---|---|---|
scene |
SceneWidget (linux-wallpaperengine) |
the package directory |
video |
the video renderer (mpv/gst) | project.json file (the video) |
web |
WebWidget |
project.json file (index.html) |
So video/web packages use hotaru's own (better-tuned) renderers rather than
the engine's built-ins — and they work even in a build without the wpe
cargo feature; only scene packages need the engine.
The source is either a filepath (the package directory) or a workshop_id
(resolved to the Steam install: $HOTARU_WPE_WORKSHOP, then
~/.local/share/Steam, ~/.steam/steam, ~/.steam/root, and Flatpak Steam,
under steamapps/workshop/content/431960/<id>).
mirror() supports clone/stretch modes: it returns a widget showing the same
output as the primary renderer without a second decode pipeline (see
per-renderer notes below).
libmpv has no GTK video sink, so the widget drives mpv's OpenGL render
API (vo=libmpv) into a gtk::GLArea:
- Handle setup —
LC_NUMERICis forced back to"C"beforempv_create(libmpv refuses to initialize otherwise, andgtk::init()applies the user's locale). Options:loop-file=inf(wallpapers loop forever),hwdec=auto-safe,terminal=yes+msg-level=all=warnso mpv errors surface on stderr (the event queue is not drained). - GL symbol resolution — libmpv resolves every GL function through a
caller-provided
get_proc_address. GTK exposes no public GL loader, so a process-wide resolver dlopenslibEGL.so.1(eglGetProcAddress) orlibGL.so.1(glXGetProcAddressARB), chosen by what GDK actually realized: Wayland is always EGL; on X11,gdk_x11_display_get_egl_display()distinguishes EGL from GLX contexts. - Render context lifecycle — created on GLArea
realize(GL context current), freed onunrealize(again with the context current, as libmpv requires).RenderContext<'a>borrows theMpvhandle; the widget stores it asRenderContext<'static>via a documented transmute, with struct field order guaranteeing the context drops before the handle. - Frame scheduling — mpv's update callback fires on an mpv thread and
only sets an
Arc<AtomicBool>; a frame-clock tick callback on the main thread polls the flag and callsqueue_render(). Redraws therefore happen at the video's own rate with no cross-thread GTK calls. Therendersignal queriesGL_FRAMEBUFFER_BINDING(GTK renders GLArea into its own FBO, not 0) and callsmpv_render_context_renderwithflip_y = true. - Property mapping —
pause,volume,mutemap directly to mpv properties (mpv's volume is the same 0-100 scale). Content fit maps to mpv's own scaling: Fill →keepaspect=no, Contain →keepaspect+panscan=0, Cover →keepaspect+panscan=1. File loading is deferred until the render context exists (loadfilebefore a VO exists would fail).
sequenceDiagram
participant mpv as mpv render thread
participant flag as AtomicBool
participant tick as frame-clock tick (main)
participant area as GLArea (main)
mpv->>flag: update callback → store(true)
loop every display frame
tick->>flag: swap(false)
alt new frame available
tick->>area: queue_render()
area->>area: query GL_FRAMEBUFFER_BINDING
area->>mpv: mpv_render_context_render(fbo, w, h, flip_y)
end
end
- mirror() — mpv renders straight into its GLArea and exposes no
gdk::Paintable, so clones use agtk::WidgetPaintablesnapshot of the GLArea.
The GTK-native pipeline: gst-play (gstreamer-play) with a
gtk4paintablesink, whose gdk::Paintable is shown by a gtk::Picture
(optionally wrapped in GtkGraphicsOffload when enable-graphics-offload
is set — the sink supports dmabuf import, enabling zero-copy paths).
The sink is statically linked (gst-plugin-gtk4 crate) and registered at
startup via gstgtk4::plugin_register_static(), so hotaru does not depend
on the system's gst-plugins-rs package; if the system provides the plugin
too, GStreamer's registry picks the newer of the two.
- Looping:
PlaySignalAdapter::connect_end_of_streamseeks back to 0. - Content fit is the
gtk::Picturecontent-fitproperty. mirror()creates anothergtk::Pictureon the same paintable, withcontent-fitbound to the primary picture — clones cost one extra texture draw, not a pipeline.- Decoding uses whatever GStreamer elements the system provides; hardware decode availability depends on installed plugin sets (VA-API/NVDEC etc.).
A WebKitGTK WebView loading the configured URI (local file or remote).
Playback controls are no-ops. mirror() uses a gtk::WidgetPaintable of
the WebView.
For wpe packages of web type, the WebView also emulates the Wallpaper
Engine browser environment (WebWidget::with_wpe), which stock
linux-wallpaperengine does not implement at all:
- Local file access —
allow-file-access-from-file-urls/allow-universal-accessso wallpapers can XHR/fetch their own assets (Spine skeletons/atlases, JSON) from thefile://package. - WPE JS API shim — a user script injected at document-start defines the
wallpaperRegister*globals (media listeners as no-ops; the audio listener fed a zeroed 128-sample spectrum so audio-reactive wallpapers run flat). - Property delivery — after load, hotaru calls
window.wallpaperPropertyListener.applyUserProperties(defaults)with the package'sgeneral.propertiesdefaults (fromwpe.rs), plusapplyGeneralProperties({fps})with theHOTARU_WPE_FPSlimit. This is what drives property-gated rendering, e.g. which model/quality a wallpaper loads. - Hardware-accelerated compositing forced on (WebGL wallpapers glitch on first paint under the default software→GPU promotion).
- Media playback — autoplay is allowed and
media-playback-requires-user-gestureis off (a desktop wallpaper gets no gesture), so wallpapers that autoplay bundled audio/video play. Local<audio>/<video>files are read by GStreamerfilesrcinside WebKit's sandboxed WebProcess (unlike page subresources, which the unsandboxed NetworkProcess fetches), so each local-file WebWidget grants its wallpaper's directory read-only into its ownWebContextsandbox — the sandbox stays fully enabled. Escape hatch:HOTARU_WEBKIT_SANDBOX=0disables WebKit's sandbox entirely, for wallpapers that read local media from outside their own directory. Interactive players that need a click to start still won't (a wallpaper receives no pointer events). - Debugging —
HOTARU_WEB_CONSOLE=1routes the wallpaper's JS console to stdout (wallpapers have no visible console).
This is emulation: audio-reactive and media-integration wallpapers run but don't react (no real spectrum / now-playing feed).
Renders scene-type Wallpaper Engine packages (the delegation target above)
through
linux-wallpaperengine's
embedding API (wpe_embed.h, on the feat/embed-api branch of our fork),
which follows the libmpv render-API model: the host owns the GL context,
frame clock, pointer input and destination FBO, and the engine draws one
frame per call. The widget therefore mirrors MpvWidget's structure:
- Backend source — the fork is pinned as a git submodule at
third_party/linux-wallpaperengine.
Build the library with
make wpe-lib(inits the submodule recursively, then CMake-buildsliblinux-wallpaperengine-lib.sowithENABLE_WEB=OFF, so no CEF/Chromium is pulled in — hotaru renders web wallpapers itself). Needsglm,glfw,glew,SDL2,mpv,lz4,freetypeand X11/Wayland dev headers. - Runtime loading — the engine library
(
liblinux-wallpaperengine-lib.so) is dlopen'd on first use, so hotaru builds and runs without it; loading a scene then logs an error instead of failing at startup. Search order:HOTARU_WPE_LIBRARYif set, then<prefix>/lib{,64}/hotaru/next to the running binary (wheremake installputs it — covers ~/.local, /usr, and Flatpak's /app), then the bare soname via the system linker path. - Assets — the Wallpaper Engine
assetsdirectory is auto-detected from a Steam installation by the engine;HOTARU_WPE_ASSETSoverrides it. - Desktop GL requirement — the engine needs OpenGL 3.3 core, not GLES,
and a GL
GLAreacontext cannot share with a GLES GDK display context. GDK prefers GLES on some EGL setups (notably NVIDIA), somain()appendsgles-apitoGDK_DISABLEbefore GTK opens the display when built with thewpefeature (HOTARU_ALLOW_GLES=1opts out). The GLArea is also restricted toGLAPI::GL. - ABI guard — the FFI structs in
scene.rsare hand-mirrored fromwpe_embed.h;wpe_abi_version()is checked right after dlopen and a mismatched library is refused (blank wallpaper + logged error) rather than risking a layout-corruption crash. BumpWPE_EMBED_ABI_VERSION(header) and theWPE_ABI_VERSIONconstant inscene.rsin lockstep on any ABI change. - GL symbols — resolved through the same process-wide loader as
MpvWidget(src/renderer/gl_loader.rs). - Frame scheduling — scenes animate continuously: a frame-clock tick
callback queues a render while playing, capped at
HOTARU_WPE_FPSFPS (default 60) so it doesn't run at full refresh on high-Hz displays. The engine derives its scene clock from the host timestamps we pass (frame-clock time), sopause()freezes the clock (wpe_context_set_paused) and damage-driven redraws while paused repeat the same still frame. - Property mapping — volume 0-100 scales to the engine's 0-128;
set_mutemaps towpe_context_set_audio_enabled. Content fit maps to the engine's viewport scaling (Fill →stretch, Contain →fit, Cover →fill) which is fixed at scene load, so a laterset_content_fitrebuilds the engine context. Pointer motion over the GLArea feeds scene parallax/interaction viawpe_context_set_mouse. - mirror() —
gtk::WidgetPaintablesnapshot of the GLArea, same asMpvWidget.
The audio-visualizer capture (PulseAudio + FFT inside the engine) is disabled; audio-reactive scenes render with a zeroed spectrum.
content-fit (GSettings, default Cover) supports:
| Value | Meaning | Wallpaper behavior |
|---|---|---|
| 0 Fill | stretch, ignore aspect | fills, may distort |
| 1 Contain | fit inside, keep aspect | letterboxes on mismatch |
| 2 Cover | fill, keep aspect, crop | fills, crops overflow (default) |
GtkContentFit::ScaleDown is deliberately not offered: never upscaling makes
sense for image viewers, not wallpapers (a small video would sit at 1:1 in a
sea of black), and mpv has no equivalent.
Note that in stretch mode the fit applies to the virtual canvas spanning all monitors, not to each monitor — with mixed orientations the canvas aspect can be extreme, which is why Cover (crop) is the default.