Files
Flaro/Flixy/Views/Detail/SeriesDetailView.swift
T
ScarriffleandClaude Opus 5 2a2afa8573 Der Aufmacher bleibt stehen, und Folgen bekommen ein Bild
Drei Sachen am Karussell.

Die Vorschlaege wechselten bei jedem Start, obwohl es dafuer laengst ein
Zeitfenster von sechs Stunden gab. Der Grund: das Los hing an der Position
in der Liste, nicht am Titel. Kam einer dazu, verschwand einer, oder
aenderte sich ein Fortschritt, bekamen alle dahinter ein anderes Los — und
„Weiterschauen\" waechst und schrumpft staendig. Jetzt haengt das Los an der
Kennung; wer dazukommt, stoert niemanden. Als eigener Typ mit elf Tests,
weil „es wechselt zu oft\" kein Befund ist, den man debuggen kann.

Folgen zeigten kein Bild. Die Kette Folge → Serie gab es zwar, aber sie
hoerte beim ersten Glied auf, das sich **zusammensetzen** liess — und ob es
das Bild dahinter gibt, stellt sich erst beim Abruf heraus. Die Reihenfolge
wandert deshalb ins Laden: Folge, Staffel, Serie, der Reihe nach, bis eine
Adresse antwortet.

Und auf dem Mac gibt es jetzt Pfeile links und rechts, sobald der Zeiger
ueber dem Aufmacher liegt, sowie Punkte, die unter dem Zeiger wachsen und
auf die man springen kann. Nur dort: auf dem iPad und dem iPhone wischt man
ohnehin, und zwei Knoepfe staenden nur im Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 20:46:34 +02:00

203 lines
7.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
artworkURLs: session.backend.map {
HeroArtwork.backdropURLs(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
}
}
}