Skip to content

feat: AirPlay from Safari to Sonos - #11

Merged
betmoar merged 13 commits into
mainfrom
feat/airplay-sonos
Jul 29, 2026
Merged

feat: AirPlay from Safari to Sonos#11
betmoar merged 13 commits into
mainfrom
feat/airplay-sonos

Conversation

@betmoar

@betmoar betmoar commented Jul 29, 2026

Copy link
Copy Markdown
Owner

This pull request introduces a frontend-only design and implementation for AirPlay support from Safari to Sonos speakers, allowing users to route only Hum's audio (not all system audio) to a speaker via a dedicated AirPlay button in the player UI. The changes include a detailed design spec, a new AirPlay icon, Svelte component updates to support the AirPlay button, and state management to ensure the feature is available only when supported and appropriate. The implementation follows a reviewed design, handles edge cases like live playback and connection loss, and ensures robust feature detection and testing.

Design documentation and review:

  • Added a comprehensive design spec for AirPlay from Safari to Sonos, detailing the technical approach, UI/UX, fallback strategies for live playback, feature detection, and a manual test checklist. The spec includes a falsifier for the no-server-change premise, pass/fail contracts for each fallback option, and incorporates feedback from a 3-lens GLM review panel. [1] [2]
  • Updated CHANGELOG.md to reflect the addition of the AirPlay design spec and its review.

UI and iconography:

  • Added an 'airplay' icon to the Icon.svelte component, including its SVG path and type definition. [1] [2]

Player and NowPlaying component updates:

  • Implemented AirPlay support in Player.svelte:
    • Integrated a new createAirplayControl utility to manage AirPlay state and interactions.
    • Attached AirPlay control to the <audio> element, with lifecycle management per Apple's battery guidance.
    • Managed AirPlay capability state reactively to gate button visibility and ensure both Player and NowPlaying surfaces update appropriately.
    • Rendered the AirPlay button in the transport controls, reflecting route state and handling user interaction via the shared control. [1] [2] [3] [4] [5]
  • Updated NowPlaying.svelte to include the AirPlay button, using the shared playerControls method and gating visibility based on AirPlay capability.

These changes collectively enable a robust, user-friendly AirPlay experience for Safari users, with careful attention to platform limitations, user expectations, and maintainability.

betmoar added 13 commits July 29, 2026 15:47
Targets 0.1.2. Frontend-only feature: an AirPlay button in the player
transport so Hum's audio alone routes to a speaker, instead of the
system-wide macOS Sound output route that also carries notification
dings and adds latency.

Central finding: AirPlay to a *speaker* is a source-encoded push, not a
URL handoff — the Mac re-encodes to ALAC and streams to the receiver,
which never fetches a Hum URL. So no bind-address change, no new route,
no signed-URL type, no invariant touched. That premise is inferred from
protocol documentation rather than measured against this app, so
verifying it is Task 0; if it's wrong the scope grows and the design
needs revisiting rather than patching.

The hard part is live playback: hls.js uses MSE, which gives the element
a blob URL that AirPlay cannot route. Spec commits to trying hls.js's
MEDIA_ATTACHING workaround first, falling back to forcing the native-HLS
branch, and hiding the button for live tracks if neither holds — never
the silent failure of a button that appears to work and doesn't.

Not yet through the review panel.
Three-lens panel (ambiguity, contradictions/feasibility, testability).
Lens B verified every code citation — all held, no contradictions.
Findings clustered on missing acceptance criteria, not wrong facts.

Body edits (the four must-resolve + should-clarify from the panel):
- §Problem: Task 0 gets an explicit falsifier — pass = audio plays from
  Sonos with HOST unchanged and no fetch to a 127.0.0.1 URL; fail = such
  a fetch appears.
- §1: disconnect mid-playback defined (button reverts, local playback
  continues at position, no auto-re-engage) and track-skip-while-routed
  defined (route persists; verified as a src-swap case alongside Task 3).
- §2: expiry recovery swaps from "or accept the drop" to auto-re-assert.
- §3: per-option pass/fail contract — A/B pass iff target connected +
  audible 60s with no fatal Hls.Error; C unconditional.
- §Testing: the "manual is the real gate" line becomes a binary-outcome
  checklist with a named Sonos model recorded; feature-detect contract
  (minimum API subset to render vs be functional); vitest scope states
  what it observably proves and what it doesn't.

Four product decisions recorded in Clarifications: keep routing across
track-skip, auto-re-assert on expiry, full A→B→C chain (dev machine is a
Mac — Safari local, so validation isn't hardware-blocked), and a new
playerControls.showPlaybackTargetPicker entry point for both surfaces.

Run report at .review-panel/2026-07-29-airplay-to-sonos-design.md.
- Add an [Unreleased] entry for the AirPlay-from-Safari-to-Sonos design
  spec landed on this branch (frontend-only, GLM-panel-reviewed).
- Fold the four orphaned Added bullets into the first ### Added block
  under [0.1.1] (they were duplicated across two Added headings after
  the Unreleased->0.1.1 promotion; this should have landed on main with
  the release but slipped onto this branch).
…rplay class

Final whole-branch review found the NowPlaying AirPlay button rendered
unconditionally — on Chrome/Firefox/Android and on live tracks it showed a
button that no-ops on click (showPicker is a safe no-op when unsupported).
That is the silent failure the spec forbids.

Fix: Player derives a single airplayCapable flag (supported AND target
available AND not a live track) and mirrors it onto playerControls.
NowPlaying gates its button on playerControls.current?.airplayCapable, the
same conditions Player already used internally. showPlaybackTargetPicker
stays defined (it no-ops safely) but the button no longer lies.

Also dropped the dead .airplay CSS class from both surfaces (no rule ever
existed for it); the existing .mode / .mode.active styles cover the button.

Tests: Player gains a contract test that airplayCapable is falsy when
unsupported; new NowPlaying.test.ts asserts the button is absent when the
flag is falsy and present when true. 186 frontend tests pass (was 183).
…bug)

Independent pre-merge review found a load-bearing reactivity bug: airplayCapable
lived on playerControls, which is a deliberately NON-reactive plain object (the
imperative method handle — see store.svelte.ts:26). Player's $effect set the
flag after the AirPlay availability event arrived post-mount, but NowPlaying's
template had no signal tracking that read, so the overlay's button never
re-rendered into existence. Player worked (local $derived); NowPlaying didn't.
The prior test passed only because it set the flag before render, not the
post-mount mutation that actually happens.

Fix: airplayCapable is now a field on the reactive store.player ($state).
Player's $effect writes it; both Player and NowPlaying read it reactively.
playerControls stays the documented plain imperative handle (showPlaybackTargetPicker
etc.) — capability state belongs in the store, not on an imperative ref.

Test: NowPlaying.test.ts now mutates store.player.airplayCapable AFTER mount
and asserts the button appears — the exact lifecycle C1 broke. This would have
caught the original bug.

Also removed a tests/probe.test.ts scratch file the reviewer left behind (bad
import path broke svelte-check; untracked, not part of the feature).
From the independent pre-merge review:
- I2: Player.test.ts had only negative gating tests (hidden when
  unsupported/live). Added a positive test: mock the picker onto the
  prototype, fire the availability event with "available", assert the
  button appears, click it, assert showPicker is called. A flipped
  conjunction (|| for &&) would now fail CI.
- M1: `airplay.state.available !== false` simplified to `airplay.state.available`
  (the field is a boolean init false; the looser form implied a third state
  the types can't express).
- M3: dropped dead `isSupported()` (exported, never called — Player uses
  isSupportedEl directly).
- M4: dropped unused `Window.WebKitPlaybackTargetAvailabilityEvent` declaration
  (the runtime never references it; feature-detection uses the picker fn only).
@betmoar
betmoar merged commit 0f8f56e into main Jul 29, 2026
3 checks passed
@betmoar
betmoar deleted the feat/airplay-sonos branch July 29, 2026 18:55
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.

1 participant