- Python 50.3%
- TypeScript 26.9%
- Shell 19.7%
- Dockerfile 2.9%
- JavaScript 0.2%
Art, D-pad, and volume-field fixes are all confirmed working on a real Deck. Writes up the three non-obvious causes (CEF blocking LAN http images, Steam navigating a flex row as a vertical list, SliderField's label row plus the PanelSectionRow separator) so they don't have to be rediscovered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| scripts | ||
| src | ||
| .gitignore | ||
| lms.py | ||
| main.py | ||
| package.json | ||
| plugin.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| rollup.config.js | ||
| tsconfig.json | ||
decky-squeezelite
A Decky Loader plugin that turns a Steam Deck into a Squeezelite network audio player (a Lyrion / Logitech Media Server client), controllable directly from the Quick Access Menu (QAM) in game mode.
Status: MVP working (2026-07-10). The plugin installs on a real Deck and the Quick Access toggle starts/stops squeezelite, which plays from LMS via autodiscovery. See "Validated on hardware" below. This README doubles as the reference spec.
Transport controls added (2026-08-12) — now-playing metadata, cover art, and play/pause/next/volume in the QAM, via the LMS control API. Validated on a real Deck, installed from a release zip. See "Transport controls" below.
Install from URL (2026-08-12) — CI publishes an installable zip to a moving
latestrelease, so the plugin installs straight into Decky with no store and no SSH. See "Installing from a URL" below.
Goal
- Install a self-contained
squeezelitebinary on the Deck without fighting the immutable SteamOS filesystem. - Configure the LMS server address (and player name) from the plugin UI.
- Toggle the player on/off from game mode.
- Play through whatever audio device the Deck is currently using — built-in speaker, Bluetooth, wired headset, or HDMI when docked — with no manual device selection.
Target environment
- Deploy target: Steam Deck running SteamOS (Arch-based, immutable root FS, audio via PipeWire), with Decky Loader installed.
- Dev machine: Manjaro (Arch-based).
squeezeliteis already installed here at/usr/bin/squeezelite— useful as a reference, but not shipped (see "Why we build our own" below). - User's setup: An existing LMS server already runs on the LAN with many clients. This Deck becomes one more player.
Key design decisions
| Decision | Choice | Rationale |
|---|---|---|
| Binary delivery | Ship a prebuilt bundle inside the plugin dir | SteamOS root FS is read-only and wiped on every OS update. ~/homebrew/plugins/ persists. No on-device toolchain needed. |
| Build location | Docker, off-device | Controls feature flags and glibc version (see below). |
| Service control | Backend-managed subprocess | Decky's Python backend runs as root and stays alive while loaded. Simplest; touches no system files. |
| Audio output | PipeWire default sink | Follows the Deck's current output device automatically. |
| Test target | Real Steam Deck with Decky | Game-mode UI and PipeWire behavior can't be fully validated on the dev box. |
Why we build our own binary
The Manjaro distro squeezelite links against ~120 shared libraries because
it was compiled with full FFmpeg support (WMA/ALAC), which drags in the entire
video-codec and rendering stack (libx264, libx265, libaom, libcairo,
libpango, CUDA libOpenCL, …). Bundling that is a non-starter.
Compiling squeezelite ourselves with a minimal feature set (no FFmpeg) drops the dependency list to roughly 8–10 libraries:
| Library | Purpose |
|---|---|
libFLAC, libvorbisfile / libvorbis / libogg |
FLAC + OGG |
libmad / libmpg123 |
MP3 |
libfaad |
AAC |
libasound |
ALSA output layer |
libsoxr (optional) |
high-quality resampling |
libm, libc, libpthread, libdl |
system (already on the Deck) |
Squeezelite dlopens its codec libraries at runtime by default (unless built
with -DLINKALL), so the binary degrades gracefully and we can drop the bundled
.sos beside it and point LD_LIBRARY_PATH at them. Result: a few-MB bundle
instead of hundreds.
glibc caveat
SteamOS lags mainline glibc. A binary compiled on bleeding-edge Manjaro may fail
on the Deck with GLIBC_2.xx not found. The build must run in a Docker
container whose base image glibc is ≤ SteamOS's. This is also why Decky's
backend build flow uses Docker.
Audio: following the current output device
The user does not want to pick a device — playback should follow whatever the Deck is outputting to (speaker / BT / wired / HDMI dock). On PipeWire this maps to the default sink, which PipeWire re-points automatically as devices connect/disconnect.
Decided (build spike): use squeezelite's ALSA backend with -o default.
On SteamOS the ALSA default PCM is routed by pipewire-alsa to the current
default sink, so playback follows the device and mixes as an ordinary stream —
and it lets us bundle the fewest libraries (we reuse the Deck's system
libasound so its PipeWire routing config applies; we do not bundle
libasound). We add -C 5 so the device is released a few seconds after idle
instead of being held open.
Fallback if hardware testing shows poor volume/mixing integration: rebuild with
the PulseAudio backend (-DPULSEAUDIO, output to a pulse device via
pipewire-pulse) and bundle libpulse. This is a build-flag change in
backend/build.sh, nothing structural.
This still needs early validation on real hardware:
- Confirm
-o defaultfollows the PipeWire default sink live (plug in BT mid-play → audio moves). - Mix with game audio (decided): squeezelite is just another PipeWire stream, so it should mix by default. Validate it doesn't grab an exclusive/hog-mode device.
- Confirm behavior when docked (HDMI sink becomes default).
Configuration surface (squeezelite flags)
Exposed in the UI, persisted via Decky settings, applied as CLI flags:
| UI field | Flag | Notes |
|---|---|---|
| Player name | -n <name> |
Settable in the plugin. Defaults to something like Steam Deck. |
| LMS server | -s <host>[:<port>] |
User has an existing server. Blank = autodiscovery. |
| Output device | -o default |
Fixed to the PipeWire default sink (not user-facing initially). |
| (future) codecs / resampling | -c, -R -u |
Advanced, optional. |
Architecture
Three layers:
- Acquire — Docker build produces a minimal, glibc-compatible
squeezelite- its codec/ALSA
.sos, checked in / released asbin/.
- its codec/ALSA
- Run — Decky Python backend launches it as a subprocess:
LD_LIBRARY_PATH=bin/lib bin/squeezelite -n <name> -s <server> -o default, tracks the PID, exposes start/stop/status. - Control — Decky frontend: a QAM panel with an on/off toggle, running
status, and config fields, calling backend methods over
@decky/api.
Proposed repo layout
decky-squeezelite/
├─ plugin.json # Decky manifest (name, author, flags)
├─ package.json # frontend deps: @decky/ui, @decky/api
├─ rollup.config.js # bundles frontend → dist/index.js
├─ src/
│ └─ index.tsx # QAM panel: toggle, status, config fields
├─ main.py # backend Plugin class: start/stop/status/save_config
├─ lms.py # LMS JSON-RPC client: discovery, status, transport
├─ scripts/
│ ├─ stage-plugin.sh # payload file list, shared by installer + CI
│ └─ install-on-deck.sh # rsync dev install over SSH
├─ backend/
│ ├─ Dockerfile # glibc-matched Arch base, minimal build
│ └─ build.sh # clone squeezelite, compile, gather libs
├─ bin/ # build output, shipped with the plugin
│ ├─ squeezelite
│ └─ lib/*.so
├─ defaults/ # default config template
└─ README.md
Milestones
- Build spike (biggest risk). Docker build of a minimal squeezelite +
bundled libs.
scpto the Deck and confirm it plays from the LMS server before any plugin code exists. - Audio validation. Confirm
-o defaultfollows the PipeWire default sink across speaker/BT/wired/HDMI, and decide mixing-vs-ducking behavior with games. - Plugin skeleton. Bare Decky plugin that loads in game mode; toggle is a no-op.
- Backend wiring. Toggle → start/stop subprocess; status reporting.
- Config UI. LMS server + player name fields → persisted → applied as flags.
- Packaging & hardening. Install flow, survives reboot and SteamOS updates.
- Transport controls. LMS control API → now-playing panel with play/pause, next/prev, volume, and cover art. (done 2026-08-12)
- Release pipeline. CI-built plugin zip on a moving
latestrelease, installable via Decky's Install from URL. (done 2026-08-12)
Building the squeezelite bundle
The build is defined once in backend/build.sh and consumed two ways:
In CI (primary): .forgejo/workflows/build-squeezelite.yml runs the build
inside a debian:bullseye-slim job container (glibc 2.31, safely older than
SteamOS), then uploads squeezelite-bundle.tar.gz as an artifact. Trigger it on
push to backend/** or manually via workflow_dispatch (with an optional
squeezelite_ref to pin the source version). Requires a Forgejo Actions runner
with the container/docker backend.
Locally (optional, needs Docker/Podman):
docker build -t sqz-build backend/ --build-arg SQUEEZELITE_REF=master
docker create --name sqz sqz-build
docker cp sqz:/out ./bin
docker rm sqz
Output lands in bin/ (squeezelite + lib/*.so + VERSION). bin/ is
gitignored — it's a build artifact, bundled into the plugin at package time.
Validate on the Deck (before writing any plugin code):
DECK=deck@steamdeck.local LMS=<your-lms-host> scripts/test-on-deck.sh
This rsyncs bin/ to the Deck and runs squeezelite against your LMS server so
you can confirm it appears as a player and plays through the current output.
Validated on hardware (2026-07-10)
The build spike succeeded end-to-end on a real Steam Deck:
- The minimal bundle (
built_glibc_max=GLIBC_2.29, 11 bundled libs, ~2.1 MB) ran with no glibc/dependency issues. squeezelite -lreporteddefault - Default ALSA Output (currently PipeWire Media Server)— confirming-o defaultroutes through PipeWire as designed.- Autodiscovery found the LMS server with no
-saddress configured. - Audio played out of the Deck. Core strategy confirmed.
Notes for later: SSH to the Deck was password-based (deck@steamdeck-wired); a key
would make deploys non-interactive. XDG_RUNTIME_DIR=/run/user/1000 was required for
a non-interactive shell to reach PipeWire (already handled in test-on-deck.sh and the
plugin backend). Still to verify: mixing with game audio, and the root→deck drop when
launched by the real Decky backend.
Validated on hardware (2026-08-12)
Second round, testing the URL install and the transport panel:
- Installed from a release zip via Decky's Install from URL — no SSH, no
store. The
_ensure_executable()fix held; the player started normally. - Play/pause and volume both drive the LMS player correctly.
- The panel appears and disappears as the squeezelite service is toggled.
- Three UI problems found and fixed: CEF blocking LAN cover art, the D-pad not moving left/right across the transport buttons, and the volume field reading as an oversized separate box. All three are written up under "Transport controls" — each had a non-obvious cause.
Installing on the Deck (dev workflow)
The plugins dir (~/homebrew/plugins) is root-owned and deck has no
passwordless sudo, so install is: stage over SSH (key), then one sudo step on
the Deck to copy into place and reload the loader.
DECK=deck@steamdeck-wired scripts/install-on-deck.sh
This builds the frontend (pnpm run build), stages plugin.json + main.py +
lms.py + package.json + dist/ + bin/, rsyncs to the Deck, then sudo-installs to
~/homebrew/plugins/Squeezelite and restarts plugin_loader. Requires the
squeezelite bundle in bin/ (from backend/build.sh or CI). Per-plugin logs
land in ~/homebrew/logs/Squeezelite/ on the Deck.
Installing from a URL (no store needed)
Decky's Settings → General → Install from URL takes a plugin zip directly.
.forgejo/workflows/release-plugin.yml publishes one to a moving latest
release, so the download URL never changes:
https://git.theluthers.me/decky/decky-squeezelite/releases/download/latest/Squeezelite.zip
Paste that into Decky (Settings → General → Install from URL). Confirmed working on a real Deck. To update, paste it again — install-from-URL has no update mechanism of its own, which is the tradeoff versus running a custom store (see below).
The workflow builds squeezelite from source in the same bullseye container as
build-squeezelite.yml (so the glibc floor is unchanged), builds the frontend,
stages the payload via scripts/stage-plugin.sh, zips it as Squeezelite/…,
and re-points the latest tag at the new commit. The repo is publicly readable,
so the Deck needs no credentials to fetch it.
The exec-bit trap
Decky installs zips with Python's ZipFile.extractall(), which discards Unix
permission bits, and it only chmods the plugin directory and plugin.json —
not our bundled binary. Reproduced locally: bin/squeezelite goes in at 0755
and lands at 0644, non-executable, so the toggle would fail with a permission
error only on zip installs. The fix held on the first real URL install.
main.py:_ensure_executable() restores the bit at start time. The rsync dev
installer preserves permissions and never hits this, which is exactly why it's
easy to miss — don't "clean up" that function.
Custom store (alternative, not wired up)
Decky also supports Settings → General → Store Channel → Custom, pointing at a URL serving a JSON array. That would give real update notifications. Two reasons it's not the default here:
- The custom store replaces the official one rather than adding to it — you lose the normal Decky store while it's selected (issue #746).
- The store list is fetched by the frontend from Steam's
steamloopback.hostorigin, so the JSON would likely needAccess-Control-Allow-Originon the Forgejo side. Unverified.
If it's ever wanted: each version object takes an optional artifact field
(selectedVer.artifact ?? pluginUrl(hash) in decky-loader's store.tsx) that
can point straight at the release asset above, and the hash is verified when
non-empty (if hash and (zip_hash != hash) in browser.py).
Toolchain / prerequisites
- Node.js ≥ 16.14,
pnpmv9 - Docker (for the backend/binary build)
- Decky CLI
- A Steam Deck with Decky Loader for testing
Decided
- No auto-start. Toggle it on manually each time. The subprocess model fits this perfectly.
- Mix with game audio. Squeezelite runs as an ordinary PipeWire stream alongside the game.
- Player name is settable in the plugin UI.
Transport controls (LMS control API)
Built and validated on hardware (2026-08-12). The QAM panel shows now-playing metadata and transport controls whenever the player is running. Play/pause, next/prev, volume, cover art, and D-pad navigation all confirmed working on a real Deck in game mode, installed from a release zip.
The key subtlety: squeezelite does not expose MPRIS, or any control interface at all. It's a "dumb" player driven by the LMS server. So play/pause/next are issued to the server's JSON-RPC API targeting this player — there is no local bus or socket to talk to.
lms.py is that control channel: a dependency-free client for
http://<server>:9000/jsonrpc.js.
| Piece | Behavior |
|---|---|
| Server address | From config; blank triggers UDP autodiscovery (port 3483), the same protocol squeezelite uses. |
| Port handling | Bare host → 9000. An explicit :3483 is corrected to 9000 — that's the slimproto port, and it's sitting right above the field in the UI. |
| Player identity | Resolved to a MAC by matching the configured player name against ["players", 0, 99], then cached. |
| Commands | TRANSPORT maps UI action names (next, playpause, …) to LMS command arrays, so the frontend never speaks LMS. |
| Status | One ["status","-",1,tags:aldKcux] call, flattened to a dict: mode, title/artist/album, art URL, time, duration, volume, muted. |
| Cover art | Proxied through the backend as a data: URL — see below. |
Backend methods: lms_status, lms_transport(action), lms_set_volume(level),
lms_seek(seconds), lms_art(url). All blocking HTTP runs in an executor so a
3s timeout to a dead server can't stall Decky's event loop. lms_status never
raises — an unreachable server is a normal state for a player that's off most of
the time.
Cover art has to go through the backend
Steam's CEF blocks plain-http images from the LAN, so an <img> pointed at
http://<server>:9000/… silently never loads. Confirmed on hardware. Art is
fetched by lms.art_data_url() and handed to the UI base64-encoded as a data:
URL, refetched only when the track changes and cached one deep.
Two size fixes matter, because this crosses Decky's websocket on every track change:
- LMS resizes server-side, so we request
_200x200_o(displayed at 52px). - LMS also transcodes, so we ask for
.jpgregardless of the source extension. Spotify-sourced art arrives as PNG, and a 200px PNG was still 66KB.
Measured on a real track: 112KB → 16KB encoded. Falls back to the original URL if a server doesn't understand the size suffix.
Only URLs on the LMS host are fetched. Art for remote streams already arrives as
an /imageproxy/ link on the server, so nothing is lost, and it keeps a
root-run backend from fetching arbitrary hosts.
Why name-matching instead of -m
Passing squeezelite an explicit MAC would give a deterministic player ID, but it would also create a new player identity in an LMS that already has this Deck on file (with its own name, volume, and playlist). Name-matching leaves existing server state alone. The tradeoff: renaming the player in the LMS web UI breaks the match until the plugin's name field is updated to agree.
Panel behavior
Cover art, title, artist — album, a progress bar (hidden for streams with no
duration), prev/play-pause/next as gamepad-focusable buttons, and a volume
slider. Details that aren't obvious from the screenshot, most of them found by
testing on hardware:
- D-pad needs
Focusable— a flexdivlays the transport buttons out side by side, but Steam still navigates them as a vertical list: layout and focus order are independent.<Focusable flow-children="horizontal">is what makes left/right work. - Poll stacking — the 1s poll skips a tick if the previous request is still in flight, so a slow server can't queue up requests.
- Slider vs. poll — polled volume is ignored for 1.5s after a drag, and the set call is debounced 250ms, so the slider doesn't fight the server.
- Stopped ≠ paused — LMS's bare
pausetoggles but won't start a stopped player, so the button sendsplaywhenmode == "stop". - Volume field spacing —
SliderFieldwith alabelrenders a title row above the slider, which plusPanelSectionRow's separator reads as a separate bordered box. An icon instead of a label andbottomSeparator="none"fixes that, but removing the separator also removes the gap — so the transport row carries its ownmarginBottom. Change one and check the other.
Stretch goal: MPRIS bridge
With the LMS client in place, MPRIS becomes a second face on the same control
channel rather than a separate feature: publish org.mpris.MediaPlayer2.Player
on the session bus and map its methods onto lms.py. That would make Steam's
media overlay and any Decky MPRIS plugin work without knowing what LMS is.
Deliberately not done by bundling the distro package (slimpris2): it pulls
dbus-python, PyGObject, and an EOL libsoup 2.4 — compiled extensions bound
to a specific Python ABI, which is the same fight the binary bundling already
had to win. The path is a pure-Python D-Bus lib (dbus-next / jeepney)
vendored into the plugin dir, ~300 lines on top of what exists now.
Two known obstacles:
- The backend runs as root; MPRIS must be published on the
deckuser's session bus. Reuse_demote_and_env()and setDBUS_SESSION_BUS_ADDRESS. - Unverified: whether SteamOS game mode's media controls genuinely consume MPRIS off the session bus. A Decky MPRIS plugin is a guaranteed consumer either way, but the Steam-overlay payoff is what justifies the work — check before starting.
Open questions / TODO
- Confirm SteamOS's current glibc version to pick the Docker base image.
Whether to also expose autodiscovery (blank server)— yes;lms.discover()implements the UDP broadcast protocol, so a blank field works for both squeezelite and the control client.MAC-address stability for the player (— resolved by name-matching instead; see above.-m)Cover art is plain— CEF does block it;http://off the LANlms_artproxies the bytes as a data URL. See "Cover art has to go through the backend".Panel is build-verified only— validated in game mode 2026-08-12; the D-pad and volume-field issues it turned up are fixed and documented above.- No LMS auth support. If the server gets a username/password,
_rpcneeds HTTP Basic (~5 lines). - Seek is wired in the backend (
lms_seek, plusseek_fwd/seek_backinTRANSPORT) but has no UI — five buttons in that row would be cramped.
References
- Decky plugin template — https://github.com/SteamDeckHomebrew/decky-plugin-template
- Decky plugin dev guide (Deckbrew wiki) — https://wiki.deckbrew.xyz/en/plugin-dev/getting-started
- Squeezelite — https://github.com/ralph-irving/squeezelite
- SteamOS read-only filesystem — https://steamdecki.org/SteamOS/Read-only_Filesystem
- systemd-sysext on Steam Deck (alt. install approach) — https://blogs.igalia.com/berto/2022/09/13/adding-software-to-the-steam-deck-with-systemd-sysext/