import SwiftUI import FlixyDesign 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(\.horizontalSizeClass) private var sizeClass @State private var children: LoadState<[MediaItem]> = .loading 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() .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. artworkURL: session.backend.flatMap { HeroArtwork.backdropURL(for: item, backend: $0, compact: sizeClass == .compact) }, logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { 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) { Text(item.kind == .show ? "detail.seasons" : "detail.episodes") .font(.flixyRowHeader) .foregroundStyle(Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.screenMargin) LazyVStack(spacing: 0) { ForEach(entries) { entry in NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) { ChildRow(item: entry) } .buttonStyle(.plain) Divider().overlay(Color.flixySeparator) } } } } } 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")) } } .padding(.horizontal, FlixySpacing.screenMargin) .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 } } }