feat: AirPlay from Safari to Sonos - #11
Merged
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
CHANGELOG.mdto reflect the addition of the AirPlay design spec and its review.UI and iconography:
Icon.sveltecomponent, including its SVG path and type definition. [1] [2]Player and NowPlaying component updates:
Player.svelte:createAirplayControlutility to manage AirPlay state and interactions.<audio>element, with lifecycle management per Apple's battery guidance.PlayerandNowPlayingsurfaces update appropriately.NowPlaying.svelteto include the AirPlay button, using the sharedplayerControlsmethod 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.