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>
209 lines
7.8 KiB
Swift
209 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
/// Eine im Player wählbare Qualitätsstufe.
|
|
///
|
|
/// Getrennt von den Einstellungen unter „Qualität": dort steht, was
|
|
/// üblicherweise gelten soll; hier greift man für diesen einen Film ein — weil
|
|
/// es gerade ruckelt oder weil man ausnahmsweise das Original will.
|
|
public enum PlayerQuality: Hashable, Sendable, Identifiable {
|
|
/// Was in den Einstellungen für die aktuelle Verbindung hinterlegt ist.
|
|
case automatic
|
|
/// Die Datei unverändert, ohne Umrechnung.
|
|
case original
|
|
/// Eine Stufe der Leiter, die der Server anbietet.
|
|
case limited(Rung)
|
|
/// So wenig Bild wie möglich.
|
|
case audioOnly
|
|
|
|
/// Eine Sprosse: Bitrate und die Auflösung, die Plex dafür vorsieht.
|
|
///
|
|
/// Die Werte stammen nicht aus der Luft, sondern aus dem, was der Server
|
|
/// über sich selbst sagt (`transcoderVideoBitrates`). Eine eigene Leiter
|
|
/// zu erfinden hieße, Stufen anzubieten, die der Transcoder gar nicht
|
|
/// kennt — er rundet dann still auf die nächste, und die Anzeige log.
|
|
public struct Rung: Hashable, Sendable {
|
|
public let bitrate: Int
|
|
public let height: Int
|
|
|
|
public init(bitrate: Int, height: Int) {
|
|
self.bitrate = bitrate
|
|
self.height = height
|
|
}
|
|
|
|
public var width: Int {
|
|
// 16:9, auf gerade Zahlen gerundet — ungerade Kantenlängen mag
|
|
// kein Kodierer.
|
|
let raw = Int((Double(height) * 16.0 / 9.0).rounded())
|
|
return raw % 2 == 0 ? raw : raw + 1
|
|
}
|
|
|
|
public var resolution: String { "\(width)x\(height)" }
|
|
}
|
|
|
|
/// Die Leiter von oben nach unten.
|
|
public static let ladder: [Rung] = [
|
|
Rung(bitrate: 20_000, height: 1080),
|
|
Rung(bitrate: 12_000, height: 1080),
|
|
Rung(bitrate: 10_000, height: 1080),
|
|
Rung(bitrate: 8_000, height: 1080),
|
|
Rung(bitrate: 4_000, height: 1080),
|
|
Rung(bitrate: 3_000, height: 720),
|
|
Rung(bitrate: 2_000, height: 720),
|
|
Rung(bitrate: 1_500, height: 720),
|
|
Rung(bitrate: 720, height: 480),
|
|
Rung(bitrate: 320, height: 360),
|
|
Rung(bitrate: 208, height: 360),
|
|
Rung(bitrate: 96, height: 240),
|
|
Rung(bitrate: 64, height: 160),
|
|
]
|
|
|
|
/// Eine Zahl, die sich aufbewahren lässt.
|
|
///
|
|
/// Über mehrere Filme hinweg gilt nur die **Absicht** — „ungefähr so viel
|
|
/// Bitrate" —, nicht die genaue Sprosse: welche Sprossen es gibt, hängt
|
|
/// von der Quelle ab. Deshalb eine Zahl und kein Aufzählungswert.
|
|
public var storageValue: Int {
|
|
switch self {
|
|
case .automatic: 0
|
|
case .original: -1
|
|
case .audioOnly: -2
|
|
case .limited(let rung): rung.bitrate
|
|
}
|
|
}
|
|
|
|
/// Die Stufe zu einer aufbewahrten Zahl, so nah wie möglich.
|
|
///
|
|
/// Gibt es die Sprosse für diese Quelle nicht, wird die nächstgelegene
|
|
/// genommen — eine 720p-Datei kennt keine 20-Megabit-Stufe, und dann
|
|
/// stillschweigend auf „automatisch" zurückzufallen wäre eine andere
|
|
/// Antwort als die, um die gebeten wurde.
|
|
public static func restored(from value: Int, forSourceHeight height: Int?) -> PlayerQuality? {
|
|
switch value {
|
|
case 0: return nil
|
|
case -1: return .original
|
|
case -2: return .audioOnly
|
|
default: break
|
|
}
|
|
|
|
let rungs = available(forSourceHeight: height).compactMap { quality -> Rung? in
|
|
if case .limited(let rung) = quality { return rung }
|
|
return nil
|
|
}
|
|
guard let closest = rungs.min(by: {
|
|
abs($0.bitrate - value) < abs($1.bitrate - value)
|
|
}) else { return nil }
|
|
return .limited(closest)
|
|
}
|
|
|
|
public static var allCases: [PlayerQuality] {
|
|
[.automatic, .original] + ladder.map(PlayerQuality.limited) + [.audioOnly]
|
|
}
|
|
|
|
public var id: String {
|
|
switch self {
|
|
case .automatic: "automatic"
|
|
case .original: "original"
|
|
case .audioOnly: "audioOnly"
|
|
case .limited(let rung): "limited-\(rung.bitrate)"
|
|
}
|
|
}
|
|
|
|
/// Obergrenze in Kilobit. `nil` bei `automatic` und `original`.
|
|
public var maximumBitrate: Int? {
|
|
switch self {
|
|
case .automatic, .original: nil
|
|
case .limited(let rung): rung.bitrate
|
|
// Plex kennt für Videos keinen reinen Tonstrom. Die unterste Sprosse
|
|
// ist das, was dem am nächsten kommt; das Bild wird dazu im Player
|
|
// ausgeblendet, damit der Schirm dunkel bleiben kann.
|
|
case .audioOnly: Self.ladder.last?.bitrate
|
|
}
|
|
}
|
|
|
|
public var resolution: String? {
|
|
switch self {
|
|
case .automatic, .original: nil
|
|
case .limited(let rung): rung.resolution
|
|
case .audioOnly: Self.ladder.last?.resolution
|
|
}
|
|
}
|
|
|
|
/// Die Höhe in Bildzeilen, sofern die Stufe eine vorgibt.
|
|
public var height: Int? {
|
|
switch self {
|
|
case .automatic, .original: nil
|
|
case .limited(let rung): rung.height
|
|
case .audioOnly: Self.ladder.last?.height
|
|
}
|
|
}
|
|
|
|
/// Ob das Bild gezeigt werden soll.
|
|
public var showsVideo: Bool {
|
|
if case .audioOnly = self { return false }
|
|
return true
|
|
}
|
|
|
|
/// Ob diese Stufe eine Umrechnung erzwingt.
|
|
///
|
|
/// `original` erzwingt das Gegenteil: keine Umrechnung, auch wenn die
|
|
/// Einstellungen eine Grenze vorsehen.
|
|
public var forcesTranscode: Bool {
|
|
switch self {
|
|
case .automatic, .original: false
|
|
case .limited, .audioOnly: true
|
|
}
|
|
}
|
|
|
|
/// Die Richtlinie für diese Stufe.
|
|
///
|
|
/// `automatic` reicht durch, was ohnehin gilt. Die übrigen überschreiben
|
|
/// sie — das ist der Zweck eines Eingriffs im Player.
|
|
public func policy(basedOn base: PlaybackPolicy) -> PlaybackPolicy {
|
|
switch self {
|
|
case .automatic:
|
|
base
|
|
case .original:
|
|
// Keine Grenze und kein Umrechnen: ausdrücklich das Original.
|
|
PlaybackPolicy(allowsTranscoding: false, maximumBitrate: nil)
|
|
case .limited, .audioOnly:
|
|
PlaybackPolicy(
|
|
allowsTranscoding: true,
|
|
maximumBitrate: maximumBitrate,
|
|
forcesTranscoding: true
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare.
|
|
///
|
|
/// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht
|
|
/// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl
|
|
/// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt
|
|
/// erreichbar, steht aber nicht im Weg.
|
|
public static let everyday: [Rung] = [
|
|
Rung(bitrate: 8_000, height: 1080),
|
|
Rung(bitrate: 3_000, height: 720),
|
|
Rung(bitrate: 720, height: 480),
|
|
]
|
|
|
|
/// Die kurze Auswahl für eine Quelle dieser Höhe.
|
|
public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] {
|
|
let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) }
|
|
let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs
|
|
return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly]
|
|
}
|
|
|
|
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
|
|
///
|
|
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
|
|
/// die Stufen sähen alle gleich aus — dieselbe Falle wie beim Herunterladen.
|
|
public static func available(forSourceHeight height: Int?) -> [PlayerQuality] {
|
|
guard let height, height > 0 else { return allCases }
|
|
let fitting = ladder.filter { $0.height <= height }
|
|
// Mindestens die unterste Sprosse anbieten, sonst bliebe bei einer
|
|
// sehr kleinen Quelle nur „Original".
|
|
let rungs = fitting.isEmpty ? Array(ladder.suffix(1)) : fitting
|
|
return [.automatic, .original] + rungs.map(PlayerQuality.limited) + [.audioOnly]
|
|
}
|
|
}
|