Engineering notes

Reading Now Playing on macOS 15.4 and later

MediaRemote stopped answering third-party apps. How Remolo still shows what’s playing — and what it costs.

6 October 2026 · Kiril Treitiak, developer of Remolo

Remolo’s media remote shows what is playing on your Mac — the title, the artist, the cover and a seek bar — for any player: Music, Spotify, a YouTube tab in Safari or Chrome. On macOS that information lives in a private framework, MediaRemote. Since macOS 15.4 it no longer answers ordinary apps. This note is how Remolo Helper still reads it, and what that costs.

What changed in 15.4

For years, utilities read Now Playing with a handful of MediaRemote calls: MRMediaRemoteGetNowPlayingInfo for the metadata, MRMediaRemoteRegisterForNowPlayingNotifications to hear about changes, MRMediaRemoteSendCommand to play or pause. In macOS 15.4 Apple started answering those calls only for processes signed by Apple. A Developer ID app like Remolo Helper still links the framework fine; it just gets nothing back.

The workaround: let an Apple-signed process ask

macOS ships Apple-signed binaries that can load arbitrary code. /usr/bin/perl is one of them, and Perl’s DynaLoader can load a dynamic library and call a C function in it. So Remolo Helper doesn’t call MediaRemote itself. It starts Perl with a four-line script that loads a small library bundled inside the Helper and runs one function in it:

use DynaLoader;
my $lib = DynaLoader::dl_load_file($ARGV[0], 0) or die DynaLoader::dl_error();
my $sym = DynaLoader::dl_find_symbol($lib, "remolo_now_playing") or die "no symbol";
my $run = DynaLoader::dl_install_xsub("main::run", $sym);
&$run();

Inside that function the code runs in Perl’s process, so MediaRemote sees an Apple-signed caller and answers. The library opens the framework with dlopen, looks up the six functions it needs with dlsym, and exits with a distinct code if any of them is missing, so the Helper can tell “framework gone” from “symbol gone”.

This isn’t our invention: the same Perl trick is what the open-source mediaremote-adapter project popularised after 15.4. We wrote our own small bridge around it, described below.

A tiny protocol over stdin and stdout

The Perl process and the Helper talk over pipes, one line at a time.

  • Out: one JSON object per line — title, artist, duration, elapsed time, playing or paused, the player’s bundle ID (looked up from its process ID), and the cover as base64. Identical lines are never sent twice.
  • Cover art once: the image goes out only when the artwork identifier changes (or, when a player doesn’t set one, a key made from the image’s length and hash). Covers are big; titles change far more often than albums.
  • In: commands like seek 93.5 or pause. Each is followed by a fresh read 250 ms later, so the phone sees the result without waiting for a notification.
  • Lifetime: when the Helper’s end of stdin closes, the bridge exits. It never outlives the app.

Notifications aren’t enough

The bridge subscribes to the three MediaRemote notifications — info changed, playing state changed, player changed. Some players move the playhead without telling anyone, so a 5-second timer re-reads as a safety net. Between reads, the elapsed time is extrapolated from the last timestamp and the playback rate, which is what lets the seek bar on the phone move smoothly instead of jumping every few seconds.

Controlling playback

Reading needs MediaRemote; controlling mostly doesn’t. Play/pause, next and previous are sent as the same system-defined media-key events your keyboard produces, so they reach whichever player macOS considers “now playing”, browsers included. Only two things go through the bridge: seeking to an exact position (MRMediaRemoteSetElapsedTime) and the pause the sleep timer sends before the Mac goes to sleep.

Browser players get a little help from the other side: skip ±10 seconds becomes L/J (what YouTube expects) when a browser is in front, and the arrow keys elsewhere.

The cost, honestly

  • It depends on Apple keeping the door open. If a future macOS stops Perl from loading our library, the bridge dies. The Helper restarts it with exponential back-off (up to a minute) and the rest of Remolo keeps working; only the title and cover disappear.
  • It only runs while a phone is connected. No phone, no Perl process.
  • It’s a private API. That’s fine for a Developer ID app distributed outside the Mac App Store, which is why Remolo Helper ships from our site, notarized by Apple.

If you’re building something similar: keep the bridge tiny, make the protocol line-based and idempotent, extrapolate time instead of polling hard, and treat the whole thing as optional so your app degrades gracefully the day it stops working.

Written from the Remolo source with help from Claude, and checked against the code. Questions or corrections: support@remolo.app.

More engineering notes