import SwiftUI import FlixyDesign import PlexKit /// 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: PlexItem @Environment(FlixySession.self) private var session @State private var children: LoadState<[PlexItem]> = .loading var body: some View { ScrollView { VStack(alignment: .leading, spacing: FlixySpacing.section) { header 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) } childList if !item.roles.isEmpty { CastRow(roles: item.roles) } } .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.hidden) .task(id: item.ratingKey) { await loadChildren() } } private var header: some View { HeroBanner( title: item.title, metadata: metadataLine, summary: nil, artworkURL: session.server?.artworkURL( path: item.art ?? item.thumb, width: 900, height: 1200 ) ) { EmptyView() } } @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.ratingKey, title: entry.title)) { ChildRow(item: entry) } .buttonStyle(.plain) Divider().overlay(Color.flixySeparator) } } } } } private var metadataLine: String? { var pieces: [String] = [] if let year = item.year { pieces.append(String(year)) } if let leafCount = item.leafCount { pieces.append(String(localized: "detail.episodeCount \(leafCount)")) } if let viewed = item.viewedLeafCount, let total = item.leafCount, 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.server else { return } children = await LoadState.load { try await server.children(of: item.ratingKey) } } } /// Eine Staffel- oder Folgenzeile. private struct ChildRow: View { let item: PlexItem @Environment(FlixySession.self) private var session var body: some View { HStack(spacing: FlixySpacing.regular) { FlixyArtwork( url: session.server?.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.leafCount.map { String(localized: "detail.episodeCount \($0)") } case .episode: item.duration.map { Duration.milliseconds($0).runtimeLabel } default: nil } } }