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,173 @@
import Testing
import PlexKit
@testable import FlixyPlayback
/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet.
///
/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst
/// werden kann: jede Änderung dort schlägt hier sichtbar durch.
@Suite("Wiedergabe-Entscheidung")
struct PlaybackDeciderTests {
/// Baut eine Datei mit den angegebenen Eigenschaften.
private func media(
container: String,
video: String,
audio: String,
bitrate: Int? = nil
) -> (PlexMedia, PlexPart) {
let streams = [
PlexStream(id: 1, streamType: 1, codec: video),
PlexStream(id: 2, streamType: 2, codec: audio, selected: true),
]
let part = PlexPart(id: 10, key: "/library/parts/10/1/file", container: container, streams: streams)
let media = PlexMedia(
id: 100,
container: container,
videoCodec: video,
audioCodec: audio,
bitrate: bitrate,
parts: [part]
)
return (media, part)
}
// MARK: - Was AVPlayer nativ kann
@Test(
"Native Formate gehen an AVPlayer",
arguments: [
("mp4", "h264", "aac"),
("mp4", "hevc", "eac3"),
("mov", "h264", "ac3"),
("m4v", "hevc", "alac"),
// Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist
// darin nicht einheitlich.
("MP4", "H264", "AAC"),
]
)
func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) {
let (media, part) = media(container: container, video: video, audio: audio)
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .avPlayer))
#expect(decision.rationale.contains(.nativelySupported))
}
// MARK: - Was VLC übernehmen muss
@Test("Matroska geht an VLC, auch bei nativen Codecs")
func matroskaFallsBackToVLC() {
let (media, part) = media(container: "mkv", video: "h264", audio: "aac")
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
}
@Test(
"Nicht unterstützter Ton geht an VLC",
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
)
func unsupportedAudioFallsBackToVLC(codec: String) {
let (media, part) = media(container: "mp4", video: "h264", audio: codec)
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
}
@Test(
"Nicht unterstütztes Bild geht an VLC",
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
)
func unsupportedVideoFallsBackToVLC(codec: String) {
let (media, part) = media(container: "mp4", video: codec, audio: "aac")
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
}
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
func decisionFollowsSelectedAudioTrack() {
// Deutsches AAC ist ausgewählt, englisches DTS liegt daneben. Würde
// der Decider nur die erste Tonspur ansehen, ginge diese Datei
// unnötig an VLC — und damit ohne Bild-in-Bild.
let streams = [
PlexStream(id: 1, streamType: 1, codec: "h264"),
PlexStream(id: 2, streamType: 2, codec: "dts", language: "English"),
PlexStream(id: 3, streamType: 2, codec: "aac", language: "Deutsch", selected: true),
]
let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: streams)
let media = PlexMedia(id: 100, container: "mp4", videoCodec: "h264", audioCodec: "dts", parts: [part])
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .avPlayer))
}
@Test("Unanalysierte Dateien gehen an den toleranteren Motor")
func unknownStreamsFallBackToVLC() {
let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: [])
let media = PlexMedia(id: 100, container: "mp4", parts: [part])
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
#expect(decision.method == .directPlay(engine: .vlc))
#expect(decision.rationale.contains(.streamsUnknown))
}
// MARK: - Die Bandbreitengrenze
@Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist")
func exceedingBitrateTranscodesWhenAllowed() {
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000)
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.method == .transcode(maximumBitrate: 8_000))
#expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000)))
}
@Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt")
func exceedingBitrateStillPlaysInPhaseOne() {
// Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft
// auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme.
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000)
let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.isDirectPlay)
#expect(decision.rationale.contains(.transcodingUnavailable))
}
@Test("Unter der Grenze bleibt alles beim Direktabspielen")
func withinBitrateLimitPlaysDirectly() {
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000)
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.method == .directPlay(engine: .avPlayer))
}
@Test("Ohne VLC übernimmt der Transcoder")
func withoutFallbackEngineTranscodingTakesOver() {
let (media, part) = media(container: "mkv", video: "h264", audio: "dts")
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.method == .transcode(maximumBitrate: nil))
}
@Test("Der Transcode-Weg läuft über AVPlayer")
func transcodingUsesAVPlayer() {
// HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive
// automatischem Bitratenwechsel. Deshalb darf VLC hier nicht greifen.
let (media, part) = media(container: "mkv", video: "h264", audio: "dts")
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
#expect(decision.engine == .avPlayer)
}
}