Files
Flaro/Flixy/Views/Player/QualitySheet.swift
T
ScarriffleandClaude Opus 5 903bfe5f63 Der Player weiß jetzt, wie es ihm geht
Bisher gab es über eine laufende Wiedergabe zwei Zahlen — gemessene
Bitrate und verworfene Bilder —, und die standen nur zur Anzeige da. Die
Frage „trägt die Leitung das gerade" war nicht zu beantworten, weder für
den Nutzer noch für die App.

**Die wichtigste neue Zahl ist `variantCount`.** Sie kommt aus
`AVAsset.variants`, also vom Parser des Players, und beantwortet die
Frage, ob es überhaupt etwas zum Umschalten gibt. Steht dort 1, greift
jedes Nachregeln ins Leere — und genau das ist heute der Fall, weil die
App bei beiden Servern eine Playlist mit einer Stufe bestellt.

Dazu der Pufferstand aus `loadedTimeRanges`: der einzige echte
Frühwarner. Schrumpft er schneller, als die Zeit vergeht, kommt weniger
herein als hinausgeht — zehn bis zwanzig Sekunden, bevor das Bild steht.
Alles andere meldet sich erst danach.

**Verworfene Bilder werden jetzt über alle Protokolleinträge summiert.**
Das Zugriffsprotokoll legt bei jedem Stufenwechsel einen neuen an, dessen
Zähler bei null beginnt; wer nur `last` liest, hält eine Wiedergabe für
makellos, die schon zwanzigmal Bilder verworfen hat.

**Der Wachhund war blind für den häufigsten Fall.** Vierzig Sekunden am
Stück ohne Daten brechen ab — aber er setzte sich bei jedem kurzen
Erholen zurück, und zehn Rückler à dreißig Sekunden lösten ihn nie aus.
`StallLedger` zählt daneben mit, wie viel Zeit im gleitenden Fenster
fürs Warten draufging. Es bricht nichts ab, es stellt nur fest — und ist
ein reiner Typ ohne Uhr und ohne AVFoundation, damit sich eine Folge von
Rücklern als Zahlenreihe hinschreiben und das Urteil prüfen lässt.
Sieben Tests, darunter genau der Fall, an dem der alte Wachhund
vorbeisah.

Gezählt wird nur der Weg vom Spielen ins Warten. Das anfängliche Puffern
ist der Start, kein Rückler; würde es mitgezählt, hätte jede Wiedergabe
eine Vorstrafe.

Und zwei Kommentare sind berichtigt. Sie behaupteten, AVPlayer wechsle
die Bitrate von selbst und das sei „die zugesagte automatische
Qualitätsanpassung". Umschalten kann er nur zwischen Varianten, die in
der Liste stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 14:59:29 +02:00

202 lines
8.0 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyPlayback
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
///
/// Ein Blatt statt eines Dialogs: die Leiter hat je nach Quelle bis zu
/// dreizehn Stufen, und `confirmationDialog` zeigt nur eine Liste von Knöpfen
/// ohne Ordnung. Hier stehen sie nach Auflösung gruppiert — und ganz oben,
/// was gerade über die Leitung kommt.
struct QualitySheet: View {
let model: PlayerModel
@Environment(\.flixyAccent) private var accent
/// **Als Fragezeichen**, nicht als Behauptung.
///
/// `@Environment(PlaybackSettings.self)` ohne Optional hält die Anwendung
/// an, wenn der Gegenstand nicht im Baum steht — und ein Blatt hängt an
/// einem eigenen Baum. Genau daran ist der Player eingefroren, und zu
/// sehen war nichts davon. Der Player reicht die Einstellungen jetzt
/// ausdrücklich herein; das Fragezeichen sorgt dafür, dass ein Vergessen
/// künftig nur eine fehlende Zeile kostet statt der ganzen App.
@Environment(PlaybackSettings.self) private var settings: PlaybackSettings?
@Environment(\.dismiss) private var dismiss
@State private var showsEveryRung = false
private var everyday: [PlayerQuality] {
PlayerQuality.everyday(forSourceHeight: model.sourceHeight)
}
private var options: [PlayerQuality] {
PlayerQuality.available(forSourceHeight: model.sourceHeight)
}
var body: some View {
NavigationStack {
List {
current
Section {
ForEach(everyday) { option in
row(for: option)
}
} footer: {
Text("quality.audioOnly.hint")
}
// Aufgeklappt nur, wer es wirklich will. Für den Alltag
// reicht „eine Stufe kleiner"; die volle Leiter braucht, wer
// eine bestimmte Bitrate erzwingen muss.
Section {
DisclosureGroup(isExpanded: $showsEveryRung) {
ForEach(groupedHeights, id: \.self) { height in
ForEach(rungs(height: height), id: \.self) { rung in
row(for: .limited(rung), detail: "\(height)p")
}
}
} label: {
Text("quality.everyRung")
}
}
}
.navigationTitle(Text("player.quality"))
.flixyInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
.flixyResizableSheet()
}
/// Was gerade läuft — gemessen, nicht gewünscht.
@ViewBuilder
private var current: some View {
if settings?.showsBitrate == true, let statistics = model.statistics {
Section {
if let observed = statistics.observedBitrate {
LabeledContent("quality.current.bitrate", value: bitrateText(observed))
}
if let resolution = statistics.resolution {
LabeledContent("quality.current.resolution", value: resolution)
}
if let dropped = statistics.droppedFrames, dropped > 0 {
LabeledContent("quality.current.dropped", value: "\(dropped)")
}
// **Die Zahl, die alles entscheidet.** Eine Playlist mit
// einer Stufe hat nichts zum Umschalten — dann greift jedes
// Nachregeln ins Leere, ganz gleich was bestellt wurde.
if let variants = statistics.variantCount {
LabeledContent("quality.current.variants", value: "\(variants)")
}
// Der Puffer ist der einzige echte Frühwarner: schrumpft er
// schneller, als die Zeit vergeht, kommt weniger herein als
// hinausgeht.
if let buffer = statistics.bufferedAhead {
LabeledContent("quality.current.buffer", value: secondsText(buffer))
}
if statistics.stallCount > 0 {
LabeledContent(
"quality.current.stalls",
value: String(
localized: "quality.stalls \(statistics.stallCount) \(secondsText(statistics.stalledTime))"
)
)
}
} header: {
Text("quality.current")
} footer: {
// Der Unterschied ist keine Haarspalterei: die Obergrenze sagt,
// was erlaubt ist, die gemessene Rate, was ankommt. Ruckelt es
// trotz hoher Stufe, steht die Antwort hier.
//
// Und wenn es nur eine Stufe gibt, gehört auch das gesagt:
// sonst sucht man den Fehler beim Player, während in
// Wahrheit nie eine Leiter bestellt wurde.
if statistics.variantCount == 1 {
Text("quality.current.noLadder")
} else {
Text("quality.current.hint")
}
}
}
}
private var groupedHeights: [Int] {
var seen = Set<Int>()
return options.compactMap { option -> Int? in
guard case .limited(let rung) = option else { return nil }
return seen.insert(rung.height).inserted ? rung.height : nil
}
}
private func rungs(height: Int) -> [PlayerQuality.Rung] {
options.compactMap { option in
guard case .limited(let rung) = option, rung.height == height else { return nil }
return rung
}
}
private func row(for option: PlayerQuality, detail: String? = nil) -> some View {
Button {
Task { await model.select(quality: option) }
dismiss()
} label: {
HStack {
Text(label(for: option))
.foregroundStyle(Color.flixyTextPrimary)
if let detail {
Text(detail)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
Spacer()
if option == model.quality {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func label(for option: PlayerQuality) -> String {
switch option {
case .automatic: String(localized: "quality.automatic")
case .original: String(localized: "quality.original")
case .audioOnly: String(localized: "quality.audioOnly")
// In der kurzen Liste zählt die Auflösung, nicht die Zahl dahinter:
// „720p" sagt mehr als „3 Mbps", wenn man nur kleiner will.
case .limited(let rung): "\(rung.height)p · \(bitrateText(rung.bitrate))"
}
}
/// Sekunden mit einer Nachkommastelle — für Puffer und Wartezeit.
private func secondsText(_ duration: Duration) -> String {
let seconds = Double(duration.components.seconds)
+ Double(duration.components.attoseconds) / 1e18
return String(localized: "quality.seconds \(String(format: "%.1f", seconds))")
}
/// Kilobit unterhalb eines Megabits, darüber Megabit mit einer Stelle.
///
/// „1500 kbps" liest sich schlechter als „1,5 Mbps", und „0,064 Mbps"
/// schlechter als „64 kbps".
private func bitrateText(_ kilobits: Int) -> String {
guard kilobits >= 1_000 else {
return String(localized: "quality.kilobits \(kilobits)")
}
let megabits = Double(kilobits) / 1_000
let rounded = megabits.rounded() == megabits
? String(Int(megabits))
: String(format: "%.1f", megabits)
return String(localized: "quality.megabits \(rounded)")
}
}