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>
155 lines
6.4 KiB
Swift
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))
|
|
}
|
|
}
|