diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 1f6c07c..7c01aee 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -471,6 +471,222 @@ } } }, + "diagnostics.connection": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" + } + } + } + }, + "diagnostics.connectionUsed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connection" + } + } + } + }, + "diagnostics.copy": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bericht kopieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy report" + } + } + } + }, + "diagnostics.copyHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Enthält keinen Token. Kannst du unbesorgt weitergeben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Contains no token. Safe to share." + } + } + } + }, + "diagnostics.lastAttempt": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Letzter Abspielversuch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Last playback attempt" + } + } + } + }, + "diagnostics.method": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weg" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Method" + } + } + } + }, + "diagnostics.noServer": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Serververbindung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No server connection." + } + } + } + }, + "diagnostics.nothingYet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Noch nichts abgespielt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing played yet." + } + } + } + }, + "diagnostics.relayWarning": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Via the Plex relay. Heavily throttled — large files barely start over it." + } + } + } + }, + "diagnostics.source": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Quelle (ohne Token)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Source (token removed)" + } + } + } + }, + "diagnostics.steps": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ablauf" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Timeline" + } + } + } + }, + "diagnostics.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diagnose" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Diagnostics" + } + } + } + }, + "diagnostics.title.label": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + } + } + }, "downloads.active": { "extractionState": "stale", "localizations": { @@ -2117,6 +2333,24 @@ } } }, + "settings.build": { + "comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Build" + } + } + } + }, "settings.connection": { "extractionState": "stale", "localizations": { @@ -3180,227 +3414,19 @@ } } }, - "settings.build": { - "comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", + "signIn.timedOut": { + "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Build" + "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal." } }, "en": { "stringUnit": { "state": "translated", - "value": "Build" - } - } - } - }, - "diagnostics.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Diagnose" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Diagnostics" - } - } - } - }, - "diagnostics.connection": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection" - } - } - } - }, - "diagnostics.noServer": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Serververbindung." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No server connection." - } - } - } - }, - "diagnostics.relayWarning": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Via the Plex relay. Heavily throttled — large files barely start over it." - } - } - } - }, - "diagnostics.lastAttempt": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Letzter Abspielversuch" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Last playback attempt" - } - } - } - }, - "diagnostics.nothingYet": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Noch nichts abgespielt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing played yet." - } - } - } - }, - "diagnostics.title.label": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Titel" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Title" - } - } - } - }, - "diagnostics.method": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Weg" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Method" - } - } - } - }, - "diagnostics.connectionUsed": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection" - } - } - } - }, - "diagnostics.source": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Quelle (ohne Token)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Source (token removed)" - } - } - } - }, - "diagnostics.steps": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ablauf" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Timeline" - } - } - } - }, - "diagnostics.copy": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bericht kopieren" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy report" - } - } - } - }, - "diagnostics.copyHint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Enthält keinen Token. Kannst du unbesorgt weitergeben." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Contains no token. Safe to share." + "value": "Sign-in wasn't completed. Please try again." } } } diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index aae3d8b..616eddf 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -150,47 +150,77 @@ final class FlixySession { /// Ö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. + /// Plex einen Token herausgibt. Sichtbar ist er nicht mehr. + /// + /// Das Fenster wird geöffnet, aber **nicht abgewartet**: Plex führt den + /// Rücksprung über `forwardUrl` nicht zuverlässig aus. Auf dem iPad landet + /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen + /// — der Token liegt längst bereit, aber die App wartete auf einen + /// Rückruf, der nie kam. Stattdessen wird hier im Sekundentakt beim Server + /// nachgefragt und nebenbei geprüft, ob das Fenster geschlossen wurde. func beginSignIn() async { phase = .authenticating + let pin: PlexPin do { - let pin = try await authenticator.requestPin() + pin = try await authenticator.requestPin() + } catch { + phase = .failed(message: message(for: error)) + return + } - guard let url = pin.authorizationURL( - clientIdentifier: identity.clientIdentifier, - product: Branding.plexProduct, - forwardURL: Branding.authCallbackURL - ) else { - phase = .failed(message: String(localized: "signIn.windowFailed")) + guard let url = pin.authorizationURL( + clientIdentifier: identity.clientIdentifier, + product: Branding.plexProduct, + forwardURL: Branding.authCallbackURL + ) else { + phase = .failed(message: String(localized: "signIn.windowFailed")) + return + } + + webAuthenticator.present(url: url, callbackScheme: Branding.urlScheme) + + // Die Frist entspricht der Gültigkeit des Codes bei Plex. + for _ in 0..<900 { + try? await Task.sleep(for: .seconds(1)) + + if let token = try? await authenticator.token(for: pin) { + webAuthenticator.dismiss() + await connect(accountToken: token) 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)) + // Fenster geschlossen, ohne dass ein Token da ist. + if webAuthenticator.didReturn { + switch webAuthenticator.outcome { + case .cancelled, nil: + // Abgebrochen oder zurückgesprungen, ohne dass der Token + // schon bereitlag. Noch ein paar Sekunden nachfragen, + // bevor aufgegeben wird — zwischen Rücksprung und Eintrag + // bei Plex liegt manchmal eine Sekunde. + if let token = try? await graceTokenLookup(pin: pin) { + await connect(accountToken: token) + return + } + phase = .signedOut + case .presentationFailed(let description): + phase = .failed(message: description) + } + return + } } + + webAuthenticator.dismiss() + phase = .failed(message: String(localized: "signIn.timedOut")) + } + + /// Fragt nach dem Schließen des Fensters noch ein paar Sekunden nach. + private func graceTokenLookup(pin: PlexPin) async throws -> String? { + for _ in 0..<5 { + if let token = try? await authenticator.token(for: pin) { return token } + try? await Task.sleep(for: .milliseconds(700)) + } + return nil } func cancelSignIn() { @@ -199,12 +229,6 @@ final class FlixySession { 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() diff --git a/Flixy/Session/PlexWebAuthenticator.swift b/Flixy/Session/PlexWebAuthenticator.swift index bb93f72..3f79c0c 100644 --- a/Flixy/Session/PlexWebAuthenticator.swift +++ b/Flixy/Session/PlexWebAuthenticator.swift @@ -21,42 +21,74 @@ final class PlexWebAuthenticator: NSObject { case presentationFailed(String) } - /// Führt die Anmeldung durch und kehrt zurück, sobald Plex die App - /// zurückgeschickt hat. - func authenticate(url: URL, callbackScheme: String) async throws(Failure) { - do { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let session = ASWebAuthenticationSession( - url: url, - callbackURLScheme: callbackScheme - ) { _, error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } + /// Das laufende Fenster — damit es sich auch von außen schließen lässt. + /// + /// Nötig, weil Plex den Rücksprung nicht immer ausführt: manchmal landet + /// der Browser nach dem Anmelden auf watch.plex.tv und bleibt dort stehen. + /// Der Token liegt dann längst bereit, nur das Fenster weiß es nicht. + private var session: ASWebAuthenticationSession? - session.presentationContextProvider = self - // `false`, damit eine bestehende Plex-Anmeldung in Safari - // mitgenutzt wird: wer dort schon angemeldet ist, braucht nur - // zu bestätigen statt das Passwort erneut einzugeben. - session.prefersEphemeralWebBrowserSession = false + /// Wie das Fenster ausgegangen ist. + /// + /// `nil`, solange es noch offen ist. + private(set) var outcome: Failure? + private(set) var didReturn = false - guard session.start() else { - continuation.resume(throwing: Failure.presentationFailed( - String(localized: "signIn.windowFailed") - )) - return + /// Öffnet das Anmeldefenster und kehrt **sofort** zurück. + /// + /// Bewusst nicht abwartend: Plex führt den Rücksprung über `forwardUrl` + /// nicht zuverlässig aus. Auf dem iPad landet der Browser nach dem + /// Anmelden auf watch.plex.tv und bleibt stehen — der Token liegt längst + /// bereit, aber ein abwartender Aufruf hinge auf einen Rückruf, der nie + /// kommt. Der Aufrufer fragt deshalb parallel beim Server nach und sieht + /// hier nur nach, ob das Fenster zwischendurch geschlossen wurde. + func present(url: URL, callbackScheme: String) { + outcome = nil + didReturn = false + + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackScheme + ) { [weak self] _, error in + // ASWebAuthenticationSession ruft den Abschlussblock auf dem + // Hauptthread auf — anders als VLCKit, wo dieselbe Annahme die App + // zum Absturz gebracht hat. Hier ist sie dokumentiert. + MainActor.assumeIsolated { + guard let self else { return } + if let error = error as? ASWebAuthenticationSessionError, + error.code == .canceledLogin { + self.outcome = .cancelled + } else if let error { + self.outcome = .presentationFailed(error.localizedDescription) } + self.didReturn = true + self.session = nil } - } catch let error as ASWebAuthenticationSessionError where error.code == .canceledLogin { - throw .cancelled - } catch let error as Failure { - throw error - } catch { - throw .presentationFailed(error.localizedDescription) } + + session.presentationContextProvider = self + // `false`, damit eine bestehende Plex-Anmeldung in Safari mitgenutzt + // wird: wer dort schon angemeldet ist, braucht nur zu bestätigen. + session.prefersEphemeralWebBrowserSession = false + + guard session.start() else { + outcome = .presentationFailed(String(localized: "signIn.windowFailed")) + didReturn = true + return + } + self.session = session + } +} + +extension PlexWebAuthenticator { + /// Schließt das Anmeldefenster. + /// + /// Wird gerufen, sobald der Token auf anderem Weg eingetroffen ist — sonst + /// bliebe der Browser mit der Plex-Seite offen stehen, obwohl die App + /// schon angemeldet ist. + func dismiss() { + session?.cancel() + session = nil } } diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index f6df4d9..04264f0 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -189,7 +189,11 @@ public final class AVPlayerEngine: VideoPlayerEngine { private func observePlayer() { observations.append( player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in - MainActor.assumeIsolated { + // Kein `assumeIsolated`: KVO meldet auf dem Thread, der die + // Änderung ausgelöst hat, und AVPlayer ändert seinen Zustand + // auch aus internen Threads. Genau dieselbe Annahme hat die App + // im VLC-Motor zum Absturz gebracht. + Task { @MainActor in guard let self else { return } switch player.timeControlStatus { case .playing: self.state = .playing @@ -207,6 +211,9 @@ public final class AVPlayerEngine: VideoPlayerEngine { forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main ) { [weak self] time in + // Hier ist `assumeIsolated` belegt statt geraten: die Warteschlange + // steht eine Zeile darüber. Viermal je Sekunde eine Aufgabe + // einzureihen wäre unnötiger Aufwand. MainActor.assumeIsolated { guard let self, time.isNumeric else { return } self.continuation?.yield(.timeChanged(.seconds(time.seconds))) @@ -217,7 +224,9 @@ public final class AVPlayerEngine: VideoPlayerEngine { private func observe(item: AVPlayerItem) { observations.append( item.observe(\.status, options: [.new]) { [weak self] item, _ in - MainActor.assumeIsolated { + // Siehe oben: auch dieser Rückruf kommt nicht verlässlich vom + // Hauptthread. + Task { @MainActor in guard let self else { return } switch item.status { case .failed: @@ -241,6 +250,7 @@ public final class AVPlayerEngine: VideoPlayerEngine { object: item, queue: .main ) { [weak self] _ in + // Ebenfalls ausdrücklich auf der Hauptwarteschlange. MainActor.assumeIsolated { guard let self else { return } self.state = .ended diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift index c5a45a7..84d8b05 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Auth/PlexAuthenticator.swift @@ -45,6 +45,23 @@ public actor PlexAuthenticator { ) } + /// Fragt einmal nach, ob der Code inzwischen bestätigt wurde. + /// + /// Gibt `nil` zurück, solange er es nicht ist. Für Aufrufer, die ihre + /// eigene Schleife führen — etwa weil sie nebenher noch auf das Schließen + /// eines Anmeldefensters achten müssen. + public func token(for pin: PlexPin) async throws(PlexError) -> String? { + let current = try await client.send( + PlexRequest(path: "/api/v2/pins/\(pin.id)"), + as: PlexPin.self + ) + guard let token = current.authToken, !token.isEmpty else { return nil } + + try? tokenStore.store(token, for: Self.accountTokenAccount) + await client.setToken(token) + return token + } + /// Wartet darauf, dass der Nutzer den Code im Browser bestätigt. /// /// Plex bietet keinen Rückkanal — es bleibt nur regelmäßiges Nachfragen. diff --git a/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift index b795adf..64b3709 100644 --- a/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift +++ b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift @@ -143,13 +143,24 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine { // 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. +/// VLCKit ruft den Delegaten von seinem **eigenen Input-Thread**, nicht über +/// die Hauptwarteschlange. +/// +/// Hier stand vorher das Gegenteil, und darauf aufbauend `assumeIsolated` — +/// mit dem Ergebnis, dass die App beim Drücken auf Play hart abbrach, sobald +/// VLC den ersten Zustandswechsel meldete: +/// +/// _dispatch_assert_queue_fail +/// MainActor.assumeIsolated +/// VLCPlayerEngine.mediaPlayerStateChanged(_:) +/// VLCKit input_thread_Events +/// +/// Deshalb jetzt ein echter Sprung auf den Hauptakteur. Die Reihenfolge bleibt +/// dabei erhalten: die Aufgaben werden aus demselben Thread nacheinander +/// eingereiht und auch so abgearbeitet. extension VLCPlayerEngine: VLCMediaPlayerDelegate { public nonisolated func mediaPlayerStateChanged(_ newState: VLCMediaPlayerState) { - MainActor.assumeIsolated { + Task { @MainActor in switch newState { case .opening: state = .loading @@ -176,13 +187,13 @@ extension VLCPlayerEngine: VLCMediaPlayerDelegate { } public nonisolated func mediaPlayerTimeChanged(_ aNotification: Notification) { - MainActor.assumeIsolated { + Task { @MainActor in continuation?.yield(.timeChanged(currentTime)) } } public nonisolated func mediaPlayerLengthChanged(_ length: Int64) { - MainActor.assumeIsolated { + Task { @MainActor in guard length > 0 else { return } let value = Duration.milliseconds(length) duration = value