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,35 @@
import Foundation
/// Was AVPlayer auf diesem Gerät nativ abspielen kann.
///
/// Diese Liste ist der Grund, warum Flixy überhaupt zwei Motoren hat. iOS
/// spielt weder Matroska-Dateien noch DTS- oder TrueHD-Ton — und genau das
/// liegt in typischen Plex-Beständen. Alles, was hier nicht steht, geht an
/// VLCKit (oder ab Phase 2 an den Transcoder).
public struct DeviceCapabilities: Sendable, Equatable {
public var containers: Set<String>
public var videoCodecs: Set<String>
public var audioCodecs: Set<String>
public init(containers: Set<String>, videoCodecs: Set<String>, audioCodecs: Set<String>) {
self.containers = containers
self.videoCodecs = videoCodecs
self.audioCodecs = audioCodecs
}
/// Der Stand für iOS 26 auf allen Geräten, die diese Version tragen
/// (A12 aufwärts).
public static let avFoundation = DeviceCapabilities(
// Kein `mkv`, kein `avi`, kein `ts`. Das ist keine Auslassung, sondern
// der Kern des Problems: Matroska ist der übliche Container für
// Remuxe, und AVFoundation öffnet ihn nicht.
containers: ["mp4", "m4v", "mov", "qt"],
// AV1 fehlt bewusst: Hardware-Dekodierung gibt es erst ab A17 Pro, und
// Software-Dekodierung von 4K-AV1 bringt auch neue Geräte an die
// Grenze. VLC macht das kontrollierter.
videoCodecs: ["h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe"],
// DTS, TrueHD und Vorbis fehlen — die drei Gründe, warum ein sonst
// tadelloser MP4-Film trotzdem stumm bliebe.
audioCodecs: ["aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm"]
)
}
@@ -0,0 +1,101 @@
import Foundation
import PlexKit
/// Entscheidet, wie ein Titel abgespielt wird.
///
/// Reine Rechnung ohne Netz und ohne Zustand — deshalb vollständig testbar.
/// Die zugehörige Testtabelle in `PlaybackDeciderTests` ist die wichtigste im
/// Projekt: sie hält fest, welche Datei auf welchem Weg landet, und schlägt
/// an, sobald jemand die Fähigkeitenliste anfasst.
public struct PlaybackDecider: Sendable {
private let capabilities: DeviceCapabilities
public init(capabilities: DeviceCapabilities = .avFoundation) {
self.capabilities = capabilities
}
public func decide(
media: PlexMedia,
part: PlexPart,
policy: PlaybackPolicy
) -> PlaybackDecision {
var rationale: [PlaybackDecision.Rationale] = []
// 1. Reicht die Leitung? Das kommt zuerst, weil es unabhängig vom
// Format ist: eine 80-Mbit-Datei über Mobilfunk ist auch dann keine
// gute Idee, wenn AVPlayer sie problemlos öffnen könnte.
if let limit = policy.maximumBitrate, let bitrate = media.bitrate, bitrate > limit {
rationale.append(.bitrateExceedsLimit(mediaBitrate: bitrate, limit: limit))
if policy.allowsTranscoding {
return PlaybackDecision(method: .transcode(maximumBitrate: limit), rationale: rationale)
}
// Ohne Transcoder bleibt nur, es trotzdem zu versuchen. Das ist
// ehrlicher als eine Fehlermeldung: im WLAN klappt es meistens,
// und der Grund steht in der Begründung.
rationale.append(.transcodingUnavailable)
}
// 2. Kennt der Server die Datei überhaupt?
let container = normalized(part.container ?? media.container)
guard !part.streams.isEmpty, let videoCodec = normalized(media.videoCodec ?? part.videoStream?.codec) else {
rationale.append(.streamsUnknown)
return fallback(policy: policy, rationale: rationale)
}
// 3. Kann AVPlayer das?
var blockers: [PlaybackDecision.Rationale] = []
if let container, !capabilities.containers.contains(container) {
blockers.append(.containerUnsupported(container))
}
if !capabilities.videoCodecs.contains(videoCodec) {
blockers.append(.videoCodecUnsupported(videoCodec))
}
// Geprüft wird die Spur, die tatsächlich klingen soll. Eine Datei mit
// deutschem AAC und englischem DTS ist für AVPlayer in Ordnung,
// solange Deutsch gewählt ist.
if let audioCodec = normalized(part.selectedAudioStream?.codec ?? media.audioCodec),
!capabilities.audioCodecs.contains(audioCodec) {
blockers.append(.audioCodecUnsupported(audioCodec))
}
guard blockers.isEmpty else {
rationale.append(contentsOf: blockers)
return fallback(policy: policy, rationale: rationale)
}
rationale.append(.nativelySupported)
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
}
/// Was passiert, wenn AVPlayer nicht kann.
///
/// VLCKit hat Vorrang vor dem Transcoder: die Datei geht unverändert über
/// die Leitung, der Server muss nicht rechnen, und die Bildqualität bleibt
/// die des Originals. Umgerechnet wird erst, wenn auch VLC ausscheidet.
private func fallback(
policy: PlaybackPolicy,
rationale: [PlaybackDecision.Rationale]
) -> PlaybackDecision {
if policy.allowsFallbackEngine {
return PlaybackDecision(method: .directPlay(engine: .vlc), rationale: rationale)
}
if policy.allowsTranscoding {
return PlaybackDecision(
method: .transcode(maximumBitrate: policy.maximumBitrate),
rationale: rationale
)
}
var rationale = rationale
rationale.append(.transcodingUnavailable)
return PlaybackDecision(method: .directPlay(engine: .avPlayer), rationale: rationale)
}
/// Plex schreibt Codecs mal groß, mal klein, und hängt bei Containern
/// gelegentlich Punkte an. Ein Vergleich ohne diese Angleichung geht
/// sporadisch schief — und zwar nur bei manchen Dateien, was die Suche
/// nach dem Grund unangenehm macht.
private func normalized(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return nil }
return value.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ". "))
}
}
@@ -0,0 +1,58 @@
import Foundation
/// Welcher Motor die Wiedergabe übernimmt.
public enum PlaybackEngineKind: String, Sendable, Equatable, Codable {
/// Der Systemplayer. Bringt Bild-in-Bild, AirPlay und die Steuerung im
/// Sperrbildschirm mit — deshalb hat er immer Vorrang, wenn er kann.
case avPlayer
/// VLCKit. Spielt praktisch jedes Format, kann dafür kein Bild-in-Bild.
case vlc
}
/// Wie ein Titel abgespielt werden soll.
public struct PlaybackDecision: Sendable, Equatable {
public enum Method: Sendable, Equatable {
/// Die Datei wird unverändert übertragen und lokal dekodiert.
case directPlay(engine: PlaybackEngineKind)
/// Der Server rechnet um. Ab Phase 2; `maximumBitrate` in Kilobit.
case transcode(maximumBitrate: Int?)
}
/// Warum es so entschieden wurde.
///
/// Wandert in die technischen Infos auf der Detailseite und ins Protokoll.
/// Ohne diese Begründung ist „warum ruckelt das" nicht zu beantworten.
public enum Rationale: Sendable, Equatable {
case nativelySupported
case containerUnsupported(String)
case videoCodecUnsupported(String)
case audioCodecUnsupported(String)
/// Der Server hat die Datei noch nicht analysiert.
case streamsUnknown
case bitrateExceedsLimit(mediaBitrate: Int, limit: Int)
/// Transcoding wäre passender, ist aber (noch) nicht verfügbar.
case transcodingUnavailable
}
public let method: Method
public let rationale: [Rationale]
public init(method: Method, rationale: [Rationale]) {
self.method = method
self.rationale = rationale
}
public var engine: PlaybackEngineKind? {
switch method {
case .directPlay(let engine): engine
// Der Transcoder liefert HLS — das ist genau die Kost, für die
// AVPlayer gebaut ist, inklusive automatischem Bitratenwechsel.
case .transcode: .avPlayer
}
}
public var isDirectPlay: Bool {
if case .directPlay = method { return true }
return false
}
}
@@ -0,0 +1,30 @@
import Foundation
/// Was bei der Wiedergabe erlaubt ist.
///
/// Das ist die Stellschraube, an der Phase 2 andockt: heute steht
/// `allowsTranscoding` auf `false` und der Decider kommt zwangsläufig immer
/// bei DirectPlay heraus. Sobald der Transcoder steht, wird hier umgelegt —
/// die Views merken davon nichts.
public struct PlaybackPolicy: Sendable, Equatable {
/// Darf der Server umrechnen?
public var allowsTranscoding: Bool
/// Darf VLCKit einspringen?
public var allowsFallbackEngine: Bool
/// Obergrenze in Kilobit pro Sekunde. `nil` heißt unbegrenzt — der
/// Normalfall im heimischen WLAN.
public var maximumBitrate: Int?
public init(
allowsTranscoding: Bool,
allowsFallbackEngine: Bool = true,
maximumBitrate: Int? = nil
) {
self.allowsTranscoding = allowsTranscoding
self.allowsFallbackEngine = allowsFallbackEngine
self.maximumBitrate = maximumBitrate
}
/// Phase 1: alles direkt, nichts umrechnen.
public static let directPlayOnly = PlaybackPolicy(allowsTranscoding: false)
}
@@ -0,0 +1,71 @@
import Foundation
import PlexKit
/// Eine wählbare Ton- oder Untertitelspur.
///
/// Bewusst getrennt von `PlexStream`: VLCKit und AVPlayer nummerieren ihre
/// Spuren selbst, und die Nummern stimmen nicht mit denen von Plex überein.
/// `engineIdentifier` hält deshalb fest, wie der jeweilige Motor die Spur
/// nennt, während `plexStreamID` für den Server gedacht ist.
public struct MediaTrack: Sendable, Equatable, Identifiable {
public enum Kind: Sendable, Equatable { case audio, subtitle }
public let id: String
public let kind: Kind
public let displayName: String
public let languageCode: String?
public let plexStreamID: Int?
/// Die Nummer, unter der der laufende Motor die Spur führt.
public let engineIdentifier: Int?
public let isForced: Bool
public init(
id: String,
kind: Kind,
displayName: String,
languageCode: String? = nil,
plexStreamID: Int? = nil,
engineIdentifier: Int? = nil,
isForced: Bool = false
) {
self.id = id
self.kind = kind
self.displayName = displayName
self.languageCode = languageCode
self.plexStreamID = plexStreamID
self.engineIdentifier = engineIdentifier
self.isForced = isForced
}
}
extension MediaTrack {
/// Baut eine Spur aus dem, was Plex gemeldet hat.
///
/// `displayTitle` ist das, was Plex selbst in seinen Oberflächen zeigt
/// (etwa „Deutsch (AC3 5.1)"). Fehlt es, wird aus Sprache und Codec etwas
/// Brauchbares gebaut, statt „Spur 3" anzuzeigen.
public init?(stream: PlexStream) {
guard let kind = stream.kind else { return nil }
let trackKind: Kind
switch kind {
case .audio: trackKind = .audio
case .subtitle: trackKind = .subtitle
case .video: return nil
}
let name = stream.displayTitle
?? stream.title
?? [stream.language, stream.codec?.uppercased()]
.compactMap(\.self)
.joined(separator: " · ")
self.init(
id: "plex-\(stream.id)",
kind: trackKind,
displayName: name.isEmpty ? "—" : name,
languageCode: stream.languageCode,
plexStreamID: stream.id,
isForced: stream.isForced
)
}
}
@@ -0,0 +1,46 @@
import Foundation
/// Der Zustand der Wiedergabe, unabhängig vom Motor.
public enum PlaybackState: Sendable, Equatable {
case idle
/// Quelle wird geöffnet, es ist noch nichts zu sehen.
case loading
case playing
case paused
/// Läuft eigentlich, wartet aber auf Daten.
case buffering
case ended
case failed(PlaybackFailure)
public var isActive: Bool {
switch self {
case .playing, .buffering: true
case .idle, .loading, .paused, .ended, .failed: false
}
}
}
/// Ein Fehler bei der Wiedergabe, in der Sprache der App statt in der des
/// jeweiligen Motors.
public struct PlaybackFailure: Sendable, Equatable, Error {
public enum Kind: Sendable, Equatable {
/// Die Quelle war nicht erreichbar.
case sourceUnavailable
/// Der Motor kann das Format nicht — sollte nach einer korrekten
/// Entscheidung nicht vorkommen und ist deshalb ein Hinweis darauf,
/// dass die Fähigkeitenliste nachgezogen werden muss.
case formatUnsupported
case networkInterrupted
case unknown
}
public let kind: Kind
/// Der ursprüngliche Fehlertext. Wandert nicht in die Oberfläche, aber in
/// die Protokolle — sonst ist „geht nicht" alles, was man hat.
public let underlyingDescription: String?
public init(kind: Kind, underlyingDescription: String? = nil) {
self.kind = kind
self.underlyingDescription = underlyingDescription
}
}
@@ -0,0 +1,72 @@
import Foundation
/// Woher das Bild kommt.
public struct PlaybackSource: Sendable, Equatable {
public enum Origin: Sendable, Equatable {
/// Vom Server — per DirectPlay oder als HLS-Strom des Transcoders.
case remote
/// Aus dem Download-Ordner.
case local
}
public let url: URL
public let origin: Origin
/// Ab wo gestartet werden soll. Kommt aus dem zuletzt gemeldeten
/// Fortschritt; `.zero` heißt von vorn.
public let startTime: Duration
public init(url: URL, origin: Origin, startTime: Duration = .zero) {
self.url = url
self.origin = origin
self.startTime = startTime
}
}
/// Was ein Motor von sich aus meldet.
///
/// Ein Strom statt beobachtbarer Eigenschaften: AVPlayer meldet über KVO,
/// VLCKit über Delegat-Rückrufe. Beide in dieselbe Ereignisfolge zu
/// übersetzen hält den Unterschied an genau einer Stelle je Motor — statt in
/// jeder View, die den Zustand anzeigt.
public enum PlaybackEvent: Sendable, Equatable {
case stateChanged(PlaybackState)
case timeChanged(Duration)
case durationResolved(Duration)
case tracksResolved(audio: [MediaTrack], subtitle: [MediaTrack])
case reachedEnd
}
/// Der gemeinsame Nenner von AVPlayer und VLCKit.
///
/// Alles, was die Oberfläche zum Steuern braucht — und nichts darüber hinaus.
/// Was hier nicht steht, gibt es für die Views nicht; dadurch bleibt der
/// Austausch des Motors unsichtbar.
@MainActor
public protocol VideoPlayerEngine: AnyObject {
var events: AsyncStream<PlaybackEvent> { get }
var state: PlaybackState { get }
var currentTime: Duration { get }
var duration: Duration? { get }
/// Ob dieser Motor Bild-in-Bild kann. Steuert, ob der Knopf überhaupt
/// erscheint — ein Knopf, der bei manchen Dateien nichts tut, ist
/// schlimmer als keiner.
var supportsPictureInPicture: Bool { get }
func load(_ source: PlaybackSource) async throws(PlaybackFailure)
func play()
func pause()
func seek(to time: Duration) async
func setRate(_ rate: Double)
var audioTracks: [MediaTrack] { get }
var subtitleTracks: [MediaTrack] { get }
func selectAudioTrack(_ track: MediaTrack?)
/// `nil` schaltet die Untertitel ab.
func selectSubtitleTrack(_ track: MediaTrack?)
/// Gibt Puffer und Dekoder frei. Muss beim Verlassen des Players laufen,
/// sonst behält besonders VLCKit den Speicher.
func teardown()
}