Fünf Punkte aus dem Praxistest
iPad meldete sich nie an Die Ursache ist unangenehm speziell: Wer im Browser schon bei Plex angemeldet ist, wird von app.plex.tv/auth sofort auf watch.plex.tv weitergeschickt — OHNE dass der Code verknüpft wird. Man sieht sich angemeldet, die App wartet vergeblich, und es gibt keinerlei Hinweis. Jetzt wird plex.tv/link mit eingetragenem Code geöffnet. Diese Seite tut nur eine Sache: den Code bestätigen. Die Gerätekennung steckt ohnehin schon im PIN. Dazu ein Ausweg: bleibt es nach fünfzehn Sekunden ohne Token, erscheint der Code im Anmeldebildschirm zum Abtippen. Schließt man das Fenster ohne Erfolg, bleibt er stehen statt abzumelden — er gilt weiter. Download fragte nicht nach der Qualität Jetzt Original, Full HD, HD oder Sparsam, jeweils mit geschätzter Größe. Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein sagt nicht, ob es aufs Telefon passt. Die komprimierten Stufen laufen über den Universal-Transcoder und sind weiterhin NICHT am Server geprüft; steht so im Quelltext. Schwarzes Bild ohne Anzeige Der Player kannte nur „spielt" und „spielt nicht". Wartete er auf Daten, stand man vor einem Standbild ohne Hinweis. Es gibt jetzt einen eigenen Pufferzustand mit Anzeige. Aufmacher war beschnitten Er stand auf 3:4, Plex liefert als `art` aber 16:9 — mit scaledToFill ging links und rechts je ein Viertel verloren, ohne dass man es dem Bild ansah. Die Seitenverhältnisse liegen jetzt an einer Stelle in FlixyAspectRatio. Schwarze Leiste über dem Aufmacher .ultraThinMaterial statt undurchsichtigem Grund: das Bild läuft unscharf hinter der Leiste weiter, statt an einer harten Kante zu enden. Weichgezeichnet, nicht abgedunkelt. 139 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ struct DownloadButton: View {
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var refusal: DownloadRefusal?
|
||||
@State private var showsQualityChoice = false
|
||||
|
||||
private var existing: DownloadItem? {
|
||||
downloads.items.first { $0.ratingKey == item.ratingKey }
|
||||
@@ -34,6 +35,18 @@ struct DownloadButton: View {
|
||||
.buttonStyle(.bordered)
|
||||
.tint(existing?.state == .completed ? accent : Color.flixyTextSecondary)
|
||||
.accessibilityLabel(Text(label))
|
||||
.confirmationDialog(
|
||||
Text("downloads.quality.title"),
|
||||
isPresented: $showsQualityChoice,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
ForEach(DownloadQuality.allCases) { quality in
|
||||
Button(qualityLabel(quality)) { enqueue(quality: quality) }
|
||||
}
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("downloads.quality.hint")
|
||||
}
|
||||
.alert(
|
||||
Text("downloads.refused.title"),
|
||||
isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })
|
||||
@@ -66,7 +79,10 @@ struct DownloadButton: View {
|
||||
|
||||
private func act() {
|
||||
guard let existing else {
|
||||
enqueue()
|
||||
// Vor dem Laden fragen. Der Unterschied zwischen einem
|
||||
// 40-GB-Remux und 2 GB in 720p ist auf einem Telefon keine
|
||||
// Nebensache — und hinterher lässt er sich nicht mehr ändern.
|
||||
showsQualityChoice = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -83,14 +99,48 @@ struct DownloadButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue() {
|
||||
private func enqueue(quality: DownloadQuality) {
|
||||
let service = DownloadService(server: session.server, coordinator: downloads)
|
||||
do {
|
||||
_ = try service.download(item, mediaIndex: mediaIndex)
|
||||
_ = try service.download(item, mediaIndex: mediaIndex, quality: quality)
|
||||
} catch {
|
||||
refusal = error
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Beschriftung samt geschätzter Größe.
|
||||
///
|
||||
/// Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein
|
||||
/// sagt nichts darüber, ob es aufs Gerät passt.
|
||||
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 bytes = quality.estimatedBytes(
|
||||
durationMilliseconds: item.duration,
|
||||
originalBytes: originalBytes
|
||||
)
|
||||
|
||||
let name = String(localized: quality.localizedName)
|
||||
guard bytes > 0 else { return name }
|
||||
|
||||
let size = bytes.formatted(.byteCount(style: .file))
|
||||
return quality == .original
|
||||
? "\(name) · \(size)"
|
||||
: String(localized: "downloads.quality.approx \(name) \(size)")
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadQuality {
|
||||
var localizedName: String.LocalizationValue {
|
||||
switch self {
|
||||
case .original: "quality.original"
|
||||
case .fullHD: "quality.fullHD"
|
||||
case .hd: "quality.hd"
|
||||
case .low: "quality.lowRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Knopf für eine ganze Staffel oder Serie.
|
||||
|
||||
@@ -39,6 +39,15 @@ struct HomeView: View {
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Branding.appName)
|
||||
.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.
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
.toolbar { SearchToolbarItem() }
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
.task(id: session.server?.name) { await load() }
|
||||
|
||||
@@ -27,7 +27,9 @@ struct RootView: View {
|
||||
case .signedOut, .authenticating:
|
||||
SignInView(
|
||||
phase: session.phase,
|
||||
onSignIn: { Task { await session.beginSignIn() } }
|
||||
manualCode: session.showsManualCode ? session.pendingCode : nil,
|
||||
onSignIn: { Task { await session.beginSignIn() } },
|
||||
onCancel: { session.cancelSignIn() }
|
||||
)
|
||||
|
||||
case .choosingServer(let servers):
|
||||
|
||||
@@ -29,6 +29,11 @@ final class PlayerModel {
|
||||
private(set) var currentTime: Duration = .zero
|
||||
private(set) var duration: Duration?
|
||||
private(set) var isPlaying = false
|
||||
/// Läuft eigentlich, wartet aber auf Daten.
|
||||
///
|
||||
/// Ohne diese Unterscheidung stand man vor einem schwarzen Bild ohne
|
||||
/// jeden Hinweis: der Player galt als „spielt", nur kam noch nichts an.
|
||||
private(set) var isBuffering = false
|
||||
|
||||
private(set) var audioTracks: [MediaTrack] = []
|
||||
private(set) var subtitleTracks: [MediaTrack] = []
|
||||
@@ -447,6 +452,7 @@ final class PlayerModel {
|
||||
switch event {
|
||||
case .stateChanged(let state):
|
||||
isPlaying = state == .playing
|
||||
isBuffering = state == .buffering || state == .loading
|
||||
// Jeder Zustandswechsel mit Zeitstempel. Erst daran ist
|
||||
// abzulesen, ob der Motor hängt oder die Leitung.
|
||||
diagnostics.note("Zustand: \(state)")
|
||||
|
||||
@@ -38,6 +38,15 @@ struct PlayerView: View {
|
||||
failure(message: message)
|
||||
|
||||
case .playing:
|
||||
// Wartet auf Daten: ohne Anzeige sieht das aus wie ein
|
||||
// Standbild, und man weiß nicht, ob noch etwas kommt.
|
||||
if model.isBuffering {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(1.4)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showsControls {
|
||||
controls(for: model).transition(.opacity)
|
||||
}
|
||||
@@ -63,6 +72,7 @@ struct PlayerView: View {
|
||||
// Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind.
|
||||
// Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen —
|
||||
// und im Querformat erst recht nicht.
|
||||
.animation(.easeInOut(duration: 0.2), value: model?.isBuffering)
|
||||
.statusBarHidden(true)
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.landscapeWhileVisible()
|
||||
|
||||
@@ -9,7 +9,10 @@ import PlexKit
|
||||
/// erreicht Flixy nie. Nach dem Anmelden schließt sich das Fenster von selbst.
|
||||
struct SignInView: View {
|
||||
let phase: FlixySession.Phase
|
||||
/// Der Code der laufenden Anmeldung, sobald er gezeigt werden soll.
|
||||
let manualCode: String?
|
||||
let onSignIn: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@@ -55,10 +58,14 @@ struct SignInView: View {
|
||||
.tint(accent)
|
||||
.disabled(isAuthenticating)
|
||||
|
||||
Text("signIn.privacy")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
if let manualCode {
|
||||
manualFallback(code: manualCode)
|
||||
} else {
|
||||
Text("signIn.privacy")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer().frame(height: FlixySpacing.section)
|
||||
@@ -68,4 +75,35 @@ struct SignInView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flixyCanvas)
|
||||
}
|
||||
|
||||
/// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft.
|
||||
///
|
||||
/// Das passiert, wenn man dort schon bei Plex angemeldet ist: die Seite
|
||||
/// springt weiter, ohne zu verknüpfen, und die App wartete vergeblich.
|
||||
/// Der Code gilt weiter — abtippen führt immer zum Ziel.
|
||||
@ViewBuilder
|
||||
private func manualFallback(code: String) -> some View {
|
||||
VStack(spacing: FlixySpacing.regular) {
|
||||
Divider().overlay(Color.flixySeparator)
|
||||
|
||||
Text("signIn.manualHint")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(code)
|
||||
.font(.system(.title, design: .monospaced, weight: .semibold))
|
||||
.tracking(6)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control))
|
||||
|
||||
Button("action.cancel", action: onCancel)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user