Vier geratene Adressen, vier 404. Statt ein fünftes Mal zu raten, klopft die App jetzt in einem Durchgang und gleichzeitig ein Dutzend Adressen ab: was /media/providers an Endpunkten angibt, welche Unterverzeichnisse Programmanbieter und Empfänger in ihrer eigenen Antwort führen, die bekannten Formen anderer Serverfassungen, die Senderliste des Tuners selbst — und zuletzt das Programmraster. Dabei zwei Fehlerquellen mitbehoben, die jede einzeln wie „keine Sender" ausgesehen hätten: der Sendername steht je nach Tunertyp in title, name, channelTitle oder callSign, und eine Antwort mit dreißig namenlosen Einträgen sieht aus wie eine Senderliste, ist aber keine. Bleibt es leer, steht jetzt jede abgeklopfte Adresse mit ihrer Antwort in der Ansicht. Das beantwortet „liegt es am Server oder an der App" ohne eine weitere Runde Rückfragen. Dazu drei Sachen bei Serien: - Groß steht, worum es geht — bei einer Folge ihr Titel, bei einer Staffel der Name der Serie. „Staffel 10" als Überschrift sagte nicht, welche Serie gemeint ist. - Die Einordnung steht darunter: Serienname bei Folgen, Staffelnummer bei Staffeln. - Von der Folge führt ein Weg zur Staffel und zur Serie. Wer über „Weiterschauen" bei einer Folge landete, saß dort bisher fest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.3 KiB
Swift
187 lines
6.3 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import PlexKit
|
|
|
|
/// 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: PlexItem
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@State private var children: LoadState<[PlexItem]> = .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.roles.isEmpty { CastRow(roles: item.roles) }
|
|
}
|
|
.padding(.bottom, FlixySpacing.section)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.task(id: item.ratingKey) { await loadChildren() }
|
|
}
|
|
|
|
private var header: some View {
|
|
HeroBanner(
|
|
title: item.rowTitle,
|
|
metadata: metadataLine,
|
|
summary: nil,
|
|
artworkURL: session.server?.artworkURL(
|
|
path: item.art ?? item.thumb,
|
|
width: 900,
|
|
height: 1200
|
|
),
|
|
logoURL: session.server?.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.ratingKey, 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 leafCount = item.leafCount {
|
|
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
|
|
}
|
|
if let viewed = item.viewedLeafCount, let total = item.leafCount, 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.server else { return }
|
|
children = await LoadState.load {
|
|
try await server.children(of: item.ratingKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Eine Staffel- oder Folgenzeile.
|
|
private struct ChildRow: View {
|
|
let item: PlexItem
|
|
@Environment(FlixySession.self) private var session
|
|
|
|
var body: some View {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
FlixyArtwork(
|
|
url: session.server?.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.leafCount.map { String(localized: "detail.episodeCount \($0)") }
|
|
case .episode:
|
|
item.duration.map { Duration.milliseconds($0).runtimeLabel }
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
}
|