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:
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -10,6 +11,10 @@ struct FlixyApp: App {
|
|||||||
@State private var theme = FlixyTheme()
|
@State private var theme = FlixyTheme()
|
||||||
@State private var session: FlixySession
|
@State private var session: FlixySession
|
||||||
@State private var playback = PlaybackCoordinator()
|
@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() {
|
init() {
|
||||||
let identity = PlexClientIdentity.current(
|
let identity = PlexClientIdentity.current(
|
||||||
@@ -27,6 +32,8 @@ struct FlixyApp: App {
|
|||||||
RootView()
|
RootView()
|
||||||
.environment(session)
|
.environment(session)
|
||||||
.environment(playback)
|
.environment(playback)
|
||||||
|
.environment(playbackSettings)
|
||||||
|
.environment(network)
|
||||||
.environment(theme)
|
.environment(theme)
|
||||||
.flixyAccent(theme)
|
.flixyAccent(theme)
|
||||||
// Der eigentliche Schalter für „nur dunkel" steht in der
|
// Der eigentliche Schalter für „nur dunkel" steht in der
|
||||||
|
|||||||
@@ -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"
|
"version": "1.1"
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,9 @@ import PlexKit
|
|||||||
/// Führt einen Abspielwunsch aus.
|
/// Führt einen Abspielwunsch aus.
|
||||||
///
|
///
|
||||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
/// 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
|
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die Ansicht
|
||||||
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
|
/// weiß nicht, welcher Motor läuft — bis auf die eine Stelle, an der die
|
||||||
/// an der die Bildfläche gebaut wird.
|
/// Bildfläche gebaut wird.
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class PlayerModel {
|
final class PlayerModel {
|
||||||
@@ -29,16 +29,51 @@ final class PlayerModel {
|
|||||||
private(set) var duration: Duration?
|
private(set) var duration: Duration?
|
||||||
private(set) var isPlaying = false
|
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 request: PlaybackRequest
|
||||||
private let server: PlexServer?
|
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.request = request
|
||||||
self.server = server
|
self.server = server
|
||||||
|
self.policy = policy
|
||||||
|
self.settings = settings
|
||||||
|
self.isLocalConnection = isLocalConnection
|
||||||
self.title = request.title
|
self.title = request.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Start
|
||||||
|
|
||||||
func start() async {
|
func start() async {
|
||||||
guard let server else {
|
guard let server else {
|
||||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||||
@@ -53,39 +88,76 @@ final class PlayerModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.item = item
|
||||||
title = item.rowTitle
|
title = item.rowTitle
|
||||||
subtitle = playerSubtitle(for: item)
|
subtitle = playerSubtitle(for: item)
|
||||||
|
|
||||||
guard let media = version(of: item),
|
guard let media = version(of: item), let part = media.parts.first else {
|
||||||
let part = media.parts.first,
|
|
||||||
let url = server.directPlayURL(part: part)
|
|
||||||
else {
|
|
||||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
|
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||||
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
|
|
||||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
|
||||||
self.decision = decision
|
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)
|
let engine = makeEngine(for: decision)
|
||||||
self.engine = engine
|
self.engine = engine
|
||||||
observe(engine)
|
observe(engine)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await engine.load(PlaybackSource(
|
try await engine.load(source)
|
||||||
url: url,
|
|
||||||
origin: .remote,
|
|
||||||
startTime: request.startTime
|
|
||||||
))
|
|
||||||
engine.play()
|
engine.play()
|
||||||
phase = .playing
|
phase = .playing
|
||||||
|
startReporting()
|
||||||
|
preloadNextEpisode()
|
||||||
} catch {
|
} catch {
|
||||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
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() {
|
func togglePlayPause() {
|
||||||
guard let engine else { return }
|
guard let engine else { return }
|
||||||
if isPlaying { engine.pause() } else { engine.play() }
|
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.
|
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||||
func skip(by offset: Duration) {
|
func skip(by offset: Duration) {
|
||||||
guard let duration else { return }
|
guard let duration else { return }
|
||||||
let target = currentTime + offset
|
seek(to: max(.zero, min(currentTime + offset, duration)))
|
||||||
seek(to: max(.zero, min(target, 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()
|
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?.teardown()
|
||||||
engine = nil
|
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
|
// MARK: - Innereien
|
||||||
|
|
||||||
private func version(of item: PlexItem) -> PlexMedia? {
|
private func version(of item: PlexItem) -> PlexMedia? {
|
||||||
@@ -119,7 +289,8 @@ final class PlayerModel {
|
|||||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||||
switch decision.engine {
|
switch decision.engine {
|
||||||
case .vlc: VLCPlayerEngine()
|
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()
|
case .avPlayer, .none: AVPlayerEngine()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,19 +308,36 @@ final class PlayerModel {
|
|||||||
}
|
}
|
||||||
case .timeChanged(let time):
|
case .timeChanged(let time):
|
||||||
currentTime = time
|
currentTime = time
|
||||||
|
updateActiveMarker()
|
||||||
case .durationResolved(let value):
|
case .durationResolved(let value):
|
||||||
duration = value
|
duration = value
|
||||||
case .reachedEnd:
|
case .reachedEnd:
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
case .tracksResolved:
|
hasReachedEnd = true
|
||||||
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
|
case .tracksResolved(let audio, let subtitle):
|
||||||
// schon — die Ansicht bietet sie nur noch nicht an.
|
audioTracks = audio
|
||||||
break
|
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? {
|
private func playerSubtitle(for item: PlexItem) -> String? {
|
||||||
guard item.kind == .episode else { return nil }
|
guard item.kind == .episode else { return nil }
|
||||||
if let season = item.parentIndex, let episode = item.index {
|
if let season = item.parentIndex, let episode = item.index {
|
||||||
@@ -158,3 +346,16 @@ final class PlayerModel {
|
|||||||
return item.title
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
@Environment(PlaybackCoordinator.self) private var playback
|
@Environment(PlaybackCoordinator.self) private var playback
|
||||||
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
|
@Environment(NetworkConditions.self) private var network
|
||||||
@Environment(\.flixyAccent) private var accent
|
@Environment(\.flixyAccent) private var accent
|
||||||
|
|
||||||
@State private var model: PlayerModel?
|
@State private var model: PlayerModel?
|
||||||
@State private var showsControls = true
|
@State private var showsControls = true
|
||||||
|
@State private var showsTrackPicker = false
|
||||||
@State private var hideTask: Task<Void, Never>?
|
@State private var hideTask: Task<Void, Never>?
|
||||||
@State private var scrubbing: Double?
|
@State private var scrubbing: Double?
|
||||||
|
|
||||||
@@ -33,15 +36,18 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
case .playing:
|
case .playing:
|
||||||
if showsControls {
|
if showsControls {
|
||||||
controls(for: model)
|
controls(for: model).transition(.opacity)
|
||||||
.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
|
// Die Kopfzeile liegt über allem und erscheint auch während
|
||||||
// erscheint auch, solange noch geladen wird. Ohne sie sitzt
|
// des Ladens. Ohne sie sitzt man bei langsamer Verbindung
|
||||||
// man bei einer langsamen Verbindung fest: kein Zurück, kein
|
// fest: kein Zurück, kein Abbrechen, nur ein Ladekreis.
|
||||||
// Abbrechen, nur ein Ladekreis.
|
|
||||||
if showsControls || !isPlayingPhase(model) {
|
if showsControls || !isPlayingPhase(model) {
|
||||||
VStack {
|
VStack {
|
||||||
topBar(for: model)
|
topBar(for: model)
|
||||||
@@ -51,21 +57,33 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.statusBarHidden(showsControls == false)
|
.statusBarHidden(!showsControls)
|
||||||
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
|
|
||||||
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
|
|
||||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||||
.contentShape(.rect)
|
.contentShape(.rect)
|
||||||
.onTapGesture { toggleControls() }
|
.onTapGesture { toggleControls() }
|
||||||
|
.sheet(isPresented: $showsTrackPicker) {
|
||||||
|
if let model { TrackPickerSheet(model: model) }
|
||||||
|
}
|
||||||
.task {
|
.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
|
self.model = model
|
||||||
await model.start()
|
await model.start()
|
||||||
scheduleControlsHide()
|
scheduleControlsHide()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
hideTask?.cancel()
|
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)
|
.padding(FlixySpacing.loose)
|
||||||
.background {
|
.background {
|
||||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
|
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht
|
||||||
// wirkt.
|
// verfälscht wirkt.
|
||||||
Color.black.opacity(0.35).ignoresSafeArea()
|
Color.black.opacity(0.35).ignoresSafeArea()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func topBar(for model: PlayerModel) -> some View {
|
private func topBar(for model: PlayerModel) -> some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top, spacing: FlixySpacing.regular) {
|
||||||
Button {
|
Button {
|
||||||
playback.dismiss()
|
playback.dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -114,6 +132,18 @@ struct PlayerView: View {
|
|||||||
|
|
||||||
Spacer()
|
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 {
|
if let decision = model.decision {
|
||||||
PlaybackMethodBadge(decision: decision)
|
PlaybackMethodBadge(decision: decision)
|
||||||
}
|
}
|
||||||
@@ -173,10 +203,10 @@ struct PlayerView: View {
|
|||||||
.tint(accent)
|
.tint(accent)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
|
Text(displayTime(for: model).timecode)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let duration = model.duration {
|
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))
|
.font(.system(.caption, design: .monospaced))
|
||||||
@@ -184,11 +214,53 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Während geladen wird.
|
private func displayTime(for model: PlayerModel) -> Duration {
|
||||||
///
|
scrubbing.map(Duration.seconds) ?? model.currentTime
|
||||||
/// Zeigt den Titel mit an, damit erkennbar bleibt, worauf gewartet wird —
|
}
|
||||||
/// und weil ein nackter Ladekreis nach zehn Sekunden wie ein Absturz
|
|
||||||
/// aussieht.
|
/// Ü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 {
|
private func preparing(for model: PlayerModel) -> some View {
|
||||||
VStack(spacing: FlixySpacing.loose) {
|
VStack(spacing: FlixySpacing.loose) {
|
||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
@@ -245,9 +317,6 @@ struct PlayerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
|
/// 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 {
|
struct PlaybackMethodBadge: View {
|
||||||
let decision: PlaybackDecision
|
let decision: PlaybackDecision
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import FlixyDesign
|
import FlixyDesign
|
||||||
|
import FlixyPlayback
|
||||||
import PlexKit
|
import PlexKit
|
||||||
|
|
||||||
/// Die Einstellungen.
|
/// Die Einstellungen.
|
||||||
@@ -9,6 +10,7 @@ import PlexKit
|
|||||||
/// es das Geregelte auch gibt.
|
/// es das Geregelte auch gibt.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(FlixySession.self) private var session
|
@Environment(FlixySession.self) private var session
|
||||||
|
@Environment(PlaybackSettings.self) private var settings
|
||||||
@State private var showsSignOutConfirmation = false
|
@State private var showsSignOutConfirmation = false
|
||||||
|
|
||||||
var body: some View {
|
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") {
|
Section("settings.app") {
|
||||||
LabeledContent("settings.version", value: Branding.version)
|
LabeledContent("settings.version", value: Branding.version)
|
||||||
}
|
}
|
||||||
@@ -63,3 +88,15 @@ struct SettingsView: View {
|
|||||||
return String(localized: "settings.connectionRemote")
|
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
|
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
|
||||||
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
|
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
|
||||||
public actor PlexServer {
|
public actor PlexServer {
|
||||||
public let resource: PlexResource
|
// `nonisolated`, weil beide unveränderlich und Sendable sind: die
|
||||||
public let connection: PlexConnection
|
// Oberfläche muss für „welche Verbindung läuft gerade" nicht auf den
|
||||||
fileprivate let client: PlexHTTPClient
|
// Actor warten. Über Modulgrenzen hinweg leitet der Compiler das nicht
|
||||||
private let token: String
|
// 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?(
|
public init?(
|
||||||
resource: PlexResource,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user