diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index b787330..52a6b72 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; + 1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; }; 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; @@ -49,6 +50,7 @@ B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */, 5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */, + 1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -114,6 +116,7 @@ 6B11A5AC704B6429B9484789 /* Sources */, B211CE3C0EF94A8D680E4AE0 /* Resources */, 7FF994FE5530D53BEFBE13FE /* Frameworks */, + 9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */, F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */, ); buildRules = ( @@ -129,6 +132,7 @@ A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */, A3E3949D664131D593CBEEDC /* CalendarProvider */, D4578EB64F7A3372B49BC50C /* WeatherProvider */, + 349226D70F51196CCD1C94F8 /* MediaProvider */, ); productName = Onyx; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; @@ -185,6 +189,26 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Scripts/build-mediaremote-adapter.sh", + ); + name = "MediaRemote-Adapter bauen und beilegen"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "OUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n"; + }; F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -425,6 +449,10 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 349226D70F51196CCD1C94F8 /* MediaProvider */ = { + isa = XCSwiftPackageProductDependency; + productName = MediaProvider; + }; 3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = { isa = XCSwiftPackageProductDependency; productName = OnyxNotch; diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index a8e0dcd..fe8d009 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -7,6 +7,7 @@ import OnyxMenuBar import OnyxWidgetKit import CalendarProvider import WeatherProvider +import MediaProvider @main struct OnyxApp: App { @@ -32,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var onyxItem: NSStatusItem? private var calendarModel: CalendarModel? private var weatherModel: WeatherModel? + private var mediaModel: MediaModel? private let settingsWindow = SettingsWindowController() func applicationDidFinishLaunching(_ notification: Notification) { @@ -127,6 +129,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.weatherModel = weatherModel WidgetRegistry.shared.register(WeatherWidget(model: weatherModel)) + let mediaModel = MediaModel() + self.mediaModel = mediaModel + WidgetRegistry.shared.register(MediaWidget(model: mediaModel)) + PlaceholderWidgets.registerAll() } diff --git a/Onyx/PlaceholderWidgets.swift b/Onyx/PlaceholderWidgets.swift index 062670b..e7882d9 100644 --- a/Onyx/PlaceholderWidgets.swift +++ b/Onyx/PlaceholderWidgets.swift @@ -44,8 +44,6 @@ enum PlaceholderWidgets { /// damit gespeicherte Layouts weitergelten. static func registerAll() { let widgets: [PlaceholderWidget] = [ - .init(id: "media", displayName: "Medien", symbolName: "play.circle", - supportedSizes: [.medium, .wide], phase: "Phase 4"), .init(id: "cpu", displayName: "CPU", symbolName: "cpu", supportedSizes: [.small, .medium], phase: "Phase 5"), .init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill", diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 56f02de..e26a986 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -15,6 +15,7 @@ let package = Package( .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), .library(name: "CalendarProvider", targets: ["CalendarProvider"]), .library(name: "WeatherProvider", targets: ["WeatherProvider"]), + .library(name: "MediaProvider", targets: ["MediaProvider"]), ], targets: [ // Der String-Katalog muss ausdrücklich als Ressource stehen — sonst gibt @@ -41,5 +42,9 @@ let package = Package( .target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], resources: [.process("Localizable.xcstrings")]), .testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]), + + .target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], + resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]), ] ) diff --git a/Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift b/Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift new file mode 100644 index 0000000..983e3c1 --- /dev/null +++ b/Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift @@ -0,0 +1,80 @@ +import Foundation + +/// Übersetzt die JSON-Zeilen des mediaremote-adapter. +/// +/// Der Adapter sendet im `stream`-Modus Zeilen der Form +/// `{"type":"data","diff":false,"payload":{…}}`. Bei `diff = true` enthält +/// `payload` **nur die geänderten Felder** — ein vollständiges Ersetzen würde +/// dann Titel und Cover löschen, sobald sich nur die Position ändert. +public enum AdapterPayload { + + public struct Envelope: Decodable { + public let type: String + public let diff: Bool? + public let payload: [String: AnyDecodable]? + } + + /// Wendet eine Zeile auf den bisherigen Stand an. + /// + /// - Returns: der neue Stand, oder `nil` wenn die Zeile nichts aussagt. + public static func apply(line: String, to previous: NowPlaying?) -> NowPlaying? { + guard let data = line.data(using: .utf8), + let envelope = try? JSONDecoder().decode(Envelope.self, from: data), + envelope.type == "data" else { return nil } + + guard let payload = envelope.payload, !payload.isEmpty else { + // Ein leerer Payload bei einer Vollmeldung heißt: es läuft nichts. + // Bei einer Teilmeldung heißt er gar nichts und wird verworfen. + return envelope.diff == true ? previous : nil + } + + // Bei einer Teilmeldung auf dem alten Stand aufbauen, sonst neu beginnen. + var state = (envelope.diff == true ? previous : nil) ?? NowPlaying() + state.origin = .adapter + + if let value = payload["title"]?.string { state.title = value } + if let value = payload["artist"]?.string { state.artist = value } + if let value = payload["album"]?.string { state.album = value } + if let value = payload["playing"]?.bool { state.isPlaying = value } + if let value = payload["duration"]?.double { state.duration = value > 0 ? value : nil } + if let value = payload["elapsedTime"]?.double { state.elapsed = value } + if let value = payload["playbackRate"]?.double { state.playbackRate = value } + if let value = payload["bundleIdentifier"]?.string { state.bundleIdentifier = value } + if let value = payload["artworkData"]?.string { + state.artwork = Data(base64Encoded: value, options: .ignoreUnknownCharacters) + } + + // `timestamp` ist der Zeitpunkt, zu dem `elapsedTime` galt — nicht der + // Empfangszeitpunkt. Ohne ihn springt der Scrubber bei jedem Ereignis. + if let raw = payload["timestamp"]?.string, + let parsed = ISO8601DateFormatter().date(from: raw) { + state.asOf = parsed + } else { + state.asOf = Date() + } + + return state + } +} + +/// Minimaler dynamischer Wert — der Payload hat kein festes Schema, und ein +/// vollständiger `Decodable`-Typ müsste bei jedem neuen Feld nachgezogen werden. +public struct AnyDecodable: Decodable, Sendable { + private enum Storage: Sendable { + case string(String), double(Double), bool(Bool), other + } + private let storage: Storage + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + // Reihenfolge zählt: `Bool` vor `Double`, sonst wird `true` zu 1. + if let value = try? container.decode(Bool.self) { storage = .bool(value) } + else if let value = try? container.decode(Double.self) { storage = .double(value) } + else if let value = try? container.decode(String.self) { storage = .string(value) } + else { storage = .other } + } + + public var string: String? { if case .string(let v) = storage { v } else { nil } } + public var double: Double? { if case .double(let v) = storage { v } else { nil } } + public var bool: Bool? { if case .bool(let v) = storage { v } else { nil } } +} diff --git a/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift b/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift new file mode 100644 index 0000000..dc6a30f --- /dev/null +++ b/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift @@ -0,0 +1,166 @@ +import Foundation +import OSLog + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media") + +/// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom. +/// +/// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen: +/// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und +/// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein +/// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte +/// Framework und gibt die Daten zeilenweise aus. +@MainActor +public final class AdapterProcess { + + /// Befehlskennungen aus `MediaRemote.h` des Adapters. + private enum Command: Int { + case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5 + } + + public private(set) var isRunning = false + /// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts. + public var onUpdate: ((NowPlaying?) -> Void)? + + private var process: Process? + private var buffer = Data() + private var current: NowPlaying? + private var restartAttempts = 0 + private var restartTimer: Timer? + + private let perl = URL(fileURLWithPath: "/usr/bin/perl") + + public init() {} + + /// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die + /// nächste Stufe der Kaskade — ohne dass irgendwo ein Prozess scheitert. + public static var isAvailable: Bool { resources != nil } + + private static var resources: (script: URL, framework: URL)? { + guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil } + let script = vendor.appendingPathComponent("mediaremote-adapter.pl") + let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework") + guard FileManager.default.fileExists(atPath: script.path), + FileManager.default.fileExists(atPath: framework.path) else { return nil } + return (script, framework) + } + + // MARK: - Lebenszyklus + + public func start() { + guard !isRunning, let resources = Self.resources else { return } + + let process = Process() + process.executableURL = perl + process.arguments = [resources.script.path, resources.framework.path, "stream"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor in self?.consume(chunk) } + } + + process.terminationHandler = { [weak self] _ in + Task { @MainActor in self?.handleTermination() } + } + + do { + try process.run() + self.process = process + isRunning = true + log.notice("MediaRemote-Adapter gestartet") + } catch { + log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)") + scheduleRestart() + } + } + + public func stop() { + restartTimer?.invalidate() + restartTimer = nil + (process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil + process?.terminationHandler = nil + process?.terminate() + process = nil + isRunning = false + } + + private func handleTermination() { + isRunning = false + process = nil + // Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein + // Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem + // echten Defekt in einer Schleife zu landen. + scheduleRestart() + } + + private func scheduleRestart() { + guard restartAttempts < 5 else { + log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück") + onUpdate?(nil) + return + } + let delay = pow(2.0, Double(restartAttempts)) + restartAttempts += 1 + restartTimer?.invalidate() + restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in + MainActor.assumeIsolated { [weak self] in self?.start() } + } + } + + // MARK: - Datenstrom + + private func consume(_ chunk: Data) { + buffer.append(chunk) + + // Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe + // enthalten. Cover kommen base64-kodiert und sprengen einen Puffer + // schnell, deshalb wird der Rest aufgehoben statt verworfen. + while let newline = buffer.firstIndex(of: 0x0A) { + let lineData = buffer[buffer.startIndex.. NowPlaying? { + for player in players { + guard NSRunningApplication + .runningApplications(withBundleIdentifier: player.bundleID) + .isEmpty == false else { continue } + if let state = query(player) { return state } + } + return nil + } + + private static func query(_ player: Player) -> NowPlaying? { + let script = """ + tell application id "\(player.bundleID)" + if player state is playing or player state is paused then + set t to name of current track + set a to artist of current track + set al to album of current track + set dur to duration of current track + set pos to player position + set st to (player state as text) + return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st + end if + end tell + """ + + var error: NSDictionary? + guard let result = NSAppleScript(source: script)? + .executeAndReturnError(&error).stringValue else { + if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") } + return nil + } + + let parts = result.components(separatedBy: "\n") + guard parts.count >= 6 else { return nil } + + // Spotify gibt die Länge in Millisekunden, Musik in Sekunden. + let rawDuration = Double(parts[3]) ?? 0 + let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration + + return NowPlaying(title: parts[0], artist: parts[1], album: parts[2], + isPlaying: parts[5].hasPrefix("playing"), + duration: duration > 0 ? duration : nil, + elapsed: Double(parts[4]) ?? 0, + playbackRate: 1, asOf: Date(), artwork: nil, + bundleIdentifier: player.bundleID, origin: .appleScript) + } + + public static func send(_ command: MediaCommand) -> Bool { + for player in players { + guard !NSRunningApplication + .runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue } + + let action = switch command { + case .playPause: "playpause" + case .next: "next track" + case .previous: "previous track" + case .seek(let position): "set player position to \(position)" + } + + var error: NSDictionary? + NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")? + .executeAndReturnError(&error) + if error == nil { return true } + } + return false + } +} + +/// Letzte Stufe: die Medientasten der Tastatur. +/// +/// Sie kennt keine Titel und keine Position — aber sie steuert **jedes** +/// Programm, auch solche, die weder der Adapter noch AppleScript erreichen. +/// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die +/// Now-Playing-Schnittstelle weiter zumacht. +public enum MediaKeys { + + private enum Key: Int32 { + case playPause = 16 // NX_KEYTYPE_PLAY + case next = 17 // NX_KEYTYPE_NEXT + case previous = 18 // NX_KEYTYPE_PREVIOUS + } + + public static func send(_ command: MediaCommand) { + let key: Key? = switch command { + case .playPause: .playPause + case .next: .next + case .previous: .previous + case .seek: nil // Über Tasten nicht möglich. + } + guard let key else { return } + post(key, down: true) + post(key, down: false) + } + + private static func post(_ key: Key, down: Bool) { + // Medientasten sind keine gewöhnlichen Tastendrücke, sondern + // NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme + // nirgends an. + let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00)) + let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8)) + + guard let event = NSEvent.otherEvent(with: .systemDefined, + location: .zero, + modifierFlags: flags, + timestamp: 0, + windowNumber: 0, + context: nil, + subtype: 8, + data1: data1, + data2: -1)?.cgEvent else { return } + event.post(tap: .cghidEventTap) + } +} diff --git a/Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift b/Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift new file mode 100644 index 0000000..6028922 --- /dev/null +++ b/Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift @@ -0,0 +1,129 @@ +import Foundation +import AppKit +import OSLog + +private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media") + +/// Führt die drei Stufen zusammen. +/// +/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für +/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern +/// immer, auch ohne jede Angabe. +/// +/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die +/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der +/// Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion. +@MainActor +@Observable +public final class MediaModel { + + public private(set) var nowPlaying: NowPlaying? + /// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur + /// solange etwas abspielt und das Panel offen ist. + public private(set) var tick = Date() + + private let adapter = AdapterProcess() + private var fallbackTimer: Timer? + private var tickTimer: Timer? + private var consumers = 0 + + public init() { + adapter.onUpdate = { [weak self] state in + guard let self else { return } + // Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt. + if let state { + nowPlaying = state + } else { + nowPlaying = AppleScriptSource.poll() + } + updateTicking() + } + } + + /// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein + /// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der + /// Adapter soll genau einmal laufen. + public func addConsumer() { + consumers += 1 + guard consumers == 1 else { return } + + if AdapterProcess.isAvailable { + adapter.start() + } else { + log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten") + } + + // AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für + // eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist. + fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() } + } + pollFallbackIfNeeded() + } + + public func removeConsumer() { + consumers = max(consumers - 1, 0) + guard consumers == 0 else { return } + adapter.stop() + fallbackTimer?.invalidate() + fallbackTimer = nil + tickTimer?.invalidate() + tickTimer = nil + } + + private func pollFallbackIfNeeded() { + // Nur einspringen, wenn der Adapter nichts liefert — sonst würde die + // schwächere Quelle die bessere überschreiben. + guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return } + nowPlaying = AppleScriptSource.poll() + updateTicking() + } + + /// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei + /// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen. + private func updateTicking() { + let shouldTick = nowPlaying?.isPlaying == true + if shouldTick, tickTimer == nil { + tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + MainActor.assumeIsolated { [weak self] in self?.tick = Date() } + } + } else if !shouldTick { + tickTimer?.invalidate() + tickTimer = nil + } + } + + // MARK: - Steuerung + + public func send(_ command: MediaCommand) { + switch nowPlaying?.origin { + case .adapter: + adapter.send(command) + case .appleScript: + if !AppleScriptSource.send(command) { MediaKeys.send(command) } + case .mediaKeys, .none: + // Ohne bekannte Quelle bleibt die Taste — sie erreicht auch + // Programme, die keine der beiden anderen Stufen kennt. + MediaKeys.send(command) + } + + // Sofort umschalten statt auf das nächste Ereignis zu warten. Ein + // Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt + // an; kommt das echte Ereignis, korrigiert es den Stand ohnehin. + if case .playPause = command, var state = nowPlaying { + state.elapsed = state.elapsed(at: Date()) + state.asOf = Date() + state.isPlaying.toggle() + nowPlaying = state + updateTicking() + } + } + + /// Öffnet das Programm, das gerade abspielt. + public func activateSource() { + guard let bundleID = nowPlaying?.bundleIdentifier, + let app = NSRunningApplication + .runningApplications(withBundleIdentifier: bundleID).first else { return } + app.activate() + } +} diff --git a/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift new file mode 100644 index 0000000..4ef826b --- /dev/null +++ b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift @@ -0,0 +1,198 @@ +import SwiftUI +import AppKit +import OnyxDesign +import OnyxWidgetKit + +public struct MediaWidget: OnyxWidget { + public let id = "media" + public var displayName: String { String(localized: "widget.media.name", bundle: .module) } + public let symbolName = "play.circle" + public let supportedSizes: [WidgetSize] = [.medium, .wide] + + private let model: MediaModel + + public init(model: MediaModel) { self.model = model } + + public func makeView(size: WidgetSize) -> AnyView { + AnyView(MediaWidgetView(model: model, size: size)) + } +} + +private struct MediaWidgetView: View { + let model: MediaModel + let size: WidgetSize + + var body: some View { + Group { + if let state = model.nowPlaying, state.hasMetadata { + Playing(model: model, state: state, size: size) + } else { + // Auch ohne Angaben bleibt das Widget bedienbar: die + // Medientasten erreichen jedes Programm. Ein Widget, das bei + // fehlenden Metadaten gar nichts mehr kann, wäre die schlechtere + // Antwort auf ein Problem, das nur die Anzeige betrifft. + Idle(model: model) + } + } + .onAppear { model.addConsumer() } + .onDisappear { model.removeConsumer() } + } +} + +private struct Idle: View { + let model: MediaModel + + var body: some View { + VStack(spacing: 8) { + Text("widget.media.nothing", bundle: .module) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textTertiary) + TransportControls(model: model, size: 13) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct Playing: View { + let model: MediaModel + let state: NowPlaying + let size: WidgetSize + + var body: some View { + HStack(spacing: 10) { + Artwork(data: state.artwork) + .frame(width: artworkSize, height: artworkSize) + + VStack(alignment: .leading, spacing: 2) { + Text(state.title) + .font(Onyx.Font.title) + .foregroundStyle(Onyx.Color.textPrimary) + .lineLimit(1) + Text(state.artist) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + .lineLimit(1) + + Spacer(minLength: 2) + + Scrubber(model: model, state: state) + + HStack { + TransportControls(model: model, size: 15) + Spacer(minLength: 0) + if state.origin == .appleScript { + // Sichtbar machen, dass gerade die schwächere Quelle + // greift: dann fehlen Cover und Browser-Wiedergaben, + // und das soll erklärbar sein statt rätselhaft. + Image(systemName: "applescript") + .font(.system(size: 9)) + .foregroundStyle(Onyx.Color.textTertiary) + .help(Text("widget.media.viaAppleScript", bundle: .module)) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { model.activateSource() } + } + + private var artworkSize: CGFloat { size == .wide ? 64 : 56 } +} + +private struct Artwork: View { + let data: Data? + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 8, style: .continuous) + + Group { + if let data, let image = NSImage(data: data) { + Image(nsImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + // Kein Cover ist der Normalfall bei Browser-Wiedergaben und + // beim AppleScript-Rückfall — deshalb ein ruhiger Platzhalter + // statt eines Fehlerzeichens. + shape.fill(Onyx.Color.elevated) + .overlay { + Image(systemName: "music.note") + .font(.system(size: 18)) + .foregroundStyle(Onyx.Color.textTertiary) + } + } + } + .clipShape(shape) + .overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1)) + } +} + +private struct Scrubber: View { + let model: MediaModel + let state: NowPlaying + + var body: some View { + // `model.tick` wird sekündlich gesetzt; das Lesen hier sorgt dafür, + // dass die Ansicht zwischen den Adapter-Ereignissen mitläuft. + let now = model.tick + let progress = state.progress(at: now) + + VStack(spacing: 2) { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(Onyx.Color.hairline) + Capsule() + .fill(Onyx.Color.accent) + .frame(width: geometry.size.width * (progress ?? 0)) + } + .contentShape(Rectangle()) + .onTapGesture { location in + guard let duration = state.duration, duration > 0 else { return } + let ratio = min(max(location.x / geometry.size.width, 0), 1) + model.send(.seek(to: duration * ratio)) + } + } + .frame(height: 3) + + if state.duration != nil { + HStack { + Text(format(state.elapsed(at: now))) + Spacer(minLength: 0) + Text(format(state.duration ?? 0)) + } + .font(.system(size: 8)) + .monospacedDigit() + .foregroundStyle(Onyx.Color.textTertiary) + } + } + .animation(.linear(duration: 0.9), value: progress) + } + + private func format(_ seconds: TimeInterval) -> String { + let total = Int(seconds.rounded()) + return String(format: "%d:%02d", total / 60, total % 60) + } +} + +private struct TransportControls: View { + let model: MediaModel + let size: CGFloat + + var body: some View { + HStack(spacing: 12) { + button("backward.fill") { model.send(.previous) } + button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") { + model.send(.playPause) + } + button("forward.fill") { model.send(.next) } + } + } + + private func button(_ symbol: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: size)) + .foregroundStyle(Onyx.Color.textPrimary) + } + .buttonStyle(.plain) + } +} diff --git a/Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift b/Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift new file mode 100644 index 0000000..e065b08 --- /dev/null +++ b/Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Woher die Angaben stammen. Bestimmt, was steuerbar ist. +public enum MediaOrigin: String, Equatable, Sendable { + /// mediaremote-adapter — kennt alle Quellen inkl. Browser. + case adapter + /// AppleScript für Spotify oder Musik. + case appleScript + /// Nur Medientasten: Steuerung ohne Angaben. + case mediaKeys +} + +/// Was gerade läuft. +public struct NowPlaying: Equatable, Sendable { + public var title: String + public var artist: String + public var album: String + public var isPlaying: Bool + /// Gesamtlänge in Sekunden. `nil` bei Livestreams. + public var duration: TimeInterval? + /// Position **zum Zeitpunkt `asOf`** — nicht jetzt. Siehe `elapsed(at:)`. + public var elapsed: TimeInterval + public var playbackRate: Double + /// Wann die Angaben gültig waren. + public var asOf: Date + public var artwork: Data? + public var bundleIdentifier: String? + public var origin: MediaOrigin + + public init(title: String = "", artist: String = "", album: String = "", + isPlaying: Bool = false, duration: TimeInterval? = nil, + elapsed: TimeInterval = 0, playbackRate: Double = 1, + asOf: Date = Date(), artwork: Data? = nil, + bundleIdentifier: String? = nil, origin: MediaOrigin = .adapter) { + self.title = title + self.artist = artist + self.album = album + self.isPlaying = isPlaying + self.duration = duration + self.elapsed = elapsed + self.playbackRate = playbackRate + self.asOf = asOf + self.artwork = artwork + self.bundleIdentifier = bundleIdentifier + self.origin = origin + } + + /// Die Position zum angegebenen Zeitpunkt. + /// + /// Der Adapter liefert `elapsedTime` **nicht** laufend nach — bei + /// unveränderter Wiedergabe kommt minutenlang kein Ereignis. Ein Scrubber, + /// der stur `elapsed` anzeigt, steht deshalb still, obwohl der Titel läuft. + /// Er muss zwischen den Ereignissen selbst hochzählen, ausgehend von + /// `asOf` und `playbackRate`. + public func elapsed(at date: Date) -> TimeInterval { + guard isPlaying else { return clamp(elapsed) } + let advanced = elapsed + date.timeIntervalSince(asOf) * playbackRate + return clamp(advanced) + } + + /// Fortschritt von 0 bis 1, oder `nil` ohne bekannte Länge. + public func progress(at date: Date) -> Double? { + guard let duration, duration > 0 else { return nil } + return min(max(elapsed(at: date) / duration, 0), 1) + } + + public var hasMetadata: Bool { !title.isEmpty || !artist.isEmpty } + + private func clamp(_ value: TimeInterval) -> TimeInterval { + // Nie über die Länge hinaus: sonst zeigt die Anzeige nach dem Ende + // eines Titels weiter steigende Zahlen, solange kein Ereignis kommt. + guard let duration, duration > 0 else { return max(value, 0) } + return min(max(value, 0), duration) + } +} + +/// Was das Widget auslösen kann. +public enum MediaCommand: Equatable, Sendable { + case playPause + case next + case previous + case seek(to: TimeInterval) +} diff --git a/Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift b/Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift new file mode 100644 index 0000000..71c2b79 --- /dev/null +++ b/Packages/OnyxKit/Tests/MediaProviderTests/NowPlayingTests.swift @@ -0,0 +1,181 @@ +import Testing +import Foundation +@testable import MediaProvider + +@Suite("Wiedergabe-Fortschritt") +struct NowPlayingProgressTests { + + private let t0 = Date(timeIntervalSince1970: 1_800_000_000) + + private func playing(elapsed: TimeInterval = 30, duration: TimeInterval? = 200, + rate: Double = 1) -> NowPlaying { + NowPlaying(title: "Titel", isPlaying: true, duration: duration, + elapsed: elapsed, playbackRate: rate, asOf: t0) + } + + @Test("Während der Wiedergabe zählt die Position zwischen den Ereignissen weiter") + func elapsedAdvancesWhilePlaying() { + // Der Adapter liefert `elapsedTime` nicht laufend nach. Ohne dieses + // Weiterzählen steht der Scrubber still, obwohl der Titel läuft. + let state = playing(elapsed: 30) + #expect(state.elapsed(at: t0.addingTimeInterval(10)) == 40) + } + + @Test("Im pausierten Zustand bleibt die Position stehen") + func elapsedFreezesWhenPaused() { + var state = playing(elapsed: 30) + state.isPlaying = false + #expect(state.elapsed(at: t0.addingTimeInterval(60)) == 30) + } + + @Test("Doppelte Geschwindigkeit zählt doppelt so schnell") + func playbackRateIsHonoured() { + let state = playing(elapsed: 0, rate: 2) + #expect(state.elapsed(at: t0.addingTimeInterval(10)) == 20) + } + + @Test("Die Position läuft nie über die Länge hinaus") + func elapsedNeverExceedsDuration() { + // Sonst zeigt die Anzeige nach dem Ende eines Titels weiter steigende + // Zahlen, solange kein neues Ereignis kommt. + let state = playing(elapsed: 190, duration: 200) + #expect(state.elapsed(at: t0.addingTimeInterval(3600)) == 200) + } + + @Test("Ohne bekannte Länge zählt sie einfach weiter — Livestreams haben kein Ende") + func liveStreamKeepsCounting() { + let state = playing(elapsed: 10, duration: nil) + #expect(state.elapsed(at: t0.addingTimeInterval(100)) == 110) + } + + @Test("Eine negative Position wird auf null gezogen") + func elapsedNeverGoesNegative() { + var state = playing(elapsed: 5) + state.isPlaying = false + state.elapsed = -3 + #expect(state.elapsed(at: t0) == 0) + } + + @Test("Fortschritt liegt zwischen 0 und 1") + func progressIsNormalised() { + let state = playing(elapsed: 50, duration: 200) + #expect(state.progress(at: t0) == 0.25) + } + + @Test("Ohne Länge gibt es keinen Fortschritt statt eines falschen Wertes") + func progressIsNilWithoutDuration() { + #expect(playing(duration: nil).progress(at: t0) == nil) + } + + @Test("Länge null ergibt keinen Fortschritt statt einer Division durch null") + func progressIsNilForZeroDuration() { + #expect(playing(duration: 0).progress(at: t0) == nil) + } +} + +@Suite("Adapter-Payload") +struct AdapterPayloadTests { + + private func line(_ json: String) -> String { json } + + @Test("Vollmeldung setzt alle Felder") + func fullPayloadPopulates() { + let state = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","album":"LP",\ + "playing":true,"duration":210.5,"elapsedTime":42.0,"playbackRate":1,\ + "bundleIdentifier":"com.google.Chrome"}} + """, to: nil) + + #expect(state?.title == "Song") + #expect(state?.artist == "Band") + #expect(state?.isPlaying == true) + #expect(state?.duration == 210.5) + #expect(state?.elapsed == 42) + #expect(state?.bundleIdentifier == "com.google.Chrome") + #expect(state?.origin == .adapter) + } + + @Test("Teilmeldung ergänzt den bisherigen Stand, statt ihn zu ersetzen") + func diffPayloadMerges() { + // Der eigentliche Grund für diese Unterscheidung: eine Teilmeldung mit + // nur der Position würde sonst Titel, Interpret und Cover löschen. + let full = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","playing":true}} + """, to: nil) + + let updated = AdapterPayload.apply(line: """ + {"type":"data","diff":true,"payload":{"elapsedTime":99.0}} + """, to: full) + + #expect(updated?.title == "Song") + #expect(updated?.artist == "Band") + #expect(updated?.elapsed == 99) + } + + @Test("Leere Vollmeldung heißt: es läuft nichts") + func emptyFullPayloadMeansNothingPlaying() { + #expect(AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{}} + """, to: nil) == nil) + } + + @Test("Leere Teilmeldung lässt den Stand unangetastet") + func emptyDiffKeepsState() { + let previous = NowPlaying(title: "Song") + let result = AdapterPayload.apply(line: """ + {"type":"data","diff":true,"payload":{}} + """, to: previous) + + #expect(result?.title == "Song") + } + + @Test("Der Zeitstempel der Quelle wird übernommen, nicht der Empfangszeitpunkt") + func timestampComesFromPayload() { + // Sonst springt der Scrubber bei jedem Ereignis, weil die Position auf + // einen falschen Bezugszeitpunkt gerechnet wird. + let state = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"S","elapsedTime":10.0,\ + "timestamp":"2026-08-10T14:22:16Z"}} + """, to: nil) + + let expected = ISO8601DateFormatter().date(from: "2026-08-10T14:22:16Z") + #expect(state?.asOf == expected) + } + + @Test("Cover kommt als base64 an") + func artworkIsDecoded() { + let raw = Data([0xFF, 0xD8, 0xFF]).base64EncodedString() + let state = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"S","artworkData":"\(raw)"}} + """, to: nil) + + #expect(state?.artwork == Data([0xFF, 0xD8, 0xFF])) + } + + @Test("Länge null wird zu 'unbekannt' statt zu einer Null-Länge") + func zeroDurationBecomesNil() { + let state = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"Stream","duration":0}} + """, to: nil) + + #expect(state?.duration == nil) + } + + @Test("Kaputte Zeilen werden verworfen, nicht interpretiert") + func garbageIsIgnored() { + for junk in ["", "kein json", "{}", "{\"type\":\"log\"}", "null"] { + #expect(AdapterPayload.apply(line: junk, to: nil) == nil) + } + } + + @Test("`playing: true` wird nicht als Zahl gelesen") + func boolIsNotMistakenForNumber() { + // Reihenfolge in AnyDecodable: Bool vor Double. Andernfalls wird `true` + // zu 1 und `playing` bliebe für immer falsch. + let state = AdapterPayload.apply(line: """ + {"type":"data","diff":false,"payload":{"title":"S","playing":true}} + """, to: nil) + + #expect(state?.isPlaying == true) + } +} diff --git a/Scripts/build-mediaremote-adapter.sh b/Scripts/build-mediaremote-adapter.sh new file mode 100755 index 0000000..abe1353 --- /dev/null +++ b/Scripts/build-mediaremote-adapter.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Baut den mediaremote-adapter aus dem mitgelieferten Quellcode. +# +# Wird als Build-Schritt des App-Targets aufgerufen. Repliziert das +# CMakeLists.txt des Projekts mit clang — cmake ist keine Voraussetzung, die +# man einem Mitentwickler zumuten will. +# +# Der Umweg über /usr/bin/perl ist kein Trick um seiner selbst willen: seit +# macOS 15.4 prüft mediaremoted Entitlements, und Fremdprogramme bekommen keine +# Now-Playing-Daten mehr. Perl ist ein berechtigtes Systemprogramm und lädt das +# hier gebaute Framework. +# +# Quelle: https://github.com/ungive/mediaremote-adapter (BSD-3-Clause), +# eingebettet unter Vendor/ — siehe ONYX-VERSION.txt für den Stand. + +set -euo pipefail +export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer + +SRC="${SRC:-$(cd "$(dirname "$0")/.." && pwd)/Vendor/mediaremote-adapter}" +OUT="${OUT:?Zielverzeichnis fehlt}" +NAME=MediaRemoteAdapter +FW="$OUT/$NAME.framework" + +[[ -d "$SRC" ]] || { echo "Quellen fehlen unter $SRC" >&2; exit 1; } + +rm -rf "$OUT" +mkdir -p "$FW/Versions/A/Headers" "$FW/Versions/A/Resources" + +SOURCES=( + "$SRC"/src/adapter/{env,get,globals,keys,now_playing,repeat,seek,send,shuffle,speed,stream,test}.m + "$SRC"/src/private/MediaRemote.m + "$SRC"/src/utility/{Debounce,helpers}.m +) + +echo "→ Framework-Binary" +xcrun clang -dynamiclib -fobjc-arc -fvisibility=default -O2 \ + -arch arm64 -mmacosx-version-min=26.0 \ + -I"$SRC/include" -I"$SRC/src" \ + -framework Foundation -framework AppKit -framework UniformTypeIdentifiers \ + -install_name "@rpath/$NAME.framework/Versions/A/$NAME" \ + -o "$FW/Versions/A/$NAME" \ + "${SOURCES[@]}" + +cp "$SRC/include/$NAME.h" "$FW/Versions/A/Headers/" + +cat > "$FW/Versions/A/Resources/Info.plist" < + + + + CFBundleExecutable$NAME + CFBundleIdentifiercom.vandenbe.$NAME + CFBundleName$NAME + CFBundlePackageTypeFMWK + CFBundleShortVersionString0.1 + CFBundleVersion0.1.0 + + +PLIST + +ln -sf A "$FW/Versions/Current" +ln -sf "Versions/Current/$NAME" "$FW/$NAME" +ln -sf Versions/Current/Headers "$FW/Headers" +ln -sf Versions/Current/Resources "$FW/Resources" + +echo "→ Test-Client" +xcrun clang -fobjc-arc -O2 -arch arm64 \ + -I"$SRC/src/test" \ + -framework Foundation -framework MediaPlayer \ + -o "$OUT/${NAME}TestClient" \ + "$SRC"/src/test/{main,NowPlayingTest}.m + +echo "→ ad-hoc signieren" +codesign --force --deep --sign - "$FW" +codesign --force --sign - "$OUT/${NAME}TestClient" + +cp "$SRC/bin/mediaremote-adapter.pl" "$OUT/" + +echo +echo "Fertig: $OUT" +ls -la "$OUT" diff --git a/Vendor/mediaremote-adapter/.clang-format b/Vendor/mediaremote-adapter/.clang-format new file mode 100644 index 0000000..a39dfd7 --- /dev/null +++ b/Vendor/mediaremote-adapter/.clang-format @@ -0,0 +1,49 @@ +--- +BasedOnStyle: LLVM +IndentWidth: 4 +--- +Language: Cpp +AccessModifierOffset: -4 +PointerAlignment: Left +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: false + AfterClass: true + AfterControlStatement: Never + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterStruct: true + AfterUnion: true + AfterExternBlock: false + BeforeCatch: true +AlignAfterOpenBracket: DontAlign +Cpp11BracedListStyle: false +IncludeBlocks: Regroup +IncludeCategories: + - Regex: "" + Priority: 3 + - Regex: "<[^Q][[:alnum:]_.]+>" + Priority: 1 + - Regex: "" + Priority: 2 + - Regex: "<[^Q].+>" + Priority: 3 + - Regex: ".*" + Priority: 4 +SeparateDefinitionBlocks: Always +AllowShortLambdasOnASingleLine: Empty +AlwaysBreakTemplateDeclarations: true +AlignOperands: DontAlign +AlignEscapedNewlines: Left +BinPackParameters: true +AlignTrailingComments: + Kind: Never +--- +Language: JavaScript +SpacesInContainerLiterals: false +JavaScriptQuotes: Single +JavaScriptWrapImports: true +--- +# Notes: +# Designated initializer list formatting: Write a ',' after the last item. diff --git a/Vendor/mediaremote-adapter/.cmake-format b/Vendor/mediaremote-adapter/.cmake-format new file mode 100644 index 0000000..418797a --- /dev/null +++ b/Vendor/mediaremote-adapter/.cmake-format @@ -0,0 +1,10 @@ +line_width = 80 +tab_size = 4 +separate_ctrl_name_with_space = False +separate_fn_name_with_space = False +dangle_parens = False +fractional_tab_policy = 'use-space' +max_subgroups_hwrap = 3 +command_case = 'canonical' +keyword_case = 'upper' +literal_comment_pattern = '.*' diff --git a/Vendor/mediaremote-adapter/.gitignore b/Vendor/mediaremote-adapter/.gitignore new file mode 100644 index 0000000..fb759c8 --- /dev/null +++ b/Vendor/mediaremote-adapter/.gitignore @@ -0,0 +1,8 @@ +build +.DS_Store +notes +CMakePresets.json +.cache +todo*.txt +dist +perltidy.ERR diff --git a/Vendor/mediaremote-adapter/.gitmodules b/Vendor/mediaremote-adapter/.gitmodules new file mode 100644 index 0000000..e69de29 diff --git a/Vendor/mediaremote-adapter/.perltidyrc b/Vendor/mediaremote-adapter/.perltidyrc new file mode 100644 index 0000000..885abf7 --- /dev/null +++ b/Vendor/mediaremote-adapter/.perltidyrc @@ -0,0 +1,4 @@ +# https://perltidy.sourceforge.net/perltidy.html + +-naws +--indent-columns=2 diff --git a/Vendor/mediaremote-adapter/CMakeLists.txt b/Vendor/mediaremote-adapter/CMakeLists.txt new file mode 100644 index 0000000..0563972 --- /dev/null +++ b/Vendor/mediaremote-adapter/CMakeLists.txt @@ -0,0 +1,92 @@ +cmake_minimum_required(VERSION 3.15) +project(MediaRemoteAdapter LANGUAGES OBJC) + +set(ADAPTER_VERSION_MAJOR 0) +set(ADAPTER_VERSION_MINOR 1) +set(ADAPTER_VERSION_PATCH 0) +set(ADAPTER_VERSION_SHORT "${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}") +set(ADAPTER_VERSION + "${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}.${ADAPTER_VERSION_PATCH}" +) + +set(MEDIAREMOTEADAPTER_FRAMEWORK_NAME "MediaRemoteAdapter" + CACHE STRING "The output name of the adapter framework") +set(MEDIAREMOTEADAPTER_TEST_CLIENT_NAME "MediaRemoteAdapterTestClient" + CACHE STRING "The output name of the adapter test client") + +set(ADAPTER_SOURCES + src/adapter/env.m + src/adapter/get.m + src/adapter/globals.m + src/adapter/keys.m + src/adapter/now_playing.m + src/adapter/repeat.m + src/adapter/seek.m + src/adapter/send.m + src/adapter/shuffle.m + src/adapter/speed.m + src/adapter/stream.m + src/adapter/test.m + src/private/MediaRemote.m + src/utility/Debounce.m + src/utility/helpers.m) +set(HEADERS include/MediaRemoteAdapter.h) + +set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64") + +add_library(MediaRemoteAdapter SHARED ${ADAPTER_SOURCES}) + +set_target_properties( + MediaRemoteAdapter + PROPERTIES FRAMEWORK TRUE + FRAMEWORK_VERSION A + MACOSX_FRAMEWORK_IDENTIFIER + "com.vandenbe.${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}" + MACOSX_FRAMEWORK_SHORT_VERSION_STRING "${ADAPTER_VERSION_SHORT}" + MACOSX_FRAMEWORK_BUNDLE_VERSION "${ADAPTER_VERSION}" + MACOSX_FRAMEWORK_BUNDLE_NAME + "${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}" + PUBLIC_HEADER "${HEADERS}" + OUTPUT_NAME "${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}") + +target_link_libraries(MediaRemoteAdapter "-framework Foundation" + "-framework AppKit") + +find_library(UT_FRAMEWORK UniformTypeIdentifiers) +if(UT_FRAMEWORK) + target_link_libraries(MediaRemoteAdapter ${UT_FRAMEWORK}) +endif() + +target_include_directories(MediaRemoteAdapter + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_include_directories(MediaRemoteAdapter + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + +target_compile_options(MediaRemoteAdapter PRIVATE "-fobjc-arc") + +# Ensure that symbols are visible, otherwise the Perl script can't call into +# functions that are exported by the framework. +target_compile_options(MediaRemoteAdapter PRIVATE -fvisibility=default) + +add_custom_command( + TARGET MediaRemoteAdapter + POST_BUILD + COMMAND codesign --force --deep --sign - + $ + COMMENT "Ad-hoc signing ${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}.framework") + +add_executable(MediaRemoteAdapterTestClient src/test/main.m + src/test/NowPlayingTest.m) +target_link_libraries(MediaRemoteAdapterTestClient + PRIVATE "-framework Foundation" "-framework MediaPlayer") +target_include_directories(MediaRemoteAdapterTestClient + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/test) +set_target_properties(MediaRemoteAdapterTestClient + PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}") +set_source_files_properties(src/test/main.m PROPERTIES COMPILE_FLAGS + "-fobjc-arc") +set_source_files_properties(src/test/NowPlayingTest.m PROPERTIES COMPILE_FLAGS + "-fobjc-arc") +set_target_properties( + MediaRemoteAdapterTestClient + PROPERTIES OUTPUT_NAME "${MEDIAREMOTEADAPTER_TEST_CLIENT_NAME}") diff --git a/Vendor/mediaremote-adapter/LICENSE b/Vendor/mediaremote-adapter/LICENSE new file mode 100644 index 0000000..e70a4bd --- /dev/null +++ b/Vendor/mediaremote-adapter/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2025, Jonas van den Berg and contributors + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Vendor/mediaremote-adapter/Makefile b/Vendor/mediaremote-adapter/Makefile new file mode 100644 index 0000000..cd8b50c --- /dev/null +++ b/Vendor/mediaremote-adapter/Makefile @@ -0,0 +1,10 @@ +.PHONY: all version + +all: + +version: + @system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p' + @date + +update-readme-badges: + python3 ./scripts/update-readme-badges.py diff --git a/Vendor/mediaremote-adapter/ONYX-VERSION.txt b/Vendor/mediaremote-adapter/ONYX-VERSION.txt new file mode 100644 index 0000000..783de85 --- /dev/null +++ b/Vendor/mediaremote-adapter/ONYX-VERSION.txt @@ -0,0 +1 @@ +3ac3d4bdf862c7b5399b4fba4df5689f5c38609a 2026-05-11 16:52:03 +0200 diff --git a/Vendor/mediaremote-adapter/README.md b/Vendor/mediaremote-adapter/README.md new file mode 100644 index 0000000..0833ff2 --- /dev/null +++ b/Vendor/mediaremote-adapter/README.md @@ -0,0 +1,529 @@ +> **@Apple** *Before breaking this, +> please consider giving Mac users the option +> to share actively playing media with the apps they use +> and to control media playback. +> Perhaps by introducing a new entitlement +> that can be granted to apps by users in the system settings. +> There are +> [many](https://musicpresence.app) +> [use](https://folivora.ai) +> [cases](https://lyricfever.com) +> [for](https://theboring.name) +> [this](https://github.com/kirtan-shah/nowplaying-cli).* + +> **@Developers** *Please **star** +> this repository to show Apple that we care.* + +--- + + +![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600) +![](https://img.shields.io/static/v1?label=macOS&message=macOS%2026.0%20%2825A5316i%29&labelColor=444&color=blue) +![](https://img.shields.io/static/v1?label=last%20tested&message=Thu%20Jul%2024%2002%3A24%3A11%20CEST%202025&labelColor=444&color) + + +# MediaRemote Adapter + +Get now playing information using the MediaRemote framework +on all macOS versions, including 15.4 and above. + +This works by using a system binary – `/usr/bin/perl` in this case – +which is entitled to use the MediaRemote framework +and by dynamically loading a custom helper framework +that prints real-time updates to stdout. + +## Example + +Install the [media-control](https://github.com/ungive/media-control) +CLI tool to see this project in action. Works on all macOS versions: + +``` +$ brew tap ungive/media-control +$ brew install media-control +$ media-control stream +``` + +## Usage + +This project provides a Perl script +with a well-defined CLI interface that you can invoke from your app +in order to read now playing information and control media players. +The [mediaremote-adapter.pl](./bin/mediaremote-adapter.pl) script +needs to be bundled with your app, +alongside the `MediaRemoteAdapter.framework` +and optionally the `MediaRemoteAdapterTestClient` +which are exposed as CMake targets in [CMakeLists.txt](./CMakeLists.txt). +You can find instructions to build the framework in the next section. + +The script must then be invoked like this: + +``` +/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework COMMAND +``` + +`COMMAND` is a placeholder for one of the commands documented below. + +For the `test` command the `NowPlayingTestCMediaRemoteAdapterTestClientlient` must be passed +as an additional argument: + +``` +/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test +``` + +For ease of use you can also always pass the path to the test client: + +``` +/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient COMMAND +``` + +For more help on available commands read below or omit the `COMMAND` argument. + +> [!WARNING] +> This project is still in development +> and the API may experience breaking changes across minor revisions. + +> [!NOTE] +> For a maintained Swift package look at this excellent fork: +> https://github.com/ejbills/mediaremote-adapter + +## Build from source + +``` +$ git clone https://github.com/ungive/mediaremote-adapter.git +$ cd mediaremote-adapter +$ mkdir build && cd build +$ cmake .. +$ cmake --build . +$ cd .. +$ FRAMEWORK_PATH=$(realpath ./build/MediaRemoteAdapter.framework) +$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" stream +``` + +This creates the `MediaRemoteAdapter.framework` in the build directory, +which must be *bundled* with your app, but *not linked against*. +The framework is only used by the script +and must merely be passed as a script argument. + +If you want to be able to test whether the adapter still works, +which can be useful to e.g. automatically fall back to AppleScript, +you need to also bundle the `MediaRemoteAdapterTestClient` executable with your app +and pass it as an additional argument: + +``` +$ HELPER_PATH=$(realpath ./build/MediaRemoteAdapterTestClient) +$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" "$HELPER_PATH" test +``` + +An exit code of `0` then means the adapter is functional and safe to use. + +The framework and test executable are built for the following architectures: +`x86_64` `arm64` + +## Commands + +- [get](#get) +- [stream](#stream) +- [send COMMAND](#send-command) +- [seek POSITION](#seek-position) +- [shuffle MODE](#shuffle-mode) +- [repeat MODE](#repeat-mode) +- [speed SPEED](#speed-speed) +- [test](#test) + +### get + +Prints now playing information once with all available metadata. + +Output is encoded as JSON and characterized by either `null` +or a dictionary with any of the following keys: + +> `bundleIdentifier` +`parentApplicationBundleIdentifier` +`playing` +`title` +`artist` +`album` +`duration` +`elapsedTime` +`timestamp` +`artworkMimeType` +`artworkData` +`chapterNumber` +`composer` +`genre` +`isAdvertisement` +`isBanned` +`isInWishList` +`isLiked` +`isMusicApp` +`playbackRate` +`prohibitsSkip` +`queueIndex` +`radioStationIdentifier` +`repeatMode` +`shuffleMode` +`startTime` +`supportsFastForward15Seconds` +`supportsIsBanned` +`supportsIsLiked` +`supportsRewind15Seconds` +`totalChapterCount` +`totalDiscCount` +`totalQueueCount` +`totalTrackCount` +`trackNumber` +`uniqueIdentifier` +`contentItemIdentifier` +`radioStationHash` +`mediaType` + +The following mandatory keys never have a null value: +`bundleIdentifier` +`playing` +`title`. +If any of the mandatory keys cannot be determined, +the command prints `null`. +Media without a title is considered invalid. + +The `mediaType` may contain one of the following values: +- `MRMediaRemoteMediaTypeMusic` +- `kMRMediaRemoteNowPlayingInfoTypeAudio` +- Possibly others, this key is not very well documented + +**Caveats** + +Metadata such as `artworkData` and `artworkMimeType` +often takes a bit of time to load +and may not appear in the output in all cases. +Do not rely on this key to be present reliably. +Either use the `stream` command or poll `get` regularly, +to ensure you get the artwork data *eventually*. + +**Options** + +`--now` Adds an `elapsedTimeNow` key with an estimation of the current +elapsed playback time. This estimation may be off by up to a second. +To determine a more accurate time without polling `get` continuously, +calculate it using the `elapsedTime` and `timestamp` keys. `elapsedTime` +contains the elapsed time at the time that is stored in `timestamp`. + +`--micros` Replaces the following keys with microsecond equivalents: + +| Original key | Converted key name | Comment | +|------------------|------------------------|---------------------------| +| `duration` | `durationMicros` | - | +| `elapsedTime` | `elapsedTimeMicros` | - | +| `elapsedTimeNow` | `elapsedTimeNowMicros` | Only present with `--now` | +| `timestamp` | `timestampEpochMicros` | Converted to epoch time | + +`--no-artwork` Omits the `artworkData` and `artworkMimeType` keys +from the payload. Useful for consumers that do not render artwork, +since this avoids emitting several hundred kilobytes of base64-encoded +image data per update. + +--- + +### stream + +Streams now playing information updates in real-time +until the script receives a SIGTERM signal. + +Output is encoded as JSON and characterized by +a dictionary with the following keys: + +> `type` +`diff` +`payload` + +`type` is always a string with the value `"data"`. + +`payload` contains the now playing information and is a dictionary +that is structurally identical to the output of the `get` command, +with the same keys. The dictionary itself is never `null`. +No keys are set at all, +when no media player is reporting now playing information. +Some keys may have a `null` value, +when the media player reports `null` for them (this happens rarely, if ever). +Any key may be `null`, when `diff` is set to true and the key vanishes. + +`diff` is a boolean that indicates whether the `payload` +contains only fields whose values have been updated. +When set to `false`, +the payload is to be considered the current now playing state +with all available keys and their values, +regardless of any payloads that have been sent in the past. +When set to `true` on the other hand, +the last sent non-diff payload must be updated with these new values, +in order to have a representation of the the current now playing state. +When a key is not present anymore, it's set to `null` in the payload +and its previous value should be removed. +**Diffing is enabled by default, but can be disabled with a command line flag.** + +**Options** + +`--no-diff` Disables diffing. `diff` is always `false` +and `payload` always contains all current information. + +`--debounce=N` Adds a debounce delay in milliseconds +between the point where changes are detected +and when they are printed. +If a new update comes in during delaying, +the delay is restarted and all updates are merged. +This is useful to prevent bursts of smaller updates. +The default is 0. + +`--micros` Identical to the `--micros` option of the `get` command. + +`--no-artwork` Identical to the `--no-artwork` option of the `get` +command. Particularly useful with `stream` with the `--no-diff` parameter, +since it otherwise re-emits the full artwork payload on every update. + +**Experimental options** + +`--experimental-peculiar-debounce:BUNDLE_ID=N`  +Adds a debounce delay in milliseconds for the case when the media player +with the given bundle identifier (`BUNDLE_ID`) reports metadata that +contains parts of the previous track and parts of the next track, +but doesn't contain the full metadata of the next track. +Whenever the track title changes, +the update for it is either delayed for the given debounce delay +or the update is printed when all other metadata updated as well, +whichever happens earlier. +Currently only `com.tidal.desktop` can be passed for `BUNDLE_ID`, +since it is the only media player that is known to have this issue. +A value of `1000` for `N` is recommended for TIDAL specifically. + +--- + +### send COMMAND + +Sends a MediaRemote command to the now playing application. + +The value for `COMMAND` must be a valid ID from the table below. + +| ID | MediaRemote key | Description | +|:--:|-------------------------|-------------------------------| +| 0 | kMRPlay | Start playback | +| 1 | kMRPause | Pause playback | +| 2 | kMRTogglePlayPause | Toggle between play and pause | +| 3 | kMRStop | Stop playback | +| 4 | kMRNextTrack | Skip to the next track | +| 5 | kMRPreviousTrack | Return to the previous track | +| 6 | kMRToggleShuffle | Toggle shuffle mode | +| 7 | kMRToggleRepeat | Toggle repeat mode | +| 8 | kMRStartForwardSeek | Start seeking forward | +| 9 | kMREndForwardSeek | Stop seeking forward | +| 10 | kMRStartBackwardSeek | Start seeking backward | +| 11 | kMREndBackwardSeek | Stop seeking backward | +| 12 | kMRGoBackFifteenSeconds | Go back 15 seconds | +| 13 | kMRSkipFifteenSeconds | Skip ahead 15 seconds | + +--- + +### seek POSITION + +Seeks to a specific timeline position with the now playing application. + +The value for `POSITION` must a valid positive integer. +The unit is microseconds. + +--- + +### shuffle MODE + +Sets the shuffle mode. + +The value for `MODE` must be a valid ID from the table below. + +| ID | Description | +|:--:|----------------| +| 1 | Disable | +| 2 | Shuffle albums | +| 3 | Shuffle tracks | + +--- + +### repeat MODE + +Sets the repeat mode. + +The value for `MODE` must be a valid ID from the table below. + +| ID | Description | +|:--:|-----------------| +| 1 | Disable | +| 2 | Repeat track | +| 3 | Repeat playlist | + +--- + +### speed SPEED + +Sets the playback speed. + +The value for `SPEED` must be a valid positive integer. + +--- + +### test + +Tests if the adapter is entitled to use the MediaRemote framework +and if it is able to execute any of the supported commands without failure. +An exit code of 0 means the adapter is functional and safe to use. + +This can be integrated into your app +to help confirm that the adapter is still functional and if not, +fall back to other methods for media detection (e.g. AppleScript), +since future macOS updates may break MediaRemote access again. + +#### Usage + +``` +/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test +``` + +Note that the `test` command requires the absolute path +to the `MediaRemoteAdapterTestClient` executable after the framework path. +For ease of use you can always pass the path to the test client executable, +even when using other commands, like `get` or `stream`. + +#### Output + +An exit code of `0` indicates that the adapter is still functional +and can safely be used to detect media. +Any other exit indicates that the adapter is likely broken. + +If you ever get an exit code other than `0`, +please [report this](https://github.com/ungive/mediaremote-adapter/issues). Thank you! + +#### How this works + +1. Now playing information is attempted to be read normally using `get` +2. If no media is detected, the `MediaRemoteAdapterTestClient` helper process is launched to simulate media playback +3. While the helper process is running, now playing information is attempted to be read again using `get` +4. Afterwards the helper process is terminated +5. If any of the `get` attempts yielded media information, the command exits with an exit code of `0` +6. Otherwise the command exits with an exit code of `1` + +> [!WARNING] +> **May interfere with other apps using MediaRemote** +> The test can create a fake media entry that will briefly appear +as the now playing application. +This only happens when no other media is playing. +Since the helper process has no bundle identifier, +it is mostly ignored by the `stream` and `get` commands — +`stream` won't update, and `get` will print `null`. + +--- + +## Built-in fixes + +This library has some fixes built-in +to accomodate for inconsistencies within the MediaRemote framework: + +- Artwork data sometimes unloads for a brief moment, + e.g. when changing the current timeline position of a track. + To combat this, artwork data is reused when the track has not changed, + the track had artwork data before and the artwork data has disappeared. + This fix is applied when using the `stream` command + +If you need a way to disable any or all of these fixes, +please open an issue or create a pull request. + +--- + +## Implementation notes + +- Consider `NSJSONSerialization` for JSON deserialization. + This is what is used for encoding +- You can use `NSData`'s `initWithBase64EncodedString` + for decoding of base64 data +- Every line printed to stderr is an error message. + If the script did not exit with a non-zero exit code, + then any of these errors are non-fatal and can be safely ignored +- Other apps using MediaRemote Adapter may run `test` which should not interfere with the `stream` and `get` commands, but will generate a missing bundle identifier error message, which can be ignored. See the `test` command section for more information. +- You should not reinvoke the script when a fatal error occurs + (non-zero exit code) +- Make sure to pass the absolute path of the bundled framework and helper executable + as arguments and not a relative path + +## Why this works + +According to the findings by [@My-Iris](https://github.com/Mx-Iris) in +[this comment](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419) +processes with a bundle identifier starting with `com.apple.` +are granted permission to access the MediaRemote framework. +The Perl platform binary `/usr/bin/perl` +is reported as having the bundle identifier `com.apple.perl` (or a variation). + +You can confirm this by streaming log messages using the Console.app +whilst running the script: + +`default 14:44:55.871495+0200 mediaremoted Adding client ` + +## Motivation + +This project was created due to the MediaRemote framework +being completely non-functional when being loaded directly from within an app, +starting with macOS 15.4 (see the numerous issues linked below). + +The aim of this project is to provide a tool (and perhaps soon a full library) +that serves as a fully functional alternative to using MediaRemote directly +and perhaps to inspire Apple to give us a public API +to read now playing information and control media playback on the device +(see the note at the top of this file). + +## Projects that use this library + +- [Now Playing Display](https://www.gxlabs.co/now-playing/): A macOS "now playing" hardware display for the Seeed XIAO ESP32-C6 paired with the Seeed Round Display for XIAO. Shows album art, track info, progress, and playback controls on a 240x240 circular TFT — connected over USB. Source code: https://github.com/gxlabs/now-playing-device +- [Music Presence](https://musicpresence.app) is a cross-platform desktop application + for showing what you are listening to in your Discord status. + It uses this library since version [2.3.1](https://github.com/ungive/discord-music-presence/releases/tag/v2.3.1) + to detect media from all media players again. Website: https://musicpresence.app +- [media-control](https://github.com/ungive/media-control) + is a CLI tool to control and observe media playback on any macOS version. + You can install it directly via brew: `$ brew tap ungive/media-control && brew install media-control`. Source code: https://github.com/ungive/media-control + +*If you use this library in your project, please +[let me know](https://github.com/ungive/mediaremote-adapter/issues) and I'll add it to the list!* + +## Useful links + +- Issues regarding MediaRemote breaking since macOS 15.4 + - https://github.com/vincentneo/LosslessSwitcher/issues/161 + - https://github.com/aviwad/LyricFever/issues/94 + - https://github.com/TheBoredTeam/boring.notch/issues/417 + - https://community.folivora.ai/t/now-playing-is-no-longer-working-on-macos-15-4/42802/11 + - https://github.com/ungive/discord-music-presence/issues/165 + - https://github.com/ungive/discord-music-presence/issues/245 + - https://github.com/kirtan-shah/nowplaying-cli/issues/28 + - https://github.com/FelixKratz/SketchyBar/issues/708 +- Getting now playing information using `osascript` and `MRNowPlayingRequest`. + Note that this is unable to load the song artwork + and it is impossible to get real-time updates with this solution. + It is much simpler to implement though + - https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800 + - https://github.com/fastfetch-cli/fastfetch/commit/1557f0c5564a8288604824e55db47508f65e82c9 + - https://gist.github.com/SKaplanOfficial/f9f5bdd6455436203d0d318c078358de + +## Acknowledgements + +Thank you [@Alexander5015](https://github.com/Alexander5015) for implementing the `test` command, +so we're able to detect when the adapter stops working! + +Thank you [@EinTim23](https://github.com/EinTim23) for bringing +a [similar workaround](https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800) to my attention! +Without your hint I most likely would not have dug into this anytime soon +and my app [Music Presence](https://musicpresence.app) +would still only work with AppleScript automation. + +Thank you [@My-Iris](https://github.com/Mx-Iris) +for providing insight into the changes made since macOS 15.4: +[aviwad/LyricFever#94](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419) + +## License + +This project is licensed under the BSD 3-Clause License. +See [LICENSE](./LICENSE) for details. + +Copyright (c) 2025 Jonas van den Berg and contributors diff --git a/Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl b/Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl new file mode 100755 index 0000000..f4c794b --- /dev/null +++ b/Vendor/mediaremote-adapter/bin/mediaremote-adapter.pl @@ -0,0 +1,280 @@ +#!/usr/bin/perl +# Copyright (c) 2025 Jonas van den Berg +# This file is licensed under the BSD 3-Clause License. + +# For usage information read below or run the script without arguments. + +use strict; +use warnings; +use DynaLoader; +use File::Spec; +use File::Basename; + +sub print_help() { + print <<'HELP'; +Usage: + mediaremote-adapter.pl FRAMEWORK_PATH [TEST_CLIENT_PATH] + [FUNCTION [PARAMS|OPTIONS...]] + +FRAMEWORK_PATH: + Absolute path to the MediaRemoteAdapter.framework directory + +TEST_CLIENT_PATH: (optional) + Absolute path to the MediaRemoteAdapterTestClient executable. Only for "test" + +FUNCTION: + stream Streams now playing information (as diff by default) + get Prints now playing information once with all available metadata + send Sends a command to the now playing application + seek Seeks to a specific timeline position + shuffle Sets the shuffle mode + repeat Sets the repeat mode + speed Sets the playback speed + test Tests if the adapter is entitled to use the MediaRemote framework. + An exit code other than 0 indicates the adapter is non-functional + +PARAMS: + send(command) + command: The MRCommand ID as a number (e.g. kMRPlay = 0) + seek(position) + position: The timeline position in microseconds + shuffle(mode) + mode: The shuffle mode + repeat(mode) + mode: The repeat mode + speed(speed) + speed: The playback speed + +OPTIONS: + get + --now: Adds an "elapsedTimeNow" key with an estimation of the current + elapsed playback time. This estimation may be off by up to a second. + To determine a more accurate time without polling "get" continuously, + calculate it using the "elapsedTime" and "timestamp" keys. "elapsedTime" + contains the elapsed time at the time that is stored in "timestamp". + stream + --no-diff: Disable diffing and always dump all metadata + --debounce=N: Delay in milliseconds to prevent spam (0 by default) + get, stream + --micros: Replaces the following time keys with microsecond equivalents: + "duration" -> "durationMicros" + "elapsedTime" -> "elapsedTimeMicros" + "elapsedTimeNow" -> "elapsedTimeNowMicros" + "timestamp" -> "timestampEpochMicros" (converted to epoch time) + --no-artwork: Omits "artworkData" and "artworkMimeType" from the payload. + Useful for consumers that do not render artwork, since this avoids + emitting several hundred kilobytes of base64 data per update. + --human-readable, -h: Makes values human-readable. Use only for debugging. + The JSON output is pretty-printed and the following keys are adapted: + "artworkData" -> Binary data is truncated to a shorter representation + +Examples (script name and framework path omitted): + stream --no-diff --debounce=100 + send 2 # Toggles play/pause in the media player (kMRATogglePlayPause) + repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist) + +HELP + exit 0; +} + +if (!defined $ARGV[1]) { + print_help(); +} + +sub fail { + my ($error) = @_; + print STDERR "$error\n"; + exit 1; +} + +fail "Framework path not provided" unless @ARGV >= 1; + +my $framework_path = shift @ARGV; + +# Optionally accept MEDIAREMOTEADAPTER_TEST_CLIENT_PATH path as second argument +my $maybe_helper_path = $ARGV[0] // ''; +if ($maybe_helper_path =~ m{/}){ + my $helper_path = shift @ARGV; + $ENV{MEDIAREMOTEADAPTER_TEST_CLIENT_PATH} = $helper_path; +} + +if (!defined $ARGV[0]) { + print_help(); +} + +my $framework_basename = File::Basename::basename($framework_path); +fail "Provided path is not a framework: $framework_path" + unless $framework_basename =~ s/\.framework$//; + +my $framework = File::Spec->catfile($framework_path, $framework_basename); +fail "Framework not found at $framework" unless -e $framework; + +my $handle = DynaLoader::dl_load_file($framework, 0) + or fail "Failed to load framework: $framework"; +my $function_name = shift @ARGV or fail "Missing function name"; +fail "Invalid function name: '$function_name'" + unless $function_name eq "stream" + || $function_name eq "get" + || $function_name eq "send" + || $function_name eq "seek" + || $function_name eq "shuffle" + || $function_name eq "repeat" + || $function_name eq "speed" + || $function_name eq "test"; + +sub parse_options { + my ($start_index) = @_; + my %arg_map; + my $i = $start_index; + while ($i <= $#ARGV) { + my $arg = $ARGV[$i]; + if ($arg =~ /^--([a-z:\.\\-]+)(?:=(.*))?$/) { + my $key = $1; + my $value = defined $2 ? $2 : undef; + $arg_map{$key} = $value; + splice @ARGV, $i, 1; + } + elsif ($arg =~ /^-([a-zA-Z]+)$/) { + my @flags = split //, $1; + $arg_map{$_} = undef for @flags; + splice @ARGV, $i, 1; + } + else { + $i++; + } + } + return \%arg_map; +} + +sub env_func { + my $symbol_name = shift; + return "${symbol_name}_env"; +} + +sub set_env_param { + my ($func, $index, $name, $value) = @_; + $ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value"; +} + +sub set_env_option_unsafe { + my ($name, $value) = @_; + $name =~ s/-/_/g; + $ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : ""; +} + +sub set_env_option { + my ($options, $key) = @_; + my $value = $options->{$key}; + if (defined $value) { + fail "Unexpected value for option '$key'"; + } + set_env_option_unsafe($key, $value); +} + +sub set_env_option_value { + my ($options, $key) = @_; + my $value = $options->{$key}; + if (!defined $value) { + fail "Missing value for option '$key'"; + } + set_env_option_unsafe($key, $value); +} + +my $symbol_name = "adapter_$function_name"; +if ($function_name eq "send") { + my $id = shift @ARGV; + fail "Missing ID for '$function_name' command" unless defined $id; + set_env_param($symbol_name, 0, "command", "$id"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "stream") { + my $options = parse_options(0); + foreach my $key (keys %{$options}) { + if ($key eq "no-diff") { + set_env_option($options, $key); + } + elsif ($key eq "debounce") { + set_env_option_value($options, $key); + } + elsif ($key eq "micros") { + set_env_option($options, $key); + } + elsif ($key eq "no-artwork") { + set_env_option($options, $key); + } + elsif ($key eq "human-readable" || $key eq "h") { + set_env_option($options, "human-readable"); + } + elsif ($key eq "experimental-peculiar-debounce:com.tidal.desktop") { + set_env_option_value($options, $key); + } + else { + fail "Unrecognized option '$key'"; + } + } + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "get") { + my $options = parse_options(0); + foreach my $key (keys %{$options}) { + if ($key eq "micros") { + set_env_option($options, $key); + } + elsif ($key eq "no-artwork") { + set_env_option($options, $key); + } + elsif ($key eq "human-readable" || $key eq "h") { + set_env_option($options, "human-readable"); + } + elsif ($key eq "now") { + set_env_option($options, $key); + } + else { + fail "Unrecognized option '$key'"; + } + } + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "seek") { + my $position = shift @ARGV; + fail "Missing position for '$function_name' command" unless defined $position; + set_env_param($symbol_name, 0, "position", "$position"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "shuffle") { + my $mode = shift @ARGV; + fail "Missing mode for '$function_name' command" unless defined $mode; + set_env_param($symbol_name, 0, "mode", "$mode"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "repeat") { + my $mode = shift @ARGV; + fail "Missing mode for '$function_name' command" unless defined $mode; + set_env_param($symbol_name, 0, "mode", "$mode"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "speed") { + my $speed = shift @ARGV; + fail "Missing speed for '$function_name' command" unless defined $speed; + set_env_param($symbol_name, 0, "speed", "$speed"); + $symbol_name = env_func($symbol_name); +} +elsif ($function_name eq "test") { + $symbol_name = "adapter_test"; +} + +if (defined shift @ARGV) { + fail "Too many arguments"; +} + +my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name") + or fail "Symbol '$symbol_name' not found in $framework"; +DynaLoader::dl_install_xsub("main::$function_name", $symbol); + +eval { + no strict "refs"; + &{"main::$function_name"}(); +}; +if ($@) { + fail "Error executing $function_name: $@"; +} diff --git a/Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h b/Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h new file mode 100644 index 0000000..c90321c --- /dev/null +++ b/Vendor/mediaremote-adapter/include/MediaRemoteAdapter.h @@ -0,0 +1,126 @@ +// Copyright (c) 2025 Jonas van den Berg +// This file is licensed under the BSD 3-Clause License. + +#ifndef MEDIAREMOTEADAPTER_ADAPTER_H +#define MEDIAREMOTEADAPTER_ADAPTER_H + +#import + +// Methods suffixed with "_env" read its parameters from the environment. +// Parameters must have the format: +// MEDIAREMOTEADAPTER___ +// Example: MEDIAREMOTEADAPTER_adapter_send_0_command + +extern NSString *kMRAProcessIdentifier; +extern NSString *kMRABundleIdentifier; +extern NSString *kMRAParentApplicationBundleIdentifier; +extern NSString *kMRAPlaying; + +extern NSString *kMRADurationMicros; +extern NSString *kMRAElapsedTimeMicros; +extern NSString *kMRATimestampEpochMicros; + +extern NSString *kMRAElapsedTimeNow; +extern NSString *kMRAElapsedTimeNowMicros; + +extern NSString *kMRATitle; +extern NSString *kMRAArtist; +extern NSString *kMRAAlbum; +extern NSString *kMRADuration; +extern NSString *kMRAElapsedTime; +extern NSString *kMRATimestamp; +extern NSString *kMRAArtworkMimeType; +extern NSString *kMRAArtworkData; + +extern NSString *kMRAChapterNumber; +extern NSString *kMRAComposer; +extern NSString *kMRAGenre; +extern NSString *kMRAIsAdvertisement; +extern NSString *kMRAIsBanned; +extern NSString *kMRAIsInWishList; +extern NSString *kMRAIsLiked; +extern NSString *kMRAIsMusicApp; +extern NSString *kMRAPlaybackRate; +extern NSString *kMRAProhibitsSkip; +extern NSString *kMRAQueueIndex; +extern NSString *kMRARadioStationIdentifier; +extern NSString *kMRARepeatMode; +extern NSString *kMRAShuffleMode; +extern NSString *kMRAStartTime; +extern NSString *kMRASupportsFastForward15Seconds; +extern NSString *kMRASupportsIsBanned; +extern NSString *kMRASupportsIsLiked; +extern NSString *kMRASupportsRewind15Seconds; +extern NSString *kMRATotalChapterCount; +extern NSString *kMRATotalDiscCount; +extern NSString *kMRATotalQueueCount; +extern NSString *kMRATotalTrackCount; +extern NSString *kMRATrackNumber; +extern NSString *kMRAUniqueIdentifier; +extern NSString *kMRAContentItemIdentifier; +extern NSString *kMRARadioStationHash; +extern NSString *kMRAMediaType; + +// Prints the current MediaRemote now playing information to stdout. +// Data is encoded as a JSON dictionary or "null" when there is no information. +extern void adapter_get(); +extern void adapter_get_env(); + +// Streams MediaRemote now playing updates to stdout. +// Each update is printed on a separate lined, encoded as a JSON dictionary. +// Exits when the process receives a SIGTERM signal. +extern void adapter_stream(); +extern void adapter_stream_env(); + +typedef enum { + kMRAPlay = 0, + kMRAPause = 1, + kMRATogglePlayPause = 2, + kMRAStop = 3, + kMRANextTrack = 4, + kMRAPreviousTrack = 5, + kMRAToggleShuffle = 6, + kMRAToggleRepeat = 7, + kMRAStartForwardSeek = 8, + kMRAEndForwardSeek = 9, + kMRAStartBackwardSeek = 10, + kMRAEndBackwardSeek = 11, + kMRAGoBackFifteenSeconds = 12, + kMRASkipFifteenSeconds = 13, +} MRACommand; + +// Sends the given MediaRemote command to the current now playing application. +extern void adapter_send(MRACommand command); +extern void adapter_send_env(); + +// Seeks the timeline of the nowplaying application to the given position. +// The position must be given in microseconds. +extern void adapter_seek(long position); +extern void adapter_seek_env(); + +typedef enum { + kMRAShuffleDisabled = 1, + kMRAShuffleAlbums = 2, + kMRAShuffleTracks = 3, +} MRAShuffleMode; + +extern void adapter_shuffle(MRAShuffleMode mode); +extern void adapter_shuffle_env(); + +typedef enum { + kMRARepeatDisabled = 1, + kMRARepeatTrack = 2, + kMRARepeatPlaylist = 3, +} MRARepeatMode; + +extern void adapter_repeat(MRARepeatMode mode); +extern void adapter_repeat_env(); + +extern void adapter_speed(int speed); +extern void adapter_speed_env(); + +// Tests whether the process is entitled to use the MediaRemote framework. +// Exits with exit code 0, if it is. Any other exit code means it is not. +extern void adapter_test(); + +#endif // MEDIAREMOTEADAPTER_ADAPTER_H diff --git a/Vendor/mediaremote-adapter/scripts/update-readme-badges.py b/Vendor/mediaremote-adapter/scripts/update-readme-badges.py new file mode 100755 index 0000000..0dbaace --- /dev/null +++ b/Vendor/mediaremote-adapter/scripts/update-readme-badges.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import subprocess +import urllib.parse +import os +import re + + +STATIC_BADGES = """ +![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600) +""" + + +def get_output(command): + result = subprocess.run( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + return result.stdout.strip() + + +def normalize_whitespace(text): + return re.sub(r'\s+', ' ', text.strip()) + + +def generate_badges(): + system_version = get_output( + "system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'" + ) + date_output = normalize_whitespace(get_output("date")) + encoded_system_version = urllib.parse.quote(system_version) + encoded_date = urllib.parse.quote(date_output) + badge1 = f"![](https://img.shields.io/static/v1?label=macOS&message={encoded_system_version}&labelColor=444&color=blue)" + badge2 = f"![](https://img.shields.io/static/v1?label=last%20tested&message={encoded_date}&labelColor=444&color)" + return f"{STATIC_BADGES.strip()}\n{badge1}\n{badge2}" + + +def update_readme(script_dir, new_badges): + readme_path = os.path.join(script_dir, "..", "README.md") + with open(readme_path, "r") as file: + content = file.read() + new_content = re.sub( + r".*?", + f"\n{new_badges}\n", + content, + flags=re.DOTALL, + ) + with open(readme_path, "w") as file: + file.write(new_content) + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + badges = generate_badges() + update_readme(script_dir, badges) + + +if __name__ == "__main__": + main() diff --git a/Vendor/mediaremote-adapter/src/adapter/env.h b/Vendor/mediaremote-adapter/src/adapter/env.h new file mode 100644 index 0000000..721e8f8 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/env.h @@ -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 + +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 diff --git a/Vendor/mediaremote-adapter/src/adapter/env.m b/Vendor/mediaremote-adapter/src/adapter/env.m new file mode 100644 index 0000000..5815381 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/env.m @@ -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 + +#import + +#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)); +} diff --git a/Vendor/mediaremote-adapter/src/adapter/get.h b/Vendor/mediaremote-adapter/src/adapter/get.h new file mode 100644 index 0000000..a23886c --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/get.h @@ -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 + +NSDictionary *internal_get(BOOL isTestMode); + +#endif // MEDIAREMOTEADAPTER_ADAPTER_GET_H diff --git a/Vendor/mediaremote-adapter/src/adapter/get.m b/Vendor/mediaremote-adapter/src/adapter/get.m new file mode 100644 index 0000000..9ec059a --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/get.m @@ -0,0 +1,146 @@ +// Copyright (c) 2025 Jonas van den Berg +// This file is licensed under the BSD 3-Clause License. + +#include +#import + +#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(); } diff --git a/Vendor/mediaremote-adapter/src/adapter/globals.h b/Vendor/mediaremote-adapter/src/adapter/globals.h new file mode 100644 index 0000000..27e3408 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/globals.h @@ -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 +#import + +#import "private/MediaRemote.h" + +extern MediaRemote* g_mediaRemote; +extern dispatch_queue_t g_serialdispatchQueue; + +#endif // MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H diff --git a/Vendor/mediaremote-adapter/src/adapter/globals.m b/Vendor/mediaremote-adapter/src/adapter/globals.m new file mode 100644 index 0000000..7f3b262 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/globals.m @@ -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); +} diff --git a/Vendor/mediaremote-adapter/src/adapter/keys.h b/Vendor/mediaremote-adapter/src/adapter/keys.h new file mode 100644 index 0000000..a3a5f03 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/keys.h @@ -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 + +// These keys are mandatory and must never be null, empty or missing. +NSArray *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 *identifyingPayloadKeys(void); + +#endif // MEDIAREMOTEADAPTER_ADAPTER_KEYS_H diff --git a/Vendor/mediaremote-adapter/src/adapter/keys.m b/Vendor/mediaremote-adapter/src/adapter/keys.m new file mode 100644 index 0000000..7bb84cc --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/keys.m @@ -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 *mandatoryPayloadKeys(void) { + return @[ kMRAProcessIdentifier, kMRATitle, kMRAPlaying ]; +} + +bool allMandatoryPayloadKeysSet(NSDictionary *data) { + NSArray *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 *identifyingPayloadKeys(void) { + return @[ + kMRAProcessIdentifier, kMRABundleIdentifier, + kMRAParentApplicationBundleIdentifier, kMRATitle, kMRAArtist, kMRAAlbum + ]; +} diff --git a/Vendor/mediaremote-adapter/src/adapter/now_playing.h b/Vendor/mediaremote-adapter/src/adapter/now_playing.h new file mode 100644 index 0000000..5f93d47 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/now_playing.h @@ -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 + +// 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 diff --git a/Vendor/mediaremote-adapter/src/adapter/now_playing.m b/Vendor/mediaremote-adapter/src/adapter/now_playing.m new file mode 100644 index 0000000..39e68c6 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/now_playing.m @@ -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; +} diff --git a/Vendor/mediaremote-adapter/src/adapter/repeat.m b/Vendor/mediaremote-adapter/src/adapter/repeat.m new file mode 100644 index 0000000..d225656 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/repeat.m @@ -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 + +#import + +#import "MediaRemoteAdapter.h" +#import "adapter/env.h" +#import "adapter/globals.h" +#import "adapter/now_playing.h" +#import "utility/helpers.h" + +static NSArray *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()); } diff --git a/Vendor/mediaremote-adapter/src/adapter/seek.m b/Vendor/mediaremote-adapter/src/adapter/seek.m new file mode 100644 index 0000000..6161e5c --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/seek.m @@ -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 + +#import + +#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()); } diff --git a/Vendor/mediaremote-adapter/src/adapter/send.m b/Vendor/mediaremote-adapter/src/adapter/send.m new file mode 100644 index 0000000..2534dc0 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/send.m @@ -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 + +#import + +#import "MediaRemoteAdapter.h" +#import "adapter/env.h" +#import "adapter/globals.h" +#import "adapter/now_playing.h" +#import "utility/helpers.h" + +static NSArray *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()); } diff --git a/Vendor/mediaremote-adapter/src/adapter/shuffle.m b/Vendor/mediaremote-adapter/src/adapter/shuffle.m new file mode 100644 index 0000000..2afe858 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/shuffle.m @@ -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 + +#import + +#import "MediaRemoteAdapter.h" +#import "adapter/env.h" +#import "adapter/globals.h" +#import "adapter/now_playing.h" +#import "utility/helpers.h" + +static NSArray *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()); +} diff --git a/Vendor/mediaremote-adapter/src/adapter/speed.m b/Vendor/mediaremote-adapter/src/adapter/speed.m new file mode 100644 index 0000000..d9e44da --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/speed.m @@ -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 + +#import + +#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()); } diff --git a/Vendor/mediaremote-adapter/src/adapter/stream.m b/Vendor/mediaremote-adapter/src/adapter/stream.m new file mode 100644 index 0000000..ff27dc8 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/stream.m @@ -0,0 +1,523 @@ +// Copyright (c) 2025 Jonas van den Berg +// This file is licensed under the BSD 3-Clause License. + +#import +#import +#import + +#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 *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 +*/ diff --git a/Vendor/mediaremote-adapter/src/adapter/test.m b/Vendor/mediaremote-adapter/src/adapter/test.m new file mode 100644 index 0000000..636a7d7 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/adapter/test.m @@ -0,0 +1,217 @@ +// Copyright (c) 2025 Alexander5015 +// This file is licensed under the BSD 3-Clause License. + +#import +#include + +#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); + } +} diff --git a/Vendor/mediaremote-adapter/src/private/MediaRemote.h b/Vendor/mediaremote-adapter/src/private/MediaRemote.h new file mode 100644 index 0000000..e23de3a --- /dev/null +++ b/Vendor/mediaremote-adapter/src/private/MediaRemote.h @@ -0,0 +1,163 @@ +// clang-format off + +#ifndef MEDIAREMOTE_PRIVATE_H_ +#define MEDIAREMOTE_PRIVATE_H_ + +#include + +#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_ */ diff --git a/Vendor/mediaremote-adapter/src/private/MediaRemote.m b/Vendor/mediaremote-adapter/src/private/MediaRemote.m new file mode 100644 index 0000000..0c66908 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/private/MediaRemote.m @@ -0,0 +1,114 @@ +// clang-format off + +#include + +#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 diff --git a/Vendor/mediaremote-adapter/src/test/NowPlayingTest.h b/Vendor/mediaremote-adapter/src/test/NowPlayingTest.h new file mode 100644 index 0000000..3292b6b --- /dev/null +++ b/Vendor/mediaremote-adapter/src/test/NowPlayingTest.h @@ -0,0 +1,50 @@ +// Copyright (c) 2025 Alexander5015 +// This file is licensed under the BSD 3-Clause License. + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol RemoteCommandCenterDelegateListener +- (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 listener; + +- (instancetype)initWithListener: + (id)listener; + +@end + +@interface NowPlayingPublishTest + : NSObject + +@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 \ No newline at end of file diff --git a/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m b/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m new file mode 100644 index 0000000..bd91a54 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/test/NowPlayingTest.m @@ -0,0 +1,231 @@ +// Copyright (c) 2025 Alexander5015 +// This file is licensed under the BSD 3-Clause License. + +#include + +#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)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 diff --git a/Vendor/mediaremote-adapter/src/test/main.m b/Vendor/mediaremote-adapter/src/test/main.m new file mode 100644 index 0000000..d71c6ed --- /dev/null +++ b/Vendor/mediaremote-adapter/src/test/main.m @@ -0,0 +1,53 @@ +// Copyright (c) 2025 Alexander5015 +// This file is licensed under the BSD 3-Clause License. + +#import + +#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; +} diff --git a/Vendor/mediaremote-adapter/src/utility/Debounce.h b/Vendor/mediaremote-adapter/src/utility/Debounce.h new file mode 100644 index 0000000..79795c8 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/utility/Debounce.h @@ -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 +#import + +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 diff --git a/Vendor/mediaremote-adapter/src/utility/Debounce.m b/Vendor/mediaremote-adapter/src/utility/Debounce.m new file mode 100644 index 0000000..31c41f9 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/utility/Debounce.m @@ -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 diff --git a/Vendor/mediaremote-adapter/src/utility/helpers.h b/Vendor/mediaremote-adapter/src/utility/helpers.h new file mode 100644 index 0000000..006a867 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/utility/helpers.h @@ -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 + +#import +#import + +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 diff --git a/Vendor/mediaremote-adapter/src/utility/helpers.m b/Vendor/mediaremote-adapter/src/utility/helpers.m new file mode 100644 index 0000000..02ee0b0 --- /dev/null +++ b/Vendor/mediaremote-adapter/src/utility/helpers.m @@ -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 +#include +#include + +#import +#if __has_include() +#import +#else +#import +#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() + 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]]; + } + } +} diff --git a/project.yml b/project.yml index f48fce8..ea62b98 100644 --- a/project.yml +++ b/project.yml @@ -49,7 +49,24 @@ targets: product: CalendarProvider - package: OnyxKit product: WeatherProvider + - package: OnyxKit + product: MediaProvider postBuildScripts: + # Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut, + # nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da + # läuft, und die Ad-hoc-Signatur passt zum restlichen Bundle. + # + # Er darf NICHT gelinkt werden. Geladen wird er von /usr/bin/perl, einem + # berechtigten Systemprogramm — im eigenen Prozess greift Apples + # Entitlement-Prüfung und es käme gar nichts an. + - name: MediaRemote-Adapter bauen und beilegen + script: | + OUT="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor" \ + "$SRCROOT/Scripts/build-mediaremote-adapter.sh" + inputFiles: + - $(SRCROOT)/Scripts/build-mediaremote-adapter.sh + outputFiles: + - $(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl # Das Menüleistensymbol soll das Glyph aus dem App-Icon zeigen, nicht die # abgerundete Kachel mit Verlauf. actool kompiliert das .icon-Bündel zu # .icns und Assets.car und legt nichts Lesbares ins Bundle — und eine