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:
49
Vendor/mediaremote-adapter/.clang-format
vendored
Normal file
49
Vendor/mediaremote-adapter/.clang-format
vendored
Normal 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.
|
||||
10
Vendor/mediaremote-adapter/.cmake-format
vendored
Normal file
10
Vendor/mediaremote-adapter/.cmake-format
vendored
Normal 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
8
Vendor/mediaremote-adapter/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
build
|
||||
.DS_Store
|
||||
notes
|
||||
CMakePresets.json
|
||||
.cache
|
||||
todo*.txt
|
||||
dist
|
||||
perltidy.ERR
|
||||
0
Vendor/mediaremote-adapter/.gitmodules
vendored
Normal file
0
Vendor/mediaremote-adapter/.gitmodules
vendored
Normal file
4
Vendor/mediaremote-adapter/.perltidyrc
vendored
Normal file
4
Vendor/mediaremote-adapter/.perltidyrc
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# https://perltidy.sourceforge.net/perltidy.html
|
||||
|
||||
-naws
|
||||
--indent-columns=2
|
||||
92
Vendor/mediaremote-adapter/CMakeLists.txt
vendored
Normal file
92
Vendor/mediaremote-adapter/CMakeLists.txt
vendored
Normal 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
28
Vendor/mediaremote-adapter/LICENSE
vendored
Normal 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
10
Vendor/mediaremote-adapter/Makefile
vendored
Normal 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
|
||||
1
Vendor/mediaremote-adapter/ONYX-VERSION.txt
vendored
Normal file
1
Vendor/mediaremote-adapter/ONYX-VERSION.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
3ac3d4bdf862c7b5399b4fba4df5689f5c38609a 2026-05-11 16:52:03 +0200
|
||||
529
Vendor/mediaremote-adapter/README.md
vendored
Normal file
529
Vendor/mediaremote-adapter/README.md
vendored
Normal file
@@ -0,0 +1,529 @@
|
||||
> **@Apple** *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** *Please **star**
|
||||
> this repository to show Apple that we care.*
|
||||
|
||||
---
|
||||
|
||||
<!-- BADGES BEGIN -->
|
||||

|
||||

|
||||

|
||||
<!-- 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 – `/usr/bin/perl` in this case –
|
||||
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` 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` 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` 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` Disables diffing. `diff` is always `false`
|
||||
and `payload` always contains all current information.
|
||||
|
||||
`--debounce=N` 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` Identical to the `--micros` option of the `get` command.
|
||||
|
||||
`--no-artwork` 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` 
|
||||
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
|
||||
280
Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl
vendored
Executable file
280
Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl
vendored
Executable 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: $@";
|
||||
}
|
||||
126
Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h
vendored
Normal file
126
Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h
vendored
Normal 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
|
||||
62
Vendor/mediaremote-adapter/scripts/update-readme-badges.py
vendored
Executable file
62
Vendor/mediaremote-adapter/scripts/update-readme-badges.py
vendored
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
STATIC_BADGES = """
|
||||

|
||||
"""
|
||||
|
||||
|
||||
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""
|
||||
badge2 = f""
|
||||
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()
|
||||
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal file
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal 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
|
||||
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal file
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal 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));
|
||||
}
|
||||
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal file
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal 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
|
||||
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal file
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal 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(); }
|
||||
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal file
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal 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
|
||||
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal 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);
|
||||
}
|
||||
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal 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
|
||||
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal file
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal 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
|
||||
];
|
||||
}
|
||||
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal file
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal 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
|
||||
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal file
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal 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;
|
||||
}
|
||||
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal file
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal 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()); }
|
||||
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal 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()); }
|
||||
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal file
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal 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()); }
|
||||
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal file
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal 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());
|
||||
}
|
||||
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal 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()); }
|
||||
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal file
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal 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
|
||||
*/
|
||||
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal file
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
163
Vendor/mediaremote-adapter/src/private/MediaRemote.h
vendored
Normal file
163
Vendor/mediaremote-adapter/src/private/MediaRemote.h
vendored
Normal 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_ */
|
||||
114
Vendor/mediaremote-adapter/src/private/MediaRemote.m
vendored
Normal file
114
Vendor/mediaremote-adapter/src/private/MediaRemote.m
vendored
Normal 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
|
||||
50
Vendor/mediaremote-adapter/src/test/NowPlayingTest.h
vendored
Normal file
50
Vendor/mediaremote-adapter/src/test/NowPlayingTest.h
vendored
Normal 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
|
||||
231
Vendor/mediaremote-adapter/src/test/NowPlayingTest.m
vendored
Normal file
231
Vendor/mediaremote-adapter/src/test/NowPlayingTest.m
vendored
Normal 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
|
||||
53
Vendor/mediaremote-adapter/src/test/main.m
vendored
Normal file
53
Vendor/mediaremote-adapter/src/test/main.m
vendored
Normal 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;
|
||||
}
|
||||
24
Vendor/mediaremote-adapter/src/utility/Debounce.h
vendored
Normal file
24
Vendor/mediaremote-adapter/src/utility/Debounce.h
vendored
Normal 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
|
||||
41
Vendor/mediaremote-adapter/src/utility/Debounce.m
vendored
Normal file
41
Vendor/mediaremote-adapter/src/utility/Debounce.m
vendored
Normal 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
|
||||
27
Vendor/mediaremote-adapter/src/utility/helpers.h
vendored
Normal file
27
Vendor/mediaremote-adapter/src/utility/helpers.h
vendored
Normal 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
|
||||
246
Vendor/mediaremote-adapter/src/utility/helpers.m
vendored
Normal file
246
Vendor/mediaremote-adapter/src/utility/helpers.m
vendored
Normal 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]];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user