Skip to content

Preset snapcast from server config, auto-import shared Spotify authorization - #1024

Open
FlatterAtMainz wants to merge 49 commits into
jaedb:masterfrom
FlatterAtMainz:pr-snapcast-spotify-config
Open

Preset snapcast from server config, auto-import shared Spotify authorization#1024
FlatterAtMainz wants to merge 49 commits into
jaedb:masterfrom
FlatterAtMainz:pr-snapcast-spotify-config

Conversation

@FlatterAtMainz

Copy link
Copy Markdown

Summary

Two related propagation gaps in how the server's [iris] config (get_config RPC) and the "Share configuration" feature (shared_config, set_shared_config/get_shared_config) reach a connecting browser's Redux state:

  • Snapcast: get_config already sends snapcast_host/port/ssl/stream/enabled to every connecting browser, but PUSHER_CONFIG's handler only ever applied country/locale/the three *_authorization_url fields — snapcast values arrived and were silently dropped. Fixed by seeding state.snapcast the same way, but as a dedicated one-time flag (ui.snapcast_config_seeded) rather than reusing the country/locale "seed only if still falsy" pattern: store/index.js's initialState hardcodes concrete snapcast defaults (host: 'localhost', port: '1780', enabled: false, ssl: false), unlike spotify.country/locale which genuinely start undefined — so a value-based guard can never fire for snapcast, not even in a brand new browser. Also had to route the enabled seed through snapcastActions.setEnabled() rather than a plain set(), since only setEnabled's middleware actually calls connect()/disconnect() as a side effect — App.js's own connect-on-boot effect only runs once (deps: [isReady]) and had already captured enabled=false by the time this async config response arrives, so a plain set() updated the flag correctly but never dialled the connection.
  • Spotify authorization: Iris already has a manual "Share configuration" flow that can push a Spotify OAuth authorization to the server (set_shared_config), broadcast live to every other browser connected to that instance (shared_config_changed) and fetched automatically on every connect (getSharedConfig() on PUSHER_CONNECTED) — but applying it required manually opening the notification/Settings prompt and clicking Import. PUSHER_SHARED_CONFIG now auto-imports a shared spotify.authorization the same way, but only into browsers that don't already have their own, so re-authenticating with Spotify in every new browser (within the same mopidy instance) is no longer necessary once someone has shared it once.
  • Also fixes a real pre-existing bug hit while wiring this up: importAuthorization(authorization) silently dropped a second me argument it was already being called with (Notifications.js's existing import flow), so action.me read by the SPOTIFY_IMPORT_AUTHORIZATION reducer case was always undefined.

All of this was verified against a live cluster (4 independent mopidy instances behind one Iris/spotify-auth setup) rather than just locally, including two real regressions caught by live testing in a fresh private-browser session and fixed in follow-up commits (both explained in their own commit messages) - the sentinel-value guard bug and the missing setEnabled() call.

Scope: this syncs within browsers connected to the same mopidy instance only. If you run multiple independent mopidy processes (e.g. one per room) with separate shared_config storage, authorizing on one instance does not propagate to the others - that would need a separate mechanism.

Test plan

  • Fresh/private browser, [iris] config has snapcast_host/enabled set: confirm Settings shows the right host and Snapcast auto-connects on load, without needing to manually toggle it off/on.
  • Confirm a snapcast host/port a user has manually customized is never overwritten by a later config response (one-time seed only).
  • Authorize Spotify in one browser, use Settings → Share configuration → check Server + Spotify → Send. In a second, never-before-authorized browser connected to the same instance, confirm it shows as authorized (not read-only) without going through the Spotify OAuth screen.
  • Confirm a browser that already has its own Spotify authorization is never overwritten by an incoming shared one.

nicolaikuntze-art and others added 30 commits June 20, 2026 13:18
… gst-plugins-rs

Cross-compiles the gst-plugins-rs spotify GStreamer plugin natively on the
amd64 build host (no QEMU) against vendored, locally editable librespot
(v0.8.0) and gst-plugins-rs (kingosticks/spotify-logging) sources, patched
together via Cargo's [patch.crates-io]. Final stage assembles a
debian:trixie-slim arm64 image with Mopidy 4 + Mopidy-Spotify 5 + extensions
for the Raspberry Pi 5 / k3s "snapserver" deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds disk-persist + serde(derive) to librespot-playback so AudioKeys can be
persisted under /var/lib/mopidy/cache/<file_id>.ext and reused across
restarts instead of re-requesting them from Spotify every time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sted PHP script

Deployed as spotify-auth in namespace snapserver (LoadBalancer IP
192.168.188.26), credentials injected from a Secret rather than hardcoded.
Removed the disabled TLS verification on the accounts.spotify.com calls and
made CLIENT_ID/CLIENT_SECRET/REDIRECT_URI configurable via env vars instead
of hardcoded PHP constants.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mpile

Dockerfile: apt-get/pip install were placed after the COPY of the
freshly-built libgstspotify.so, so any Rust rebuild (which happens on
every build) cache-busted the unrelated, expensive package installs too.
Reordered them before that COPY and added BuildKit cache mounts for
apt/pip. Verified: a second build after touching Rust source now shows
both layers as CACHED.

player.rs: append_track_json had three compile errors (wrong open()
arity, nonexistent `id` field, missing serde_json dependency) and would
have written one file per track forever even if it had compiled, since
AudioItem isn't Serialize and the file path was per-track despite using
append mode. Replaced with a small NowPlaying DTO appended as one JSONL
line per track change to a single shared log
(/var/lib/mopidy/cache/nowplaying.jsonl), intended for an external
display to tail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Spotify's Developer Dashboard rejects http:// redirect URIs (requires
https:// or a loopback literal). spotify-auth is LAN-only, so the existing
letsencrypt ClusterIssuers don't apply (they're HTTP-01 against the public
ingress). Instead: a dedicated self-signed root (k8s/internal-ca.yaml,
ClusterIssuer "internal-ca-issuer") issues a leaf cert for the spotify-auth
hostname (k8s/auth-server-certificate.yaml), mounted into the pod and
served via Apache's mod_ssl. SPOTIFY_REDIRECT_URI updated to https://.

internal-ca.crt is the root's public certificate, exported for import into
browsers/devices - no private key material in it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y 3.4.2 stack

Root cause (found via GST_DEBUG=librespot:6,spotifyaudiosrc:6 GST_DEBUG_LIBRESPOT=DEBUG
on a live pod): librespot's audio CDN fetch accepted whatever storage-resolve
returned as the first URL and gave up entirely on a non-206 response, instead
of trying the next candidate URL. Spotify's CDN intermittently returns 500 for
the first URL in the list, which made every single track fail with "track is
not available" regardless of librespot/gst-plugins-rs version, account
entitlement, or token scope - all of which were individually verified fine
along the way. Backported the fix from upstream PR
librespot-org/librespot#1722 (open, unmerged as of 2026-06-19) into
vendor/librespot/audio/src/fetch/mod.rs.

Also switched the Dockerfile from Mopidy 4 + Mopidy-Spotify 5.0.0 to
Mopidy==3.4.2 + Mopidy-Spotify==5.0.0a3, matching what's already running in
production (confirmed via `pip show` on live pods). Mopidy 4 deleted
mopidy.models.serialize.ModelJSONEncoder, which the current Mopidy-Iris
(3.70.0) still imports directly - Iris fails to load at all on Mopidy 4,
upstream incompatibility, not something fixable here. Mopidy-Local and
Mopidy-InternetArchive correspondingly pinned to their last Mopidy-3-compatible
releases (3.2.1 / 3.1.2); the current Mopidy-Local 3.2.1 still imports the
stdlib `imghdr` module, removed in Python 3.13 (PEP 594), so local file
browsing is currently broken on this image - tracked as a follow-up.

setuptools<81 pin carried over: Iris/YouTube still `import pkg_resources`,
removed from setuptools 81+.

Verified end-to-end on mopidy1 (k8s1:31320/mopidy_stable:test): both a regular
track and an audiobook-style track play and advance in real time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mopidy2-4 had conf/podcast volumeMounts missing from their containers
even though the volumes list still referenced them - they were silently
running on a blank default Mopidy config with Spotify disabled. Restored
to match mopidy1 and verified all four now load their real config and
play Spotify audio on k8s1:31320/mopidy_stable:latest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Expand the per-file_id JSONL log (written to /var/lib/mopidy/cache/<file_id>.jsonl)
with all available AudioItem metadata: format, bytes_per_second, is_explicit,
language, album_artists, popularity, track/disc number, and show_name for episodes.

Fix the prior bug where audio_item.file_id was referenced (field does not exist
on AudioItem); append_track_json is now called inside load_track where the actual
FileId and AudioFileFormat locals are in scope, and removed from start_playback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'sync' target (git pull --ff-only origin main) as a prerequisite
for 'build' and 'push', so both targets always start from the latest
committed code. --ff-only aborts loudly if local and remote have diverged
instead of silently creating a merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- vendor/librespot/.upstream-ref: records v0.8.0 (the tag the vendor is based on)
- vendor/gst-plugins-rs/.upstream-ref: records the spotify-logging branch
- Makefile: init-upstreams clones/fetches upstream repos into sibling dirs
  (../librespot-upstream, ../gst-plugins-rs-upstream) to avoid the nested-git
  conflict that would arise inside vendor/
- Makefile: update-librespot and update-gst-plugins-rs show new upstream
  commits and a file diff so you can review before manually integrating

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In librespot v0.8.0, SpotifyUri::to_id() returns String (not Result),
so the ? operator in spotifylyricssrc/imp.rs:321 was invalid. Drop the ?
after .to_id() to match the current API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dockerfile:
- Split rust-cross-builder into rust-toolchain (apt + rustup) and
  rust-cross-builder (COPY + cargo). The toolchain layers are now
  independent of source changes, so apt/rustup only re-run when the
  toolchain definition itself changes.
- Add --mount=type=cache to the apt install in rust-toolchain, matching
  the final stage's pattern. Package downloads survive cold layer cache.

Makefile:
- Add --cache-from / --cache-to type=registry to build and push targets.
  The buildx builder stores cache in its container; this exports it to
  REGISTRY/mopidy_stable:buildcache so layers survive builder restarts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both extensions import `imghdr`, which was removed from the Python 3.13
standard library (debian:trixie-slim). They fail at startup with
ModuleNotFoundError, and the Local extension failure leaves Mopidy core
in a state that breaks Iris connectivity.

Neither is used in this Spotify-based setup, so removing them is the
clean fix rather than adding a third-party imghdr backport.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v0.9.0 vendor update (37c1d42) overwrote player.rs with upstream,
erasing two sets of local modifications:

1. Audio key disk cache (DiskPersist<DataSer>): avoids a round-trip to
   the Spotify CDN for the decryption key on every play by persisting it
   to /var/lib/mopidy/cache/<file_id>.ext between sessions.

2. Per-file_id metadata log (append_track_json): writes a JSONL entry to
   /var/lib/mopidy/cache/<file_id>.jsonl on each play with full track
   metadata (name, artists, album, format, bitrate, etc.).

Re-applies both patches against the v0.9.0 source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
patches/librespot/player.patch and patches/gst-plugins-rs/spotifylyricssrc-imp.patch
capture all local modifications relative to the upstream vendor state, so
they survive future upstream syncs.

New Makefile targets:
- integrate-librespot / integrate-gst-plugins-rs: rsync upstream HEAD into
  vendor/ then immediately re-apply the stored patches (patch -p1 --fuzz=3)
- apply-patches: apply all patches without rsync (e.g. after a bare rsync)
- save-patches: regenerate .patch files from current vendor state vs upstream
  HEAD (requires init-upstreams; run after manually resolving conflicts)

update-librespot / update-gst-plugins-rs now print a hint pointing to the
integrate target so the full workflow is self-documenting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The v0.9.0 vendor update erased the three dependencies our local patches
require from playback/Cargo.toml:
  disk-persist = "0.1"    # audio-key disk cache
  serde / serde_json "1"  # per-track metadata JSONL serialisation

Also adds patches/librespot/playback-Cargo.patch so future integrate-librespot
and apply-patches targets restore these deps automatically alongside the
player.rs patch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.output-control__inner capped its height with max-height on mobile but
used overflow-y: visible, so a snapserver device/group list taller than
the popup just overflowed off-screen with no way to scroll to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vendors github.com/FlatterAtMainz/Iris under vendor/iris (same pattern
as the librespot/gst-plugins-rs vendoring, minus a patches/ layer since
it's our own fork rather than a third-party upstream). The Dockerfile
gains an iris-builder stage that runs npm ci/npm run prod and packages
a wheel, which the final stage now installs in place of the PyPI
Mopidy-Iris package. Makefile gets matching update-iris/integrate-iris
targets for pulling in future fork changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
max-height: calc(100vh - 140px) sizes the popup using the full
layout viewport, which on mobile includes space the browser's
address bar can occupy. When the address bar is visible, the
actually-visible viewport is shorter than 100vh, so the popup
(anchored to the bottom, growing upward) extends above what's
visible, hiding its top row. Add a 100dvh override, which tracks
the real visible viewport, after the 100vh fallback for browsers
that don't support dvh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ess bar

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
On mobile the popup only auto-opens via PlaybackControls expanding
(force_expanded=true), which also hides the popup's own close button
(display:none in CSS) and suppressed its own click-outside overlay.
That left no way to dismiss just the output-control popup - only
tapping in the empty space above it (collapsing the whole playback
bar) worked. Always render the click-outside overlay regardless of
force_expanded so the popup can close independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Errors surfaced through handleException often ended up either blank
or as a bare technical fragment (e.g. "Could not load your profile"
with no explanation, or a raw "SyntaxError: JSON.parse: unexpected
end of input"). Add a humanizeError util that recognises common
failure shapes (invalid/empty JSON responses, network failures,
expired/revoked OAuth grants, known HTTP status codes) and prefixes
the description with a plain-language explanation, applied centrally
in the HANDLE_EXCEPTION handler so every existing call site benefits.

Also:
- handleException now falls back to a plain Error object's .message
  for the description, and no longer throws when data.error is
  undefined (message derivation had the same unguarded access).
- Wrap the JSON.parse of xhr.responseText in HANDLE_EXCEPTION in a
  try/catch - a non-JSON error response would otherwise throw inside
  the exception handler itself.
- Spotify getMe() and refreshToken() failures now get specific
  messages pointing at expired/revoked authorization, since that was
  the actual root cause behind the vague errors we hit in practice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vendored from a local merge of fix/mobile-output-control-popup and
fix/error-message-context (both open as separate PRs upstream, since
they're unrelated concerns).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Kaniko (what this cluster's Kubernetes-executor runner uses, since it
has no privileged/DinD access for real docker buildx) ignores FROM
--platform=$BUILDPLATFORM entirely and just builds as its own pod's
real host arch - confirmed against Kaniko's own docs/issue tracker
before touching anything, not assumed. Since every node here is arm64,
that means a Kaniko build of this Dockerfile was already effectively
native, but the Rust build stage's hardcoded --target
aarch64-unknown-linux-gnu cross-compilation (gcc-aarch64-linux-gnu
toolchain and all) would still have tried to run as if cross-compiling
FROM some other arch, on a host that already IS the target - at best
redundant, at worst broken.

Both Rust-toolchain-install and cargo-build steps now branch on
`dpkg --print-architecture` vs TARGETARCH (defaulted to arm64, and set
explicitly in .gitlab-ci.yml, since Kaniko doesn't auto-populate this
build-arg the way buildx does) at RUN-time: native build with no cross
toolchain when they already match (this cluster's CI, always), the
original cross-compilation path when they don't (still available for
anyone building from an amd64 dev machine via buildx).

Added .gitlab-ci.yml: builds mopidy_stable + spotify-auth via
cluster/ci-templates, force-restarts mopidy1-4 and spotify-auth
(mirroring the Makefile's existing `make deploy` scope, and covering
spotify-auth which has no keel.sh annotation at all).
… optional chaining

- Humanized error explanations now live under errors.* in en.yaml
  (via i18n()) instead of being hardcoded in JS, so they're
  translatable like the rest of the UI.
- Replace `x && x.y` null checks with optional chaining per review
  feedback.
…onal chaining

Addresses upstream PR review feedback (jaedb) on the plain-language
error notification work: humanized explanations now live in en.yaml
under errors.* instead of hardcoded JS strings, and null-checks use
optional chaining.
The 2026-08-05 build hung for a full hour after the job pod's node
(k8s6) briefly lost kubelet connectivity mid-build, breaking the
runner's log stream - the job itself wasn't slow, GitLab just had no
way to notice the connection had died. 30m still leaves headroom for
a legitimately slow cold build (no cross-run Kaniko cache, armv8 Pi
CPUs, full toolchain + dependency rebuild every run) while failing
fast on a repeat of the same failure mode.
…ream clones

Vendors our FlatterAtMainz/mopidy fork (branch stream-error-fix-v4.0.1,
commit 4bcdfe8f) into vendor/mopidy, same pattern as vendor/iris: no
patches/ layer, changes tracked directly in the fork's own git history.
This carries the GStreamer stream_error auto-recovery fix built earlier
against the v4.0.1 release tag.

Also moves the librespot, gst-plugins-rs, and Iris upstream clones (used
by the update-*/integrate-* Makefile targets) from loose siblings in the
parent Cluster/ directory into upstream/ inside this repo, gitignored,
so the whole build's inputs live in one place instead of scattered
across an unrelated projects directory.
Replaces the PyPI-installed Mopidy==3.4.2 pin with a pip install of
vendor/mopidy (our stream_error-patched v4.0.1 fork), now that vendor/iris
carries the upstream fix for Mopidy 4's removed ModelJSONEncoder - the
thing that previously blocked this migration. Mopidy-Spotify and
Mopidy-InternetArchive move to their current stable releases (5.0.0 /
4.0.1), the first of each requiring Mopidy>=4.0.0.

Verified in isolation (debian:trixie-slim, native arm64 build): the
vendored source builds and installs cleanly via
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_MOPIDY, both extensions import, and
the mopidy CLI runs. Full image build with Iris + the Rust GStreamer
plugin is the next step.
Without it, the build context included upstream/ (500MB+ of nested git
clones, mostly the Iris checkout) now that those live inside this repo
instead of as siblings - none of it is referenced by the Dockerfile.
…ailures

getSearchResults never resolved its process notification on request
failure or on cancel-button click, leaving it stuck in the UI forever.
The five Spotify library-fetch actions had the same underlying gap
(no rejection handler at all), silently swallowing errors and leaking
stuck process state on any failed request.
The fetch handler served every cached response indefinitely with no
invalidation path: the cache is only purged in 'activate', which only
fires when the service worker script's own bytes change - something a
plain webpack rebuild of app.min.js/app.min.css never does. In
practice, once a browser cached the app bundle on first visit, it kept
serving that exact bundle forever, silently masking every subsequent
deploy (bug fixes included) until a user manually cleared the service
worker/cache storage.

Flip to network-first: always attempt a live fetch first (still
caching successful GET responses under the same blacklist rules as
before), and only fall back to the cache when the network request
itself fails. The cache now serves its intended purpose - an offline
fallback - without being able to hide live updates from users who are
online.
jaedb/Iris's master branch never received jojo141185's mopidy-alpha-
compatibility fix (jaedb#1003) - it only landed on upstream's
develop branch, which this fork doesn't track. This was previously
applied by hand directly in custom-mopidy's vendored copy, bypassing
this repo entirely, which meant a routine re-sync silently reverted it.
Cherry-picking here makes this repo authoritative again.

(cherry picked from commit 4954f6d)
The manual rsync + .upstream-ref tracking let this directory silently
diverge from its source repo twice in one week (once when independent
Mopidy-4 work landed on main without a corresponding Iris/ commit, once
when a hand-applied core.py patch got clobbered by a routine re-sync
because it only ever existed here, not upstream). Converting to a git
subtree in the next commit so there's exactly one copy of this code,
not two that need to be kept in sync by hand.
…auth

Two related propagation gaps, both surfaced by the mopidy.conf config
centralization: the server's get_config() RPC already sends
snapcast_host/port/ssl/stream/enabled to every connecting browser, but
PUSHER_CONFIG's handler only ever applied country/locale/the three
*_authorization_url fields - snapcast values arrived and were silently
dropped. Fixed by extending the same guarded first-time-seed pattern
already used for country/locale (never clobbers a value the user set
deliberately).

Separately: Iris already has a manual "Share configuration" flow that
can push a Spotify OAuth authorization to the server (set_shared_config),
broadcast live to every other browser connected to that instance, but
applying it required the user to manually open the notification/Settings
prompt and click Import. Now PUSHER_SHARED_CONFIG auto-imports a shared
spotify.authorization the same way, but only into browsers that don't
already have their own - so re-authenticating with Spotify in every new
browser (within the same mopidy instance) is no longer necessary.

Also fixes a real pre-existing bug hit while wiring this up:
importAuthorization(authorization) silently dropped a second `me`
argument it was already being called with (Notifications.js's existing
import flow), so action.me read by the SPOTIFY_IMPORT_AUTHORIZATION
reducer case was always undefined.

Scope: this syncs within browsers connected to the same mopidy instance
only - mopidy1-4 remain 4 independent processes with separate
shared_config storage, so authorizing on one instance does not (yet)
propagate to the others.
Confirmed broken by live testing in a private browser window: neither
snapcast_host nor snapcast_enabled ever got applied. Root cause -
store/index.js's initialState hardcodes concrete snapcast defaults
(host: 'localhost', port: '1780', enabled: false, ssl: false), unlike
spotify.country/locale/authorization which genuinely start undefined.
The guard pattern this borrowed from country/locale ("only seed if
still undefined") can never fire for snapcast, because none of those
fields are ever undefined - not even in a brand new browser.

Replaced with a dedicated one-time flag (ui.snapcast_config_seeded),
the same pattern already used for ui.initial_setup_complete, which
persists correctly across reloads since 'ui' isn't blacklisted from
redux-persist. Confirmed the Spotify authorization auto-import
(PUSHER_SHARED_CONFIG) is unaffected - spotify.authorization has no
hardcoded default, so that guard was correct as originally written.
Confirmed by live testing: snapcast_enabled did get set to true (the
previous fix), but the UI still showed Disconnected until manually
toggling the enabled checkbox off and back on. Root cause - the seed
dispatched a plain snapcastActions.set({enabled: true}), but only
SNAPCAST_SET_ENABLED's middleware actually calls connect()/disconnect()
as a side effect. App.js's own connect-on-boot effect only runs once
(deps: [isReady]) and had already captured enabled=false - the
hardcoded initialState default - by the time this async PUSHER_CONFIG
response arrived, so nothing ever dialled the connection despite the
flag being correct in state.

Switched the enabled seed to snapcastActions.setEnabled(), which goes
through that same middleware and triggers connect(). Connection detail
fields (host/port/ssl/stream) are dispatched first, since
SNAPCAST_CONNECT reads them live from state at dispatch time - they
need to already be correct before setEnabled(true) can connect to the
right server.
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.

2 participants