Bei „Auto\" bestellen wir eine Liste von Stufen; die Schnipsel stehen eine Ebene darunter. Der Speicher erkannte das zwar, stieg dann aber aus und reichte die Liste mit relativen Adressen durch — die zeigten danach auf den lokalen Server, wo es sie nicht gibt. Es wurde nichts vorgeholt, und beinahe waere auch nichts mehr gelaufen. Jetzt wird auch die Stufenliste umgeschrieben. Holt der Player eine Stufe, kommt er wieder bei uns an, und dort beginnt das Vorauslaufen — bei der Stufe, fuer die er sich entschieden hat. Dazu eine Zeile im Qualitaetsblatt, die sagt, ob der Vorrat laeuft und wie weit er voraus ist. Von aussen ist das nicht zu sehen: laeuft er nicht, spielt es trotzdem, nur ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der laeuft und nichts bekommt. Ohne diese Zeile sind die beiden Faelle nicht zu unterscheiden, und ich haette wieder geraten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
8.4 KiB
Swift
210 lines
8.4 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyPlayback
|
|
|
|
/// Die Qualitätswahl im Player, samt dem, was gerade tatsächlich läuft.
|
|
///
|
|
/// Ein Sheet 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 Sheet 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)")
|
|
}
|
|
|
|
// **Ob der eigene Vorrat läuft, und wie weit.** Von aussen ist
|
|
// das nicht zu sehen: läuft er nicht, spielt es trotzdem, nur
|
|
// ohne Vorsprung — und genau so sieht auch ein Vorrat aus, der
|
|
// läuft und nichts bekommt.
|
|
if let vorrat = model.prefetchStatus {
|
|
LabeledContent("quality.current.prefetch", value: vorrat)
|
|
}
|
|
|
|
// **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)")
|
|
}
|
|
}
|