Files
Flaro/Flixy/Views/Detail/SeriesDetailView.swift
T
ScarriffleandClaude Opus 5 93695dc332 Beim Umpacken wird nach der Tonspur gefragt, nicht geraten
Mein Fehler, und ein vermeidbarer. Beim Umpacken legt der Server genau eine
Tonspur in die Datei, und welche, hat bisher allein die Abwägung entschieden
— bei einer Aufnahme mit Audiodeskription die falsche. Nach dem Herunterladen
gibt es nichts mehr zu korrigieren; drei Gigabyte Datenvolumen waren weg.

Wo eine Spur verlorengeht, wird jetzt gefragt. Nur dort: bei einer Datei, die
der Systemplayer ohnehin öffnet, kommt alles mit, und eine Frage ohne Folgen
ist nur ein Klick mehr.

**Der Vorschlag kommt aus dem, was schon bekannt ist.** Die gemerkte Sprache
der Serie zuerst, dann die Serieneinstellung, dann die allgemeine — dieselbe
Abwägung wie beim Abspielen (`AudioTrackChooser`), über denselben Weg. Sie
ist jetzt Vorschlag statt Entscheidung und steht als „· empfohlen" dabei.

Beim Stapel aus der Mehrfachauswahl wird einmal gefragt, nicht je Folge: die
Folgen einer Staffel sind gleich aufgebaut, und zwanzigmal dieselbe Frage
wäre keine Wahl, sondern eine Prüfung der Geduld.

**Was am Server gemessen wurde, und warum es nicht besser geht:**

- `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska;
  die Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht.
- `stream.mp4` bildet genau eine Tonspur ab.
- Die HLS-Liste des Servers führt **eine** Variante und **keine**
  `EXT-X-MEDIA`-Tonspuren.

Mehrere Tonspuren offline gibt dieser Server auf keinem seiner Wege her. Das
liegt nicht an MP4 — das kann mehrere — und nicht an AVFoundation, sondern am
Transcoder, der je Sitzung eine Spur abbildet.

iOS und tvOS bauen.

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

460 lines
18 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 FlixyDownloads
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(DownloadCoordinator.self) private var downloads
@Environment(LanguagePreferences.self) private var languages
@Environment(\.flixyAccent) 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?
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()
.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, 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) {
// **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 {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
Spacer()
if entries.contains(where: { $0.kind == .episode }) {
selectionControls(for: entries)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
row(for: entry)
Divider().overlay(Color.flixySeparator)
}
}
}
}
}
/// 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: FlixySpacing.snug) {
ChildRow(item: entry)
Image(systemName: gewaehlt ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(gewaehlt ? accent : Color.flixyTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.padding(.horizontal, FlixySpacing.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: FlixySpacing.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, FlixySpacing.screenMargin)
}
}
@ViewBuilder
private func selectionControls(for entries: [MediaItem]) -> some View {
let folgen = entries.filter { $0.kind == .episode }.map(\.id)
HStack(spacing: FlixySpacing.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.flixyTextSecondary)
} else {
Button("detail.select") { isSelecting = true }
}
}
.font(.flixyCaption)
.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(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(isWorking)
.padding(.vertical, FlixySpacing.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(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"))
}
}
// 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, 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
}
}
}