Auf der Serienseite stand ein Menü für den Ton, gefüllt mit zwanzig Sprachen, von denen die Serie zwei hatte. Für Untertitel gab es keines, und auf der Filmseite überhaupt nichts. Jetzt beides an beiden Orten, und zwar nur mit dem, was es wirklich gibt: Bei einer **Serie** über den neuen Endpunkt — die Schnittmenge über alle Folgen. Was dadurch wegfällt, steht als Satz darunter, sonst sähe eine gekürzte Liste aus wie die ganze Wahrheit. Bei einem **Film** aus den Spuren der Datei. Kein Serverweg nötig: dort ist die Datei der Titel, und ihre Spuren stehen in der Antwort, die die Seite ohnehin geholt hat. Ton und Untertitel teilen sich einen Typ. Sie unterscheiden sich in genau zwei Punkten — woher der Wert kommt und ob es „Keine" gibt; ein zweiter Typ wäre dieselbe Datei mit zwei geänderten Zeilen. „Keine" gibt es nur beim Untertitel: ein Film ohne Tonspur ist kein Wunsch, sondern ein Fehler. Die Untertitelsprache liegt je Titel, in einem Topf für Serien und Filme — eine Filmkennung kollidiert mit keiner Serie, und zwei Wörterbücher für dieselbe Frage liefen auseinander. Der Spieler liest sie jetzt: vorher kannte er nur die allgemeine, und eine Serie im Original mit deutschen Untertiteln liess sich nicht einstellen, ohne sie überall einzuschalten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
554 lines
22 KiB
Swift
554 lines
22 KiB
Swift
import SwiftUI
|
||
import FlaroDesign
|
||
import FlaroDownloads
|
||
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(FlaroSession.self) private var session
|
||
@Environment(DownloadCoordinator.self) private var downloads
|
||
@Environment(LanguagePreferences.self) private var languages
|
||
@Environment(\.flaroAccent) private var accent
|
||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||
@State private var children: LoadState<[MediaItem]> = .loading
|
||
|
||
// MARK: - Mehrere Folgen auf einmal
|
||
|
||
/// Ob gerade ausgewählt wird.
|
||
///
|
||
/// **Ein eigener Zustand statt „leere Auswahl heisst aus".** Sonst ließe
|
||
/// sich der Modus nicht betreten, ohne sofort etwas anzukreuzen — und das
|
||
/// Abwählen der letzten Folge würde ihn wieder verlassen.
|
||
@State private var isSelecting = false
|
||
@State private var selection: Set<String> = []
|
||
@State private var sample: MediaItem?
|
||
@State private var showsQualityChoice = false
|
||
/// Stufe steht, Tonspur fehlt — gefragt wird einmal für den ganzen Stapel.
|
||
@State private var pendingQuality: DownloadQuality?
|
||
@State private var isWorking = false
|
||
@State private var summary: String?
|
||
|
||
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
|
||
///
|
||
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
|
||
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
|
||
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
|
||
@State private var gewaehlteStaffel: Int?
|
||
|
||
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
|
||
/// niemand weiss.
|
||
@State private var sprachen: AvailableLanguages?
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
|
||
header
|
||
|
||
ParentLinks(item: item)
|
||
|
||
if let summary = item.summary, !summary.isEmpty {
|
||
Text(summary)
|
||
.font(.flaroBody)
|
||
.foregroundStyle(Color.flaroTextSecondary)
|
||
.padding(.horizontal, FlaroSpacing.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 {
|
||
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
|
||
AudioLanguagePicker(
|
||
scope: .series(item.id),
|
||
title: "languages.forThisSeries",
|
||
kind: .audio,
|
||
available: sprachen?.audio
|
||
)
|
||
|
||
AudioLanguagePicker(
|
||
scope: .series(item.id),
|
||
title: "languages.subtitlesForThisSeries",
|
||
kind: .subtitles,
|
||
available: sprachen?.subtitles
|
||
)
|
||
|
||
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
|
||
// sieht sonst aus wie die ganze Wahrheit, und wer
|
||
// eine Sprache vermisst, die er in Staffel 1 gesehen
|
||
// hat, hält es für einen Fehler.
|
||
if let partial = sprachen?.partial, partial > 0 {
|
||
Text("languages.partial \(partial)")
|
||
.font(.flaroCaption)
|
||
.foregroundStyle(Color.flaroTextMuted)
|
||
}
|
||
}
|
||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||
.task(id: item.id) {
|
||
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
|
||
}
|
||
}
|
||
|
||
childList
|
||
|
||
if !item.cast.isEmpty { CastRow(cast: item.cast) }
|
||
}
|
||
.padding(.bottom, FlaroSpacing.section)
|
||
}
|
||
.scrollIndicators(.never)
|
||
.flaroRevealsTopChrome()
|
||
.safeAreaInset(edge: .bottom, spacing: 0) { selectionBar }
|
||
.confirmationDialog(
|
||
Text("downloads.quality.title"),
|
||
isPresented: $showsQualityChoice,
|
||
titleVisibility: .visible
|
||
) {
|
||
let choice = DownloadQualityChoice(
|
||
item: sample ?? item,
|
||
backend: session.backend
|
||
)
|
||
ForEach(choice.available) { quality in
|
||
Button(choice.label(for: quality)) { chose(quality) }
|
||
}
|
||
Button("action.cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("downloads.quality.hint")
|
||
}
|
||
.confirmationDialog(
|
||
Text("downloads.audio.title"),
|
||
isPresented: Binding(
|
||
get: { pendingQuality != nil },
|
||
set: { if !$0 { pendingQuality = nil } }
|
||
),
|
||
titleVisibility: .visible
|
||
) {
|
||
// **Einmal für den Stapel.** Die Folgen einer Staffel sind gleich
|
||
// aufgebaut; zwanzigmal dieselbe Frage wäre keine Wahl, sondern
|
||
// eine Prüfung der Geduld.
|
||
if let muster = sample?.preferredVersion?.files.first {
|
||
let empfohlen = sample.map { downloadService.preferredAudioStream(in: muster, of: $0) }
|
||
ForEach(muster.audioStreams) { stream in
|
||
Button(
|
||
stream == empfohlen
|
||
? String(localized: "downloads.audio.recommended \(stream.name)")
|
||
: stream.name
|
||
) {
|
||
if let pendingQuality {
|
||
Task { await loadSelected(quality: pendingQuality, audioStreamIndex: stream.index) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Button("action.cancel", role: .cancel) {}
|
||
} message: {
|
||
Text("downloads.audio.hint")
|
||
}
|
||
.alert(
|
||
Text("downloads.batch.title"),
|
||
isPresented: Binding(get: { summary != nil }, set: { if !$0 { summary = nil } })
|
||
) {
|
||
Button("action.close") { summary = nil }
|
||
} message: {
|
||
if let summary { Text(summary) }
|
||
}
|
||
.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,
|
||
placement: .detailHeader,
|
||
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.
|
||
//
|
||
// Beides kommt deshalb aus derselben Quelle und mit demselben
|
||
// Verwendungszweck: `detailHeader` bleibt hochkant, auch seit das
|
||
// Karussell auf dem Telefon quer ist.
|
||
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
|
||
) {
|
||
DownloadAllButton(container: item)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var childList: some View {
|
||
switch children {
|
||
case .loading:
|
||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, FlaroSpacing.section)
|
||
|
||
case .failed(let message):
|
||
Text(message)
|
||
.font(.flaroCaption)
|
||
.foregroundStyle(Color.flaroTextMuted)
|
||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||
|
||
case .loaded(let entries):
|
||
let nummern = staffelnummern(in: entries)
|
||
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
|
||
|
||
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
|
||
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
|
||
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
|
||
// als von allem anderen auf dieser Seite.
|
||
HStack {
|
||
if nummern.count > 1 {
|
||
staffelwahl(nummern)
|
||
} else {
|
||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||
.font(.flaroRowHeader)
|
||
.foregroundStyle(Color.flaroTextPrimary)
|
||
}
|
||
Spacer()
|
||
if sichtbar.contains(where: { $0.kind == .episode }) {
|
||
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
|
||
// das meinen, was dasteht — sonst lädt ein Griff
|
||
// sechzehn Folgen herunter statt acht.
|
||
selectionControls(for: sichtbar)
|
||
}
|
||
}
|
||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(sichtbar) { entry in
|
||
row(for: entry)
|
||
Divider().overlay(Color.flaroSeparator)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Welche Staffeln in dieser Liste vorkommen.
|
||
///
|
||
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
|
||
/// wählen, die Liste **ist** die Wahl.
|
||
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
|
||
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
|
||
}
|
||
|
||
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
|
||
guard staffeln.count > 1 else { return entries }
|
||
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
|
||
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
|
||
}
|
||
|
||
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
|
||
///
|
||
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
|
||
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
|
||
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
|
||
private func staffelwahl(_ nummern: [Int]) -> some View {
|
||
let gewaehlt = gewaehlteStaffel ?? nummern[0]
|
||
|
||
return Menu {
|
||
ForEach(nummern, id: \.self) { nummer in
|
||
Button {
|
||
gewaehlteStaffel = nummer
|
||
// Eine Auswahl aus der vorigen Staffel meint hier nichts
|
||
// mehr — stehen zu lassen, was man nicht mehr sieht,
|
||
// lädt unsichtbare Folgen herunter.
|
||
selection.removeAll()
|
||
} label: {
|
||
Label(
|
||
String(localized: "season.short \(nummer)"),
|
||
systemImage: nummer == gewaehlt ? "checkmark" : ""
|
||
)
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: FlaroSpacing.tight) {
|
||
Text("season.short \(gewaehlt)")
|
||
.font(.flaroRowHeader)
|
||
.foregroundStyle(Color.flaroTextPrimary)
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.flaroCaption)
|
||
.foregroundStyle(accent)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
|
||
@ViewBuilder
|
||
private func row(for entry: MediaItem) -> some View {
|
||
if isSelecting, entry.kind == .episode {
|
||
let gewaehlt = selection.contains(entry.id)
|
||
// Hier darf der Kreis **im** Label stehen: die ganze Zeile ist der
|
||
// Knopf, es gibt nichts zu verschachteln.
|
||
Button {
|
||
if gewaehlt { selection.remove(entry.id) } else { selection.insert(entry.id) }
|
||
} label: {
|
||
HStack(spacing: FlaroSpacing.snug) {
|
||
ChildRow(item: entry)
|
||
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
|
||
.font(.title3)
|
||
.foregroundStyle(gewaehlt ? accent : Color.flaroTextMuted)
|
||
}
|
||
.contentShape(.rect)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||
.accessibilityAddTraits(gewaehlt ? .isSelected : [])
|
||
} else {
|
||
// **Der Knopf steht neben dem Link, nicht darin.** Ein `Button` im
|
||
// Label eines `NavigationLink` löst auf iOS nie aus — der Link
|
||
// macht sein Label zu einer einzigen Fläche, und `.plain` lässt das
|
||
// Versagen lautlos geschehen. Dieselbe Falle steht in
|
||
// `QuickPlayTile` beschrieben.
|
||
//
|
||
// Als Geschwister in einer Reihe statt als Überzug: dann teilt das
|
||
// Layout den Platz auf, und der Titel kann gar nicht erst unter
|
||
// dem Knopf verschwinden.
|
||
HStack(spacing: FlaroSpacing.snug) {
|
||
NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) {
|
||
ChildRow(item: entry)
|
||
}
|
||
.buttonStyle(.plain)
|
||
|
||
// **Nur bei Folgen.** Für eine ganze Staffel gibt es den Knopf
|
||
// oben im Kopf der Seite; zweimal dasselbe Angebot auf einem
|
||
// Bildschirm ist keine Abkürzung, sondern eine Frage.
|
||
if entry.kind == .episode {
|
||
DownloadButton(item: entry, loadsDetails: true)
|
||
}
|
||
}
|
||
.padding(.horizontal, FlaroSpacing.screenMargin)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func selectionControls(for entries: [MediaItem]) -> some View {
|
||
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
|
||
|
||
HStack(spacing: FlaroSpacing.regular) {
|
||
if isSelecting {
|
||
Button(selection.count == folgen.count ? "detail.selectNone" : "detail.selectAll") {
|
||
selection = selection.count == folgen.count ? [] : Set(folgen)
|
||
}
|
||
Button("action.cancel") {
|
||
isSelecting = false
|
||
selection = []
|
||
}
|
||
.foregroundStyle(Color.flaroTextSecondary)
|
||
} else {
|
||
Button("detail.select") { isSelecting = true }
|
||
}
|
||
}
|
||
.font(.flaroCaption)
|
||
.buttonStyle(.plain)
|
||
.foregroundStyle(accent)
|
||
}
|
||
|
||
/// Die Leiste unten, solange etwas angekreuzt ist.
|
||
///
|
||
/// Als Sicherheitsrand und nicht als Überzug: so endet die Liste darüber,
|
||
/// statt dass die letzte Folge dahinter verschwindet.
|
||
@ViewBuilder
|
||
private var selectionBar: some View {
|
||
if isSelecting, !selection.isEmpty {
|
||
Button {
|
||
Task { await askForQuality() }
|
||
} label: {
|
||
Group {
|
||
if isWorking {
|
||
ProgressView().controlSize(.small)
|
||
} else {
|
||
Label(
|
||
String(localized: "detail.loadSelected \(selection.count)"),
|
||
systemImage: "arrow.down.circle"
|
||
)
|
||
}
|
||
}
|
||
.font(.flaroCardTitle)
|
||
.padding(.horizontal, FlaroSpacing.loose)
|
||
.padding(.vertical, FlaroSpacing.snug)
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
.tint(accent)
|
||
.disabled(isWorking)
|
||
.padding(.vertical, FlaroSpacing.regular)
|
||
.frame(maxWidth: .infinity)
|
||
.background(.bar)
|
||
}
|
||
}
|
||
|
||
private var selectedEpisodes: [MediaItem] {
|
||
guard case .loaded(let entries) = children else { return [] }
|
||
return entries.filter { selection.contains($0.id) }
|
||
}
|
||
|
||
/// Holt die Dateiangaben **einer** Folge und fragt dann nach der Qualität.
|
||
///
|
||
/// Eine reicht: die Folgen einer Staffel sind in derselben Fassung
|
||
/// kodiert, und zwanzig Anfragen für eine Frage, die einmal gestellt wird,
|
||
/// wären zwanzig zu viel. Geladen wird danach jede Folge einzeln — dort
|
||
/// zählt wieder ihre eigene Größe.
|
||
private func askForQuality() async {
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
if let erste = selectedEpisodes.first {
|
||
sample = (try? await session.backend?.item(id: erste.id)) ?? erste
|
||
}
|
||
showsQualityChoice = true
|
||
}
|
||
|
||
private var downloadService: DownloadService {
|
||
DownloadService(backend: session.backend, coordinator: downloads, languages: languages)
|
||
}
|
||
|
||
/// Nach der Stufe: fragen, wenn beim Umpacken eine Tonspur verlorenginge.
|
||
private func chose(_ quality: DownloadQuality) {
|
||
if let muster = sample, downloadService.needsAudioChoice(for: muster, quality: quality) {
|
||
pendingQuality = quality
|
||
} else {
|
||
Task { await loadSelected(quality: quality, audioStreamIndex: nil) }
|
||
}
|
||
}
|
||
|
||
private func loadSelected(quality: DownloadQuality, audioStreamIndex: Int?) async {
|
||
pendingQuality = nil
|
||
isWorking = true
|
||
defer { isWorking = false }
|
||
|
||
let result = await downloadService.download(
|
||
selectedEpisodes, quality: quality, audioStreamIndex: audioStreamIndex
|
||
)
|
||
|
||
var zeilen = [String(localized: "downloads.batch.queued \(result.accepted)")]
|
||
if let erster = result.refused.first {
|
||
// Der erste Grund reicht: bei einem erreichten Speicherlimit ist
|
||
// er für alle übrigen derselbe.
|
||
zeilen.append(erster.1.explanation)
|
||
}
|
||
if result.unavailable > 0 {
|
||
zeilen.append(String(localized: "downloads.batch.unavailable \(result.unavailable)"))
|
||
}
|
||
summary = zeilen.joined(separator: "\n")
|
||
|
||
isSelecting = false
|
||
selection = []
|
||
}
|
||
|
||
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(FlaroSession.self) private var session
|
||
|
||
var body: some View {
|
||
HStack(spacing: FlaroSpacing.regular) {
|
||
FlaroArtwork(
|
||
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.resumeProgress {
|
||
WatchProgressBar(fraction: progress)
|
||
}
|
||
}
|
||
.clipShape(.rect(cornerRadius: 4))
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(rowTitle)
|
||
.font(.flaroCardTitle)
|
||
.foregroundStyle(Color.flaroTextPrimary)
|
||
.lineLimit(2)
|
||
|
||
if let subtitle {
|
||
Text(subtitle)
|
||
.font(.flaroCaption)
|
||
.foregroundStyle(Color.flaroTextMuted)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
if item.isWatched {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.foregroundStyle(Color.flaroTextMuted)
|
||
.accessibilityLabel(Text("item.watched"))
|
||
}
|
||
}
|
||
// Das waagerechte Polster liegt jetzt eine Ebene höher, bei der
|
||
// Reihe aus Zeile und Download-Knopf — sonst klebte der Knopf am
|
||
// Bildschirmrand.
|
||
.padding(.vertical, FlaroSpacing.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
|
||
}
|
||
}
|
||
}
|