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
+7
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
@main
@@ -10,6 +11,10 @@ struct FlixyApp: App {
@State private var theme = FlixyTheme()
@State private var session: FlixySession
@State private var playback = PlaybackCoordinator()
@State private var playbackSettings = PlaybackSettings()
/// Beobachtet WLAN und Mobilfunk. Lebt hier, damit der Beobachter einmal
/// läuft statt bei jedem Öffnen des Players neu zu starten.
@State private var network = NetworkConditions()
init() {
let identity = PlexClientIdentity.current(
@@ -27,6 +32,8 @@ struct FlixyApp: App {
RootView()
.environment(session)
.environment(playback)
.environment(playbackSettings)
.environment(network)
.environment(theme)
.flixyAccent(theme)
// Der eigentliche Schalter für „nur dunkel" steht in der
+337
View File
@@ -1839,6 +1839,343 @@
}
}
}
},
"player.tracks": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ton und Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio and subtitles"
}
}
}
},
"player.audioTracks": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Tonspur"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio"
}
}
}
},
"player.subtitleTracks": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"player.subtitlesOff": {
"comment": "Eintrag, der die Untertitel abschaltet",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Aus"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Off"
}
}
}
},
"player.skipIntro": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Intro überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip intro"
}
}
}
},
"player.skipCredits": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Abspann überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip credits"
}
}
}
},
"player.nextEpisode": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nächste Folge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next episode"
}
}
}
},
"player.transcodeRefused": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat die Wiedergabe abgelehnt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server refused playback."
}
}
}
},
"settings.quality": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Qualität"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Quality"
}
}
}
},
"settings.qualityWifi": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Im WLAN"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "On Wi-Fi"
}
}
}
},
"settings.qualityCellular": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Über Mobilfunk"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "On cellular"
}
}
}
},
"settings.qualityExplanation": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection."
}
}
}
},
"settings.playback": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wiedergabe"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback"
}
}
}
},
"settings.autoSkipIntro": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Intro automatisch überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip intros automatically"
}
}
}
},
"settings.autoSkipCredits": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Abspann automatisch überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip credits automatically"
}
}
}
},
"settings.autoPlayNext": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nächste Folge automatisch"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Play next episode automatically"
}
}
}
},
"quality.original": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Original"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Original"
}
}
}
},
"quality.high": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Hoch (12 Mbit/s)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "High (12 Mbps)"
}
}
}
},
"quality.medium": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mittel (8 Mbit/s)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Medium (8 Mbps)"
}
}
}
},
"quality.low": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Niedrig (4 Mbit/s)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Low (4 Mbps)"
}
}
}
},
"quality.minimal": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Sparsam (2 Mbit/s)"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Data saver (2 Mbps)"
}
}
}
}
},
"version": "1.1"
+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
)
}
}
+226 -25
View File
@@ -7,9 +7,9 @@ import PlexKit
/// Führt einen Abspielwunsch aus.
///
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
/// an der die Bildfläche gebaut wird.
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
/// Bildfläche gebaut wird.
@MainActor
@Observable
final class PlayerModel {
@@ -29,16 +29,51 @@ final class PlayerModel {
private(set) var duration: Duration?
private(set) var isPlaying = false
private(set) var audioTracks: [MediaTrack] = []
private(set) var subtitleTracks: [MediaTrack] = []
private(set) var selectedAudioTrack: MediaTrack?
private(set) var selectedSubtitleTrack: MediaTrack?
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: PlexMarker?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: PlexItem?
private(set) var hasReachedEnd = false
private let request: PlaybackRequest
private let server: PlexServer?
private var eventTask: Task<Void, Never>?
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
private let isLocalConnection: Bool
init(request: PlaybackRequest, server: PlexServer?) {
private var item: PlexItem?
private var eventTask: Task<Void, Never>?
private var reportTask: Task<Void, Never>?
private var transcodeSession: String?
/// Marken, die bereits automatisch übersprungen wurden. Ohne dieses
/// Gedächtnis würde ein Rücksprung ins Intro es sofort wieder wegspringen
/// lassen — und man käme nie zurück.
private var skippedMarkers: Set<Int> = []
init(
request: PlaybackRequest,
server: PlexServer?,
policy: PlaybackPolicy,
settings: PlaybackSettings,
isLocalConnection: Bool
) {
self.request = request
self.server = server
self.policy = policy
self.settings = settings
self.isLocalConnection = isLocalConnection
self.title = request.title
}
// MARK: - Start
func start() async {
guard let server else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
@@ -53,39 +88,76 @@ final class PlayerModel {
return
}
self.item = item
title = item.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item),
let part = media.parts.first,
let url = server.directPlayURL(part: part)
else {
guard let media = version(of: item), let part = media.parts.first else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
self.decision = decision
guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
let engine = makeEngine(for: decision)
self.engine = engine
observe(engine)
do {
try await engine.load(PlaybackSource(
url: url,
origin: .remote,
startTime: request.startTime
))
try await engine.load(source)
engine.play()
phase = .playing
startReporting()
preloadNextEpisode()
} catch {
phase = .failed(message: String(localized: "player.loadFailed"))
}
}
/// Baut die Quelle — entweder die Datei selbst oder den HLS-Strom.
private func source(
for decision: PlaybackDecision,
item: PlexItem,
media: PlexMedia,
part: PlexPart,
server: PlexServer
) async -> PlaybackSource? {
switch decision.method {
case .directPlay:
guard let url = server.directPlayURL(part: part) else { return nil }
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
case .transcode(let maximumBitrate):
let transcode = TranscodeRequest(
ratingKey: item.ratingKey,
mediaIndex: request.mediaIndex,
maximumBitrate: maximumBitrate,
isLocal: isLocalConnection
)
transcodeSession = transcode.sessionIdentifier
// Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier
// schon nein, steht der Grund im Klartext da — statt eines
// schwarzen Bildes nach zwanzig Sekunden Warten.
if let decision = try? await server.transcodeDecision(for: transcode),
!decision.isAllowed {
phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused"))
return nil
}
guard let url = server.transcodeURL(for: transcode) else { return nil }
return PlaybackSource(url: url, origin: .remote, startTime: request.startTime)
}
}
// MARK: - Steuerung
func togglePlayPause() {
guard let engine else { return }
if isPlaying { engine.pause() } else { engine.play() }
@@ -99,16 +171,114 @@ final class PlayerModel {
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
func skip(by offset: Duration) {
guard let duration else { return }
let target = currentTime + offset
seek(to: max(.zero, min(target, duration)))
seek(to: max(.zero, min(currentTime + offset, duration)))
}
func stop() {
/// Springt ans Ende der laufenden Marke.
func skipActiveMarker() {
guard let marker = activeMarker else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endTimeOffset))
}
func selectAudioTrack(_ track: MediaTrack) {
engine?.selectAudioTrack(track)
selectedAudioTrack = track
}
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?) {
engine?.selectSubtitleTrack(track)
selectedSubtitleTrack = track
}
func stop() async {
eventTask?.cancel()
reportTask?.cancel()
// Letzte Meldung vor dem Verlassen: erst sie nimmt den Titel auf dem
// Server aus „wird gerade abgespielt" heraus.
await report(state: .stopped)
// Den Transcoder anhalten. Ohne dieses Signal rechnet der Server
// minutenlang weiter, obwohl niemand mehr zusieht.
if let transcodeSession, let server {
await server.stopTranscode(sessionIdentifier: transcodeSession)
}
engine?.teardown()
engine = nil
}
// MARK: - Fortschritt melden
private func startReporting() {
reportTask?.cancel()
reportTask = Task { [weak self] in
while !Task.isCancelled {
// Alle zehn Sekunden — so oft melden es auch die Apps von Plex
// selbst. Häufiger brächte nichts, seltener verlöre beim
// Abwürgen der App zu viel.
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled else { return }
await report(state: isPlaying ? .playing : .paused)
}
}
}
private func report(state: PlaybackReportState) async {
guard let server, let item, let duration, currentTime > .zero else { return }
await server.reportProgress(
ratingKey: item.ratingKey,
state: state,
time: currentTime,
duration: duration
)
}
// MARK: - Marken und nächste Folge
private func updateActiveMarker() {
guard let item else { return }
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else {
return false
}
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
if marker.isCredits && item.kind != .episode { return false }
return true
}
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
|| (marker.isCredits && settings.autoSkipCredits)
if shouldSkip { skipActiveMarker() }
}
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let server else { return }
Task { [weak self] in
let next = try? await server.nextEpisode(after: item)
self?.nextEpisode = next
}
}
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? {
guard let nextEpisode else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.ratingKey,
title: nextEpisode.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
}
// MARK: - Innereien
private func version(of item: PlexItem) -> PlexMedia? {
@@ -119,7 +289,8 @@ final class PlayerModel {
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS, und
// genau darin wechselt AVPlayer die Bitrate von selbst.
case .avPlayer, .none: AVPlayerEngine()
}
}
@@ -137,19 +308,36 @@ final class PlayerModel {
}
case .timeChanged(let time):
currentTime = time
updateActiveMarker()
case .durationResolved(let value):
duration = value
case .reachedEnd:
isPlaying = false
case .tracksResolved:
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
// schon — die Ansicht bietet sie nur noch nicht an.
break
hasReachedEnd = true
case .tracksResolved(let audio, let subtitle):
audioTracks = audio
subtitleTracks = subtitle
applyPreferredLanguages()
}
}
}
}
/// Wählt die bevorzugten Sprachen, sofern es sie gibt.
///
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() {
if selectedAudioTrack == nil, !settings.preferredAudioLanguage.isEmpty,
let match = audioTracks.first(where: { $0.matches(language: settings.preferredAudioLanguage) }) {
selectAudioTrack(match)
}
if selectedSubtitleTrack == nil, !settings.preferredSubtitleLanguage.isEmpty,
let match = subtitleTracks.first(where: { $0.matches(language: settings.preferredSubtitleLanguage) }) {
selectSubtitleTrack(match)
}
}
private func playerSubtitle(for item: PlexItem) -> String? {
guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index {
@@ -158,3 +346,16 @@ final class PlayerModel {
return item.title
}
}
extension MediaTrack {
/// Ob die Spur zu einem Sprachcode passt.
///
/// Verglichen wird nachsichtig: Plex meldet mal `de`, mal `deu`, mal
/// `ger`, und AVFoundation liefert manchmal `de-DE`. Ein strenger
/// Vergleich fände die Spur in der Hälfte der Fälle nicht.
func matches(language code: String) -> Bool {
guard let languageCode, !code.isEmpty else { return false }
let normalized = languageCode.lowercased().prefix(2)
return normalized == code.lowercased().prefix(2)
}
}
+93 -24
View File
@@ -9,10 +9,13 @@ struct PlayerView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@State private var showsControls = true
@State private var showsTrackPicker = false
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
@@ -33,15 +36,18 @@ struct PlayerView: View {
case .playing:
if showsControls {
controls(for: model)
.transition(.opacity)
controls(for: model).transition(.opacity)
}
// Überspringen und „Nächste Folge" liegen außerhalb der
// Bedienelemente: sie sollen auch dann erscheinen, wenn
// die ausgeblendet sind — sonst müsste man erst antippen,
// um das Intro überspringen zu können.
overlayActions(for: model)
}
// Die Kopfzeile mit dem Schließen-Knopf liegt über allem und
// erscheint auch, solange noch geladen wird. Ohne sie sitzt
// man bei einer langsamen Verbindung fest: kein Zurück, kein
// Abbrechen, nur ein Ladekreis.
// Die Kopfzeile liegt über allem und erscheint auch während
// des Ladens. Ohne sie sitzt man bei langsamer Verbindung
// fest: kein Zurück, kein Abbrechen, nur ein Ladekreis.
if showsControls || !isPlayingPhase(model) {
VStack {
topBar(for: model)
@@ -51,21 +57,33 @@ struct PlayerView: View {
}
}
}
.statusBarHidden(showsControls == false)
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
.statusBarHidden(!showsControls)
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
.contentShape(.rect)
.onTapGesture { toggleControls() }
.sheet(isPresented: $showsTrackPicker) {
if let model { TrackPickerSheet(model: model) }
}
.task {
let model = PlayerModel(request: request, server: session.server)
let model = PlayerModel(
request: request,
server: session.server,
policy: settings.policy(for: network),
settings: settings,
isLocalConnection: session.server?.connection.local ?? false
)
self.model = model
await model.start()
scheduleControlsHide()
}
.onDisappear {
hideTask?.cancel()
model?.stop()
// Losgelöst von der Ansicht: das Anhalten des Transcoders und die
// Schlussmeldung sollen auch dann durchlaufen, wenn die Ansicht
// schon verschwunden ist.
if let model {
Task { await model.stop() }
}
}
}
@@ -83,14 +101,14 @@ struct PlayerView: View {
.padding(FlixySpacing.loose)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
// wirkt.
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
// verfälscht wirkt.
Color.black.opacity(0.35).ignoresSafeArea()
}
}
private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top) {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
Button {
playback.dismiss()
} label: {
@@ -114,6 +132,18 @@ struct PlayerView: View {
Spacer()
if !model.audioTracks.isEmpty || !model.subtitleTracks.isEmpty {
Button {
showsTrackPicker = true
hideTask?.cancel()
} label: {
Image(systemName: "captions.bubble")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.tracks"))
}
if let decision = model.decision {
PlaybackMethodBadge(decision: decision)
}
@@ -173,10 +203,10 @@ struct PlayerView: View {
.tint(accent)
HStack {
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
Text(displayTime(for: model).timecode)
Spacer()
if let duration = model.duration {
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
Text("−" + (duration - displayTime(for: model)).timecode)
}
}
.font(.system(.caption, design: .monospaced))
@@ -184,11 +214,53 @@ struct PlayerView: View {
}
}
/// Während geladen wird.
///
/// Zeigt den Titel mit an, damit erkennbar bleibt, worauf gewartet wird —
/// und weil ein nackter Ladekreis nach zehn Sekunden wie ein Absturz
/// aussieht.
private func displayTime(for model: PlayerModel) -> Duration {
scrubbing.map(Duration.seconds) ?? model.currentTime
}
/// Überspringen-Knopf und „Nächste Folge".
@ViewBuilder
private func overlayActions(for model: PlayerModel) -> some View {
VStack {
Spacer()
HStack {
Spacer()
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
Button {
if let next = model.nextEpisodeRequest() {
playback.request = next
}
} label: {
Label("player.nextEpisode", systemImage: "forward.end.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
} else if let marker = model.activeMarker {
Button {
model.skipActiveMarker()
} label: {
Text(marker.isIntro ? "player.skipIntro" : "player.skipCredits")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.bordered)
.tint(.white)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.padding(.horizontal, FlixySpacing.section)
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind.
.padding(.bottom, showsControls ? 110 : FlixySpacing.section)
}
.animation(.snappy, value: model.activeMarker?.id)
.animation(.snappy, value: model.hasReachedEnd)
}
private func preparing(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.loose) {
ProgressView().tint(.white)
@@ -245,9 +317,6 @@ struct PlayerView: View {
}
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
///
/// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob
/// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag.
struct PlaybackMethodBadge: View {
let decision: PlaybackDecision
+80
View File
@@ -0,0 +1,80 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
/// Auswahl von Ton- und Untertitelspur.
struct TrackPickerSheet: View {
let model: PlayerModel
@Environment(\.dismiss) private var dismiss
@Environment(\.flixyAccent) private var accent
var body: some View {
NavigationStack {
List {
if !model.audioTracks.isEmpty {
Section("player.audioTracks") {
ForEach(model.audioTracks) { track in
row(
title: track.displayName,
isSelected: model.selectedAudioTrack == track
) {
model.selectAudioTrack(track)
}
}
}
}
if !model.subtitleTracks.isEmpty {
Section("player.subtitleTracks") {
// „Aus" gehört in dieselbe Liste wie die Spuren, nicht
// als eigener Schalter daneben: es ist eine Auswahl
// unter mehreren, keine getrennte Eigenschaft.
row(
title: String(localized: "player.subtitlesOff"),
isSelected: model.selectedSubtitleTrack == nil
) {
model.selectSubtitleTrack(nil)
}
ForEach(model.subtitleTracks) { track in
row(
title: track.displayName,
isSelected: model.selectedSubtitleTrack == track
) {
model.selectSubtitleTrack(track)
}
}
}
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("player.tracks"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.presentationDetents([.medium])
}
private func row(title: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Text(title)
.foregroundStyle(Color.flixyTextPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark").foregroundStyle(accent)
}
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.listRowBackground(Color.flixySurface)
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
}
}
+37
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Die Einstellungen.
@@ -9,6 +10,7 @@ import PlexKit
/// es das Geregelte auch gibt.
struct SettingsView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackSettings.self) private var settings
@State private var showsSignOutConfirmation = false
var body: some View {
@@ -29,6 +31,29 @@ struct SettingsView: View {
}
}
Section {
Picker("settings.qualityWifi", selection: Bindable(settings).wifiQuality) {
ForEach(PlaybackSettings.Quality.allCases) { quality in
Text(quality.label).tag(quality)
}
}
Picker("settings.qualityCellular", selection: Bindable(settings).cellularQuality) {
ForEach(PlaybackSettings.Quality.allCases) { quality in
Text(quality.label).tag(quality)
}
}
} header: {
Text("settings.quality")
} footer: {
Text("settings.qualityExplanation")
}
Section("settings.playback") {
Toggle("settings.autoSkipIntro", isOn: Bindable(settings).autoSkipIntro)
Toggle("settings.autoSkipCredits", isOn: Bindable(settings).autoSkipCredits)
Toggle("settings.autoPlayNext", isOn: Bindable(settings).autoPlayNext)
}
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
}
@@ -63,3 +88,15 @@ struct SettingsView: View {
return String(localized: "settings.connectionRemote")
}
}
extension PlaybackSettings.Quality {
var label: LocalizedStringKey {
switch self {
case .original: "quality.original"
case .high: "quality.high"
case .medium: "quality.medium"
case .low: "quality.low"
case .minimal: "quality.minimal"
}
}
}
@@ -0,0 +1,38 @@
import Foundation
import Network
import Observation
/// Beobachtet, worüber das Gerät gerade am Netz hängt.
///
/// Die Unterscheidung WLAN/Mobilfunk entscheidet, welche Bitratengrenze gilt.
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert deckt
/// auch den Fall ab, dass das iPhone am persönlichen Hotspot eines anderen
/// Geräts hängt — technisch WLAN, in der Abrechnung Mobilfunk.
@MainActor
@Observable
public final class NetworkConditions {
public private(set) var isReachable = true
/// Mobilfunk oder Hotspot — alles, was Datenvolumen kostet.
public private(set) var isExpensive = false
/// Der Sparmodus für Daten ist an. Dann sollte die App von sich aus
/// sparsam sein, auch im WLAN.
public private(set) var isConstrained = false
private let monitor = NWPathMonitor()
public init() {
monitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor [weak self] in
guard let self else { return }
isReachable = path.status == .satisfied
isExpensive = path.isExpensive
isConstrained = path.isConstrained
}
}
monitor.start(queue: DispatchQueue(label: "flixy.network"))
}
deinit {
monitor.cancel()
}
}
@@ -0,0 +1,191 @@
import Foundation
extension PlexServer {
// MARK: - Transcoding
/// Die Adresse des HLS-Stroms für diese Wiedergabe.
///
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
public nonisolated func transcodeURL(for request: TranscodeRequest) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
resolvingAgainstBaseURL: false
)
else { return nil }
components.queryItems = request.queryItems + [
URLQueryItem(name: "X-Plex-Token", value: token),
] + clientProfileItems
return components.url
}
/// Fragt den Server, ob er diese Wiedergabe zulässt.
public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision {
try await client.send(
PlexRequest(
path: "/video/:/transcode/universal/decision",
query: request.queryItems + clientProfileItems
),
as: TranscodeDecision.self
)
}
/// Hält den Transcoder an.
///
/// Muss beim Verlassen des Players laufen. Ohne dieses Signal rechnet der
/// Server weiter, bis er von selbst merkt, dass niemand mehr abruft — das
/// dauert Minuten und kostet die ganze Zeit über Rechenleistung.
public func stopTranscode(sessionIdentifier: String) async {
_ = try? await client.send(
PlexRequest(
path: "/video/:/transcode/universal/stop",
query: [URLQueryItem(name: "session", value: sessionIdentifier)],
timeout: .seconds(5)
)
)
}
/// Was dieses Gerät abspielen kann, in der Schreibweise von Plex.
///
/// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen
/// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS
/// wirklich kann, schickte Plex einen Strom, der hier stumm bliebe oder
/// schwarz.
private nonisolated var clientProfileItems: [URLQueryItem] {
[
URLQueryItem(
name: "X-Plex-Client-Profile-Extra",
value: [
// Was ohne Umrechnung durchgereicht werden darf.
"add-transcode-target(type=videoProfile&context=streaming&protocol=hls&container=mpegts&videoCodec=h264&audioCodec=aac)",
// DTS und TrueHD kann iOS nicht — für sie ausdrücklich
// eine Umrechnung nach AAC verlangen, statt zu hoffen,
// dass Plex es von selbst merkt.
"add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=aac)",
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value=52)",
].joined(separator: "+")
),
]
}
// MARK: - Fortschritt melden
/// Meldet die aktuelle Stelle an den Server.
///
/// Grundlage für das geräteübergreifende Weiterschauen: was hier
/// ankommt, sieht der Fernseher im Wohnzimmer. Fehler werden verschluckt —
/// eine Fehlermeldung mitten im Film wäre störender als ein verlorener
/// Fortschrittswert, und der nächste Bericht kommt in Sekunden.
public func reportProgress(
ratingKey: String,
state: PlaybackReportState,
time: Duration,
duration: Duration
) async {
_ = try? await client.send(
PlexRequest(
path: "/:/timeline",
query: [
URLQueryItem(name: "ratingKey", value: ratingKey),
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
URLQueryItem(name: "state", value: state.rawValue),
URLQueryItem(name: "time", value: String(time.milliseconds)),
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
],
timeout: .seconds(10)
)
)
}
/// Markiert einen Titel als gesehen.
public func markWatched(ratingKey: String) async throws(PlexError) {
_ = try await client.send(scrobbleRequest(path: "/:/scrobble", ratingKey: ratingKey))
}
/// Nimmt die Markierung zurück.
public func markUnwatched(ratingKey: String) async throws(PlexError) {
_ = try await client.send(scrobbleRequest(path: "/:/unscrobble", ratingKey: ratingKey))
}
/// Setzt eine Bewertung. `rating` von 0 bis 10; 0 entfernt sie.
public func rate(ratingKey: String, rating: Int) async throws(PlexError) {
_ = try await client.send(
PlexRequest(path: "/:/rate", query: [
URLQueryItem(name: "key", value: ratingKey),
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
URLQueryItem(name: "rating", value: String(max(0, min(10, rating)))),
])
)
}
/// Stößt einen Suchlauf in einer Mediathek an.
public func refreshLibrary(_ library: PlexLibrary) async throws(PlexError) {
_ = try await client.send(PlexRequest(path: "/library/sections/\(library.key)/refresh"))
}
private nonisolated func scrobbleRequest(path: String, ratingKey: String) -> PlexRequest {
PlexRequest(path: path, query: [
URLQueryItem(name: "key", value: ratingKey),
// Ohne diese Kennung antwortet Plex mit 400. Sie benennt die
// Bibliotheks-Erweiterung, an die sich die Meldung richtet.
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
])
}
}
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
public enum PlaybackReportState: String, Sendable {
case playing
case paused
/// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus
/// „wird gerade abgespielt" entfernen.
case stopped
case buffering
}
extension Duration {
/// Die Dauer in ganzen Millisekunden — die Einheit, in der Plex rechnet.
public var milliseconds: Int {
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
}
}
extension PlexServer {
/// Die nächste Folge nach der angegebenen.
///
/// Plex kennt keinen Endpunkt dafür. Der Weg führt über die Geschwister:
/// die Folgen derselben Staffel holen und die mit der nächsthöheren Nummer
/// nehmen. Ist die Staffel zu Ende, wird in der folgenden weitergesucht —
/// sonst bräche das Weiterschauen jedes Mal am Staffelende ab.
public func nextEpisode(after episode: PlexItem) async throws(PlexError) -> PlexItem? {
guard episode.kind == .episode,
let seasonKey = episode.parentRatingKey,
let currentIndex = episode.index
else { return nil }
let siblings = try await children(of: seasonKey)
if let next = siblings
.filter({ ($0.index ?? 0) > currentIndex })
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
return next
}
// Staffelende: in der nächsten Staffel weitersuchen.
guard let showKey = episode.grandparentRatingKey,
let seasonNumber = episode.parentIndex
else { return nil }
let seasons = try await children(of: showKey)
guard let nextSeason = seasons
.filter({ ($0.index ?? 0) > seasonNumber })
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
else { return nil }
return try await children(of: nextSeason.ratingKey)
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
}
}
@@ -5,10 +5,17 @@ import Foundation
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
public actor PlexServer {
public let resource: PlexResource
public let connection: PlexConnection
fileprivate let client: PlexHTTPClient
private let token: String
// `nonisolated`, weil beide unveränderlich und Sendable sind: die
// Oberfläche muss für „welche Verbindung läuft gerade" nicht auf den
// Actor warten. Über Modulgrenzen hinweg leitet der Compiler das nicht
// von selbst her.
public nonisolated let resource: PlexResource
public nonisolated let connection: PlexConnection
/// Modulweit sichtbar, weil die Endpunkte auf mehrere Dateien verteilt
/// sind — Blättern hier, Wiedergabe in PlexServer+Playback. Nach außen
/// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen.
let client: PlexHTTPClient
nonisolated let token: String
public init?(
resource: PlexResource,
@@ -0,0 +1,151 @@
import Foundation
/// Die Anfrage an den Transcoder von Plex.
///
/// Baut die Parameter für `/video/:/transcode/universal/…`. Plex entscheidet
/// daraufhin selbst, wie viel es umrechnen muss: passt der Bildcodec, aber
/// nicht der Container, wird nur umgepackt („direct stream"); passt nichts,
/// wird neu kodiert. Das ist deutlich schonender für den Server, als pauschal
/// alles neu zu rechnen — deshalb steht `directStream` auf 1.
public struct TranscodeRequest: Sendable, Equatable {
public let ratingKey: String
public let mediaIndex: Int
public let partIndex: Int
/// Obergrenze in Kilobit pro Sekunde.
public let maximumBitrate: Int?
/// Zielauflösung als `"1920x1080"`. `nil` behält die des Originals.
public let resolution: String?
/// Ob der Server im selben Netz steht. Plex wählt danach andere
/// Voreinstellungen für Puffergrößen.
public let isLocal: Bool
/// Kennung dieser Wiedergabe. Über sie lässt sich der Transcoder später
/// wieder anhalten — ohne sie rechnet der Server weiter, auch wenn längst
/// niemand mehr zusieht.
public let sessionIdentifier: String
/// Welche Untertitelspur eingebrannt werden soll.
public let subtitleStreamID: Int?
public let audioStreamID: Int?
public init(
ratingKey: String,
mediaIndex: Int = 0,
partIndex: Int = 0,
maximumBitrate: Int? = nil,
resolution: String? = nil,
isLocal: Bool,
sessionIdentifier: String = UUID().uuidString,
subtitleStreamID: Int? = nil,
audioStreamID: Int? = nil
) {
self.ratingKey = ratingKey
self.mediaIndex = mediaIndex
self.partIndex = partIndex
self.maximumBitrate = maximumBitrate
self.resolution = resolution
self.isLocal = isLocal
self.sessionIdentifier = sessionIdentifier
self.subtitleStreamID = subtitleStreamID
self.audioStreamID = audioStreamID
}
var queryItems: [URLQueryItem] {
var items = [
URLQueryItem(name: "path", value: "/library/metadata/\(ratingKey)"),
URLQueryItem(name: "mediaIndex", value: String(mediaIndex)),
URLQueryItem(name: "partIndex", value: String(partIndex)),
URLQueryItem(name: "protocol", value: "hls"),
// Ohne fastSeek muss der Transcoder beim Springen von vorn
// beginnen — bei einem Zweistünder dauert das ewig.
URLQueryItem(name: "fastSeek", value: "1"),
// 0 heißt: wir fragen hier ausdrücklich nach einem Strom. Ob
// DirectPlay möglich wäre, hat der PlaybackDecider schon geprüft.
URLQueryItem(name: "directPlay", value: "0"),
// 1 erlaubt das bloße Umpacken, wenn der Bildcodec passt. Das
// spart dem Server die Neukodierung.
URLQueryItem(name: "directStream", value: "1"),
URLQueryItem(name: "copyts", value: "1"),
URLQueryItem(name: "location", value: isLocal ? "lan" : "wan"),
URLQueryItem(name: "session", value: sessionIdentifier),
URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier),
]
if let maximumBitrate {
items.append(URLQueryItem(name: "maxVideoBitrate", value: String(maximumBitrate)))
// `videoQuality` skaliert mit: ohne sie ignoriert Plex die
// Bitratengrenze bei manchen Fassungen.
items.append(URLQueryItem(name: "videoQuality", value: String(quality(for: maximumBitrate))))
}
if let resolution {
items.append(URLQueryItem(name: "videoResolution", value: resolution))
}
if let subtitleStreamID {
items.append(URLQueryItem(name: "subtitles", value: "burn"))
items.append(URLQueryItem(name: "subtitleStreamID", value: String(subtitleStreamID)))
items.append(URLQueryItem(name: "subtitleSize", value: "100"))
}
if let audioStreamID {
items.append(URLQueryItem(name: "audioStreamID", value: String(audioStreamID)))
}
return items
}
/// Übersetzt eine Bitrate in Plex' Qualitätsskala von 10 bis 100.
///
/// Die Werte stammen aus der Staffelung, die Plex in seinen eigenen Apps
/// anbietet. Zwischen den Stufen wird aufgerundet — lieber etwas zu gut
/// als sichtbar zu schlecht.
private func quality(for bitrate: Int) -> Int {
switch bitrate {
case ..<1_000: 20
case ..<2_000: 40
case ..<4_000: 60
case ..<8_000: 75
case ..<12_000: 90
default: 100
}
}
}
/// Was der Server zur geplanten Wiedergabe sagt.
///
/// Wird vor dem Start abgefragt. Der Wert liegt weniger in der Entscheidung
/// selbst — die trifft der `PlaybackDecider` — als im Fehlerfall: sagt Plex
/// hier schon nein, erfährt man den Grund im Klartext, statt später vor einem
/// schwarzen Bild zu sitzen.
public struct TranscodeDecision: Sendable, Decodable {
public let generalDecisionCode: Int?
public let generalDecisionText: String?
public let transcodeDecisionCode: Int?
public let transcodeDecisionText: String?
public let mediaDecisionCode: Int?
/// Ob der Server die Wiedergabe grundsätzlich zulässt.
///
/// Code 1000 heißt „in Ordnung". Alles andere ist eine Absage — meist
/// fehlende Berechtigungen oder ein überlasteter Transcoder.
public var isAllowed: Bool {
generalDecisionCode == nil || generalDecisionCode == 1000
}
public var explanation: String? {
generalDecisionText ?? transcodeDecisionText
}
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey {
case generalDecisionCode, generalDecisionText
case transcodeDecisionCode, transcodeDecisionText
case mediaDecisionCode
}
public init(from decoder: any Decoder) throws {
let outer = try decoder.container(keyedBy: OuterKeys.self)
let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer)
self.generalDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .generalDecisionCode)
self.generalDecisionText = try inner.decodeIfPresent(String.self, forKey: .generalDecisionText)
self.transcodeDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .transcodeDecisionCode)
self.transcodeDecisionText = try inner.decodeIfPresent(String.self, forKey: .transcodeDecisionText)
self.mediaDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .mediaDecisionCode)
}
}
@@ -0,0 +1,158 @@
import Foundation
import Testing
@testable import PlexKit
/// Die Suche nach der nächsten Folge.
///
/// Plex kennt dafür keinen Endpunkt — der Weg führt über die Geschwister, und
/// am Staffelende über die nächste Staffel. Genau dort bricht es ohne diese
/// Logik ab, und zwar an der Stelle, an der man es am wenigsten gebrauchen
/// kann: mitten in einer Serie.
@Suite("Nächste Folge")
struct NextEpisodeTests {
private func episode(
ratingKey: String,
index: Int,
season: Int,
seasonKey: String,
showKey: String = "show-1"
) -> String {
"""
{
"ratingKey": "\(ratingKey)", "type": "episode", "title": "Folge \(index)",
"index": \(index), "parentIndex": \(season),
"parentRatingKey": "\(seasonKey)", "grandparentRatingKey": "\(showKey)",
"grandparentTitle": "Eine Serie"
}
"""
}
private func container(_ items: [String]) -> String {
"""
{"MediaContainer":{"size":\(items.count),"Metadata":[\(items.joined(separator: ","))]}}
"""
}
private func season(ratingKey: String, index: Int) -> String {
"""
{"ratingKey":"\(ratingKey)","type":"season","title":"Staffel \(index)","index":\(index)}
"""
}
private func server(routes: [String: String]) -> PlexServer {
PlexServer(
resource: PlexResource(
name: "Test",
clientIdentifier: "srv",
accessToken: "token",
connections: [PlexConnection(uri: "http://server:32400", local: true)]
),
connection: PlexConnection(uri: "http://server:32400", local: true),
identity: .test,
transport: FakeTransport.routes(routes)
)!
}
private func current(index: Int, season: Int = 1, seasonKey: String = "season-1") throws -> PlexItem {
let data = Data(container([episode(ratingKey: "e\(index)", index: index, season: season, seasonKey: seasonKey)]).utf8)
let decoded = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data)
return try #require(decoded.content.first)
}
@Test("Innerhalb einer Staffel kommt die nächsthöhere Folge")
func nextWithinSeason() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e3", index: 3, season: 1, seasonKey: "season-1"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "e2")
}
@Test("Lücken in der Nummerierung überspringen nichts Falsches")
func handlesGapsInNumbering() async throws {
// Fehlt eine Folge auf der Platte, darf nicht die Folge mit der
// höchsten Nummer kommen, sondern die nächsterreichbare.
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e5", index: 5, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e9", index: 9, season: 1, seasonKey: "season-1"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "e5")
}
@Test("Am Staffelende geht es in der nächsten Staffel weiter")
func rollsOverToNextSeason() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([
season(ratingKey: "season-1", index: 1),
season(ratingKey: "season-2", index: 2),
]),
"/library/metadata/season-2/children": container([
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
episode(ratingKey: "s2e2", index: 2, season: 2, seasonKey: "season-2"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 2))
#expect(next?.ratingKey == "s2e1")
}
@Test("Nach der letzten Folge der letzten Staffel kommt nichts mehr")
func stopsAtTheEnd() async throws {
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([season(ratingKey: "season-1", index: 1)]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next == nil)
}
@Test("Ein Film hat keine nächste Folge")
func moviesHaveNoNext() async throws {
let data = Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"m1","type":"movie","title":"Ein Film"}]}}"#.utf8)
let movie = try #require(try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data).content.first)
let next = try await server(routes: [:]).nextEpisode(after: movie)
#expect(next == nil)
}
@Test("Staffel 0 mit Specials wird nicht vorgezogen")
func specialsDoNotJumpAhead() async throws {
// Plex führt Specials als Staffel 0. Sie stehen in der Liste vor der
// ersten regulären Staffel — würde einfach die erste genommen, landete
// man nach dem Staffelfinale bei einem Weihnachtsspecial.
let server = server(routes: [
"/library/metadata/season-1/children": container([
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
]),
"/library/metadata/show-1/children": container([
season(ratingKey: "season-0", index: 0),
season(ratingKey: "season-1", index: 1),
season(ratingKey: "season-2", index: 2),
]),
"/library/metadata/season-2/children": container([
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
]),
])
let next = try await server.nextEpisode(after: try current(index: 1))
#expect(next?.ratingKey == "s2e1")
}
}
@@ -0,0 +1,101 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Transcode-Anfrage")
struct TranscodeRequestTests {
private func value(_ request: TranscodeRequest, _ name: String) -> String? {
request.queryItems.first { $0.name == name }?.value
}
@Test("Der Pfad zeigt auf den Titel, nicht auf die Datei")
func pathPointsAtMetadata() {
// Plex erwartet hier die Metadaten-Adresse. Gibt man den Dateipfad an,
// antwortet der Transcoder mit 404.
let request = TranscodeRequest(ratingKey: "45231", isLocal: true)
#expect(value(request, "path") == "/library/metadata/45231")
}
@Test("Umpacken ist erlaubt, DirectPlay ausdrücklich nicht")
func allowsDirectStreamButNotDirectPlay() {
// directStream=1 lässt Plex nur den Container wechseln, wenn der
// Bildcodec passt — das spart die Neukodierung. directPlay=0, weil
// diese Frage der PlaybackDecider bereits entschieden hat.
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(request, "directStream") == "1")
#expect(value(request, "directPlay") == "0")
}
@Test("Ohne fastSeek müsste der Transcoder beim Springen von vorn beginnen")
func enablesFastSeek() {
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "fastSeek") == "1")
}
@Test("Die Bitratengrenze bringt eine passende Qualitätsstufe mit")
func bitrateCarriesQuality() {
// Ohne videoQuality ignoriert Plex die Grenze bei manchen Fassungen.
let request = TranscodeRequest(ratingKey: "1", maximumBitrate: 4_000, isLocal: false)
#expect(value(request, "maxVideoBitrate") == "4000")
#expect(value(request, "videoQuality") != nil)
}
@Test("Höhere Bitraten ergeben höhere Qualitätsstufen")
func qualityRisesWithBitrate() {
func quality(_ bitrate: Int) -> Int {
Int(value(TranscodeRequest(ratingKey: "1", maximumBitrate: bitrate, isLocal: true), "videoQuality") ?? "0")!
}
#expect(quality(2_000) < quality(8_000))
#expect(quality(8_000) < quality(20_000))
#expect(quality(20_000) <= 100)
}
@Test("Ohne Grenze steht keine Bitrate in der Anfrage")
func noBitrateWhenUnlimited() {
// Ein leerer Wert ist für Plex nicht dasselbe wie ein fehlender — er
// würde als Grenze von null gelesen.
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(request, "maxVideoBitrate") == nil)
#expect(value(request, "videoQuality") == nil)
}
@Test("Lokal und außer Haus werden unterschieden")
func distinguishesLocation() {
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "location") == "lan")
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: false), "location") == "wan")
}
@Test("Die Sitzungskennung steht in beiden Feldern")
func sessionAppearsTwice() {
// Plex erwartet sie sowohl als `session` als auch als
// `X-Plex-Session-Identifier`. Fehlt eines, lässt sich der Transcoder
// später nicht gezielt anhalten — und er rechnet minutenlang weiter.
let request = TranscodeRequest(ratingKey: "1", isLocal: true, sessionIdentifier: "sitzung-1")
#expect(value(request, "session") == "sitzung-1")
#expect(value(request, "X-Plex-Session-Identifier") == "sitzung-1")
}
@Test("Untertitel werden nur eingebrannt, wenn eine Spur gewählt ist")
func subtitlesOnlyWhenRequested() {
let without = TranscodeRequest(ratingKey: "1", isLocal: true)
#expect(value(without, "subtitles") == nil)
let with = TranscodeRequest(ratingKey: "1", isLocal: true, subtitleStreamID: 310_024)
#expect(value(with, "subtitles") == "burn")
#expect(value(with, "subtitleStreamID") == "310024")
}
}
@Suite("Dauer in Plex-Einheiten")
struct DurationConversionTests {
@Test("Millisekunden werden ganzzahlig gerechnet")
func millisecondsAreWholeNumbers() {
#expect(Duration.seconds(90).milliseconds == 90_000)
#expect(Duration.milliseconds(4_892_000).milliseconds == 4_892_000)
#expect(Duration.zero.milliseconds == 0)
}
@Test("Sekunden als Gleitkommazahl")
func secondsAsDouble() {
#expect(abs(Duration.milliseconds(1_500).seconds - 1.5) < 0.0001)
}
}