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>
252 lines
9.7 KiB
Swift
252 lines
9.7 KiB
Swift
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) {}
|
||
}
|