diff --git a/Flixy/Session/NowPlaying.swift b/Flixy/Session/NowPlaying.swift new file mode 100644 index 0000000..7e23e76 --- /dev/null +++ b/Flixy/Session/NowPlaying.swift @@ -0,0 +1,154 @@ +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)) + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 69d140b..676926f 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -56,6 +56,15 @@ final class PlayerModel { private(set) var isOffline = false /// Ob ein laufender Sender abgespielt wird. private(set) var isLive = false + + /// Die Spulweiten, wie sie eingestellt sind. + /// + /// Nach außen sichtbar, weil die Systembedienung — Kontrollzentrum, + /// Sperrbildschirm, Medientasten — dieselben Zahlen anbieten soll wie die + /// Knöpfe im Player. Zwei Weiten, die auseinanderlaufen, sind schlimmer + /// als eine, die nicht jedem gefällt. + var rewindSeconds: Int { settings.rewindSeconds } + var forwardSeconds: Int { settings.forwardSeconds } /// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen. private(set) var quality: PlayerQuality = .automatic /// Ob gerade auf eine andere Stufe umgestellt wird. @@ -202,6 +211,10 @@ final class PlayerModel { func start() async { diagnostics.begin(title: request.title, connection: connectionDescription) + // Beim System anmelden, bevor das erste Bild kommt: ab hier gehören + // die Medientasten diesem Titel und nicht mehr dem, was vorher lief. + NowPlaying.shared.takeOver(self) + // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein // Datenvolumen und funktioniert auch dann, wenn der Server gerade // nicht erreichbar ist — der eigentliche Sinn des Herunterladens. @@ -583,6 +596,7 @@ final class PlayerModel { isPlaying = false isBuffering = false bufferWatchdog?.cancel() + NowPlaying.shared.release(for: self) } /// Meldet dem Server, dass Schluss ist. Darf dauern. @@ -767,6 +781,7 @@ final class PlayerModel { isPlaying = state == .playing isBuffering = state == .buffering || state == .loading watchBuffering(isBuffering) + NowPlaying.shared.update(from: self) // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // abzulesen, ob die Engine hängt oder die Leitung. diagnostics.note("Zustand: \(state)") @@ -783,6 +798,9 @@ final class PlayerModel { } case .timeChanged(let time): currentTime = time + // Gedrosselt in `NowPlaying`: zwischen zwei Meldungen + // zählt das System selbst weiter, es kennt das Tempo. + NowPlaying.shared.update(from: self) // Angekommen: ab hier zeigt das Bild wieder die Stelle, // an der man war. if let target = resumeTarget, time + .seconds(2) >= target { @@ -791,6 +809,7 @@ final class PlayerModel { updateActiveMarker() case .durationResolved(let value): duration = value + NowPlaying.shared.update(from: self) case .reachedEnd: isPlaying = false hasReachedEnd = true