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:
Scarriffle
2026-09-15 21:55:49 +02:00
co-authored by Claude Opus 5
parent fc812f5a25
commit 5d09836686
2 changed files with 173 additions and 0 deletions
+154
View File
@@ -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))
}
}
+19
View File
@@ -56,6 +56,15 @@ final class PlayerModel {
private(set) var isOffline = false private(set) var isOffline = false
/// Ob ein laufender Sender abgespielt wird. /// Ob ein laufender Sender abgespielt wird.
private(set) var isLive = false 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. /// Die im Player gewählte Stufe. `automatic` folgt den Einstellungen.
private(set) var quality: PlayerQuality = .automatic private(set) var quality: PlayerQuality = .automatic
/// Ob gerade auf eine andere Stufe umgestellt wird. /// Ob gerade auf eine andere Stufe umgestellt wird.
@@ -202,6 +211,10 @@ final class PlayerModel {
func start() async { func start() async {
diagnostics.begin(title: request.title, connection: connectionDescription) 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 // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein
// Datenvolumen und funktioniert auch dann, wenn der Server gerade // Datenvolumen und funktioniert auch dann, wenn der Server gerade
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens. // nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
@@ -583,6 +596,7 @@ final class PlayerModel {
isPlaying = false isPlaying = false
isBuffering = false isBuffering = false
bufferWatchdog?.cancel() bufferWatchdog?.cancel()
NowPlaying.shared.release(for: self)
} }
/// Meldet dem Server, dass Schluss ist. Darf dauern. /// Meldet dem Server, dass Schluss ist. Darf dauern.
@@ -767,6 +781,7 @@ final class PlayerModel {
isPlaying = state == .playing isPlaying = state == .playing
isBuffering = state == .buffering || state == .loading isBuffering = state == .buffering || state == .loading
watchBuffering(isBuffering) watchBuffering(isBuffering)
NowPlaying.shared.update(from: self)
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist // Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
// abzulesen, ob die Engine hängt oder die Leitung. // abzulesen, ob die Engine hängt oder die Leitung.
diagnostics.note("Zustand: \(state)") diagnostics.note("Zustand: \(state)")
@@ -783,6 +798,9 @@ final class PlayerModel {
} }
case .timeChanged(let time): case .timeChanged(let time):
currentTime = 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, // Angekommen: ab hier zeigt das Bild wieder die Stelle,
// an der man war. // an der man war.
if let target = resumeTarget, time + .seconds(2) >= target { if let target = resumeTarget, time + .seconds(2) >= target {
@@ -791,6 +809,7 @@ final class PlayerModel {
updateActiveMarker() updateActiveMarker()
case .durationResolved(let value): case .durationResolved(let value):
duration = value duration = value
NowPlaying.shared.update(from: self)
case .reachedEnd: case .reachedEnd:
isPlaying = false isPlaying = false
hasReachedEnd = true hasReachedEnd = true