Files
onyx/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m
Guido Schmit 433cdcc3dd 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.
2026-08-10 21:02:13 +02:00

232 lines
6.6 KiB
Objective-C

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