import SwiftUI import FlixyDesign import MediaKit /// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder /// einzelne Folge. struct PlayableDetailView: View { let item: MediaItem @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @Environment(\.flixyAccent) private var accent @Environment(\.horizontalSizeClass) private var sizeClass @State private var selectedVersion = 0 @State private var showsTechnicalDetails = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: FlixySpacing.section) { header // Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile // unter dem Titel. Zwei Wege zum selben Ziel, einer davon als // Knopfreihe am unteren Rand, waren einer zu viel. if item.kind != .episode { ParentLinks(item: item) } if item.hasMultipleVersions { VersionPicker(media: item.versions, selection: $selectedVersion) .padding(.horizontal, FlixySpacing.screenMargin) } 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) } if !item.cast.isEmpty { CastRow(cast: item.cast) } if let media = currentMedia, let part = media.files.first { TechnicalDetails( media: media, part: part, isExpanded: $showsTechnicalDetails ) .padding(.horizontal, FlixySpacing.screenMargin) } } .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.never) .flixyRevealsTopChrome() } private var currentMedia: MediaVersion? { guard item.versions.indices.contains(selectedVersion) else { return item.versions.first } return item.versions[selectedVersion] } private var header: some View { HeroBanner( title: item.rowTitle, metadata: item.kind == .episode ? nil : metadataLine, metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil, 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) ) { Button { playback.play(item, mediaIndex: selectedVersion) } label: { Label( item.watchProgress == nil ? "hero.play" : "hero.resume", systemImage: "play.fill" ) .font(.flixyCardTitle) .padding(.horizontal, FlixySpacing.loose) .padding(.vertical, FlixySpacing.snug) } .buttonStyle(.borderedProminent) .tint(accent) .disabled(currentMedia?.files.first == nil) if let progress = item.watchProgress, progress > 0 { Button { playback.play(item, mediaIndex: selectedVersion, resuming: false) } label: { Label("detail.playFromStart", systemImage: "gobackward") .labelStyle(.iconOnly) .font(.flixyCardTitle) .padding(FlixySpacing.snug) } .buttonStyle(.bordered) .tint(Color.flixyTextSecondary) } DownloadButton(item: item, mediaIndex: selectedVersion) } } /// Die Zeile unter dem Folgentitel — mit Wegen nach oben. /// /// Serienname und „Staffel 2" führen dorthin, wohin sie zeigen. Vorher /// stand hier „S2 E1" als Text und darunter eine Reihe Knöpfe; wer zur /// Serie wollte, musste erst den Knopf finden. Der Name der Serie steht /// ohnehin da — dann soll er auch der Weg sein. @ViewBuilder private var episodeMetadata: some View { let plain = plainPieces HStack(spacing: 0) { if let series = item.seriesTitle, let showID = item.seriesID { NavigationLink(value: MediaRoute(ratingKey: showID, title: series)) { Text(series).underline() } .buttonStyle(.plain) separator } else if let series = item.seriesTitle { Text(series) separator } if let season = item.parentIndex { let title = String(localized: "season.number \(season)") if let seasonID = item.parentID { NavigationLink(value: MediaRoute(ratingKey: seasonID, title: title)) { Text(title).underline() } .buttonStyle(.plain) } else { Text(title) } separator } if let episode = item.index { // Über `String(localized:)` wie die Staffel darüber, nicht als // `LocalizedStringKey`. Bei der Schlüsselform ist der // Platzhalter nicht eindeutig: Xcode trug beim Auslesen // `%@` ein, während die Laufzeit `%lld` nachschlägt — und // zwischen zwei verschiedenen Schlüsseln findet man nie einen. Text(String(localized: "episode.number \(episode)")) if !plain.isEmpty { separator } } Text(plain.joined(separator: " · ")) } .lineLimit(1) } private var separator: some View { Text(" · ") } /// Was in der Zeile nur dasteht und nirgendwohin führt. private var plainPieces: [String] { var pieces: [String] = [] if let year = item.year { pieces.append(String(year)) } if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } if let rating = item.contentRating { pieces.append(rating) } if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) } return pieces } private var metadataLine: String? { var pieces: [String] = [] if let year = item.year { pieces.append(String(year)) } if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) } if let rating = item.contentRating { pieces.append(rating) } if let resolution = currentMedia?.videoResolution { pieces.append(resolution.uppercased()) } return pieces.isEmpty ? nil : pieces.joined(separator: " · ") } } /// Auswahl zwischen mehreren Fassungen desselben Titels. struct VersionPicker: View { let media: [MediaVersion] @Binding var selection: Int var body: some View { VStack(alignment: .leading, spacing: FlixySpacing.snug) { Text("detail.versions") .font(.flixyRowHeader) .foregroundStyle(Color.flixyTextPrimary) Picker("detail.versions", selection: $selection) { ForEach(Array(media.enumerated()), id: \.offset) { index, version in Text(version.qualityLabel).tag(index) } } .pickerStyle(.segmented) .labelsHidden() } } } /// Genres als Streifen. struct TagStrip: View { let tags: [String] var body: some View { ScrollView(.horizontal) { HStack(spacing: FlixySpacing.snug) { ForEach(tags, id: \.self) { tag in Text(tag) .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) .padding(.horizontal, FlixySpacing.regular) .padding(.vertical, FlixySpacing.tight) .background(Color.flixyElevated, in: .capsule) } } .padding(.horizontal, FlixySpacing.screenMargin) } .scrollIndicators(.never) } } /// Die Besetzung mit Porträts. struct CastRow: View { let cast: [MediaPerson] @Environment(FlixySession.self) private var session var body: some View { VStack(alignment: .leading, spacing: FlixySpacing.regular) { Text("detail.cast") .font(.flixyRowHeader) .foregroundStyle(Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.screenMargin) ScrollView(.horizontal) { HStack(alignment: .top, spacing: FlixySpacing.regular) { ForEach(cast.prefix(20)) { role in VStack(spacing: FlixySpacing.snug) { FlixyArtwork( url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160), aspectRatio: 1, fallbackSymbol: "person" ) .frame(width: 72, height: 72) .clipShape(.circle) Text(role.name) .font(.flixyCaption) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) .multilineTextAlignment(.center) if let character = role.role { Text(character) .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .lineLimit(1) } } .frame(width: 90) .accessibilityElement(children: .combine) } } .padding(.horizontal, FlixySpacing.screenMargin) } .scrollIndicators(.never) } } }