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() } }