Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player

Die Datenschicht:
- plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund
- Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor
  Vermittlung), mit Abgleich der Server-Kennung gegen veraltete
  plex.direct-Adressen
- PlexServer als einzige Stelle, an der die App Endpunkte kennt:
  Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten,
  Staffeln/Folgen, Suche

Die Oberfläche:
- Startseite mit Aufmacher und den Reihen vom Server
- Mediathek als Raster oder Liste, mit Filter- und Sortierblatt
- Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung,
  Fassungsauswahl und aufklappbaren technischen Angaben
- Suche mit gemerkten Anfragen
- Vollbildplayer mit eigenem Chrome

Der Player:
- AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll
- VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test`
  in FlixyKit nicht das 920-MB-Binärpaket mitzieht

Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf,
dass `strong=true` einen dreißigstelligen Code liefert, der sich bei
plex.tv/link nicht eintippen lässt — jetzt der kurze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 16:46:28 +02:00
co-authored by Claude Opus 5
parent 1ed64bf166
commit 9a69df4635
44 changed files with 6311 additions and 138 deletions
+8
View File
@@ -20,6 +20,14 @@ enum Branding {
/// stehen, bis er von Hand entfernt wird. /// stehen, bis er von Hand entfernt wird.
static let plexProduct = appName static let plexProduct = appName
/// Unter welchem Namen die Tokens im Schlüsselbund liegen.
///
/// An die Bundle-Kennung gebunden statt an den Anzeigenamen: eine spätere
/// Umbenennung der App soll niemanden abmelden.
static var keychainService: String {
Bundle.main.bundleIdentifier ?? "com.scarriffleservices.flixy"
}
/// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`. /// Die Version aus dem Bundle, für die Kopfzeile `X-Plex-Version`.
static var version: String { static var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
+20 -5
View File
@@ -1,22 +1,37 @@
import SwiftUI import SwiftUI
import FlixyDesign import FlixyDesign
import PlexKit
@main @main
struct FlixyApp: App { struct FlixyApp: App {
/// Lebt hier, nicht in einer View: die Akzentfarbe gilt für die gesamte /// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// App, und ein Neuaufbau der Wurzelansicht darf die Auswahl nicht /// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die
/// zurücksetzen. /// eingestellte Farbe zurücksetzen.
@State private var theme = FlixyTheme() @State private var theme = FlixyTheme()
@State private var session: FlixySession
@State private var playback = PlaybackCoordinator()
init() {
let identity = PlexClientIdentity.current(
product: Branding.plexProduct,
version: Branding.version
)
_session = State(initialValue: FlixySession(
identity: identity,
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(session)
.environment(playback)
.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
// Info.plist (UIUserInterfaceStyle). Diese Zeile ist die // Info.plist (UIUserInterfaceStyle). Diese Zeile ist die
// Absicherung für SwiftUI-Vorschauen, die die Info.plist // Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
// nicht lesen.
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.tint(theme.accentColor) .tint(theme.accentColor)
} }
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
import Foundation
import Observation
import PlexKit
/// Der Zustand der Verbindung zum Server.
///
/// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß,
/// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort,
/// an dem sich entscheidet, was auf dem Bildschirm erscheint.
@MainActor
@Observable
final class FlixySession {
enum Phase: Equatable {
/// Beim Start: liegt ein Token im Schlüsselbund?
case launching
case signedOut
/// Der Code steht bereit, der Nutzer bestätigt ihn im Browser.
case awaitingAuthorization(PlexPin)
/// Mehrere Server am Konto — einer muss gewählt werden.
case choosingServer([PlexResource])
/// Ein Weg zum Server wird gesucht.
case connecting(serverName: String)
case ready
case failed(message: String)
static func == (lhs: Phase, rhs: Phase) -> Bool {
switch (lhs, rhs) {
case (.launching, .launching), (.signedOut, .signedOut), (.ready, .ready):
true
case (.awaitingAuthorization(let l), .awaitingAuthorization(let r)):
l.id == r.id
case (.choosingServer(let l), .choosingServer(let r)):
l.map(\.clientIdentifier) == r.map(\.clientIdentifier)
case (.connecting(let l), .connecting(let r)):
l == r
case (.failed(let l), .failed(let r)):
l == r
default:
false
}
}
}
private enum Key {
static let preferredServer = "flixy.server.clientIdentifier"
}
/// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
/// bestätigten Code darüber diesem Gerät zu.
var clientIdentifier: String { identity.clientIdentifier }
private(set) var phase: Phase = .launching
private(set) var server: PlexServer?
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [PlexLibrary] = []
private let identity: PlexClientIdentity
private let tokenStore: any TokenStoring
private let authenticator: PlexAuthenticator
private let defaults: UserDefaults
private var authorizationTask: Task<Void, Never>?
init(
identity: PlexClientIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.identity = identity
self.tokenStore = tokenStore
self.defaults = defaults
self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore)
}
// MARK: - Start
/// Versucht, mit einem bereits gespeicherten Token weiterzumachen.
func restore() async {
guard let token = authenticator.storedToken else {
phase = .signedOut
return
}
await connect(accountToken: token)
}
// MARK: - Anmeldung
func beginSignIn() async {
do {
let pin = try await authenticator.requestPin()
phase = .awaitingAuthorization(pin)
waitForAuthorization(of: pin)
} catch {
phase = .failed(message: message(for: error))
}
}
func cancelSignIn() {
authorizationTask?.cancel()
authorizationTask = nil
phase = .signedOut
}
private func waitForAuthorization(of pin: PlexPin) {
authorizationTask?.cancel()
authorizationTask = Task { [authenticator] in
do {
let token = try await authenticator.waitForAuthorization(pin: pin)
guard !Task.isCancelled else { return }
await connect(accountToken: token)
} catch {
guard !Task.isCancelled else { return }
phase = .failed(message: message(for: error))
}
}
}
func signOut() {
authorizationTask?.cancel()
authenticator.signOut()
defaults.removeObject(forKey: Key.preferredServer)
server = nil
libraries = []
phase = .signedOut
}
// MARK: - Verbinden
private func connect(accountToken: String) async {
phase = .connecting(serverName: "")
let directory = PlexServerDirectory(identity: identity, token: accountToken)
let servers: [PlexResource]
do {
servers = try await directory.servers()
} catch PlexError.unauthorized {
// Der Token wurde am Konto zurückgezogen. Ihn zu behalten würde
// bei jedem Start denselben Fehler erzeugen.
authenticator.signOut()
phase = .signedOut
return
} catch {
phase = .failed(message: message(for: error))
return
}
guard !servers.isEmpty else {
phase = .failed(message: String(localized: "error.noServers"))
return
}
// Beim zweiten Start soll niemand erneut wählen müssen.
let remembered = defaults.string(forKey: Key.preferredServer)
if let chosen = servers.first(where: { $0.clientIdentifier == remembered }) ?? servers.singleElement {
await connect(to: chosen)
} else {
phase = .choosingServer(servers)
}
}
func connect(to resource: PlexResource) async {
phase = .connecting(serverName: resource.name)
let selector = PlexConnectionSelector(identity: identity)
guard let connection = await selector.selectConnection(for: resource) else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
}
guard let server = PlexServer(
resource: resource,
connection: connection,
identity: identity
) else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
}
self.server = server
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
do {
libraries = try await server.libraries().filter(\.isSupported)
phase = .ready
} catch {
phase = .failed(message: message(for: error))
}
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if let token = authenticator.storedToken {
await connect(accountToken: token)
} else {
phase = .signedOut
}
}
private func message(for error: any Error) -> String {
guard let plexError = error as? PlexError else { return error.localizedDescription }
return switch plexError {
case .unauthorized: String(localized: "error.unauthorized")
case .notFound: String(localized: "error.notFound")
case .noReachableConnection: String(localized: "error.serverUnreachable")
case .server(let status): String(localized: "error.server \(status)")
case .transport(let description): description
case .decoding: String(localized: "error.unexpectedResponse")
}
}
}
extension Array {
/// Das einzige Element — oder `nil`, wenn es keines oder mehrere gibt.
var singleElement: Element? { count == 1 ? first : nil }
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
import Observation
import PlexKit
/// Was abgespielt werden soll.
struct PlaybackRequest: Identifiable, Hashable {
let id = UUID()
let ratingKey: String
/// Für die Titelzeile, solange die Metadaten noch geladen werden.
let title: String
/// Welche Fassung, wenn ein Titel in mehreren vorliegt.
let mediaIndex: Int
/// Ab wo — aus dem gemeldeten Fortschritt.
let startTime: Duration
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
func hash(into hasher: inout Hasher) { hasher.combine(id) }
}
/// Nimmt Abspielwünsche von überall entgegen.
///
/// Ohne diese Stelle bräuchte jeder Bildschirm, von dem aus sich etwas starten
/// lässt — Aufmacher, Reihe, Detailseite, Suche — seinen eigenen
/// Vollbild-Übergang. Vier Kopien desselben Ablaufs, die auseinanderdriften.
@MainActor
@Observable
final class PlaybackCoordinator {
var request: PlaybackRequest?
func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) {
request = PlaybackRequest(
ratingKey: item.ratingKey,
title: item.playbackTitle,
mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero
)
}
func dismiss() {
request = nil
}
}
extension PlexItem {
/// Wie der Titel im Player heißt.
///
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
/// sagt nichts darüber, welche Serie läuft.
var playbackTitle: String {
if kind == .episode, let series = grandparentTitle {
return "\(series) · \(title)"
}
return title
}
/// Ab welcher Stelle fortgesetzt wird.
var resumeTime: Duration {
guard let viewOffset, viewOffset > 0 else { return .zero }
return .milliseconds(viewOffset)
}
/// Die Beschriftung unter einer Kachel.
var cardSubtitle: String? {
switch kind {
case .episode:
if let season = parentIndex, let episode = index {
return String(localized: "episode.short \(season) \(episode)")
}
return grandparentTitle
case .movie:
return year.map(String.init)
default:
return year.map(String.init)
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Detailseite eines Titels.
///
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
struct MediaDetailView: View {
let route: MediaRoute
@Environment(FlixySession.self) private var session
@State private var state: LoadState<PlexItem> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded(let item):
switch item.kind {
case .show, .season:
SeriesDetailView(item: item)
default:
PlayableDetailView(item: item)
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(state.value?.title ?? route.title)
.navigationBarTitleDisplayMode(.inline)
.task(id: route.ratingKey) { await load() }
}
private func load() async {
guard let server = session.server else { return }
state = await LoadState.load {
try await server.metadata(ratingKey: route.ratingKey)
}
}
}
+205
View File
@@ -0,0 +1,205 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
struct PlayableDetailView: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@State private var selectedVersion = 0
@State private var showsTechnicalDetails = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
header
if item.hasMultipleVersions {
VersionPicker(media: item.media, selection: $selectedVersion)
.padding(.horizontal, FlixySpacing.screenMargin)
}
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.screenMargin)
}
if !item.genres.isEmpty {
TagStrip(tags: item.genres)
}
if !item.roles.isEmpty {
CastRow(roles: item.roles)
}
if let media = currentMedia, let part = media.parts.first {
TechnicalDetails(
media: media,
part: part,
isExpanded: $showsTechnicalDetails
)
.padding(.horizontal, FlixySpacing.screenMargin)
}
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
}
private var currentMedia: PlexMedia? {
guard item.media.indices.contains(selectedVersion) else { return item.media.first }
return item.media[selectedVersion]
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.grandparentArt ?? item.thumb,
width: 900,
height: 1200
)
) {
Button {
playback.play(item, mediaIndex: selectedVersion)
} label: {
Label(
item.watchProgress == nil ? "hero.play" : "hero.resume",
systemImage: "play.fill"
)
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(currentMedia?.parts.first == nil)
if let progress = item.watchProgress, progress > 0 {
Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: {
Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.padding(FlixySpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
}
}
}
private var metadataLine: String? {
var pieces: [String] = []
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
pieces.append(String(localized: "episode.short \(season) \(episode)"))
}
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
/// Auswahl zwischen mehreren Fassungen desselben Titels.
struct VersionPicker: View {
let media: [PlexMedia]
@Binding var selection: Int
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text("detail.versions")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
Picker("detail.versions", selection: $selection) {
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
Text(version.qualityLabel).tag(index)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
}
/// Genres als Streifen.
struct TagStrip: View {
let tags: [PlexTag]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
ForEach(tags) { tag in
Text(tag.tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background(Color.flixyElevated, in: .capsule)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
.scrollIndicators(.hidden)
}
}
/// Die Besetzung mit Porträts.
struct CastRow: View {
let roles: [PlexTag]
@Environment(FlixySession.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text("detail.cast")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
ForEach(roles.prefix(20)) { role in
VStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.server?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
)
.frame(width: 72, height: 72)
.clipShape(.circle)
Text(role.tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.center)
if let character = role.role {
Text(character)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
.frame(width: 90)
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
.scrollIndicators(.hidden)
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Detailseite einer Serie oder Staffel.
///
/// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste
/// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen.
struct SeriesDetailView: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
@State private var children: LoadState<[PlexItem]> = .loading
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
header
if let summary = item.summary, !summary.isEmpty {
Text(summary)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.screenMargin)
}
if !item.genres.isEmpty { TagStrip(tags: item.genres) }
childList
if !item.roles.isEmpty { CastRow(roles: item.roles) }
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
.task(id: item.ratingKey) { await loadChildren() }
}
private var header: some View {
HeroBanner(
title: item.title,
metadata: metadataLine,
summary: nil,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.thumb,
width: 900,
height: 1200
)
) {
EmptyView()
}
}
@ViewBuilder
private var childList: some View {
switch children {
case .loading:
ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlixySpacing.section)
case .failed(let message):
Text(message)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.screenMargin)
case .loaded(let entries):
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
NavigationLink(value: MediaRoute(ratingKey: entry.ratingKey, title: entry.title)) {
ChildRow(item: entry)
}
.buttonStyle(.plain)
Divider().overlay(Color.flixySeparator)
}
}
}
}
}
private var metadataLine: String? {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let leafCount = item.leafCount {
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
}
if let viewed = item.viewedLeafCount, let total = item.leafCount, viewed < total {
pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)"))
}
if let rating = item.contentRating { pieces.append(rating) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
private func loadChildren() async {
guard let server = session.server else { return }
children = await LoadState.load {
try await server.children(of: item.ratingKey)
}
}
}
/// Eine Staffel- oder Folgenzeile.
private struct ChildRow: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(
path: item.thumb,
width: item.kind == .season ? 120 : 240,
height: item.kind == .season ? 180 : 135
),
aspectRatio: item.kind == .season ? PosterCard.aspectRatio : LandscapeCard.aspectRatio,
fallbackSymbol: "play.rectangle"
)
.frame(width: item.kind == .season ? 48 : 96)
.clipShape(.rect(cornerRadius: 4))
.overlay(alignment: .bottom) {
if let progress = item.watchProgress {
WatchProgressBar(fraction: progress)
}
}
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(rowTitle)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
private var rowTitle: String {
if item.kind == .episode, let episode = item.index {
return "\(episode). \(item.title)"
}
return item.title
}
private var subtitle: String? {
switch item.kind {
case .season:
item.leafCount.map { String(localized: "detail.episodeCount \($0)") }
case .episode:
item.duration.map { Duration.milliseconds($0).runtimeLabel }
default:
nil
}
}
}
+128
View File
@@ -0,0 +1,128 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Die aufklappbaren technischen Angaben zu einer Datei.
///
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flixy sie
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
struct TechnicalDetails: View {
let media: PlexMedia
let part: PlexPart
@Binding var isExpanded: Bool
private var decision: PlaybackDecision {
PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
}
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
row("technical.playbackMethod", playbackMethodLabel)
if let reason = rationaleLabel {
row("technical.reason", reason)
}
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
if let container = part.container { row("technical.container", container.uppercased()) }
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
if let width = media.width, let height = media.height {
row("technical.resolution", "\(width) × \(height)")
}
if let bitrate = media.bitrate {
row("technical.bitrate", "\(bitrate / 1000) Mbit/s")
}
if let size = part.size {
row("technical.size", size.formatted(.byteCount(style: .file)))
}
if !part.audioStreams.isEmpty {
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
ForEach(part.audioStreams) { stream in
row("technical.audio", trackLabel(stream))
}
}
if !part.subtitleStreams.isEmpty {
ForEach(part.subtitleStreams) { stream in
row("technical.subtitle", trackLabel(stream))
}
}
if let file = part.file {
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
VStack(alignment: .leading, spacing: 2) {
Text("technical.file")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
Text(file)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextSecondary)
.textSelection(.enabled)
}
}
}
.padding(.top, FlixySpacing.snug)
} label: {
Text("technical.title")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
}
.tint(Color.flixyTextSecondary)
}
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
Spacer()
Text(value)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.multilineTextAlignment(.trailing)
}
}
private func trackLabel(_ stream: PlexStream) -> String {
stream.displayTitle ?? [stream.language, stream.codec?.uppercased()]
.compactMap(\.self)
.joined(separator: " · ")
}
private var playbackMethodLabel: String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: String(localized: "technical.directPlayNative")
case .vlc: String(localized: "technical.directPlayVLC")
}
case .transcode:
String(localized: "technical.transcode")
}
}
/// Der erste Grund, der gegen den Systemplayer sprach — mehrere
/// aufzuzählen hilft niemandem, es reicht einer.
private var rationaleLabel: String? {
for reason in decision.rationale {
switch reason {
case .containerUnsupported(let container):
return String(localized: "technical.reason.container \(container.uppercased())")
case .videoCodecUnsupported(let codec):
return String(localized: "technical.reason.videoCodec \(codec.uppercased())")
case .audioCodecUnsupported(let codec):
return String(localized: "technical.reason.audioCodec \(codec.uppercased())")
case .streamsUnknown:
return String(localized: "technical.reason.unanalyzed")
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
continue
}
}
return nil
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Der Aufmacher oben auf der Startseite.
struct HeroSection: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
var body: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadata,
summary: item.summary,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.grandparentArt ?? item.thumb,
width: 900,
height: 1200
)
) {
Button {
playback.play(item)
} label: {
Label(playLabel, systemImage: "play.fill")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
Label("hero.details", systemImage: "info.circle")
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
}
}
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
private var playLabel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume"
}
private var metadata: String? {
var pieces: [String] = []
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
pieces.append(String(localized: "episode.short \(season) \(episode)"))
}
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
extension Duration {
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
var runtimeLabel: String {
let totalMinutes = Int(components.seconds / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 {
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
}
return String(localized: "runtime.minutes \(minutes)")
}
}
+78
View File
@@ -0,0 +1,78 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
struct HomeView: View {
@Environment(FlixySession.self) private var session
@State private var state: LoadState<[PlexHub]> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded(let hubs):
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
} description: {
Text("home.empty.description")
}
} else {
content(hubs: hubs)
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(Branding.appName)
.navigationBarTitleDisplayMode(.inline)
.toolbar { SearchToolbarItem() }
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.task(id: session.server?.name) { await load() }
.refreshable { await load() }
}
private func content(hubs: [PlexHub]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
if let featured = heroItem(in: hubs) {
HeroSection(item: featured)
}
ForEach(hubs) { hub in
MediaRow(hub: hub)
}
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
}
/// Was oben groß gezeigt wird.
///
/// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens
/// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der
/// Neuzugang an die Stelle.
private func heroItem(in hubs: [PlexHub]) -> PlexItem? {
let continuing = hubs.first { $0.hubIdentifier.contains("continue") }
return continuing?.items.first ?? hubs.first?.items.first
}
private func load() async {
guard let server = session.server else { return }
state = await LoadState.load(keepingPrevious: state) {
try await server.homeHubs()
}
}
}
+73
View File
@@ -0,0 +1,73 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Eine waagerechte Reihe von Titeln.
struct MediaRow: View {
let hub: PlexHub
@Environment(FlixySession.self) private var session
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
private let cardWidth: CGFloat = 220
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(hub.title)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
ScrollView(.horizontal) {
LazyHStack(spacing: FlixySpacing.regular) {
ForEach(hub.items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
LandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(
path: item.rowArtworkPath,
width: 440,
height: 248
),
progress: item.watchProgress
)
.frame(width: cardWidth)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
// bleibt an beliebiger Stelle stehen.
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.scrollIndicators(.hidden)
}
}
}
extension PlexItem {
/// Der Titel auf einer Kachel in einer Reihe.
///
/// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen
/// verschiedener Serien nebeneinander, und der Folgentitel allein sagt
/// nicht, worum es geht.
var rowTitle: String {
kind == .episode ? (grandparentTitle ?? title) : title
}
/// Welches Bild auf die Querformat-Kachel gehört.
///
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
var rowArtworkPath: String? {
switch kind {
case .episode: thumb ?? grandparentThumb
default: art ?? thumb
}
}
}
@@ -0,0 +1,104 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View {
@Binding var query: LibraryQuery
let libraryKind: PlexLibrary.Kind?
@Environment(\.dismiss) private var dismiss
@Environment(FlixySession.self) private var session
@State private var genres: [PlexFilterOption] = []
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
///
/// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: LibraryQuery
init(query: Binding<LibraryQuery>, libraryKind: PlexLibrary.Kind?) {
self._query = query
self.libraryKind = libraryKind
self._draft = State(initialValue: query.wrappedValue)
}
var body: some View {
NavigationStack {
Form {
Section("filter.sort") {
Picker("filter.sort", selection: $draft.sort) {
ForEach(LibraryQuery.Sort.allCases, id: \.self) { sort in
Text(sort.label).tag(sort)
}
}
.labelsHidden()
.pickerStyle(.inline)
}
Section("filter.filters") {
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none)
ForEach(genres) { genre in
Text(genre.title).tag(String?.some(genre.key))
}
}
Picker("filter.resolution", selection: $draft.resolution) {
Text("filter.any").tag(String?.none)
Text("4K").tag(String?.some("4k"))
Text("1080p").tag(String?.some("1080"))
Text("720p").tag(String?.some("720"))
Text("SD").tag(String?.some("sd"))
}
}
Section {
Button("filter.reset", role: .destructive) {
draft = LibraryQuery(sort: draft.sort)
}
.disabled(!draft.isFiltering)
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("filter.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("action.apply") {
query = draft
dismiss()
}
}
}
.task { await loadGenres() }
}
.presentationDetents([.medium, .large])
}
private func loadGenres() async {
guard let server = session.server,
let library = session.libraries.first(where: { $0.kind == libraryKind })
else { return }
genres = (try? await server.filterOptions(inLibrary: library, field: "genre")) ?? []
}
}
extension LibraryQuery.Sort {
var label: LocalizedStringKey {
switch self {
case .titleAscending: "sort.titleAscending"
case .titleDescending: "sort.titleDescending"
case .recentlyAdded: "sort.recentlyAdded"
case .releaseDate: "sort.releaseDate"
case .rating: "sort.rating"
case .lastViewed: "sort.lastViewed"
}
}
}
+242
View File
@@ -0,0 +1,242 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Eine Mediathek zum Durchblättern.
struct LibraryView: View {
let library: PlexLibrary
@Environment(FlixySession.self) private var session
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
@State private var items: [PlexItem] = []
@State private var state: LoadState<Void> = .loading
@State private var query = LibraryQuery()
@State private var page: PlexPage<PlexItem>?
@State private var isLoadingMore = false
@State private var showsFilters = false
var body: some View {
Group {
switch state {
case .loading where items.isEmpty:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message) where items.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await reload() } }
}
default:
if items.isEmpty {
ContentUnavailableView {
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
Button("library.clearFilters") {
query = LibraryQuery(sort: query.sort)
}
}
}
} else {
contents
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(library.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, libraryKind: library.kind)
}
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
.task(id: library.key) { await reload() }
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.refreshable { await reload() }
}
@ViewBuilder
private var contents: some View {
switch layout {
case .grid: grid
case .list: list
}
}
private var grid: some View {
ScrollView {
LazyVGrid(
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
// ohne dass die Größenklasse abgefragt werden muss.
columns: [GridItem(.adaptive(minimum: 110), spacing: FlixySpacing.regular)],
spacing: FlixySpacing.loose
) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress
)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
}
}
.padding(FlixySpacing.screenMargin)
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
}
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(items) { item in
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
LibraryListRow(item: item)
}
.buttonStyle(.plain)
.task { await loadMoreIfNeeded(reaching: item) }
Divider().overlay(Color.flixySeparator)
}
}
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("library.filters"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
layout = layout == .grid ? .list : .grid
} label: {
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
}
.accessibilityLabel(Text("library.toggleLayout"))
}
SearchToolbarItem()
}
// MARK: - Laden
private func reload() async {
guard let server = session.server else { return }
state = .loading
do {
let first = try await server.items(inLibrary: library, query: query)
items = first.items
page = first
state = .loaded(())
} catch is CancellationError {
return
} catch {
state = .failed(message: PlexErrorText.describe(error))
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: PlexItem) async {
guard let server = session.server,
let page, page.hasMore,
!isLoadingMore,
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
// jedes Scrollen eine Anfrage auslöst.
items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey })
else { return }
isLoadingMore = true
defer { isLoadingMore = false }
do {
let next = try await server.items(
inLibrary: library,
query: query,
offset: page.nextOffset
)
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
// und SwiftUI beschwert sich über doppelte Kennungen.
let known = Set(items.map(\.ratingKey))
items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) })
self.page = next
} catch {
// Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten
// im Scrollen wäre störender als ein paar fehlende Einträge.
self.page = nil
}
}
}
enum LibraryLayout: String, CaseIterable {
case grid, list
}
/// Eine Zeile in der Listenansicht.
private struct LibraryListRow: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
var body: some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = item.cardSubtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
Spacer()
if item.isWatched {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.flixyTextMuted)
.accessibilityLabel(Text("item.watched"))
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.snug)
.contentShape(.rect)
.accessibilityElement(children: .combine)
}
}
+73
View File
@@ -0,0 +1,73 @@
import Foundation
import PlexKit
/// Der Ladezustand eines Bildschirms.
///
/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`,
/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und
/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden,
/// was dann gilt.
enum LoadState<Value: Sendable>: Sendable {
case loading
case loaded(Value)
case failed(message: String)
var value: Value? {
if case .loaded(let value) = self { return value }
return nil
}
}
extension LoadState {
/// Führt eine Ladeoperation aus und übersetzt Fehler in Text.
///
/// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei
/// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch
/// einen Ladekreis ersetzt zu werden.
///
/// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen
/// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable`
/// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt.
@MainActor
static func load(
keepingPrevious previous: Self? = nil,
_ operation: () async throws -> Value
) async -> Self {
do {
return .loaded(try await operation())
} catch is CancellationError {
// Der Bildschirm wurde verlassen, während geladen wurde. Ein
// Fehlertext wäre hier falsch — es ist niemand mehr da, den er
// interessiert.
return previous ?? .loading
} catch {
return .failed(message: PlexErrorText.describe(error))
}
}
}
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
enum PlexErrorText {
static func describe(_ error: any Error) -> String {
guard let plexError = error as? PlexError else { return error.localizedDescription }
return switch plexError {
case .unauthorized: String(localized: "error.unauthorized")
case .notFound: String(localized: "error.notFound")
case .noReachableConnection: String(localized: "error.serverUnreachable")
case .server(let status): String(localized: "error.server \(status)")
case .transport(let description): description
case .decoding: String(localized: "error.unexpectedResponse")
}
}
}
/// Wohin ein Tipp auf eine Kachel führt.
///
/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der
/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die
/// Detailseite muss den Titel ohnehin frisch holen.
struct MediaRoute: Hashable {
let ratingKey: String
/// Für die Titelzeile, solange noch geladen wird.
let title: String
}
+80 -22
View File
@@ -1,49 +1,103 @@
import SwiftUI import SwiftUI
import FlixyDesign import FlixyDesign
import PlexKit
/// Die Wurzelansicht. /// Die Wurzelansicht.
/// ///
/// Legt fest, in welcher Form die Seitenleiste erscheint: auf dem iPhone als /// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
/// ausfahrbares Blatt, auf dem iPad dauerhaft neben dem Inhalt. Beide Wege /// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
/// nutzen dieselbe `SidebarView` — der Unterschied liegt nur im Rahmen. /// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
struct RootView: View { struct RootView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass @Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home @State private var selection: SidebarDestination = .home
@State private var isSidebarOpen = false @State private var isSidebarOpen = false
/// Kommt in Phase 1 vom Server. Solange leer, blendet die Seitenleiste den
/// Abschnitt aus, statt eine leere Überschrift zu zeigen.
@State private var libraries: [SidebarDestination] = []
var body: some View { var body: some View {
Group { Group {
if sizeClass == .regular { switch session.phase {
splitLayout case .launching:
} else { ProgressView()
drawerLayout .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
case .signedOut, .awaitingAuthorization:
SignInView(
phase: session.phase,
clientIdentifier: session.clientIdentifier,
onSignIn: { Task { await session.beginSignIn() } },
onCancel: { session.cancelSignIn() }
)
case .choosingServer(let servers):
NavigationStack {
ServerPickerView(servers: servers) { resource in
Task { await session.connect(to: resource) }
}
}
case .connecting(let name):
ConnectingView(serverName: name)
case .failed(let message):
ConnectionFailureView(
message: message,
onRetry: { Task { await session.retry() } },
onSignOut: { session.signOut() }
)
case .ready:
library
} }
} }
.background(Color.flixyCanvas) .background(Color.flixyCanvas)
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
.fullScreenCover(item: Bindable(playback).request) { request in
PlayerView(request: request)
}
.task { await session.restore() }
.onChange(of: session.phase) { _, newPhase in
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
// bleiben, die es auf dem neuen Server gar nicht gibt.
if newPhase == .ready, case .library = selection { selection = .home }
}
} }
/// iPad: die Seitenleiste ist Teil des Fensters und lässt sich über den @ViewBuilder
/// Systemknopf einklappen. private var library: some View {
if sizeClass == .regular {
splitLayout
} else {
drawerLayout
}
}
private var sidebarDestinations: [SidebarDestination] {
session.libraries.map { .library(key: $0.key, title: $0.title) }
}
/// iPad: die Seitenleiste ist Teil des Fensters.
private var splitLayout: some View { private var splitLayout: some View {
NavigationSplitView { NavigationSplitView {
SidebarView(libraries: libraries, selection: $selection, showsHeader: false) {} SidebarView(
.navigationTitle(Branding.appName) libraries: sidebarDestinations,
.toolbar(removing: .sidebarToggle) selection: $selection,
showsHeader: false
) {}
.navigationTitle(Branding.appName)
} detail: { } detail: {
NavigationStack { NavigationStack { destination }
destination
}
} }
} }
/// iPhone: die Seitenleiste fährt über den Inhalt. /// iPhone: die Seitenleiste fährt über den Inhalt.
private var drawerLayout: some View { private var drawerLayout: some View {
DrawerContainer(isOpen: $isSidebarOpen) { DrawerContainer(isOpen: $isSidebarOpen) {
SidebarView(libraries: libraries, selection: $selection) { SidebarView(libraries: sidebarDestinations, selection: $selection) {
isSidebarOpen = false isSidebarOpen = false
} }
} content: { } content: {
@@ -67,15 +121,19 @@ struct RootView: View {
private var destination: some View { private var destination: some View {
switch selection { switch selection {
case .home: case .home:
PlaceholderScreen(destination: .home) HomeView()
case .library(let key, let title): case .library(let key, let title):
PlaceholderScreen(destination: .library(key: key, title: title)) if let library = session.libraries.first(where: { $0.key == key }) {
LibraryView(library: library)
} else {
PlaceholderScreen(destination: .library(key: key, title: title))
}
case .liveTV: case .liveTV:
PlaceholderScreen(destination: .liveTV) PlaceholderScreen(destination: .liveTV)
case .downloads: case .downloads:
PlaceholderScreen(destination: .downloads) PlaceholderScreen(destination: .downloads)
case .settings: case .settings:
PlaceholderScreen(destination: .settings) SettingsView()
} }
} }
} }
+160
View File
@@ -0,0 +1,160 @@
import Foundation
import Observation
import FlixyPlayback
import FlixyVLC
import PlexKit
/// Führt einen Abspielwunsch aus.
///
/// 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
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
/// an der die Bildfläche gebaut wird.
@MainActor
@Observable
final class PlayerModel {
enum Phase: Equatable {
case preparing
case playing
case failed(message: String)
}
private(set) var phase: Phase = .preparing
private(set) var engine: (any VideoPlayerEngine)?
private(set) var decision: PlaybackDecision?
private(set) var title: String
private(set) var subtitle: String?
private(set) var currentTime: Duration = .zero
private(set) var duration: Duration?
private(set) var isPlaying = false
private let request: PlaybackRequest
private let server: PlexServer?
private var eventTask: Task<Void, Never>?
init(request: PlaybackRequest, server: PlexServer?) {
self.request = request
self.server = server
self.title = request.title
}
func start() async {
guard let server else {
phase = .failed(message: String(localized: "error.serverUnreachable"))
return
}
let item: PlexItem
do {
item = try await server.metadata(ratingKey: request.ratingKey)
} catch {
phase = .failed(message: PlexErrorText.describe(error))
return
}
title = item.rowTitle
subtitle = playerSubtitle(for: item)
guard let media = version(of: item),
let part = media.parts.first,
let url = server.directPlayURL(part: part)
else {
phase = .failed(message: String(localized: "player.noPlayableFile"))
return
}
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
self.decision = decision
let engine = makeEngine(for: decision)
self.engine = engine
observe(engine)
do {
try await engine.load(PlaybackSource(
url: url,
origin: .remote,
startTime: request.startTime
))
engine.play()
phase = .playing
} catch {
phase = .failed(message: String(localized: "player.loadFailed"))
}
}
func togglePlayPause() {
guard let engine else { return }
if isPlaying { engine.pause() } else { engine.play() }
}
func seek(to time: Duration) {
guard let engine else { return }
Task { await engine.seek(to: time) }
}
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
func skip(by offset: Duration) {
guard let duration else { return }
let target = currentTime + offset
seek(to: max(.zero, min(target, duration)))
}
func stop() {
eventTask?.cancel()
engine?.teardown()
engine = nil
}
// MARK: - Innereien
private func version(of item: PlexItem) -> PlexMedia? {
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
return item.media[request.mediaIndex]
}
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
switch decision.engine {
case .vlc: VLCPlayerEngine()
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
case .avPlayer, .none: AVPlayerEngine()
}
}
private func observe(_ engine: any VideoPlayerEngine) {
eventTask?.cancel()
eventTask = Task { [weak self] in
for await event in engine.events {
guard let self else { return }
switch event {
case .stateChanged(let state):
isPlaying = state == .playing
if case .failed = state {
phase = .failed(message: String(localized: "player.playbackFailed"))
}
case .timeChanged(let time):
currentTime = time
case .durationResolved(let value):
duration = value
case .reachedEnd:
isPlaying = false
case .tracksResolved:
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
// schon — die Ansicht bietet sie nur noch nicht an.
break
}
}
}
}
private func playerSubtitle(for item: PlexItem) -> String? {
guard item.kind == .episode else { return nil }
if let season = item.parentIndex, let episode = item.index {
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
}
return item.title
}
}
+251
View File
@@ -0,0 +1,251 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Der Vollbildplayer.
struct PlayerView: View {
let request: PlaybackRequest
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@State private var model: PlayerModel?
@State private var showsControls = true
@State private var hideTask: Task<Void, Never>?
@State private var scrubbing: Double?
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let model {
VideoSurface(engine: model.engine)
.ignoresSafeArea()
switch model.phase {
case .preparing:
ProgressView().tint(.white)
case .failed(let message):
failure(message: message)
case .playing:
if showsControls {
controls(for: model)
.transition(.opacity)
}
}
}
}
.statusBarHidden(showsControls == false)
// 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)
.contentShape(.rect)
.onTapGesture { toggleControls() }
.task {
let model = PlayerModel(request: request, server: session.server)
self.model = model
await model.start()
scheduleControlsHide()
}
.onDisappear {
hideTask?.cancel()
model?.stop()
}
}
// MARK: - Bedienelemente
private func controls(for model: PlayerModel) -> some View {
VStack {
topBar(for: model)
Spacer()
transport(for: model)
Spacer()
scrubber(for: model)
}
.padding(FlixySpacing.loose)
.background {
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
// wirkt.
Color.black.opacity(0.35).ignoresSafeArea()
}
}
private func topBar(for model: PlayerModel) -> some View {
HStack(alignment: .top) {
Button {
playback.dismiss()
} label: {
Image(systemName: "chevron.down")
.font(.title3)
.padding(FlixySpacing.snug)
}
.accessibilityLabel(Text("player.close"))
VStack(alignment: .leading, spacing: 2) {
Text(model.title)
.font(.flixyCardTitle)
.lineLimit(1)
if let subtitle = model.subtitle {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
Spacer()
if let decision = model.decision {
PlaybackMethodBadge(decision: decision)
}
}
.foregroundStyle(.white)
}
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlixySpacing.section * 2) {
Button {
model.skip(by: .seconds(-10))
scheduleControlsHide()
} label: {
Image(systemName: "gobackward.10").font(.largeTitle)
}
.accessibilityLabel(Text("player.back10"))
Button {
model.togglePlayPause()
scheduleControlsHide()
} label: {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 52))
}
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
Button {
model.skip(by: .seconds(10))
scheduleControlsHide()
} label: {
Image(systemName: "goforward.10").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
}
.foregroundStyle(.white)
}
private func scrubber(for model: PlayerModel) -> some View {
VStack(spacing: FlixySpacing.tight) {
Slider(
value: Binding(
get: { scrubbing ?? model.currentTime.seconds },
set: { scrubbing = $0 }
),
in: 0...max(model.duration?.seconds ?? 1, 1),
onEditingChanged: { editing in
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
// springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer.
if !editing, let target = scrubbing {
model.seek(to: .seconds(target))
scrubbing = nil
}
scheduleControlsHide()
}
)
.tint(accent)
HStack {
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
Spacer()
if let duration = model.duration {
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
}
}
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.white.opacity(0.8))
}
}
private func failure(message: String) -> some View {
VStack(spacing: FlixySpacing.loose) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.white.opacity(0.8))
Text(message)
.font(.flixyBody)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button("action.close") { playback.dismiss() }
.buttonStyle(.borderedProminent)
.tint(accent)
}
.padding(FlixySpacing.section)
}
// MARK: - Ein- und Ausblenden
private func toggleControls() {
withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() }
if showsControls { scheduleControlsHide() }
}
/// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange
/// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer
/// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis.
private func scheduleControlsHide() {
hideTask?.cancel()
hideTask = Task {
try? await Task.sleep(for: .seconds(3.5))
guard !Task.isCancelled, model?.isPlaying == true else { return }
withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
}
}
}
/// 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 {
let decision: PlaybackDecision
var body: some View {
Text(label)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(.white.opacity(0.75))
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.background(.white.opacity(0.15), in: .capsule)
}
private var label: String {
switch decision.method {
case .directPlay(let engine):
switch engine {
case .avPlayer: "AVPlayer"
case .vlc: "VLC"
}
case .transcode:
"Transcode"
}
}
}
extension Duration {
/// Als „1:23:45" bzw. „23:45".
var timecode: String {
let total = max(0, Int(components.seconds))
let hours = total / 3600
let minutes = (total % 3600) / 60
let seconds = total % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}
+74
View File
@@ -0,0 +1,74 @@
import AVKit
import SwiftUI
import FlixyPlayback
import FlixyVLC
/// Die Bildfläche des laufenden Motors.
///
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
/// `UIView`.
struct VideoSurface: View {
let engine: (any VideoPlayerEngine)?
var body: some View {
switch engine {
case let avEngine as AVPlayerEngine:
AVPlayerSurface(player: avEngine.player)
case let vlcEngine as VLCPlayerEngine:
UIViewContainer(view: vlcEngine.videoView)
default:
Color.black
}
}
}
/// Die Bildebene des Systemplayers, samt Bild-in-Bild.
private struct AVPlayerSurface: UIViewRepresentable {
let player: AVPlayer
func makeUIView(context: Context) -> PlayerLayerView {
let view = PlayerLayerView()
view.playerLayer.player = player
view.playerLayer.videoGravity = .resizeAspect
context.coordinator.setUp(with: view.playerLayer)
return view
}
func updateUIView(_ uiView: PlayerLayerView, context: Context) {
uiView.playerLayer.player = player
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator {
private var pictureInPicture: AVPictureInPictureController?
func setUp(with layer: AVPlayerLayer) {
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
pictureInPicture = AVPictureInPictureController(playerLayer: layer)
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
// Wiedergabe anzuhalten.
pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true
}
}
}
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
///
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
/// UIKit.
final class PlayerLayerView: UIView {
override static var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
private struct UIViewContainer: UIViewRepresentable {
let view: UIView
func makeUIView(context: Context) -> UIView { view }
func updateUIView(_ uiView: UIView, context: Context) {}
}
+133
View File
@@ -0,0 +1,133 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten.
struct SearchToolbarItem: ToolbarContent {
var body: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: SearchRoute()) {
Image(systemName: "magnifyingglass")
}
.accessibilityLabel(Text("search.title"))
}
}
}
struct SearchRoute: Hashable {}
/// Sucht über alle Mediatheken des Servers.
struct SearchView: View {
@Environment(FlixySession.self) private var session
@State private var text = ""
@State private var hubs: [PlexHub] = []
@State private var isSearching = false
@State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
private static let recentKey = "flixy.search.recent"
private static let recentLimit = 8
var body: some View {
Group {
if text.isEmpty {
recentSearches
} else if hubs.isEmpty && !isSearching {
ContentUnavailableView.search(text: text)
} else {
results
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("search.title"))
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always))
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
// von denen elf verworfen werden.
.task(id: text) { await search() }
}
private var results: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
ForEach(hubs) { hub in
MediaRow(hub: hub)
}
}
.padding(.vertical, FlixySpacing.loose)
}
}
@ViewBuilder
private var recentSearches: some View {
if recent.isEmpty {
ContentUnavailableView {
Label("search.title", systemImage: "magnifyingglass")
} description: {
Text("search.hint")
}
} else {
List {
Section {
ForEach(recent, id: \.self) { entry in
Button {
text = entry
} label: {
Label(entry, systemImage: "clock.arrow.circlepath")
.foregroundStyle(Color.flixyTextSecondary)
}
}
.onDelete { offsets in
recent.remove(atOffsets: offsets)
persistRecent()
}
} header: {
Text("search.recent")
}
.listRowBackground(Color.flixySurface)
}
.scrollContentBackground(.hidden)
}
}
private func search() async {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let server = session.server, trimmed.count >= 2 else {
hubs = []
return
}
do {
try await Task.sleep(for: .milliseconds(300))
} catch {
// Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der
// Zweck der Pause.
return
}
isSearching = true
defer { isSearching = false }
do {
hubs = try await server.search(trimmed)
if !hubs.isEmpty { remember(trimmed) }
} catch is CancellationError {
return
} catch {
hubs = []
}
}
private func remember(_ entry: String) {
recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame }
recent.insert(entry, at: 0)
recent = Array(recent.prefix(Self.recentLimit))
persistRecent()
}
private func persistRecent() {
UserDefaults.standard.set(recent, forKey: Self.recentKey)
}
}
+65
View File
@@ -0,0 +1,65 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Die Einstellungen.
///
/// In Phase 1 bewusst schmal: Serverangaben und Abmelden. Akzentfarbe,
/// Qualitätsregler und Download-Einstellungen kommen mit den Phasen, in denen
/// es das Geregelte auch gibt.
struct SettingsView: View {
@Environment(FlixySession.self) private var session
@State private var showsSignOutConfirmation = false
var body: some View {
Form {
if let server = session.server {
Section("settings.server") {
LabeledContent("settings.serverName", value: server.name)
LabeledContent("settings.connection", value: connectionLabel(server.connection))
LabeledContent("settings.address", value: server.connection.uri)
.font(.flixyCaption)
.textSelection(.enabled)
}
Section("settings.libraries") {
ForEach(session.libraries) { library in
LabeledContent(library.title, value: library.type.capitalized)
}
}
}
Section("settings.app") {
LabeledContent("settings.version", value: Branding.version)
}
Section {
Button("action.signOut", role: .destructive) {
showsSignOutConfirmation = true
}
}
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.settings"))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
Text("settings.signOutConfirm"),
isPresented: $showsSignOutConfirmation,
titleVisibility: .visible
) {
Button("action.signOut", role: .destructive) { session.signOut() }
Button("action.cancel", role: .cancel) {}
} message: {
Text("settings.signOutExplanation")
}
}
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
/// Ruckeln ist das die erste Frage.
private func connectionLabel(_ connection: PlexConnection) -> String {
if connection.local { return String(localized: "settings.connectionLocal") }
if connection.relay { return String(localized: "settings.connectionRelay") }
return String(localized: "settings.connectionRemote")
}
}
@@ -0,0 +1,93 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Während ein Weg zum Server gesucht wird.
struct ConnectingView: View {
let serverName: String
var body: some View {
VStack(spacing: FlixySpacing.loose) {
ProgressView()
Text(serverName.isEmpty
? String(localized: "connecting.searching")
: String(localized: "connecting.to \(serverName)"))
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
}
}
/// Wenn am Konto mehrere Server hängen.
struct ServerPickerView: View {
let servers: [PlexResource]
let onSelect: (PlexResource) -> Void
@Environment(\.flixyAccent) private var accent
var body: some View {
List(servers) { server in
Button {
onSelect(server)
} label: {
HStack(spacing: FlixySpacing.regular) {
Image(systemName: server.owned ? "server.rack" : "person.2")
.foregroundStyle(accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
Text(subtitle(for: server))
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
Spacer()
Image(systemName: "chevron.right")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.listRowBackground(Color.flixySurface)
}
.scrollContentBackground(.hidden)
.background(Color.flixyCanvas)
.navigationTitle(Text("serverPicker.title"))
}
private func subtitle(for server: PlexResource) -> String {
var pieces: [String] = []
pieces.append(server.owned
? String(localized: "serverPicker.owned")
: String(localized: "serverPicker.shared"))
if let platform = server.platform { pieces.append(platform) }
if let version = server.productVersion { pieces.append(version) }
return pieces.joined(separator: " · ")
}
}
/// Wenn etwas schiefgegangen ist.
struct ConnectionFailureView: View {
let message: String
let onRetry: () -> Void
let onSignOut: () -> Void
var body: some View {
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
Button("action.signOut", action: onSignOut)
.foregroundStyle(Color.flixyTextSecondary)
}
.background(Color.flixyCanvas)
}
}
+107
View File
@@ -0,0 +1,107 @@
import SwiftUI
import FlixyDesign
import PlexKit
/// Der Anmeldebildschirm.
///
/// Zeigt den Code, den Plex vergeben hat, und schickt in den Browser. Die App
/// bekommt Benutzername und Passwort nie zu sehen — sie fragt nur regelmäßig
/// nach, ob der Code bestätigt wurde.
struct SignInView: View {
let phase: FlixySession.Phase
let clientIdentifier: String
let onSignIn: () -> Void
let onCancel: () -> Void
@Environment(\.openURL) private var openURL
@Environment(\.flixyAccent) private var accent
var body: some View {
VStack(spacing: FlixySpacing.section) {
Spacer()
VStack(spacing: FlixySpacing.regular) {
Image(systemName: "play.rectangle.on.rectangle")
.font(.system(size: 52))
.foregroundStyle(accent)
Text(Branding.appName)
.font(.flixyHero)
.foregroundStyle(Color.flixyTextPrimary)
Text("signIn.tagline")
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
.multilineTextAlignment(.center)
}
Spacer()
if case .awaitingAuthorization(let pin) = phase {
awaitingAuthorization(pin: pin)
} else {
Button(action: onSignIn) {
Text("signIn.action")
.font(.flixyRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
}
Spacer().frame(height: FlixySpacing.section)
}
.padding(.horizontal, FlixySpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flixyCanvas)
}
@ViewBuilder
private func awaitingAuthorization(pin: PlexPin) -> some View {
VStack(spacing: FlixySpacing.loose) {
Text("signIn.codeHint")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.multilineTextAlignment(.center)
Text(pin.code)
// Feste Zeichenbreite, damit der Code beim Abtippen nicht
// springt und Ziffern nicht mit Buchstaben verschwimmen.
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
.tracking(6)
.foregroundStyle(Color.flixyTextPrimary)
.textSelection(.enabled)
.padding(.vertical, FlixySpacing.regular)
.frame(maxWidth: .infinity)
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.sheet))
Button {
if let url = pin.authorizationURL(
clientIdentifier: clientIdentifier,
product: Branding.plexProduct
) {
openURL(url)
}
} label: {
Label("signIn.openBrowser", systemImage: "safari")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
HStack(spacing: FlixySpacing.snug) {
ProgressView().controlSize(.small)
Text("signIn.waiting")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
Button("action.cancel", action: onCancel)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
}
}
@@ -1,17 +1,66 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"Amber" : {
"comment" : "Name of the accent color option.",
"isCommentAutoGenerated" : true
},
"Custom" : {
"comment" : "Name of the accent color option when the user has selected a custom color.",
"isCommentAutoGenerated" : true
},
"Ember" : {
"comment" : "Name of the accent color \"Ember\".",
"isCommentAutoGenerated" : true
},
"Indigo" : {
"comment" : "Name of the accent color option \"Indigo\".",
"isCommentAutoGenerated" : true
},
"Lime" : {
"comment" : "Name of the accent color \"Lime\".",
"isCommentAutoGenerated" : true
},
"Mint" : {
"comment" : "Name of the accent color \"Mint\".",
"isCommentAutoGenerated" : true
},
"Orchid" : {
"comment" : "Name of the accent color \"Orchid\".",
"isCommentAutoGenerated" : true
},
"progress.watched" : { "progress.watched" : {
"comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln", "comment" : "Barrierefreiheits-Beschriftung des Fortschrittsbalkens unter angefangenen Titeln",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "state" : "translated", "value" : "Fortschritt" } "stringUnit" : {
"state" : "translated",
"value" : "Fortschritt"
}
}, },
"en" : { "en" : {
"stringUnit" : { "state" : "translated", "value" : "Progress" } "stringUnit" : {
"state" : "translated",
"value" : "Progress"
}
} }
} }
},
"Rose" : {
"comment" : "Text for a color name.",
"isCommentAutoGenerated" : true
},
"Sky" : {
},
"Tangerine" : {
"comment" : "Name of the accent color option \"Tangerine\".",
"isCommentAutoGenerated" : true
},
"Teal" : {
"comment" : "Name of the accent color teal.",
"isCommentAutoGenerated" : true
} }
}, },
"version" : "1.0" "version" : "1.1"
} }
@@ -0,0 +1,261 @@
import AVFoundation
import Foundation
/// Wiedergabe über den Systemplayer.
///
/// Der bevorzugte Weg, wann immer das Format ihn zulässt: Bild-in-Bild,
/// AirPlay, die Steuerung im Sperrbildschirm und der Umgang mit
/// Unterbrechungen durch Anrufe kommen alle mit, ohne dass etwas davon
/// nachgebaut werden müsste.
@MainActor
public final class AVPlayerEngine: VideoPlayerEngine {
/// Wird von der Ansicht gebraucht, um die Bildebene zu bauen. Der einzige
/// Grund, warum AVFoundation überhaupt nach außen sichtbar ist.
public let player = AVPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
let seconds = player.currentTime().seconds
guard seconds.isFinite, seconds >= 0 else { return .zero }
return .seconds(seconds)
}
public let supportsPictureInPicture = true
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any?
private var observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)?
public init() {
AudioSession.activate()
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
teardownObservers()
state = .loading
let asset = AVURLAsset(url: source.url)
let item = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: item)
observe(item: item)
observePlayer()
do {
let assetDuration = try await asset.load(.duration)
if assetDuration.isNumeric {
let value = Duration.seconds(assetDuration.seconds)
duration = value
continuation?.yield(.durationResolved(value))
}
try await loadTracks(from: asset)
} catch {
let failure = PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: String(describing: error)
)
state = .failed(failure)
throw failure
}
if source.startTime > .zero {
await seek(to: source.startTime)
}
}
public func play() {
player.play()
}
public func pause() {
player.pause()
}
public func seek(to time: Duration) async {
let target = CMTime(seconds: time.seconds, preferredTimescale: 600)
// Genau springen statt zum nächsten Schlüsselbild: beim Zurückspringen
// um zehn Sekunden landet man sonst je nach Datei bis zu mehreren
// Sekunden daneben.
await player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero)
continuation?.yield(.timeChanged(time))
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, in: .audible)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, in: .legible)
}
public func teardown() {
teardownObservers()
player.replaceCurrentItem(with: nil)
continuation?.finish()
continuation = nil
state = .idle
AudioSession.deactivate()
}
// MARK: - Spurwahl
private func select(_ track: MediaTrack?, in characteristic: AVMediaCharacteristic) {
guard let item = player.currentItem,
let group = item.asset.mediaSelectionGroup(forMediaCharacteristic: characteristic)
else { return }
guard let track, let index = track.engineIdentifier, group.options.indices.contains(index) else {
// `nil` schaltet ab — bei Untertiteln der Normalfall, bei Ton
// sollte es nicht vorkommen.
item.select(nil, in: group)
return
}
item.select(group.options[index], in: group)
}
private func loadTracks(from asset: AVURLAsset) async throws {
func tracks(
for characteristic: AVMediaCharacteristic,
kind: MediaTrack.Kind
) async throws -> [MediaTrack] {
guard let group = try await asset.loadMediaSelectionGroup(for: characteristic) else { return [] }
return group.options.enumerated().map { index, option in
MediaTrack(
id: "av-\(kind)-\(index)",
kind: kind,
displayName: option.displayName,
languageCode: option.extendedLanguageTag,
engineIdentifier: index,
isForced: option.hasMediaCharacteristic(.containsOnlyForcedSubtitles)
)
}
}
audioTracks = try await tracks(for: .audible, kind: .audio)
subtitleTracks = try await tracks(for: .legible, kind: .subtitle)
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
// MARK: - Beobachtung
private func observePlayer() {
observations.append(
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
MainActor.assumeIsolated {
guard let self else { return }
switch player.timeControlStatus {
case .playing: self.state = .playing
case .paused: if self.state != .ended { self.state = .paused }
case .waitingToPlayAtSpecifiedRate: self.state = .buffering
@unknown default: break
}
}
}
)
// Viermal je Sekunde: fein genug, dass der Schieberegler flüssig
// wirkt, grob genug, dass er nicht jede Bildwiederholung antreibt.
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.25, preferredTimescale: 600),
queue: .main
) { [weak self] time in
MainActor.assumeIsolated {
guard let self, time.isNumeric else { return }
self.continuation?.yield(.timeChanged(.seconds(time.seconds)))
}
}
}
private func observe(item: AVPlayerItem) {
observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in
MainActor.assumeIsolated {
guard let self, item.status == .failed else { return }
self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) }
))
}
}
)
endObserver = NotificationCenter.default.addObserver(
forName: AVPlayerItem.didPlayToEndTimeNotification,
object: item,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.state = .ended
self.continuation?.yield(.reachedEnd)
}
}
}
private func teardownObservers() {
observations.forEach { $0.invalidate() }
observations.removeAll()
if let timeObserver {
player.removeTimeObserver(timeObserver)
self.timeObserver = nil
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
self.endObserver = nil
}
}
}
extension Duration {
/// Die Dauer als Gleitkommazahl in Sekunden — AVFoundation rechnet darin.
public var seconds: Double {
Double(components.seconds) + Double(components.attoseconds) / 1e18
}
}
/// Die Tonsitzung des Systems.
///
/// Nur auf iOS vorhanden. Gekapselt statt an drei Stellen mit `#if` versehen —
/// macOS steht in diesem Paket ohnehin nur, damit die Logik-Tests ohne
/// Simulator laufen.
enum AudioSession {
static func activate() {
#if os(iOS)
// Ohne diese Kategorie schweigt die Wiedergabe, sobald der
// Stummschalter am Gerät umgelegt ist — und Bild-in-Bild gäbe es gar
// nicht.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
#endif
}
static func deactivate() {
#if os(iOS)
// `notifyOthersOnDeactivation` lässt die Musik-App wieder anlaufen,
// die vor dem Film gespielt hat.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
}
@@ -0,0 +1,108 @@
import Foundation
/// Führt die Anmeldung bei plex.tv durch.
public actor PlexAuthenticator {
/// Wo das Konto-Token im Schlüsselbund steht.
public static let accountTokenAccount = "plex.account"
private let client: PlexHTTPClient
private let identity: PlexClientIdentity
private let tokenStore: any TokenStoring
private let clock: any Clock<Duration>
public init(
identity: PlexClientIdentity,
tokenStore: any TokenStoring,
transport: any PlexTransport = URLSession.shared,
clock: any Clock<Duration> = ContinuousClock()
) {
self.identity = identity
self.tokenStore = tokenStore
self.clock = clock
self.client = PlexHTTPClient(
baseURL: URL(string: "https://plex.tv")!,
identity: identity,
transport: transport
)
}
/// Der bereits gespeicherte Token, falls vorhanden.
public nonisolated var storedToken: String? {
tokenStore.token(for: Self.accountTokenAccount)
}
/// Fordert einen neuen Anmeldecode an.
///
/// Bewusst ohne `strong=true`: das liefert zwar einen schwerer zu
/// erratenden Code, aber einen dreißigstelligen. Bei plex.tv/link
/// abzutippen ist der nicht — und genau das ist der Weg, den Flixy
/// anbietet. Der kurze Code ist vier Zeichen lang und läuft nach wenigen
/// Minuten ab, was das Raten ausreichend aussichtslos macht.
public func requestPin() async throws(PlexError) -> PlexPin {
try await client.send(
PlexRequest(method: .post, path: "/api/v2/pins"),
as: PlexPin.self
)
}
/// Wartet darauf, dass der Nutzer den Code im Browser bestätigt.
///
/// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen.
/// Das Intervall ist bewusst großzügig: der Weg über den Browser dauert
/// ohnehin Sekunden, und sekündliches Anklopfen brächte nichts außer Last.
public func waitForAuthorization(
pin: PlexPin,
pollInterval: Duration = .seconds(2),
timeout: Duration = .seconds(300)
) async throws(PlexError) -> String {
// Über die Anzahl der Versuche statt über einen Zeitpunkt: eine
// existenzielle `any Clock` gibt ihren Instant-Typ nicht preis, und
// deshalb lassen sich zwei ihrer Zeitpunkte nicht vergleichen. Das
// Ergebnis ist dasselbe, und in Tests reicht eine Uhr, die sofort
// zurückkehrt.
//
// Nach oben begrenzt, damit ein versehentlich winziges Intervall nicht
// in einen Überlauf oder Millionen Anfragen läuft.
let attempts = pollInterval > .zero
? min(1000, max(1, Int(timeout / pollInterval)))
: 1
for _ in 0..<attempts {
do {
try await clock.sleep(for: pollInterval)
} catch {
// Abbruch durch die Oberfläche, etwa weil das Anmeldeblatt
// geschlossen wurde. Kein Fehlerfall.
throw .transport(description: "Anmeldung abgebrochen")
}
let current = try await client.send(
PlexRequest(path: "/api/v2/pins/\(pin.id)"),
as: PlexPin.self
)
if let token = current.authToken, !token.isEmpty {
try? tokenStore.store(token, for: Self.accountTokenAccount)
await client.setToken(token)
return token
}
if let expiresAt = current.expiresAt, expiresAt < Date() {
throw .transport(description: "Der Anmeldecode ist abgelaufen")
}
}
throw .transport(description: "Zeit für die Anmeldung abgelaufen")
}
/// Meldet das Gerät ab. Der Token verschwindet aus dem Schlüsselbund;
/// am Konto bleibt der Eintrag stehen, bis er dort entfernt wird.
///
/// `nonisolated`, weil nur der Schlüsselbund angefasst wird — der ist
/// selbst abgesichert. Die Oberfläche soll beim Abmelden nicht auf den
/// Actor warten müssen, denn das kann mitten in einer laufenden Abfrage
/// passieren.
public nonisolated func signOut() {
tokenStore.remove(for: Self.accountTokenAccount)
}
}
@@ -0,0 +1,53 @@
import Foundation
/// Der Anmeldecode aus dem PIN-Verfahren.
///
/// Plex legt auf Anfrage einen vierstelligen Code an. Der Nutzer bestätigt ihn
/// im Browser bei plex.tv, danach trägt derselbe Datensatz den Token. Die App
/// selbst bekommt nie Benutzername oder Passwort zu sehen — deshalb ist dieser
/// Weg dem manuellen Eintippen eines Tokens vorzuziehen.
public struct PlexPin: Sendable, Equatable, Decodable {
public let id: Int
public let code: String
public let authToken: String?
public let expiresAt: Date?
private enum CodingKeys: String, CodingKey {
case id, code, authToken, expiresAt
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.id = try values.decode(Int.self, forKey: .id)
self.code = try values.decode(String.self, forKey: .code)
self.authToken = try values.decodeIfPresent(String.self, forKey: .authToken)
// Plex liefert ISO-8601 mit Zeitzone. Ein eigener Formatierer statt
// einer Decoder-Strategie, weil sonst alle anderen Datumsfelder in
// dieser Antwort mitbetroffen wären.
if let text = try values.decodeIfPresent(String.self, forKey: .expiresAt) {
self.expiresAt = ISO8601DateFormatter().date(from: text)
} else {
self.expiresAt = nil
}
}
public init(id: Int, code: String, authToken: String? = nil, expiresAt: Date? = nil) {
self.id = id
self.code = code
self.authToken = authToken
self.expiresAt = expiresAt
}
/// Die Adresse, unter der der Nutzer den Code bestätigt.
///
/// `forwardUrl` fehlt bewusst: der Rücksprung in die App wäre bequemer,
/// verlangt aber ein eigenes URL-Schema. Solange die App den Code ohnehin
/// im Hintergrund abfragt, merkt niemand einen Unterschied.
public func authorizationURL(clientIdentifier: String, product: String) -> URL? {
var components = URLComponents(string: "https://app.plex.tv/auth")
components?.fragment = "?clientID=\(clientIdentifier)&code=\(code)"
+ "&context%5Bdevice%5D%5Bproduct%5D=\(product.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? product)"
return components?.url
}
}
@@ -0,0 +1,100 @@
import Foundation
import Security
/// Legt Tokens im Schlüsselbund ab.
///
/// Nicht in `UserDefaults`: ein Plex-Token gibt vollen Zugriff auf die
/// Mediathek und bleibt gültig, bis er am Konto zurückgezogen wird. In den
/// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup
/// mitnimmt.
public protocol TokenStoring: Sendable {
func store(_ token: String, for account: String) throws
func token(for account: String) -> String?
func remove(for account: String)
}
public struct KeychainTokenStore: TokenStoring {
private let service: String
public init(service: String) {
self.service = service
}
public enum Failure: Error, Equatable {
case keychain(status: OSStatus)
}
public func store(_ token: String, for account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let attributes: [String: Any] = [
kSecValueData as String: Data(token.utf8),
// Ohne Gerätesperre kein Zugriff, und nicht in Backups übertragbar.
// Ein Token, der auf einem anderen Gerät wieder auftaucht, wäre
// ein stiller Zweitzugang zur Mediathek.
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
let insert = query.merging(attributes) { current, _ in current }
let addStatus = SecItemAdd(insert as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw Failure.keychain(status: addStatus) }
default:
throw Failure.keychain(status: status)
}
}
public func token(for account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
public func remove(for account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(query as CFDictionary)
}
}
/// Ein Ablageort im Arbeitsspeicher — für Tests und SwiftUI-Vorschauen.
public final class InMemoryTokenStore: TokenStoring, @unchecked Sendable {
private let lock = NSLock()
private var tokens: [String: String] = [:]
public init() {}
public func store(_ token: String, for account: String) throws {
lock.withLock { tokens[account] = token }
}
public func token(for account: String) -> String? {
lock.withLock { tokens[account] }
}
public func remove(for account: String) {
_ = lock.withLock { tokens.removeValue(forKey: account) }
}
}
@@ -0,0 +1,88 @@
import Foundation
/// Führt Anfragen gegen einen Host aus.
///
/// Es gibt zwei davon: einen für plex.tv (Anmeldung, Serverliste) und einen je
/// Server. Sie unterscheiden sich nur in Basisadresse und Token — deshalb
/// dieselbe Klasse statt zweier ähnlicher.
public actor PlexHTTPClient {
private let baseURL: URL
private let identity: PlexClientIdentity
private let transport: any PlexTransport
private var token: String?
public init(
baseURL: URL,
identity: PlexClientIdentity,
token: String? = nil,
transport: any PlexTransport = URLSession.shared
) {
self.baseURL = baseURL
self.identity = identity
self.token = token
self.transport = transport
}
public func setToken(_ token: String?) {
self.token = token
}
public var currentToken: String? { token }
public func send(_ request: PlexRequest) async throws(PlexError) -> Data {
let urlRequest = try request.urlRequest(baseURL: baseURL, identity: identity, token: token)
let data: Data
let response: URLResponse
do {
(data, response) = try await transport.data(for: urlRequest)
} catch let error as URLError {
// Der Klartext von URLError nennt Zeitüberschreitung, fehlende
// Verbindung oder Zertifikatsprobleme beim Namen. Das ist der
// Unterschied zwischen einer brauchbaren Fehlermeldung und
// „Etwas ist schiefgelaufen".
throw .transport(description: error.localizedDescription)
} catch {
throw .transport(description: String(describing: error))
}
return try PlexResponse.validate(data: data, response: response)
}
public func send<T: Decodable & Sendable>(
_ request: PlexRequest,
as type: T.Type
) async throws(PlexError) -> T {
let data = try await send(request)
return try PlexResponse.decode(type, from: data)
}
/// Baut die Adresse eines Bildes, ohne es zu laden.
///
/// Plex kann Bilder serverseitig verkleinern — ein Poster in Kachelgröße
/// statt in Originalauflösung spart bei einer Reihe aus zwanzig Titeln ein
/// Vielfaches an Übertragung.
public nonisolated func artworkURL(
path: String,
width: Int,
height: Int,
token: String
) -> URL? {
guard var components = URLComponents(
url: baseURL.appending(path: "/photo/:/transcode"),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = [
URLQueryItem(name: "width", value: String(width)),
URLQueryItem(name: "height", value: String(height)),
URLQueryItem(name: "minSize", value: "1"),
URLQueryItem(name: "upscale", value: "1"),
URLQueryItem(name: "url", value: path),
// Hier muss der Token ausnahmsweise in die Adresse: das Bild lädt
// AsyncImage selbst, und dort lassen sich keine Kopfzeilen setzen.
URLQueryItem(name: "X-Plex-Token", value: token),
]
return components.url
}
}
@@ -0,0 +1,110 @@
import Foundation
/// Sucht den besten erreichbaren Weg zu einem Server.
///
/// Geht die Wege nach Rang durch — erst lokal, dann direkt von außen, zuletzt
/// über die Vermittlung von Plex. Innerhalb eines Rangs laufen die Versuche
/// gleichzeitig, weil sie sich gegenseitig nicht ausschließen und ein
/// unerreichbarer Weg sonst die volle Zeitüberschreitung kostet, bevor der
/// nächste überhaupt beginnt.
public struct PlexConnectionSelector: Sendable {
private let identity: PlexClientIdentity
private let transport: any PlexTransport
private let probeTimeout: Duration
public init(
identity: PlexClientIdentity,
transport: any PlexTransport = URLSession.shared,
probeTimeout: Duration = .seconds(5)
) {
self.identity = identity
self.transport = transport
self.probeTimeout = probeTimeout
}
public func selectConnection(for resource: PlexResource) async -> PlexConnection? {
guard let token = resource.accessToken else { return nil }
let tiers = Dictionary(grouping: resource.connections, by: \.tier)
for tier in tiers.keys.sorted() {
guard let candidates = tiers[tier] else { continue }
if let winner = await race(candidates, token: token, expecting: resource.clientIdentifier) {
return winner
}
}
return nil
}
/// Lässt die Wege eines Rangs gegeneinander antreten; der erste, der sich
/// korrekt ausweist, gewinnt.
private func race(
_ connections: [PlexConnection],
token: String,
expecting clientIdentifier: String
) async -> PlexConnection? {
await withTaskGroup(of: PlexConnection?.self) { group in
for connection in connections {
group.addTask {
await probe(connection, token: token, expecting: clientIdentifier) ? connection : nil
}
}
for await result in group {
if let result {
// Die übrigen Versuche interessieren nicht mehr. Ohne das
// Abbrechen liefe eine unerreichbare Adresse noch bis zur
// Zeitüberschreitung weiter und hielte die Aufgabe offen.
group.cancelAll()
return result
}
}
return nil
}
}
/// Fragt `/identity` ab — der einzige Endpunkt, der ohne Token antwortet
/// und die Kennung des Servers nennt.
private func probe(
_ connection: PlexConnection,
token: String,
expecting clientIdentifier: String
) async -> Bool {
guard let baseURL = connection.url else { return false }
let client = PlexHTTPClient(
baseURL: baseURL,
identity: identity,
token: token,
transport: transport
)
do {
let identityResponse = try await client.send(
PlexRequest(path: "/identity"),
as: PlexIdentity.self
)
// Der Abgleich der Kennung ist keine Förmlichkeit: eine veraltete
// plex.direct-Adresse kann auf einen fremden Server zeigen, und
// ohne Prüfung würde die App fremde Mediatheken anzeigen.
return identityResponse.machineIdentifier == clientIdentifier
} catch {
return false
}
}
}
/// Die Antwort von `/identity`.
public struct PlexIdentity: Sendable, Decodable {
public let machineIdentifier: String
public let version: String?
private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" }
private enum InnerKeys: String, CodingKey { case machineIdentifier, version }
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.machineIdentifier = try inner.decode(String.self, forKey: .machineIdentifier)
self.version = try inner.decodeIfPresent(String.self, forKey: .version)
}
}
@@ -0,0 +1,131 @@
import Foundation
/// Ein Gerät, das am Plex-Konto hängt — für uns nur Server interessant.
public struct PlexResource: Sendable, Equatable, Decodable, Identifiable {
public let name: String
public let product: String?
public let productVersion: String?
public let platform: String?
public let clientIdentifier: String
public let provides: String
public let owned: Bool
/// Der Token für genau diesen Server.
///
/// Nicht derselbe wie der Konto-Token: bei geteilten Servern bekommt man
/// einen eigenen, eingeschränkten. Wer stattdessen den Konto-Token
/// verwendet, bekommt bei fremden Servern wortlos 401.
public let accessToken: String?
public let connections: [PlexConnection]
public var id: String { clientIdentifier }
/// Ob es sich um einen Medienserver handelt — `provides` kann auch
/// „client", „player" oder eine Kombination sein.
public var isServer: Bool {
provides.split(separator: ",").contains("server")
}
private enum CodingKeys: String, CodingKey {
case name, product, productVersion, platform, clientIdentifier
case provides, owned, accessToken, connections
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.name = try values.decode(String.self, forKey: .name)
self.product = try values.decodeIfPresent(String.self, forKey: .product)
self.productVersion = try values.decodeIfPresent(String.self, forKey: .productVersion)
self.platform = try values.decodeIfPresent(String.self, forKey: .platform)
self.clientIdentifier = try values.decode(String.self, forKey: .clientIdentifier)
self.provides = try values.decodeIfPresent(String.self, forKey: .provides) ?? ""
self.owned = try values.decodeIfPresent(Bool.self, forKey: .owned) ?? false
self.accessToken = try values.decodeIfPresent(String.self, forKey: .accessToken)
self.connections = try values.decodeIfPresent([PlexConnection].self, forKey: .connections) ?? []
}
public init(
name: String,
clientIdentifier: String,
provides: String = "server",
owned: Bool = true,
accessToken: String? = nil,
connections: [PlexConnection] = [],
product: String? = nil,
productVersion: String? = nil,
platform: String? = nil
) {
self.name = name
self.product = product
self.productVersion = productVersion
self.platform = platform
self.clientIdentifier = clientIdentifier
self.provides = provides
self.owned = owned
self.accessToken = accessToken
self.connections = connections
}
}
/// Ein Weg zu einem Server.
public struct PlexConnection: Sendable, Equatable, Decodable, Identifiable {
public let uri: String
public let address: String?
public let port: Int?
public let local: Bool
/// Ob der Weg über die Vermittlung von Plex läuft. Der ist immer
/// erreichbar, aber auf wenige Mbit gedrosselt — taugt für Metadaten,
/// nicht für 4K.
public let relay: Bool
public let `protocol`: String?
public let iPv6: Bool
public var id: String { uri }
public var url: URL? { URL(string: uri) }
private enum CodingKeys: String, CodingKey {
case uri, address, port, local, relay
case `protocol`
case iPv6 = "IPv6"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.uri = try values.decode(String.self, forKey: .uri)
self.address = try values.decodeIfPresent(String.self, forKey: .address)
self.port = try values.decodeIfPresent(Int.self, forKey: .port)
self.local = try values.decodeIfPresent(Bool.self, forKey: .local) ?? false
self.relay = try values.decodeIfPresent(Bool.self, forKey: .relay) ?? false
self.protocol = try values.decodeIfPresent(String.self, forKey: .protocol)
self.iPv6 = try values.decodeIfPresent(Bool.self, forKey: .iPv6) ?? false
}
public init(
uri: String,
local: Bool = false,
relay: Bool = false,
address: String? = nil,
port: Int? = nil,
protocol: String? = nil,
iPv6: Bool = false
) {
self.uri = uri
self.local = local
self.relay = relay
self.address = address
self.port = port
self.protocol = `protocol`
self.iPv6 = iPv6
}
/// In welcher Reihenfolge die Wege probiert werden.
///
/// Nicht „wer zuerst antwortet, gewinnt": die Vermittlung von Plex meldet
/// sich oft schneller als der Server im eigenen Netz, ist aber gedrosselt.
/// Ein Rennen ohne Rangordnung würde also ausgerechnet den langsamsten Weg
/// prämieren.
public var tier: Int {
if local { return 0 }
if relay { return 2 }
return 1
}
}
@@ -0,0 +1,34 @@
import Foundation
/// Holt die Serverliste vom Konto.
public actor PlexServerDirectory {
private let client: PlexHTTPClient
public init(
identity: PlexClientIdentity,
token: String,
transport: any PlexTransport = URLSession.shared
) {
self.client = PlexHTTPClient(
baseURL: URL(string: "https://plex.tv")!,
identity: identity,
token: token,
transport: transport
)
}
/// Alle Medienserver, auf die das Konto Zugriff hat — eigene und geteilte.
public func servers() async throws(PlexError) -> [PlexResource] {
let resources = try await client.send(
PlexRequest(path: "/api/v2/resources", query: [
// Ohne diese beiden Schalter fehlen in der Antwort die
// plex.direct-Adressen und die Vermittlung — übrig bliebe nur
// die lokale IP, und außer Haus ginge gar nichts.
URLQueryItem(name: "includeHttps", value: "1"),
URLQueryItem(name: "includeRelay", value: "1"),
]),
as: [PlexResource].self
)
return resources.filter(\.isServer)
}
}
@@ -0,0 +1,85 @@
import Foundation
/// Filter und Sortierung einer Mediatheksansicht.
///
/// Wird zu Abfrageparametern für Plex — die Filterung passiert also auf dem
/// Server. Bei zehntausend Titeln wäre alles andere aussichtslos: die Liste
/// erst vollständig zu laden, um sie dann lokal zu durchsuchen, dauert
/// Minuten und belegt entsprechend Speicher.
public struct LibraryQuery: Sendable, Equatable {
public enum Sort: String, Sendable, Equatable, CaseIterable {
case titleAscending = "titleSort:asc"
case titleDescending = "titleSort:desc"
case recentlyAdded = "addedAt:desc"
case releaseDate = "originallyAvailableAt:desc"
case rating = "rating:desc"
case lastViewed = "lastViewedAt:desc"
}
public var sort: Sort
public var unwatchedOnly: Bool
public var genre: String?
public var year: Int?
public var contentRating: String?
public var resolution: String?
public init(
sort: Sort = .titleAscending,
unwatchedOnly: Bool = false,
genre: String? = nil,
year: Int? = nil,
contentRating: String? = nil,
resolution: String? = nil
) {
self.sort = sort
self.unwatchedOnly = unwatchedOnly
self.genre = genre
self.year = year
self.contentRating = contentRating
self.resolution = resolution
}
/// Ob überhaupt gefiltert wird — steuert, ob die Oberfläche den
/// Filterknopf hervorhebt.
public var isFiltering: Bool {
unwatchedOnly || genre != nil || year != nil || contentRating != nil || resolution != nil
}
func queryItems(libraryKind: PlexLibrary.Kind?) -> [URLQueryItem] {
var items: [URLQueryItem] = [URLQueryItem(name: "sort", value: sort.rawValue)]
// Plex nummeriert die Arten: 1 Film, 2 Serie, 4 Folge. Ohne diese
// Angabe liefert eine Serienmediathek alle Folgen einzeln statt der
// Serien.
switch libraryKind {
case .movie: items.append(URLQueryItem(name: "type", value: "1"))
case .show: items.append(URLQueryItem(name: "type", value: "2"))
default: break
}
if unwatchedOnly { items.append(URLQueryItem(name: "unwatched", value: "1")) }
if let genre { items.append(URLQueryItem(name: "genre", value: genre)) }
if let year { items.append(URLQueryItem(name: "year", value: String(year))) }
if let contentRating { items.append(URLQueryItem(name: "contentRating", value: contentRating)) }
if let resolution { items.append(URLQueryItem(name: "resolution", value: resolution)) }
return items
}
}
/// Ein Ausschnitt aus einer längeren Liste.
public struct PlexPage<Element: Sendable>: Sendable {
public let items: [Element]
public let offset: Int
/// Wie viele Einträge es insgesamt gibt.
public let totalSize: Int
public init(items: [Element], offset: Int, totalSize: Int) {
self.items = items
self.offset = offset
self.totalSize = totalSize
}
public var hasMore: Bool { offset + items.count < totalSize }
public var nextOffset: Int { offset + items.count }
}
@@ -0,0 +1,211 @@
import Foundation
/// Ein verbundener Plex-Server.
///
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
public actor PlexServer {
public let resource: PlexResource
public let connection: PlexConnection
fileprivate let client: PlexHTTPClient
private let token: String
public init?(
resource: PlexResource,
connection: PlexConnection,
identity: PlexClientIdentity,
transport: any PlexTransport = URLSession.shared
) {
guard let baseURL = connection.url, let token = resource.accessToken else { return nil }
self.resource = resource
self.connection = connection
self.token = token
self.client = PlexHTTPClient(
baseURL: baseURL,
identity: identity,
token: token,
transport: transport
)
}
public nonisolated var name: String { resource.name }
// MARK: - Mediatheken
public func libraries() async throws(PlexError) -> [PlexLibrary] {
let container = try await client.send(
PlexRequest(path: "/library/sections"),
as: PlexMediaContainer<PlexLibrary>.self
)
return container.content
}
// MARK: - Startseite
/// Die fertig zusammengestellten Reihen für die Startseite.
public func homeHubs() async throws(PlexError) -> [PlexHub] {
let container = try await client.send(
PlexRequest(path: "/hubs", query: [
// Leere Reihen wären auf der Startseite nur Überschriften ohne
// Inhalt.
URLQueryItem(name: "includeEmpty", value: "0"),
URLQueryItem(name: "count", value: "20"),
]),
as: PlexMediaContainer<PlexHub>.self
)
return container.content.filter { !$0.items.isEmpty }
}
/// Was als Nächstes dran ist: angefangene Filme und die nächste ungesehene
/// Folge je Serie.
public func continueWatching() async throws(PlexError) -> [PlexItem] {
let container = try await client.send(
PlexRequest(path: "/hubs/continueWatching/items"),
as: PlexMediaContainer<PlexItem>.self
)
return container.content
}
// MARK: - Blättern
public func items(
inLibrary library: PlexLibrary,
query: LibraryQuery = LibraryQuery(),
offset: Int = 0,
limit: Int = 60
) async throws(PlexError) -> PlexPage<PlexItem> {
let container = try await client.send(
PlexRequest(
path: "/library/sections/\(library.key)/all",
query: query.queryItems(libraryKind: library.kind),
// Die Seitengröße steht in Kopfzeilen, nicht in der Adresse —
// eine Eigenheit von Plex.
additionalHeaders: [
"X-Plex-Container-Start": String(offset),
"X-Plex-Container-Size": String(limit),
]
),
as: PlexMediaContainer<PlexItem>.self
)
return PlexPage(
items: container.content,
offset: container.offset ?? offset,
// `totalSize` fehlt, wenn alles auf eine Seite passt. Dann ist die
// Gesamtzahl gleich der Anzahl der gelieferten Einträge.
totalSize: container.totalSize ?? (offset + container.content.count)
)
}
/// Ein einzelner Titel mit allem, was die Detailseite braucht.
public func metadata(ratingKey: String) async throws(PlexError) -> PlexItem {
let container = try await client.send(
PlexRequest(path: "/library/metadata/\(ratingKey)", query: [
// Marker für Intro und Abspann. Sie werden schon jetzt
// mitgeholt, obwohl sie erst in Phase 2 ausgewertet werden —
// sie kosten nichts und ersparen später eine zweite Anfrage.
URLQueryItem(name: "includeMarkers", value: "1"),
URLQueryItem(name: "includeChapters", value: "1"),
URLQueryItem(name: "includeRelated", value: "1"),
]),
as: PlexMediaContainer<PlexItem>.self
)
guard let item = container.content.first else { throw .notFound }
return item
}
/// Die Staffeln einer Serie bzw. die Folgen einer Staffel.
public func children(of ratingKey: String) async throws(PlexError) -> [PlexItem] {
let container = try await client.send(
PlexRequest(path: "/library/metadata/\(ratingKey)/children"),
as: PlexMediaContainer<PlexItem>.self
)
return container.content
}
// MARK: - Suche
/// Sucht über alle Mediatheken. Plex gruppiert das Ergebnis selbst nach
/// Filmen, Serien, Folgen und Personen.
public func search(_ query: String, limit: Int = 30) async throws(PlexError) -> [PlexHub] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
let container = try await client.send(
PlexRequest(path: "/hubs/search", query: [
URLQueryItem(name: "query", value: trimmed),
URLQueryItem(name: "limit", value: String(limit)),
]),
as: PlexMediaContainer<PlexHub>.self
)
return container.content.filter { !$0.items.isEmpty }
}
// MARK: - Adressen
/// Die Adresse eines Bildes in der gewünschten Größe.
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
guard let path, !path.isEmpty else { return nil }
return client.artworkURL(path: path, width: width, height: height, token: token)
}
/// Die Adresse für DirectPlay: die Datei, unverändert.
public nonisolated func directPlayURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: false)
}
/// Dieselbe Datei, aber als Download angefordert. Der Unterschied liegt
/// nur im Schalter — Plex setzt daraufhin Kopfzeilen, die das Weiterladen
/// nach einem Abbruch erlauben.
public nonisolated func downloadURL(part: PlexPart) -> URL? {
streamURL(partKey: part.key, download: true)
}
private nonisolated func streamURL(partKey: String, download: Bool) -> URL? {
guard let baseURL = connection.url,
var components = URLComponents(
url: baseURL.appending(path: partKey),
resolvingAgainstBaseURL: false
)
else { return nil }
var query = [URLQueryItem(name: "X-Plex-Token", value: token)]
if download { query.append(URLQueryItem(name: "download", value: "1")) }
components.queryItems = query
return components.url
}
}
// MARK: - Filterwerte
/// Ein Wert, nach dem gefiltert werden kann — ein Genre, ein Jahr, eine
/// Altersfreigabe.
public struct PlexFilterOption: Sendable, Equatable, Decodable, Identifiable {
public let key: String
public let title: String
public var id: String { key }
public init(key: String, title: String) {
self.key = key
self.title = title
}
}
extension PlexServer {
/// Die Werte, die es in dieser Mediathek für ein Filterfeld gibt.
///
/// Wird vom Server berechnet, nicht aus der Titelliste abgeleitet: die
/// Liste ist seitenweise geladen und kennt die Genres der übrigen
/// Tausenden Titel gar nicht.
public func filterOptions(
inLibrary library: PlexLibrary,
field: String
) async throws(PlexError) -> [PlexFilterOption] {
let container = try await client.send(
PlexRequest(path: "/library/sections/\(library.key)/\(field)"),
as: PlexMediaContainer<PlexFilterOption>.self
)
return container.content
}
}
@@ -6,7 +6,10 @@ import Foundation
/// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf /// `type`. Das ist ungewohnt, aber praktisch: Suchergebnisse und Reihen auf
/// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das /// der Startseite mischen die Arten, und ein gemeinsamer Typ erspart das
/// Aufteilen bei jedem Abruf. /// Aufteilen bei jedem Abruf.
public struct PlexItem: Sendable, Equatable, Codable, Identifiable { /// Nur `Decodable`: geschrieben wird dieser Typ nirgends. Der Offline-Bestand
/// in Phase 3 bekommt ein eigenes SwiftData-Modell, weil er andere Felder
/// braucht — lokaler Dateipfad, Ladezeitpunkt — und andere nicht.
public struct PlexItem: Sendable, Equatable, Decodable, Identifiable {
public enum Kind: String, Sendable, Codable { public enum Kind: String, Sendable, Codable {
case movie, show, season, episode, clip, collection case movie, show, season, episode, clip, collection
} }
@@ -58,6 +61,15 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
public let media: [PlexMedia] public let media: [PlexMedia]
// Schlagwörter. Plex liefert sie nur bei der Einzelabfrage eines Titels
// mit, nicht in Listen — deshalb sind sie dort leer.
public let genres: [PlexTag]
public let directors: [PlexTag]
public let writers: [PlexTag]
/// Die Besetzung, mit Porträt und Rollennamen.
public let roles: [PlexTag]
public let markers: [PlexMarker]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case ratingKey, key, guid, type, title, titleSort, summary, year, duration case ratingKey, key, guid, type, title, titleSort, summary, year, duration
case contentRating, rating, audienceRating case contentRating, rating, audienceRating
@@ -67,6 +79,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
case viewCount, viewOffset, lastViewedAt, addedAt case viewCount, viewOffset, lastViewedAt, addedAt
case leafCount, viewedLeafCount, childCount case leafCount, viewedLeafCount, childCount
case media = "Media" case media = "Media"
case genres = "Genre"
case directors = "Director"
case writers = "Writer"
case roles = "Role"
case markers = "Marker"
} }
/// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen /// Von Hand geschrieben statt erzeugt, weil der erzeugte Decoder einen
@@ -112,6 +129,11 @@ public struct PlexItem: Sendable, Equatable, Codable, Identifiable {
self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount) self.childCount = try values.decodeIfPresent(Int.self, forKey: .childCount)
self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? [] self.media = try values.decodeIfPresent([PlexMedia].self, forKey: .media) ?? []
self.genres = try values.decodeIfPresent([PlexTag].self, forKey: .genres) ?? []
self.directors = try values.decodeIfPresent([PlexTag].self, forKey: .directors) ?? []
self.writers = try values.decodeIfPresent([PlexTag].self, forKey: .writers) ?? []
self.roles = try values.decodeIfPresent([PlexTag].self, forKey: .roles) ?? []
self.markers = try values.decodeIfPresent([PlexMarker].self, forKey: .markers) ?? []
} }
/// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt /// `ratingKey` ist serverweit eindeutig und eignet sich deshalb direkt
@@ -162,6 +184,7 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
case size, totalSize, offset, title1, title2 case size, totalSize, offset, title1, title2
case metadata = "Metadata" case metadata = "Metadata"
case directory = "Directory" case directory = "Directory"
case hub = "Hub"
} }
public init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
@@ -174,13 +197,15 @@ public struct PlexMediaContainer<Content: Decodable & Sendable>: Sendable, Decod
self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1) self.title1 = try inner.decodeIfPresent(String.self, forKey: .title1)
self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2) self.title2 = try inner.decodeIfPresent(String.self, forKey: .title2)
// `Metadata` bei Titeln, `Directory` bei Bibliotheken und Staffeln. // `Metadata` bei Titeln, `Directory` bei Mediatheken, `Hub` bei den
// Eine leere Mediathek liefert gar kein Feld — das ist kein Fehler, // Reihen der Startseite. Eine leere Mediathek liefert gar kein Feld —
// sondern bedeutet schlicht null Einträge. // das ist kein Fehler, sondern bedeutet schlicht null Einträge.
if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) { if let metadata = try inner.decodeIfPresent([Content].self, forKey: .metadata) {
self.content = metadata self.content = metadata
} else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) { } else if let directory = try inner.decodeIfPresent([Content].self, forKey: .directory) {
self.content = directory self.content = directory
} else if let hub = try inner.decodeIfPresent([Content].self, forKey: .hub) {
self.content = hub
} else { } else {
self.content = [] self.content = []
} }
@@ -0,0 +1,106 @@
import Foundation
/// Eine Mediathek auf dem Server.
public struct PlexLibrary: Sendable, Equatable, Decodable, Identifiable {
public enum Kind: String, Sendable, Codable {
case movie, show, artist, photo
/// Live-TV und Aufnahmen.
case livetv
}
/// Die Nummer des Abschnitts, als Zeichenkette. Steckt in fast jedem
/// weiteren Pfad.
public let key: String
public let title: String
public let type: String
public let uuid: String?
public let art: String?
public let thumb: String?
public let updatedAt: Int?
public var id: String { key }
public var kind: Kind? { Kind(rawValue: type) }
/// Ob Flixy diese Mediathek darstellen kann. Musik und Fotos sind laut
/// Plan nicht vorgesehen — sie erscheinen deshalb gar nicht erst in der
/// Seitenleiste, statt dort auf einen leeren Bildschirm zu führen.
public var isSupported: Bool {
kind == .movie || kind == .show
}
public init(
key: String,
title: String,
type: String,
uuid: String? = nil,
art: String? = nil,
thumb: String? = nil,
updatedAt: Int? = nil
) {
self.key = key
self.title = title
self.type = type
self.uuid = uuid
self.art = art
self.thumb = thumb
self.updatedAt = updatedAt
}
}
/// Eine Reihe auf der Startseite.
///
/// Plex nennt sie „Hub" und liefert sie fertig zusammengestellt — Weiterschauen,
/// Zuletzt hinzugefügt, Sammlungen. Die App muss diese Reihen also nicht selbst
/// bauen, sondern nur darstellen.
public struct PlexHub: Sendable, Equatable, Decodable, Identifiable {
/// Stabiler Bezeichner wie `home.continue` oder `movie.recentlyadded`.
/// Danach richtet sich die Sortierung in den Einstellungen.
public let hubIdentifier: String
public let title: String
public let type: String?
public let key: String?
public let size: Int?
/// Ob es mehr Einträge gibt, als die Reihe zeigt — Grundlage für ein
/// „Alle anzeigen" am Ende.
public let more: Bool
public let items: [PlexItem]
public var id: String { hubIdentifier }
private enum CodingKeys: String, CodingKey {
case hubIdentifier, title, type, key, size, more
case items = "Metadata"
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.title = try values.decode(String.self, forKey: .title)
// Ältere Serverfassungen liefern keinen `hubIdentifier`. Der Titel ist
// dann der einzige verfügbare Schlüssel — schlechter, aber besser als
// eine Antwort, die gar nicht lesbar ist.
self.hubIdentifier = try values.decodeIfPresent(String.self, forKey: .hubIdentifier) ?? title
self.type = try values.decodeIfPresent(String.self, forKey: .type)
self.key = try values.decodeIfPresent(String.self, forKey: .key)
self.size = try values.decodeIfPresent(Int.self, forKey: .size)
self.more = try values.decodeIfPresent(Bool.self, forKey: .more) ?? false
self.items = try values.decodeIfPresent([PlexItem].self, forKey: .items) ?? []
}
public init(
hubIdentifier: String,
title: String,
type: String? = nil,
key: String? = nil,
size: Int? = nil,
more: Bool = false,
items: [PlexItem] = []
) {
self.hubIdentifier = hubIdentifier
self.title = title
self.type = type
self.key = key
self.size = size
self.more = more
self.items = items
}
}
@@ -0,0 +1,63 @@
import Foundation
/// Ein Schlagwort am Titel: Genre, Regie, Rolle, Land.
///
/// Plex gibt sie alle in derselben Form aus und unterscheidet nur über den
/// Feldnamen. `role` ist dabei nur bei Besetzungseinträgen gefüllt und trägt
/// den Namen der gespielten Figur.
public struct PlexTag: Sendable, Equatable, Hashable, Decodable, Identifiable {
public let tagKey: String?
public let tag: String
public let role: String?
public let thumb: String?
private let rawID: Int?
/// Eigene Kennung statt `id` vom Server: Genres kommen ohne, und zwei
/// Einträge ohne Kennung würden in `ForEach` zusammenfallen.
public var id: String { tagKey ?? rawID.map(String.init) ?? tag }
private enum CodingKeys: String, CodingKey {
case rawID = "id"
case tagKey, tag, role, thumb
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.rawID = try values.decodeIfPresent(Int.self, forKey: .rawID)
self.tagKey = try values.decodeIfPresent(String.self, forKey: .tagKey)
self.tag = try values.decode(String.self, forKey: .tag)
self.role = try values.decodeIfPresent(String.self, forKey: .role)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
}
public init(tag: String, role: String? = nil, thumb: String? = nil, tagKey: String? = nil) {
self.rawID = nil
self.tagKey = tagKey
self.tag = tag
self.role = role
self.thumb = thumb
}
}
/// Eine Sprungmarke in einer Datei: Intro oder Abspann.
///
/// Wird schon in Phase 1 mitgelesen, obwohl sie erst in Phase 2 ausgewertet
/// wird — sie kostet nichts und erspart später eine zweite Anfrage.
public struct PlexMarker: Sendable, Equatable, Decodable, Identifiable {
public let id: Int
public let type: String
/// In Millisekunden ab Beginn.
public let startTimeOffset: Int
public let endTimeOffset: Int
public var isIntro: Bool { type == "intro" }
public var isCredits: Bool { type == "credits" }
public init(id: Int, type: String, startTimeOffset: Int, endTimeOffset: Int) {
self.id = id
self.type = type
self.startTimeOffset = startTimeOffset
self.endTimeOffset = endTimeOffset
}
}
@@ -0,0 +1,167 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Verbindungsauswahl")
struct ConnectionSelectorTests {
private let serverID = "server-abc-123"
private func identityResponse(_ machineIdentifier: String) -> String {
#"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
}
private func resource(connections: [PlexConnection]) -> PlexResource {
PlexResource(
name: "Wohnzimmer",
clientIdentifier: serverID,
accessToken: "server-token",
connections: connections
)
}
@Test("Der lokale Weg gewinnt, auch wenn die Vermittlung erreichbar ist")
func localWinsOverRelay() async {
// Der eigentliche Zweck der Rangordnung: die Vermittlung von Plex
// antwortet im Zweifel schneller, ist aber gedrosselt. Ein Rennen ohne
// Rang würde ausgerechnet den langsamsten Weg krönen.
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "http://192.168.1.10:32400")
}
@Test("Der direkte Weg von außen kommt vor der Vermittlung")
func directRemoteBeatsRelay() async {
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
}
@Test("Ein Server mit fremder Kennung wird abgelehnt")
func rejectsWrongServer() async {
// Eine veraltete plex.direct-Adresse kann auf einen fremden Server
// zeigen. Ohne Abgleich der Kennung zeigte die App fremde Mediatheken.
let transport = FakeTransport.routes(["/identity": identityResponse("ein-anderer-server")])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
]))
#expect(chosen == nil)
}
@Test("Ohne erreichbaren Weg gibt es kein Ergebnis")
func noReachableConnection() async {
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
]))
#expect(chosen == nil)
}
@Test("Ohne Server-Token wird gar nicht erst probiert")
func missingAccessTokenFailsFast() async {
// Bei geteilten Servern liefert plex.tv gelegentlich keinen Token —
// etwa wenn die Freigabe zurückgezogen wurde. Anfragen ohne ihn
// beantwortet der Server wortlos mit 401.
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let resource = PlexResource(
name: "Geteilt",
clientIdentifier: serverID,
accessToken: nil,
connections: [PlexConnection(uri: "http://192.168.1.10:32400", local: true)]
)
#expect(await selector.selectConnection(for: resource) == nil)
}
@Test("Die Rangordnung der Wege stimmt")
func tierOrdering() {
#expect(PlexConnection(uri: "x", local: true).tier == 0)
#expect(PlexConnection(uri: "x").tier == 1)
#expect(PlexConnection(uri: "x", relay: true).tier == 2)
}
}
@Suite("Serverliste")
struct ServerDirectoryTests {
private let resourcesJSON = """
[
{
"name": "Wohnzimmer",
"product": "Plex Media Server",
"clientIdentifier": "server-abc",
"provides": "server",
"owned": true,
"accessToken": "token-eigen",
"connections": [
{ "uri": "http://192.168.1.10:32400", "address": "192.168.1.10", "port": 32400, "local": true, "relay": false, "protocol": "http", "IPv6": false },
{ "uri": "https://1-2-3-4.abc.plex.direct:32400", "local": false, "relay": false, "protocol": "https" }
]
},
{
"name": "iPhone von Guido",
"clientIdentifier": "client-xyz",
"provides": "client,player",
"owned": true,
"connections": []
},
{
"name": "Server eines Freundes",
"clientIdentifier": "server-def",
"provides": "server",
"owned": false,
"accessToken": "token-geteilt",
"connections": [
{ "uri": "https://5-6-7-8.def.plex.direct:32400", "local": false, "relay": false }
]
}
]
"""
@Test("Nur Server erscheinen in der Liste, keine Abspielgeräte")
func filtersOutNonServers() async throws {
// Das Konto listet auch jedes iPhone und jeden Fernseher, der je
// angemeldet war. Ungefiltert stünden sie alle in der Serverauswahl.
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
#expect(servers.count == 2)
#expect(!servers.contains { $0.clientIdentifier == "client-xyz" })
}
@Test("Geteilte Server behalten ihren eigenen Token")
func sharedServersKeepTheirOwnToken() async throws {
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
let shared = try #require(servers.first { !$0.owned })
#expect(shared.accessToken == "token-geteilt")
#expect(shared.accessToken != "konto-token")
}
}
@@ -0,0 +1,100 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Mediatheks-Abfragen")
struct LibraryQueryTests {
private func value(_ items: [URLQueryItem], _ name: String) -> String? {
items.first { $0.name == name }?.value
}
@Test("Eine Serienmediathek liefert Serien, nicht einzelne Folgen")
func showLibraryRequestsShows() {
// Ohne `type=2` schüttet Plex alle Folgen einzeln aus — bei einer
// Serienmediathek sind das schnell zehntausend Einträge statt dreißig
// Serien.
let items = LibraryQuery().queryItems(libraryKind: .show)
#expect(value(items, "type") == "2")
}
@Test("Eine Filmmediathek fragt nach Filmen")
func movieLibraryRequestsMovies() {
#expect(value(LibraryQuery().queryItems(libraryKind: .movie), "type") == "1")
}
@Test("Unbekannte Mediathekenarten bekommen keine Artangabe")
func unknownKindOmitsType() {
#expect(value(LibraryQuery().queryItems(libraryKind: nil), "type") == nil)
}
@Test("Sortierung landet immer in der Abfrage")
func sortIsAlwaysPresent() {
for sort in LibraryQuery.Sort.allCases {
let items = LibraryQuery(sort: sort).queryItems(libraryKind: .movie)
#expect(value(items, "sort") == sort.rawValue)
}
}
@Test("Ohne Filter stehen keine Filterparameter in der Abfrage")
func unsetFiltersAreOmitted() {
// Ein leerer Parameter ist für Plex nicht dasselbe wie ein fehlender:
// `genre=` filtert auf ein Genre ohne Namen und liefert nichts.
let items = LibraryQuery().queryItems(libraryKind: .movie)
#expect(value(items, "genre") == nil)
#expect(value(items, "year") == nil)
#expect(value(items, "unwatched") == nil)
#expect(value(items, "resolution") == nil)
}
@Test("Gesetzte Filter landen vollständig in der Abfrage")
func setFiltersAppear() {
let query = LibraryQuery(
sort: .recentlyAdded,
unwatchedOnly: true,
genre: "18",
year: 2017,
resolution: "4k"
)
let items = query.queryItems(libraryKind: .movie)
#expect(value(items, "sort") == "addedAt:desc")
#expect(value(items, "unwatched") == "1")
#expect(value(items, "genre") == "18")
#expect(value(items, "year") == "2017")
#expect(value(items, "resolution") == "4k")
}
@Test("Der Filterknopf weiß, ob gefiltert wird")
func filteringFlagReflectsState() {
#expect(!LibraryQuery().isFiltering)
// Die Sortierung allein zählt nicht als Filter — sonst wäre der Knopf
// dauerhaft hervorgehoben.
#expect(!LibraryQuery(sort: .rating).isFiltering)
#expect(LibraryQuery(unwatchedOnly: true).isFiltering)
#expect(LibraryQuery(genre: "18").isFiltering)
}
}
@Suite("Seitenweises Nachladen")
struct PlexPageTests {
@Test("Eine volle Seite kündigt Nachschub an")
func fullPageHasMore() {
let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250)
#expect(page.hasMore)
#expect(page.nextOffset == 60)
}
@Test("Die letzte Seite kündigt keinen Nachschub an")
func lastPageHasNoMore() {
// Genau die Bedingung, die sonst zu einer Endlosschleife führt: die
// Ansicht lädt nach, bekommt nichts, lädt wieder nach.
let page = PlexPage(items: Array(0..<10), offset: 240, totalSize: 250)
#expect(!page.hasMore)
}
@Test("Eine leere Mediathek verlangt kein Nachladen")
func emptyLibraryHasNoMore() {
let page = PlexPage(items: [Int](), offset: 0, totalSize: 0)
#expect(!page.hasMore)
}
}
@@ -0,0 +1,57 @@
import Foundation
@testable import PlexKit
/// Ein Transport, der vorbereitete Antworten liefert.
///
/// Statt `URLProtocol` zu unterwandern: das ist global und bringt bei parallel
/// laufenden Tests die Antworten durcheinander. Hier hat jeder Test seinen
/// eigenen Transport.
struct FakeTransport: PlexTransport {
let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse)
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await handler(request)
}
/// Antwortet auf jeden Pfad, der als Schlüssel hinterlegt ist, und mit 404
/// auf alle übrigen.
static func routes(
_ routes: [String: String],
status: Int = 200
) -> FakeTransport {
FakeTransport { request in
let path = request.url?.path() ?? ""
guard let body = routes.first(where: { path.hasSuffix($0.key) })?.value else {
return (Data(), response(for: request, status: 404))
}
return (Data(body.utf8), response(for: request, status: status))
}
}
/// Verweigert jede Verbindung — für die Wege, die im Test nicht gewinnen
/// sollen.
static func unreachable() -> FakeTransport {
FakeTransport { _ in throw URLError(.cannotConnectToHost) }
}
static func response(for request: URLRequest, status: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: request.url ?? URL(string: "http://example.invalid")!,
statusCode: status,
httpVersion: nil,
headerFields: nil
)!
}
}
extension PlexClientIdentity {
static let test = PlexClientIdentity(
product: "FlixyTests",
version: "0",
clientIdentifier: "test-client",
platform: "iOS",
platformVersion: "26.0",
device: "iPhone",
deviceName: "Testgerät"
)
}
+15
View File
@@ -0,0 +1,15 @@
{
"originHash" : "b573b58f837dec84b5981e792f7548e0bb126412c181987af9b265133a652e02",
"pins" : [
{
"identity" : "vlckit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/videolan/vlckit.git",
"state" : {
"revision" : "6cbc4e7b248aa51ec0906697abb30ecae47194b7",
"version" : "4.0.0-a24"
}
}
],
"version" : 3
}
+31
View File
@@ -0,0 +1,31 @@
// swift-tools-version: 6.2
import PackageDescription
/// VLCKit steckt bewusst in einem eigenen Package, nicht in FlixyKit.
///
/// Das Binärpaket ist 920 MB groß und entpackt sich zu rund 2,7 GB. Läge es im
/// Abhängigkeitsbaum von FlixyKit, zöge es jeder `swift test`-Lauf hinter sich
/// her — und aus drei Sekunden Testlaufzeit würden Minuten.
let package = Package(
name: "FlixyVLC",
defaultLocalization: "en",
platforms: [.iOS(.v26)],
products: [
.library(name: "FlixyVLC", targets: ["FlixyVLC"]),
],
dependencies: [
.package(name: "FlixyKit", path: "../FlixyKit"),
// Genau festgenagelt: die 4.0-Reihe ist noch im Alpha-Stadium, und die
// API hat sich zwischen den Vorabversionen mehrfach geändert.
.package(url: "https://github.com/videolan/vlckit.git", exact: "4.0.0-a24"),
],
targets: [
.target(
name: "FlixyVLC",
dependencies: [
.product(name: "FlixyPlayback", package: "FlixyKit"),
.product(name: "VLCKit", package: "vlckit"),
]
),
]
)
@@ -0,0 +1,241 @@
import AVFoundation
import Foundation
import FlixyPlayback
import UIKit
@preconcurrency import VLCKit
/// Wiedergabe über VLCKit.
///
/// Der Weg für alles, was der Systemplayer nicht öffnet: Matroska-Container,
/// DTS- und TrueHD-Ton, ältere Codecs. Langsamer beim Starten und stromhungriger,
/// dafür spielt er praktisch jede Datei ohne Zutun des Servers.
///
/// `@preconcurrency import`, weil VLCKit als Objective-C-Bibliothek nichts über
/// Nebenläufigkeit aussagt. Die ganze Klasse liegt deshalb auf dem Hauptakteur —
/// VLCKit erwartet seine Rückrufe ohnehin dort.
@MainActor
public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
/// Die Fläche, in die VLC zeichnet. Die Ansicht hängt sie in ihren
/// Hierarchiebaum.
public let videoView = UIView()
private let player = VLCMediaPlayer()
public private(set) var state: PlaybackState = .idle {
didSet {
guard state != oldValue else { return }
continuation?.yield(.stateChanged(state))
}
}
public private(set) var duration: Duration?
public private(set) var audioTracks: [MediaTrack] = []
public private(set) var subtitleTracks: [MediaTrack] = []
public var currentTime: Duration {
.milliseconds(Int(player.time.intValue))
}
/// VLCKit 4 bringt ein eigenes Bild-in-Bild-Protokoll mit
/// (`VLCPictureInPictureMediaControlling`). Angebunden ist es noch nicht —
/// solange steht hier `false`, damit kein Knopf erscheint, der nichts tut.
/// Nachzuholen in Phase 2, zusammen mit der Steuerung im Sperrbildschirm.
public let supportsPictureInPicture = false
public private(set) lazy var events: AsyncStream<PlaybackEvent> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var pendingStartTime: Duration = .zero
public override init() {
super.init()
videoView.backgroundColor = .black
player.delegate = self
player.drawable = videoView
AudioSessionBridge.activate()
}
public func load(_ source: PlaybackSource) async throws(PlaybackFailure) {
state = .loading
pendingStartTime = source.startTime
let media = VLCMedia(url: source.url)
player.media = media
// VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat.
// Beides kommt später über den Delegaten — hier gibt es nichts zu
// warten, und ein `await` würde nur den Bildaufbau verzögern.
player.play()
}
public func play() {
player.play()
}
public func pause() {
// `pause()` ist bei VLC ein Umschalter. Ohne diese Prüfung würde ein
// Aufruf im pausierten Zustand die Wiedergabe wieder starten.
if player.isPlaying { player.pause() }
}
public func seek(to time: Duration) async {
player.time = VLCTime(int: time.vlcMilliseconds)
continuation?.yield(.timeChanged(time))
}
public func setRate(_ rate: Double) {
player.rate = Float(rate)
}
public func selectAudioTrack(_ track: MediaTrack?) {
select(track, from: player.audioTracks)
}
public func selectSubtitleTrack(_ track: MediaTrack?) {
select(track, from: player.textTracks)
}
public func teardown() {
player.stop()
player.delegate = nil
player.drawable = nil
continuation?.finish()
continuation = nil
state = .idle
AudioSessionBridge.deactivate()
}
// MARK: - Spuren
private func select(_ track: MediaTrack?, from tracks: [VLCMediaPlayer.Track]) {
guard let track, let identifier = track.engineTrackID else {
// Abschalten: alle Spuren dieser Art abwählen. Bei Untertiteln der
// Normalfall.
tracks.forEach { $0.isSelected = false }
return
}
// `isSelectedExclusively` wählt diese Spur und wählt die übrigen
// desselben Typs ab. Einzeln zu setzen ließe zwei Tonspuren gleichzeitig
// laufen.
tracks.first { $0.trackId == identifier }?.isSelectedExclusively = true
}
private func refreshTracks() {
audioTracks = player.audioTracks.map { $0.mediaTrack(kind: .audio) }
subtitleTracks = player.textTracks.map { $0.mediaTrack(kind: .subtitle) }
continuation?.yield(.tracksResolved(audio: audioTracks, subtitle: subtitleTracks))
}
}
// MARK: - VLCMediaPlayerDelegate
/// VLCKit stellt seine Rückrufe standardmäßig über die Hauptwarteschlange zu
/// (siehe `VLCEventsConfiguration`). Deshalb `assumeIsolated` statt eines
/// `Task`: ein nachgelagerter Task würde die Reihenfolge der Zustandswechsel
/// durcheinanderbringen — „spielt" könnte nach „gestoppt" ankommen.
extension VLCPlayerEngine: VLCMediaPlayerDelegate {
public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) {
MainActor.assumeIsolated {
switch newState {
case .opening:
state = .loading
case .playing:
state = .playing
applyPendingStartTime()
refreshTracks()
case .paused:
state = .paused
case .stopped, .stopping:
// VLC meldet das Ende einer Datei als „gestoppt". Ob das
// Ansehen abgeschlossen ist oder jemand abgebrochen hat,
// unterscheidet nur die Position.
state = isNearEnd ? .ended : .idle
if isNearEnd { continuation?.yield(.reachedEnd) }
case .error:
state = .failed(PlaybackFailure(kind: .formatUnsupported))
case .nothingSpecial:
break
@unknown default:
break
}
}
}
public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) {
MainActor.assumeIsolated {
continuation?.yield(.timeChanged(currentTime))
}
}
public nonisolated func mediaPlayerLengthChanged(_ length: Int64) {
MainActor.assumeIsolated {
guard length > 0 else { return }
let value = Duration.milliseconds(length)
duration = value
continuation?.yield(.durationResolved(value))
}
}
/// Beim Fortsetzen: VLC nimmt eine Position erst an, wenn die Wiedergabe
/// tatsächlich läuft. Vorher gesetzt, wird sie kommentarlos verworfen.
private func applyPendingStartTime() {
guard pendingStartTime > .zero else { return }
player.time = VLCTime(int: pendingStartTime.vlcMilliseconds)
pendingStartTime = .zero
}
private var isNearEnd: Bool {
// Ein Rest von unter zwei Sekunden zählt als durchgesehen — VLC
// stoppt selten exakt am Dateiende.
guard let duration else { return false }
return currentTime + .seconds(2) >= duration
}
}
extension Duration {
/// Millisekunden als `Int32`, wie VLCTime sie erwartet.
///
/// Abgeschnitten statt umgewandelt: `Int32(...)` stürzt bei Überlauf ab,
/// und die Grenze liegt bei rund 24 Tagen Laufzeit. Das wird keine Datei
/// erreichen — aber ein Absturz an dieser Stelle wäre besonders schwer zu
/// erklären.
var vlcMilliseconds: Int32 {
let milliseconds = components.seconds * 1000
return Int32(clamping: milliseconds)
}
}
extension VLCMediaPlayer.Track {
fileprivate func mediaTrack(kind: MediaTrack.Kind) -> MediaTrack {
MediaTrack(
id: "vlc-\(trackId)",
kind: kind,
displayName: trackName.isEmpty ? trackId : trackName,
engineIdentifier: nil
)
}
}
extension MediaTrack {
/// VLC führt seine Spuren unter Zeichenketten, nicht unter Zahlen —
/// deshalb steckt die Kennung im `id`-Feld statt in `engineIdentifier`.
fileprivate var engineTrackID: String? {
id.hasPrefix("vlc-") ? String(id.dropFirst(4)) : nil
}
}
/// Die Tonsitzung, damit der Ton auch bei umgelegtem Stummschalter läuft.
enum AudioSessionBridge {
static func activate() {
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try? AVAudioSession.sharedInstance().setActive(true)
}
static func deactivate() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
}
+6
View File
@@ -19,6 +19,10 @@ settings:
packages: packages:
FlixyKit: FlixyKit:
path: Packages/FlixyKit path: Packages/FlixyKit
# Eigenes Package, weil das VLCKit-Binärpaket 920 MB wiegt und sonst bei
# jedem `swift test` in FlixyKit mitgezogen würde.
FlixyVLC:
path: Packages/FlixyVLC
targets: targets:
Flixy: Flixy:
@@ -35,6 +39,8 @@ targets:
product: FlixyPlayback product: FlixyPlayback
- package: FlixyKit - package: FlixyKit
product: FlixyDownloads product: FlixyDownloads
- package: FlixyVLC
product: FlixyVLC
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy