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>
164 lines
6.2 KiB
Swift
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)")
|
|
}
|
|
}
|