import Foundation import MediaPlayer /// Meldet die laufende Wiedergabe beim System an — und nimmt von dort Befehle /// entgegen. /// /// Ohne diese Anmeldung sind die Medientasten der Tastatur wirkungslos, und /// zwar lautlos: macOS schickt sie an das Programm, das sich als „läuft /// gerade" gemeldet hat. Meldet sich keines, gewinnt irgendein anderes — /// Musik, ein Browser-Tab, was zuletzt etwas abgespielt hat. Man drückt auf /// Pause und pausiert etwas, das man gar nicht sieht. /// /// Dieselbe Anmeldung trägt die Sperrbildschirm-Bedienung, das /// Kontrollzentrum, die Knöpfe an Kopfhörern und die Auskunft in der /// Menüleiste. Es ist **eine** Sache, nicht vier. /// /// Eine Stelle für die ganze App, kein Wert je Player: das System kennt /// ohnehin nur einen laufenden Titel. Läge die Anmeldung beim Modell, würde /// ein zweiter Player dem ersten die Tasten wegnehmen und beim Beenden die /// des anderen abräumen. @MainActor final class NowPlaying { static let shared = NowPlaying() /// Wofür wir uns angemeldet haben — zum Wiederabmelden. /// /// `addTarget` gibt eine Marke zurück, und nur mit ihr lässt sich der /// eigene Eintrag wieder lösen. Ohne diese Liste sammelten sich bei jedem /// neuen Film weitere Empfänger an, und ein Tastendruck löste die /// Wiedergabe fünfmal um. private var registered: [(command: MPRemoteCommand, target: Any)] = [] fileprivate(set) weak var model: PlayerModel? /// Wann zuletzt gemeldet wurde und mit welchem Tempo. /// /// Die Stelle kommt viermal je Sekunde herein; sie jedes Mal zu melden /// wäre umsonst, weil das System zwischen zwei Meldungen selbst /// weiterzählt — es kennt ja das Tempo. Gemeldet wird deshalb nur, wenn /// sich das Tempo ändert oder seit der letzten Meldung Zeit vergangen ist. private var lastReport: (at: Date, rate: Double)? private init() {} // MARK: - An- und abmelden func takeOver(_ model: PlayerModel) { release() self.model = model let center = MPRemoteCommandCenter.shared() add(center.togglePlayPauseCommand) { $0.togglePlayPause() } add(center.playCommand) { if !$0.isPlaying { $0.togglePlayPause() } } add(center.pauseCommand) { if $0.isPlaying || $0.isBuffering { $0.togglePlayPause() } } // Die Spulweiten aus den Einstellungen, damit die Knöpfe im // Kontrollzentrum dasselbe tun wie die im Player. Zwei Zahlen, die // auseinanderlaufen, sind schlimmer als eine, die nicht gefällt. center.skipBackwardCommand.preferredIntervals = [NSNumber(value: model.rewindSeconds)] center.skipForwardCommand.preferredIntervals = [NSNumber(value: model.forwardSeconds)] add(center.skipBackwardCommand) { $0.skip(by: .seconds(-$0.rewindSeconds)) } add(center.skipForwardCommand) { $0.skip(by: .seconds($0.forwardSeconds)) } // Das Ziehen am Balken im Kontrollzentrum. Die Stelle wird vor dem // Sprung auf den Hauptakteur ausgelesen: das Ereignis selbst darf // nicht mitreisen. let position = center.changePlaybackPositionCommand let target = position.addTarget { event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } let seconds = event.positionTime Task { @MainActor in guard let model = NowPlaying.shared.model else { return } model.seek(to: .seconds(seconds)) NowPlaying.shared.update(from: model) } return .success } registered.append((position, target)) update(from: model) } /// Meldet ab — aber nur, wenn dieser Player noch der angemeldete ist. /// /// Ohne die Prüfung räumte ein Player, der spät zu Ende geht, dem längst /// gestarteten nächsten die Anmeldung weg: die Tasten wären tot, obwohl /// etwas läuft. func release(for model: PlayerModel? = nil) { if let model, self.model !== model { return } for (command, target) in registered { command.removeTarget(target) } registered = [] self.model = nil lastReport = nil MPNowPlayingInfoCenter.default().nowPlayingInfo = nil MPNowPlayingInfoCenter.default().playbackState = .stopped } // MARK: - Melden func update(from model: PlayerModel) { guard self.model === model else { return } let rate = model.isPlaying ? 1.0 : 0.0 if let last = lastReport, last.rate == rate, Date.now.timeIntervalSince(last.at) < 2 { return } lastReport = (Date.now, rate) var info: [String: Any] = [ MPMediaItemPropertyTitle: model.title, MPNowPlayingInfoPropertyPlaybackRate: rate, MPNowPlayingInfoPropertyElapsedPlaybackTime: model.currentTime.seconds, MPNowPlayingInfoPropertyIsLiveStream: model.isLive, ] if let subtitle = model.subtitle { info[MPMediaItemPropertyArtist] = subtitle } // Bei einem laufenden Sender keine Länge: es gibt kein Ende, und ein // Balken, der auf eines zuläuft, wäre eine Behauptung. if let duration = model.duration, !model.isLive { info[MPMediaItemPropertyPlaybackDuration] = duration.seconds } MPNowPlayingInfoCenter.default().nowPlayingInfo = info MPNowPlayingInfoCenter.default().playbackState = model.isPlaying ? .playing : .paused } // MARK: - Kleinkram /// Meldet sich für einen Befehl an und merkt sich die Marke dazu. /// /// Die Handlung läuft über einen Sprung auf den Hauptakteur statt über /// `assumeIsolated`: dass diese Rückrufe dort ankommen, ist nirgends /// zugesagt, und dieselbe Annahme hat die App im VLC-Engine schon einmal /// zum Absturz gebracht. Der Umweg kostet einen Durchlauf — unbemerkbar /// neben dem Tastendruck, der ihn ausgelöst hat. private func add( _ command: MPRemoteCommand, handle: @escaping @Sendable @MainActor (PlayerModel) -> Void ) { command.isEnabled = true let target = command.addTarget { [weak self] _ in Task { @MainActor [weak self] in guard let model = self?.model else { return } handle(model) self?.update(from: model) } return .success } registered.append((command, target)) } }