Phase 4: Medien-Widget mit dreistufiger Kaskade
Adapter für alles inklusive Browser, AppleScript für Spotify und Musik, Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige — aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann bedienbar, wenn gar keine Metadaten da sind. Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die Anzeige nach dem Ende weiter steigende Zahlen. Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen. In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true zu 1 und die Wiedergabe gilt für immer als angehalten. Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte Ereignis korrigiert den Stand ohnehin. Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von /usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung. 124 Tests grün.
This commit is contained in:
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
80
Packages/OnyxKit/Sources/MediaProvider/AdapterPayload.swift
Normal file
@@ -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 } }
|
||||
}
|
||||
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
166
Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift
Normal file
@@ -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..<newline]
|
||||
buffer.removeSubrange(buffer.startIndex...newline)
|
||||
|
||||
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
|
||||
restartAttempts = 0
|
||||
|
||||
if let updated = AdapterPayload.apply(line: line, to: current) {
|
||||
current = updated
|
||||
onUpdate?(updated)
|
||||
} else if line.contains("\"payload\":{}") {
|
||||
current = nil
|
||||
onUpdate?(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Steuerung
|
||||
|
||||
public func send(_ command: MediaCommand) {
|
||||
switch command {
|
||||
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
|
||||
case .next: run(["send", String(Command.next.rawValue)])
|
||||
case .previous: run(["send", String(Command.previous.rawValue)])
|
||||
case .seek(let position):
|
||||
// Der Adapter erwartet Mikrosekunden.
|
||||
run(["seek", String(Int(position * 1_000_000))])
|
||||
}
|
||||
}
|
||||
|
||||
/// Befehle laufen als eigener, kurzlebiger Prozess.
|
||||
///
|
||||
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
|
||||
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
|
||||
private func run(_ arguments: [String]) {
|
||||
guard let resources = Self.resources else { return }
|
||||
let process = Process()
|
||||
process.executableURL = perl
|
||||
process.arguments = [resources.script.path, resources.framework.path] + arguments
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
try? process.run()
|
||||
}
|
||||
}
|
||||
25
Packages/OnyxKit/Sources/MediaProvider/Localizable.xcstrings
Normal file
25
Packages/OnyxKit/Sources/MediaProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"widget.media.name" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Medien" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Media" } }
|
||||
}
|
||||
},
|
||||
"widget.media.nothing" : {
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts läuft gerade" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing playing" } }
|
||||
}
|
||||
},
|
||||
"widget.media.viaAppleScript" : {
|
||||
"comment" : "Hinweis, dass die schwächere Quelle greift",
|
||||
"localizations" : {
|
||||
"de" : { "stringUnit" : { "state" : "translated", "value" : "Über AppleScript — ohne Cover, nur Spotify und Musik" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Via AppleScript — no artwork, Spotify and Music only" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
138
Packages/OnyxKit/Sources/MediaProvider/MediaFallbacks.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
|
||||
|
||||
/// Zweite Stufe: AppleScript für Spotify und Musik.
|
||||
///
|
||||
/// Deckt weit weniger ab als der Adapter — Browser und alle anderen Programme
|
||||
/// fallen heraus. Dafür ist es offiziell unterstützt und überlebt
|
||||
/// macOS-Aktualisierungen, die den Adapter brechen könnten.
|
||||
public enum AppleScriptSource {
|
||||
|
||||
private struct Player {
|
||||
let bundleID: String
|
||||
let name: String
|
||||
}
|
||||
|
||||
private static let players = [
|
||||
Player(bundleID: "com.spotify.client", name: "Spotify"),
|
||||
Player(bundleID: "com.apple.Music", name: "Music"),
|
||||
]
|
||||
|
||||
/// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen —
|
||||
/// ein AppleScript an ein beendetes Programm würde es sonst **starten**.
|
||||
public static func poll() -> 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)
|
||||
}
|
||||
}
|
||||
129
Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift
Normal file
129
Packages/OnyxKit/Sources/MediaProvider/MediaModel.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
198
Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
83
Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift
Normal file
83
Packages/OnyxKit/Sources/MediaProvider/NowPlaying.swift
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user