diff --git a/Flaro/Views/Player/PlayerView.swift b/Flaro/Views/Player/PlayerView.swift index 0d68fc6..5cb4e36 100644 --- a/Flaro/Views/Player/PlayerView.swift +++ b/Flaro/Views/Player/PlayerView.swift @@ -106,8 +106,18 @@ struct PlayerView: View { // Jetzt rechnet das Layout. Der schwarze Rand ist schlicht // der Hintergrund dahinter, und wer ihn anklickt, klickt // nicht aufs Bild — das muss niemand mehr feststellen. + // **Im Vollbild über alles, sonst nicht unter die + // Insel.** + // + // Bei 21∶9 auf einem Telefon reicht das Bild über die + // ganze Breite — und damit hinter die Dynamic Island, die + // links einen Streifen davon verdeckt. Wer Vollbild + // wählt, hat sich fürs Beschneiden entschieden; dort ist + // Rand an Rand folgerichtig. Im normalen Modus ist der + // Anspruch gerade, dass nichts fehlt — dann darf auch + // nichts verdeckt sein. Plex macht es genauso. Color.clear - .ignoresSafeArea() + .modifier(Randlos(an: model.videoFit == .fill)) .overlay { surface(for: model) .contentShape(.rect) @@ -1049,3 +1059,16 @@ extension Duration { return String(format: "%d:%02d", minutes, seconds) } } + +/// Lässt eine Fläche über die Sicherheitsränder hinausreichen — oder nicht. +/// +/// **Ein eigener Modifikator, weil `ignoresSafeArea` keinen Schalter hat.** +/// Ein `if` im Baum wäre zwei verschiedene Ansichten, und SwiftUI baut die +/// beim Umschalten neu auf — die Wiedergabe begänne von vorn. +private struct Randlos: ViewModifier { + let an: Bool + + func body(content: Content) -> some View { + if an { content.ignoresSafeArea() } else { content } + } +} diff --git a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift index f0f3a96..3fce4ec 100644 --- a/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift +++ b/Packages/FlaroKit/Sources/FlaroDownloads/Engine/DownloadCoordinator.swift @@ -458,6 +458,13 @@ public final class DownloadCoordinator { } } + /// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde. + /// + /// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart + /// beginnt das Zählen von vorn — und das ist richtig: der Server hat + /// inzwischen vermutlich fertig gepackt. + private var wartend: [String: Int] = [:] + private func start(_ item: DownloadItem) { guard let url = URL(string: item.sourceURLString) else { fail(item, message: "Ungültige Adresse") @@ -526,6 +533,7 @@ public final class DownloadCoordinator { } func handleCompletion(ratingKey: String, temporaryFile: URL) { + wartend[ratingKey] = nil guard let item = items.first(where: { $0.ratingKey == ratingKey }), let sourceURL = URL(string: item.sourceURLString) else { return } @@ -608,6 +616,45 @@ public final class DownloadCoordinator { } /// Der Server hat die Anfrage abgelehnt. + /// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird. + /// + /// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf + /// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die + /// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von + /// fünfzehn Sekunden sind fünf Minuten. + private static let hoechstensWarten = 20 + private static let wartezeit = Duration.seconds(15) + + /// Der Server packt noch um — später noch einmal fragen. + /// + /// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt + /// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder + /// wegzuwerfen. + func handleNotReady(ratingKey: String) { + guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return } + + let versuche = (wartend[ratingKey] ?? 0) + 1 + wartend[ratingKey] = versuche + + guard versuche <= Self.hoechstensWarten else { + wartend[ratingKey] = nil + fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module)) + Task { await startNextIfPossible() } + return + } + + // **Zurück in die Warteschlange, nicht in den Fehler.** Es ist + // nichts schiefgegangen; es ist nur noch nicht fertig. + item.state = .waiting + save() + + Task { [weak self] in + try? await Task.sleep(for: Self.wartezeit) + guard let self else { return } + await self.resume(item) + } + } + func handleServerRejection(ratingKey: String, status: Int) { guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return } fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module)) @@ -740,6 +787,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch ) { guard let key = downloadTask.taskDescription else { return } + // **202 heisst: noch nicht, komm wieder.** + // + // Der Server packt eine Datei um, die so nicht ausgeliefert werden + // kann, und antwortet währenddessen mit 202 und einem kurzen Text. + // Das ist ein Erfolgscode, also ging er durch die Prüfung darunter + // glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem + // Gerät, „in einer Sekunde heruntergeladen". + if let response = downloadTask.response as? HTTPURLResponse, + response.statusCode == 202 { + Task { @MainActor [weak coordinator] in + coordinator?.handleNotReady(ratingKey: key) + } + return + } + // `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich // und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine // zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer diff --git a/Packages/FlaroKit/Sources/FlaroDownloads/Localizable.xcstrings b/Packages/FlaroKit/Sources/FlaroDownloads/Localizable.xcstrings index 3ce8f90..845fdca 100644 --- a/Packages/FlaroKit/Sources/FlaroDownloads/Localizable.xcstrings +++ b/Packages/FlaroKit/Sources/FlaroDownloads/Localizable.xcstrings @@ -1,6 +1,40 @@ { "sourceLanguage": "en", "strings": { + "downloads.failed.notReady": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server is still repackaging this file. Try again in a few minutes." + } + } + } + }, + "downloads.failed.status %lld": { + "comment": "Der Server hat die Download-Anfrage abgelehnt", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat den Download mit Fehler %lld abgelehnt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server refused the download with error %lld." + } + } + } + }, "downloads.failed.tooSmall %@": { "comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein", "localizations": { @@ -34,23 +68,6 @@ } } } - }, - "downloads.failed.status %lld": { - "comment": "Der Server hat die Download-Anfrage abgelehnt", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat den Download mit Fehler %lld abgelehnt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server refused the download with error %lld." - } - } - } } }, "version": "1.0"