Files
Flaro/Packages/FlaroKit/Sources/FlaroPlayback/Decision/PlayerQuality.swift
T
ScarriffleandClaude Opus 5 a7cf6c5def 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>
2026-10-03 19:38:27 +02:00

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