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:
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