Squeezelite (Lyrion/LMS player) control for Steam Deck via Decky Loader
  • Python 50.3%
  • TypeScript 26.9%
  • Shell 19.7%
  • Dockerfile 2.9%
  • JavaScript 0.2%
Find a file
eman 88c5e6c027 docs: record hardware validation of the transport panel and URL install
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>
2026-08-12 10:45:03 -07:00
.forgejo/workflows Add LMS transport controls, and publish an installable plugin zip 2026-08-12 01:15:24 -07:00
backend build: add -I/usr/include/opus so opusfile.h resolves on Debian 2026-07-10 17:05:42 -07:00
scripts Add LMS transport controls, and publish an installable plugin zip 2026-08-12 01:15:24 -07:00
src Add breathing room under the transport buttons 2026-08-12 10:21:19 -07:00
.gitignore Add LMS transport controls, and publish an installable plugin zip 2026-08-12 01:15:24 -07:00
lms.py Fix cover art, D-pad navigation, and volume field layout 2026-08-12 10:14:50 -07:00
main.py Fix cover art, D-pad navigation, and volume field layout 2026-08-12 10:14:50 -07:00
package.json Scaffold Decky plugin: frontend panel + backend subprocess control 2026-07-10 16:32:52 -07:00
plugin.json Scaffold Decky plugin: frontend panel + backend subprocess control 2026-07-10 16:32:52 -07:00
pnpm-lock.yaml Add repeatable deck install script; docs: MVP working on hardware 2026-07-10 20:03:33 -07:00
README.md docs: record hardware validation of the transport panel and URL install 2026-08-12 10:45:03 -07:00
rollup.config.js fix frontend build: default import from @decky/rollup, self-contained tsconfig 2026-07-10 17:45:20 -07:00
tsconfig.json fix frontend build: default import from @decky/rollup, self-contained tsconfig 2026-07-10 17:45:20 -07:00

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 latest release, so the plugin installs straight into Decky with no store and no SSH. See "Installing from a URL" below.

Goal

  • Install a self-contained squeezelite binary 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). squeezelite is 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 810 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 default follows 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:

  1. Acquire — Docker build produces a minimal, glibc-compatible squeezelite
    • its codec/ALSA .sos, checked in / released as bin/.
  2. 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.
  3. 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

  1. Build spike (biggest risk). Docker build of a minimal squeezelite + bundled libs. scp to the Deck and confirm it plays from the LMS server before any plugin code exists.
  2. Audio validation. Confirm -o default follows the PipeWire default sink across speaker/BT/wired/HDMI, and decide mixing-vs-ducking behavior with games.
  3. Plugin skeleton. Bare Decky plugin that loads in game mode; toggle is a no-op.
  4. Backend wiring. Toggle → start/stop subprocess; status reporting.
  5. Config UI. LMS server + player name fields → persisted → applied as flags.
  6. Packaging & hardening. Install flow, survives reboot and SteamOS updates.
  7. Transport controls. LMS control API → now-playing panel with play/pause, next/prev, volume, and cover art. (done 2026-08-12)
  8. Release pipeline. CI-built plugin zip on a moving latest release, 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 -l reported default - Default ALSA Output (currently PipeWire Media Server) — confirming -o default routes through PipeWire as designed.
  • Autodiscovery found the LMS server with no -s address 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.host origin, so the JSON would likely need Access-Control-Allow-Origin on 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, pnpm v9
  • 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 .jpg regardless 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 flex div lays 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 pause toggles but won't start a stopped player, so the button sends play when mode == "stop".
  • Volume field spacingSliderField with a label renders a title row above the slider, which plus PanelSectionRow's separator reads as a separate bordered box. An icon instead of a label and bottomSeparator="none" fixes that, but removing the separator also removes the gap — so the transport row carries its own marginBottom. 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 deck user's session bus. Reuse _demote_and_env() and set DBUS_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 (-m) — resolved by name-matching instead; see above.
  • Cover art is plain http:// off the LAN — CEF does block it; lms_art proxies 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, _rpc needs HTTP Basic (~5 lines).
  • Seek is wired in the backend (lms_seek, plus seek_fwd/seek_back in TRANSPORT) but has no UI — five buttons in that row would be cramped.

References