Files
Flaro/Flixy/Session/NowPlaying.swift
T
ScarriffleandClaude Opus 5 6b927c21b0 VLC ist weg — es gibt nur noch einen Spieler
Du hattest recht mit der Erwartung, nur nicht mit dem Stand: entfernt war er
nicht. Was wir gemacht haben, war, ihm Arbeit wegzunehmen — DTS, TrueHD und
Vorbis gehen seit Kurzem über das Umpacken beim Server. Übrig blieb er an
einer Stelle, `PlaybackDecider.fallback`.

Jetzt ist er ganz weg, samt dem 920-MB-Binärpaket.

**Was an seine Stelle tritt.** Die Entscheidung sagt nicht mehr, *wer* spielt,
sondern *was ankommt*: direkt, umgepackt oder umgerechnet. `PlaybackEngineKind`
fällt damit weg, `directPlay` trägt keine Engine mehr, und `PlaybackPolicy`
kennt kein `allowsFallbackEngine`.

**Das Auffangnetz bleibt, nur anders geknüpft.** Scheiterte das Umpacken am
Player, sprang bisher VLC ein — der Server entscheidet erst beim Ausliefern,
ob er die Ströme wirklich kopieren kann, und manche Antworten nimmt AVPlayer
nicht an. Statt der zweiten Engine rechnet der Server jetzt neu
(`fallBackToTranscode`). Teurer für ihn, aber es kommt etwas an, statt einer
schwarzen Wand mit einer Fehlernummer.

**Offline wäre das ein Rückschritt gewesen** — dort gibt es keinen Server, der
einspringt. Deshalb ging der vorige Commit voraus: was heruntergeladen wird,
kommt schon in einer Hülle, die der Systemplayer öffnet.

Was dadurch überall gilt, auch für Dateien, die bisher bei VLC landeten:
Bild-in-Bild, AirPlay, Bedienung im Sperrbildschirm — und dasselbe Verhalten
auf dem Apple TV, wo es VLC ohnehin nie gab.

Die Attrappe heißt ihren Schalter jetzt `--dts` statt `--vlc`: der Fall ist
geblieben, sein Ziel nicht.

iOS und tvOS bauen, 494 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:04:23 +02:00

155 lines
6.4 KiB
Swift

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