Files
Flaro/Flixy/Views/Player/QualitySheet.swift
T
ScarriffleandClaude Opus 5 8a63f815cd Das Qualitätsblatt hat die App angehalten
Kein Hänger, sondern ein Absturz, der nicht durchkam. Der Stapel des
eingefrorenen Prozesses endet in `_assertionFailure` beim Lesen eines
`@Environment`-Werts — mitten in `SheetBridge.present`, also während das
Blatt aufgebaut wird. Weil der Player in diesem Moment in einem
Layoutdurchlauf steckt, erschien nie eine Meldung: das Fenster stand
still.

`@Environment(PlaybackSettings.self)` ohne Fragezeichen ist keine Bitte,
sondern eine Behauptung — fehlt der Gegenstand, hält Swift die Anwendung
an. Und ein Blatt hängt an einem eigenen Darstellungsbaum. Auf dem iPad
kam die Umgebung des Players dort bisher an, auf dem Mac nicht.

Zwei Änderungen, beide nötig:

- Der Player reicht `PlaybackSettings` und den Akzent ausdrücklich in
  seine Blätter hinein, statt sich auf Vererbung zu verlassen.
- `QualitySheet` liest die Einstellungen als Optional. Ein Vergessen
  kostet künftig eine fehlende Zeile im Blatt statt der ganzen App.

Dazu ein Test, der genau diesen Weg geht: abspielen, Qualitätsknopf,
Blatt da? Er lief schon vor der Korrektur durch — der Unterschied
zwischen iPad und Mac ist mit einem Simulator nicht zu fassen. Er hält
trotzdem fest, dass der Weg überhaupt gegangen werden kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:45:09 +02:00

164 lines
6.2 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)")
}
} 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.
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))"
}
}
/// 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)")
}
}