From 222ac1c9681a5f6289c261cebb461f665d9a4cbb Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 11 Sep 2026 20:43:26 +0200 Subject: [PATCH] Vier Fehler aus dem ersten echten Gebrauch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AppIcon.icon/Assets/play.svg | 1 + AppIcon.icon/icon.json | 49 ++++++++++ Flixy/FlixyApp.swift | 12 +++ Flixy/Resources/Localizable.xcstrings | 89 +++++++++++-------- Flixy/Session/FlixySession.swift | 85 +++++++++++++++++- Flixy/Session/OrientationLock.swift | 70 +++++++++++++++ Flixy/Views/Player/PlayerView.swift | 18 +++- .../Engine/DownloadCoordinator.swift | 34 +++++-- .../FlixyPlayback/Engine/AVPlayerEngine.swift | 86 +++++++++++++----- .../Sources/FlixyVLC/VLCPlayerEngine.swift | 12 ++- project.yml | 4 + 11 files changed, 394 insertions(+), 66 deletions(-) create mode 100644 AppIcon.icon/Assets/play.svg create mode 100644 AppIcon.icon/icon.json create mode 100644 Flixy/Session/OrientationLock.swift diff --git a/AppIcon.icon/Assets/play.svg b/AppIcon.icon/Assets/play.svg new file mode 100644 index 0000000..e2aaae1 --- /dev/null +++ b/AppIcon.icon/Assets/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json new file mode 100644 index 0000000..6d8fa01 --- /dev/null +++ b/AppIcon.icon/icon.json @@ -0,0 +1,49 @@ +{ + "fill-specializations" : [ + { + "value" : { + "automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000" + } + }, + { + "appearance" : "dark", + "value" : { + "automatic-gradient" : "display-p3:0.14018,0.08725,0.31323,1.00000" + } + } + ], + "groups" : [ + { + "layers" : [ + { + "fill" : { + "automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000" + }, + "image-name" : "play.svg", + "name" : "play", + "position" : { + "scale" : 1.18, + "translation-in-points" : [ + 49.7265625, + 1.468530916781674e-05 + ] + } + } + ], + "shadow" : { + "kind" : "neutral", + "opacity" : 0.5 + }, + "translucency" : { + "enabled" : true, + "value" : 0.5 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} \ No newline at end of file diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 66bdd12..3605bd6 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -91,6 +91,18 @@ final class AppDelegate: NSObject, UIApplicationDelegate { /// selbst ist an den Hauptakteur gebunden. private var pendingCompletionHandler: (() -> Void)? + /// Welche Ausrichtungen gerade erlaubt sind. + /// + /// Wird vom System bei jeder Drehung gefragt. Ohne diese Antwort dreht + /// `requestGeometryUpdate` zwar kurz, das System dreht aber sofort zurück + /// — genau das sah aus wie „wechselt nicht so richtig ins Querformat". + func application( + _ application: UIApplication, + supportedInterfaceOrientationsFor window: UIWindow? + ) -> UIInterfaceOrientationMask { + MainActor.assumeIsolated { OrientationLock.shared.mask } + } + func application( _ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index e801039..8f1ce6d 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -2689,6 +2689,39 @@ } } }, + "signIn.cancelled": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldung abgebrochen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in cancelled." + } + } + } + }, + "signIn.privacy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anmeldemaske kommt von Plex. Dein Passwort erreicht Flixy nie." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in page comes from Plex. Your password never reaches Flixy." + } + } + } + }, "signIn.tagline": { "comment": "Untertitel auf dem Anmeldebildschirm", "localizations": { @@ -2706,6 +2739,23 @@ } } }, + "signIn.windowFailed": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Anmeldefenster ließ sich nicht öffnen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in window couldn't be opened." + } + } + } + }, "sort.lastViewed": { "extractionState": "stale", "localizations": { @@ -3112,50 +3162,19 @@ } } }, - "signIn.privacy": { + "player.relayWarning": { + "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Die Anmeldemaske kommt von Plex. Dein Passwort erreicht Flixy nie." + "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." } }, "en": { "stringUnit": { "state": "translated", - "value": "The sign-in page comes from Plex. Your password never reaches Flixy." - } - } - } - }, - "signIn.windowFailed": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Anmeldefenster ließ sich nicht öffnen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The sign-in window couldn't be opened." - } - } - } - }, - "signIn.cancelled": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anmeldung abgebrochen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign-in cancelled." + "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." } } } diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index 4467b89..aae3d8b 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -43,8 +43,18 @@ final class FlixySession { 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 } @@ -81,9 +91,60 @@ final class FlixySession { 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. @@ -148,7 +209,7 @@ final class FlixySession { func signOut() { authorizationTask?.cancel() authenticator.signOut() - defaults.removeObject(forKey: Key.preferredServer) + forgetRememberedConnection() server = nil libraries = [] phase = .signedOut @@ -207,7 +268,7 @@ final class FlixySession { } self.server = server - defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) + remember(resource: resource, connection: connection) do { libraries = try await server.libraries().filter(\.isSupported) @@ -217,6 +278,26 @@ final class FlixySession { } } + /// 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 { diff --git a/Flixy/Session/OrientationLock.swift b/Flixy/Session/OrientationLock.swift new file mode 100644 index 0000000..ade29ac --- /dev/null +++ b/Flixy/Session/OrientationLock.swift @@ -0,0 +1,70 @@ +import SwiftUI +import UIKit + +/// Bestimmt, in welche Richtungen sich die App drehen darf. +/// +/// In reinem SwiftUI gibt es dafür keinen Weg: `requestGeometryUpdate` dreht +/// zwar, aber das System dreht sofort zurück, wenn der Bildschirm es nicht +/// auch erlaubt. Erlaubt wird es über +/// `application(_:supportedInterfaceOrientationsFor:)` — und das gibt es nur +/// am klassischen App-Delegaten. Diese Klasse ist die Brücke dazwischen. +@MainActor +final class OrientationLock { + static let shared = OrientationLock() + + private(set) var mask: UIInterfaceOrientationMask = .portrait + + private init() { + // Auf dem iPad bleibt alles erlaubt: dort ist das Querformat beim + // Blättern der Normalfall, nicht die Ausnahme. + if UIDevice.current.userInterfaceIdiom == .pad { + mask = .all + } + } + + /// Dreht ins Querformat und hält es dort. + func lockLandscape() { + guard UIDevice.current.userInterfaceIdiom != .pad else { return } + apply(.landscape, rotatingTo: .landscape) + } + + /// Gibt die Ausrichtung wieder frei und dreht zurück ins Hochformat. + func releaseLandscape() { + guard UIDevice.current.userInterfaceIdiom != .pad else { return } + apply(.portrait, rotatingTo: .portrait) + } + + private func apply( + _ newMask: UIInterfaceOrientationMask, + rotatingTo target: UIInterfaceOrientationMask + ) { + mask = newMask + + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + else { return } + + // Erst die neue Erlaubnis bekanntgeben, dann drehen. In der anderen + // Reihenfolge lehnt das System die Drehung ab, weil die alte Maske + // noch gilt — und das sah aus wie „dreht sich nicht richtig". + scene.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + scene.requestGeometryUpdate(.iOS(interfaceOrientations: target)) + } +} + +/// Dreht beim Erscheinen ins Querformat und beim Verschwinden zurück. +struct LandscapeWhileVisible: ViewModifier { + func body(content: Content) -> some View { + content + .onAppear { OrientationLock.shared.lockLandscape() } + .onDisappear { OrientationLock.shared.releaseLandscape() } + } +} + +extension View { + /// Für den Vollbildplayer: Film ist Querformat. + func landscapeWhileVisible() -> some View { + modifier(LandscapeWhileVisible()) + } +} diff --git a/Flixy/Views/Player/PlayerView.swift b/Flixy/Views/Player/PlayerView.swift index a13bc0c..baf9fdd 100644 --- a/Flixy/Views/Player/PlayerView.swift +++ b/Flixy/Views/Player/PlayerView.swift @@ -59,8 +59,12 @@ struct PlayerView: View { } } } - .statusBarHidden(!showsControls) + // Immer aus, nicht nur wenn die Bedienelemente ausgeblendet sind. + // Die Uhrzeit über einem laufenden Film hat dort nichts zu suchen — + // und im Querformat erst recht nicht. + .statusBarHidden(true) .persistentSystemOverlays(showsControls ? .automatic : .hidden) + .landscapeWhileVisible() .contentShape(.rect) .onTapGesture { toggleControls() } .sheet(isPresented: $showsTrackPicker) { @@ -274,6 +278,18 @@ struct PlayerView: View { Text("player.preparing") .font(.flixyCaption) .foregroundStyle(.white.opacity(0.6)) + + // Über die Vermittlung von Plex ist die Leitung auf wenige Mbit + // gedrosselt. Ein großer Film läuft darüber nicht an, und ohne + // diesen Hinweis sucht man den Fehler in der App statt in der + // Verbindung. + if session.server?.connection.relay == true { + Label("player.relayWarning", systemImage: "exclamationmark.triangle") + .font(.flixyCaption) + .foregroundStyle(.white.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.top, FlixySpacing.snug) + } } .padding(FlixySpacing.section) } diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift index 4dd4267..5cf5b25 100644 --- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift @@ -116,9 +116,29 @@ public final class DownloadCoordinator { private var context: ModelContext { container.mainContext } private let fileStore: DownloadFileStore private let defaults: UserDefaults - private var session: URLSession! + /// Die Hintergrundsitzung — erst beim ersten Gebrauch angelegt. + /// + /// `URLSession(configuration: .background(…))` spricht über XPC mit + /// nsurlsessiond und braucht dafür spürbar Zeit. Im Initialisierer der App + /// aufgerufen verzögerte das den allerersten Bildaufbau: die App stand, + /// bevor überhaupt etwas zu sehen war. Wer keine Downloads hat, braucht + /// die Sitzung nie. + private var createdSession: URLSession? + private var sessionConfiguration: URLSessionConfiguration? private let delegate = DownloadSessionDelegate() + private var session: URLSession { + if let createdSession { return createdSession } + let created = URLSession( + configuration: sessionConfiguration + ?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly), + delegate: delegate, + delegateQueue: nil + ) + createdSession = created + return created + } + #if os(iOS) private let activityPresenter = DownloadActivityPresenter() #endif @@ -162,12 +182,7 @@ public final class DownloadCoordinator { keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil ) - self.session = URLSession( - configuration: sessionConfiguration - ?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly), - delegate: delegate, - delegateQueue: nil - ) + self.sessionConfiguration = sessionConfiguration delegate.coordinator = self } @@ -181,6 +196,11 @@ public final class DownloadCoordinator { public func restore() async { reloadItems() + // Ohne Einträge gibt es nichts abzugleichen — und vor allem gibt es + // keinen Grund, die Hintergrundsitzung überhaupt anzulegen. Bei einer + // frischen Installation ist das der Normalfall. + guard !items.isEmpty else { return } + let runningKeys = Set(await session.allTasks.compactMap(\.taskDescription)) for item in items where item.state == .downloading { diff --git a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift index e3c1cf2..f6df4d9 100644 --- a/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlixyKit/Sources/FlixyPlayback/Engine/AVPlayerEngine.swift @@ -41,6 +41,12 @@ public final class AVPlayerEngine: VideoPlayerEngine { private var continuation: AsyncStream.Continuation? private var timeObserver: Any? + private var metadataTask: Task? + /// Wohin gesprungen werden soll, sobald der Player bereit ist. + /// + /// Ein `seek` vor `readyToPlay` verwirft AVPlayer kommentarlos — das + /// Fortsetzen landete dann wieder am Anfang. + private var pendingStartTime: Duration = .zero private var observations: [NSKeyValueObservation] = [] private var endObserver: (any NSObjectProtocol)? @@ -52,32 +58,52 @@ public final class AVPlayerEngine: VideoPlayerEngine { teardownObservers() state = .loading - let asset = AVURLAsset(url: source.url) + pendingStartTime = source.startTime + + let asset = AVURLAsset(url: source.url, options: [ + // Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei + // manchen Formaten die ganze Datei, bevor es überhaupt anfängt — + // und „ganze Datei" heißt bei einem Remux vierzig Gigabyte. + AVURLAssetPreferPreciseDurationAndTimingKey: false, + ]) let item = AVPlayerItem(asset: asset) player.replaceCurrentItem(with: item) observe(item: item) observePlayer() - do { - let assetDuration = try await asset.load(.duration) - if assetDuration.isNumeric { + // Hier ist Schluss. Dauer und Spuren werden NICHT abgewartet. + // + // Genau daran hing der Player vorher fest: `asset.load(.duration)` + // holt so viel von der Datei, bis der Index gefunden ist. Bei MP4 und + // MKV sitzt der oft am Ende — über eine langsame Leitung dauert das + // Minuten, und in dieser Zeit stand nur ein Ladekreis da, obwohl + // AVPlayer längst hätte anfangen können. + // + // Beides kommt jetzt nach, sobald es da ist, und meldet sich über den + // Ereignisstrom. Bis dahin läuft das Bild schon. + loadMetadataInBackground(from: asset) + } + + /// Holt Dauer und Spuren nach, ohne die Wiedergabe aufzuhalten. + private func loadMetadataInBackground(from asset: AVURLAsset) { + metadataTask?.cancel() + metadataTask = Task { [weak self] in + guard let self else { return } + + if let assetDuration = try? await asset.load(.duration), + assetDuration.isNumeric, + !Task.isCancelled { let value = Duration.seconds(assetDuration.seconds) duration = value continuation?.yield(.durationResolved(value)) } - try await loadTracks(from: asset) - } catch { - let failure = PlaybackFailure( - kind: .sourceUnavailable, - underlyingDescription: String(describing: error) - ) - state = .failed(failure) - throw failure - } - if source.startTime > .zero { - await seek(to: source.startTime) + guard !Task.isCancelled else { return } + // Scheitert die Spurenliste, bleibt die Wiedergabe trotzdem + // laufen — es fehlt dann nur die Auswahl im Menü. Das ist ein + // besserer Zustand als ein Abbruch. + try? await loadTracks(from: asset) } } @@ -192,11 +218,20 @@ public final class AVPlayerEngine: VideoPlayerEngine { observations.append( item.observe(\.status, options: [.new]) { [weak self] item, _ in MainActor.assumeIsolated { - guard let self, item.status == .failed else { return } - self.state = .failed(PlaybackFailure( - kind: .sourceUnavailable, - underlyingDescription: item.error.map { String(describing: $0) } - )) + guard let self else { return } + switch item.status { + case .failed: + self.state = .failed(PlaybackFailure( + kind: .sourceUnavailable, + underlyingDescription: item.error.map { String(describing: $0) } + )) + case .readyToPlay: + self.applyPendingStartTime() + case .unknown: + break + @unknown default: + break + } } } ) @@ -214,7 +249,18 @@ public final class AVPlayerEngine: VideoPlayerEngine { } } + /// Springt an die gemerkte Stelle, sobald der Player bereit ist. + private func applyPendingStartTime() { + guard pendingStartTime > .zero else { return } + let target = pendingStartTime + pendingStartTime = .zero + Task { await seek(to: target) } + } + private func teardownObservers() { + metadataTask?.cancel() + metadataTask = nil + observations.forEach { $0.invalidate() } observations.removeAll() diff --git a/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift index 1895909..b795adf 100644 --- a/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift +++ b/Packages/FlixyVLC/Sources/FlixyVLC/VLCPlayerEngine.swift @@ -63,7 +63,17 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine { state = .loading pendingStartTime = source.startTime - let media = VLCMedia(url: source.url) + // `initWithURL:` ist nullable. Ohne diese Prüfung bliebe `media` still + // `nil`, `play()` täte nichts, und auf dem Bildschirm stünde ein + // Ladekreis ohne Ende — ohne dass irgendwo ein Fehler aufschlüge. + guard let media = VLCMedia(url: source.url) else { + let failure = PlaybackFailure( + kind: .sourceUnavailable, + underlyingDescription: "VLCMedia ließ sich für \(source.url) nicht anlegen" + ) + state = .failed(failure) + throw failure + } player.media = media // VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat. diff --git a/project.yml b/project.yml index 19d809c..3ee8958 100644 --- a/project.yml +++ b/project.yml @@ -61,6 +61,10 @@ targets: # Statusleiste und Tastatur richten sich danach nicht. UIUserInterfaceStyle: Dark + # Alle drei bleiben eingetragen: die Info.plist sagt, was die App + # grundsätzlich kann. Was davon gerade gilt, entscheidet zur Laufzeit + # OrientationLock über den App-Delegaten — Hochformat beim Blättern, + # Querformat im Player. UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft