Files
Flaro/Flixy/Views/Detail/SeriesDetailView.swift
T
ScarriffleandClaude Opus 5 a665d79e6d Antippen spielt ab — und vier Dinge, die daneben lagen
**Antippen startet, langes Drücken führt auf die Detailseite.** Am Telefon
und am iPad; am Mac bleibt der Klick die Seite, denn dort gibt es den
Abspielknopf beim Überfahren und langes Drücken ist keine übliche Geste.

Die Hürde war nicht die Geste, sondern das Schieben: ein `NavigationLink`
schiebt selbst, ein Menüeintrag kann es nicht. Der Stapel ist deshalb ein
eigener Typ mit Pfad (`MediaStack`) und reicht eine Schiebefunktion über
die Umgebung nach innen — denselben Weg nimmt schon die Meldung, dass sich
am Server etwas geändert hat.

**Zwei Knöpfe übereinander auf der Detailseite.** Das Monogramm hing als
Überzug an `mediaStack` — also über **jedem** Bildschirm dieses Stapels,
auch den geschobenen, und dort über deren Drei-Punkte-Menü. Es gilt jetzt
für die Startseite und endet mit ihr.

**Der Kopf der Detailseite war viel zu flach.** Dort stand kein
Seitenverhältnis, also galt die Vorgabe 16∶9 — während `HeroArtwork` auf
einem schmalen Gerät ein **Plakat** anfordert, also hochkant. Zu sehen war
ein Streifen aus der Mitte. Der Aufmacher auf der Startseite macht es seit
Langem richtig; die beiden Detailseiten nicht.

**Die Mediathekenleiste stand linksbündig in einer halbleeren Kapsel.**
Jetzt ist sie so breit wie ihr Inhalt und mittig. `ViewThatFits` nimmt die
ungescrollte Reihe, solange sie passt, sonst die scrollbare — damit steht
nirgends eine Zahl, ab wann es kippt.

**Und der Grund, warum die Mediathekenauswahl beim Nutzer nie griff:**
Plex. Dessen Hubs sagten nicht, aus welcher Mediathek sie stammen, also
warf der Filter jede Reihe weg und fiel auf „alles" zurück — ein Schalter,
der nichts tut. `PlexHub` liest jetzt `librarySectionID`, als Zahl oder
als Zeichenkette, je nachdem was der Server liefert. Vier Tests dazu.

Das ist **kein** Wiederaufnehmen der Plex-Arbeit: es repariert eine
Funktion, die dort gelogen hat. Neues kommt weiterhin nicht dazu.

iOS und tvOS bauen, 476 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 10:39:45 +02:00

211 lines
7.8 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),
// **Dasselbe Verhältnis, in dem das Bild bestellt wurde.** Hier
// stand keines, also galt die Vorgabe 16∶9 — während `HeroArtwork`
// auf einem schmalen Gerät ein **Plakat** anfordert, also hochkant.
// Zu sehen war davon ein Streifen aus der Mitte: das Bild wirkte
// angeschnitten und der Kopf der Seite viel zu flach. Der Aufmacher
// auf der Startseite macht es seit Langem richtig; die Detailseiten
// nicht.
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact)
) {
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
}
}
}