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