Kein Video lief jemals an AVPlayerEngine.load() wartete auf asset.load(.duration) und die Spurenliste, BEVOR es zurückkehrte — und erst danach rief das Modell play(). AVFoundation muss für die Dauer aber so viel von der Datei holen, bis es den Index findet, und der sitzt bei MP4 wie MKV oft am Ende. Über eine langsame Leitung sind das Minuten, in denen nur ein Ladekreis zu sehen war, obwohl der Player längst hätte anfangen können. Jetzt kehrt load() zurück, sobald das Item hängt; Dauer und Spuren kommen nach und melden sich über den Ereignisstrom. Dazu AVURLAssetPreferPreciseDurationAndTimingKey: false — sonst durchsucht AVFoundation bei manchen Formaten die ganze Datei. Zweiter Fund an derselben Stelle: VLCMedia(url:) ist nullable. Ging es schief, blieb media still nil, play() tat nichts und es gab keinen Fehler — nur einen Ladekreis ohne Ende. Wird jetzt gemeldet. Kein Querformat im Player requestGeometryUpdate allein dreht nicht: das System dreht sofort zurück, solange die erlaubten Ausrichtungen es nicht hergeben. Die Erlaubnis kommt nur über den App-Delegaten, deshalb OrientationLock als Brücke. Player dreht ins Querformat, beim Verlassen zurück. iPad bleibt frei — dort ist Querformat beim Blättern der Normalfall. Uhrzeit über dem Film Die Statusleiste war nur ausgeblendet, solange die Bedienelemente weg waren. Jetzt immer. Sehr langer Start Zwei Ursachen. Erstens legte der Initialisierer der App die Hintergrund-URLSession an — die spricht über XPC mit nsurlsessiond und verzögerte den allerersten Bildaufbau. Jetzt erst bei Bedarf, und wer keine Downloads hat, braucht sie nie. Zweitens fragte jeder Start erst plex.tv nach der Serverliste und suchte die Verbindung neu. Die zuletzt funktionierende wird jetzt gemerkt (Server-Token im Schlüsselbund) und mit kurzer Zeitgrenze zuerst probiert; die vollständige Suche greift nur noch, wenn sie nicht mehr antwortet — etwa wenn man von zuhause weggeht. Dazu ein Hinweis im Player, wenn die Verbindung über die gedrosselte Plex-Vermittlung läuft. Sonst sucht man den Fehler in der App. 132 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
12 KiB
Swift
327 lines
12 KiB
Swift
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
|
|
/// Das Anmeldefenster von Plex ist offen.
|
|
case authenticating
|
|
/// 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 (.authenticating, .authenticating):
|
|
true
|
|
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"
|
|
static let cachedConnectionURI = "flixy.server.connectionURI"
|
|
static let cachedServerName = "flixy.server.name"
|
|
static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal"
|
|
}
|
|
|
|
/// Unter welchem Namen der Server-Token im Schlüsselbund liegt.
|
|
///
|
|
/// Nicht in den Voreinstellungen: er gibt vollen Zugriff auf die
|
|
/// Mediathek, genau wie der Konto-Token.
|
|
private static let serverTokenAccount = "plex.server"
|
|
|
|
|
|
/// 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 webAuthenticator = PlexWebAuthenticator()
|
|
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
|
|
}
|
|
|
|
// Erst der kurze Weg: die Verbindung, die beim letzten Mal
|
|
// funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an
|
|
// plex.tv plus eine neue Verbindungssuche — zusammen mehrere
|
|
// Sekunden, in denen nur ein Ladekreis zu sehen ist.
|
|
if await restoreFromRememberedConnection() { return }
|
|
|
|
await connect(accountToken: token)
|
|
}
|
|
|
|
/// Nimmt die zuletzt funktionierende Verbindung wieder auf.
|
|
///
|
|
/// Gibt `false` zurück, wenn nichts gespeichert ist oder der Server nicht
|
|
/// mehr darüber erreichbar ist — dann übernimmt die vollständige Suche.
|
|
/// Das ist der Fall, wenn man von zuhause weggeht: die lokale Adresse
|
|
/// antwortet nicht mehr, und der direkte Weg muss neu gefunden werden.
|
|
private func restoreFromRememberedConnection() async -> Bool {
|
|
guard let identifier = defaults.string(forKey: Key.preferredServer),
|
|
let uri = defaults.string(forKey: Key.cachedConnectionURI),
|
|
let serverToken = tokenStore.token(for: Self.serverTokenAccount)
|
|
else { return false }
|
|
|
|
let name = defaults.string(forKey: Key.cachedServerName) ?? ""
|
|
phase = .connecting(serverName: name)
|
|
|
|
let connection = PlexConnection(
|
|
uri: uri,
|
|
local: defaults.bool(forKey: Key.cachedConnectionIsLocal)
|
|
)
|
|
let resource = PlexResource(
|
|
name: name,
|
|
clientIdentifier: identifier,
|
|
accessToken: serverToken,
|
|
connections: [connection]
|
|
)
|
|
|
|
// Kurze Zeitgrenze: wenn die gemerkte Adresse nicht sofort antwortet,
|
|
// ist die vollständige Suche schneller als weiteres Warten.
|
|
let selector = PlexConnectionSelector(identity: identity, probeTimeout: .seconds(2))
|
|
guard await selector.selectConnection(for: resource) != nil,
|
|
let server = PlexServer(resource: resource, connection: connection, identity: identity)
|
|
else { return false }
|
|
|
|
self.server = server
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
return true
|
|
} catch {
|
|
self.server = nil
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Anmeldung
|
|
|
|
/// Öffnet die Anmeldeseite von Plex.
|
|
///
|
|
/// Der PIN steckt weiterhin darunter — er ist der einzige Weg, auf dem
|
|
/// Plex einen Token herausgibt. Sichtbar ist er nicht mehr: Plex liefert
|
|
/// seine eigene Anmeldemaske aus und schickt die App danach über
|
|
/// `forwardUrl` zurück.
|
|
func beginSignIn() async {
|
|
phase = .authenticating
|
|
|
|
do {
|
|
let pin = try await authenticator.requestPin()
|
|
|
|
guard let url = pin.authorizationURL(
|
|
clientIdentifier: identity.clientIdentifier,
|
|
product: Branding.plexProduct,
|
|
forwardURL: Branding.authCallbackURL
|
|
) else {
|
|
phase = .failed(message: String(localized: "signIn.windowFailed"))
|
|
return
|
|
}
|
|
|
|
try await webAuthenticator.authenticate(
|
|
url: url,
|
|
callbackScheme: Branding.urlScheme
|
|
)
|
|
|
|
// Das Fenster ist zu, jetzt liegt der Token bereit. Kurz
|
|
// nachgefragt statt sofort erwartet: zwischen dem Rücksprung und
|
|
// dem Eintrag bei Plex liegen manchmal ein, zwei Sekunden.
|
|
let token = try await authenticator.waitForAuthorization(
|
|
pin: pin,
|
|
pollInterval: .milliseconds(500),
|
|
timeout: .seconds(20)
|
|
)
|
|
await connect(accountToken: token)
|
|
|
|
} catch PlexWebAuthenticator.Failure.cancelled {
|
|
// Fenster geschlossen. Kein Fehler — kommentarlos zurück.
|
|
phase = .signedOut
|
|
} catch let error as PlexWebAuthenticator.Failure {
|
|
phase = .failed(message: webAuthenticationMessage(for: error))
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
func cancelSignIn() {
|
|
authorizationTask?.cancel()
|
|
authorizationTask = nil
|
|
phase = .signedOut
|
|
}
|
|
|
|
private func webAuthenticationMessage(for failure: PlexWebAuthenticator.Failure) -> String {
|
|
switch failure {
|
|
case .cancelled: String(localized: "signIn.cancelled")
|
|
case .presentationFailed(let description): description
|
|
}
|
|
}
|
|
|
|
func signOut() {
|
|
authorizationTask?.cancel()
|
|
authenticator.signOut()
|
|
forgetRememberedConnection()
|
|
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
|
|
remember(resource: resource, connection: connection)
|
|
|
|
do {
|
|
libraries = try await server.libraries().filter(\.isSupported)
|
|
phase = .ready
|
|
} catch {
|
|
phase = .failed(message: message(for: error))
|
|
}
|
|
}
|
|
|
|
/// Merkt sich, worüber es funktioniert hat.
|
|
private func remember(resource: PlexResource, connection: PlexConnection) {
|
|
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer)
|
|
defaults.set(connection.uri, forKey: Key.cachedConnectionURI)
|
|
defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal)
|
|
defaults.set(resource.name, forKey: Key.cachedServerName)
|
|
if let token = resource.accessToken {
|
|
try? tokenStore.store(token, for: Self.serverTokenAccount)
|
|
}
|
|
}
|
|
|
|
/// Vergisst die gemerkte Verbindung.
|
|
private func forgetRememberedConnection() {
|
|
for key in [Key.preferredServer, Key.cachedConnectionURI,
|
|
Key.cachedConnectionIsLocal, Key.cachedServerName] {
|
|
defaults.removeObject(forKey: key)
|
|
}
|
|
tokenStore.remove(for: Self.serverTokenAccount)
|
|
}
|
|
|
|
/// 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 }
|
|
}
|