Skip to content

fix(player): recover media keys after an audio route loss - #422

Open
9tong wants to merge 5 commits into
sozercan:mainfrom
9tong:claude/kaset-bluetooth-f8-hotkey-bb647c
Open

fix(player): recover media keys after an audio route loss#422
9tong wants to merge 5 commits into
sozercan:mainfrom
9tong:claude/kaset-bluetooth-f8-hotkey-bb647c

Conversation

@9tong

@9tong 9tong commented Aug 8, 2026

Copy link
Copy Markdown

Problem

Media keys (F8) stop controlling playback after Bluetooth headphones disconnect and reconnect. Only clicking Play in the window restores them.

Reproduced and localized with a timestamped file trace per docs/common-bug-patterns.md, not inferred. Three defects turned out to stack — the first is the direct cause, and it was only identifiable once Control Center showed that pressing the second card's play button did nothing while Kaset's own card worked.

1. WebKit's Now Playing session survives a route loss as a zombie

WebKit registers its own Now Playing client with the system, independent of the app's MPNowPlayingInfoCenter. When the audio route disappears, that registration outlives the media session it described: its Control Center card still captures the media keys, but its transport actions do nothing.

It cannot be withdrawn from the app side. Clearing navigator.mediaSession metadata and playbackState was tried and only blanks the card's contents — WebKit keeps the registration as long as a media element that has played still exists. The only thing that rebinds the session to a live route is playback running again, which is exactly what the manual "click Play" workaround did.

2. A route-loss pause was recorded as a deliberate user pause

macOS stops playback on a vanished route by sending an ordinary pause remote command, indistinguishable from the user pressing Pause. Kaset set isExplicitPauseIntentActive, whose purpose is to stop YouTube's autoplay from overriding a user's pause. With it set, applyObservedPlaybackState re-pauses the page the instant anything resumes it — so even a media key that did reach a healthy session started playback and had it killed a moment later.

3. The native Now Playing claim could never be released

The hands-off branch waited for "WebKit to atomically replace the app-wide metadata" before standing down. WebKit never does that — it does not write MPNowPlayingInfoCenter at all — so the claim was never withdrawn and Kaset kept a second, permanently stale Control Center entry once it had paused even once.

Approach

Resume playback when the audio route returns, scoped precisely to playback the route loss itself stopped. This restores what the user was listening to and, as a direct consequence, rebinds WebKit's media session so the media keys work again for everything afterward.

  • MusicPauseOrigin separates .user from .routeLoss(at:). Only a user pause records the standing intent to stay paused.
  • DefaultOutputDeviceMonitor identifies a route loss by the previous default device becoming unusable — checking kAudioDevicePropertyDeviceIsAlive as well as list membership, since Core Audio can mark a device dead before dropping its ID. A manual output switch or a device merely arriving never arms recovery.
  • Each disappearance can explain at most one pause, so a user's Pause moments after the system's keeps its own meaning.
  • Timing anchors on the ingress admission instant, and the route timeline is reconstructed as of that instant. Remote commands drain onto the MainActor asynchronously; dating decisions to handling time lets a busy main actor misclassify a real route pause, or sort the recovery marker after a reconnect that already happened.
  • Recovery is bounded by intent, not a timer: the marker is retired by a user pause or by playback actually starting, and survives issuing a resume so the retry can act while the route is still settling.
  • Resume requires output to have come back and still be there — under flapping, the newest event after the marker can be another disappearance.
  • Core Audio invokes the listener directly on the notifying thread with no serialization, so record commits are monotonic and superseded readings are dropped.

Verification

Fix confirmed end-to-end against the real hardware repro (play → disconnect → reconnect): playback resumed 540ms after the reconnect and F8 then toggled cleanly four times in a row, routed through the rebound WebKit session.

swift build · 2972 tests · swiftlint --strict 0 violations · swiftformat clean. All diagnostic instrumentation removed.

Scope

The YouTube video source keeps the same gap — handleRemotePause is unclassified there, so video playback still loses media keys after a route change. Nothing about that path changed, so it is a gap rather than a regression. It needs its own recovery marker and resume path in a separate service and could not be verified against this reproduction, which was music-only. Recorded as an explicit non-goal in ADR-0033 rather than shipped unverified.

See ADR-0033 for the full reasoning, including the known limitation around route changes within a single output device.

🤖 Generated with Claude Code

9tong and others added 5 commits August 8, 2026 08:40
Media keys stopped controlling playback after Bluetooth headphones
disconnected and reconnected, and only clicking Play in the window
restored them. A timestamped trace showed three defects stacking.

WebKit registers its own Now Playing client, separate from the app's
MPNowPlayingInfoCenter. Losing the audio route leaves that registration
behind: its Control Center card still captures the media keys but its
transport actions do nothing. The registration cannot be withdrawn from
the app side — clearing navigator.mediaSession only blanks the card —
so the only thing that rebinds it is playback running again, which is
exactly what the manual workaround did.

macOS stops playback on a vanished route by sending an ordinary pause
remote command, indistinguishable from the user pressing Pause. Kaset
recorded it as a deliberate pause, and isExplicitPauseIntentActive then
made the observer re-pause the page the instant anything resumed it, so
even a media key reaching a healthy session started playback and had it
killed a moment later.

The native Now Playing claim's hands-off branch waited for WebKit to
replace the app-wide metadata before standing down. WebKit never does
that, so the claim was never released and Kaset kept a second, stale
Control Center entry once it had paused even once.

Resume playback when the route returns, scoped to playback the route
loss itself stopped. MusicPauseOrigin separates a system pause from a
user one; only a user pause records a standing intent to stay paused.
DefaultOutputDeviceMonitor identifies a route loss by the previous
default device becoming unusable — checking DeviceIsAlive as well as
list membership, since Core Audio can mark a device dead before
dropping its ID — so a manual output switch never arms recovery.

Remote commands drain onto the MainActor asynchronously, so both the
classification window and the recovery marker anchor on the ingress
admission instant, and the route timeline is reconstructed as of that
instant rather than as of handling time. Recovery is bounded by intent
rather than a timer: the marker is retired by a user pause or by
playback actually starting, and survives issuing a resume so the retry
can act while the route is still settling.

The YouTube video source keeps the same gap and is documented as out of
scope in ADR-0033; it needs its own recovery path and could not be
verified against this reproduction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Core Audio timestamps a route change immediately but publishes it only
after several synchronous device queries, while the pause command it
provokes drains onto the MainActor independently. When the command wins
that race it is judged against a route log that does not yet contain the
disappearance, so a genuine route-loss pause records explicit pause
intent — and nothing later can undo it, leaving exactly the media-key
failure this change exists to repair.

Classify from both sides instead. A remote pause no route loss explains
still behaves as the user's, but keeps its admission instant; publishing
the route event retries the attribution and flips the pause when the
disappearance accounts for it. The claim is the same one-shot used at
admission, so a disconnect still explains at most one pause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Core Audio invokes the listener directly on the notifying thread, so
overlapping default-output notifications ran their device queries
concurrently and committed independently. Two callbacks could interleave
badly: one pairing its captured device ID with another's usability
result, or a slower earlier callback being dropped by the monotonic
guard after a later one appended. Dropping it discards a disappearance
that a pause admitted between the two timestamps needs, so the pause
keeps user semantics and recovery never arms.

Take the timestamp and run both queries under the record's lock. The
sequence is then atomic per callback, timestamps are monotonic by
construction rather than by a guard that discards, and no commit can mix
state read by a different one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two orderings the design claims to support were still broken.

Moving the route timestamp inside the record lock defeated the late
re-attribution added a commit earlier: that lock is also held by the
pause claim, so when the pause got there first the disappearance was
stamped after `admittedAt` and `routeLossIndex` rejected it. Capture the
instant at listener entry again, before any contention, and keep the
Core Audio queries under the lock. Arrival and commit order can then
differ, so events are inserted in timestamp order rather than appended,
and never dropped — a discarded disappearance is one a queued pause may
still need.

Recovery attempts were also scheduled only by the output-device
callback. A reconnect can be recorded and handled before its earlier
pause drains, which the classification deliberately allows, spending
both attempts while no marker existed yet. Installing the route-loss
marker now schedules them too; it is a no-op when no route has returned,
since the resume re-checks that itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Classification runs in NowPlayingManager before the command is enqueued,
while the player's transport queue drains later. A disappearance
recorded in that window has its callback find no marker to attribute,
and the drain then installed the marker without rechecking — leaving the
event unclaimed with nothing guaranteed to come back for it. The drain
now retries the claim after installing the marker, so the stated
invariant actually holds: whichever of the route event and the pause
lands second performs the attribution.

The route log also sorted events by arrival stamp while `defaultDeviceID`
advanced in lock-acquisition order, so a callback that arrived earlier
but committed later compared against a future baseline and could log a
restoration where there was a disappearance. Transitions are consistent
with the baseline they were compared against, so the log now orders the
same way: an arrival stamp older than the previous commit is clamped
forward rather than sorted behind it. Uncontended, which is the ordinary
case, the stamp is still exactly arrival time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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