Vier Fehler aus dem ersten echten Gebrauch

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>
This commit is contained in:
Guido
2026-09-11 20:43:26 +02:00
co-authored by Claude Opus 5
parent 1756ed11cc
commit 222ac1c968
11 changed files with 394 additions and 66 deletions
+1
View File
@@ -0,0 +1 @@
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>

After

Width:  |  Height:  |  Size: 675 B

+49
View File
@@ -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"
}
}
+12
View File
@@ -91,6 +91,18 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
/// selbst ist an den Hauptakteur gebunden. /// selbst ist an den Hauptakteur gebunden.
private var pendingCompletionHandler: (() -> Void)? 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( func application(
_ application: UIApplication, _ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String, handleEventsForBackgroundURLSession identifier: String,
+54 -35
View File
@@ -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": { "signIn.tagline": {
"comment": "Untertitel auf dem Anmeldebildschirm", "comment": "Untertitel auf dem Anmeldebildschirm",
"localizations": { "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": { "sort.lastViewed": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
@@ -3112,50 +3162,19 @@
} }
} }
}, },
"signIn.privacy": { "player.relayWarning": {
"comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "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": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "The sign-in page comes from Plex. Your password never reaches Flixy." "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it."
}
}
}
},
"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."
} }
} }
} }
+83 -2
View File
@@ -43,8 +43,18 @@ final class FlixySession {
private enum Key { private enum Key {
static let preferredServer = "flixy.server.clientIdentifier" 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 /// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den
/// bestätigten Code darüber diesem Gerät zu. /// bestätigten Code darüber diesem Gerät zu.
var clientIdentifier: String { identity.clientIdentifier } var clientIdentifier: String { identity.clientIdentifier }
@@ -81,9 +91,60 @@ final class FlixySession {
phase = .signedOut phase = .signedOut
return 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) 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 // MARK: - Anmeldung
/// Öffnet die Anmeldeseite von Plex. /// Öffnet die Anmeldeseite von Plex.
@@ -148,7 +209,7 @@ final class FlixySession {
func signOut() { func signOut() {
authorizationTask?.cancel() authorizationTask?.cancel()
authenticator.signOut() authenticator.signOut()
defaults.removeObject(forKey: Key.preferredServer) forgetRememberedConnection()
server = nil server = nil
libraries = [] libraries = []
phase = .signedOut phase = .signedOut
@@ -207,7 +268,7 @@ final class FlixySession {
} }
self.server = server self.server = server
defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) remember(resource: resource, connection: connection)
do { do {
libraries = try await server.libraries().filter(\.isSupported) 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. /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async { func retry() async {
if let token = authenticator.storedToken { if let token = authenticator.storedToken {
+70
View File
@@ -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())
}
}
+17 -1
View File
@@ -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) .persistentSystemOverlays(showsControls ? .automatic : .hidden)
.landscapeWhileVisible()
.contentShape(.rect) .contentShape(.rect)
.onTapGesture { toggleControls() } .onTapGesture { toggleControls() }
.sheet(isPresented: $showsTrackPicker) { .sheet(isPresented: $showsTrackPicker) {
@@ -274,6 +278,18 @@ struct PlayerView: View {
Text("player.preparing") Text("player.preparing")
.font(.flixyCaption) .font(.flixyCaption)
.foregroundStyle(.white.opacity(0.6)) .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) .padding(FlixySpacing.section)
} }
@@ -116,9 +116,29 @@ public final class DownloadCoordinator {
private var context: ModelContext { container.mainContext } private var context: ModelContext { container.mainContext }
private let fileStore: DownloadFileStore private let fileStore: DownloadFileStore
private let defaults: UserDefaults 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 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) #if os(iOS)
private let activityPresenter = DownloadActivityPresenter() private let activityPresenter = DownloadActivityPresenter()
#endif #endif
@@ -162,12 +182,7 @@ public final class DownloadCoordinator {
keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil
) )
self.session = URLSession( self.sessionConfiguration = sessionConfiguration
configuration: sessionConfiguration
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
delegate: delegate,
delegateQueue: nil
)
delegate.coordinator = self delegate.coordinator = self
} }
@@ -181,6 +196,11 @@ public final class DownloadCoordinator {
public func restore() async { public func restore() async {
reloadItems() 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)) let runningKeys = Set(await session.allTasks.compactMap(\.taskDescription))
for item in items where item.state == .downloading { for item in items where item.state == .downloading {
@@ -41,6 +41,12 @@ public final class AVPlayerEngine: VideoPlayerEngine {
private var continuation: AsyncStream<PlaybackEvent>.Continuation? private var continuation: AsyncStream<PlaybackEvent>.Continuation?
private var timeObserver: Any? private var timeObserver: Any?
private var metadataTask: Task<Void, Never>?
/// 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 observations: [NSKeyValueObservation] = []
private var endObserver: (any NSObjectProtocol)? private var endObserver: (any NSObjectProtocol)?
@@ -52,32 +58,52 @@ public final class AVPlayerEngine: VideoPlayerEngine {
teardownObservers() teardownObservers()
state = .loading 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) let item = AVPlayerItem(asset: asset)
player.replaceCurrentItem(with: item) player.replaceCurrentItem(with: item)
observe(item: item) observe(item: item)
observePlayer() observePlayer()
do { // Hier ist Schluss. Dauer und Spuren werden NICHT abgewartet.
let assetDuration = try await asset.load(.duration) //
if assetDuration.isNumeric { // 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) let value = Duration.seconds(assetDuration.seconds)
duration = value duration = value
continuation?.yield(.durationResolved(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 { guard !Task.isCancelled else { return }
await seek(to: source.startTime) // 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( observations.append(
item.observe(\.status, options: [.new]) { [weak self] item, _ in item.observe(\.status, options: [.new]) { [weak self] item, _ in
MainActor.assumeIsolated { MainActor.assumeIsolated {
guard let self, item.status == .failed else { return } guard let self else { return }
switch item.status {
case .failed:
self.state = .failed(PlaybackFailure( self.state = .failed(PlaybackFailure(
kind: .sourceUnavailable, kind: .sourceUnavailable,
underlyingDescription: item.error.map { String(describing: $0) } 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() { private func teardownObservers() {
metadataTask?.cancel()
metadataTask = nil
observations.forEach { $0.invalidate() } observations.forEach { $0.invalidate() }
observations.removeAll() observations.removeAll()
@@ -63,7 +63,17 @@ public final class VLCPlayerEngine: NSObject, VideoPlayerEngine {
state = .loading state = .loading
pendingStartTime = source.startTime 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 player.media = media
// VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat. // VLC kennt Länge und Spuren erst, wenn es die Datei geöffnet hat.
+4
View File
@@ -61,6 +61,10 @@ targets:
# Statusleiste und Tastatur richten sich danach nicht. # Statusleiste und Tastatur richten sich danach nicht.
UIUserInterfaceStyle: Dark 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: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft