Phase 0: Projektgerüst, Design-Tokens und Navigation

Legt das Fundament für den Plex-Client an:

- XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices,
  strict concurrency, Projektdatei nicht eingecheckt)
- FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback,
  FlixyDownloads. macOS steht als Plattform mit drin, damit die
  Logik-Tests ohne Simulator laufen.
- FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und
  Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die
  Umgebung und wird per WCAG-Rechnung lesbar gehalten.
- PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und
  die Medienmodelle samt nachsichtigem Decoder.
- FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die
  eine Funktion, an der Phase 2 das Transcoding andockt.
- Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad.
- 45 Tests, alle grün.

VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit
strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig,
deshalb bekommt sie in Phase 1 ein eigenes Target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
commit 1ed64bf166
54 changed files with 3584 additions and 0 deletions
@@ -0,0 +1,100 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
/// Wer wir gegenüber Plex sind.
///
/// Plex verlangt bei jeder Anfrage eine Handvoll `X-Plex-*`-Kopfzeilen. Die
/// wichtigste ist `X-Plex-Client-Identifier`: darüber erkennt der Server das
/// Gerät wieder. Ändert sie sich, gilt die App als neues Gerät — die
/// Anmeldung ist futsch und in der Geräteliste des Kontos sammelt sich Müll
/// an. Deshalb wird sie einmal erzeugt und dann festgehalten.
public struct PlexClientIdentity: Sendable, Equatable {
public let product: String
public let version: String
public let clientIdentifier: String
public let platform: String
public let platformVersion: String
public let device: String
public let deviceName: String
public init(
product: String,
version: String,
clientIdentifier: String,
platform: String,
platformVersion: String,
device: String,
deviceName: String
) {
self.product = product
self.version = version
self.clientIdentifier = clientIdentifier
self.platform = platform
self.platformVersion = platformVersion
self.device = device
self.deviceName = deviceName
}
/// Die Kopfzeilen für eine Anfrage — ohne Token, das kommt je nach Ziel
/// (plex.tv oder Server) aus einer anderen Quelle.
public var headers: [String: String] {
[
"X-Plex-Product": product,
"X-Plex-Version": version,
"X-Plex-Client-Identifier": clientIdentifier,
"X-Plex-Platform": platform,
"X-Plex-Platform-Version": platformVersion,
"X-Plex-Device": device,
"X-Plex-Device-Name": deviceName,
// Ohne diese Zeile antwortet Plex in XML. Die Modelle hier sind auf
// JSON ausgelegt, also fragen wir es ausdrücklich an.
"Accept": "application/json",
]
}
}
extension PlexClientIdentity {
private static let storageKey = "flixy.plex.clientIdentifier"
/// Baut die Kennung für das laufende Gerät und merkt sich die Client-ID.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist. Das mit
/// `assumeIsolated` zu umgehen wäre ein Absturz mit Anlauf — die Kennung
/// wird ohnehin einmal beim Start gebaut.
@MainActor
public static func current(
product: String,
version: String,
defaults: UserDefaults = .standard
) -> PlexClientIdentity {
let identifier: String
if let stored = defaults.string(forKey: storageKey) {
identifier = stored
} else {
identifier = UUID().uuidString
defaults.set(identifier, forKey: storageKey)
}
#if canImport(UIKit)
let systemVersion = UIDevice.current.systemVersion
let model = UIDevice.current.model
let name = UIDevice.current.name
#else
let systemVersion = ProcessInfo.processInfo.operatingSystemVersionString
let model = "Unknown"
let name = ProcessInfo.processInfo.hostName
#endif
return PlexClientIdentity(
product: product,
version: version,
clientIdentifier: identifier,
platform: "iOS",
platformVersion: systemVersion,
device: model,
deviceName: name
)
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Was beim Sprechen mit Plex schiefgehen kann.
///
/// Bewusst grob geschnitten: die Oberfläche muss unterscheiden können, ob sie
/// zur Anmeldung zurückschicken (`unauthorized`), einen Wiederholen-Knopf
/// zeigen (`transport`) oder einen Fehler melden soll. Feinere Abstufungen
/// hätten kein anderes Verhalten zur Folge.
public enum PlexError: Error, Sendable, Equatable {
/// Token fehlt oder wurde am Server zurückgezogen.
case unauthorized
/// Angefragte Ressource gibt es nicht — etwa ein gelöschter Titel.
case notFound
/// Der Server hat geantwortet, aber mit einem Fehlerstatus.
case server(status: Int)
/// Verbindung kam nicht zustande. Der Grund steht als Text drin, damit die
/// Fehlermeldung konkret werden kann statt „Etwas ist schiefgelaufen".
case transport(description: String)
/// Antwort kam an, ließ sich aber nicht lesen. Fast immer ein Zeichen
/// dafür, dass ein Feld in einer neueren Plex-Version anders heißt.
case decoding(description: String)
/// Es war kein Weg zum Server erreichbar — weder lokal noch über plex.tv.
case noReachableConnection
/// Ob ein erneuter Versuch überhaupt Aussicht auf Erfolg hat. Steuert, ob
/// die Oberfläche einen Wiederholen-Knopf anbietet.
public var isRetryable: Bool {
switch self {
case .transport, .noReachableConnection:
true
case .server(let status):
// 5xx ist ein Serverproblem und kann vorübergehen; 4xx liegt an
// der Anfrage und wird beim zweiten Mal genauso scheitern.
status >= 500
case .unauthorized, .notFound, .decoding:
false
}
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Übersetzt eine HTTP-Antwort in entweder Nutzdaten oder einen `PlexError`.
public enum PlexResponse {
public static func validate(
data: Data,
response: URLResponse
) throws(PlexError) -> Data {
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Antwort war keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300:
return data
case 401, 403:
throw .unauthorized
case 404:
throw .notFound
default:
throw .server(status: http.statusCode)
}
}
public static func decode<T: Decodable>(
_ type: T.Type,
from data: Data
) throws(PlexError) -> T {
do {
return try JSONDecoder().decode(type, from: data)
} catch {
// Der Fehlertext von `DecodingError` nennt den Schlüsselpfad. Das
// ist beim Nachziehen einer geänderten Plex-Version Gold wert,
// deshalb wird er mitgeschleppt statt verschluckt.
throw .decoding(description: String(describing: error))
}
}
}
@@ -0,0 +1,71 @@
import Foundation
/// Der schmale Spalt, durch den PlexKit ans Netz kommt.
///
/// Alles darüber kennt nur dieses Protokoll. Dadurch laufen die Tests gegen
/// aufgezeichnete Antworten statt gegen einen echten Server — und zwar ohne
/// `URLProtocol`-Verrenkungen, die bei paralleler Testausführung erfahrungs-
/// gemäß ins Rutschen kommen.
public protocol PlexTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: PlexTransport {}
/// Eine Anfrage an Plex, unabhängig davon, an welchen Host sie geht.
///
/// Plex nimmt den Token wahlweise als Kopfzeile oder als Abfrageparameter.
/// Hier steht er in der Kopfzeile — sonst landet er in jedem Protokoll und in
/// jedem Absturzbericht, der URLs mitschreibt.
public struct PlexRequest: Sendable, Equatable {
public enum Method: String, Sendable { case get = "GET", post = "POST", put = "PUT", delete = "DELETE" }
public var method: Method
public var path: String
public var query: [URLQueryItem]
public var additionalHeaders: [String: String]
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
additionalHeaders: [String: String] = [:]
) {
self.method = method
self.path = path
self.query = query
self.additionalHeaders = additionalHeaders
}
/// Setzt die Anfrage gegen einen Host zusammen.
public func urlRequest(
baseURL: URL,
identity: PlexClientIdentity,
token: String?
) throws(PlexError) -> URLRequest {
guard var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Ungültige Basisadresse: \(baseURL)")
}
components.queryItems = query.isEmpty ? nil : query
guard let url = components.url else {
throw .transport(description: "Anfrage ließ sich nicht zusammensetzen: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
for (field, value) in identity.headers {
request.setValue(value, forHTTPHeaderField: field)
}
for (field, value) in additionalHeaders {
request.setValue(value, forHTTPHeaderField: field)
}
if let token {
request.setValue(token, forHTTPHeaderField: "X-Plex-Token")
}
return request
}
}
@@ -0,0 +1,188 @@
import Foundation
/// Ein Eintrag in der Mediathek — Film, Serie, Staffel oder Folge.
///
/// Plex verwendet für alle vier denselben Datensatz und unterscheidet über
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
/// Aufteilen bei jedem Abruf.
public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, season, episode, clip, collection
}
/// Die Kennung, unter der der Server den Eintrag führt. Kommt als
/// Zeichenkette, obwohl es eine Zahl ist.
public let ratingKey: String
public let key: String
public let guid: String?
public let type: String
public let title: String
public let titleSort: String?
public let summary: String?
public let year: Int?
/// Laufzeit in Millisekunden.
public let duration: Int?
public let contentRating: String?
public let rating: Double?
public let audienceRating: Double?
// Bildpfade. Relativ zum Server — die vollständige Adresse entsteht erst
// beim Abruf, weil sie Token und Größenangaben braucht.
public let thumb: String?
public let art: String?
public let parentThumb: String?
public let grandparentThumb: String?
public let grandparentArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel, `grandparentTitle` der Serientitel.
public let index: Int?
public let parentIndex: Int?
public let parentTitle: String?
public let parentRatingKey: String?
public let grandparentTitle: String?
public let grandparentRatingKey: String?
// Fortschritt.
public let viewCount: Int?
/// Wie weit gesehen wurde, in Millisekunden.
public let viewOffset: Int?
public let lastViewedAt: Int?
public let addedAt: Int?
// Zählwerte bei Serien und Staffeln.
public let leafCount: Int?
public let viewedLeafCount: Int?
public let childCount: Int?
public let media: [PlexMedia]
private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration
case contentRating, rating, audienceRating
case thumb, art, parentThumb, grandparentThumb, grandparentArt
case index, parentIndex, parentTitle, parentRatingKey
case grandparentTitle, grandparentRatingKey
case viewCount, viewOffset, lastViewedAt, addedAt
case leafCount, viewedLeafCount, childCount
case media = "Media"
}
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
/// `Media`-Block verlangen würde. Plex liefert Einträge aber schon aus,
/// bevor es die Dateien analysiert hat — ohne diese Nachsicht wäre eine
/// frisch hinzugefügte Staffel als Ganzes unlesbar statt nur unvollständig.
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.ratingKey = try values.decode(String.self, forKey: .ratingKey)
self.key = try values.decodeIfPresent(String.self, forKey: .key) ?? "/library/metadata/\(ratingKey)"
self.guid = try values.decodeIfPresent(String.self, forKey: .guid)
self.type = try values.decode(String.self, forKey: .type)
self.title = try values.decode(String.self, forKey: .title)
self.titleSort = try values.decodeIfPresent(String.self, forKey: .titleSort)
self.summary = try values.decodeIfPresent(String.self, forKey: .summary)
self.year = try values.decodeIfPresent(Int.self, forKey: .year)
self.duration = try values.decodeIfPresent(Int.self, forKey: .duration)
self.contentRating = try values.decodeIfPresent(String.self, forKey: .contentRating)
self.rating = try values.decodeIfPresent(Double.self, forKey: .rating)
self.audienceRating = try values.decodeIfPresent(Double.self, forKey: .audienceRating)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.art = try values.decodeIfPresent(String.self, forKey: .art)
self.parentThumb = try values.decodeIfPresent(String.self, forKey: .parentThumb)
self.grandparentThumb = try values.decodeIfPresent(String.self, forKey: .grandparentThumb)
self.grandparentArt = try values.decodeIfPresent(String.self, forKey: .grandparentArt)
self.index = try values.decodeIfPresent(Int.self, forKey: .index)
self.parentIndex = try values.decodeIfPresent(Int.self, forKey: .parentIndex)
self.parentTitle = try values.decodeIfPresent(String.self, forKey: .parentTitle)
self.parentRatingKey = try values.decodeIfPresent(String.self, forKey: .parentRatingKey)
self.grandparentTitle = try values.decodeIfPresent(String.self, forKey: .grandparentTitle)
self.grandparentRatingKey = try values.decodeIfPresent(String.self, forKey: .grandparentRatingKey)
self.viewCount = try values.decodeIfPresent(Int.self, forKey: .viewCount)
self.viewOffset = try values.decodeIfPresent(Int.self, forKey: .viewOffset)
self.lastViewedAt = try values.decodeIfPresent(Int.self, forKey: .lastViewedAt)
self.addedAt = try values.decodeIfPresent(Int.self, forKey: .addedAt)
self.leafCount = try values.decodeIfPresent(Int.self, forKey: .leafCount)
self.viewedLeafCount = try values.decodeIfPresent(Int.self, forKey: .viewedLeafCount)
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
}
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
/// als Kennung für `ForEach`.
public var id: String { ratingKey }
public var kind: Kind? { Kind(rawValue: type) }
/// Wie weit der Titel gesehen ist, als Anteil von 0 bis 1.
///
/// `nil`, wenn noch gar nicht angefangen wurde — die Kachel zeigt dann
/// keinen Balken statt eines leeren.
public var watchProgress: Double? {
guard let viewOffset, let duration, duration > 0, viewOffset > 0 else { return nil }
return min(Double(viewOffset) / Double(duration), 1)
}
public var isWatched: Bool { (viewCount ?? 0) > 0 }
/// Die Fassung, die abgespielt werden soll, wenn der Nutzer nichts anderes
/// wählt: die mit der höchsten Bitrate. Plex sortiert nicht zuverlässig,
/// deshalb wird hier selbst ausgewählt.
public var preferredMedia: PlexMedia? {
media.max { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) }
}
/// Ob die Detailseite eine Versionsauswahl anbieten muss.
public var hasMultipleVersions: Bool { media.count > 1 }
}
/// Die Hülle, in die Plex jede Antwort packt.
///
/// Der Name des Inhaltsfelds wechselt je nach Endpunkt (`Metadata`,
/// `Directory`, `Hub`), die Hülle selbst bleibt gleich. Deshalb ist sie
/// generisch über den Inhalt.
public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decodable {
public let size: Int?
/// Wie viele Einträge es insgesamt gibt — wichtig fürs seitenweise
/// Nachladen, weil `size` nur die aktuelle Seite zählt.
public let totalSize: Int?
public let offset: Int?
public let title1: String?
public let title2: String?
public let content: [Content]
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey {
case size, totalSize, offset, title1, title2
case metadata = "Metadata"
case directory = "Directory"
}
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.size = try inner.decodeIfPresent(Int.self, forKey: .size)
self.totalSize = try inner.decodeIfPresent(Int.self, forKey: .totalSize)
self.offset = try inner.decodeIfPresent(Int.self, forKey: .offset)
self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1)
self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2)
// `Metadata` bei Titeln, `Directory` bei Bibliotheken und Staffeln.
// Eine leere Mediathek liefert gar kein Feld — das ist kein Fehler,
// sondern bedeutet schlicht null Einträge.
if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) {
self.content = metadata
} else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) {
self.content = directory
} else {
self.content = []
}
}
}
@@ -0,0 +1,142 @@
import Foundation
/// Eine Datei auf der Platte des Servers.
///
/// Plex schachtelt hier zweifach: ein Titel hat mehrere `Media` (dieselbe
/// Fassung in 4K und 1080p), und jedes `Media` hat `Part` (ein Film, der auf
/// zwei Dateien verteilt ist). Für die Wiedergabe zählt der `Part` — er trägt
/// den Pfad.
public struct PlexPart: Sendable, Equatable, Codable, Identifiable {
public let id: Int
/// Der Pfad, unter dem die Datei abrufbar ist.
public let key: String
public let container: String?
public let size: Int64?
public let duration: Int?
public let file: String?
public let streams: [PlexStream]
private enum CodingKeys: String, CodingKey {
case id, key, container, size, duration, file
// Plex schreibt verschachtelte Sammlungen groß.
case streams = "Stream"
}
public init(
id: Int,
key: String,
container: String? = nil,
size: Int64? = nil,
duration: Int? = nil,
file: String? = nil,
streams: [PlexStream] = []
) {
self.id = id
self.key = key
self.container = container
self.size = size
self.duration = duration
self.file = file
self.streams = streams
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.key = try container.decode(String.self, forKey: .key)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.size = try container.decodeIfPresent(Int64.self, forKey: .size)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.file = try container.decodeIfPresent(String.self, forKey: .file)
// Fehlt die Spurenliste, ist die Datei noch nicht analysiert. Das ist
// kein Fehler — es heißt nur, dass wir nicht über DirectPlay
// entscheiden können und im Zweifel transcodieren lassen.
self.streams = try container.decodeIfPresent([PlexStream].self, forKey: .streams) ?? []
}
public var videoStream: PlexStream? { streams.first { $0.kind == .video } }
public var audioStreams: [PlexStream] { streams.filter { $0.kind == .audio } }
public var subtitleStreams: [PlexStream] { streams.filter { $0.kind == .subtitle } }
/// Die Tonspur, die der Server als aktiv meldet — sonst die erste.
public var selectedAudioStream: PlexStream? {
audioStreams.first { $0.isSelected } ?? audioStreams.first
}
}
/// Eine Fassung eines Titels.
public struct PlexMedia: Sendable, Equatable, Codable, Identifiable {
public let id: Int
public let container: String?
public let videoCodec: String?
public let audioCodec: String?
/// Plex meldet hier Werte wie `"1080"`, `"4k"` oder `"sd"` — keine Zahl.
public let videoResolution: String?
public let videoProfile: String?
/// In Kilobit pro Sekunde.
public let bitrate: Int?
public let width: Int?
public let height: Int?
public let audioChannels: Int?
public let duration: Int?
public let parts: [PlexPart]
private enum CodingKeys: String, CodingKey {
case id, container, videoCodec, audioCodec, videoResolution, videoProfile
case bitrate, width, height, audioChannels, duration
case parts = "Part"
}
public init(
id: Int,
container: String? = nil,
videoCodec: String? = nil,
audioCodec: String? = nil,
videoResolution: String? = nil,
videoProfile: String? = nil,
bitrate: Int? = nil,
width: Int? = nil,
height: Int? = nil,
audioChannels: Int? = nil,
duration: Int? = nil,
parts: [PlexPart] = []
) {
self.id = id
self.container = container
self.videoCodec = videoCodec
self.audioCodec = audioCodec
self.videoResolution = videoResolution
self.videoProfile = videoProfile
self.bitrate = bitrate
self.width = width
self.height = height
self.audioChannels = audioChannels
self.duration = duration
self.parts = parts
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.container = try container.decodeIfPresent(String.self, forKey: .container)
self.videoCodec = try container.decodeIfPresent(String.self, forKey: .videoCodec)
self.audioCodec = try container.decodeIfPresent(String.self, forKey: .audioCodec)
self.videoResolution = try container.decodeIfPresent(String.self, forKey: .videoResolution)
self.videoProfile = try container.decodeIfPresent(String.self, forKey: .videoProfile)
self.bitrate = try container.decodeIfPresent(Int.self, forKey: .bitrate)
self.width = try container.decodeIfPresent(Int.self, forKey: .width)
self.height = try container.decodeIfPresent(Int.self, forKey: .height)
self.audioChannels = try container.decodeIfPresent(Int.self, forKey: .audioChannels)
self.duration = try container.decodeIfPresent(Int.self, forKey: .duration)
self.parts = try container.decodeIfPresent([PlexPart].self, forKey: .parts) ?? []
}
/// Eine kurze Beschreibung für die Versionsauswahl auf der Detailseite,
/// etwa „4K · HEVC · 38,2 Mbit/s".
public var qualityLabel: String {
var pieces: [String] = []
if let videoResolution { pieces.append(videoResolution.uppercased()) }
if let videoCodec { pieces.append(videoCodec.uppercased()) }
if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") }
return pieces.joined(separator: " · ")
}
}
@@ -0,0 +1,74 @@
import Foundation
/// Eine einzelne Spur in einer Datei: Bild, Ton oder Untertitel.
///
/// Die Feldnamen folgen der JSON-Antwort von Plex. Was dort fehlen darf, ist
/// hier optional — Plex lässt bei unvollständig analysierten Dateien ganze
/// Felder weg, und ein nicht-optionales Feld würde den gesamten Titel
/// unlesbar machen statt nur eine Angabe.
public struct PlexStream: Sendable, Equatable, Codable, Identifiable {
/// Plex unterscheidet die Spurart über eine Zahl.
public enum Kind: Int, Sendable, Codable {
case video = 1
case audio = 2
case subtitle = 3
}
public let id: Int
public let streamType: Int
public let codec: String?
public let language: String?
public let languageCode: String?
public let displayTitle: String?
public let title: String?
/// Bei Ton: die Kanalzahl. Entscheidet mit darüber, ob AVPlayer den
/// Ton annimmt — 5.1-DTS kann es nicht, 2.0-AAC schon.
public let channels: Int?
public let bitDepth: Int?
/// Nur bei Untertiteln: liegt die Spur als eigene Datei neben dem Video?
/// Solche lassen sich bei DirectPlay dazuschalten, eingebettete nicht
/// immer.
public let key: String?
private let selected: Bool?
private let `default`: Bool?
private let forced: Bool?
public var kind: Kind? { Kind(rawValue: streamType) }
public var isSelected: Bool { selected ?? false }
public var isDefault: Bool { `default` ?? false }
public var isForced: Bool { forced ?? false }
/// Untertitel, die als eigene Datei vorliegen.
public var isExternal: Bool { key != nil }
public init(
id: Int,
streamType: Int,
codec: String? = nil,
language: String? = nil,
languageCode: String? = nil,
displayTitle: String? = nil,
title: String? = nil,
channels: Int? = nil,
bitDepth: Int? = nil,
key: String? = nil,
selected: Bool? = nil,
default: Bool? = nil,
forced: Bool? = nil
) {
self.id = id
self.streamType = streamType
self.codec = codec
self.language = language
self.languageCode = languageCode
self.displayTitle = displayTitle
self.title = title
self.channels = channels
self.bitDepth = bitDepth
self.key = key
self.selected = selected
self.default = `default`
self.forced = forced
}
}