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