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() 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)") } }