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>
203 lines
7.2 KiB
Swift
203 lines
7.2 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|