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