diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index abc67af..7dd1b74 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -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 diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index fe92b3a..f4d2813 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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" diff --git a/Flixy/Session/PlaybackSettings.swift b/Flixy/Session/PlaybackSettings.swift new file mode 100644 index 0000000..e3aa9f7 --- /dev/null +++ b/Flixy/Session/PlaybackSettings.swift @@ -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 + ) + } +} diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 23951b9..210a402 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -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? + 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? + private var reportTask: Task? + 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 = [] + + 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) + } +} diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index 806ce5a..3fbdf07 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -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? @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 diff --git a/Flixy/Views/Player/TrackPickerSheet.swift b/Flixy/Views/Player/TrackPickerSheet.swift new file mode 100644 index 0000000..17d202b --- /dev/null +++ b/Flixy/Views/Player/TrackPickerSheet.swift @@ -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) + } +} diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index 7f5d53c..2e265ea 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -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" + } + } +} diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Decision/NetworkConditions.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/NetworkConditions.swift new file mode 100644 index 0000000..662d713 --- /dev/null +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Decision/NetworkConditions.swift @@ -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() + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift new file mode 100644 index 0000000..f222ca4 --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -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) }) + } +} diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift index 644ddad..6406248 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer.swift @@ -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, diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift new file mode 100644 index 0000000..17d2c7c --- /dev/null +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/TranscodeRequest.swift @@ -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) + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift new file mode 100644 index 0000000..0fe571c --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift @@ -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.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.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") + } +} diff --git a/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift b/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift new file mode 100644 index 0000000..2ac09d7 --- /dev/null +++ b/Packages/FlixyKit/Tests/PlexKitTests/TranscodeRequestTests.swift @@ -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) + } +}