diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index eb37d97..48aac42 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1087,6 +1087,60 @@ } } }, + "downloads.audio.hint": { + "comment": "Erklärt, warum die Wahl jetzt fällt", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Beim Umpacken kommt genau eine mit — danach lässt sie sich nicht mehr wechseln." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repackaging keeps exactly one — it cannot be changed afterwards." + } + } + } + }, + "downloads.audio.recommended %@": { + "comment": "Markiert den Vorschlag", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · empfohlen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · recommended" + } + } + } + }, + "downloads.audio.title": { + "comment": "Überschrift der Tonspurwahl beim Herunterladen", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Welche Tonspur?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Which audio track?" + } + } + } + }, "downloads.available": { "localizations": { "de": { diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index e363730..f102e14 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -102,7 +102,8 @@ struct DownloadService { func plan( for item: MediaItem, mediaIndex: Int = 0, - quality: DownloadQuality = .original + quality: DownloadQuality = .original, + audioStream: MediaStream? = nil ) throws(DownloadRefusal) -> DownloadPlan { guard let backend else { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer")) @@ -126,15 +127,13 @@ struct DownloadService { let url: URL? switch quality { case .original where umpacken: - let ton = languages.flatMap { - // Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet - // eine Anfrage je Folge, und die Ebene darüber — Serie und - // allgemein — trägt den Wunsch ohnehin. - AudioTrackChooser.choose( - from: part.streams, - wish: $0.wish(seriesID: item.seriesID, libraryID: nil) - ) - } ?? part.selectedAudioStream + // **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus + // `AudioTrackChooser` — und die hat bei einer Aufnahme mit + // Audiodeskription die falsche Spur genommen, ohne zu fragen. + // Nach dem Download gibt es nichts mehr zu korrigieren; drei + // Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde, + // und die Abwägung ist nur noch der Vorschlag. + let ton = audioStream ?? preferredAudioStream(in: part, of: item) let untertitel = part.subtitleStreams.first { $0.isSelected } ?? part.subtitleStreams.first @@ -193,9 +192,30 @@ struct DownloadService { func download( _ item: MediaItem, mediaIndex: Int = 0, - quality: DownloadQuality = .original + quality: DownloadQuality = .original, + audioStream: MediaStream? = nil ) throws(DownloadRefusal) -> DownloadItem { - try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality)) + try coordinator.enqueue( + try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream) + ) + } + + /// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt + /// werden muss. + /// + /// **Die Frage gehört vor den Download, nicht danach.** Wird umgepackt, + /// legt der Server genau eine Tonspur in die neue Hülle; was nicht + /// mitkommt, ist offline nicht mehr zu holen. Automatisch zu wählen hiess + /// hier: dem Nutzer die Entscheidung abnehmen und ihn mit dem Ergebnis + /// leben lassen — bei einer Aufnahme mit Audiodeskription genau der + /// falsche Weg. + func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool { + guard quality == .original else { return false } + guard let media = mediaIndex < item.versions.count + ? item.versions[mediaIndex] : item.preferredVersion, + let part = media.files.first + else { return false } + return !systemPlayerOpens(media: media, part: part) && part.audioStreams.count > 1 } /// Was ein Stapel ergeben hat. @@ -226,7 +246,8 @@ struct DownloadService { /// stillschweigend. func download( _ episodes: [MediaItem], - quality: DownloadQuality = .original + quality: DownloadQuality = .original, + audioStreamIndex: Int? = nil ) async -> BatchResult { guard let backend else { return (0, [], 0) } @@ -245,7 +266,16 @@ struct DownloadService { let detailed = (try? await backend.item(id: episode.id)) ?? episode do { - _ = try coordinator.enqueue(try plan(for: detailed, quality: quality)) + // Die Nummer, nicht die Spur: innerhalb einer Staffel sind + // die Spuren gleich angeordnet, und die Folge selbst kennt + // ihre eigene Liste. + let ton = audioStreamIndex.flatMap { nummer in + detailed.preferredVersion?.files.first?.audioStreams + .first { $0.index == nummer } + } + _ = try coordinator.enqueue( + try plan(for: detailed, quality: quality, audioStream: ton) + ) accepted += 1 } catch DownloadRefusal.noDownloadableFile { // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie @@ -279,6 +309,25 @@ struct DownloadService { } /// Der Abzug der Serverangaben, den der Player offline braucht. + /// Welche Tonspur vorgeschlagen wird. + /// + /// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf + /// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und + /// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen + /// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über + /// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und + /// die allgemeine Sprache in dieser Reihenfolge. + /// + /// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine + /// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin. + func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? { + guard let languages else { return part.selectedAudioStream } + return AudioTrackChooser.choose( + from: part.streams, + wish: languages.wish(seriesID: item.seriesID, libraryID: nil) + ) ?? part.selectedAudioStream + } + /// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet. private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool { DeviceCapabilities.avFoundation.opens( diff --git a/Flixy/Views/Components/QuickPlayTile.swift b/Flixy/Views/Components/QuickPlayTile.swift index 50c40cf..8c9a8d7 100644 --- a/Flixy/Views/Components/QuickPlayTile.swift +++ b/Flixy/Views/Components/QuickPlayTile.swift @@ -43,6 +43,8 @@ struct QuickPlayTile: View { /// nachgeladen werden. Ein eigenes `Bool` daneben wäre ein zweiter /// Zustand, der mit diesem gleichziehen müsste. @State private var downloadChoice: MediaItem? + /// Stufe steht, Tonspur fehlt — siehe `DownloadButton`. + @State private var pendingQuality: (item: MediaItem, quality: DownloadQuality)? /// Was die umgebende Seite tun soll, wenn sich am Server etwas geändert /// hat. Siehe `ItemActionEntries`. @@ -147,13 +149,40 @@ struct QuickPlayTile: View { if let ziel = downloadChoice { let wahl = DownloadQualityChoice(item: ziel, backend: session.backend) ForEach(wahl.available) { quality in - Button(wahl.label(for: quality)) { enqueue(ziel, quality: quality) } + Button(wahl.label(for: quality)) { chose(ziel, quality: quality) } } } Button("action.cancel", role: .cancel) {} } message: { Text("downloads.quality.hint") } + .confirmationDialog( + Text("downloads.audio.title"), + isPresented: Binding( + get: { pendingQuality != nil }, + set: { if !$0 { pendingQuality = nil } } + ), + titleVisibility: .visible + ) { + if let offen = pendingQuality { + let spuren = offen.item.preferredVersion?.files.first?.audioStreams ?? [] + let empfohlen = offen.item.preferredVersion?.files.first.flatMap { + service.preferredAudioStream(in: $0, of: offen.item) + } + ForEach(spuren) { stream in + Button( + stream == empfohlen + ? String(localized: "downloads.audio.recommended \(stream.name)") + : stream.name + ) { + enqueue(offen.item, quality: offen.quality, audioStream: stream) + } + } + } + Button("action.cancel", role: .cancel) {} + } message: { + Text("downloads.audio.hint") + } .alert( Text("error.title"), isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) @@ -232,12 +261,24 @@ struct QuickPlayTile: View { downloadChoice = (try? await session.backend?.item(id: item.id)) ?? item } - private func enqueue(_ ziel: MediaItem, quality: DownloadQuality) { - let service = DownloadService( - backend: session.backend, coordinator: downloads, languages: languages - ) + private var service: DownloadService { + DownloadService(backend: session.backend, coordinator: downloads, languages: languages) + } + + /// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge. + private func chose(_ ziel: MediaItem, quality: DownloadQuality) { + downloadChoice = nil + if service.needsAudioChoice(for: ziel, quality: quality) { + pendingQuality = (ziel, quality) + } else { + enqueue(ziel, quality: quality, audioStream: nil) + } + } + + private func enqueue(_ ziel: MediaItem, quality: DownloadQuality, audioStream: MediaStream?) { + pendingQuality = nil do { - _ = try service.download(ziel, quality: quality) + _ = try service.download(ziel, quality: quality, audioStream: audioStream) } catch { errorMessage = error.explanation } diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 53c62ae..ddbfc36 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -28,6 +28,8 @@ struct SeriesDetailView: View { @State private var selection: Set = [] @State private var sample: MediaItem? @State private var showsQualityChoice = false + /// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel. + @State private var pendingQuality: DownloadQuality? @State private var isWorking = false @State private var summary: String? @@ -78,14 +80,41 @@ struct SeriesDetailView: View { backend: session.backend ) ForEach(choice.available) { quality in - Button(choice.label(for: quality)) { - Task { await loadSelected(quality: quality) } - } + Button(choice.label(for: quality)) { chose(quality) } } Button("action.cancel", role: .cancel) {} } message: { Text("downloads.quality.hint") } + .confirmationDialog( + Text("downloads.audio.title"), + isPresented: Binding( + get: { pendingQuality != nil }, + set: { if !$0 { pendingQuality = nil } } + ), + titleVisibility: .visible + ) { + // **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich + // aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern + // eine Prüfung der Geduld. + if let muster = sample?.preferredVersion?.files.first { + let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) } + ForEach(muster.audioStreams) { stream in + Button( + stream == empfohlen + ? String(localized: "downloads.audio.recommended \(stream.name)") + : stream.name + ) { + if let pendingQuality { + Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) } + } + } + } + } + Button("action.cancel", role: .cancel) {} + } message: { + Text("downloads.audio.hint") + } .alert( Text("downloads.batch.title"), isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } }) @@ -292,14 +321,27 @@ struct SeriesDetailView: View { showsQualityChoice = true } - private func loadSelected(quality: DownloadQuality) async { + private var downloadService: DownloadService { + DownloadService(backend: session.backend, coordinator: downloads, languages: languages) + } + + /// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge. + private func chose(_ quality: DownloadQuality) { + if let muster = sample, downloadService.needsAudioChoice(for: muster, quality: quality) { + pendingQuality = quality + } else { + Task { await loadSelected(quality: quality, audioStreamIndex: nil) } + } + } + + private func loadSelected(quality: DownloadQuality, audioStreamIndex: Int?) async { + pendingQuality = nil isWorking = true defer { isWorking = false } - let service = DownloadService( - backend: session.backend, coordinator: downloads, languages: languages + let result = await downloadService.download( + selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex ) - let result = await service.download(selectedEpisodes, quality: quality) var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")] if let erster = result.refused.first { diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 6cb7147..7c35457 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -36,6 +36,14 @@ struct DownloadButton: View { /// Der nachgeholte Titel — gilt ab dann statt `item`. @State private var detailed: MediaItem? @State private var isLoadingDetails = false + /// Die Stufe steht fest, die Tonspur fehlt noch. + /// + /// **Weil die Wahl nach dem Herunterladen nicht mehr zu korrigieren ist.** + /// Beim Umpacken legt der Server genau eine Tonspur in die Datei; welche, + /// hat vorher die Abwägung entschieden — und bei einer Aufnahme mit + /// Audiodeskription die falsche. Drei Gigabyte später gab es keinen Weg + /// zurück. + @State private var pendingQuality: DownloadQuality? private var existing: DownloadItem? { downloads.items.first { $0.ratingKey == item.id } @@ -76,12 +84,29 @@ struct DownloadButton: View { titleVisibility: .visible ) { ForEach(choice.available) { quality in - Button(choice.label(for: quality)) { enqueue(quality: quality) } + Button(choice.label(for: quality)) { chose(quality) } } Button("action.cancel", role: .cancel) {} } message: { Text("downloads.quality.hint") } + .confirmationDialog( + Text("downloads.audio.title"), + isPresented: Binding( + get: { pendingQuality != nil }, + set: { if !$0 { pendingQuality = nil } } + ), + titleVisibility: .visible + ) { + ForEach(audioStreams) { stream in + Button(audioLabel(stream)) { + if let pendingQuality { enqueue(quality: pendingQuality, audioStream: stream) } + } + } + Button("action.cancel", role: .cancel) {} + } message: { + Text("downloads.audio.hint") + } .alert( Text("downloads.refused.title"), isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } }) @@ -158,12 +183,45 @@ struct DownloadButton: View { showsQualityChoice = true } - private func enqueue(quality: DownloadQuality) { - let service = DownloadService( - backend: session.backend, coordinator: downloads, languages: languages - ) + private var service: DownloadService { + DownloadService(backend: session.backend, coordinator: downloads, languages: languages) + } + + private var audioStreams: [MediaStream] { + let media = source.versions.indices.contains(mediaIndex) + ? source.versions[mediaIndex] : source.preferredVersion + return media?.files.first?.audioStreams ?? [] + } + + /// Die Spur, die vorgeschlagen würde — gemerkte Sprache der Serie zuerst. + private var recommended: MediaStream? { + let media = source.versions.indices.contains(mediaIndex) + ? source.versions[mediaIndex] : source.preferredVersion + guard let part = media?.files.first else { return nil } + return service.preferredAudioStream(in: part, of: source) + } + + private func audioLabel(_ stream: MediaStream) -> String { + stream == recommended + ? String(localized: "downloads.audio.recommended \(stream.name)") + : stream.name + } + + /// Nach der Stufe: entweder fragen oder einreihen. + private func chose(_ quality: DownloadQuality) { + if service.needsAudioChoice(for: source, mediaIndex: mediaIndex, quality: quality) { + pendingQuality = quality + } else { + enqueue(quality: quality, audioStream: nil) + } + } + + private func enqueue(quality: DownloadQuality, audioStream: MediaStream?) { + pendingQuality = nil do { - _ = try service.download(source, mediaIndex: mediaIndex, quality: quality) + _ = try service.download( + source, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream + ) } catch { refusal = error }