diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index f355e58..c4fd04e 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -631,6 +631,24 @@ } } }, + "detail.loadSelected %lld": { + "comment": "Die angekreuzten Folgen laden", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Folgen laden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download %lld episodes" + } + } + } + }, "detail.playFromStart": { "extractionState": "stale", "localizations": { @@ -665,6 +683,60 @@ } } }, + "detail.select": { + "comment": "Mehrere Folgen auswählen", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select" + } + } + } + }, + "detail.selectAll": { + "comment": "Alle Folgen ankreuzen", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "All" + } + } + } + }, + "detail.selectNone": { + "comment": "Alle Haken entfernen", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + } + } + }, "detail.toSeason": { "extractionState": "stale", "localizations": { @@ -1557,6 +1629,7 @@ } }, "downloads.resume": { + "extractionState": "stale", "localizations": { "de": { "stringUnit": { diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index c3560b8..c38232d 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -158,22 +158,37 @@ struct DownloadService { try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality)) } - /// Lädt alle Folgen einer Staffel oder Serie. - /// - /// Holt die Metadaten jeder Folge einzeln nach: die Kinderliste enthält - /// keine Dateiangaben, und ohne Dateigröße ließe sich weder das - /// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen. - func downloadAll( - in container: MediaItem - ) async -> (accepted: Int, refused: [(MediaItem, DownloadRefusal)], unavailable: Int) { - guard let backend else { return (0, [], 0) } + /// Was ein Stapel ergeben hat. + typealias BatchResult = ( + accepted: Int, + refused: [(MediaItem, DownloadRefusal)], + unavailable: Int + ) - let episodes: [MediaItem] - do { - episodes = try await allEpisodes(in: container, backend: backend) - } catch { + /// Lädt alle Folgen einer Staffel oder Serie. + func downloadAll( + in container: MediaItem, + quality: DownloadQuality = .original + ) async -> BatchResult { + guard let backend else { return (0, [], 0) } + guard let episodes = try? await allEpisodes(in: container, backend: backend) else { return (0, [], 0) } + return await download(episodes, quality: quality) + } + + /// Lädt genau die übergebenen Folgen. + /// + /// **Holt die Metadaten jeder Folge einzeln nach.** Die Kinderliste einer + /// Staffel enthält keine Dateiangaben; ohne Dateigröße ließe sich weder + /// das Speicherlimit prüfen noch ein Fortschrittsbalken zeigen — und eine + /// erwartete Größe von null überspringt die Prüfung auf freien Platz + /// stillschweigend. + func download( + _ episodes: [MediaItem], + quality: DownloadQuality = .original + ) async -> BatchResult { + guard let backend else { return (0, [], 0) } var accepted = 0 var refused: [(MediaItem, DownloadRefusal)] = [] @@ -190,7 +205,7 @@ struct DownloadService { let detailed = (try? await backend.item(id: episode.id)) ?? episode do { - _ = try coordinator.enqueue(try plan(for: detailed)) + _ = try coordinator.enqueue(try plan(for: detailed, quality: quality)) accepted += 1 } catch DownloadRefusal.noDownloadableFile { // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index b54606c..2bbdd90 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -1,5 +1,6 @@ import SwiftUI import FlixyDesign +import FlixyDownloads import MediaKit /// Die Detailseite einer Serie oder Staffel. @@ -10,9 +11,25 @@ struct SeriesDetailView: View { let item: MediaItem @Environment(FlixySession.self) private var session + @Environment(DownloadCoordinator.self) private var downloads + @Environment(\.flixyAccent) private var accent @Environment(\.horizontalSizeClass) private var sizeClass @State private var children: LoadState<[MediaItem]> = .loading + // MARK: - Mehrere Folgen auf einmal + + /// Ob gerade ausgewählt wird. + /// + /// **Ein eigener Zustand statt „leere Auswahl heisst aus".** Sonst ließe + /// sich der Modus nicht betreten, ohne sofort etwas anzukreuzen — und das + /// Abwählen der letzten Folge würde ihn wieder verlassen. + @State private var isSelecting = false + @State private var selection: Set = [] + @State private var sample: MediaItem? + @State private var showsQualityChoice = false + @State private var isWorking = false + @State private var summary: String? + var body: some View { ScrollView { VStack(alignment: .leading, spacing: FlixySpacing.section) { @@ -49,6 +66,33 @@ struct SeriesDetailView: View { } .scrollIndicators(.never) .flixyRevealsTopChrome() + .safeAreaInset(edge: .bottom, spacing: 0) { selectionBar } + .confirmationDialog( + Text("downloads.quality.title"), + isPresented: $showsQualityChoice, + titleVisibility: .visible + ) { + let choice = DownloadQualityChoice( + item: sample ?? item, + backend: session.backend + ) + ForEach(choice.available) { quality in + Button(choice.label(for: quality)) { + Task { await loadSelected(quality: quality) } + } + } + Button("action.cancel", role: .cancel) {} + } message: { + Text("downloads.quality.hint") + } + .alert( + Text("downloads.batch.title"), + isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } }) + ) { + Button("action.close") { summary = nil } + } message: { + if let summary { Text(summary) } + } .task(id: item.id) { await loadChildren() } } @@ -99,41 +143,23 @@ struct SeriesDetailView: View { case .loaded(let entries): VStack(alignment: .leading, spacing: FlixySpacing.regular) { - Text(item.kind == .show ? "detail.seasons" : "detail.episodes") - .font(.flixyRowHeader) - .foregroundStyle(Color.flixyTextPrimary) - .padding(.horizontal, FlixySpacing.screenMargin) + // **Der Auswählen-Knopf steht bei dem, was er auswählt.** + // Oben in der Werkzeugleiste wäre er weiter weg von der Liste + // als von allem anderen auf dieser Seite. + HStack { + Text(item.kind == .show ? "detail.seasons" : "detail.episodes") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + Spacer() + if entries.contains(where: { $0.kind == .episode }) { + selectionControls(for: entries) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) LazyVStack(spacing: 0) { ForEach(entries) { entry in - // **Der Knopf steht neben dem Link, nicht darin.** Ein - // `Button` im Label eines `NavigationLink` löst auf iOS - // nie aus — der Link macht sein Label zu einer einzigen - // Fläche, und `.plain` lässt das Versagen lautlos - // geschehen. Dieselbe Falle steht in `QuickPlayTile` - // beschrieben. - // - // Als Geschwister in einer Reihe statt als Überzug: - // dann teilt das Layout den Platz auf, und der Titel - // kann gar nicht erst unter dem Knopf verschwinden. - HStack(spacing: FlixySpacing.snug) { - NavigationLink( - value: MediaRoute(ratingKey: entry.id, title: entry.title) - ) { - ChildRow(item: entry) - } - .buttonStyle(.plain) - - // **Nur bei Folgen.** Für eine ganze Staffel gibt - // es den Knopf oben im Kopf der Seite; zweimal - // dasselbe Angebot auf einem Bildschirm ist keine - // Abkürzung, sondern eine Frage. - if entry.kind == .episode { - DownloadButton(item: entry, loadsDetails: true) - } - } - .padding(.horizontal, FlixySpacing.screenMargin) - + row(for: entry) Divider().overlay(Color.flixySeparator) } } @@ -141,6 +167,152 @@ struct SeriesDetailView: View { } } + /// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen. + @ViewBuilder + private func row(for entry: MediaItem) -> some View { + if isSelecting, entry.kind == .episode { + let gewaehlt = selection.contains(entry.id) + // Hier darf der Kreis **im** Label stehen: die ganze Zeile ist der + // Knopf, es gibt nichts zu verschachteln. + Button { + if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) } + } label: { + HStack(spacing: FlixySpacing.snug) { + ChildRow(item: entry) + Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(gewaehlt ? accent : Color.flixyTextMuted) + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .padding(.horizontal, FlixySpacing.screenMargin) + .accessibilityAddTraits(gewaehlt ? .isSelected : []) + } else { + // **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im + // Label eines `NavigationLink` löst auf iOS nie aus — der Link + // macht sein Label zu einer einzigen Fläche, und `.plain` lässt das + // Versagen lautlos geschehen. Dieselbe Falle steht in + // `QuickPlayTile` beschrieben. + // + // Als Geschwister in einer Reihe statt als Überzug: dann teilt das + // Layout den Platz auf, und der Titel kann gar nicht erst unter + // dem Knopf verschwinden. + HStack(spacing: FlixySpacing.snug) { + NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) { + ChildRow(item: entry) + } + .buttonStyle(.plain) + + // **Nur bei Folgen.** Für eine ganze Staffel gibt es den Knopf + // oben im Kopf der Seite; zweimal dasselbe Angebot auf einem + // Bildschirm ist keine Abkürzung, sondern eine Frage. + if entry.kind == .episode { + DownloadButton(item: entry, loadsDetails: true) + } + } + .padding(.horizontal, FlixySpacing.screenMargin) + } + } + + @ViewBuilder + private func selectionControls(for entries: [MediaItem]) -> some View { + let folgen = entries.filter { $0.kind == .episode }.map(\.id) + + HStack(spacing: FlixySpacing.regular) { + if isSelecting { + Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") { + selection = selection.count == folgen.count ? [] : Set(folgen) + } + Button("action.cancel") { + isSelecting = false + selection = [] + } + .foregroundStyle(Color.flixyTextSecondary) + } else { + Button("detail.select") { isSelecting = true } + } + } + .font(.flixyCaption) + .buttonStyle(.plain) + .foregroundStyle(accent) + } + + /// Die Leiste unten, solange etwas angekreuzt ist. + /// + /// Als Sicherheitsrand und nicht als Überzug: so endet die Liste darüber, + /// statt dass die letzte Folge dahinter verschwindet. + @ViewBuilder + private var selectionBar: some View { + if isSelecting, !selection.isEmpty { + Button { + Task { await askForQuality() } + } label: { + Group { + if isWorking { + ProgressView().controlSize(.small) + } else { + Label( + String(localized: "detail.loadSelected \(selection.count)"), + systemImage: "arrow.down.circle" + ) + } + } + .font(.flixyCardTitle) + .padding(.horizontal, FlixySpacing.loose) + .padding(.vertical, FlixySpacing.snug) + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(isWorking) + .padding(.vertical, FlixySpacing.regular) + .frame(maxWidth: .infinity) + .background(.bar) + } + } + + private var selectedEpisodes: [MediaItem] { + guard case .loaded(let entries) = children else { return [] } + return entries.filter { selection.contains($0.id) } + } + + /// Holt die Dateiangaben **einer** Folge und fragt dann nach der Qualität. + /// + /// Eine reicht: die Folgen einer Staffel sind in derselben Fassung + /// kodiert, und zwanzig Anfragen für eine Frage, die einmal gestellt wird, + /// wären zwanzig zu viel. Geladen wird danach jede Folge einzeln — dort + /// zählt wieder ihre eigene Größe. + private func askForQuality() async { + isWorking = true + defer { isWorking = false } + if let erste = selectedEpisodes.first { + sample = (try? await session.backend?.item(id: erste.id)) ?? erste + } + showsQualityChoice = true + } + + private func loadSelected(quality: DownloadQuality) async { + isWorking = true + defer { isWorking = false } + + let service = DownloadService(backend: session.backend, coordinator: downloads) + let result = await service.download(selectedEpisodes, quality: quality) + + var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")] + if let erster = result.refused.first { + // Der erste Grund reicht: bei einem erreichten Speicherlimit ist + // er für alle übrigen derselbe. + zeilen.append(erster.1.explanation) + } + if result.unavailable > 0 { + zeilen.append(String(localized: "downloads.batch.unavailable \(result.unavailable)")) + } + summary = zeilen.joined(separator: "\n") + + isSelecting = false + selection = [] + } + private var metadataLine: String? { var pieces: [String] = [] // Bei einer Staffel steht oben der Serienname — welche Staffel es diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 8a9ae53..654781f 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -74,8 +74,8 @@ struct DownloadButton: View { isPresented: $showsQualityChoice, titleVisibility: .visible ) { - ForEach(availableQualities) { quality in - Button(qualityLabel(quality)) { enqueue(quality: quality) } + ForEach(choice.available) { quality in + Button(choice.label(for: quality)) { enqueue(quality: quality) } } Button("action.cancel", role: .cancel) {} } message: { @@ -166,53 +166,9 @@ struct DownloadButton: View { } } - private var currentMedia: MediaVersion? { - source.versions.indices.contains(mediaIndex) ? source.versions[mediaIndex] : source.preferredVersion - } - - /// Nur Stufen, die unter der Auflösung der Datei liegen. - /// - /// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt. - /// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die - /// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage. - private var availableQualities: [DownloadQuality] { - let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight) - guard canDownloadCompressed else { return [.original] } - return all - } - - private var canDownloadCompressed: Bool { - session.backend?.compressedDownloadURL( - itemID: item.id, - versionIndex: mediaIndex, - maximumBitrate: 8_000, - resolution: nil - ) != nil - } - - /// Die Beschriftung samt Auflösung und geschätzter Größe. - /// - /// Beim Original steht dabei, was es überhaupt ist: „Original · 720p". - /// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade - /// herunterrechnet oder nicht. - private func qualityLabel(_ quality: DownloadQuality) -> String { - let originalBytes = currentMedia?.files.first?.size ?? 0 - let bytes = quality.estimatedBytes( - durationMilliseconds: item.duration, - originalBytes: originalBytes - ) - - var name = String(localized: quality.localizedName) - if quality == .original, let resolution = currentMedia?.resolutionLabel { - name += " · \(resolution)" - } - - guard bytes > 0 else { return name } - - let size = bytes.formatted(.byteCount(style: .file)) - return quality == .original - ? "\(name) · \(size)" - : String(localized: "downloads.quality.approx \(name) \(size)") + /// Was zur Wahl steht — siehe `DownloadQualityChoice`. + private var choice: DownloadQualityChoice { + DownloadQualityChoice(item: source, mediaIndex: mediaIndex, backend: session.backend) } } diff --git a/Flixy/Views/Downloads/DownloadQualityChoice.swift b/Flixy/Views/Downloads/DownloadQualityChoice.swift new file mode 100644 index 0000000..e25d9e0 --- /dev/null +++ b/Flixy/Views/Downloads/DownloadQualityChoice.swift @@ -0,0 +1,67 @@ +import Foundation +import FlixyDownloads +import MediaKit + +/// Welche Qualitätsstufen für einen Titel zur Wahl stehen — und wie sie heißen. +/// +/// **Als eigener Typ, weil es die Frage inzwischen an zwei Stellen gibt:** am +/// Knopf einer einzelnen Folge und an der Leiste, mit der man mehrere auf +/// einmal lädt. Dieselbe Entscheidung zweimal zu schreiben hiesse, dass sie +/// sich auseinanderentwickelt — und die erste Kopie wäre die mit dem Fehler. +/// +/// Kein `View`: hier steht keine Zeile SwiftUI, nur die Frage, was angeboten +/// werden darf und wie es beschriftet ist. +struct DownloadQualityChoice { + let item: MediaItem + var mediaIndex: Int = 0 + let backend: (any MediaBackend)? + + /// Nur Stufen, die unter der Auflösung der Datei liegen. + /// + /// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt. + /// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die + /// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage. + var available: [DownloadQuality] { + let alle = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight) + guard allowsCompressed else { return [.original] } + return alle + } + + /// Die Beschriftung samt Auflösung und geschätzter Größe. + /// + /// Beim Original steht dabei, was es überhaupt ist: „Original · 720p". + /// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade + /// herunterrechnet oder nicht. + func label(for quality: DownloadQuality) -> String { + let originalBytes = currentMedia?.files.first?.size ?? 0 + let bytes = quality.estimatedBytes( + durationMilliseconds: item.duration, + originalBytes: originalBytes + ) + + var name = String(localized: quality.localizedName) + if quality == .original, let resolution = currentMedia?.resolutionLabel { + name += " · \(resolution)" + } + + guard bytes > 0 else { return name } + + let size = bytes.formatted(.byteCount(style: .file)) + return quality == .original + ? "\(name) · \(size)" + : String(localized: "downloads.quality.approx \(name) \(size)") + } + + private var currentMedia: MediaVersion? { + item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion + } + + private var allowsCompressed: Bool { + backend?.compressedDownloadURL( + itemID: item.id, + versionIndex: mediaIndex, + maximumBitrate: 8_000, + resolution: nil + ) != nil + } +}