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:
co-authored by
Claude Opus 5
parent
b53fce65b8
commit
f628819765
+2785
-2753
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())")
|
||||
case .streamsUnknown:
|
||||
return String(localized: "technical.reason.unanalyzed")
|
||||
case .qualityChosen:
|
||||
return String(localized: "technical.reason.chosen")
|
||||
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -229,7 +229,13 @@ final class PlayerModel {
|
||||
server: server,
|
||||
startingAt: resumeAt
|
||||
) 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"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@ struct QualitySheet: View {
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@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)
|
||||
}
|
||||
@@ -24,23 +30,27 @@ struct QualitySheet: View {
|
||||
current
|
||||
|
||||
Section {
|
||||
row(for: .automatic)
|
||||
row(for: .original)
|
||||
ForEach(everyday) { option in
|
||||
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: {
|
||||
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"))
|
||||
.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 {
|
||||
Task { await model.select(quality: option) }
|
||||
dismiss()
|
||||
@@ -101,6 +111,11 @@ struct QualitySheet: View {
|
||||
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)
|
||||
@@ -116,7 +131,9 @@ struct QualitySheet: View {
|
||||
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)
|
||||
// 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 {
|
||||
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
|
||||
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
|
||||
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
|
||||
|
||||
@@ -32,6 +32,8 @@ public struct PlaybackDecision: Sendable, Equatable {
|
||||
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
|
||||
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
|
||||
case transcodingUnavailable
|
||||
/// Im Player ausdrücklich eine Stufe gewählt.
|
||||
case qualityChosen
|
||||
}
|
||||
|
||||
public let method: Method
|
||||
|
||||
@@ -14,15 +14,24 @@ public struct PlaybackPolicy: Sendable, Equatable {
|
||||
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
|
||||
/// Normalfall im heimischen WLAN.
|
||||
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(
|
||||
allowsTranscoding: Bool,
|
||||
allowsFallbackEngine: Bool = true,
|
||||
maximumBitrate: Int? = nil
|
||||
maximumBitrate: Int? = nil,
|
||||
forcesTranscoding: Bool = false
|
||||
) {
|
||||
self.allowsTranscoding = allowsTranscoding
|
||||
self.allowsFallbackEngine = allowsFallbackEngine
|
||||
self.maximumBitrate = maximumBitrate
|
||||
self.forcesTranscoding = forcesTranscoding
|
||||
}
|
||||
|
||||
/// Phase 1: alles direkt, nichts umrechnen.
|
||||
|
||||
@@ -134,11 +134,31 @@ public enum PlayerQuality: Hashable, Sendable, Identifiable {
|
||||
PlaybackPolicy(
|
||||
allowsTranscoding: true,
|
||||
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.
|
||||
///
|
||||
/// 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)
|
||||
#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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user