import SwiftUI import FlixyDesign import FlixyDownloads import MediaKit /// Die Detailseite einer Serie oder Staffel. /// /// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste /// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen. struct SeriesDetailView: View { let item: MediaItem @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @Environment(LanguagePreferences.self) private var languages @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 /// 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? var body: some View { ScrollView { VStack(alignment: .leading, spacing: FlixySpacing.section) { header ParentLinks(item: item) if let summary = item.summary, !summary.isEmpty { Text(summary) .font(.flixyBody) .foregroundStyle(Color.flixyTextSecondary) .padding(.horizontal, FlixySpacing.screenMargin) } if !item.genres.isEmpty { TagStrip(tags: item.genres) } // **Nur bei der Serie, nicht bei der Staffel.** Die Ebene // heisst Serie; sie zweimal anzubieten hiesse, zwei Orte für // dieselbe Einstellung zu haben, von denen einer nie gelesen // wird. if item.kind == .show { AudioLanguagePicker( scope: .series(item.id), title: "languages.forThisSeries" ) .padding(.horizontal, FlixySpacing.screenMargin) } childList if !item.cast.isEmpty { CastRow(cast: item.cast) } } .padding(.bottom, FlixySpacing.section) } .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)) { 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 } }) ) { Button("action.close") { summary = nil } } message: { if let summary { Text(summary) } } .task(id: item.id) { await loadChildren() } } private var header: some View { HeroBanner( title: item.rowTitle, metadata: metadataLine, summary: nil, // Über `HeroArtwork`, nicht von Hand: hier standen 900×1200 — // hochkant — für ein Bild, das im 16:9-Rahmen landet. Zu sehen war // davon ein Streifen aus der Mitte. Derselbe Fehler war im // Aufmacher schon einmal behoben worden, nur hier nicht. artworkURLs: session.backend.map { HeroArtwork.backdropURLs( for: item, backend: $0, placement: .detailHeader, compact: sizeClass == .compact ) } ?? [], logoURL: session.backend?.rawImageURL(path: item.clearLogoPath), // **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier // stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork` // auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant. // Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte // angeschnitten und der Kopf der Seite viel zu flach. // // Beides kommt deshalb aus derselben Quelle und mit demselben // Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das // Karussell auf dem Telefon quer ist. aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact) ) { DownloadAllButton(container: item) } } @ViewBuilder private var childList: some View { switch children { case .loading: ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlixySpacing.section) case .failed(let message): Text(message) .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .padding(.horizontal, FlixySpacing.screenMargin) case .loaded(let entries): VStack(alignment: .leading, spacing: FlixySpacing.regular) { // **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 row(for: entry) Divider().overlay(Color.flixySeparator) } } } } } /// 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 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 result = await downloadService.download( selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex ) 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 // ist, muss deshalb hierher. if item.kind == .season, let number = item.index { pieces.append(String(localized: "season.short \(number)")) } if let year = item.year { pieces.append(String(year)) } if let episodeCount = item.episodeCount { pieces.append(String(localized: "detail.episodeCount \(episodeCount)")) } if let viewed = item.playedEpisodeCount, let total = item.episodeCount, viewed < total { pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)")) } if let rating = item.contentRating { pieces.append(rating) } return pieces.isEmpty ? nil : pieces.joined(separator: " · ") } private func loadChildren() async { guard let server = session.backend else { return } children = await LoadState.load { try await server.children(of: item.id) } } } /// Eine Staffel- oder Folgenzeile. private struct ChildRow: View { let item: MediaItem @Environment(FlixySession.self) private var session var body: some View { HStack(spacing: FlixySpacing.regular) { FlixyArtwork( url: session.backend?.artworkURL( path: item.thumb, width: item.kind == .season ? 120 : 240, height: item.kind == .season ? 180 : 135 ), aspectRatio: item.kind == .season ? PosterCard.aspectRatio : LandscapeCard.aspectRatio, fallbackSymbol: "play.rectangle" ) .frame(width: item.kind == .season ? 48 : 96) .clipShape(.rect(cornerRadius: 4)) .overlay(alignment: .bottom) { if let progress = item.watchProgress { WatchProgressBar(fraction: progress) } } .clipShape(.rect(cornerRadius: 4)) VStack(alignment: .leading, spacing: 2) { Text(rowTitle) .font(.flixyCardTitle) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) if let subtitle { Text(subtitle) .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .lineLimit(1) } } Spacer() if item.isWatched { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Color.flixyTextMuted) .accessibilityLabel(Text("item.watched")) } } // Das waagerechte Polster liegt jetzt eine Ebene höher, bei der // Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am // Bildschirmrand. .padding(.vertical, FlixySpacing.snug) .contentShape(.rect) .accessibilityElement(children: .combine) } private var rowTitle: String { if item.kind == .episode, let episode = item.index { return "\(episode). \(item.title)" } return item.title } private var subtitle: String? { switch item.kind { case .season: item.episodeCount.map { String(localized: "detail.episodeCount \($0)") } case .episode: item.duration.map { Duration.milliseconds($0).runtimeLabel } default: nil } } }