Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen. Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm. **Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities. Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar Zeilen mehr und eine Sorge weniger. **Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer `PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten Bildschirm vergessen. Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen `NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das Anmeldefenster, das ein anderes Fenster als Anker braucht. **Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit `TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim Wechsel kurz zwei Seiten gleichzeitig zu zeigen. **Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in- Bild startet von Hand statt beim Verlassen der App. Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da. Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst. iOS baut unverändert, 263 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
5.7 KiB
Swift
156 lines
5.7 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(PlaybackSettings.self) private var settings
|
|
@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, 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)")
|
|
}
|
|
}
|