Die Medientasten bedienen jetzt Flixy
Die App hat sich beim System nie als „läuft gerade" gemeldet — das MediaPlayer-Framework kam im ganzen Projekt nicht vor. macOS schickt die Medientasten aber genau dorthin: an das Programm, das sich gemeldet hat. Meldet sich keines, gewinnt irgendein anderes, und man pausiert etwas, das man gar nicht sieht. `NowPlaying` meldet Titel, Untertitel, Länge, Stelle und Tempo und nimmt entgegen: Abspielen, Anhalten, Umschalten, Vor- und Zurückspulen mit den **eingestellten** Weiten, und das Ziehen am Balken. Damit tun Medientasten, Kontrollzentrum, Sperrbildschirm und Kopfhörerknöpfe dasselbe wie die Knöpfe im Player — es ist eine Sache, nicht vier. Eine Anmeldung für die ganze App, nicht eine je Player: das System kennt ohnehin nur einen laufenden Titel. Abgemeldet wird nur vom Player, der auch angemeldet ist — sonst räumte einer, der spät zu Ende geht, dem längst gestarteten nächsten die Tasten weg. Die Rückrufe springen auf den Hauptakteur, statt ihn anzunehmen. Dass sie dort ankommen, ist nirgends zugesagt, und dieselbe Annahme hat die App im VLC-Engine schon einmal zum Absturz gebracht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fc812f5a25
commit
5d09836686
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user