Die vier erfundenen Stufen weichen der Leiter, die der Server selbst meldet: 64, 96, 208, 320, 720, 1500, 2000, 3000, 4000, 8000, 10000, 12000, 20000 kbps mit den Auflösungen, die Plex dafür vorsieht. Eine eigene Leiter hieße, Stufen anzubieten, die der Transcoder nicht kennt — er rundet dann still auf die nächste, und die Anzeige log. Angeboten wird nur, was zur Quelle passt. Eine 720p-Datei auf 1080p hochzurechnen bringt kein Bild dazu, und die Stufen sähen alle gleich aus. Oben im Blatt steht, was tatsächlich über die Leitung kommt — gemessene Bitrate, Auflösung, verworfene Bilder. Der Unterschied zur gewählten Stufe ist keine Haarspalterei: die eine sagt, was erlaubt ist, die andere, was ankommt. Ruckelt es trotz hoher Stufe, steht die Antwort dort. „Nur Ton" gibt es, aber ehrlich beschriftet: Plex kennt für Videos keinen reinen Tonstrom, also wird die unterste Sprosse geholt und das Bild ausgeblendet. Dazu Doppeltipp links und rechts für zehn Sekunden zurück und vor. Die Reihenfolge der Gesten ist entscheidend — das einfache Tippen verschluckt sonst das doppelte. Die Kachel hängt nicht mehr in vier Ecken fest, sondern klebt links oder rechts und ist in der Höhe frei. Vier Ecken sind schnell gebaut, aber im Weg: steht sie über einem Knopf, bleiben drei Orte, die ebenso stören. Größer wird sie durch Zusammen- und Auseinanderziehen; der Griff ist weg, er saß zwangsläufig auf dem Bild und stritt sich mit dem Verschieben. Und die Begriffe: „Motor" für engine sagt kein deutscher Entwickler. Fachwörter, die im Deutschen englisch bleiben, bleiben jetzt englisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
4.8 KiB
Swift
138 lines
4.8 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
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private var options: [PlayerQuality] {
|
|
PlayerQuality.available(forSourceHeight: model.sourceHeight)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
current
|
|
|
|
Section {
|
|
row(for: .automatic)
|
|
row(for: .original)
|
|
}
|
|
|
|
ForEach(groupedHeights, id: \.self) { height in
|
|
Section("\(height)p") {
|
|
ForEach(rungs(height: height), id: \.self) { rung in
|
|
row(for: .limited(rung))
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
row(for: .audioOnly)
|
|
} footer: {
|
|
Text("quality.audioOnly.hint")
|
|
}
|
|
}
|
|
.navigationTitle(Text("player.quality"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("action.done") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
|
|
/// Was gerade läuft — gemessen, nicht gewünscht.
|
|
@ViewBuilder
|
|
private var current: some View {
|
|
if 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) -> some View {
|
|
Button {
|
|
Task { await model.select(quality: option) }
|
|
dismiss()
|
|
} label: {
|
|
HStack {
|
|
Text(label(for: option))
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
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")
|
|
case .limited(let rung): 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)")
|
|
}
|
|
}
|