Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
@@ -0,0 +1,616 @@
import AVFoundation
import Foundation
import MediaKit
/// Wiedergabe über den Systemplayer.
///
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
/// nachgebaut werden müsste.
@MainActor
public final class AVPlayerEngine: VideoPlayerEngine {
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
public let player = AVPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
noteStall(from: oldValue, to: state)
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
let seconds = player.currentTime().seconds
guard seconds.isFinite, seconds >= 0 else { return .zero }
return .seconds(seconds)
}
public let supportsPictureInPicture = true
public let supportsAirPlay = true
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any?
private var metadataTask: Task<Void, Never>?
/// Wohin gesprungen werden soll, sobald der Player bereit ist.
///
/// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das
/// Fortsetzen landete dann wieder am Anfang.
private var pendingStartTime: Duration = .zero
/// Ob gerade ein Sprung an die gemerkte Stelle läuft.
private var isSeekingToStart = false
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
private var stallObserver: (any NSObjectProtocol)?
/// Die Sprossen der Playlist, aufsteigend, in Kilobit je Sekunde.
///
/// Leer, solange nichts geladen ist — und leer bleibt sie auch bei einer
/// Datei, die gar keine Playlist ist. Genau daran unterscheidet
/// `statistics` später „keine Leiter" von „eine Stufe".
private var variantBitrates: [Int] = []
/// Ob überhaupt jemand spielen wollte.
///
/// **Der Unterschied zwischen „pausiert" und „kommt nicht voran".**
/// `timeControlStatus` meldet beides als `.paused`: das Anhalten durch
/// den Nutzer und den Fall, dass `play()` gerufen wurde, der Player aber
/// nicht losläuft — weil die Quelle noch nicht bereit ist oder weil nichts
/// ankommt. Ohne diese Unterscheidung zeigte der Player ein
/// Abspielsymbol, als hätte man selbst angehalten, während in Wahrheit
/// auf Daten gewartet wurde. Wer dann tippte, drückte auf Play bei etwas,
/// das längst laufen sollte.
private var wantsToPlay = false
/// Wie oft es gestockt hat, seit diese Quelle läuft.
///
/// Aus `playbackStalledNotification` und **nicht** aus dem
/// Zugriffsprotokoll: die Meldung kommt bei jeder Quelle, das Protokoll
/// nur bei Streamsn über das Netz. Beides zu addieren zählte doppelt.
private var subtitleStyle: SubtitleStyle?
private var stallCount = 0
/// Aufsummierte Wartezeit. Siehe `noteStall(from:to:)`.
private var accumulatedStall: Duration = .zero
private var bufferingSince: ContinuousClock.Instant?
public init() {
AudioSession.activate()
// **AVPlayer wählt Untertitel sonst selbst — und schaltet sie ab.**
//
// Diese Eigenschaft steht von Haus aus auf `true`: der Player richtet
// sich dann nach den Untertiteleinstellungen des Systems. Die stehen
// in aller Regel auf „aus", und damit nimmt er jede Wahl, die wir
// treffen, wieder zurück — lautlos, und erst nachdem wir sie gesetzt
// haben.
//
// Zu sehen war das als „ich schalte Untertitel ein und sehe nichts":
// der Server lieferte sie, die Spur war im Stream, ausgewählt war sie
// auch, und angezeigt wurde trotzdem nichts.
//
// Beim Ton ändert das Abschalten nichts: welche Spur klingt, ist
// längst beim Aushandeln entschieden, und bei einem umgerechneten
// Stream kommt ohnehin nur eine an.
player.appliesMediaSelectionCriteriaAutomatically = false
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
teardownObservers()
state = .loading
// Die Buchführung gehört zur Quelle, nicht zur Engine: ein neuer Titel
// fängt bei null an, sonst schleppte die Zahl den vorigen Film mit.
variantBitrates = []
// Sie gehören zum Stream, nicht zur Engine — sonst hielte die Liste
// zwischen zwei Aufbauten die Spuren des alten.
audioTracks = []
subtitleTracks = []
isSeekingToStart = false
wantsToPlay = false
stallCount = 0
accumulatedStall = .zero
bufferingSince = nil
pendingStartTime = source.startTime
let asset = AVURLAsset(url: source.url, options: [
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
AVURLAssetPreferPreciseDurationAndTimingKey: false,
])
let item = AVPlayerItem(asset: asset)
// **Vorrat statt Reaktion.**
//
// Ohne diese Angabe legt AVPlayer so viel an, wie es für einen
// zügigen Start braucht — und keinen Meter mehr. Das ist richtig, wenn
// die Leitung trägt, und falsch, wenn sie aussetzt: ein Loch von
// fünf Sekunden trifft dann auf einen Puffer von fünf Sekunden, und
// das Bild steht.
//
// Der Preis ist ehrlich: der Start dauert länger, und wer springt,
// wirft den angelegten Vorrat weg. Beides ist weniger schlimm als ein
// Rückler mitten im Satz.
if let vorrat = source.forwardBuffer {
item.preferredForwardBufferDuration = TimeInterval(
Double(vorrat.components.seconds)
)
}
// **Auch am frischen Gegenstand.** Er beginnt ungestaltet, und ohne
// diese Zeile fiele jede weitere Folge auf die Vorgabe zurück.
if let subtitleStyle {
item.textStyleRules = subtitleStyle.textStyleRules
}
player.replaceCurrentItem(with: item)
observe(item: item)
observePlayer()
// Hier ist Schluss. Dauer und Spuren werden NICHT abgewartet.
//
// Genau daran hing der Player vorher fest: `asset.load(.duration)`
// holt so viel von der Datei, bis der Index gefunden ist. Bei MP4 und
// MKV sitzt der oft am Ende — über eine langsame Leitung dauert das
// Minuten, und in dieser Zeit stand nur ein Ladekreis da, obwohl
// AVPlayer längst hätte anfangen können.
//
// Beides kommt jetzt nach, sobald es da ist, und meldet sich über den
// Ereignisstrom. Bis dahin läuft das Bild schon.
loadMetadataInBackground(from: asset)
}
/// Holt Dauer und Spuren nach, ohne die Wiedergabe aufzuhalten.
private func loadMetadataInBackground(from asset: AVURLAsset) {
metadataTask?.cancel()
metadataTask = Task { [weak self] in
guard let self else { return }
if let assetDuration = try? await asset.load(.duration),
assetDuration.isNumeric,
!Task.isCancelled {
let value = Duration.seconds(assetDuration.seconds)
duration = value
continuation?.yield(.durationResolved(value))
}
guard !Task.isCancelled else { return }
// **Die Leiter.** Hier und nicht vorher: die Frage „wie viele
// Stufen hat diese Playlist" beantwortet der Parser des Players,
// aber sie zu beantworten kostet einen Abruf — und der darf den
// Start so wenig aufhalten wie Dauer und Spuren.
//
// Bei einer Datei kommt eine leere Liste zurück. Das ist keine
// Leiter mit null Sprossen, sondern gar keine Leiter, und genau
// so wird es unten auch gemeldet.
if let variants = try? await asset.load(.variants), !Task.isCancelled {
variantBitrates = variants
.compactMap(\.peakBitRate)
.map { Int(($0 / 1_000).rounded()) }
.filter { $0 > 0 }
.sorted()
}
guard !Task.isCancelled else { return }
// Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem
// laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein
// besserer Zustand als ein Abbruch.
try? await loadTracks(from: asset)
}
}
public func play() {
wantsToPlay = true
player.play()
}
public func pause() {
wantsToPlay = false
player.pause()
}
public func seek(to time: Duration) async {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
// Sekunden daneben.
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
continuation?.yield(.timeChanged(time))
}
public func setPeakBitrate(_ kilobits: Int?) {
guard let item = player.currentItem else { return }
// Null heißt bei AVFoundation „keine Grenze". Kein Sonderfall, nur
// eine andere Schreibweise für dasselbe.
item.preferredPeakBitRate = kilobits.map { Double($0) * 1_000 } ?? 0
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, in: .audible)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, in: .legible)
}
/// Gestaltet die Untertitel — sofort, und auch beim nächsten Film.
///
/// **Beides ist nötig.** Ein frisches `AVPlayerItem` beginnt ungestaltet;
/// wer nur das laufende anfasst, sieht die nächste Folge wieder in der
/// Vorgabe. Deshalb wird der Wunsch gemerkt und in `load(_:)` erneut
/// angelegt.
public func applySubtitleStyle(_ style: SubtitleStyle) {
subtitleStyle = style
guard let item = player.currentItem else { return }
item.textStyleRules = style.textStyleRules
// **Und die Spur neu wählen, sonst bleibt es bei der alten Form.**
//
// `textStyleRules` wird nur gelesen, wenn die Untertitelspur
// ausgewählt wird — eine laufende Einblendung zeichnet AVFoundation
// davon nicht neu. Das war genau der Eindruck: man stellt um, es
// passiert nichts, und erst wer die Untertitel aus- und wieder
// einschaltet, sieht die Änderung. Das Aus- und Einschalten ist die
// Neuwahl — also machen wir sie selbst.
//
// Abgewählt und wieder angewählt wird dieselbe Option, nicht die
// erste: bei mehreren Spuren wäre das ein stiller Sprachwechsel.
guard let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: .legible),
let gewählt = item.currentMediaSelection.selectedMediaOption(in: group)
else { return }
item.select(nil, in: group)
item.select(gewählt, in: group)
}
/// Was gerade über die Leitung kommt.
///
/// Aus dem Zugriffsprotokoll des Players: es führt Buch über jede
/// gezogene Stufe. Der **letzte** Eintrag ist der aktuelle; die früheren
/// beschreiben, was vorher lief.
public var statistics: PlaybackStatistics? {
guard let item = player.currentItem else { return nil }
let events = item.accessLog()?.events ?? []
let event = events.last
func kilobits(_ value: Double?) -> Int? {
guard let value, value > 0 else { return nil }
return Int((value / 1_000).rounded())
}
let size = item.presentationSize
return PlaybackStatistics(
observedBitrate: kilobits(event?.observedBitrate),
indicatedBitrate: kilobits(event?.indicatedBitrate),
width: size.width > 0 ? Int(size.width) : nil,
height: size.height > 0 ? Int(size.height) : nil,
// **Über alle Einträge summiert, nicht nur der letzte.** Das
// Protokoll legt bei jedem Stufenwechsel einen neuen an, und
// dessen Zähler beginnt wieder bei null. Wer nur `last` liest,
// verliert nach jedem Wechsel die ganze Vorgeschichte — und sieht
// eine Wiedergabe, die schon zwanzigmal Bilder verworfen hat, als
// makellos an.
droppedFrames: events.isEmpty
? nil
: events.reduce(0) { $0 + max(0, $1.numberOfDroppedVideoFrames) },
variantCount: variantBitrates.isEmpty ? nil : variantBitrates.count,
variantBitrates: variantBitrates,
bufferedAhead: bufferedAhead(in: item),
isLikelyToKeepUp: item.isPlaybackLikelyToKeepUp,
stallCount: stallCount,
stalledTime: stalledTime
)
}
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// Gibt `nil` zurück, wenn noch gar nichts geladen ist — das ist etwas
/// anderes als ein leerer Puffer und darf nicht als Alarm durchgehen.
/// Liegen dagegen Bereiche vor, in denen die Abspielstelle **nicht**
/// liegt, ist der Puffer an genau dieser Stelle leer, und das ist eine
/// Aussage: null.
private func bufferedAhead(in item: AVPlayerItem) -> Duration? {
let ranges = item.loadedTimeRanges.map(\.timeRangeValue)
guard !ranges.isEmpty else { return nil }
let now = item.currentTime()
guard now.isNumeric else { return nil }
for range in ranges where range.containsTime(now) {
let end = range.end
guard end.isNumeric else { continue }
return .seconds(max(0, (end - now).seconds))
}
return .zero
}
/// Die Wartezeit einschließlich der gerade laufenden.
private var stalledTime: Duration {
guard let bufferingSince else { return accumulatedStall }
return accumulatedStall + (ContinuousClock.now - bufferingSince)
}
/// Buchführung über das Stocken.
///
/// Gezählt wird **nur** der Weg vom Spielen ins Warten. Das anfängliche
/// Puffern einer frisch geladenen Quelle ist kein Rückler — es ist der
/// Start. Würde es mitgezählt, hätte jede Wiedergabe von vornherein eine
/// Vorstrafe, und eine Regel wie „zwei Rückler in einer Minute" löste
/// schon beim Anschauen des ersten Bildes aus.
private func noteStall(from previous: PlaybackState, to current: PlaybackState) {
if current == .buffering {
if previous == .playing { bufferingSince = .now }
} else if let since = bufferingSince {
accumulatedStall += ContinuousClock.now - since
bufferingSince = nil
}
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
continuation?.finish()
continuation = nil
state = .idle
AudioSession.deactivate()
}
// MARK: - Spurwahl
private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) {
guard let item = player.currentItem,
let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic)
else { return }
guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else {
// `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton
// sollte es nicht vorkommen.
item.select(nil, in: group)
return
}
item.select(group.options[index], in: group)
}
private func loadTracks(from asset: AVURLAsset) async throws {
func tracks(
for characteristic: AVMediaCharacteristic,
kind: MediaTrack.Kind
) async throws -> [MediaTrack] {
guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] }
return group.options.enumerated().map { index, option in
MediaTrack(
id: "av-\(kind)-\(index)",
kind: kind,
displayName: option.displayName,
languageCode: option.extendedLanguageTag,
engineIdentifier: index,
isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)
)
}
}
audioTracks = try await tracks(for: .audible, kind: .audio)
subtitleTracks = try await tracks(for: .legible, kind: .subtitle)
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
// MARK: - Beobachtung
private func observePlayer() {
observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
// Kein `assumeIsolated`: KVO meldet auf dem Thread, der die
// Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand
// auch aus internen Threads. Dieselbe Annahme hat die App
// an anderer Stelle schon einmal hart abbrechen lassen.
Task { @MainActor in
guard let self else { return }
switch player.timeControlStatus {
case .playing: self.state = .playing
case .paused:
guard self.state != .ended else { break }
// Stillstand mit Absicht ist Pause. Stillstand ohne
// Absicht ist Warten — und gehört als solches
// gemeldet, nicht als Pause.
self.state = self.wantsToPlay ? .buffering : .paused
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
@unknown default: break
}
}
}
)
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] time in
// Hier ist `assumeIsolated` belegt statt geraten: die Warteschlange
// steht eine Zeile darüber. Viermal je Sekunde eine Aufgabe
// einzureihen wäre unnötiger Aufwand.
MainActor.assumeIsolated {
guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
}
}
}
private func observe(item: AVPlayerItem) {
observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in
// Siehe oben: auch dieser Rückruf kommt nicht verlässlich vom
// Hauptthread.
Task { @MainActor in
guard let self else { return }
switch item.status {
case .failed:
self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) }
))
case .readyToPlay:
self.applyPendingStartTime()
case .unknown:
break
@unknown default:
break
}
}
}
)
// **Ein zweiter Anlass für den Sprung.**
//
// Bei einer Abspielliste wächst der springbare Bereich, während der
// Server produziert. Ein Sprung, der bei „bereit" noch ins Leere
// ging, kann Sekunden später gelingen — und ohne diesen Beobachter
// gäbe es niemanden, der es noch einmal versucht. Die Stelle bleibt
// gemerkt, bis ein Sprung bestätigt ist; danach tut der Beobachter
// nichts mehr.
observations.append(
item.observe(\.seekableTimeRanges, options: [.new]) { [weak self] _, _ in
Task { @MainActor in self?.applyPendingStartTime() }
}
)
// Der ausdrückliche Stocker-Melder des Systems.
//
// Er kommt **nur**, wenn die Wiedergabe schon lief und dann stehen
// blieb — beim anfänglichen Puffern schweigt er. Damit ist er der
// ehrlichste Zähler, den es gibt, und der Massstab, an dem sich am
// Ende ablesen lässt, ob es besser wurde.
stallObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.playbackStalledNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.stallCount += 1 }
}
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
// Ebenfalls ausdrücklich auf der Hauptwarteschlange.
MainActor.assumeIsolated {
guard let self else { return }
self.state = .ended
self.continuation?.yield(.reachedEnd)
}
}
}
/// Springt an die gemerkte Stelle, sobald der Player bereit ist.
///
/// **Und merkt sich, ob es geklappt hat.** Vorher wurde die Stelle
/// verbraucht, *bevor* gesprungen wurde, und der Rückgabewert von
/// `seek` verworfen. Misslang der Sprung — und bei einem gerade erst
/// entstehenden Transcode ist das der Normalfall, nicht die Ausnahme —,
/// merkte es niemand und niemand wiederholte ihn. Das Fortsetzen hängt an
/// genau diesem einen Sprung, weil der Server bewusst bei null beginnt.
///
/// Jetzt bleibt die Stelle stehen, bis ein Sprung bestätigt ist, und
/// jede Änderung am springbaren Bereich ist ein neuer Anlass, es zu
/// versuchen.
private func applyPendingStartTime() {
guard pendingStartTime > .zero, !isSeekingToStart else { return }
let target = pendingStartTime
isSeekingToStart = true
Task { [weak self] in
let geschafft = await self?.seekReportingSuccess(to: target) ?? false
guard let self else { return }
isSeekingToStart = false
if geschafft { pendingStartTime = .zero }
}
}
/// Springt und sagt, ob es getragen hat.
///
/// Die Toleranz ist bewusst grosszügig: framegenau in einen Stream zu
/// springen, den der Server gerade erst erzeugt, ist der teuerste und
/// fragilste Fall. Eine halbe Sekunde daneben merkt niemand, ein
/// misslungener Sprung dagegen kostet den ganzen Fortschritt.
private func seekReportingSuccess(to time: Duration) async -> Bool {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
let geschafft = await player.seek(
to: target,
toleranceBefore: CMTime(seconds: 0.5, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.5, preferredTimescale: 600)
)
if geschafft { continuation?.yield(.timeChanged(time)) }
return geschafft
}
private func teardownObservers() {
metadataTask?.cancel()
metadataTask = nil
observations.forEach { $0.invalidate() }
observations.removeAll()
if let timeObserver {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
if let stallObserver {
NotificationCenter.default.removeObserver(stallObserver)
self.stallObserver = nil
}
}
}
/// Die Tonsitzung des Systems.
///
/// Auf iOS **und tvOS**. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
///
/// **tvOS stand hier lange nicht**, und das war ein stiller Fehler: die
/// Kategorie wäre dort verfügbar (`AVAudioSessionCategoryPlayback` ist
/// `tvos(9.0)`), wurde aber nie gesetzt. Man hätte es nicht am Übersetzer
/// gemerkt, sondern erst daran, dass sich die Wiedergabe gegen anderen Ton
/// nicht durchsetzt.
enum AudioSession {
static func activate() {
#if os(iOS) || os(tvOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
static func deactivate() {
#if os(iOS) || os(tvOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Eine wählbare Ton- oder Untertitelspur.
///
/// Bewusst getrennt von den Spuren, die der Server meldet: der Player
/// nummeriert selbst, und seine Nummern stimmen mit denen des Servers nicht
/// überein. `engineIdentifier` hält fest, wie die Engine die Spur nennt,
/// `serverStreamID`, wie der Server sie nennt.
public struct MediaTrack: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case audio, subtitle }
public let id: String
public let kind: Kind
public let displayName: String
public let languageCode: String?
public let serverStreamID: Int?
/// Die Nummer, unter der der laufende Engine die Spur führt.
public let engineIdentifier: Int?
public let isForced: Bool
public init(
id: String,
kind: Kind,
displayName: String,
languageCode: String? = nil,
serverStreamID: Int? = nil,
engineIdentifier: Int? = nil,
isForced: Bool = false
) {
self.id = id
self.kind = kind
self.displayName = displayName
self.languageCode = languageCode
self.serverStreamID = serverStreamID
self.engineIdentifier = engineIdentifier
self.isForced = isForced
}
}
@@ -0,0 +1,46 @@
import Foundation
/// Der Zustand der Wiedergabe, unabhängig vom Engine.
public enum PlaybackState: Sendable, Equatable {
case idle
/// Quelle wird geöffnet, es ist noch nichts zu sehen.
case loading
case playing
case paused
/// Läuft eigentlich, wartet aber auf Daten.
case buffering
case ended
case failed(PlaybackFailure)
public var isActive: Bool {
switch self {
case .playing, .buffering: true
case .idle, .loading, .paused, .ended, .failed: false
}
}
}
/// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des
/// jeweiligen Engine.
public struct PlaybackFailure: Sendable, Equatable, Error {
public enum Kind: Sendable, Equatable {
/// Die Quelle war nicht erreichbar.
case sourceUnavailable
/// Der Engine kann das Format nicht — sollte nach einer korrekten
/// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf,
/// dass die Fähigkeitenliste nachgezogen werden muss.
case formatUnsupported
case networkInterrupted
case unknown
}
public let kind: Kind
/// Der ursprüngliche Fehlertext. Wandert nicht in die Oberfläche, aber in
/// die Protokolle — sonst ist „geht nicht" alles, was man hat.
public let underlyingDescription: String?
public init(kind: Kind, underlyingDescription: String? = nil) {
self.kind = kind
self.underlyingDescription = underlyingDescription
}
}
@@ -0,0 +1,45 @@
import AVFoundation
import CoreMedia
import Foundation
public extension SubtitleStyle {
/// Die Regeln, wie AVFoundation sie erwartet.
///
/// **Vier Fallen stecken hier drin, und jede ist lautlos:**
///
/// 1. **Die Farbe ist ein Feld aus vier Zahlen von 0 bis 1, Alpha
/// zuerst** — A, R, G, B. Kein `CGColor`, kein `UIColor`; ein solcher
/// wird ohne Fehlermeldung ignoriert.
/// 2. **Die Grösse ist ein Prozentwert**, hundert heisst unverändert.
/// `BaseFontSizePercentageRelativeToVideoHeight` **nicht** zusätzlich
/// setzen — die beiden beziehen sich auf Verschiedenes und streiten
/// sich.
/// 3. **Den Hintergrund immer setzen, auch für „durchsichtig".** Ihn
/// wegzulassen ist nicht dasselbe wie Alpha null: dann gilt, was der
/// Untertitel selbst mitbringt, und Jellyfins WebVTT bringt oft einen
/// schwarzen Kasten mit.
/// 4. **`AVTextStyleRule(textMarkupAttributes:)` kann fehlschlagen.** Ein
/// falscher Schlüssel ergibt `nil` — und damit gar keine Regel, ohne
/// dass irgendwo etwas auffiele. Genau dafür gibt es den Test.
var textStyleRules: [AVTextStyleRule] {
var attribute: [String: Any] = [
kCMTextMarkupAttribute_ForegroundColorARGB as String: foreground.argb,
kCMTextMarkupAttribute_BackgroundColorARGB as String: background.argb,
kCMTextMarkupAttribute_RelativeFontSize as String: Double(size.rawValue),
]
if wantsEdgeShadow {
attribute[kCMTextMarkupAttribute_CharacterEdgeStyle as String] =
kCMTextMarkupCharacterEdgeStyle_DropShadow as String
}
return [AVTextStyleRule(textMarkupAttributes: attribute)].compactMap(\.self)
}
}
extension SubtitleStyle.RGBA {
/// Alpha zuerst — die Reihenfolge, die CoreMedia verlangt.
var argb: [NSNumber] {
[alpha, red, green, blue].map(NSNumber.init(value:))
}
}
@@ -0,0 +1,97 @@
import Foundation
/// Wie Untertitel aussehen sollen.
///
/// **Warum hier und nicht im App-Ziel.** Weil es prüfbar sein muss: die
/// Übersetzung nach AVFoundation ist eine Tabelle aus Schlüsseln, bei der ein
/// Tippfehler lautlos dazu führt, dass **gar nichts** gesetzt wird. Ein Test
/// findet das, ein Blick auf den Bildschirm nicht — dort sieht „keine Regel"
/// genauso aus wie „Regel ohne Wirkung".
public struct SubtitleStyle: Sendable, Equatable {
/// Eine Farbe, wie CoreMedia sie versteht: vier Anteile von 0 bis 1.
public struct RGBA: Sendable, Equatable {
public let red: Double
public let green: Double
public let blue: Double
public let alpha: Double
public init(red: Double, green: Double, blue: Double, alpha: Double = 1) {
self.red = red
self.green = green
self.blue = blue
self.alpha = alpha
}
static let white = RGBA(red: 1, green: 1, blue: 1)
static let black = RGBA(red: 0, green: 0, blue: 0)
static let yellow = RGBA(red: 1, green: 0.85, blue: 0.2)
static let clear = RGBA(red: 0, green: 0, blue: 0, alpha: 0)
/// Nicht ganz deckend: ein voll schwarzer Kasten wirkt wie ein Loch
/// im Bild.
static let box = RGBA(red: 0, green: 0, blue: 0, alpha: 0.75)
}
/// Die Grösse, **relativ** zur Vorgabe des Players.
///
/// Ein Prozentwert, keine Punktgrösse: hundert heisst unverändert. So
/// bleibt die Einstellung richtig, egal wie gross das Bild ist.
public enum Size: Int, CaseIterable, Identifiable, Sendable, Codable {
case small = 75
case normal = 100
case large = 130
case huge = 175
public var id: Int { rawValue }
}
/// Schrift und Kasten als **Paar**, nicht als zwei Regler.
///
/// Unabhängige Achsen liessen unlesbare Mischungen zu — Schwarz auf
/// durchsichtig über einer Nachtszene ist eine gültige Einstellung und
/// eine wertlose. Diese fünf decken ab, was man tatsächlich will.
public enum Palette: String, CaseIterable, Identifiable, Sendable, Codable {
case whiteOnClear
case whiteOnBlack
case blackOnWhite
case yellowOnClear
case yellowOnBlack
public var id: String { rawValue }
}
public var size: Size
public var palette: Palette
public init(size: Size = .normal, palette: Palette = .whiteOnClear) {
self.size = size
self.palette = palette
}
public var foreground: RGBA {
switch palette {
case .whiteOnClear, .whiteOnBlack: .white
case .blackOnWhite: .black
case .yellowOnClear, .yellowOnBlack: .yellow
}
}
public var background: RGBA {
switch palette {
case .whiteOnClear, .yellowOnClear: .clear
case .whiteOnBlack, .yellowOnBlack: .box
case .blackOnWhite: RGBA(red: 1, green: 1, blue: 1, alpha: 0.85)
}
}
/// Ob ein Schlagschatten nötig ist.
///
/// Ohne Kasten steht die Schrift unmittelbar im Bild, und Weiss auf einer
/// hellen Szene ist dort unlesbar. Mit Kasten wäre der Schatten nur
/// Unruhe.
public var wantsEdgeShadow: Bool {
switch palette {
case .whiteOnClear, .yellowOnClear: true
case .whiteOnBlack, .blackOnWhite, .yellowOnBlack: false
}
}
}
@@ -0,0 +1,251 @@
import Foundation
/// Woher das Bild kommt.
public struct PlaybackSource: Sendable, Equatable {
public enum Origin: Sendable, Equatable {
/// Vom Server — per DirectPlay oder als HLS-Stream des Transcoders.
case remote
/// Aus dem Download-Ordner.
case local
}
public let url: URL
public let origin: Origin
/// Ab wo gestartet werden soll. Kommt aus dem zuletzt gemeldeten
/// Fortschritt; `.zero` heißt von vorn.
public let startTime: Duration
/// Wie viel Vorrat der Player anlegen soll.
///
/// `nil` überlässt es dem System. Das ist für eine Datei auf der Platte
/// richtig und für einen Sender richtig — bei einem Stream über eine
/// wackelige Leitung ist es zu wenig: der Systemwert ist auf schnellen
/// Start ausgelegt, nicht darauf, einen Aussetzer zu überstehen.
///
/// **Wer das entscheidet, muss mehr wissen als die Engine.** Ob es ein
/// Sender ist, ob die Datei lokal liegt — das weiß der Player, nicht die
/// Engine. Deshalb reist die Zahl mit der Quelle, statt dass die Engine
/// rät.
public let forwardBuffer: Duration?
public init(
url: URL,
origin: Origin,
startTime: Duration = .zero,
forwardBuffer: Duration? = nil
) {
self.url = url
self.origin = origin
self.startTime = startTime
self.forwardBuffer = forwardBuffer
}
}
/// Was eine Engine von sich aus meldet.
///
/// Ein Stream statt beobachtbarer Eigenschaften: der Player meldet über
/// KVO, und das in eine Ereignisfolge zu übersetzen hält den Unterschied an
/// einer Stelle — statt in jeder View, die den Zustand anzeigt.
public enum PlaybackEvent: Sendable, Equatable {
case stateChanged(PlaybackState)
case timeChanged(Duration)
case durationResolved(Duration)
case tracksResolved(audio: [MediaTrack], subtitle: [MediaTrack])
case reachedEnd
}
/// Wo das Bild aufhört.
///
/// **Hier und nicht in der App**, weil es beide Engines angeht. Am Bild
/// einzupassen ist nur zur Hälfte Sache der Ansicht: sie bestimmt die Fläche,
/// die Engine bestimmt, wie das Bild darin liegt.
public enum VideoFit: Sendable {
/// Ganz sichtbar, mit schwarzen Balken.
case fit
/// Bildfüllend, oben und unten beschnitten.
case fill
}
/// Was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
///
/// **Ein Protokoll für einen Spieler.** Es gab einmal zwei, und der Schnitt
/// ist geblieben: er hält die Oberfläche von AVFoundation fern, und was hier
/// nicht steht, gibt es für die Views nicht.
@MainActor
public protocol VideoPlayerEngine: AnyObject {
var events: AsyncStream<PlaybackEvent> { get }
var state: PlaybackState { get }
var currentTime: Duration { get }
var duration: Duration? { get }
/// Ob dieser Engine Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
/// schlimmer als keiner.
var supportsPictureInPicture: Bool { get }
/// Ob dieser Engine sein Bild an ein AirPlay-Gerät geben kann.
///
/// Ein Knopf dafür darf nur stehen, wo er auch etwas trifft.
var supportsAirPlay: Bool { get }
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
func play()
func pause()
func seek(to time: Duration) async
func setRate(_ rate: Double)
var audioTracks: [MediaTrack] { get }
var subtitleTracks: [MediaTrack] { get }
func selectAudioTrack(_ track: MediaTrack?)
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?)
/// Wie Untertitel dargestellt werden sollen.
///
/// **Darf jederzeit gesetzt werden, auch mitten im Film.** Genau deshalb
/// steht es hier und nicht an `PlaybackSource`: die wird einmal beim
/// Öffnen gelesen, und eine Einstellung, die erst beim nächsten Film
/// wirkt, hält jeder für kaputt.
func applySubtitleStyle(_ style: SubtitleStyle)
/// Was gerade tatsächlich über die Leitung kommt.
///
/// Nicht, was angefordert wurde: bei einem HLS-Stream entscheidet der
/// Player selbst, welche Stufe er zieht, und die kann von der gewählten
/// Obergrenze abweichen. Ohne diese Angabe bleibt „ruckelt es, weil die
/// Leitung schlecht ist?" eine Vermutung.
var statistics: PlaybackStatistics? { get }
/// Deckelt, welche Sprosse der Player höchstens ziehen darf.
///
/// **Der billige Hebel.** Er wirkt ohne Neuaufbau: kein Anhalten am
/// Server, keine neue Aushandlung, kein Sprung zurück an die alte Stelle.
/// `nil` hebt die Grenze auf.
///
/// Zwei Einschränkungen gehören dazugesagt. Der Wert greift erst an der
/// nächsten Schnipselgrenze, also mit einigen Sekunden Verzug — ein Grund
/// mehr, nicht bei jedem Ausschlag daran zu drehen. Und **bei einer
/// Playlist mit einer einzigen Stufe tut er nichts**: der Player spielt
/// sie auch dann, wenn sie über der Grenze liegt. Der Wert ist ein
/// Wunsch, keine Vorschrift.
func setPeakBitrate(_ kilobits: Int?)
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen.
func teardown()
}
/// Was über die Leitung kommt, während gespielt wird.
public struct PlaybackStatistics: Sendable, Equatable {
/// Was tatsächlich ankommt, in Kilobit je Sekunde.
///
/// **Nachlaufend und leicht misszuverstehen.** Spielt der Player
/// zufrieden eine kleine Sprosse, holt er wenig — und dieser Wert sieht
/// schlecht aus, ohne dass die Leitung schlecht wäre. Als alleiniges
/// Kriterium für eine Regelung taugt er deshalb nicht.
public let observedBitrate: Int?
/// Was der Stream für sich angibt — also auf welcher Sprosse man steht.
public let indicatedBitrate: Int?
public let width: Int?
public let height: Int?
/// Verworfene Bilder — der ehrlichste Hinweis auf Überlastung.
public let droppedFrames: Int?
/// Wie viele Stufen die Playlist überhaupt anbietet.
///
/// **Die wichtigste Zahl hier**, und die einzige, die die Frage
/// beantwortet, ob es etwas zum Umschalten gibt. Sie kommt vom Parser des
/// Players, nicht aus einer Vermutung über den Server: `1` heißt, dass
/// alles Nachregeln ins Leere greift, ganz gleich was bestellt wurde.
///
/// `nil` bei allem, was keine Playlist ist — eine Datei hat keine Leiter,
/// und das ist die richtige Aussage, nicht „null Stufen".
public let variantCount: Int?
/// Die Sprossen selbst, aufsteigend, in Kilobit je Sekunde.
///
/// Nötig, um eine Stufe tiefer zu **zielen**: ein Wert zwischen zwei
/// Sprossen wählt dieselbe wie die untere und macht jede Anzeige darüber
/// zur Lüge.
public let variantBitrates: [Int]
/// Wie weit der Puffer vor der Abspielstelle reicht.
///
/// **Der einzige echte Frühwarner.** Schrumpft er schneller, als die Zeit
/// vergeht, kommt weniger herein als hinausgeht — und das ist zehn bis
/// zwanzig Sekunden vor dem Rückler zu sehen. Alles andere meldet sich
/// erst, wenn das Bild schon steht.
public let bufferedAhead: Duration?
/// Apples eigene Einschätzung. Früh, aber grob.
public let isLikelyToKeepUp: Bool?
/// Wie oft es seit dem Start gestockt hat.
public let stallCount: Int
/// Wie lange insgesamt gewartet wurde.
public let stalledTime: Duration
public init(
observedBitrate: Int? = nil,
indicatedBitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
droppedFrames: Int? = nil,
variantCount: Int? = nil,
variantBitrates: [Int] = [],
bufferedAhead: Duration? = nil,
isLikelyToKeepUp: Bool? = nil,
stallCount: Int = 0,
stalledTime: Duration = .zero
) {
self.observedBitrate = observedBitrate
self.indicatedBitrate = indicatedBitrate
self.width = width
self.height = height
self.droppedFrames = droppedFrames
self.variantCount = variantCount
self.variantBitrates = variantBitrates
self.bufferedAhead = bufferedAhead
self.isLikelyToKeepUp = isLikelyToKeepUp
self.stallCount = stallCount
self.stalledTime = stalledTime
}
public var resolution: String? {
guard let width, let height, width > 0, height > 0 else { return nil }
return "\(width)×\(height)"
}
/// Ob es überhaupt etwas zum Umschalten gibt.
///
/// Bei `false` ist `preferredPeakBitRate` wirkungslos — AVPlayer spielt
/// die eine Stufe auch dann, wenn sie über der Grenze liegt. Genau dafür
/// gibt es die Sicherheitsleine.
public var hasLadder: Bool { (variantCount ?? 0) > 1 }
/// Die nächste Sprosse unterhalb der angegebenen — oder die unterste.
///
/// Gibt `nil` zurück, wenn es nichts Tieferes gibt. Dann ist der billige
/// Weg erschöpft.
public func rung(below bitrate: Int) -> Int? {
variantBitrates.last { $0 < bitrate }
}
}
extension VideoPlayerEngine {
/// Engines, die nichts melden, melden nichts — statt jeden zu zwingen.
public var statistics: PlaybackStatistics? { nil }
/// Wer keine Leiter hat, kann auch nichts deckeln. Kein Fehler, nur
/// nichts zu tun — bei einer Datei gibt es keine Stufen.
public func setPeakBitrate(_ kilobits: Int?) {}
/// Wer Untertitel nicht gestalten kann, lässt sie, wie sie sind.
///
public func applySubtitleStyle(_ style: SubtitleStyle) {}
/// Wer nichts dazu sagt, kann es nicht.
public var supportsAirPlay: Bool { false }
/// Der Systemplayer regelt es über seine Bildebene, und die hält die App —
/// siehe `PlayerModel.applyVideoFit()`. Hier ist nichts zu tun.
public func setVideoFit(_ fit: VideoFit) {}
}