Player: eine gewählte Stufe wurde gar nicht umgerechnet

Der Decider rechnet nur um, wenn die Datei die Grenze überschreitet. Für
den Normalfall ist das richtig — für eine ausdrückliche Wahl im Player
nicht: wer für eine 8-Mbit-Datei „12 Mbit" wählt, bekam DirectPlay, weil
die Datei ja darunter liegt. Gemeint ist aber „rechne es mir auf diese
Stufe um", nicht „gib mir die Datei, falls sie so vorliegt".

Die Richtlinie kann eine Umrechnung jetzt erzwingen, und die Wahl im
Player tut das.

Dazu verschluckte `select(quality:)` den eigentlichen Grund: lehnte der
Server ab und sagte im Klartext warum, wurde diese Meldung danach durch
das allgemeine „keine abspielbare Datei" ersetzt — der eine brauchbare
Hinweis war damit weg. Jetzt bleibt stehen, was der Server gesagt hat.

Und das Menü war zu viel. Vorn steht die kurze Liste — automatisch,
Original, je eine brauchbare Stufe pro Auflösung, nur Ton. Wer eine
bestimmte Bitrate erzwingen muss, klappt „Alle Stufen" auf; im Alltag
will man „eine Stufe kleiner" und nicht die Wahl zwischen zehn und zwölf
Megabit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 22:05:27 +02:00
co-authored by Claude Opus 5
parent b53fce65b8
commit f628819765
9 changed files with 2928 additions and 2771 deletions
File diff suppressed because it is too large Load Diff
@@ -119,6 +119,8 @@ struct TechnicalDetails: View {
return String(localized: "technical.reason.audioCodec \(codec.uppercased())") return String(localized: "technical.reason.audioCodec \(codec.uppercased())")
case .streamsUnknown: case .streamsUnknown:
return String(localized: "technical.reason.unanalyzed") return String(localized: "technical.reason.unanalyzed")
case .qualityChosen:
return String(localized: "technical.reason.chosen")
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable: case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
continue continue
} }
+6
View File
@@ -229,7 +229,13 @@ final class PlayerModel {
server: server, server: server,
startingAt: resumeAt startingAt: resumeAt
) else { ) else {
// Nur dann eine allgemeine Meldung, wenn `source` keine eigene
// hinterlassen hat. Vorher stand hier immer „keine abspielbare
// Datei", auch wenn der Server im Klartext gesagt hatte, warum er
// ablehnt — und damit war der eine brauchbare Hinweis weg.
if case .failed = phase {} else {
phase = .failed(message: String(localized: "player.noPlayableFile")) phase = .failed(message: String(localized: "player.noPlayableFile"))
}
return return
} }
+32 -15
View File
@@ -14,6 +14,12 @@ struct QualitySheet: View {
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showsEveryRung = false
private var everyday: [PlayerQuality] {
PlayerQuality.everyday(forSourceHeight: model.sourceHeight)
}
private var options: [PlayerQuality] { private var options: [PlayerQuality] {
PlayerQuality.available(forSourceHeight: model.sourceHeight) PlayerQuality.available(forSourceHeight: model.sourceHeight)
} }
@@ -24,23 +30,27 @@ struct QualitySheet: View {
current current
Section { Section {
row(for: .automatic) ForEach(everyday) { option in
row(for: .original) row(for: option)
} }
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: { } footer: {
Text("quality.audioOnly.hint") 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")) .navigationTitle(Text("player.quality"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -93,7 +103,7 @@ struct QualitySheet: View {
} }
} }
private func row(for option: PlayerQuality) -> some View { private func row(for option: PlayerQuality, detail: String? = nil) -> some View {
Button { Button {
Task { await model.select(quality: option) } Task { await model.select(quality: option) }
dismiss() dismiss()
@@ -101,6 +111,11 @@ struct QualitySheet: View {
HStack { HStack {
Text(label(for: option)) Text(label(for: option))
.foregroundStyle(Color.flixyTextPrimary) .foregroundStyle(Color.flixyTextPrimary)
if let detail {
Text(detail)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
Spacer() Spacer()
if option == model.quality { if option == model.quality {
Image(systemName: "checkmark").foregroundStyle(accent) Image(systemName: "checkmark").foregroundStyle(accent)
@@ -116,7 +131,9 @@ struct QualitySheet: View {
case .automatic: String(localized: "quality.automatic") case .automatic: String(localized: "quality.automatic")
case .original: String(localized: "quality.original") case .original: String(localized: "quality.original")
case .audioOnly: String(localized: "quality.audioOnly") case .audioOnly: String(localized: "quality.audioOnly")
case .limited(let rung): bitrateText(rung.bitrate) // 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))"
} }
} }
@@ -21,6 +21,18 @@ public struct PlaybackDecider: Sendable {
) -> PlaybackDecision { ) -> PlaybackDecision {
var rationale: [PlaybackDecision.Rationale] = [] var rationale: [PlaybackDecision.Rationale] = []
// 0. Hat jemand ausdrücklich eine Stufe gewählt, wird umgerechnet —
// auch wenn die Datei die Grenze gar nicht überschreitet. Sonst
// bliebe die Wahl folgenlos: für eine 8-Mbit-Datei „12 Mbit" zu
// wählen ergäbe DirectPlay, und am Bild änderte sich nichts.
if policy.forcesTranscoding, policy.allowsTranscoding {
rationale.append(.qualityChosen)
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom // 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine // Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte. // gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
@@ -32,6 +32,8 @@ public struct PlaybackDecision: Sendable, Equatable {
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int) case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar. /// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
case transcodingUnavailable case transcodingUnavailable
/// Im Player ausdrücklich eine Stufe gewählt.
case qualityChosen
} }
public let method: Method public let method: Method
@@ -14,15 +14,24 @@ public struct PlaybackPolicy: Sendable, Equatable {
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der /// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
/// Normalfall im heimischen WLAN. /// Normalfall im heimischen WLAN.
public var maximumBitrate: Int? public var maximumBitrate: Int?
/// Umrechnen, auch wenn die Datei die Grenze gar nicht überschreitet.
///
/// Für die Wahl im Player. Ohne das verpuffte sie: wer für eine
/// 8-Mbit-Datei „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja
/// unter der Grenze liegt — die Wahl blieb folgenlos. Gemeint ist aber
/// „rechne es mir auf diese Stufe um".
public var forcesTranscoding: Bool
public init( public init(
allowsTranscoding: Bool, allowsTranscoding: Bool,
allowsFallbackEngine: Bool = true, allowsFallbackEngine: Bool = true,
maximumBitrate: Int? = nil maximumBitrate: Int? = nil,
forcesTranscoding: Bool = false
) { ) {
self.allowsTranscoding = allowsTranscoding self.allowsTranscoding = allowsTranscoding
self.allowsFallbackEngine = allowsFallbackEngine self.allowsFallbackEngine = allowsFallbackEngine
self.maximumBitrate = maximumBitrate self.maximumBitrate = maximumBitrate
self.forcesTranscoding = forcesTranscoding
} }
/// Phase 1: alles direkt, nichts umrechnen. /// Phase 1: alles direkt, nichts umrechnen.
@@ -134,11 +134,31 @@ public enum PlayerQuality: Hashable, Sendable, Identifiable {
PlaybackPolicy( PlaybackPolicy(
allowsTranscoding: true, allowsTranscoding: true,
allowsFallbackEngine: false, allowsFallbackEngine: false,
maximumBitrate: maximumBitrate maximumBitrate: maximumBitrate,
forcesTranscoding: true
) )
} }
} }
/// Die kurze Liste: je Auflösung eine Stufe, und zwar eine brauchbare.
///
/// Dreizehn Sprossen sind für den Normalfall zu viel — man will „geht
/// gerade nicht flüssig, nimm eine Stufe kleiner", nicht die Wahl
/// zwischen zehn und zwölf Megabit. Die vollständige Leiter bleibt
/// erreichbar, steht aber nicht im Weg.
public static let everyday: [Rung] = [
Rung(bitrate: 8_000, height: 1080),
Rung(bitrate: 3_000, height: 720),
Rung(bitrate: 720, height: 480),
]
/// Die kurze Auswahl für eine Quelle dieser Höhe.
public static func everyday(forSourceHeight height: Int?) -> [PlayerQuality] {
let rungs = everyday.filter { height == nil || $0.height <= (height ?? 0) }
let usable = rungs.isEmpty ? Array(everyday.suffix(1)) : rungs
return [.automatic, .original] + usable.map(PlayerQuality.limited) + [.audioOnly]
}
/// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind. /// Nur die Stufen, die für eine Quelle dieser Höhe sinnvoll sind.
/// ///
/// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und /// Eine 720p-Datei auf 1080p „hochzurechnen" bringt kein Bild dazu, und
@@ -170,4 +170,61 @@ struct PlaybackDeciderTests {
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy) let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.engine == .avPlayer) #expect(decision.engine == .avPlayer)
} }
@Test("Eine gewählte Stufe wird umgerechnet, auch wenn die Datei darunter liegt")
func honoursAnExplicitChoice() {
// Ohne das verpuffte die Wahl im Player: wer für eine 8-Mbit-Datei
// „12 Mbit" wählt, bekam DirectPlay, weil die Datei ja unter der
// Grenze liegt. Gemeint ist aber „rechne es mir auf diese Stufe um".
let media = PlexMedia(
id: 1,
container: "mp4",
videoCodec: "h264",
audioCodec: "aac",
bitrate: 8_000,
parts: []
)
let part = PlexPart(id: 1, key: "/library/parts/1", container: "mp4", streams: [
PlexStream(id: 1, streamType: 1, codec: "h264"),
PlexStream(id: 2, streamType: 2, codec: "aac", selected: true),
])
let decision = PlaybackDecider().decide(
media: media,
part: part,
policy: PlaybackPolicy(
allowsTranscoding: true,
allowsFallbackEngine: false,
maximumBitrate: 12_000,
forcesTranscoding: true
)
)
#expect(decision.method == .transcode(maximumBitrate: 12_000))
#expect(decision.rationale.contains(.qualityChosen))
}
@Test("Ohne ausdrückliche Wahl bleibt es bei DirectPlay")
func leavesAutomaticAlone() {
let media = PlexMedia(
id: 1,
container: "mp4",
videoCodec: "h264",
audioCodec: "aac",
bitrate: 8_000,
parts: []
)
let part = PlexPart(id: 1, key: "/library/parts/1", container: "mp4", streams: [
PlexStream(id: 1, streamType: 1, codec: "h264"),
PlexStream(id: 2, streamType: 2, codec: "aac", selected: true),
])
let decision = PlaybackDecider().decide(
media: media,
part: part,
policy: PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 12_000)
)
#expect(decision.method == .directPlay(engine: .avPlayer))
}
} }