It costs one sound
Four attempts at receiving an AirPods double-tap on iOS, three of which failed
I am building a thing that lets me talk to my Mac from my earbuds while I am out of the house. The Mac does the work; the phone is a microphone and a speaker.
The whole interaction rests on one gesture: double-tap the earbud, screen locked, phone in pocket, and my app starts listening. No wake word, no unlocking, no looking at anything. If that gesture does not arrive, there is no product — everything else is plumbing behind it.
On macOS I already had this working. A small Swift binary claims the system's Now Playing slot, receives the media-key events, and never plays a single sample of audio. It has run for months.
I assumed it would port. It took four builds on a real device to find out what iOS actually does, and the answer is one sentence that I could not find written down anywhere.
The setup
An iPhone SE (2nd gen) on iOS 26.6, signed with a free Personal Team, talking to the Mac over the network. The round trip worked on the first build: 2.03 s of speech captured on the phone, transcribed on the Mac in 1.43 s, a complete turn back in 5.55 s.
So the hard-looking part was fine. The gesture was the problem.
The test is the same every time, and it is deliberately hostile: Spotify playing, screen locked, phone in my pocket, double-tap the right earbud. If my app is going to lose, I want it to lose in the test rather than on a run.
Attempt 1 — claim the slot without playing anything
MPNowPlayingInfoCenter with playback rate 1.0 and state .playing,
re-asserted on a 30-second timer, shaped exactly the way the macOS version does
it. No audio rendered. No audio session held between turns.
This is the macOS design, ported.
Result: Spotify started playing. My handler never fired.
Note how it lost, because it turns out to be the whole story: Spotify was stopped or paused at the time. It held the slot while producing no sound whatsoever.
That matches something I had already measured on macOS and written down months earlier, arrived at completely independently:
Pausing does not release the slot. A paused YouTube tab in Brave reports
playbackState "paused", not"none", and keeps the slot indefinitely while a silent re-claim never wins it back.
On macOS I solved that with a CoreAudio watcher that claims the instant the output device actually goes quiet. iOS exposes no equivalent — and it would not have helped anyway. Spotify was already quiet, and still won.
Attempt 2 — hold a real audio session
The obvious next hypothesis: a metadata claim is cheap talk, and what the system
actually respects is an app holding an audio session. So: a continuous
.playback session with .mixWithOthers, established before the Now Playing
claim, held permanently, rendering nothing.
Result: Spotify skipped a track.
remote:never appeared in my log.
Two cheap options gone, and they share a property that I think is the real
mechanism: .mixWithOthers declares your audio secondary. iOS gives Now
Playing to the app whose audio the listener is actually attending to, and mixable
audio is a declaration that you are not that app. If that reading is right, no
mixing configuration can ever win the slot, however loudly it claims.
The next idea in the queue was to render looped silence under the audio
background mode — a permanently-playing silent buffer. I rejected it without
testing. It is a battery tax carried forever to work around a platform rule, and
I would rather not ship that. Recording it here as a decision, not a
measurement — it might work, I chose not to find out.
Attempt 3 — kill the competition entirely
If Spotify holds the slot, remove Spotify. Not paused — swiped away in the app switcher, terminated. My app running, holding its active session.
Double-tap the right earbud:
Result: Spotify relaunched, resumed playing, and my app got nothing.
This is the decisive one. iOS persists the last Now Playing app and will resurrect a terminated process to deliver a remote command, in preference to a live app that is claiming the slot and holding an audio session.
There is no quiet moment to claim in, because the slot is never vacant. It survives the app's death.
I wrote the conclusion down: MPRemoteCommandCenter is unreachable in every
configuration short of my app being the primary audio player. Not "hard", not
"needs the right options". Closed. I started listing the alternatives — Siri via
an App Intent, the Action Button, a Lock Screen widget, a Watch complication —
and began mourning the double-tap vocabulary, which was the nicest part of the
design.
Attempt 4 — it works, and the rule was there all along
Later that day I was testing something unrelated. My app had a full conversation: it played its get-ready cue tone, recorded, and spoke the reply back. Then it released audio focus and sat idle for about ninety seconds.
Then, from the earbud, on a whim:
PHONE remote: nextTrack → TRIGGER
PHONE audio focus: EXCLUSIVE — other audio interrupted
PHONE conversation started — Audon owns the audio
A double-tap reached my app. Not while it was holding focus — while it was idle, having deliberately given focus up.
Here is the rule, and it is the one that beat me three times, pointing the other way:
The Now Playing slot belongs to the last app that genuinely PLAYED AUDIO.
Spotify held it because Spotify had played, and kept it through pause and even through termination. My app has now played — the cue tone, and every spoken reply — so my app holds it, and keeps holding it after deactivating the session.
That is why all three failures failed, stated precisely:
| Attempt | What it offered | Why it lost |
|---|---|---|
| 1 | A metadata claim | A claim is not playback |
| 2 | A silent mixing session | A session is not playback |
| 4 | A cue tone | A cue tone is playback |
The conclusion I drew after attempt 3 — unreachable short of being the primary audio player — was correct. What I had missed is that becoming the primary audio player is cheap. It costs one sound.
The condition, and it is livable
My app must make a sound once to own the taps, and it keeps them until another media app plays. So: start the first conversation from the app, and after that the earbuds drive it. Play Spotify and Spotify takes the slot back — which is correct. That is what you asked for when you pressed play.
The cue tone now has a comment above it saying it is load-bearing, because it looks exactly like an affordance and someone — probably me — would otherwise eventually delete it as noise.
The part that is structural
On macOS, a plain executable that produces no audio at all can hold Now Playing indefinitely. Mine does, and has for months.
iOS reserves the slot for real media playback. That is a platform difference, not a bug to be worked around, and it means the macOS trigger design does not port — no amount of porting effort would have made it. I spent three builds discovering that the thing I was porting was not portable.
If you are building something similar, the short version:
- A
MPNowPlayingInfoCenterclaim with no audio behind it does nothing on iOS. - An active
.mixWithOtherssession does nothing either, and probably cannot. - The competing app does not have to be playing. It does not have to be running.
- Play one sound. Then you have the taps.
Everything above was measured on a real device against a real competing app, not read in a document. The three failures are recorded in as much detail as the success, because the failures are what made the rule findable — I only located it by having been beaten by it three times from the wrong side.