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.
524 lines
20 KiB
Objective-C
524 lines
20 KiB
Objective-C
// 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
|
|
*/
|