Files
Flaro/Flixy/Views/Detail/PlayableDetailView.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

291 lines
11 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 von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
struct PlayableDetailView: View {
let item: MediaItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var selectedVersion = 0
@State private var showsTechnicalDetails = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
header
// Bei einer Folge nicht mehr: dorthin führt jetzt die Zeile
// unter dem Titel. Zwei Wege zum selben Ziel, einer davon als
// Knopfreihe am unteren Rand, waren einer zu viel.
if item.kind != .episode {
ParentLinks(item: item)
}
if item.hasMultipleVersions {
VersionPicker(media: item.versions, selection: $selectedVersion)
.padding(.horizontal, FlixySpacing.screenMargin)
}
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)
}
if !item.cast.isEmpty {
CastRow(cast: item.cast)
}
if let media = currentMedia, let part = media.files.first {
TechnicalDetails(
media: media,
part: part,
isExpanded: $showsTechnicalDetails
)
.padding(.horizontal, FlixySpacing.screenMargin)
}
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.never)
.flixyRevealsTopChrome()
}
private var currentMedia: MediaVersion? {
guard item.versions.indices.contains(selectedVersion) else { return item.versions.first }
return item.versions[selectedVersion]
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: item.kind == .episode ? nil : metadataLine,
metadataContent: item.kind == .episode ? AnyView(episodeMetadata) : nil,
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)
) {
Button {
playback.play(item, mediaIndex: selectedVersion)
} label: {
Label(
item.watchProgress == nil ? "hero.play" : "hero.resume",
systemImage: "play.fill"
)
.font(.flixyCardTitle)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(currentMedia?.files.first == nil)
if let progress = item.watchProgress, progress > 0 {
Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: {
Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly)
.font(.flixyCardTitle)
.padding(FlixySpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flixyTextSecondary)
}
DownloadButton(item: item, mediaIndex: selectedVersion)
}
}
/// Die Zeile unter dem Folgentitel — mit Wegen nach oben.
///
/// Serienname und „Staffel 2" führen dorthin, wohin sie zeigen. Vorher
/// stand hier „S2 E1" als Text und darunter eine Reihe Knöpfe; wer zur
/// Serie wollte, musste erst den Knopf finden. Der Name der Serie steht
/// ohnehin da — dann soll er auch der Weg sein.
@ViewBuilder
private var episodeMetadata: some View {
let plain = plainPieces
HStack(spacing: 0) {
if let series = item.seriesTitle, let showID = item.seriesID {
NavigationLink(value: MediaRoute(ratingKey: showID, title: series)) {
Text(series).underline()
}
.buttonStyle(.plain)
separator
} else if let series = item.seriesTitle {
Text(series)
separator
}
if let season = item.parentIndex {
let title = String(localized: "season.number \(season)")
if let seasonID = item.parentID {
NavigationLink(value: MediaRoute(ratingKey: seasonID, title: title)) {
Text(title).underline()
}
.buttonStyle(.plain)
} else {
Text(title)
}
separator
}
if let episode = item.index {
// Über `String(localized:)` wie die Staffel darüber, nicht als
// `LocalizedStringKey`. Bei der Schlüsselform ist der
// Platzhalter nicht eindeutig: Xcode trug beim Auslesen
// `%@` ein, während die Laufzeit `%lld` nachschlägt — und
// zwischen zwei verschiedenen Schlüsseln findet man nie einen.
Text(String(localized: "episode.number \(episode)"))
if !plain.isEmpty { separator }
}
Text(plain.joined(separator: " · "))
}
.lineLimit(1)
}
private var separator: some View {
Text(" · ")
}
/// Was in der Zeile nur dasteht und nirgendwohin führt.
private var plainPieces: [String] {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces
}
private var metadataLine: String? {
var pieces: [String] = []
if let year = item.year { pieces.append(String(year)) }
if let duration = item.duration { pieces.append(Duration.milliseconds(duration).runtimeLabel) }
if let rating = item.contentRating { pieces.append(rating) }
if let resolution = currentMedia?.resolutionLabel { pieces.append(resolution) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
/// Auswahl zwischen mehreren Fassungen desselben Titels.
struct VersionPicker: View {
let media: [MediaVersion]
@Binding var selection: Int
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
Text("detail.versions")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
Picker("detail.versions", selection: $selection) {
ForEach(Array(media.enumerated()), id: \.offset) { index, version in
Text(version.qualityLabel).tag(index)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
}
/// Genres als Streifen.
struct TagStrip: View {
let tags: [String]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background(Color.flixyElevated, in: .capsule)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
.scrollIndicators(.never)
}
}
/// Die Besetzung mit Porträts.
struct CastRow: View {
let cast: [MediaPerson]
@Environment(FlixySession.self) private var session
var body: some View {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text("detail.cast")
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.screenMargin)
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: FlixySpacing.regular) {
ForEach(cast.prefix(20)) { role in
VStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
)
.frame(width: 72, height: 72)
.clipShape(.circle)
Text(role.name)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.center)
if let character = role.role {
Text(character)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
.frame(width: 90)
.accessibilityElement(children: .combine)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
.scrollIndicators(.never)
}
}
}