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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user