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.
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`.
static var version: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
+20 -5
View File
@@ -1,22 +1,37 @@
import SwiftUI
import FlixyDesign
import PlexKit
@main
struct FlixyApp: App {
/// Lebt hier, nicht in einer View: die Akzentfarbe gilt für die gesamte
/// App, und ein Neuaufbau der Wurzelansicht darf die Auswahl nicht
/// zurücksetzen.
/// Leben hier, nicht in einer View: sie gelten für die gesamte App, und
/// ein Neuaufbau der Wurzelansicht darf weder die Anmeldung noch die
/// eingestellte Farbe zurücksetzen.
@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 {
WindowGroup {
RootView()
.environment(session)
.environment(playback)
.environment(theme)
.flixyAccent(theme)
// Der eigentliche Schalter für „nur dunkel" steht in der
// Info.plist (UIUserInterfaceStyle). Diese Zeile ist die
// Absicherung für SwiftUI-Vorschauen, die die Info.plist
// nicht lesen.
// Absicherung für SwiftUI-Vorschauen, die sie nicht lesen.
.preferredColorScheme(.dark)
.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 FlixyDesign
import PlexKit
/// Die Wurzelansicht.
///
/// Legt fest, in welcher Form die Seitenleiste erscheint: auf dem iPhone als
/// ausfahrbares Blatt, auf dem iPad dauerhaft neben dem Inhalt. Beide Wege
/// nutzen dieselbe `SidebarView` — der Unterschied liegt nur im Rahmen.
/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
struct RootView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selection: SidebarDestination = .home
@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 {
Group {
if sizeClass == .regular {
splitLayout
} else {
drawerLayout
switch session.phase {
case .launching:
ProgressView()
.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)
// 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
/// Systemknopf einklappen.
@ViewBuilder
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 {
NavigationSplitView {
SidebarView(libraries: libraries, selection: $selection, showsHeader: false) {}
.navigationTitle(Branding.appName)
.toolbar(removing: .sidebarToggle)
SidebarView(
libraries: sidebarDestinations,
selection: $selection,
showsHeader: false
) {}
.navigationTitle(Branding.appName)
} detail: {
NavigationStack {
destination
}
NavigationStack { destination }
}
}
/// iPhone: die Seitenleiste fährt über den Inhalt.
private var drawerLayout: some View {
DrawerContainer(isOpen: $isSidebarOpen) {
SidebarView(libraries: libraries, selection: $selection) {
SidebarView(libraries: sidebarDestinations, selection: $selection) {
isSidebarOpen = false
}
} content: {
@@ -67,15 +121,19 @@ struct RootView: View {
private var destination: some View {
switch selection {
case .home:
PlaceholderScreen(destination: .home)
HomeView()
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:
PlaceholderScreen(destination: .liveTV)
case .downloads:
PlaceholderScreen(destination: .downloads)
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)
}
}
}