Vier Punkte: Player-Hänger, kaputte Downloads, Stufenliste, Kopfleiste

Player hing sich auf
  Die Qualitätsauswahl war ein Menu. Schloss man es durch einen Tipp ins
  Bild, blieb eine unsichtbare Ebene zurück, die alle weiteren Tipps
  schluckte — die Bedienelemente kamen nicht mehr, pausieren ging nicht
  mehr. Jetzt ein confirmationDialog, der sich selbst wegräumt. Nach dem
  Schließen werden die Bedienelemente eingeblendet und die Ausblendfrist
  neu gestartet.

Downloads galten als fertig, waren aber leer
  Zwei Fehler übereinander. Erstens gilt ein URLSessionDownloadTask auch
  bei 404 oder 500 als erfolgreich und legt die Fehlerseite als Datei ab.
  Zweitens überschrieb ich danach die erwartete Größe mit der empfangenen
  — womit die Anzeige auf hundert Prozent stand und der Beweis weg war.
  873 MB waren in einer Sekunde „fertig", und beim Abspielen kam nur
  „Wiedergabe wurde unterbrochen".

  Jetzt wird der Statuscode geprüft, die erwartete Größe bleibt stehen,
  und eine unplausible Datei wird verworfen statt verbucht: unter 100 KB
  ist keine Videodatei, unter der Hälfte der angekündigten Größe ist eine
  abgebrochene Übertragung. Sechs Tests dazu.

Stufenliste ergab keinen Sinn
  Für einen 720p-Inhalt standen Original, Full HD und HD mit derselben
  geschätzten Größe untereinander — das sah aus, als wäre die Schätzung
  kaputt. Sie war es nicht, die Stufen waren es: hochrechnen bringt nur
  eine größere Datei. Angeboten wird jetzt nur noch, was unter der
  Auflösung der Datei liegt, und beim Original steht dabei, was es ist:
  „Original · 720p · 873 MB".

Kopfleiste
  Kein Hintergrund mehr statt eines weichgezeichneten — die Knöpfe
  bringen ihre eigene Glasfläche mit. Der Titel ist weg: der Name der App
  gehört auf den Homescreen, nicht über jeden Bildschirm.

145 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 22:32:19 +02:00
co-authored by Claude Opus 5
parent 8a77ab6600
commit 6150b018ec
9 changed files with 291 additions and 40 deletions
+26
View File
@@ -35,6 +35,32 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
}
}
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
var pixelHeight: Int? {
switch self {
case .original: nil
case .fullHD: 1080
case .hd: 720
case .low: 480
}
}
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
///
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
guard let height else { return allCases }
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
let threshold = Double(height) * 0.9
return allCases.filter { quality in
guard let target = quality.pixelHeight else { return true }
return Double(target) < threshold
}
}
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
///
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
+20 -9
View File
@@ -40,7 +40,7 @@ struct DownloadButton: View {
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
ForEach(DownloadQuality.allCases) { quality in
ForEach(availableQualities) { quality in
Button(qualityLabel(quality)) { enqueue(quality: quality) }
}
Button("action.cancel", role: .cancel) {}
@@ -108,21 +108,32 @@ struct DownloadButton: View {
}
}
/// Die Beschriftung samt geschätzter Größe.
private var currentMedia: PlexMedia? {
item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia
}
/// Nur Stufen, die unter der Auflösung der Datei liegen.
private var availableQualities: [DownloadQuality] {
DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight)
}
/// Die Beschriftung samt Auflösung und geschätzter Größe.
///
/// Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein
/// sagt nichts darüber, ob es aufs Gerät passt.
/// Beim Original steht dabei, was es überhaupt ist: „Original · 720p".
/// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade
/// herunterrechnet oder nicht.
private func qualityLabel(_ quality: DownloadQuality) -> String {
let media = item.media.indices.contains(mediaIndex)
? item.media[mediaIndex]
: item.preferredMedia
let originalBytes = media?.parts.first?.size ?? 0
let originalBytes = currentMedia?.parts.first?.size ?? 0
let bytes = quality.estimatedBytes(
durationMilliseconds: item.duration,
originalBytes: originalBytes
)
let name = String(localized: quality.localizedName)
var name = String(localized: quality.localizedName)
if quality == .original, let resolution = currentMedia?.resolutionLabel {
name += " · \(resolution)"
}
guard bytes > 0 else { return name }
let size = bytes.formatted(.byteCount(style: .file))
+10 -9
View File
@@ -37,16 +37,17 @@ struct HomeView: View {
}
}
.background(Color.flixyCanvas)
.navigationTitle(Branding.appName)
// Kein Titel: der Name der App gehört auf den Homescreen, nicht
// über jeden Bildschirm. Darunter liegt der Aufmacher, und der sagt
// ohnehin mehr.
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
// Unscharf statt schwarz: der Aufmacher läuft hinter der Leiste
// weiter, statt an einer harten Kante zu enden. `.ultraThinMaterial`
// macht genau das — es weichzeichnet, was darunter liegt, ohne es
// zusätzlich abzudunkeln.
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
// Helle Symbole und Schrift, weil darunter ein Bild liegt und kein
// heller Grund.
// Gar kein Hintergrund statt eines weichgezeichneten: die Knöpfe
// bringen ihre eigene Glasfläche mit und bleiben auch über dem Bild
// lesbar. `.ultraThinMaterial` verschleierte dagegen den halben
// Aufmacher.
.toolbarBackground(.hidden, for: .navigationBar)
// Helle Symbole, weil darunter ein Bild liegt und kein heller Grund.
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar { SearchToolbarItem() }
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
+44 -19
View File
@@ -19,6 +19,7 @@ struct PlayerView: View {
@State private var model: PlayerModel?
@State private var showsControls = true
@State private var showsTrackPicker = false
@State private var showsQualityChoice = false
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
@@ -81,6 +82,28 @@ struct PlayerView: View {
.sheet(isPresented: $showsTrackPicker) {
if let model { TrackPickerSheet(model: model) }
}
.confirmationDialog(
Text("player.quality"),
isPresented: $showsQualityChoice,
titleVisibility: .visible
) {
if let model {
ForEach(PlayerQuality.allCases) { option in
Button(qualityChoiceLabel(option, current: model.quality)) {
Task { await model.select(quality: option) }
}
}
}
Button("action.cancel", role: .cancel) {}
}
.onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
// weil die alte Frist während des Dialogs abgelaufen ist.
guard !isShown else { return }
withAnimation(.easeInOut(duration: 0.2)) { showsControls = true }
scheduleControlsHide()
}
.task {
let model = PlayerModel(
request: request,
@@ -164,30 +187,23 @@ struct PlayerView: View {
}
if !model.isOffline {
qualityMenu(for: model)
qualityButton(for: model)
}
}
.foregroundStyle(.white)
}
/// Die Qualitätsstufe für diesen Film.
/// Der Knopf für die Qualitätsstufe.
///
/// Im Player und nicht nur in den Einstellungen, weil der Eingriff genau
/// dann gebraucht wird, wenn es gerade ruckelt — und dafür will niemand
/// den Film verlassen.
private func qualityMenu(for model: PlayerModel) -> some View {
Menu {
Picker("player.quality", selection: Binding(
get: { model.quality },
set: { selected in
Task { await model.select(quality: selected) }
scheduleControlsHide()
}
)) {
ForEach(PlayerQuality.allCases) { option in
Text(option.label).tag(option)
}
}
/// Ein `Menu` stand hier vorher — und war der Grund dafür, dass sich der
/// Player aufhängte: schloss man es durch einen Tipp ins Bild, blieb eine
/// unsichtbare Ebene zurück, die alle weiteren Tipps schluckte. Die
/// Bedienelemente kamen dann nicht mehr, und pausieren ging nicht mehr.
/// Ein `confirmationDialog` räumt sich selbst weg.
private func qualityButton(for model: PlayerModel) -> some View {
Button {
showsQualityChoice = true
hideTask?.cancel()
} label: {
if model.isChangingQuality {
ProgressView().controlSize(.small).tint(.white)
@@ -353,6 +369,15 @@ struct PlayerView: View {
.padding(FlixySpacing.section)
}
/// Die gewählte Stufe bekommt einen Haken.
///
/// 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
}
private func isPlayingPhase(_ model: PlayerModel) -> Bool {
if case .playing = model.phase { return true }
return false
@@ -393,7 +418,7 @@ extension Duration {
}
extension PlayerQuality {
var label: LocalizedStringKey {
var localizedName: String.LocalizationValue {
switch self {
case .automatic: "quality.automatic"
case .original: "quality.original"