Phase 4: Medien-Widget mit dreistufiger Kaskade

Adapter für alles inklusive Browser, AppleScript für Spotify und Musik,
Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern
Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon
einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige —
aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann
bedienbar, wenn gar keine Metadaten da sind.

Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte
gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der
stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet
wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die
Anzeige nach dem Ende weiter steigende Zahlen.

Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine
Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen.

In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true
zu 1 und die Wiedergabe gilt für immer als angehalten.

Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein
Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte
Ereignis korrigiert den Stand ohnehin.

Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in
ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary
eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von
/usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung.

124 Tests grün.
This commit is contained in:
Guido Schmit
2026-08-10 21:02:13 +02:00
parent 03dd7b945e
commit 433cdcc3dd
53 changed files with 4888 additions and 2 deletions

View File

@@ -0,0 +1,49 @@
---
BasedOnStyle: LLVM
IndentWidth: 4
---
Language: Cpp
AccessModifierOffset: -4
PointerAlignment: Left
BreakBeforeBraces: Custom
BraceWrapping:
AfterCaseLabel: false
AfterClass: true
AfterControlStatement: Never
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
AfterExternBlock: false
BeforeCatch: true
AlignAfterOpenBracket: DontAlign
Cpp11BracedListStyle: false
IncludeBlocks: Regroup
IncludeCategories:
- Regex: "<httplib\\.h>"
Priority: 3
- Regex: "<[^Q][[:alnum:]_.]+>"
Priority: 1
- Regex: "<Q.+>"
Priority: 2
- Regex: "<[^Q].+>"
Priority: 3
- Regex: ".*"
Priority: 4
SeparateDefinitionBlocks: Always
AllowShortLambdasOnASingleLine: Empty
AlwaysBreakTemplateDeclarations: true
AlignOperands: DontAlign
AlignEscapedNewlines: Left
BinPackParameters: true
AlignTrailingComments:
Kind: Never
---
Language: JavaScript
SpacesInContainerLiterals: false
JavaScriptQuotes: Single
JavaScriptWrapImports: true
---
# Notes:
# Designated initializer list formatting: Write a ',' after the last item.

View File

@@ -0,0 +1,10 @@
line_width = 80
tab_size = 4
separate_ctrl_name_with_space = False
separate_fn_name_with_space = False
dangle_parens = False
fractional_tab_policy = 'use-space'
max_subgroups_hwrap = 3
command_case = 'canonical'
keyword_case = 'upper'
literal_comment_pattern = '.*'

8
Vendor/mediaremote-adapter/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
build
.DS_Store
notes
CMakePresets.json
.cache
todo*.txt
dist
perltidy.ERR

View File

View File

@@ -0,0 +1,4 @@
# https://perltidy.sourceforge.net/perltidy.html
-naws
--indent-columns=2

View File

@@ -0,0 +1,92 @@
cmake_minimum_required(VERSION 3.15)
project(MediaRemoteAdapter LANGUAGES OBJC)
set(ADAPTER_VERSION_MAJOR 0)
set(ADAPTER_VERSION_MINOR 1)
set(ADAPTER_VERSION_PATCH 0)
set(ADAPTER_VERSION_SHORT "${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}")
set(ADAPTER_VERSION
"${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}.${ADAPTER_VERSION_PATCH}"
)
set(MEDIAREMOTEADAPTER_FRAMEWORK_NAME "MediaRemoteAdapter"
CACHE STRING "The output name of the adapter framework")
set(MEDIAREMOTEADAPTER_TEST_CLIENT_NAME "MediaRemoteAdapterTestClient"
CACHE STRING "The output name of the adapter test client")
set(ADAPTER_SOURCES
src/adapter/env.m
src/adapter/get.m
src/adapter/globals.m
src/adapter/keys.m
src/adapter/now_playing.m
src/adapter/repeat.m
src/adapter/seek.m
src/adapter/send.m
src/adapter/shuffle.m
src/adapter/speed.m
src/adapter/stream.m
src/adapter/test.m
src/private/MediaRemote.m
src/utility/Debounce.m
src/utility/helpers.m)
set(HEADERS include/MediaRemoteAdapter.h)
set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64")
add_library(MediaRemoteAdapter SHARED ${ADAPTER_SOURCES})
set_target_properties(
MediaRemoteAdapter
PROPERTIES FRAMEWORK TRUE
FRAMEWORK_VERSION A
MACOSX_FRAMEWORK_IDENTIFIER
"com.vandenbe.${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
MACOSX_FRAMEWORK_SHORT_VERSION_STRING "${ADAPTER_VERSION_SHORT}"
MACOSX_FRAMEWORK_BUNDLE_VERSION "${ADAPTER_VERSION}"
MACOSX_FRAMEWORK_BUNDLE_NAME
"${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
PUBLIC_HEADER "${HEADERS}"
OUTPUT_NAME "${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}")
target_link_libraries(MediaRemoteAdapter "-framework Foundation"
"-framework AppKit")
find_library(UT_FRAMEWORK UniformTypeIdentifiers)
if(UT_FRAMEWORK)
target_link_libraries(MediaRemoteAdapter ${UT_FRAMEWORK})
endif()
target_include_directories(MediaRemoteAdapter
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_include_directories(MediaRemoteAdapter
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_options(MediaRemoteAdapter PRIVATE "-fobjc-arc")
# Ensure that symbols are visible, otherwise the Perl script can't call into
# functions that are exported by the framework.
target_compile_options(MediaRemoteAdapter PRIVATE -fvisibility=default)
add_custom_command(
TARGET MediaRemoteAdapter
POST_BUILD
COMMAND codesign --force --deep --sign -
$<TARGET_BUNDLE_DIR:MediaRemoteAdapter>
COMMENT "Ad-hoc signing ${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}.framework")
add_executable(MediaRemoteAdapterTestClient src/test/main.m
src/test/NowPlayingTest.m)
target_link_libraries(MediaRemoteAdapterTestClient
PRIVATE "-framework Foundation" "-framework MediaPlayer")
target_include_directories(MediaRemoteAdapterTestClient
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/test)
set_target_properties(MediaRemoteAdapterTestClient
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
set_source_files_properties(src/test/main.m PROPERTIES COMPILE_FLAGS
"-fobjc-arc")
set_source_files_properties(src/test/NowPlayingTest.m PROPERTIES COMPILE_FLAGS
"-fobjc-arc")
set_target_properties(
MediaRemoteAdapterTestClient
PROPERTIES OUTPUT_NAME "${MEDIAREMOTEADAPTER_TEST_CLIENT_NAME}")

28
Vendor/mediaremote-adapter/LICENSE vendored Normal file
View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025, Jonas van den Berg and contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

10
Vendor/mediaremote-adapter/Makefile vendored Normal file
View File

@@ -0,0 +1,10 @@
.PHONY: all version
all:
version:
@system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'
@date
update-readme-badges:
python3 ./scripts/update-readme-badges.py

View File

@@ -0,0 +1 @@
3ac3d4bdf862c7b5399b4fba4df5689f5c38609a 2026-05-11 16:52:03 +0200

529
Vendor/mediaremote-adapter/README.md vendored Normal file
View File

@@ -0,0 +1,529 @@
> **@Apple**&ensp;*Before breaking this,
> please consider giving Mac users the option
> to share actively playing media with the apps they use
> and to control media playback.
> Perhaps by introducing a new entitlement
> that can be granted to apps by users in the system settings.
> There are
> [many](https://musicpresence.app)
> [use](https://folivora.ai)
> [cases](https://lyricfever.com)
> [for](https://theboring.name)
> [this](https://github.com/kirtan-shah/nowplaying-cli).*
> **@Developers**&ensp;*Please **star**
> this repository to show Apple that we care.*
---
<!-- BADGES BEGIN -->
![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600)
![](https://img.shields.io/static/v1?label=macOS&message=macOS%2026.0%20%2825A5316i%29&labelColor=444&color=blue)
![](https://img.shields.io/static/v1?label=last%20tested&message=Thu%20Jul%2024%2002%3A24%3A11%20CEST%202025&labelColor=444&color)
<!-- BADGES END -->
# MediaRemote Adapter
Get now playing information using the MediaRemote framework
on all macOS versions, including 15.4 and above.
This works by using a system binary &ndash; `/usr/bin/perl` in this case &ndash;
which is entitled to use the MediaRemote framework
and by dynamically loading a custom helper framework
that prints real-time updates to stdout.
## Example
Install the [media-control](https://github.com/ungive/media-control)
CLI tool to see this project in action. Works on all macOS versions:
```
$ brew tap ungive/media-control
$ brew install media-control
$ media-control stream
```
## Usage
This project provides a Perl script
with a well-defined CLI interface that you can invoke from your app
in order to read now playing information and control media players.
The [mediaremote-adapter.pl](./bin/mediaremote-adapter.pl) script
needs to be bundled with your app,
alongside the `MediaRemoteAdapter.framework`
and optionally the `MediaRemoteAdapterTestClient`
which are exposed as CMake targets in [CMakeLists.txt](./CMakeLists.txt).
You can find instructions to build the framework in the next section.
The script must then be invoked like this:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework COMMAND
```
`COMMAND` is a placeholder for one of the commands documented below.
For the `test` command the `NowPlayingTestCMediaRemoteAdapterTestClientlient` must be passed
as an additional argument:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
```
For ease of use you can also always pass the path to the test client:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient COMMAND
```
For more help on available commands read below or omit the `COMMAND` argument.
> [!WARNING]
> This project is still in development
> and the API may experience breaking changes across minor revisions.
> [!NOTE]
> For a maintained Swift package look at this excellent fork:
> https://github.com/ejbills/mediaremote-adapter
## Build from source
```
$ git clone https://github.com/ungive/mediaremote-adapter.git
$ cd mediaremote-adapter
$ mkdir build && cd build
$ cmake ..
$ cmake --build .
$ cd ..
$ FRAMEWORK_PATH=$(realpath ./build/MediaRemoteAdapter.framework)
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" stream
```
This creates the `MediaRemoteAdapter.framework` in the build directory,
which must be *bundled* with your app, but *not linked against*.
The framework is only used by the script
and must merely be passed as a script argument.
If you want to be able to test whether the adapter still works,
which can be useful to e.g. automatically fall back to AppleScript,
you need to also bundle the `MediaRemoteAdapterTestClient` executable with your app
and pass it as an additional argument:
```
$ HELPER_PATH=$(realpath ./build/MediaRemoteAdapterTestClient)
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" "$HELPER_PATH" test
```
An exit code of `0` then means the adapter is functional and safe to use.
The framework and test executable are built for the following architectures:
`x86_64` `arm64`
## Commands
- [get](#get)
- [stream](#stream)
- [send COMMAND](#send-command)
- [seek POSITION](#seek-position)
- [shuffle MODE](#shuffle-mode)
- [repeat MODE](#repeat-mode)
- [speed SPEED](#speed-speed)
- [test](#test)
### get
Prints now playing information once with all available metadata.
Output is encoded as JSON and characterized by either `null`
or a dictionary with any of the following keys:
> `bundleIdentifier`
`parentApplicationBundleIdentifier`
`playing`
`title`
`artist`
`album`
`duration`
`elapsedTime`
`timestamp`
`artworkMimeType`
`artworkData`
`chapterNumber`
`composer`
`genre`
`isAdvertisement`
`isBanned`
`isInWishList`
`isLiked`
`isMusicApp`
`playbackRate`
`prohibitsSkip`
`queueIndex`
`radioStationIdentifier`
`repeatMode`
`shuffleMode`
`startTime`
`supportsFastForward15Seconds`
`supportsIsBanned`
`supportsIsLiked`
`supportsRewind15Seconds`
`totalChapterCount`
`totalDiscCount`
`totalQueueCount`
`totalTrackCount`
`trackNumber`
`uniqueIdentifier`
`contentItemIdentifier`
`radioStationHash`
`mediaType`
The following mandatory keys never have a null value:
`bundleIdentifier`
`playing`
`title`.
If any of the mandatory keys cannot be determined,
the command prints `null`.
Media without a title is considered invalid.
The `mediaType` may contain one of the following values:
- `MRMediaRemoteMediaTypeMusic`
- `kMRMediaRemoteNowPlayingInfoTypeAudio`
- Possibly others, this key is not very well documented
**Caveats**
Metadata such as `artworkData` and `artworkMimeType`
often takes a bit of time to load
and may not appear in the output in all cases.
Do not rely on this key to be present reliably.
Either use the `stream` command or poll `get` regularly,
to ensure you get the artwork data *eventually*.
**Options**
`--now`&ensp;Adds an `elapsedTimeNow` key with an estimation of the current
elapsed playback time. This estimation may be off by up to a second.
To determine a more accurate time without polling `get` continuously,
calculate it using the `elapsedTime` and `timestamp` keys. `elapsedTime`
contains the elapsed time at the time that is stored in `timestamp`.
`--micros`&ensp;Replaces the following keys with microsecond equivalents:
| Original key | Converted key name | Comment |
|------------------|------------------------|---------------------------|
| `duration` | `durationMicros` | - |
| `elapsedTime` | `elapsedTimeMicros` | - |
| `elapsedTimeNow` | `elapsedTimeNowMicros` | Only present with `--now` |
| `timestamp` | `timestampEpochMicros` | Converted to epoch time |
`--no-artwork`&ensp;Omits the `artworkData` and `artworkMimeType` keys
from the payload. Useful for consumers that do not render artwork,
since this avoids emitting several hundred kilobytes of base64-encoded
image data per update.
---
### stream
Streams now playing information updates in real-time
until the script receives a SIGTERM signal.
Output is encoded as JSON and characterized by
a dictionary with the following keys:
> `type`
`diff`
`payload`
`type` is always a string with the value `"data"`.
`payload` contains the now playing information and is a dictionary
that is structurally identical to the output of the `get` command,
with the same keys. The dictionary itself is never `null`.
No keys are set at all,
when no media player is reporting now playing information.
Some keys may have a `null` value,
when the media player reports `null` for them (this happens rarely, if ever).
Any key may be `null`, when `diff` is set to true and the key vanishes.
`diff` is a boolean that indicates whether the `payload`
contains only fields whose values have been updated.
When set to `false`,
the payload is to be considered the current now playing state
with all available keys and their values,
regardless of any payloads that have been sent in the past.
When set to `true` on the other hand,
the last sent non-diff payload must be updated with these new values,
in order to have a representation of the the current now playing state.
When a key is not present anymore, it's set to `null` in the payload
and its previous value should be removed.
**Diffing is enabled by default, but can be disabled with a command line flag.**
**Options**
`--no-diff`&ensp;Disables diffing. `diff` is always `false`
and `payload` always contains all current information.
`--debounce=N`&ensp;Adds a debounce delay in milliseconds
between the point where changes are detected
and when they are printed.
If a new update comes in during delaying,
the delay is restarted and all updates are merged.
This is useful to prevent bursts of smaller updates.
The default is 0.
`--micros`&ensp;Identical to the `--micros` option of the `get` command.
`--no-artwork`&ensp;Identical to the `--no-artwork` option of the `get`
command. Particularly useful with `stream` with the `--no-diff` parameter,
since it otherwise re-emits the full artwork payload on every update.
**Experimental options**
`--experimental-peculiar-debounce:BUNDLE_ID=N`&ensp;
Adds a debounce delay in milliseconds for the case when the media player
with the given bundle identifier (`BUNDLE_ID`) reports metadata that
contains parts of the previous track and parts of the next track,
but doesn't contain the full metadata of the next track.
Whenever the track title changes,
the update for it is either delayed for the given debounce delay
or the update is printed when all other metadata updated as well,
whichever happens earlier.
Currently only `com.tidal.desktop` can be passed for `BUNDLE_ID`,
since it is the only media player that is known to have this issue.
A value of `1000` for `N` is recommended for TIDAL specifically.
---
### send COMMAND
Sends a MediaRemote command to the now playing application.
The value for `COMMAND` must be a valid ID from the table below.
| ID | MediaRemote key | Description |
|:--:|-------------------------|-------------------------------|
| 0 | kMRPlay | Start playback |
| 1 | kMRPause | Pause playback |
| 2 | kMRTogglePlayPause | Toggle between play and pause |
| 3 | kMRStop | Stop playback |
| 4 | kMRNextTrack | Skip to the next track |
| 5 | kMRPreviousTrack | Return to the previous track |
| 6 | kMRToggleShuffle | Toggle shuffle mode |
| 7 | kMRToggleRepeat | Toggle repeat mode |
| 8 | kMRStartForwardSeek | Start seeking forward |
| 9 | kMREndForwardSeek | Stop seeking forward |
| 10 | kMRStartBackwardSeek | Start seeking backward |
| 11 | kMREndBackwardSeek | Stop seeking backward |
| 12 | kMRGoBackFifteenSeconds | Go back 15 seconds |
| 13 | kMRSkipFifteenSeconds | Skip ahead 15 seconds |
---
### seek POSITION
Seeks to a specific timeline position with the now playing application.
The value for `POSITION` must a valid positive integer.
The unit is microseconds.
---
### shuffle MODE
Sets the shuffle mode.
The value for `MODE` must be a valid ID from the table below.
| ID | Description |
|:--:|----------------|
| 1 | Disable |
| 2 | Shuffle albums |
| 3 | Shuffle tracks |
---
### repeat MODE
Sets the repeat mode.
The value for `MODE` must be a valid ID from the table below.
| ID | Description |
|:--:|-----------------|
| 1 | Disable |
| 2 | Repeat track |
| 3 | Repeat playlist |
---
### speed SPEED
Sets the playback speed.
The value for `SPEED` must be a valid positive integer.
---
### test
Tests if the adapter is entitled to use the MediaRemote framework
and if it is able to execute any of the supported commands without failure.
An exit code of 0 means the adapter is functional and safe to use.
This can be integrated into your app
to help confirm that the adapter is still functional and if not,
fall back to other methods for media detection (e.g. AppleScript),
since future macOS updates may break MediaRemote access again.
#### Usage
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
```
Note that the `test` command requires the absolute path
to the `MediaRemoteAdapterTestClient` executable after the framework path.
For ease of use you can always pass the path to the test client executable,
even when using other commands, like `get` or `stream`.
#### Output
An exit code of `0` indicates that the adapter is still functional
and can safely be used to detect media.
Any other exit indicates that the adapter is likely broken.
If you ever get an exit code other than `0`,
please [report this](https://github.com/ungive/mediaremote-adapter/issues). Thank you!
#### How this works
1. Now playing information is attempted to be read normally using `get`
2. If no media is detected, the `MediaRemoteAdapterTestClient` helper process is launched to simulate media playback
3. While the helper process is running, now playing information is attempted to be read again using `get`
4. Afterwards the helper process is terminated
5. If any of the `get` attempts yielded media information, the command exits with an exit code of `0`
6. Otherwise the command exits with an exit code of `1`
> [!WARNING]
> **May interfere with other apps using MediaRemote**
> The test can create a fake media entry that will briefly appear
as the now playing application.
This only happens when no other media is playing.
Since the helper process has no bundle identifier,
it is mostly ignored by the `stream` and `get` commands —
`stream` won't update, and `get` will print `null`.
---
## Built-in fixes
This library has some fixes built-in
to accomodate for inconsistencies within the MediaRemote framework:
- Artwork data sometimes unloads for a brief moment,
e.g. when changing the current timeline position of a track.
To combat this, artwork data is reused when the track has not changed,
the track had artwork data before and the artwork data has disappeared.
This fix is applied when using the `stream` command
If you need a way to disable any or all of these fixes,
please open an issue or create a pull request.
---
## Implementation notes
- Consider `NSJSONSerialization` for JSON deserialization.
This is what is used for encoding
- You can use `NSData`'s `initWithBase64EncodedString`
for decoding of base64 data
- Every line printed to stderr is an error message.
If the script did not exit with a non-zero exit code,
then any of these errors are non-fatal and can be safely ignored
- Other apps using MediaRemote Adapter may run `test` which should not interfere with the `stream` and `get` commands, but will generate a missing bundle identifier error message, which can be ignored. See the `test` command section for more information.
- You should not reinvoke the script when a fatal error occurs
(non-zero exit code)
- Make sure to pass the absolute path of the bundled framework and helper executable
as arguments and not a relative path
## Why this works
According to the findings by [@My-Iris](https://github.com/Mx-Iris) in
[this comment](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
processes with a bundle identifier starting with `com.apple.`
are granted permission to access the MediaRemote framework.
The Perl platform binary `/usr/bin/perl`
is reported as having the bundle identifier `com.apple.perl` (or a variation).
You can confirm this by streaming log messages using the Console.app
whilst running the script:
`default 14:44:55.871495+0200 mediaremoted Adding client <MRDMediaRemoteClient 0x15820b1a0, bundleIdentifier = com.apple.perl5, pid = 86889>`
## Motivation
This project was created due to the MediaRemote framework
being completely non-functional when being loaded directly from within an app,
starting with macOS 15.4 (see the numerous issues linked below).
The aim of this project is to provide a tool (and perhaps soon a full library)
that serves as a fully functional alternative to using MediaRemote directly
and perhaps to inspire Apple to give us a public API
to read now playing information and control media playback on the device
(see the note at the top of this file).
## Projects that use this library
- [Now Playing Display](https://www.gxlabs.co/now-playing/): A macOS "now playing" hardware display for the Seeed XIAO ESP32-C6 paired with the Seeed Round Display for XIAO. Shows album art, track info, progress, and playback controls on a 240x240 circular TFT — connected over USB. Source code: https://github.com/gxlabs/now-playing-device
- [Music Presence](https://musicpresence.app) is a cross-platform desktop application
for showing what you are listening to in your Discord status.
It uses this library since version [2.3.1](https://github.com/ungive/discord-music-presence/releases/tag/v2.3.1)
to detect media from all media players again. Website: https://musicpresence.app
- [media-control](https://github.com/ungive/media-control)
is a CLI tool to control and observe media playback on any macOS version.
You can install it directly via brew: `$ brew tap ungive/media-control && brew install media-control`. Source code: https://github.com/ungive/media-control
*If you use this library in your project, please
[let me know](https://github.com/ungive/mediaremote-adapter/issues) and I'll add it to the list!*
## Useful links
- Issues regarding MediaRemote breaking since macOS 15.4
- https://github.com/vincentneo/LosslessSwitcher/issues/161
- https://github.com/aviwad/LyricFever/issues/94
- https://github.com/TheBoredTeam/boring.notch/issues/417
- https://community.folivora.ai/t/now-playing-is-no-longer-working-on-macos-15-4/42802/11
- https://github.com/ungive/discord-music-presence/issues/165
- https://github.com/ungive/discord-music-presence/issues/245
- https://github.com/kirtan-shah/nowplaying-cli/issues/28
- https://github.com/FelixKratz/SketchyBar/issues/708
- Getting now playing information using `osascript` and `MRNowPlayingRequest`.
Note that this is unable to load the song artwork
and it is impossible to get real-time updates with this solution.
It is much simpler to implement though
- https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800
- https://github.com/fastfetch-cli/fastfetch/commit/1557f0c5564a8288604824e55db47508f65e82c9
- https://gist.github.com/SKaplanOfficial/f9f5bdd6455436203d0d318c078358de
## Acknowledgements
Thank you [@Alexander5015](https://github.com/Alexander5015) for implementing the `test` command,
so we're able to detect when the adapter stops working!
Thank you [@EinTim23](https://github.com/EinTim23) for bringing
a [similar workaround](https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800) to my attention!
Without your hint I most likely would not have dug into this anytime soon
and my app [Music Presence](https://musicpresence.app)
would still only work with AppleScript automation.
Thank you [@My-Iris](https://github.com/Mx-Iris)
for providing insight into the changes made since macOS 15.4:
[aviwad/LyricFever#94](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
## License
This project is licensed under the BSD 3-Clause License.
See [LICENSE](./LICENSE) for details.
Copyright (c) 2025 Jonas van den Berg and contributors

View File

@@ -0,0 +1,280 @@
#!/usr/bin/perl
# Copyright (c) 2025 Jonas van den Berg
# This file is licensed under the BSD 3-Clause License.
# For usage information read below or run the script without arguments.
use strict;
use warnings;
use DynaLoader;
use File::Spec;
use File::Basename;
sub print_help() {
print <<'HELP';
Usage:
mediaremote-adapter.pl FRAMEWORK_PATH [TEST_CLIENT_PATH]
[FUNCTION [PARAMS|OPTIONS...]]
FRAMEWORK_PATH:
Absolute path to the MediaRemoteAdapter.framework directory
TEST_CLIENT_PATH: (optional)
Absolute path to the MediaRemoteAdapterTestClient executable. Only for "test"
FUNCTION:
stream Streams now playing information (as diff by default)
get Prints now playing information once with all available metadata
send Sends a command to the now playing application
seek Seeks to a specific timeline position
shuffle Sets the shuffle mode
repeat Sets the repeat mode
speed Sets the playback speed
test Tests if the adapter is entitled to use the MediaRemote framework.
An exit code other than 0 indicates the adapter is non-functional
PARAMS:
send(command)
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
seek(position)
position: The timeline position in microseconds
shuffle(mode)
mode: The shuffle mode
repeat(mode)
mode: The repeat mode
speed(speed)
speed: The playback speed
OPTIONS:
get
--now: Adds an "elapsedTimeNow" key with an estimation of the current
elapsed playback time. This estimation may be off by up to a second.
To determine a more accurate time without polling "get" continuously,
calculate it using the "elapsedTime" and "timestamp" keys. "elapsedTime"
contains the elapsed time at the time that is stored in "timestamp".
stream
--no-diff: Disable diffing and always dump all metadata
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
get, stream
--micros: Replaces the following time keys with microsecond equivalents:
"duration" -> "durationMicros"
"elapsedTime" -> "elapsedTimeMicros"
"elapsedTimeNow" -> "elapsedTimeNowMicros"
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
--no-artwork: Omits "artworkData" and "artworkMimeType" from the payload.
Useful for consumers that do not render artwork, since this avoids
emitting several hundred kilobytes of base64 data per update.
--human-readable, -h: Makes values human-readable. Use only for debugging.
The JSON output is pretty-printed and the following keys are adapted:
"artworkData" -> Binary data is truncated to a shorter representation
Examples (script name and framework path omitted):
stream --no-diff --debounce=100
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
HELP
exit 0;
}
if (!defined $ARGV[1]) {
print_help();
}
sub fail {
my ($error) = @_;
print STDERR "$error\n";
exit 1;
}
fail "Framework path not provided" unless @ARGV >= 1;
my $framework_path = shift @ARGV;
# Optionally accept MEDIAREMOTEADAPTER_TEST_CLIENT_PATH path as second argument
my $maybe_helper_path = $ARGV[0] // '';
if ($maybe_helper_path =~ m{/}){
my $helper_path = shift @ARGV;
$ENV{MEDIAREMOTEADAPTER_TEST_CLIENT_PATH} = $helper_path;
}
if (!defined $ARGV[0]) {
print_help();
}
my $framework_basename = File::Basename::basename($framework_path);
fail "Provided path is not a framework: $framework_path"
unless $framework_basename =~ s/\.framework$//;
my $framework = File::Spec->catfile($framework_path, $framework_basename);
fail "Framework not found at $framework" unless -e $framework;
my $handle = DynaLoader::dl_load_file($framework, 0)
or fail "Failed to load framework: $framework";
my $function_name = shift @ARGV or fail "Missing function name";
fail "Invalid function name: '$function_name'"
unless $function_name eq "stream"
|| $function_name eq "get"
|| $function_name eq "send"
|| $function_name eq "seek"
|| $function_name eq "shuffle"
|| $function_name eq "repeat"
|| $function_name eq "speed"
|| $function_name eq "test";
sub parse_options {
my ($start_index) = @_;
my %arg_map;
my $i = $start_index;
while ($i <= $#ARGV) {
my $arg = $ARGV[$i];
if ($arg =~ /^--([a-z:\.\\-]+)(?:=(.*))?$/) {
my $key = $1;
my $value = defined $2 ? $2 : undef;
$arg_map{$key} = $value;
splice @ARGV, $i, 1;
}
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
my @flags = split //, $1;
$arg_map{$_} = undef for @flags;
splice @ARGV, $i, 1;
}
else {
$i++;
}
}
return \%arg_map;
}
sub env_func {
my $symbol_name = shift;
return "${symbol_name}_env";
}
sub set_env_param {
my ($func, $index, $name, $value) = @_;
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
}
sub set_env_option_unsafe {
my ($name, $value) = @_;
$name =~ s/-/_/g;
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
}
sub set_env_option {
my ($options, $key) = @_;
my $value = $options->{$key};
if (defined $value) {
fail "Unexpected value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
sub set_env_option_value {
my ($options, $key) = @_;
my $value = $options->{$key};
if (!defined $value) {
fail "Missing value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
my $symbol_name = "adapter_$function_name";
if ($function_name eq "send") {
my $id = shift @ARGV;
fail "Missing ID for '$function_name' command" unless defined $id;
set_env_param($symbol_name, 0, "command", "$id");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "stream") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "no-diff") {
set_env_option($options, $key);
}
elsif ($key eq "debounce") {
set_env_option_value($options, $key);
}
elsif ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "no-artwork") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "experimental-peculiar-debounce:com.tidal.desktop") {
set_env_option_value($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "get") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "no-artwork") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "now") {
set_env_option($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "seek") {
my $position = shift @ARGV;
fail "Missing position for '$function_name' command" unless defined $position;
set_env_param($symbol_name, 0, "position", "$position");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "shuffle") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "repeat") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "speed") {
my $speed = shift @ARGV;
fail "Missing speed for '$function_name' command" unless defined $speed;
set_env_param($symbol_name, 0, "speed", "$speed");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "test") {
$symbol_name = "adapter_test";
}
if (defined shift @ARGV) {
fail "Too many arguments";
}
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
or fail "Symbol '$symbol_name' not found in $framework";
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
eval {
no strict "refs";
&{"main::$function_name"}();
};
if ($@) {
fail "Error executing $function_name: $@";
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_H
#define MEDIAREMOTEADAPTER_ADAPTER_H
#import <Foundation/Foundation.h>
// Methods suffixed with "_env" read its parameters from the environment.
// Parameters must have the format:
// MEDIAREMOTEADAPTER_<FUNC_NAME>_<PARAM_INDEX>_<PARAM_NAME>
// Example: MEDIAREMOTEADAPTER_adapter_send_0_command
extern NSString *kMRAProcessIdentifier;
extern NSString *kMRABundleIdentifier;
extern NSString *kMRAParentApplicationBundleIdentifier;
extern NSString *kMRAPlaying;
extern NSString *kMRADurationMicros;
extern NSString *kMRAElapsedTimeMicros;
extern NSString *kMRATimestampEpochMicros;
extern NSString *kMRAElapsedTimeNow;
extern NSString *kMRAElapsedTimeNowMicros;
extern NSString *kMRATitle;
extern NSString *kMRAArtist;
extern NSString *kMRAAlbum;
extern NSString *kMRADuration;
extern NSString *kMRAElapsedTime;
extern NSString *kMRATimestamp;
extern NSString *kMRAArtworkMimeType;
extern NSString *kMRAArtworkData;
extern NSString *kMRAChapterNumber;
extern NSString *kMRAComposer;
extern NSString *kMRAGenre;
extern NSString *kMRAIsAdvertisement;
extern NSString *kMRAIsBanned;
extern NSString *kMRAIsInWishList;
extern NSString *kMRAIsLiked;
extern NSString *kMRAIsMusicApp;
extern NSString *kMRAPlaybackRate;
extern NSString *kMRAProhibitsSkip;
extern NSString *kMRAQueueIndex;
extern NSString *kMRARadioStationIdentifier;
extern NSString *kMRARepeatMode;
extern NSString *kMRAShuffleMode;
extern NSString *kMRAStartTime;
extern NSString *kMRASupportsFastForward15Seconds;
extern NSString *kMRASupportsIsBanned;
extern NSString *kMRASupportsIsLiked;
extern NSString *kMRASupportsRewind15Seconds;
extern NSString *kMRATotalChapterCount;
extern NSString *kMRATotalDiscCount;
extern NSString *kMRATotalQueueCount;
extern NSString *kMRATotalTrackCount;
extern NSString *kMRATrackNumber;
extern NSString *kMRAUniqueIdentifier;
extern NSString *kMRAContentItemIdentifier;
extern NSString *kMRARadioStationHash;
extern NSString *kMRAMediaType;
// Prints the current MediaRemote now playing information to stdout.
// Data is encoded as a JSON dictionary or "null" when there is no information.
extern void adapter_get();
extern void adapter_get_env();
// Streams MediaRemote now playing updates to stdout.
// Each update is printed on a separate lined, encoded as a JSON dictionary.
// Exits when the process receives a SIGTERM signal.
extern void adapter_stream();
extern void adapter_stream_env();
typedef enum {
kMRAPlay = 0,
kMRAPause = 1,
kMRATogglePlayPause = 2,
kMRAStop = 3,
kMRANextTrack = 4,
kMRAPreviousTrack = 5,
kMRAToggleShuffle = 6,
kMRAToggleRepeat = 7,
kMRAStartForwardSeek = 8,
kMRAEndForwardSeek = 9,
kMRAStartBackwardSeek = 10,
kMRAEndBackwardSeek = 11,
kMRAGoBackFifteenSeconds = 12,
kMRASkipFifteenSeconds = 13,
} MRACommand;
// Sends the given MediaRemote command to the current now playing application.
extern void adapter_send(MRACommand command);
extern void adapter_send_env();
// Seeks the timeline of the nowplaying application to the given position.
// The position must be given in microseconds.
extern void adapter_seek(long position);
extern void adapter_seek_env();
typedef enum {
kMRAShuffleDisabled = 1,
kMRAShuffleAlbums = 2,
kMRAShuffleTracks = 3,
} MRAShuffleMode;
extern void adapter_shuffle(MRAShuffleMode mode);
extern void adapter_shuffle_env();
typedef enum {
kMRARepeatDisabled = 1,
kMRARepeatTrack = 2,
kMRARepeatPlaylist = 3,
} MRARepeatMode;
extern void adapter_repeat(MRARepeatMode mode);
extern void adapter_repeat_env();
extern void adapter_speed(int speed);
extern void adapter_speed_env();
// Tests whether the process is entitled to use the MediaRemote framework.
// Exits with exit code 0, if it is. Any other exit code means it is not.
extern void adapter_test();
#endif // MEDIAREMOTEADAPTER_ADAPTER_H

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import subprocess
import urllib.parse
import os
import re
STATIC_BADGES = """
![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600)
"""
def get_output(command):
result = subprocess.run(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
return result.stdout.strip()
def normalize_whitespace(text):
return re.sub(r'\s+', ' ', text.strip())
def generate_badges():
system_version = get_output(
"system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'"
)
date_output = normalize_whitespace(get_output("date"))
encoded_system_version = urllib.parse.quote(system_version)
encoded_date = urllib.parse.quote(date_output)
badge1 = f"![](https://img.shields.io/static/v1?label=macOS&message={encoded_system_version}&labelColor=444&color=blue)"
badge2 = f"![](https://img.shields.io/static/v1?label=last%20tested&message={encoded_date}&labelColor=444&color)"
return f"{STATIC_BADGES.strip()}\n{badge1}\n{badge2}"
def update_readme(script_dir, new_badges):
readme_path = os.path.join(script_dir, "..", "README.md")
with open(readme_path, "r") as file:
content = file.read()
new_content = re.sub(
r"<!-- BADGES BEGIN -->.*?<!-- BADGES END -->",
f"<!-- BADGES BEGIN -->\n{new_badges}\n<!-- BADGES END -->",
content,
flags=re.DOTALL,
)
with open(readme_path, "w") as file:
file.write(new_content)
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
badges = generate_badges()
update_readme(script_dir, badges)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_ENV_H
#define MEDIAREMOTEADAPTER_ADAPTER_ENV_H
#import <Foundation/Foundation.h>
NSString *getEnvValue(NSString *name);
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
NSString *param_name);
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
NSString *param_name);
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
NSString *param_name);
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
NSString *param_name);
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
NSString *param_name);
NSString *getEnvOption(NSString *option_name);
NSNumber *getEnvOptionInt(NSString *option_name);
#endif // MEDIAREMOTEADAPTER_ADAPTER_ENV_H

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "env.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "utility/helpers.h"
static NSNumber *parseIntegerOrNil(NSString *str) {
if (str == nil)
return nil;
NSScanner *scanner = [NSScanner scannerWithString:str];
NSInteger value;
if ([scanner scanInteger:&value] && [scanner isAtEnd]) {
return @(value);
} else {
return nil;
}
}
NSString *getEnvValue(NSString *name) {
NSDictionary *env = [[NSProcessInfo processInfo] environment];
return env[[name stringByReplacingOccurrencesOfString:@"-"
withString:@"_"]];
}
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *envVarName =
[NSString stringWithFormat:@"MEDIAREMOTEADAPTER_PARAM_%@_%d_%@",
func_name, param_pos, param_name];
return getEnvValue(envVarName);
}
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *result = getEnvFuncParam(func_name, param_pos, param_name);
if (result == nil) {
failf(@"Missing parameter '%@' for "
@"function '%@' at position %d",
param_name, func_name, param_pos);
}
return result;
}
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
NSString *param_name) {
return parseIntegerOrNil(getEnvFuncParam(func_name, param_pos, param_name));
}
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *raw = getEnvFuncParam(func_name, param_pos, param_name);
if (raw == nil) {
failf(@"Missing parameter '%@' for "
@"function '%@' at position %d",
param_name, func_name, param_pos);
}
NSNumber *result = parseIntegerOrNil(raw);
if (result == nil) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is not an integer: '%@'",
param_name, func_name, param_pos, raw);
}
if ([result longLongValue] > [result longValue]) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too large to fit into a "
@"long integer: %@",
param_name, func_name, param_pos, raw);
}
return [result longValue];
}
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
NSString *param_name) {
long value = getEnvFuncParamLongSafe(func_name, param_pos, param_name);
if (value > INT_MAX) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too large to fit into an "
@"integer: %ld",
param_name, func_name, param_pos, value);
}
if (value < INT_MIN) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too small to fit into an "
@"integer: %ld",
param_name, func_name, param_pos, value);
}
return (int)value;
}
NSString *getEnvOption(NSString *option_name) {
NSString *envVarName = [NSString
stringWithFormat:@"MEDIAREMOTEADAPTER_OPTION_%@", option_name];
return getEnvValue(envVarName);
}
NSNumber *getEnvOptionInt(NSString *option_name) {
return parseIntegerOrNil(getEnvOption(option_name));
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GET_H
#define MEDIAREMOTEADAPTER_ADAPTER_GET_H
#import <Foundation/Foundation.h>
NSDictionary *internal_get(BOOL isTestMode);
#endif // MEDIAREMOTEADAPTER_ADAPTER_GET_H

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/get.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
#define GET_TIMEOUT_MILLIS 2000
#define JSON_NULL @"null"
NSDictionary *internal_get(BOOL isTestMode) {
NSString *micros_option = getEnvOption(@"micros");
__block const bool convert_micros = micros_option != nil;
NSString *human_readable_option = getEnvOption(@"human-readable");
__block const bool human_readable = human_readable_option != nil;
NSString *now_option = getEnvOption(@"now");
__block const bool calculate_now = now_option != nil;
NSString *no_artwork_option = getEnvOption(@"no-artwork");
const bool no_artwork = no_artwork_option != nil;
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
__block BOOL isFromTestClient = NO;
dispatch_group_t group = dispatch_group_create();
// PID and Bundle Identifier
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
if (pid != 0) {
liveData[kMRAProcessIdentifier] = @(pid);
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
if (process.bundleIdentifier != nil) {
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
}
dispatch_group_leave(group);
});
if (!ok) {
dispatch_group_leave(group);
}
} else {
dispatch_group_leave(group);
}
});
// Now Playing Client
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
NSString *parentAppBundleID = nil;
if (client && [client respondsToSelector:@selector
(parentApplicationBundleIdentifier)]) {
parentAppBundleID = [client
performSelector:@selector(parentApplicationBundleIdentifier)];
}
if (parentAppBundleID) {
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
}
dispatch_group_leave(group);
});
// Is Playing
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingApplicationIsPlaying(
g_serialdispatchQueue, ^(bool isPlaying) {
liveData[kMRAPlaying] = @(isPlaying);
dispatch_group_leave(group);
});
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
NSDictionary *information) {
NSString *serviceIdentifier =
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
if (!isTestMode &&
[serviceIdentifier
isEqualToString:@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
isFromTestClient = YES;
dispatch_group_leave(group);
return;
}
NSDictionary *converted = convertNowPlayingInformation(
information, convert_micros, calculate_now, no_artwork);
[liveData addEntriesFromDictionary:converted];
dispatch_group_leave(group);
});
// Wait for all async callbacks or timeout
dispatch_time_t timeout =
dispatch_time(DISPATCH_TIME_NOW, GET_TIMEOUT_MILLIS * NSEC_PER_MSEC);
long result = dispatch_group_wait(group, timeout);
if (result != 0) {
printErrf(
@"Reading now playing information timed out after %d milliseconds",
GET_TIMEOUT_MILLIS);
return nil;
}
if (isFromTestClient) {
return nil;
}
if (human_readable) {
makePayloadHumanReadable(liveData);
}
if (!allMandatoryPayloadKeysSet(liveData)) {
return nil;
}
return liveData;
}
void adapter_get() {
NSDictionary *liveData = internal_get(NO);
NSString *micros_option = getEnvOption(@"micros");
const bool convert_micros = micros_option != nil;
NSString *human_readable_option = getEnvOption(@"human-readable");
const bool human_readable = human_readable_option != nil;
NSString *resultStr = nil;
if (!liveData) {
resultStr = JSON_NULL;
} else {
resultStr = serializeJsonDictionarySafe(liveData, human_readable);
if (!resultStr) {
fail(@"Failed to serialize now playing information");
}
}
printOut(resultStr);
}
extern void adapter_get_env() { adapter_get(); }

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
#define MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
#import <CoreFoundation/CoreFoundation.h>
#import <dispatch/dispatch.h>
#import "private/MediaRemote.h"
extern MediaRemote* g_mediaRemote;
extern dispatch_queue_t g_serialdispatchQueue;
#endif // MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "globals.h"
#import "utility/helpers.h"
MediaRemote *g_mediaRemote = NULL;
dispatch_queue_t g_serialdispatchQueue;
__attribute__((constructor)) static void initGlobals() {
g_mediaRemote = [[MediaRemote alloc] init];
if (!g_mediaRemote) {
fail(@"Failed to initialize MediaRemote Framework");
return;
}
g_serialdispatchQueue = dispatch_queue_create(
"mediaremote-adapter.serial-dispatch-queue", DISPATCH_QUEUE_SERIAL);
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
#define MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
#import <Foundation/Foundation.h>
// These keys are mandatory and must never be null, empty or missing.
NSArray<NSString *> *mandatoryPayloadKeys(void);
// Checks whether all mandatory payload keys returned by mandatoryPayloadKeys()
// are present in the given payload dictionary and have a non-null value.
bool allMandatoryPayloadKeysSet(NSDictionary *data);
// These keys identify a now playing item uniquely.
NSArray<NSString *> *identifyingPayloadKeys(void);
#endif // MEDIAREMOTEADAPTER_ADAPTER_KEYS_H

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "keys.h"
#import "MediaRemoteAdapter.h"
NSString *kMRAProcessIdentifier = @"processIdentifier";
NSString *kMRABundleIdentifier = @"bundleIdentifier";
NSString *kMRAParentApplicationBundleIdentifier =
@"parentApplicationBundleIdentifier";
NSString *kMRAPlaying = @"playing";
NSString *kMRADurationMicros = @"durationMicros";
NSString *kMRAElapsedTimeMicros = @"elapsedTimeMicros";
NSString *kMRATimestampEpochMicros = @"timestampEpochMicros";
NSString *kMRAElapsedTimeNow = @"elapsedTimeNow";
NSString *kMRAElapsedTimeNowMicros = @"elapsedTimeNowMicros";
NSString *kMRATitle = @"title";
NSString *kMRAArtist = @"artist";
NSString *kMRAAlbum = @"album";
NSString *kMRADuration = @"duration";
NSString *kMRAElapsedTime = @"elapsedTime";
NSString *kMRATimestamp = @"timestamp";
NSString *kMRAArtworkMimeType = @"artworkMimeType";
NSString *kMRAArtworkData = @"artworkData";
NSString *kMRAChapterNumber = @"chapterNumber";
NSString *kMRAComposer = @"composer";
NSString *kMRAGenre = @"genre";
NSString *kMRAIsAdvertisement = @"isAdvertisement";
NSString *kMRAIsBanned = @"isBanned";
NSString *kMRAIsInWishList = @"isInWishList";
NSString *kMRAIsLiked = @"isLiked";
NSString *kMRAIsMusicApp = @"isMusicApp";
NSString *kMRAPlaybackRate = @"playbackRate";
NSString *kMRAProhibitsSkip = @"prohibitsSkip";
NSString *kMRAQueueIndex = @"queueIndex";
NSString *kMRARadioStationIdentifier = @"radioStationIdentifier";
NSString *kMRARepeatMode = @"repeatMode";
NSString *kMRAShuffleMode = @"shuffleMode";
NSString *kMRAStartTime = @"startTime";
NSString *kMRASupportsFastForward15Seconds = @"supportsFastForward15Seconds";
NSString *kMRASupportsIsBanned = @"supportsIsBanned";
NSString *kMRASupportsIsLiked = @"supportsIsLiked";
NSString *kMRASupportsRewind15Seconds = @"supportsRewind15Seconds";
NSString *kMRATotalChapterCount = @"totalChapterCount";
NSString *kMRATotalDiscCount = @"totalDiscCount";
NSString *kMRATotalQueueCount = @"totalQueueCount";
NSString *kMRATotalTrackCount = @"totalTrackCount";
NSString *kMRATrackNumber = @"trackNumber";
NSString *kMRAUniqueIdentifier = @"uniqueIdentifier";
NSString *kMRAContentItemIdentifier = @"contentItemIdentifier";
NSString *kMRARadioStationHash = @"radioStationHash";
NSString *kMRAMediaType = @"mediaType";
NSArray<NSString *> *mandatoryPayloadKeys(void) {
return @[ kMRAProcessIdentifier, kMRATitle, kMRAPlaying ];
}
bool allMandatoryPayloadKeysSet(NSDictionary *data) {
NSArray<NSString *> *keys = mandatoryPayloadKeys();
for (NSString *key in keys) {
if (data[key] == nil || data[key] == [NSNull null]) {
return false;
}
id value = data[key];
if ([value isKindOfClass:[NSString class]] &&
[(NSString *)value length] == 0) {
return false;
}
}
return true;
}
NSArray<NSString *> *identifyingPayloadKeys(void) {
return @[
kMRAProcessIdentifier, kMRABundleIdentifier,
kMRAParentApplicationBundleIdentifier, kMRATitle, kMRAArtist, kMRAAlbum
];
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
#define MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
#import <Foundation/Foundation.h>
// Requests information once so that the process runs long enough for the
// MediaRemote command to actually be sent to the now playing application.
void waitForCommandCompletion();
// Converts raw MediaRemote now playing information to adapter keys.
// Optionally replaces keys with time values with microseconds equivalents.
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
bool convertMicros,
bool calculateNow,
bool withoutArtwork);
#endif // MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "now_playing.h"
#import "MediaRemoteAdapter.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "private/MediaRemote.h"
#define WAIT_TIMEOUT_MILLIS 2000
void waitForCommandCompletion() {
id semaphore = dispatch_semaphore_create(0);
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
dispatch_semaphore_signal(semaphore);
});
dispatch_time_t timeout =
dispatch_time(DISPATCH_TIME_NOW, WAIT_TIMEOUT_MILLIS * NSEC_PER_MSEC);
dispatch_semaphore_wait(semaphore, timeout);
}
NSNumber *getElapsedTimeNow(NSDictionary *information) {
id elapsed = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (![elapsed isKindOfClass:[NSNumber class]]) {
return nil;
}
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
if (![timestamp isKindOfClass:[NSDate class]]) {
return elapsed;
}
NSTimeInterval timestampEpoch = [(NSDate *)timestamp timeIntervalSince1970];
NSTimeInterval currentEpoch = [[NSDate date] timeIntervalSince1970];
NSTimeInterval timeDiff = currentEpoch - timestampEpoch;
double playbackRate = 0;
id playbackRateVal = information[kMRMediaRemoteNowPlayingInfoPlaybackRate];
if ([playbackRateVal isKindOfClass:[NSNumber class]]) {
playbackRate = [(NSNumber *)playbackRateVal doubleValue];
}
double realElapsed = [(NSNumber *)elapsed doubleValue];
if (playbackRate >= 0) {
realElapsed += timeDiff * playbackRate;
}
return @(realElapsed);
}
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
bool convertMicros,
bool calculateNow,
bool withoutArtwork) {
NSMutableDictionary *data = [NSMutableDictionary dictionary];
void (^setKey)(id, id) = ^(id key, id fromKey) {
id value = nil;
if (information != nil) {
id result = information[fromKey];
if (result != nil) {
value = result;
}
}
if (value != nil) {
[data setObject:value forKey:key];
}
};
void (^setValue)(id key, id (^)(void)) = ^(id key, id (^evaluate)(void)) {
id value = nil;
if (information != nil) {
value = evaluate();
}
if (value != nil) {
[data setObject:value forKey:key];
}
};
setKey(kMRATitle, kMRMediaRemoteNowPlayingInfoTitle);
setKey(kMRAArtist, kMRMediaRemoteNowPlayingInfoArtist);
setKey(kMRAAlbum, kMRMediaRemoteNowPlayingInfoAlbum);
if (!withoutArtwork) {
setKey(kMRAArtworkMimeType,
kMRMediaRemoteNowPlayingInfoArtworkMIMEType);
setKey(kMRAArtworkData, kMRMediaRemoteNowPlayingInfoArtworkData);
}
if (!convertMicros) {
setKey(kMRADuration, kMRMediaRemoteNowPlayingInfoDuration);
setKey(kMRAElapsedTime, kMRMediaRemoteNowPlayingInfoElapsedTime);
setKey(kMRATimestamp, kMRMediaRemoteNowPlayingInfoTimestamp);
if (calculateNow) {
// This key is added and does not replace the original because it
// is just a rough estimation, meant to be used for convenience.
setValue(kMRAElapsedTimeNow, ^id {
id elapsedTime =
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
id nowValue = getElapsedTimeNow(information);
if (nowValue != nil) {
elapsedTime = nowValue;
}
return elapsedTime;
});
}
} else {
// These keys replace their original counterparts because semantics
// don't change and no accuracy is lost, merely the time unit changes.
setValue(kMRADurationMicros, ^id {
id duration = information[kMRMediaRemoteNowPlayingInfoDuration];
if (duration != nil && [duration isKindOfClass:[NSNumber class]]) {
NSTimeInterval durationMicros =
[duration doubleValue] * 1000 * 1000;
return @(floor(durationMicros));
}
return nil;
});
setValue(kMRAElapsedTimeMicros, ^id {
id elapsedTime = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (elapsedTime != nil &&
[elapsedTime isKindOfClass:[NSNumber class]]) {
NSTimeInterval elapsedTimeMicros =
[elapsedTime doubleValue] * 1000 * 1000;
return @(floor(elapsedTimeMicros));
}
return nil;
});
if (calculateNow) {
// This key is added and does not replace the original because it
// is just a rough estimation, meant to be used for convenience.
setValue(kMRAElapsedTimeNowMicros, ^id {
id elapsedTime =
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (calculateNow) {
elapsedTime = getElapsedTimeNow(information);
}
if (elapsedTime != nil &&
[elapsedTime isKindOfClass:[NSNumber class]]) {
NSTimeInterval elapsedTimeMicros =
[elapsedTime doubleValue] * 1000 * 1000;
return @(floor(elapsedTimeMicros));
}
return nil;
});
}
setValue(kMRATimestampEpochMicros, ^id {
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
if (timestamp != nil && [timestamp isKindOfClass:[NSDate class]]) {
NSTimeInterval timestampEpoch = [timestamp timeIntervalSince1970];
NSTimeInterval timestampEpochMicro = timestampEpoch * 1000 * 1000;
return @(floor(timestampEpochMicro));
}
return nil;
});
}
// Some of the following keys might fail due to not being convertible
// to JSON automatically. This is difficult to test because most media
// players do not even set these keys and the data types are not documented
// anywhere. Still, JSON serialization of the resulting dictionary deletes
// any invalid keys, converts or deletes invalid values and prints error
// messages whenever any dictionary entry has been removed. Users should
// report whenever they encounter such an error with these keys.
// clang-format off
setKey(kMRAChapterNumber, kMRMediaRemoteNowPlayingInfoChapterNumber);
setKey(kMRAComposer, kMRMediaRemoteNowPlayingInfoComposer);
setKey(kMRAGenre, kMRMediaRemoteNowPlayingInfoGenre);
setKey(kMRAIsAdvertisement, kMRMediaRemoteNowPlayingInfoIsAdvertisement);
setKey(kMRAIsBanned, kMRMediaRemoteNowPlayingInfoIsBanned);
setKey(kMRAIsInWishList, kMRMediaRemoteNowPlayingInfoIsInWishList);
setKey(kMRAIsLiked, kMRMediaRemoteNowPlayingInfoIsLiked);
setKey(kMRAIsMusicApp, kMRMediaRemoteNowPlayingInfoIsMusicApp);
setKey(kMRAPlaybackRate, kMRMediaRemoteNowPlayingInfoPlaybackRate);
setKey(kMRAProhibitsSkip, kMRMediaRemoteNowPlayingInfoProhibitsSkip);
setKey(kMRAQueueIndex, kMRMediaRemoteNowPlayingInfoQueueIndex);
setKey(kMRARadioStationIdentifier, kMRMediaRemoteNowPlayingInfoRadioStationIdentifier);
setKey(kMRARepeatMode, kMRMediaRemoteNowPlayingInfoRepeatMode);
setKey(kMRAShuffleMode, kMRMediaRemoteNowPlayingInfoShuffleMode);
setKey(kMRAStartTime, kMRMediaRemoteNowPlayingInfoStartTime);
setKey(kMRASupportsFastForward15Seconds, kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds);
setKey(kMRASupportsIsBanned, kMRMediaRemoteNowPlayingInfoSupportsIsBanned);
setKey(kMRASupportsIsLiked, kMRMediaRemoteNowPlayingInfoSupportsIsLiked);
setKey(kMRASupportsRewind15Seconds, kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds);
setKey(kMRATotalChapterCount, kMRMediaRemoteNowPlayingInfoTotalChapterCount);
setKey(kMRATotalDiscCount, kMRMediaRemoteNowPlayingInfoTotalDiscCount);
setKey(kMRATotalQueueCount, kMRMediaRemoteNowPlayingInfoTotalQueueCount);
setKey(kMRATotalTrackCount, kMRMediaRemoteNowPlayingInfoTotalTrackCount);
setKey(kMRATrackNumber, kMRMediaRemoteNowPlayingInfoTrackNumber);
setKey(kMRAUniqueIdentifier, kMRMediaRemoteNowPlayingInfoUniqueIdentifier);
setKey(kMRAContentItemIdentifier, kMRMediaRemoteNowPlayingInfoContentItemIdentifier);
setKey(kMRARadioStationHash, kMRMediaRemoteNowPlayingInfoRadioStationHash);
setKey(kMRAMediaType, kMRMediaRemoteNowPlayingInfoMediaType);
// clang-format on
return data;
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedModes;
__attribute__((constructor)) static void init() {
acceptedModes = @[
@(kMRARepeatDisabled),
@(kMRARepeatTrack),
@(kMRARepeatPlaylist),
];
}
static bool isModeAccepted(int mode) {
return [acceptedModes containsObject:@(mode)];
}
void adapter_repeat(MRARepeatMode mode) {
if (!isModeAccepted((int)mode)) {
failf(@"Invalid repeat mode: %d", (int)mode);
}
g_mediaRemote.setRepeatMode((int)mode);
waitForCommandCompletion();
}
static inline int repeat_0_mode() {
return getEnvFuncParamIntSafe(@"adapter_repeat", 0, @"mode");
}
void adapter_repeat_env() { adapter_repeat((MRARepeatMode)repeat_0_mode()); }

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
void adapter_seek(long position) {
if (position < 0) {
failf(@"Negative values are not allowed: %d", position);
}
g_mediaRemote.setElapsedTime(position / 1000000.0);
waitForCommandCompletion();
}
static inline long seek_0_position() {
return getEnvFuncParamLongSafe(@"adapter_seek", 0, @"position");
}
void adapter_seek_env() { adapter_seek(seek_0_position()); }

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedCommands;
__attribute__((constructor)) static void init() {
acceptedCommands = @[
@(kMRAPlay),
@(kMRAPause),
@(kMRATogglePlayPause),
@(kMRAStop),
@(kMRANextTrack),
@(kMRAPreviousTrack),
@(kMRAToggleShuffle),
@(kMRAToggleRepeat),
@(kMRAStartForwardSeek),
@(kMRAEndForwardSeek),
@(kMRAStartBackwardSeek),
@(kMRAEndBackwardSeek),
@(kMRAGoBackFifteenSeconds),
@(kMRASkipFifteenSeconds),
];
// TODO like/unlike tracks by reading now playing information first,
// getting the track ID, station ID and station hash
// and then sending the respective MRCommand.
// does "ban" mean "remove like" here?
}
static MRCommand findCommand(int command, bool *found) {
if ([acceptedCommands containsObject:@(command)]) {
*found = true;
return (MRCommand)command;
}
*found = false;
return (MRCommand)0;
}
void adapter_send(MRACommand command) {
bool ok = false;
MRCommand commandValue = findCommand((int)command, &ok);
if (!ok) {
failf(@"Invalid command: %d", command);
}
bool result = g_mediaRemote.sendCommand(commandValue, nil);
if (!result) {
failf(@"Failed to send command %d", command);
}
waitForCommandCompletion();
}
static inline int send_0_command() {
return getEnvFuncParamIntSafe(@"adapter_send", 0, @"command");
}
void adapter_send_env() { adapter_send((MRACommand)send_0_command()); }

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedModes;
__attribute__((constructor)) static void init() {
acceptedModes = @[
@(kMRAShuffleDisabled),
@(kMRAShuffleAlbums),
@(kMRAShuffleTracks),
];
}
static bool isModeAccepted(int mode) {
return [acceptedModes containsObject:@(mode)];
}
void adapter_shuffle(MRAShuffleMode mode) {
if (!isModeAccepted((int)mode)) {
failf(@"Invalid shuffle mode: %d", (int)mode);
}
g_mediaRemote.setShuffleMode((int)mode);
waitForCommandCompletion();
}
static inline int shuffle_0_mode() {
return getEnvFuncParamIntSafe(@"adapter_shuffle", 0, @"mode");
}
void adapter_shuffle_env() {
adapter_shuffle((MRAShuffleMode)shuffle_0_mode());
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
void adapter_speed(int speed) {
if (speed < 0) {
failf(@"Negative values are not allowed: %d", speed);
}
g_mediaRemote.setPlaybackSpeed(speed);
waitForCommandCompletion();
}
static inline int speed_0_speed() {
return getEnvFuncParamIntSafe(@"adapter_speed", 0, @"speed");
}
void adapter_speed_env() { adapter_speed(speed_0_speed()); }

View File

@@ -0,0 +1,523 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import <AppKit/AppKit.h>
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "adapter/now_playing.h"
#import "private/MediaRemote.h"
#import "utility/Debounce.h"
#import "utility/helpers.h"
#ifndef DEBOUNCE_DELAY_MILLIS
#define DEBOUNCE_DELAY_MILLIS 0
#endif
static CFRunLoopRef g_runLoop = NULL;
static NSString *serializeData(NSDictionary *data, BOOL diff, BOOL pretty) {
return serializeJsonDictionarySafe(
@{
@"type" : @"data",
@"diff" : @(diff),
@"payload" : data ?: @{},
},
pretty);
}
static NSDictionary *createDiff(NSDictionary *a, NSDictionary *b) {
NSMutableDictionary *diff = [NSMutableDictionary dictionary];
NSMutableSet *allKeys = [NSMutableSet setWithArray:a.allKeys];
[allKeys addObjectsFromArray:b.allKeys];
for (id key in allKeys) {
id oldValue = a[key];
id newValue = b[key];
BOOL valuesDiffer = NO;
if (oldValue == nil && newValue != nil) {
valuesDiffer = YES;
} else if (oldValue != nil && newValue == nil) {
valuesDiffer = YES;
} else if (![oldValue isEqual:newValue]) {
valuesDiffer = YES;
}
if (valuesDiffer) {
diff[key] = newValue ?: [NSNull null];
}
}
return [diff copy];
}
static BOOL isSameItemIdentity(NSDictionary *a, NSDictionary *b) {
NSArray<NSString *> *keys = identifyingPayloadKeys();
for (NSString *key in keys) {
id aValue = a[key];
id bValue = b[key];
if (aValue == nil && bValue == nil) {
continue;
}
if (aValue == nil || bValue == nil) {
return NO;
}
if (![aValue isEqual:bValue]) {
return NO;
}
}
return YES;
}
static NSDictionary *previousData = nil;
static void printData(NSDictionary *data, BOOL diff, BOOL pretty) {
NSString *serialized = nil;
if (diff && previousData != nil && isSameItemIdentity(previousData, data)) {
NSDictionary *result = createDiff(previousData, data);
if ([result count] == 0) {
return;
}
serialized = serializeData(result, YES, pretty);
} else {
serialized = serializeData(data, NO, pretty);
}
if (serialized != nil) {
if (diff) {
previousData = [data copy];
}
// Print the serialized data without duplicates. Note that while this
// can fail when the key order in the serialized JSON output changes,
// it practically won't because if it did, there would also be a change
// in values that needs to be reported.
printOutUnique(serialized);
}
if (!diff) {
previousData = nil;
}
}
static void appForNotification(NSNotification *notification,
void (^block)(NSRunningApplication *)) {
NSDictionary *userInfo = notification.userInfo;
id pidValue = userInfo[kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey];
if (pidValue != nil) {
int pid = [pidValue intValue];
appForPID(pid, block);
} else {
block(nil);
}
};
typedef struct MetadataStats {
BOOL trackTitleChanged;
int identifyingTrackKeysIdentical;
int identifyingTrackKeysChanged;
} MetadataStats;
static MetadataStats createMetadataStats() {
MetadataStats stats = {
.trackTitleChanged = NO,
.identifyingTrackKeysIdentical = 0,
.identifyingTrackKeysChanged = 0,
};
return stats;
}
static MetadataStats compareIdentifyingTrackKeys(NSDictionary *prev,
NSDictionary *next) {
MetadataStats stats = createMetadataStats();
for (NSString *key in @[ kMRATitle, kMRAArtist, kMRAAlbum ]) {
id a = prev[key], b = next[key];
if (a == nil || b == nil)
continue;
if ([a isEqual:b]) {
stats.identifyingTrackKeysIdentical++;
} else {
stats.identifyingTrackKeysChanged++;
if ([key isEqualToString:kMRATitle]) {
stats.trackTitleChanged = YES;
}
}
}
return stats;
}
extern void adapter_stream() {
// Get ADAPTER_TEST_MODE as a boolean and set BOOL isTestMode
BOOL isTestMode = NO;
char *testModeEnv = getenv("ADAPTER_TEST_MODE");
if (testModeEnv && strcmp(testModeEnv, "0") != 0 &&
strlen(testModeEnv) > 0) {
isTestMode = YES;
}
int debounce_delay_millis = 0;
NSNumber *debounce_option = getEnvOptionInt(@"debounce");
if (debounce_option != nil) {
debounce_delay_millis = [debounce_option intValue];
}
NSString *no_diff_option = getEnvOption(@"no_diff");
NSString *no_artwork_option = getEnvOption(@"no-artwork");
NSString *micros_option = getEnvOption(@"micros");
NSString *human_readable_option = getEnvOption(@"human-readable");
// This option is needed for media players which, when changing tracks,
// update the artist and/or other fields later than e.g. the title, the
// invalid in-between metadata therefore representing "peculiar" media. The
// only known player that does this is the TIDAL desktop player with the
// bundle ID "com.tidal.desktop". This is easy to reproduce when playing
// media from a playlist with tracks from different artists.
// FIXME Implement this for any bundle ID, should other players need it.
// In that case parse any "experimental-peculiar-debounce:*" option.
NSNumber *peculiar_debounce_option =
getEnvOptionInt(@"experimental-peculiar-debounce:com.tidal.desktop");
__block NSString *peculiar_bundle_id = nil;
__block Debounce *peculiar_debounce = nil;
__block BOOL did_peculiar_debounce = NO;
if (peculiar_debounce_option != nil) {
peculiar_bundle_id = @"com.tidal.desktop";
int debounce_millis = [peculiar_debounce_option intValue];
peculiar_debounce =
[[Debounce alloc] initWithDelay:(debounce_millis / 1000.0)
queue:g_serialdispatchQueue];
}
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
__block MetadataStats liveDataStats = createMetadataStats();
__block const Debounce *const debounce =
[[Debounce alloc] initWithDelay:(debounce_delay_millis / 1000.0)
queue:g_serialdispatchQueue];
__block const BOOL no_diff = (no_diff_option != nil);
__block const BOOL no_artwork = (no_artwork_option != nil);
__block const BOOL convert_micros = (micros_option != nil);
__block const bool human_readable = (human_readable_option != nil);
void (^localPrintData)(NSDictionary *) = ^(NSDictionary *data) {
printData(data, !no_diff, human_readable);
};
void (^directHandle)() = ^() {
if (allMandatoryPayloadKeysSet(liveData)) {
if (human_readable) {
NSMutableDictionary *shallowClone =
[NSMutableDictionary dictionaryWithDictionary:liveData];
makePayloadHumanReadable(shallowClone);
localPrintData(shallowClone);
} else {
localPrintData(liveData);
}
} else {
localPrintData(nil);
}
};
void (^internalHandle)(bool) = ^(bool updatedStats) {
if (peculiar_debounce == nil ||
![peculiar_bundle_id isEqual:liveData[kMRABundleIdentifier]]) {
directHandle();
return;
}
if (updatedStats && liveDataStats.trackTitleChanged &&
liveDataStats.identifyingTrackKeysIdentical > 0) {
did_peculiar_debounce = true;
[peculiar_debounce call:^{
did_peculiar_debounce = false;
directHandle();
}];
} else if (did_peculiar_debounce &&
(!updatedStats ||
liveDataStats.identifyingTrackKeysChanged == 0)) {
// Ignore this handle call, since there is an active debounce call.
} else {
[peculiar_debounce cancel];
did_peculiar_debounce = false;
directHandle();
}
};
void (^handle)() = ^() {
internalHandle(false);
};
void (^handleWithUpdatedStats)() = ^() {
internalHandle(true);
};
void (^requestNowPlayingApplicationPID)() = ^{
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
if (pid == 0) {
liveData[kMRAProcessIdentifier] = nil;
handle();
return;
}
liveData[kMRAProcessIdentifier] = @(pid);
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
if (process.bundleIdentifier != nil) {
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
}
handle();
});
if (!ok) {
handle();
}
});
};
void (^requestNowPlayingParentApplicationBundleIdentifier)() = ^{
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
NSString *parentAppBundleID = nil;
if (client && [client respondsToSelector:@selector
(parentApplicationBundleIdentifier)]) {
id result = [client
performSelector:@selector(parentApplicationBundleIdentifier)];
if ([result isKindOfClass:[NSString class]]) {
parentAppBundleID = result;
}
}
if (parentAppBundleID) {
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
} else {
[liveData removeObjectForKey:kMRAParentApplicationBundleIdentifier];
}
handle();
});
};
void (^requestNowPlayingApplicationIsPlaying)() = ^{
g_mediaRemote.getNowPlayingApplicationIsPlaying(
g_serialdispatchQueue, ^(bool isPlaying) {
liveData[kMRAPlaying] = @(isPlaying);
handle();
});
};
void (^requestNowPlayingInfo)() = ^{
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
NSDictionary *information) {
NSString *serviceIdentifier =
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
if (!isTestMode &&
[serviceIdentifier
isEqualToString:
@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
return;
}
NSMutableDictionary *converted = convertNowPlayingInformation(
information, convert_micros, false, no_artwork);
// Transfer anything over from the existing live data.
if (liveData[kMRAProcessIdentifier] != nil) {
converted[kMRAProcessIdentifier] = liveData[kMRAProcessIdentifier];
}
if (liveData[kMRABundleIdentifier] != nil) {
converted[kMRABundleIdentifier] = liveData[kMRABundleIdentifier];
}
if (liveData[kMRAParentApplicationBundleIdentifier] != nil) {
converted[kMRAParentApplicationBundleIdentifier] =
liveData[kMRAParentApplicationBundleIdentifier];
}
if (liveData[kMRAPlaying] != nil) {
converted[kMRAPlaying] = liveData[kMRAPlaying];
}
// Use the old artwork data, since often the MediaRemote framework
// unloads the artwork and then loads it again shortly after.
// Only do this when the items have the same identity.
if (isSameItemIdentity(liveData, converted) &&
liveData[kMRAArtworkData] != nil &&
liveData[kMRAArtworkData] != [NSNull null] &&
converted[kMRAArtworkData] == nil) {
converted[kMRAArtworkData] = liveData[kMRAArtworkData];
if (liveData[kMRAArtworkMimeType] != nil &&
liveData[kMRAArtworkMimeType] != [NSNull null] &&
converted[kMRAArtworkMimeType] == nil) {
converted[kMRAArtworkMimeType] = liveData[kMRAArtworkMimeType];
}
}
liveDataStats = compareIdentifyingTrackKeys(liveData, converted);
[liveData setDictionary:converted];
handleWithUpdatedStats();
});
};
void (^requestAll)() = ^{
requestNowPlayingApplicationPID();
requestNowPlayingParentApplicationBundleIdentifier();
requestNowPlayingApplicationIsPlaying();
requestNowPlayingInfo();
};
void (^resetAll)() = ^{
[liveData removeAllObjects];
};
void (^refreshAll)() = ^{
resetAll();
requestAll();
};
// FIXME Is this foolproof? This continues and registers observers
// which might intervene with the initial requests.
requestAll();
NSNotificationCenter *default_center = [NSNotificationCenter defaultCenter];
NSNotificationCenter *shared_workscape_notification_center =
[[NSWorkspace sharedWorkspace] notificationCenter];
// TODO Refactor the below two callbacks. They share a lot of code.
id is_playing_change_observer = [default_center
addObserverForName:
kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
dispatch_async(g_serialdispatchQueue, ^() {
appForNotification(notification, ^(
NSRunningApplication *process) {
if (process == nil) {
// The process for this notification could not be
// determined. Assume that there is no now playing
// application anymore.
resetAll();
handle();
return;
}
id isPlayingValue =
notification.userInfo
[kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey];
if (isPlayingValue == nil) {
return;
}
if (liveData[kMRABundleIdentifier] != nil &&
process.bundleIdentifier != nil &&
![liveData[kMRABundleIdentifier]
isEqual:process.bundleIdentifier]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] != nil &&
![liveData[kMRAProcessIdentifier]
isEqual:@(process.processIdentifier)]) {
// This is a different process, reset all data.
resetAll();
}
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
requestNowPlayingParentApplicationBundleIdentifier();
liveData[kMRAPlaying] = @([isPlayingValue boolValue]);
if (liveData[kMRATitle] == nil) {
requestNowPlayingInfo();
} else {
handle();
}
});
});
}];
id info_change_observer = [default_center
addObserverForName:kMRMediaRemoteNowPlayingInfoDidChangeNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
[debounce call:^{
appForNotification(notification, ^(
NSRunningApplication *process) {
if (process == nil) {
// The process for this notification could not be
// determined. Assume that there is no now playing
// application anymore.
resetAll();
handle();
return;
}
if (liveData[kMRABundleIdentifier] != nil &&
process.bundleIdentifier != nil &&
![liveData[kMRABundleIdentifier]
isEqual:process.bundleIdentifier]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] != nil &&
![liveData[kMRAProcessIdentifier]
isEqual:@(process.processIdentifier)]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] == nil) {
requestNowPlayingApplicationPID();
}
if (liveData[kMRAParentApplicationBundleIdentifier] ==
nil) {
requestNowPlayingParentApplicationBundleIdentifier();
}
if (liveData[kMRAPlaying] == nil) {
requestNowPlayingApplicationIsPlaying();
}
requestNowPlayingInfo();
});
}];
}];
// Register notifications for when applications are closed.
id app_termination_observer = [shared_workscape_notification_center
addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
dispatch_async(g_serialdispatchQueue, ^() {
NSDictionary *userInfo = [notification userInfo];
id bundleIdentifier =
userInfo[@"NSApplicationBundleIdentifier"];
if (bundleIdentifier != nil &&
[bundleIdentifier
isEqual:liveData[kMRABundleIdentifier]]) {
// Refresh all data, since the application terminated.
refreshAll();
}
});
}];
g_mediaRemote.registerForNowPlayingNotifications(g_serialdispatchQueue);
CFRunLoopRun();
g_mediaRemote.unregisterForNowPlayingNotifications();
[default_center removeObserver:is_playing_change_observer];
[default_center removeObserver:info_change_observer];
[shared_workscape_notification_center
removeObserver:app_termination_observer];
}
extern void adapter_stream_env() { adapter_stream(); }
extern void _adapter_stream_cancel() {
if (g_runLoop) {
CFRunLoopStop(g_runLoop);
}
}
static void handleSignal(int signal) {
if (signal == SIGINT || signal == SIGTERM) {
_adapter_stream_cancel();
}
}
__attribute__((constructor)) static void init() {
g_runLoop = CFRunLoopGetCurrent();
signal(SIGINT, handleSignal);
signal(SIGTERM, handleSignal);
}
__attribute__((destructor)) static void teardown() { _adapter_stream_cancel(); }
// FIXME Fix "peculiar media" (artist is updated later than title). Example:
/*
35.558 Thirteen by Big Star on Camping Songs
36.091 Good Vibrations (Remastered 2001) by Big Star on Camping Songs
36.204 Good Vibrations (Remastered 2001) by Big Star on Camping Songs (+image)
36.624 Good Vibrations (Remastered 2001) by The Beach Boys on Camping Songs
*/

View File

@@ -0,0 +1,217 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#include <signal.h>
#import "MediaRemoteAdapter.h"
#import "adapter/get.h"
#import "test/NowPlayingTest.h"
#import "utility/helpers.h"
static NSTask *nowPlayingClientHelperTask = nil;
static NSFileHandle *helperInput = nil;
static NSFileHandle *helperOutput = nil;
void cleanup_helper() {
if (nowPlayingClientHelperTask && helperInput && helperOutput) {
@try {
[helperInput writeData:[@"cleanup\n"
dataUsingEncoding:NSUTF8StringEncoding]];
[helperInput closeFile];
} @catch (NSException *exception) {
}
// Graceful shutdown with timeout
NSTimeInterval timeout = 2.0;
NSDate *cleanupDeadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
while (nowPlayingClientHelperTask.isRunning &&
[cleanupDeadline timeIntervalSinceNow] > 0) {
[NSThread sleepForTimeInterval:0.1];
}
if (nowPlayingClientHelperTask.isRunning) {
@try {
[nowPlayingClientHelperTask terminate];
} @catch (NSException *exception) {
}
NSDate *terminationDeadline =
[NSDate dateWithTimeIntervalSinceNow:1.0];
while (nowPlayingClientHelperTask.isRunning &&
[terminationDeadline timeIntervalSinceNow] > 0) {
[NSThread sleepForTimeInterval:0.1];
}
}
if (nowPlayingClientHelperTask.isRunning) {
// Force kill as last resort
kill(nowPlayingClientHelperTask.processIdentifier, SIGKILL);
}
@try {
if (helperOutput.readabilityHandler) {
helperOutput.readabilityHandler = nil;
}
[helperOutput closeFile];
} @catch (__unused NSException *exception) {
}
@
try {
[nowPlayingClientHelperTask waitUntilExit];
} @catch (__unused NSException *exception) {
}
} else if (nowPlayingClientHelperTask) {
@try {
[nowPlayingClientHelperTask terminate];
[nowPlayingClientHelperTask waitUntilExit];
} @catch (__unused NSException *exception) {
}
}
nowPlayingClientHelperTask = nil;
helperInput = nil;
helperOutput = nil;
}
void cleanup_and_exit() {
cleanup_helper();
exit(1);
}
void handleSignal(int signal) {
if (signal == SIGINT || signal == SIGTERM)
cleanup_and_exit();
}
extern void adapter_test(void) {
@autoreleasepool {
signal(SIGINT, handleSignal);
signal(SIGTERM, handleSignal);
signal(SIGPIPE, SIG_IGN);
// If adapterOutput is not null, we know the adapter is working
// correctly
NSDictionary *result = internal_get(YES);
if (result != nil) {
cleanup_helper();
exit(0);
}
// Instantiate helper to ensure MediaRemote has data
// We only do this if adapterOutput is null to minimize the impact on
// other apps using the adapter
NSString *helperPath =
NSProcessInfo.processInfo
.environment[@"MEDIAREMOTEADAPTER_TEST_CLIENT_PATH"];
if (helperPath.length == 0) {
printErrf(@"Test client path is missing");
cleanup_helper();
exit(1);
}
// Set up pipes for communication with the helper process
NSPipe *inputPipe = [NSPipe pipe];
NSPipe *outputPipe = [NSPipe pipe];
nowPlayingClientHelperTask = [[NSTask alloc] init];
nowPlayingClientHelperTask.launchPath = helperPath;
nowPlayingClientHelperTask.standardInput = inputPipe;
nowPlayingClientHelperTask.standardOutput = outputPipe;
@try {
[nowPlayingClientHelperTask launch];
} @catch (NSException *exception) {
printErrf(
@"Exeption while trying to launch test client task: %@: %@",
exception.name, exception.reason);
cleanup_helper();
exit(2);
}
helperInput = inputPipe.fileHandleForWriting;
helperOutput = outputPipe.fileHandleForReading;
dispatch_semaphore_t setupSem = dispatch_semaphore_create(0);
NSMutableString *lineBuffer = [[NSMutableString alloc] init];
helperOutput.readabilityHandler = ^(NSFileHandle *fh) {
@autoreleasepool {
NSData *chunk = [fh availableData];
if (chunk.length == 0) {
fh.readabilityHandler = nil;
return;
}
// Validate UTF-8 encoding with graceful degradation
NSString *chunkStr =
[[NSString alloc] initWithData:chunk
encoding:NSUTF8StringEncoding];
if (!chunkStr) {
return;
}
[lineBuffer appendString:chunkStr];
NSUInteger bufferLength = [lineBuffer length];
NSUInteger searchStart = 0;
while (searchStart < bufferLength) {
NSRange remainingRange =
NSMakeRange(searchStart, bufferLength - searchStart);
NSRange nlRange = [lineBuffer rangeOfString:@"\n"
options:0
range:remainingRange];
if (nlRange.location == NSNotFound) {
break;
}
NSUInteger lineLength = nlRange.location - searchStart;
NSString *line = [lineBuffer
substringWithRange:NSMakeRange(searchStart, lineLength)];
if ([line isEqualToString:@"setup_done"]) {
fh.readabilityHandler = nil;
dispatch_semaphore_signal(setupSem);
return;
}
searchStart = nlRange.location + nlRange.length;
}
if (searchStart > 0) {
[lineBuffer
deleteCharactersInRange:NSMakeRange(0, searchStart)];
}
}
};
// Wait for setup_done or timeout
NSTimeInterval setupTimeout = 3.0;
dispatch_time_t timeout = dispatch_time(
DISPATCH_TIME_NOW, (int64_t)(setupTimeout * NSEC_PER_SEC));
long result_wait = dispatch_semaphore_wait(setupSem, timeout);
if (helperOutput.readabilityHandler) {
helperOutput.readabilityHandler = nil;
}
if (result_wait != 0) {
printErrf(@"The test client did not signal setup_done within %.1fs",
setupTimeout);
cleanup_helper();
exit(3);
}
// Small delay to ensure new data is available, for some reason the
// first call to adapter_get slows down MediaRemote?
[NSThread sleepForTimeInterval:0.01];
result = internal_get(YES);
if (result != nil) {
cleanup_helper();
exit(0);
}
cleanup_helper();
exit(4);
}
}

View File

@@ -0,0 +1,163 @@
// clang-format off
#ifndef MEDIAREMOTE_PRIVATE_H_
#define MEDIAREMOTE_PRIVATE_H_
#include <Foundation/Foundation.h>
#pragma mark Notifications
extern NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification;
extern NSString *kMRMediaRemotePickableRoutesDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification;
extern NSString *kMRMediaRemoteRouteStatusDidChangeNotification;
#pragma mark Keys
extern NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey;
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey;
extern NSString *kMRMediaRemoteNowPlayingInfoAlbum;
extern NSString *kMRMediaRemoteNowPlayingInfoArtist;
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkData;
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType;
extern NSString *kMRMediaRemoteNowPlayingInfoChapterNumber;
extern NSString *kMRMediaRemoteNowPlayingInfoComposer;
extern NSString *kMRMediaRemoteNowPlayingInfoDuration;
extern NSString *kMRMediaRemoteNowPlayingInfoElapsedTime;
extern NSString *kMRMediaRemoteNowPlayingInfoGenre;
extern NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement;
extern NSString *kMRMediaRemoteNowPlayingInfoIsBanned;
extern NSString *kMRMediaRemoteNowPlayingInfoIsInWishList;
extern NSString *kMRMediaRemoteNowPlayingInfoIsLiked;
extern NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp;
extern NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate;
extern NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip;
extern NSString *kMRMediaRemoteNowPlayingInfoQueueIndex;
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoRepeatMode;
extern NSString *kMRMediaRemoteNowPlayingInfoShuffleMode;
extern NSString *kMRMediaRemoteNowPlayingInfoStartTime;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds;
extern NSString *kMRMediaRemoteNowPlayingInfoTimestamp;
extern NSString *kMRMediaRemoteNowPlayingInfoTitle;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTrackNumber;
extern NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash;
extern NSString *kMRMediaRemoteNowPlayingInfoMediaType;
extern NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier;
extern NSString *kMRMediaRemoteOptionMediaType;
extern NSString *kMRMediaRemoteOptionSourceID;
extern NSString *kMRMediaRemoteOptionTrackID;
extern NSString *kMRMediaRemoteOptionStationID;
extern NSString *kMRMediaRemoteOptionStationHash;
extern NSString *kMRMediaRemoteRouteDescriptionUserInfoKey;
extern NSString *kMRMediaRemoteRouteStatusUserInfoKey;
#pragma mark API
typedef enum {
/*
* Use nil for userInfo.
*/
kMRPlay = 0,
kMRPause = 1,
kMRTogglePlayPause = 2,
kMRStop = 3,
kMRNextTrack = 4,
kMRPreviousTrack = 5,
kMRToggleShuffle = 6,
kMRToggleRepeat = 7,
kMRStartForwardSeek = 8,
kMREndForwardSeek = 9,
kMRStartBackwardSeek = 10,
kMREndBackwardSeek = 11,
kMRGoBackFifteenSeconds = 12,
kMRSkipFifteenSeconds = 13,
/*
* Use a NSDictionary for userInfo, which contains three keys:
* kMRMediaRemoteOptionTrackID
* kMRMediaRemoteOptionStationID
* kMRMediaRemoteOptionStationHash
*/
kMRLikeTrack = 0x6A,
kMRBanTrack = 0x6B,
kMRAddTrackToWishList = 0x6C,
kMRRemoveTrackFromWishList = 0x6D
} MRCommand;
extern CFStringRef MRMediaRemoteSendCommand;
typedef bool (*MRMediaRemoteSendCommand_t)(MRCommand command, id userInfo);
extern CFStringRef MRMediaRemoteSetPlaybackSpeed;
extern CFStringRef MRMediaRemoteSetElapsedTime;
extern CFStringRef MRMediaRemoteSetShuffleMode;
extern CFStringRef MRMediaRemoteSetRepeatMode;
typedef void (*MRMediaRemoteSetPlaybackSpeed_t)(int speed);
typedef void (*MRMediaRemoteSetElapsedTime_t)(double elapsedTime);
typedef void (*MRMediaRemoteSetShuffleMode_t)(int mode);
typedef void (*MRMediaRemoteSetRepeatMode_t)(int mode);
extern CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications;
extern CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications;
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationPID;
extern CFStringRef MRMediaRemoteGetNowPlayingClient;
extern CFStringRef MRMediaRemoteGetNowPlayingInfo;
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying;
typedef void (*MRMediaRemoteRegisterForNowPlayingNotifications_t)(dispatch_queue_t queue);
typedef void (*MRMediaRemoteUnregisterForNowPlayingNotifications_t)();
typedef void (^MRMediaRemoteGetNowPlayingInfoCompletion_t)(NSDictionary *information);
typedef void (^MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t)(int PID);
typedef void (^MRMediaRemoteGetNowPlayingClientCompletion_t)(id clientObj);
typedef void (^MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t)(bool isPlaying);
typedef void (*MRMediaRemoteGetNowPlayingApplicationPID_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingClient_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingClientCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingInfo_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingInfoCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t completion);
#pragma mark Miscellaneous
extern NSString *kMRNowPlayingClientUserInfoKey;
// Accessed with the kMRNowPlayingClientUserInfoKey
// on the userInfo dictionary of an NSNotification.
@interface MRClient : NSObject {}
-(NSString *)parentApplicationBundleIdentifier;
-(NSString *)bundleIdentifier;
-(NSString *)displayName;
@end
@interface MediaRemote : NSObject
// Commands
@property(readonly) MRMediaRemoteSendCommand_t sendCommand;
// Other controls
@property(readonly) MRMediaRemoteSetPlaybackSpeed_t setPlaybackSpeed;
@property(readonly) MRMediaRemoteSetElapsedTime_t setElapsedTime;
@property(readonly) MRMediaRemoteSetShuffleMode_t setShuffleMode;
@property(readonly) MRMediaRemoteSetRepeatMode_t setRepeatMode;
// Observers
@property(readonly) MRMediaRemoteRegisterForNowPlayingNotifications_t registerForNowPlayingNotifications;
@property(readonly) MRMediaRemoteUnregisterForNowPlayingNotifications_t unregisterForNowPlayingNotifications;
// Metadata
@property(readonly) MRMediaRemoteGetNowPlayingApplicationPID_t getNowPlayingApplicationPID;
@property(readonly) MRMediaRemoteGetNowPlayingClient_t getNowPlayingClient;
@property(readonly) MRMediaRemoteGetNowPlayingInfo_t getNowPlayingInfo;
@property(readonly) MRMediaRemoteGetNowPlayingApplicationIsPlaying_t getNowPlayingApplicationIsPlaying;
// Constructor
-(id)init;
@end
#endif /* MEDIAREMOTE_PRIVATE_H_ */

View File

@@ -0,0 +1,114 @@
// clang-format off
#include <Foundation/Foundation.h>
#include "MediaRemote.h"
NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification = @"kMRMediaRemoteNowPlayingInfoDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification = @"kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification";
NSString *kMRMediaRemotePickableRoutesDidChangeNotification = @"kMRMediaRemotePickableRoutesDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification";
NSString *kMRMediaRemoteRouteStatusDidChangeNotification = @"kMRMediaRemoteRouteStatusDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey";
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey";
NSString *kMRMediaRemoteNowPlayingInfoAlbum = @"kMRMediaRemoteNowPlayingInfoAlbum";
NSString *kMRMediaRemoteNowPlayingInfoArtist = @"kMRMediaRemoteNowPlayingInfoArtist";
NSString *kMRMediaRemoteNowPlayingInfoArtworkData = @"kMRMediaRemoteNowPlayingInfoArtworkData";
NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType = @"kMRMediaRemoteNowPlayingInfoArtworkMIMEType";
NSString *kMRMediaRemoteNowPlayingInfoChapterNumber = @"kMRMediaRemoteNowPlayingInfoChapterNumber";
NSString *kMRMediaRemoteNowPlayingInfoComposer = @"kMRMediaRemoteNowPlayingInfoComposer";
NSString *kMRMediaRemoteNowPlayingInfoDuration = @"kMRMediaRemoteNowPlayingInfoDuration";
NSString *kMRMediaRemoteNowPlayingInfoElapsedTime = @"kMRMediaRemoteNowPlayingInfoElapsedTime";
NSString *kMRMediaRemoteNowPlayingInfoGenre = @"kMRMediaRemoteNowPlayingInfoGenre";
NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement = @"kMRMediaRemoteNowPlayingInfoIsAdvertisement";
NSString *kMRMediaRemoteNowPlayingInfoIsBanned = @"kMRMediaRemoteNowPlayingInfoIsBanned";
NSString *kMRMediaRemoteNowPlayingInfoIsInWishList = @"kMRMediaRemoteNowPlayingInfoIsInWishList";
NSString *kMRMediaRemoteNowPlayingInfoIsLiked = @"kMRMediaRemoteNowPlayingInfoIsLiked";
NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp = @"kMRMediaRemoteNowPlayingInfoIsMusicApp";
NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate = @"kMRMediaRemoteNowPlayingInfoPlaybackRate";
NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip = @"kMRMediaRemoteNowPlayingInfoProhibitsSkip";
NSString *kMRMediaRemoteNowPlayingInfoQueueIndex = @"kMRMediaRemoteNowPlayingInfoQueueIndex";
NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier = @"kMRMediaRemoteNowPlayingInfoRadioStationIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoRepeatMode = @"kMRMediaRemoteNowPlayingInfoRepeatMode";
NSString *kMRMediaRemoteNowPlayingInfoShuffleMode = @"kMRMediaRemoteNowPlayingInfoShuffleMode";
NSString *kMRMediaRemoteNowPlayingInfoStartTime = @"kMRMediaRemoteNowPlayingInfoStartTime";
NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds";
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned = @"kMRMediaRemoteNowPlayingInfoSupportsIsBanned";
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked = @"kMRMediaRemoteNowPlayingInfoSupportsIsLiked";
NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds";
NSString *kMRMediaRemoteNowPlayingInfoTimestamp = @"kMRMediaRemoteNowPlayingInfoTimestamp";
NSString *kMRMediaRemoteNowPlayingInfoTitle = @"kMRMediaRemoteNowPlayingInfoTitle";
NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount = @"kMRMediaRemoteNowPlayingInfoTotalChapterCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount = @"kMRMediaRemoteNowPlayingInfoTotalDiscCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount = @"kMRMediaRemoteNowPlayingInfoTotalQueueCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount = @"kMRMediaRemoteNowPlayingInfoTotalTrackCount";
NSString *kMRMediaRemoteNowPlayingInfoTrackNumber = @"kMRMediaRemoteNowPlayingInfoTrackNumber";
NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier = @"kMRMediaRemoteNowPlayingInfoUniqueIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier = @"kMRMediaRemoteNowPlayingInfoContentItemIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash = @"kMRMediaRemoteNowPlayingInfoRadioStationHash";
NSString *kMRMediaRemoteNowPlayingInfoMediaType = @"kMRMediaRemoteNowPlayingInfoMediaType";
NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier = @"kMRMediaRemoteNowPlayingInfoServiceIdentifier";
NSString *kMRMediaRemoteOptionMediaType = @"kMRMediaRemoteOptionMediaType";
NSString *kMRMediaRemoteOptionSourceID = @"kMRMediaRemoteOptionSourceID";
NSString *kMRMediaRemoteOptionTrackID = @"kMRMediaRemoteOptionTrackID";
NSString *kMRMediaRemoteOptionStationID = @"kMRMediaRemoteOptionStationID";
NSString *kMRMediaRemoteOptionStationHash = @"kMRMediaRemoteOptionStationHash";
NSString *kMRMediaRemoteRouteDescriptionUserInfoKey = @"kMRMediaRemoteRouteDescriptionUserInfoKey";
NSString *kMRMediaRemoteRouteStatusUserInfoKey = @"kMRMediaRemoteRouteStatusUserInfoKey";
CFStringRef MRMediaRemoteSendCommand = CFSTR("MRMediaRemoteSendCommand");
CFStringRef MRMediaRemoteSetPlaybackSpeed = CFSTR("MRMediaRemoteSetPlaybackSpeed");
CFStringRef MRMediaRemoteSetElapsedTime = CFSTR("MRMediaRemoteSetElapsedTime");
CFStringRef MRMediaRemoteSetShuffleMode = CFSTR("MRMediaRemoteSetShuffleMode");
CFStringRef MRMediaRemoteSetRepeatMode = CFSTR("MRMediaRemoteSetRepeatMode");
CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications = CFSTR("MRMediaRemoteRegisterForNowPlayingNotifications");
CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications = CFSTR("MRMediaRemoteUnregisterForNowPlayingNotifications");
CFStringRef MRMediaRemoteGetNowPlayingApplicationPID = CFSTR("MRMediaRemoteGetNowPlayingApplicationPID");
CFStringRef MRMediaRemoteGetNowPlayingClient = CFSTR("MRMediaRemoteGetNowPlayingClient");
CFStringRef MRMediaRemoteGetNowPlayingInfo = CFSTR("MRMediaRemoteGetNowPlayingInfo");
CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying = CFSTR("MRMediaRemoteGetNowPlayingApplicationIsPlaying");
NSString *kMRNowPlayingClientUserInfoKey = @"kMRNowPlayingClientUserInfoKey";
static NSString *MediaRemoteFrameworkBundleURL = @"/System/Library/PrivateFrameworks/MediaRemote.framework";
@implementation MediaRemote
@synthesize sendCommand;
@synthesize setPlaybackSpeed;
@synthesize setElapsedTime;
@synthesize setShuffleMode;
@synthesize setRepeatMode;
@synthesize registerForNowPlayingNotifications;
@synthesize unregisterForNowPlayingNotifications;
@synthesize getNowPlayingApplicationPID;
@synthesize getNowPlayingClient;
@synthesize getNowPlayingInfo;
@synthesize getNowPlayingApplicationIsPlaying;
-(id)init
{
if (!(self = [super init])) {
return nil;
}
CFURLRef bundleURL = (__bridge CFURLRef)[NSURL fileURLWithPath:MediaRemoteFrameworkBundleURL];
CFBundleRef bundle = CFBundleCreate(kCFAllocatorDefault, bundleURL);
if (!bundle) {
return nil;
}
sendCommand = (MRMediaRemoteSendCommand_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSendCommand);
setPlaybackSpeed = (MRMediaRemoteSetPlaybackSpeed_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetPlaybackSpeed);
setElapsedTime = (MRMediaRemoteSetElapsedTime_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetElapsedTime);
setShuffleMode = (MRMediaRemoteSetShuffleMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetShuffleMode);
setRepeatMode = (MRMediaRemoteSetRepeatMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetRepeatMode);
registerForNowPlayingNotifications = (MRMediaRemoteRegisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteRegisterForNowPlayingNotifications);
unregisterForNowPlayingNotifications = (MRMediaRemoteUnregisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteUnregisterForNowPlayingNotifications);
getNowPlayingApplicationPID = (MRMediaRemoteGetNowPlayingApplicationPID_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationPID);
getNowPlayingClient = (MRMediaRemoteGetNowPlayingClient_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingClient);
getNowPlayingInfo = (MRMediaRemoteGetNowPlayingInfo_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingInfo);
getNowPlayingApplicationIsPlaying = (MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationIsPlaying);
return self;
}
@end

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#import <MediaPlayer/MediaPlayer.h>
NS_ASSUME_NONNULL_BEGIN
@protocol RemoteCommandCenterDelegateListener <NSObject>
- (void)didReceivePlayCommand;
- (void)didReceivePauseCommand;
@end
@interface NowPlayingInfoDelegate : NSObject
@property(nonatomic, readonly) MPNowPlayingInfoCenter *center;
- (void)updateMetadataWithTitle:(NSString *)title
artist:(NSString *)artist
duration:(NSTimeInterval)duration;
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time;
@end
@interface RemoteCommandCenterDelegate : NSObject
@property(nonatomic, weak) id<RemoteCommandCenterDelegateListener> listener;
- (instancetype)initWithListener:
(id<RemoteCommandCenterDelegateListener>)listener;
@end
@interface NowPlayingPublishTest
: NSObject <RemoteCommandCenterDelegateListener>
@property(nonatomic, strong, readonly)
NowPlayingInfoDelegate *nowPlayingDelegate;
@property(nonatomic, strong, readonly)
RemoteCommandCenterDelegate *commandDelegate;
@property(nonatomic, assign, readonly) BOOL isPlaying;
@property(nonatomic, assign, readonly) NSTimeInterval elapsedTime;
@property(nonatomic, strong, nullable, readonly) NSDate *playbackStartDate;
@property(nonatomic, assign, readonly) NSTimeInterval totalDuration;
- (void)updateNowPlayingInfo;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#include <MediaPlayer/MediaPlayer.h>
#import "NowPlayingTest.h"
NS_ASSUME_NONNULL_BEGIN
// Constants
static const NSTimeInterval kDefaultTrackDuration = 10.0 * 60.0; // 10 minutes
static const float kPlayingRate = 1.0f;
static const float kPausedRate = 0.0f;
@implementation NowPlayingInfoDelegate {
MPNowPlayingInfoCenter *_center;
}
- (instancetype)init {
if (self = [super init]) {
_center = [MPNowPlayingInfoCenter defaultCenter];
}
return self;
}
- (MPNowPlayingInfoCenter *)center {
return _center;
}
- (void)updateMetadataWithTitle:(NSString *)title
artist:(NSString *)artist
duration:(NSTimeInterval)duration {
NSMutableDictionary *nowPlayingInfo = [@{
MPMediaItemPropertyTitle : title ?: @"Unknown Title",
MPMediaItemPropertyAlbumTitle : @"Unknown Album",
MPMediaItemPropertyArtist : artist ?: @"Unknown Artist",
MPMediaItemPropertyPlaybackDuration : @(duration),
MPNowPlayingInfoPropertyElapsedPlaybackTime : @0,
MPNowPlayingInfoPropertyCurrentPlaybackDate : [NSDate date],
MPNowPlayingInfoPropertyPlaybackRate : @(kPlayingRate),
MPNowPlayingInfoPropertyMediaType : @(MPNowPlayingInfoMediaTypeAudio),
MPNowPlayingInfoPropertyServiceIdentifier :
@"com.vandenbe.MediaRemoteAdapter.TestClient",
} mutableCopy];
#if defined(__MAC_15_0) && __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_15_0
if (@available(macOS 15, *)) {
nowPlayingInfo[MPNowPlayingInfoPropertyExcludeFromSuggestions] = @YES;
}
#endif
self.center.playbackState = MPNowPlayingPlaybackStatePlaying;
self.center.nowPlayingInfo = [nowPlayingInfo copy];
}
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time {
NSMutableDictionary *currentInfo = [self.center.nowPlayingInfo mutableCopy];
if (!currentInfo) {
return;
}
currentInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate);
currentInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(time);
self.center.nowPlayingInfo = [currentInfo copy];
}
@end
@implementation RemoteCommandCenterDelegate
- (instancetype)initWithListener:
(id<RemoteCommandCenterDelegateListener>)listener {
if (self = [super init]) {
_listener = listener;
[self setupRemoteCommandHandlers];
}
return self;
}
- (void)setupRemoteCommandHandlers {
MPRemoteCommandCenter *commandCenter =
[MPRemoteCommandCenter sharedCommandCenter];
[commandCenter.playCommand addTarget:self
action:@selector(handlePlayCommand:)];
[commandCenter.pauseCommand addTarget:self
action:@selector(handlePauseCommand:)];
}
- (MPRemoteCommandHandlerStatus)handlePlayCommand:
(MPRemoteCommandEvent *)event {
[self.listener didReceivePlayCommand];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus)handlePauseCommand:
(MPRemoteCommandEvent *)event {
[self.listener didReceivePauseCommand];
return MPRemoteCommandHandlerStatusSuccess;
}
@end
@interface NowPlayingPublishTest ()
@property(nonatomic, strong, readwrite)
NowPlayingInfoDelegate *nowPlayingDelegate;
@property(nonatomic, strong, readwrite)
RemoteCommandCenterDelegate *commandDelegate;
@property(nonatomic, assign, readwrite) BOOL isPlaying;
@property(nonatomic, assign, readwrite) NSTimeInterval elapsedTime;
@property(nonatomic, strong, nullable, readwrite) NSDate *playbackStartDate;
@property(nonatomic, assign, readwrite) NSTimeInterval totalDuration;
@end
@implementation NowPlayingPublishTest
- (instancetype)init {
if (self = [super init]) {
[self setupDelegates];
[self initializePlaybackState];
[self setupInitialTrack];
}
return self;
}
- (void)setupDelegates {
self.nowPlayingDelegate = [[NowPlayingInfoDelegate alloc] init];
self.commandDelegate =
[[RemoteCommandCenterDelegate alloc] initWithListener:self];
}
- (void)initializePlaybackState {
self.totalDuration = kDefaultTrackDuration;
self.elapsedTime = 0.0;
self.playbackStartDate = [NSDate date];
self.isPlaying = YES;
}
- (void)setupInitialTrack {
[self.nowPlayingDelegate updateMetadataWithTitle:@"Is It Broken Yet?"
artist:@"Alexander5015, ungive"
duration:self.totalDuration];
[self updateNowPlayingInfo];
}
- (void)didReceivePlayCommand {
if (self.isPlaying) {
return; // Already playing
}
[self startPlayback];
}
- (void)didReceivePauseCommand {
if (!self.isPlaying) {
return; // Already paused
}
[self pausePlayback];
}
- (void)startPlayback {
self.isPlaying = YES;
self.playbackStartDate = [NSDate date];
[self updateNowPlayingInfo];
}
- (void)pausePlayback {
self.isPlaying = NO;
[self updateElapsedTimeFromPlaybackStart];
self.playbackStartDate = nil;
[self updateNowPlayingInfo];
}
- (void)updateElapsedTimeFromPlaybackStart {
if (!self.playbackStartDate) {
return;
}
NSTimeInterval playedInterval =
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
self.elapsedTime += playedInterval;
// Ensure elapsed time doesn't exceed total duration
if (self.elapsedTime > self.totalDuration) {
self.elapsedTime = self.totalDuration;
}
}
- (void)updateNowPlayingInfo {
NSTimeInterval currentElapsedTime = [self calculateCurrentElapsedTime];
float playbackRate = [self calculatePlaybackRate:currentElapsedTime];
[self.nowPlayingDelegate setPlaybackRate:playbackRate
elapsedTime:currentElapsedTime];
}
- (NSTimeInterval)calculateCurrentElapsedTime {
NSTimeInterval currentElapsed = self.elapsedTime;
if (self.isPlaying && self.playbackStartDate) {
NSTimeInterval intervalSinceStart =
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
currentElapsed += intervalSinceStart;
// Cap at total duration
if (currentElapsed > self.totalDuration) {
currentElapsed = self.totalDuration;
}
}
return currentElapsed;
}
- (float)calculatePlaybackRate:(NSTimeInterval)currentElapsedTime {
if (!self.isPlaying) {
return kPausedRate;
}
// Check if track has ended
if (currentElapsedTime >= self.totalDuration) {
self.isPlaying = NO; // Auto-pause when track ends
return kPausedRate;
}
return kPlayingRate;
}
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#import "NowPlayingTest.h"
static const NSTimeInterval kRunLoopInterval = 0.1;
static const size_t kInputBufferSize = 256;
int main(int argc, const char *argv[]) {
@autoreleasepool {
dup2(STDOUT_FILENO, STDERR_FILENO);
NowPlayingPublishTest *test = [[NowPlayingPublishTest alloc] init];
puts("setup_done");
fflush(stdout);
BOOL shouldExit = NO;
while (!shouldExit) {
NSDate *waitUntil =
[NSDate dateWithTimeIntervalSinceNow:kRunLoopInterval];
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:waitUntil];
fd_set fds;
struct timeval tv = {0, 0};
FD_ZERO(&fds);
FD_SET(STDIN_FILENO, &fds);
int ret = select(STDIN_FILENO + 1, &fds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(STDIN_FILENO, &fds)) {
char buf[kInputBufferSize];
if (fgets(buf, sizeof(buf), stdin)) {
NSString *command =
[[NSString alloc] initWithUTF8String:buf];
command = [command
stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([command isEqualToString:@"cleanup"]) {
printf("cleanup_done\n");
fflush(stdout);
shouldExit = YES;
break;
} else {
puts("unknown_command");
fflush(stdout);
}
}
}
}
}
return 0;
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
#define MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface Debounce : NSObject
@property(nonatomic, assign, readonly) NSTimeInterval delay;
- (instancetype)initWithDelay:(NSTimeInterval)delay
queue:(nullable dispatch_queue_t)queue;
- (void)call:(dispatch_block_t)block;
- (void)cancel;
@end
NS_ASSUME_NONNULL_END
#endif // MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "Debounce.h"
@interface Debounce ()
@property(nonatomic, strong) dispatch_queue_t queue;
@property(nonatomic, strong, nullable) dispatch_block_t pendingBlock;
@property(nonatomic, assign, readwrite) NSTimeInterval delay;
@end
@implementation Debounce
- (instancetype)initWithDelay:(NSTimeInterval)delay
queue:(dispatch_queue_t)queue {
self = [super init];
if (self) {
_delay = MAX(0.0, delay);
_queue = queue ?: dispatch_get_main_queue();
}
return self;
}
- (void)call:(dispatch_block_t)block {
[self cancel];
self.pendingBlock = dispatch_block_create(DISPATCH_BLOCK_BARRIER, block);
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.delay * NSEC_PER_SEC)),
self.queue, self.pendingBlock);
}
- (void)cancel {
if (self.pendingBlock) {
dispatch_block_cancel(self.pendingBlock);
self.pendingBlock = nil;
}
}
@end

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
#define MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
#include <stdarg.h>
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
void printOut(NSString *message);
void printOutUnique(NSString *message);
void printErr(NSString *message);
void printErrf(NSString *format, ...);
void fail(NSString *message);
void failf(NSString *format, ...);
NSString *formatError(NSError *error);
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint);
bool appForPID(int pid, void (^block)(NSRunningApplication *));
void makePayloadHumanReadable(NSMutableDictionary *dict);
#endif // MEDIAREMOTEADAPTER_UTILITY_HELPERS_H

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "helpers.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#import <ImageIO/ImageIO.h>
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#else
#import <CoreServices/CoreServices.h>
#endif
#define JSON_NULL @"null";
void printOut(NSString *message) {
fprintf(stdout, "%s\n", [message UTF8String]);
fflush(stdout);
}
void printOutUnique(NSString *message) {
static NSString *previous = nil;
if (![previous isEqualToString:message]) {
printOut(message);
previous = [message copy];
}
}
void printErr(NSString *message) {
fprintf(stderr, "%s\n", [message UTF8String]);
fflush(stderr);
}
void printErrf(NSString *format, ...) {
va_list args;
va_start(args, format);
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
arguments:args];
va_end(args);
fprintf(stderr, "%s\n", [formattedMessage UTF8String]);
fflush(stderr);
}
void fail(NSString *message) {
printErr(message);
exit(1);
}
void failf(NSString *format, ...) {
va_list args;
va_start(args, format);
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
arguments:args];
va_end(args);
fail(formattedMessage);
}
NSString *formatError(NSError *error) {
return
[NSString stringWithFormat:@"%@ (%@:%ld)", [error localizedDescription],
[error domain], (long)[error code]];
}
static id sanitizeValueForJsonEncoding(id value, NSString *parentKey) {
const id unsupported_type = nil; // remove silently at call site with log
if ([value isKindOfClass:[NSDictionary class]]) {
NSDictionary *dictionary = (NSDictionary *)value;
NSMutableDictionary *result = [NSMutableDictionary dictionary];
for (id key in dictionary) {
if (![key isKindOfClass:[NSString class]]) {
printErrf(@"Invalid JSON key in dictionary: %@ (%@)",
[key description], [key class]);
continue;
}
id raw = [dictionary objectForKey:key];
id clean = sanitizeValueForJsonEncoding(raw, key);
if (clean) {
result[key] = clean;
} else {
printErrf(@"Invalid JSON value type in dictionary for key "
@"'%@': %@ (%@)",
key, raw, [raw class]);
}
}
return result;
} else if ([value isKindOfClass:[NSArray class]]) {
NSArray *array = (NSArray *)value;
NSMutableArray *result = [NSMutableArray array];
for (NSUInteger i = 0; i < array.count; i++) {
id elem = array[i];
id clean = sanitizeValueForJsonEncoding(elem, parentKey);
if (clean) {
[result addObject:clean];
} else if (parentKey != nil) {
printErrf(@"Invalid JSON value type in array at index %d "
@"under key '%@': %@ (%@)",
i, parentKey, elem, [elem class]);
} else {
printErrf(
@"Invalid JSON value type in array at index %d: %@ (%@)", i,
elem, [elem class]);
}
}
return result;
} else if ([value isKindOfClass:[NSString class]] ||
[value isKindOfClass:[NSNull class]]) {
return value;
} else if ([value isKindOfClass:[NSNumber class]]) {
NSNumber *number = (NSNumber *)value;
double unwrapped = [number doubleValue];
if (isnan(unwrapped) || isinf(unwrapped)) {
return unsupported_type;
}
return value;
} else if ([value isKindOfClass:[NSDate class]]) {
static NSDateFormatter *formatter = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [[NSDateFormatter alloc] init];
formatter.locale =
[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"];
formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
});
return [formatter stringFromDate:(NSDate *)value];
} else if ([value isKindOfClass:[NSURL class]]) {
return [(NSURL *)value absoluteString];
} else if ([value isKindOfClass:[NSData class]]) {
return [(NSData *)value base64EncodedStringWithOptions:0];
} else {
return unsupported_type;
}
}
static NSDictionary *sanitizeDictionaryForJsonEncoding(NSDictionary *data) {
return sanitizeValueForJsonEncoding(data, nil);
}
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint) {
if (any == nil) {
NSCAssert(false, @"Cannot serialize nil as JSON");
return JSON_NULL;
}
any = sanitizeDictionaryForJsonEncoding(any);
if (any == nil) {
NSCAssert(false, @"Sanitized JSON dictionary is nil");
return JSON_NULL;
}
NSCAssert([NSJSONSerialization isValidJSONObject:any],
@"Sanitized JSON dictionary is not a valid JSON object");
@try {
NSError *error;
NSJSONWritingOptions options =
prettyPrint ? NSJSONWritingPrettyPrinted : 0;
NSData *serialized = [NSJSONSerialization dataWithJSONObject:any
options:options
error:&error];
if (!serialized) {
printErrf(@"Failed to serialize JSON: %@", error);
return nil;
}
return [[NSString alloc] initWithData:serialized
encoding:NSUTF8StringEncoding];
} @catch (NSException *exception) {
if ([exception.name isEqualToString:NSInvalidArgumentException]) {
printErrf(@"Exception during JSON serialization: %@: %@", exception,
[any class]);
} else {
printErrf(@"Exception during JSON serialization: %@", exception);
}
return nil;
}
}
/*
// Dictionary with invalid values to test sanitization before serialization.
any = @{
@"validString" : @"Hello",
@"validNumber" : @123,
@"invalidDate" : [NSDate date],
@"invalidURL" : [NSURL URLWithString:@"https://apple.com"],
@"invalidSet" : [NSSet setWithObjects:@"a", @"b", nil],
@"nestedDict" : @{@42 : @"badKey", @"validNestedKey" :
@"nestedValue"},
@123 : @"badKeyAtRoot",
@"binaryData" :
[@"Hello, base64!" dataUsingEncoding:NSUTF8StringEncoding],
@"invalidArray" : @[
@"okay", [@"Hello, base64!"
dataUsingEncoding:NSUTF8StringEncoding]
],
};
*/
bool appForPID(int pid, void (^block)(NSRunningApplication *)) {
if (pid <= 0) {
return false;
}
NSRunningApplication *process =
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (process == nil) {
return false;
}
block(process);
return true;
}
static NSString *guessImageMimeTypeFromData(NSData *data) {
if (!data)
return nil;
CGImageSourceRef src =
CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
if (!src)
return nil;
CFStringRef uti = CGImageSourceGetType(src);
CFRelease(src);
if (!uti)
return nil;
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
UTType *type = [UTType typeWithIdentifier:(__bridge NSString *)uti];
return type.preferredMIMEType;
#else
CFStringRef mime =
UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType);
if (!mime)
return nil;
NSString *mimeType = (__bridge_transfer NSString *)mime;
return mimeType;
#endif
}
void makePayloadHumanReadable(NSMutableDictionary *dict) {
for (NSString *key in [dict allKeys]) {
id value = dict[key];
if ([value isKindOfClass:[NSData class]]) {
NSString *mimeType = guessImageMimeTypeFromData(value);
dict[key] = [NSString
stringWithFormat:@"<%@%@%lu bytes...>", mimeType ?: @"",
mimeType ? @" " : @"",
(unsigned long)[value length]];
}
}
}