Der schwarze Streifen über der Navigation war kein gezeichneter Balken, sondern fehlender Inhalt: der gesamte Baum saß innerhalb des Sicherheitsrands. Deshalb erreichte kein noch so gut gesetzter Rand weiter unten die Fläche darüber — Inhalt kam dort nie hin. Jetzt reißt der HStack den Rand oben auf und gibt den Platz kontrolliert zurück: Seitenleiste und Insel bekommen ihren Abstand zur Statusleiste explizit, der Inhalt bekommt ihn als Sicherheitsrand. Der Aufmacher nimmt ihn sich wieder — um genau diese Strecke höher und um genau diese Strecke größer, sodass unten alles bleibt, wo es war. Dazu ein knapper Verlauf oben, damit Uhrzeit und Batterie auch über einem hellen Bild lesbar bleiben. Und die Leiste oben fährt beim Scrollen weg. Nur sie: der Sicherheitsrand darunter bleibt stehen, sonst ruckte die Seite bei jedem Ein- und Ausblenden unter dem Finger weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
6.3 KiB
Swift
188 lines
6.3 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
|
|
@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) }
|
|
|
|
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,
|
|
artworkURL: session.backend?.artworkURL(
|
|
path: item.art ?? item.thumb,
|
|
width: 900,
|
|
height: 1200
|
|
),
|
|
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
|
|
}
|
|
}
|
|
}
|