Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.1.2] - 2026-07-29

### Added

- **AirPlay from Safari to AirPlay 2 speakers** (e.g. Sonos): a per-element
AirPlay button in the player transport routes Hum's audio alone to the
speaker, replacing the system-wide macOS Sound Output route (which also
carries notification audio and adds scrubber latency). The button appears in
both the mini-player and the expanded now-playing overlay, gated on AirPlay
support and target availability. VOD playback routes over the existing signed
native-HLS URL; live tracks honestly hide the button (hls.js/MSE output can't
be AirPlay-routed). Frontend-only — no server changes, the speaker never
fetches a Hum URL (AirPlay-to-speaker is a source-encoded push). Verified
end-to-end against a Sonos Move.

## [0.1.1] - 2026-07-29

### Added
Expand Down Expand Up @@ -91,6 +106,7 @@ First tagged release of Hum — a self-hosted YouTube audio streamer.
3.11/3.12) and frontend (svelte-check, vitest, vite build); tag-triggered
release workflow.

[Unreleased]: https://github.com/betmoar/hum/compare/v0.1.1...HEAD
[Unreleased]: https://github.com/betmoar/hum/compare/v0.1.2...HEAD
[0.1.2]: https://github.com/betmoar/hum/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/betmoar/hum/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/betmoar/hum/releases/tag/v0.1.0
813 changes: 813 additions & 0 deletions docs/superpowers/plans/2026-07-29-airplay-to-sonos.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Panel run — 2026-07-29-airplay-to-sonos-design.md (2026-07-29 15:51)

- **artifact:** docs/superpowers/specs/2026-07-29-airplay-to-sonos-design.md
- **reviewer:** glm-review-design **N:** 3
- **lenses:** A ambiguity · B contradictions/feasibility · C testability
- **per-lens:** A → 13 findings · B → 2 · C → 13 (tokens: none reported by agents; omitting per-skill "never fabricate")
- **buckets:** must-resolve 4 · should-clarify 7 · consider ~8 · dropped <50: 0 (all reports were already scoped)
- **asked:** 4 should-clarify → answers in the artifact's Clarifications section
- **verdict:** GLM first pass. No code-vs-claim contradictions (lens B clean). Findings cluster on two missing artifacts: a falsifier for Task 0, and explicit acceptance/per-option pass-fail. One live-strategy answer (lens C Q3) reframed the build sequencing — see Clarifications.

## Findings

### must-resolve
- [85] Task 0 "verify empirically" has no falsifier (lens A:38-41, lens C:37-41). Verdict confirmed by two lenses. → RESOLVE in spec: pass = audio plays from Sonos with HOST=127.0.0.1 unchanged; fail = Safari/Sonos attempts a fetch to a 127.0.0.1 URL it would have to resolve.
- [80] Option A→B→C sequencing has no per-option pass/fail signal (lens A:121-123, lens C:121-123). → RESOLVE in spec: define the observable that triggers fallback (target connected + no audio within Ns, or fatal Hls.Error).
- [78] AirPlay disconnect mid-playback undefined (lens A:96-123). → RESOLVE in spec: button reverts to inactive via the changed event; local playback continues at current position; no stall.
- [75] Manual gate has no acceptance language (lens C:131-135). → RESOLVE in spec: binary-outcome checklist with a named Sonos model recorded alongside results.

### should-clarify (→ asked)
- [72] Track-skip while routed — undefined (lens A:96-104). → **A:** Keep routing (route persists across track changes).
- [70] Expiry-induced src swap drops the route (lens A/C:90-94, 127-130). → **A:** Auto re-assert after the recovery swap.
- [68] Live A→B→C sequencing / hardware availability (lens C gaps, lens B consider). → **A:** dev machine is a Mac (Safari local) — build full A→B→C chain, validate locally; C-first MVP rationale withdrawn.
- [65] AirPlay picker entry point across both surfaces (lens B:78-80). → **A:** new playerControls method (showPlaybackTargetPicker).
- [60] Feature-detect contract for partial WebKit support (lens A:144, C:144). [hold for spec edit]
- [60] Vitest scope — what "our logic" observably proves (lens C:127-130). [hold for spec edit]
- [60] Manual gate acceptance wording (lens C:131-135). [overlaps must-resolve]

### consider
- [58] ALAC 16/44.1 vs lossy ≤48kHz source — one-line note (lens A:27-35).
- [55] Battery-listener lifecycle tied to element mount vs playback (lens A:66-70).
- [55] "survives past expiry" wording — expiry is on signed URL not route (lens A:90-94).
- [52] Sonos model/firmware baseline recorded with results (lens C gap).
- [50] iOS "no in-page picker" assertion uncited (lens A:43-51).
- [50] NowPlaying querySelector('audio') path sufficient for the action or needs the new method (lens B:78-80) — resolved by the picker-method answer.

### dropped <50
- (none — all three reports were already scoped; dedup removed rest)
257 changes: 257 additions & 0 deletions docs/superpowers/specs/2026-07-29-airplay-to-sonos-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
# AirPlay from Safari to Sonos — design

Date: 2026-07-29
Status: draft, pending maintainer approval

## Problem

Playing Hum through a Sonos speaker today requires macOS System Settings → Sound
→ Output, which routes **all** system audio (notification dings included) and
adds latency that disagrees with the scrubber. What's wanted is a per-element
route: an AirPlay button in Hum's own transport that sends only Hum's audio to
the speaker.

Safari exposes this, but Hum's `<audio>` element has no `controls` attribute
(`frontend/src/components/Player.svelte:416-426`) — the app draws its own
transport — so Safari never renders the native media bar where its free AirPlay
button lives. The picker has to be driven from our own UI.

## What AirPlay actually does here (constrains everything below)

AirPlay to a **speaker** is a source-encoded push, not a URL handoff: the Mac
decodes our stream, re-encodes to ALAC (capped 16-bit/44.1 kHz), and streams to
the Sonos itself. The speaker never fetches our URL. This is the opposite of
Chromecast, and different from AirPlay video-to-AppleTV, which *is* a URL
handoff.

Two consequences that shape the whole design:

1. **No server changes are needed.** `HOST` stays `127.0.0.1`
(`app/config.py:21`); the Sonos never resolves a Hum URL, so signed URLs, the
host allowlist, and all three invariants are untouched. This is a
frontend-only feature.
2. **MSE output cannot be routed.** hls.js feeds the element a blob URL, and
AirPlay has nothing to hand off or re-encode from a blob in the way the
element-level route expects. This is the live-playback problem in §3.

⚠️ Consequence 1 is inferred from documented protocol behavior, not measured on
this app. **Task 0 of the plan is to verify it empirically** before building on
it, with an explicit falsifier:

- **Pass:** with `HOST=127.0.0.1` unchanged, AirPlay a VOD track from Safari to
the Sonos — audio plays. (The speaker never fetched a Hum URL.)
- **Fail:** Safari or the Sonos attempts a request to a `127.0.0.1` URL it
would have to resolve — i.e. the route only works if the receiver can reach
the origin. Watch the browser network panel and the Sonos for any such fetch.

If Task 0 fails, the scope grows substantially (bind address, CORS origins,
signed-URL audience) and this design should be revisited rather than patched.

## Scope

In scope: an AirPlay button in the Player transport, availability-gated, for
**VOD playback**. Live playback gets a defined, honest behavior (§3) rather than
a silent failure.

Out of scope: Chromecast, multi-room grouping, AirPlay from iOS Safari (iOS has
no in-page picker; the system Control Center route is the answer there), and any
attempt to improve audio quality over the AirPlay path (protocol-capped).

## Section 1 — The button

Four WebKit APIs on `HTMLMediaElement`, none in `lib.dom.d.ts`:

| API | Use |
|---|---|
| `webkitShowPlaybackTargetPicker()` | Opens the native picker. Requires a user gesture. |
| `webkitplaybacktargetavailabilitychanged` | Event; `event.availability` is `"available"` / `"not-available"`. Gates button visibility. |
| `webkitCurrentPlaybackTargetIsWireless` | Boolean; drives the button's active state. |
| `webkitcurrentplaybacktargetiswirelesschanged` | Event; fires when the above flips. |

Design decisions:

- **Availability listener is mounted with the player, not globally.** Apple
documents that monitoring availability costs battery. It attaches in the same
`$effect` that owns the element and detaches on cleanup. Registering the
listener immediately dispatches an initial event with current availability, so
there's no separate "query current state" call.
- **Button is hidden, not disabled, when unavailable.** Every non-Safari browser
reports nothing here; a permanently-disabled button in Chrome is noise.
- **Disconnect mid-playback:** when the wireless route drops mid-track (Sonos
powered off, network blip), `webkitcurrentplaybacktargetiswirelesschanged`
fires and the button reverts to inactive. Local playback **continues at the
current position** — no stall, no restart. The route is not auto-re-engaged;
the user re-taps if they want it back.
- **Track-skip while routed:** the route persists across track changes — the new
track plays on the Sonos. `src` swaps via `pickVodSrc` must not tear the route
down; this is verified alongside the recovery-swap case in Task 3, since both
are src-swap events.
- **`x-webkit-airplay="allow"`** on the `<audio>` element.
- **TypeScript declarations are required, not optional.** `svelte-check` runs in
the gate (`scripts/check.sh`), so a `.d.ts` augmenting `HTMLAudioElement` with
these four members must land in the same change or CI fails.

Placement: the transport row in `Player.svelte`, and the overlay controls in
`NowPlaying.svelte` — the existing pattern where both surfaces drive the same
element through `playerControls`.

## Section 2 — VOD (the path that works)

No changes needed to source selection. `pickVodSrc`
(`Player.svelte:17-21`) already hands Safari the native HLS URL
`/api/hls/{id}.m3u8` — signed at `app/api/video.py:47-49` — because `hlsNative`
is true there. That is a plain, receiver-fetchable, non-MSE source, which is
exactly what the route wants.

The one thing to verify: the signed HLS URL carries an expiry
(`stream_url_ttl_seconds`, default 6 h). If a session outlasts it, the element
errors and `handleError` (`Player.svelte:327+`) does its refetch dance — which
swaps `src`, which **drops the AirPlay route**. (The expiry is on the signed
URL, not on the route itself; the Mac has already been fetching and re-encoding,
so it bites when the element re-requests segments, not on every packet.)
Task 3 **auto re-asserts** the route after the recovery swap completes, so
playback continues uninterrupted on the Sonos. The re-assert is verified as part
of the same src-swap test as track-skip (§1), since both are src-swap events.

## Section 3 — Live (the path that breaks)

`Player.svelte:170-242` attaches hls.js whenever `Hls.isSupported()`, true in
desktop Safari. MSE → blob URL → not routable. Left alone, the user taps AirPlay
during a live stream and gets either a refused route or audio that keeps playing
locally — a silent failure, which this repo's conventions specifically reject.

Three options, in the order I'd try them:

**A. hls.js `MEDIA_ATTACHING` workaround (try first).** Do the AirPlay setup
during hls.js's `MEDIA_ATTACHING` event. This is what Stremio landed
(`Stremio/stremio-video` PR #74) for the same problem. Cheapest if it works;
keeps hls.js's recovery logic intact.

**B. Force the native-HLS branch while a route is active.** `Player.svelte:243-245`
already has this branch for iOS Safari (`audio.src = src`). When an AirPlay
target connects, tear down hls.js and switch to it; re-attach hls.js on
disconnect. Costs the recovery logic that the comment at `Player.svelte:165-168`
says is the entire reason hls.js is there for live — playlist refresh, segment
scheduling, discontinuity handling. A live stream that hiccups while routed will
recover worse.

**C. Hide the button for live tracks.** Honest, trivially correct, zero risk.
The fallback if A and B both fail.

// DECISION: try A, fall back to B, ship C if neither holds. **Never ship the
silent failure.** Whichever lands, the button's state must tell the truth about
whether the route will work.

**Pass/fail per option** (the observable that triggers fallback to the next):
- **A passes iff:** with hls.js attached and a target connected,
`webkitCurrentPlaybackTargetIsWireless === true` **and** audio is audible on
the Sonos through a 60s live segment with no fatal `Hls.Error`. If audio
never surfaces or a fatal error fires within that window → A fails, try B.
- **B passes iff:** after tearing down hls.js and setting `audio.src = src`,
the same target-connected + audible-for-60s contract holds. Re-attach of
hls.js on disconnect must resume at the live edge (no stall, no manual seek).
- **C** is unconditional — live tracks simply don't show the button.

## Testing

**Unit (vitest)** — what it observably proves, and what it doesn't:
- The WebKit APIs are absent in jsdom, so they get mocked onto the element.
This tests **our logic, not Safari**: it proves that given a mocked
`availability` event the button's bound `hidden` state flips, and that after
unmount a further dispatch no longer mutates state (detach **by behaviour**,
not by a `removeEventListener` spy — a spy only proves the call was made, not
that the name matched a registered listener).
- It does **not** prove: that the listener registers on the real Safari element,
that `webkitShowPlaybackTargetPicker` is called inside a user gesture (jsdom
has no gesture semantics), or that detach removes the Safari-side listener by
name. Those are manual.
- Cases: dispatch `webkitplaybacktargetavailabilitychanged` with `"available"` →
button `hidden` is false; dispatch `"not-available"` → true; after unmount,
the same dispatch is a no-op; the magic string `webkitplaybacktargetavailabilitychanged`
is asserted verbatim (it appears four times across four identifiers; a typo
would pass jsdom silently).

**Feature-detection contract** (the minimum API subset, testable as a pure
boolean `airplaySupported(el)`):
- Button **renders** iff `el.webkitShowPlaybackTargetPicker` is a function. This
is the minimum.
- Button is **functional** iff that *plus* the `availability`-event mechanism is
present (the event name constant is reachable). Partial support — picker
present but no `availabilitychanged` — renders the button but leaves it
permanently in its initial state; treat as unsupported and hide.
- The standard `remote.prompt()` (Remote Playback API) is feature-detected
alongside, so a future standards-based path is a small edit. If both exist,
prefer the WebKit API on Safari (the standard is poorly adopted there).

**Manual, and it is the real gate.** None of this is verifiable in CI: no
Safari, no Sonos. The gate is a binary-outcome checklist, **with the Sonos model
and firmware recorded alongside the results** so it's reproducible. Minimum
entries:

1. **Task 0 (no-server-change premise):** `HOST=127.0.0.1` unchanged, AirPlay a
VOD track from Safari → Sonos. Pass = audio plays and the browser/Sonos makes
**no** fetch to a `127.0.0.1` URL. Fail = such a fetch appears.
2. **VOD route:** open picker → Sonos appears in list within ~2s → select →
audio surfaces from the speaker, system output unchanged.
3. **Disconnect mid-playback:** pull the route (power off Sonos / network blip)
→ button reverts to inactive, local playback continues at current position.
4. **Track-skip while routed:** next track → audio continues on the Sonos (route
persists).
5. **Expiry recovery while routed:** force URL expiry → `handleError` recovery
swap fires → route auto re-asserts → audio continues on the Sonos.
6. **Live (whichever of A/B/C landed):** per the §3 pass/fail contract.
7. **Non-Safari (negative):** open in Chrome → no AirPlay button renders.

Record results against a named Sonos model (e.g. `Sonos Era 100, S2 15.x`).
A `docs/PLAYBOOKS.md` entry names this checklist so a CI grep can confirm it
landed.

## Risks

| Risk | Handling |
|---|---|
| The no-server-change premise (§Problem) is wrong. | Task 0 verifies before anything is built. If wrong, revisit this design. |
| Live can't be routed at all. | Option C — hide the button — is always available and always honest. |
| Expiry recovery drops the route mid-listen. | Task 3 auto-re-asserts the route after the recovery swap (decided — see §2). |
| WebKit APIs are non-standard and unversioned. | Feature-detect every one; also detect the standard `remote.prompt()` so a future standards-based path is a small edit. |
| Sonos's AirPlay 2 implementation differs from a HomePod's. | Manual testing is against the actual target hardware, not a proxy. |

## Non-goals reaffirmed

This adds no server surface, no new route, no new signed-URL type, and no change
to `app/models.py` / `frontend/src/lib/types.ts`. If an implementation finds
itself touching any of those, that's a signal the premise in §Problem broke —
stop and revisit.

## Clarifications (2026-07-29)

From the GLM review panel (3 lenses). Resolved findings folded into the body
above; the four product/build decisions recorded here.

- **Q (lens A/B/C): track-skip while routed** → **A:** the route persists across
track changes; the new track plays on the Sonos. Matches native-media-app
behaviour. `src` swaps via `pickVodSrc` must not tear the route down — verify
in Task 3 alongside the recovery-swap case, since both are src-swap events.
- **Q (lens A/C): expiry-induced recovery swap drops the route** → **A:** auto
re-assert. After `handleError`'s `src` swap completes, programmatically
re-engage the AirPlay route so playback continues uninterrupted on the Sonos.
Replaces the "or accept the drop and document it" hedge in §2.
- **Q (lens C): live A→B→C sequencing needs hardware** → **A:** the dev machine
is a Mac — Safari is local, so validation does not block on external hardware.
Withdraw the C-first-MVP recommendation: build the full A→B→C chain and
validate locally. (Sonos is still required for the final manual gate, but not
for iterating on the hls.js workaround.)
- **Q (lens B): AirPlay picker entry across both surfaces** → **A:** add a new
`playerControls` method (`showPlaybackTargetPicker`). `NowPlaying.svelte`
reaches the element via `document.querySelector('audio')` for scrub/restart
(`NowPlaying.svelte:17,47,59`), not through `playerControls` — the AirPlay
action gets a shared entry point instead, matching the `toggle()` pattern
(`store.svelte.ts:24`, `NowPlaying.svelte:54`).

## Panel follow-ups

All four must-resolve / should-clarify items from the run report have been
folded into the body: Task 0 falsifier (§Problem), option pass/fail contract
(§3), disconnect mid-playback (§1), and the manual-gate checklist + feature-detect
contract + vitest scope (§Testing). The risk table's expiry row was updated to
match the auto-re-assert decision.
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "hum-frontend",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
Loading