fix(player): recover media keys after an audio route loss - #422
Open
9tong wants to merge 5 commits into
Open
Conversation
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>
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.
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.mediaSessionmetadata andplaybackStatewas 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
pauseremote command, indistinguishable from the user pressing Pause. Kaset setisExplicitPauseIntentActive, whose purpose is to stop YouTube's autoplay from overriding a user's pause. With it set,applyObservedPlaybackStatere-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
MPNowPlayingInfoCenterat 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.
MusicPauseOriginseparates.userfrom.routeLoss(at:). Only a user pause records the standing intent to stay paused.DefaultOutputDeviceMonitoridentifies a route loss by the previous default device becoming unusable — checkingkAudioDevicePropertyDeviceIsAliveas 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.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 --strict0 violations ·swiftformatclean. All diagnostic instrumentation removed.Scope
The YouTube video source keeps the same gap —
handleRemotePauseis 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