Files
Flaro/Flixy/Views/Detail/PlayableDetailView.swift
T
GuidoandClaude Opus 5 9a69df4635 Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player
Die Datenschicht:
- plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund
- Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor
  Vermittlung), mit Abgleich der Server-Kennung gegen veraltete
  plex.direct-Adressen
- PlexServer als einzige Stelle, an der die App Endpunkte kennt:
  Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten,
  Staffeln/Folgen, Suche

Die Oberfläche:
- Startseite mit Aufmacher und den Reihen vom Server
- Mediathek als Raster oder Liste, mit Filter- und Sortierblatt
- Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung,
  Fassungsauswahl und aufklappbaren technischen Angaben
- Suche mit gemerkten Anfragen
- Vollbildplayer mit eigenem Chrome

Der Player:
- AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll
- VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test`
  in FlixyKit nicht das 920-MB-Binärpaket mitzieht

Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf,
dass `strong=true` einen dreißigstelligen Code liefert, der sich bei
plex.tv/link nicht eintippen lässt — jetzt der kurze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 16:46:28 +02:00

206 lines
7.4 KiB
Swift

import SwiftUI
import FlixyDesign
import PlexKit
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
struct PlayableDetailView: View {
let item: PlexItem
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(\.flixyAccent) private var accent
@State private var selectedVersion = 0
@State private var showsTechnicalDetails = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.section) {
header
if item.hasMultipleVersions {
VersionPicker(media: item.media, 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.roles.isEmpty {
CastRow(roles: item.roles)
}
if let media = currentMedia, let part = media.parts.first {
TechnicalDetails(
media: media,
part: part,
isExpanded: $showsTechnicalDetails
)
.padding(.horizontal, FlixySpacing.screenMargin)
}
}
.padding(.bottom, FlixySpacing.section)
}
.scrollIndicators(.hidden)
}
private var currentMedia: PlexMedia? {
guard item.media.indices.contains(selectedVersion) else { return item.media.first }
return item.media[selectedVersion]
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
metadata: metadataLine,
summary: nil,
artworkURL: session.server?.artworkURL(
path: item.art ?? item.grandparentArt ?? item.thumb,
width: 900,
height: 1200
)
) {
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?.parts.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)
}
}
}
private var metadataLine: String? {
var pieces: [String] = []
if item.kind == .episode, let season = item.parentIndex, let episode = item.index {
pieces.append(String(localized: "episode.short \(season) \(episode)"))
}
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?.videoResolution { pieces.append(resolution.uppercased()) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
}
/// Auswahl zwischen mehreren Fassungen desselben Titels.
struct VersionPicker: View {
let media: [PlexMedia]
@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: [PlexTag]
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: FlixySpacing.snug) {
ForEach(tags) { tag in
Text(tag.tag)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background(Color.flixyElevated, in: .capsule)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
.scrollIndicators(.hidden)
}
}
/// Die Besetzung mit Porträts.
struct CastRow: View {
let roles: [PlexTag]
@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(roles.prefix(20)) { role in
VStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.server?.artworkURL(path: role.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "person"
)
.frame(width: 72, height: 72)
.clipShape(.circle)
Text(role.tag)
.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(.hidden)
}
}
}