Getting Native Video Wallpapers Working in KDE Plasma with TuxPapers
Building TuxPapers a native Linux animated wallpaper manager sounds straightforward until you start fighting KDE Plasma's plugin system, Qt's private ABI, and a plasmashell that quietly ignores your changes. This is the story of how I got from "black screen" to native MKV playback.
The Goal
TuxPapers works as a two-process system: a persistent daemon handles all display output, and a Tauri UI acts as a control panel. On wlroots compositors (Sway, Hyprland, etc.) I use wlr-layer-shell to render video directly onto the desktop. KDE Plasma, however, doesn't support wlr-layer-shell on its own compositor. It has its own plugin system for wallpapers, and that's the rabbit hole I fell into.
The intended approach: write a KDE wallpaper plugin in QML that uses import QtMultimedia to decode and display video files. Simple enough on paper.
Problem 1: The ABI Mismatch
The first attempt loaded import QtMultimedia in a QML wallpaper plugin and immediately crashed plasmashell with:
libQt6Multimedia.so.6: undefined symbol: ZN14QObjectPrivateC2E16QtPrivate6_11_1, version Qt_6_PRIVATE_API
Both plasma-workspace and qt6-multimedia reported version 6.11.1. Same version. Still binary-incompatible. Why? CachyOS ships a custom build of plasma-workspace compiled against Qt6 private APIs from a slightly different build of Qt than what Arch's extra/qt6-multimedia was compiled against. Qt private APIs are explicitly not stable between builds — the ABI version symbol encodes a build fingerprint, not just the version string. Two packages can both say 6.11.1 and still be incompatible at the binary level if they weren't compiled from the same source tree.
The lesson: on any distro that patches or custom-builds KDE components, import QtMultimedia in a wallpaper plugin is a landmine. You have no control over what plasmashell was compiled against.
Temporary workaround: Abandon QtMultimedia entirely. Spawn ffmpeg in the daemon to continuously decode the video and overwrite a single JPEG at /tmp/tuxpapers_frame.jpg. The QML plugin uses two Image components alternating to display successive frames. Ugly, but functional.
Problem 2: KPackageStructure and the Silent Black Screen
With the ffmpeg hack in place, I still got a black wallpaper. The plugin loaded — no errors — but displayed nothing. The culprit was a single missing field in metadata.json:
{
"KPackageStructure": "Plasma/Wallpaper", ← this line was missing
"KPlugin": {
"Id": "com.tuxpapers.wallpaper",
...
}
} Without KPackageStructure, plasmashell accepts the plugin but doesn't render it as a wallpaper. It just silently renders nothing. No error. No warning. No indication anything is wrong. KDE's plugin validation is entirely silent on failure. Similarly, the QML root element matters. Using a plain Item as the root instead of WallpaperItem (from import org.kde.plasma.plasmoid) means Plasma can't set the item's dimensions correctly — you get a zero-size element rendered off-screen.
Problem 3: The In-Memory QML Cache
After fixing the metadata and root element, I made further changes to main.qml, deployed them, and... nothing changed. The old behaviour persisted. Deploying via DBus (toggling the wallpaper plugin off and back on) didn't help. The issue: plasmashell's QQmlTypeLoader caches compiled QML components in memory. Toggling the plugin via DBus re-uses the cached compiled version — it never re-reads from disk. The only way to clear it is a full plasmashell restart:
plasmashell --replace & This cost us a lot of debugging time before I understood it. Any time you change QML files in a Plasma plugin, you need a full shell restart to see the changes. Keep this in mind.
Problem 4: Desktop Icons Disappearing
Once I had the plugin rendering, desktop icons vanished. The wallpaper was visible but clicking the desktop did nothing — FolderView (the KDE desktop icon component) was gone. This turned out to be a layering issue. Our earlier attempts had tried using wlr-layer-shell with the BACKGROUND layer even on KDE, which placed our surface beneath the Plasma desktop containment. When that surface was opaque, it covered everything. When it was transparent, KDE suppressed the desktop containment entirely. The fix was straightforward once I understood it: use KDE's own wallpaper plugin system correctly (with WallpaperItem as the root), never render via wlr-layer-shell on KDE, and ensure the QML always renders opaque content so Plasma's compositor knows there's something there.
Problem 5: The Frame Flash
The ffmpeg approach worked but the display was unusable — every frame transition caused a brief black flash as Qt blanked the Image component while loading the new source. Qt's Image component briefly clears its displayed content when you change source. At 200ms intervals this is a rapid strobe effect. The fix is double-buffering:
property bool showA: true
Image {
id: frameA
visible: showA
onStatusChanged: {
if (status === Image.Ready && !_showA)
showA = true
}
}
Image {
id: frameB
visible: !showA
onStatusChanged: {
if (status === Image.Ready && showA)
showA = false
}
}
The hidden image preloads the next frame. When it signals Ready, I flip visibility.
Problem 6: file:// XHR is Disabled in plasmashell
Now I needed a way to tell the QML plugin which video to play. The daemon writes a config file at ~/.config/tuxpapers/current.json. In QML, reading a local file via XMLHttpRequest seems obvious:
xhr.open("GET", "file:///home/user/.config/tuxpapers/current.json", true)
## plasmashell blocks this:
XMLHttpRequest: Using GET on a local file is disabled by default. Set QML_XHR_ALLOW_FILE_READ to 1 to enable this feature. I don't control how plasmashell sets its environment variables, so QML_XHR_ALLOW_FILE_READ isn't an option. The fix: have the daemon serve current.json over a local HTTP endpoint on 127.0.0.1:18462. QML's XHR to HTTP localhost works fine:
xhr.open("GET", "http://127.0.0.1:18462/config?" + Date.now(), true) The Date.now() cache-buster ensures each poll is treated as a distinct request.
The Final Breakthrough
After a system update, the original ABI mismatch disappeared. CachyOS synced their plasma-workspace build against the same Qt sources as extra/qt6-multimedia.
import QtMultimedia suddenly worked.
I tried using a Loader to isolate the QtMultimedia code so that if it failed, the ffmpeg frame fallback would still show. The Loader loaded without error — but the screen stayed black. VideoOutput rendered into a sub-tree that wasn't compositing into the wallpaper layer correctly.
The fix was to import QtMultimedia directly in main.qml rather than through a Loader:
import QtQuick
import QtMultimedia
import org.kde.plasma.plasmoid
WallpaperItem {
id: root
MediaPlayer {
id: player
source: contentPath ? ("file://" + contentPath) : ""
loops: MediaPlayer.Infinite
audioOutput: AudioOutput { volume: 0.0 }
videoOutput: videoOut
onSourceChanged: if (source !== "") play()
}
VideoOutput {
id: videoOut
anchors.fill: parent
}
// polls http://127.0.0.1:18462/config every second
...
}VideoOutput as a direct child of WallpaperItem hooks into the scene graph correctly. Native MKV playback. Desktop icons intact. No ffmpeg. No frame extraction.
What the Final Architecture Looks Like
tuxpapers-daemon
├─ Reads state.json → knows which video to play
├─ Writes ~/.config/tuxpapers/current.json on wallpaper change
└─ Serves that JSON at http://127.0.0.1:18462/config
plasmashell (KDE)
└─ Loads com.tuxpapers.wallpaper plugin
└─ main.qml (WallpaperItem + QtMultimedia)
├─ Polls /config every second
└─ Plays video natively via MediaPlayer + VideoOutput
Key Takeaways
KPackageStructure is mandatory. Its absence gives no error — just a black wallpaper.
WallpaperItem is the correct root element, not Item. Without it, Plasma can't size the plugin.
plasmashell caches QML in memory. plasmashell --replace is required to pick up any QML change.
Qt private ABI is not stable across builds, even within the same version string. Never assume two 6.x.x packages are binary-compatible if one is a custom distro build.
file:// XHR is disabled in plasmashell. Use a local HTTP server for any IPC that the QML plugin needs to read.
Loader sub-trees don't always composite correctly. If VideoOutput is your root visual, it needs to be a direct child of the wallpaper item — not wrapped in a Loader.
I'm currently aiming to have TuxPapers out on the Steam Store within the next few weeks. I need to extensively test all other forms of distro and desktop environment. But at the minute I'm aiming to have a decently well supported list of distros and DE's.
Related Posts
Bringing the Laravel Herd Experience to Linux with “Lerd”
Laravel Herd is extremely powerful and easy to host and debug Laravel applications on MacOS and Windows. Has someone created the solution for Linux?
Read More →
Steam Winter Sale 2025 why does it feel like it isn't though?
Has anyone been looking at this years 2025 Steam Sale and been going... This really doesn't feel like a sale? Well, me too. Let's explore why.
Read More →