Player: die Qualitätsleiter des Servers, Bitrate im Blick, Doppeltipp

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>
This commit is contained in:
Scarriffle
2026-09-12 21:50:39 +02:00
co-authored by Claude Opus 5
parent c2ddc10871
commit 12bb49615b
28 changed files with 666 additions and 262 deletions
+1 -1
View File
@@ -226,7 +226,7 @@ struct LiveTVView: View {
defer { isTuning = nil }
// Die laufende Sendung mitgeben: über ihre Adresse beim
// Programmanbieter führt der direkte Weg zum Strom.
// Programmanbieter führt der direkte Weg zum Stream.
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
+2 -2
View File
@@ -107,8 +107,8 @@ struct RootView: View {
/// Preis; der Sender, auf den umgeschaltet wird, ist ohnehin schon
/// abgestimmt, bevor es hier ankommt.
private func handOver(to request: PlaybackRequest?) async {
// Zuerst und ohne Warten: den alten Motor stilllegen. Das Aufräumen am
// Server darf dauern, das Abschalten nicht — läuft der alte Motor
// Zuerst und ohne Warten: den alten Engine stilllegen. Das Aufräumen am
// Server darf dauern, das Abschalten nicht — läuft der alte Engine
// weiter, während der neue startet, streiten sich zwei um dieselbe
// Audiositzung. Zu hören war das als alter Ton, der weiterlief, und
// als neue Wiedergabe, die stumm blieb.
+33 -61
View File
@@ -17,23 +17,26 @@ struct MiniPlayer: View {
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
/// Die Ecke bleibt über Starts hinweg erhalten. Wer die Kachel einmal
/// nach links unten gezogen hat, will sie beim nächsten Mal dort haben.
@AppStorage("miniPlayer.corner") private var storedCorner = MiniPlayerPlacement.Corner.bottomTrailing.rawValue
/// Seite und Höhe bleiben über Starts hinweg erhalten. Wer die Kachel
/// einmal nach links unten gezogen hat, will sie dort wiederfinden.
@AppStorage("miniPlayer.side") private var storedSide = MiniPlayerPlacement.Side.trailing.rawValue
/// Als Anteil, nicht in Punkten: beim Drehen bleibt sie so an derselben
/// Stelle, statt unten herauszufallen.
@AppStorage("miniPlayer.height") private var storedFraction = 0.85
/// Verschiebung während des Ziehens, noch nicht eingerastet.
@State private var drag: CGSize = .zero
@State private var showsControls = false
@State private var hideTask: Task<Void, Never>?
private var corner: MiniPlayerPlacement.Corner {
MiniPlayerPlacement.Corner(rawValue: storedCorner) ?? .bottomTrailing
private var side: MiniPlayerPlacement.Side {
MiniPlayerPlacement.Side(rawValue: storedSide) ?? .trailing
}
/// Die selbst eingestellte Breite. Null heißt „noch nie angefasst".
@AppStorage("miniPlayer.width") private var storedWidth: Double = 0
/// Änderung während des Ziehens am Griff, noch nicht übernommen.
@State private var widthChange: CGFloat = 0
/// Vergrößerung während des Zusammen- oder Auseinanderziehens.
@State private var zoom: CGFloat = 1
/// Die zuletzt tatsächlich gezeigte Breite — schon begrenzt.
///
/// Ohne sie würde beim Loslassen der ungebremste Wert gespeichert, und
@@ -49,7 +52,7 @@ struct MiniPlayer: View {
// Nach oben durch den Bildschirm begrenzt: eine Kachel, die breiter
// ist als das Fenster, ließe sich nicht mehr wegziehen.
let upper = max(160, min(bounds.width * 0.75, 560))
let width = min(max(chosen + widthChange, 140), upper)
let width = min(max(chosen * zoom, 140), upper)
return CGSize(width: width, height: (width / FlixyAspectRatio.landscape).rounded())
}
@@ -57,18 +60,21 @@ struct MiniPlayer: View {
GeometryReader { geometry in
let tileSize = tileSize(in: geometry.size)
let placement = MiniPlayerPlacement(bounds: geometry.size, tile: tileSize)
let resting = placement.center(for: corner)
let resting = placement.center(side: side, verticalFraction: storedFraction)
let position = placement.clamped(
center: CGPoint(x: resting.x + drag.width, y: resting.y + drag.height)
)
tile(corner: corner)
tile
.frame(width: tileSize.width, height: tileSize.height)
.onChange(of: tileSize.width, initial: true) { _, width in
lastWidth = width
}
.position(position)
.gesture(dragGesture(in: placement, from: resting))
// Gleichzeitig, damit das Zusammenziehen nicht erst nach dem
// Loslassen greift — beides passiert mit denselben Fingern.
.simultaneousGesture(zoomGesture(in: geometry.size))
// Nur während des Ziehens ohne Animation, sonst würde die
// Kachel dem Finger hinterherfedern statt an ihm zu kleben.
.animation(drag == .zero ? .snappy(duration: 0.3) : nil, value: position)
@@ -76,7 +82,7 @@ struct MiniPlayer: View {
.transition(.scale(scale: 0.8).combined(with: .opacity))
}
private func tile(corner: MiniPlayerPlacement.Corner) -> some View {
private var tile: some View {
ZStack {
Color.black
@@ -88,7 +94,6 @@ struct MiniPlayer: View {
if showsControls {
controls.transition(.opacity)
resizeHandle(corner: corner).transition(.opacity)
}
// Bei einem laufenden Sender hat ein Fortschritt keine Bedeutung —
@@ -156,58 +161,22 @@ struct MiniPlayer: View {
}
}
/// Der Griff zum Größerziehen.
/// Größer und kleiner durch Zusammen- und Auseinanderziehen.
///
/// Er sitzt in der Ecke, die zur Bildschirmmitte zeigt — dort ist er
/// erreichbar, egal in welcher Ecke die Kachel klebt. Am äußeren Rand
/// läge er halb außerhalb des Daumens.
private func resizeHandle(corner: MiniPlayerPlacement.Corner) -> some View {
// Über `overlay(alignment:)` gesetzt, nicht über gedrehte Stapel:
// eine Drehung auf dem Überzug kippt die gesamte Trefferfläche mit,
// und der Griff lag dann dort, wo man nicht hinfasst. Genau daran war
// das Ziehen vorher wirkungslos.
Color.clear.overlay(alignment: handleAlignment(for: corner)) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
// Nur das Symbol dreht sich, damit der Pfeil in die Richtung
// zeigt, in die es tatsächlich größer wird.
.rotationEffect(.degrees(corner.isTop == corner.isLeading ? 0 : 90))
.frame(width: 44, height: 44)
.contentShape(.rect)
// Vorrang vor dem Verschieben der Kachel, das am selben Ort
// hängt — sonst gewinnt das Ziehen und die Kachel wandert.
.highPriorityGesture(resizeGesture(corner: corner))
// Und ein Tippen darf hier nicht ins Vollbild führen.
.onTapGesture { reveal() }
.accessibilityLabel(Text("miniPlayer.resize"))
}
}
/// Die Ecke der Kachel, die zur Bildschirmmitte zeigt.
private func handleAlignment(for corner: MiniPlayerPlacement.Corner) -> Alignment {
switch corner {
case .topLeading: .bottomTrailing
case .topTrailing: .bottomLeading
case .bottomLeading: .topTrailing
case .bottomTrailing: .topLeading
}
}
private func resizeGesture(corner: MiniPlayerPlacement.Corner) -> some Gesture {
DragGesture(minimumDistance: 2)
/// Kein Griff mehr: der saß zwangsläufig irgendwo auf dem Bild, war klein
/// und stritt sich mit dem Verschieben um dieselbe Geste.
private func zoomGesture(in bounds: CGSize) -> some Gesture {
MagnifyGesture()
.onChanged { value in
hideTask?.cancel()
// Klebt die Kachel rechts, wächst sie beim Ziehen nach links.
widthChange = corner.isLeading ? value.translation.width : -value.translation.width
zoom = value.magnification
}
.onEnded { _ in
// Die **begrenzte** Breite merken, nicht die gezogene: eine
// einmal zu weit gezogene Kachel bliebe sonst für immer zu
// einmal zu weit aufgezogene Kachel bliebe sonst für immer zu
// groß, auch auf einem kleineren Bildschirm.
storedWidth = Double(lastWidth)
widthChange = 0
reveal()
zoom = 1
}
}
@@ -227,13 +196,16 @@ struct MiniPlayer: View {
}
.onEnded { value in
// Nach dem **Mittelpunkt** der Kachel entscheiden, nicht nach
// dem Finger: sonst springt eine Kachel, die man an der Kante
// angefasst hat, in die falsche Hälfte.
let center = CGPoint(
// dem Finger: sonst springt eine Kachel, die man am Rand
// angefasst hat, auf die falsche Seite.
let center = placement.clamped(center: CGPoint(
x: resting.x + value.predictedEndTranslation.width,
y: resting.y + value.predictedEndTranslation.height
)
storedCorner = placement.nearestCorner(toCenter: center).rawValue
))
// Seitlich einrasten, in der Höhe **nicht** — dort bleibt sie,
// wohin man sie gezogen hat.
storedSide = placement.nearestSide(toCenter: center).rawValue
storedFraction = placement.verticalFraction(forCenter: center)
drag = .zero
}
}
+31 -20
View File
@@ -9,8 +9,8 @@ import PlexKit
/// Führt einen Abspielwunsch aus.
///
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
/// passendie Engine und hält dessen Zustand für die Ansicht bereit. Die Ansicht
/// weiß nicht, welche Engine läuft — bis auf die eine Stelle, an der die
/// Bildfläche gebaut wird.
@MainActor
@Observable
@@ -58,6 +58,17 @@ final class PlayerModel {
/// Ob gerade auf eine andere Stufe umgestellt wird.
private(set) var isChangingQuality = false
/// Was gerade über die Leitung kommt. Beim Öffnen der Wahl abgefragt.
var statistics: PlaybackStatistics? { engine?.statistics }
/// Die Höhe der Quelldatei — danach richtet sich, welche Stufen überhaupt
/// Sinn ergeben. Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein
/// Bild dazu.
var sourceHeight: Int? { currentMedia?.height }
/// Ob das Bild gezeigt werden soll.
var showsVideo: Bool { quality.showsVideo }
/// Die Bildebene des Systemplayers.
///
/// Gehört dem Modell, nicht der Ansicht: das Modell überlebt den Wechsel
@@ -181,7 +192,7 @@ final class PlayerModel {
/// Stellt auf eine andere Qualitätsstufe um.
///
/// Bedeutet zwangsläufig einen Neuaufbau: die Stufe steckt in der Adresse
/// des Stroms, nicht in einer Einstellung, die sich unterwegs drehen ließe.
/// des Streams, nicht in einer Einstellung, die sich unterwegs drehen ließe.
/// Die Stelle wird gemerkt und danach wieder angefahren — sonst begänne der
/// Film bei jedem Wechsel von vorn.
func select(quality newQuality: PlayerQuality) async {
@@ -196,7 +207,7 @@ final class PlayerModel {
quality = newQuality
// Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet
// der Server zwei Ströme gleichzeitig für einen Zuschauer.
// der Server zwei Streams gleichzeitig für einen Zuschauer.
if let transcodeSession {
await server.stopTranscode(sessionIdentifier: transcodeSession)
self.transcodeSession = nil
@@ -228,7 +239,7 @@ final class PlayerModel {
/// Wiedergabe aus dem Download-Ordner.
///
/// Ohne Server und ohne Transcoding: die Datei liegt so vor, wie sie auf
/// dem Server lag. Welcher Motor sie öffnen kann, steht im Abzug, der beim
/// dem Server lag. Welcher Engine sie öffnen kann, steht im Abzug, der beim
/// Herunterladen mitgesichert wurde — offline lässt sich nichts nachfragen.
/// Live-TV: die Adresse steht schon fest.
///
@@ -236,7 +247,7 @@ final class PlayerModel {
/// Sender steht in keiner Mediathek. Und ohne Fortschrittsmeldung: es
/// gibt keine Stelle, an der man ihn später fortsetzen könnte.
private func startLiveStream(url: URL) async {
diagnostics.note("Live-Strom öffnen")
diagnostics.note("Live-Stream öffnen")
isLive = true
title = request.title
// Was gerade läuft, kommt mit dem Wunsch — nachzuschlagen ist es
@@ -309,11 +320,11 @@ final class PlayerModel {
observe(engine)
do {
diagnostics.note("Motor öffnet Quelle")
diagnostics.note("Engine öffnet Quelle")
try await engine.load(source)
engine.play()
phase = .playing
diagnostics.note("Motor gestartet")
diagnostics.note("Engine gestartet")
// Weder offline noch live gibt es einen Fortschritt zu melden.
if !isOffline, !isLive { startReporting() }
} catch {
@@ -322,7 +333,7 @@ final class PlayerModel {
}
}
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom.
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream.
private func source(
for decision: PlaybackDecision,
item: PlexItem,
@@ -366,7 +377,7 @@ final class PlayerModel {
/// Beendet ewiges Laden mit einer Meldung statt mit einem Kreisel.
///
/// Ein Live-Strom kann sterben, ohne dass der Motor es merkt — der Tuner
/// Ein Live-Stream kann sterben, ohne dass die Engine es merkt — der Tuner
/// gibt auf, der Transcoder bricht ab, und der Player wartet auf Daten,
/// die nie kommen. Ohne diese Grenze dreht sich der Kreisel bis zum
/// nächsten Antippen, und niemand weiß, ob noch etwas kommt.
@@ -379,8 +390,8 @@ final class PlayerModel {
guard !Task.isCancelled, let self, self.isBuffering else { return }
diagnostics.note("Seit 40 s keine Daten — abgebrochen")
// Den Motor gleich stilllegen: er würde sonst weiter an einem
// toten Strom ziehen, und bei Live-TV bliebe der Tuner belegt.
// Den Engine gleich stilllegen: er würde sonst weiter an einem
// toten Stream ziehen, und bei Live-TV bliebe der Tuner belegt.
silence()
phase = .failed(message: String(localized: "player.stalled"))
Task { await self.releaseOnServer() }
@@ -451,11 +462,11 @@ final class PlayerModel {
selectedSubtitleTrack = track
}
/// Legt den Motor **sofort** still. Kein Netzwerk, kein Warten.
/// Legt die Engine **sofort** still. Kein Netzwerk, kein Warten.
///
/// Getrennt vom Aufräumen am Server, und das ist der ganze Punkt: früher
/// stand der Motor erst nach drei Netzwerkaufrufen still. Wurde in der
/// Zwischenzeit etwas Neues gestartet, liefen zwei Motoren gleichzeitig,
/// stand die Engine erst nach drei Netzwerkaufrufen still. Wurde in der
/// Zwischenzeit etwas Neues gestartet, liefen zwei Engines gleichzeitig,
/// und der alte hat der neuen Wiedergabe beim Abräumen die Audiositzung
/// weggenommen. Was man hörte: der alte Ton lief weiter — auch nachdem
/// die App im Hintergrund war —, und das Neue blieb stumm.
@@ -574,10 +585,10 @@ final class PlayerModel {
return item.media[request.mediaIndex]
}
/// Hängt die gehaltene Bildebene an den aktuellen Motor.
/// Hängt die gehaltene Bildebene an den aktuellen Engine.
///
/// Nach **jedem** Motorwechsel nötig — also auch nach einem
/// Qualitätswechsel, der den Motor neu baut. Ohne das zeigt die Ebene
/// Nach **jedem** Engine-Wechsel nötig — also auch nach einem
/// Qualitätswechsel, der die Engine neu baut. Ohne das zeigt die Ebene
/// weiter auf einen abgeräumten Player und bleibt schwarz.
private func attachSurface(to engine: any VideoPlayerEngine) {
guard let avEngine = engine as? AVPlayerEngine else { return }
@@ -628,14 +639,14 @@ final class PlayerModel {
isBuffering = state == .buffering || state == .loading
watchBuffering(isBuffering)
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
// abzulesen, ob der Motor hängt oder die Leitung.
// abzulesen, ob die Engine hängt oder die Leitung.
diagnostics.note("Zustand: \(state)")
if case .failed(let failure) = state {
diagnostics.finish(outcome: "Wiedergabe gescheitert: \(failure)")
// Der Grund gehört auf den Bildschirm, nicht nur ins
// Protokoll: „die Wiedergabe wurde unterbrochen" allein
// ist eine Sackgasse — es sagt weder, ob der Server
// ablehnt, noch ob der Motor das Format nicht mag.
// ablehnt, noch ob die Engine das Format nicht mag.
phase = .failed(message: [
String(localized: "player.playbackFailed"),
String(describing: failure),
+58 -32
View File
@@ -27,14 +27,21 @@ struct PlayerView: View {
@State private var showsQualityChoice = false
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
/// Welche Richtung gerade als Hinweis eingeblendet ist.
@State private var seekHint: Double?
@State private var hintTask: Task<Void, Never>?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
Group {
VideoSurface(engine: model.engine, surface: model.surface)
.ignoresSafeArea()
// Bei „nur Ton" bleibt der Schirm dunkel — das ist der halbe
// Zweck der Stufe.
if model.showsVideo {
VideoSurface(engine: model.engine, surface: model.surface)
.ignoresSafeArea()
}
switch model.phase {
case .preparing:
@@ -83,21 +90,20 @@ struct PlayerView: View {
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible()
.contentShape(.rect)
.onTapGesture { toggleControls() }
// Zwei Hälften: links zurück, rechts vor. Ein einzelnes Tippen zeigt
// weiterhin die Bedienelemente — die Reihenfolge entscheidet, sonst
// verschluckt das einfache Tippen das doppelte.
.overlay {
HStack(spacing: 0) {
seekZone(seconds: -10, symbol: "gobackward.10")
seekZone(seconds: 10, symbol: "goforward.10")
}
}
.sheet(isPresented: $showsTrackPicker) {
TrackPickerSheet(model: model)
}
.confirmationDialog(
Text("player.quality"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
ForEach(PlayerQuality.allCases) { option in
Button(qualityChoiceLabel(option, current: model.quality)) {
Task { await model.select(quality: option) }
}
}
Button("action.cancel", role: .cancel) {}
.sheet(isPresented: $showsQualityChoice) {
QualitySheet(model: model)
}
.onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die
@@ -364,13 +370,45 @@ struct PlayerView: View {
.padding(FlixySpacing.section)
}
/// Die gewählte Stufe bekommt einen Haken.
/// Eine Bildschirmhälfte zum Springen.
///
/// Ein `Picker` im Dialog wäre eleganter, aber `confirmationDialog` zeigt
/// nur Knöpfe — und die Auswahl muss trotzdem erkennbar sein.
private func qualityChoiceLabel(_ option: PlayerQuality, current: PlayerQuality) -> String {
let name = String(localized: option.localizedName)
return option == current ? "\(name) ✓" : name
/// Bewusst ohne sichtbare Trennung: die Fläche soll sich nicht aufdrängen.
/// Der kurze Hinweis beim Doppeltippen sagt genug.
private func seekZone(seconds: Double, symbol: String) -> some View {
Color.clear
.contentShape(.rect)
.overlay {
if seekHint == seconds {
Label(
seconds < 0 ? "player.back10" : "player.forward10",
systemImage: symbol
)
.labelStyle(.iconOnly)
.font(.system(size: 44))
.foregroundStyle(.white)
.padding(FlixySpacing.section)
.background(.black.opacity(0.35), in: .circle)
.transition(.opacity.combined(with: .scale))
}
}
// Das doppelte zuerst: sonst gewinnt das einfache und man käme nie
// zum Springen.
.onTapGesture(count: 2) { seek(by: seconds) }
.onTapGesture { toggleControls() }
}
private func seek(by seconds: Double) {
guard let current = model.duration != nil || model.isLive ? model.currentTime : nil
else { return }
model.seek(to: current + .seconds(seconds))
withAnimation(.easeOut(duration: 0.15)) { seekHint = seconds }
hintTask?.cancel()
hintTask = Task {
try? await Task.sleep(for: .milliseconds(600))
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.2)) { seekHint = nil }
}
}
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
@@ -416,15 +454,3 @@ extension Duration {
return String(format: "%d:%02d", minutes, seconds)
}
}
extension PlayerQuality {
var localizedName: String.LocalizationValue {
switch self {
case .automatic: "quality.automatic"
case .original: "quality.original"
case .fullHD: "quality.fullHD"
case .hd: "quality.hd"
case .low: "quality.lowRes"
}
}
}
+137
View File
@@ -0,0 +1,137 @@
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)")
}
}
+2 -2
View File
@@ -3,10 +3,10 @@ import SwiftUI
import FlixyPlayback
import FlixyVLC
/// Die Bildfläche des laufenden Motors.
/// Die Bildfläche des laufendie Engine.
///
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
/// Engines sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
/// `UIView`.
struct VideoSurface: View {
+1 -1
View File
@@ -4,7 +4,7 @@ import PlexKit
/// Was beim letzten Abspielversuch passiert ist.
///
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welcher Motor gewählt
/// Gedacht als Antwort auf „es läuft nicht": hier steht, welche Engine gewählt
/// wurde, über welche Verbindung, und wie lange jeder Schritt gedauert hat.
/// Ein Screenshot davon sagt mehr als jede Beschreibung.
struct DiagnosticsView: View {