Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync

Der Decider brauchte keine Änderung — nur eine andere Richtlinie.
Genau dafür war er gebaut.

Transcoding
- TranscodeRequest baut die Parameter für /video/:/transcode/universal.
  directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt,
  statt pauschal neu zu kodieren.
- Vor dem Start wird der Server gefragt (decision); sagt er nein, steht
  der Grund im Klartext da statt eines schwarzen Bildes.
- Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal
  rechnet der Server minutenlang für niemanden weiter.
- Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst.

Qualitätseinstellungen
- Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen.
  Voreinstellung: zuhause Original, unterwegs 4 Mbit.
- NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch
  den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet.

Spuren und Marken
- Ton- und Untertitelwahl im Player, bevorzugte Sprache in den
  Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE).
- Intro- und Abspann-Marken mit Überspringen-Knopf, optional
  automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme
  man nach einem Rücksprung nie ins Intro zurück.
- Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden
  nicht vorgezogen.

Fortschritt
- Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen.
  Erst die nimmt den Titel aus „wird gerade abgespielt" heraus.
- markWatched/markUnwatched/rate/refreshLibrary stehen bereit.

86 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:24:17 +02:00
co-authored by Claude Opus 5
parent d0a84f35de
commit 55bcfe2fbf
13 changed files with 1512 additions and 53 deletions
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import Observation
import FlixyPlayback
/// Was der Nutzer über die Wiedergabe eingestellt hat.
///
/// Getrennt nach WLAN und Mobilfunk, weil sich die Anforderungen dort
/// grundlegend unterscheiden: im Heimnetz ist die Originaldatei das Beste,
/// unterwegs zählt das Datenvolumen.
@MainActor
@Observable
final class PlaybackSettings {
/// Die Stufen, die zur Auswahl stehen.
///
/// Bewusst wenige und in Schritten, die man spürt. Eine freie Eingabe in
/// Kilobit hilft niemandem bei der Frage „reicht das für heute Abend".
enum Quality: Int, CaseIterable, Identifiable, Sendable {
case original = 0
case high = 12_000
case medium = 8_000
case low = 4_000
case minimal = 2_000
var id: Int { rawValue }
/// `nil` heißt: keine Grenze, also die Originaldatei.
var bitrateLimit: Int? { self == .original ? nil : rawValue }
}
private enum Key {
static let wifiQuality = "flixy.playback.quality.wifi"
static let cellularQuality = "flixy.playback.quality.cellular"
static let autoSkipIntro = "flixy.playback.autoSkipIntro"
static let autoSkipCredits = "flixy.playback.autoSkipCredits"
static let autoPlayNext = "flixy.playback.autoPlayNext"
static let preferredAudioLanguage = "flixy.playback.audioLanguage"
static let preferredSubtitleLanguage = "flixy.playback.subtitleLanguage"
}
private let defaults: UserDefaults
var wifiQuality: Quality { didSet { defaults.set(wifiQuality.rawValue, forKey: Key.wifiQuality) } }
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
var preferredAudioLanguage: String { didSet { defaults.set(preferredAudioLanguage, forKey: Key.preferredAudioLanguage) } }
var preferredSubtitleLanguage: String { didSet { defaults.set(preferredSubtitleLanguage, forKey: Key.preferredSubtitleLanguage) } }
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
// Voreinstellung: im WLAN das Original, unterwegs 4 Mbit. Das ist die
// Aufteilung, die im Alltag am seltensten stört — zuhause die beste
// Qualität, unterwegs ein Wert, der auch bei mäßigem Empfang trägt.
self.wifiQuality = Quality(rawValue: defaults.object(forKey: Key.wifiQuality) as? Int ?? 0) ?? .original
self.cellularQuality = Quality(rawValue: defaults.object(forKey: Key.cellularQuality) as? Int ?? 4_000) ?? .low
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.preferredAudioLanguage = defaults.string(forKey: Key.preferredAudioLanguage) ?? ""
self.preferredSubtitleLanguage = defaults.string(forKey: Key.preferredSubtitleLanguage) ?? ""
}
/// Die Richtlinie für die aktuelle Lage.
///
/// Das ist die Stelle, an der Phase 2 greift: `allowsTranscoding` steht
/// jetzt auf `true`. Am Decider selbst musste dafür nichts geändert werden.
func policy(for conditions: NetworkConditions) -> PlaybackPolicy {
let quality = conditions.isExpensive || conditions.isConstrained
? cellularQuality
: wifiQuality
return PlaybackPolicy(
allowsTranscoding: true,
allowsFallbackEngine: true,
maximumBitrate: quality.bitrateLimit
)
}
}