Phase 4: Medien-Widget mit dreistufiger Kaskade

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

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

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

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

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

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

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

View File

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

View File

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

View File

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