Engine - Hintergrund-URLSession: läuft weiter, wenn die App zu ist. Der ganze Zustand liegt in SwiftData, weil es nach einem Neustart kein anderes Gedächtnis gibt. - Pausieren über cancel(byProducingResumeData:) — nur so lässt sich weiterladen statt neu zu beginnen. Bei 40 GB der Unterschied zwischen brauchbar und unbrauchbar. - Zwei Übertragungen gleichzeitig, Folgen in Staffel-/Folgenreihenfolge. Alles parallel zu laden hieße: keine Folge wird fertig. - Speicherlimit und Geräteplatz werden vor dem Annehmen geprüft, ein Gigabyte bleibt als Puffer frei. - Verwaiste Dateien werden beim Start entfernt; sie entstehen, wenn die App genau beim Abschluss beendet wird. - Downloads liegen in Application Support, nicht in Caches (iOS räumt den eigenmächtig leer) und sind von der iCloud-Sicherung ausgenommen. Oberfläche - Download-Knopf auf Detailseiten mit vier Zuständen in einem Knopf. - „Alle Folgen laden" für Staffel und Serie; ein abgelehnter Titel stoppt den Stapel nicht. - Downloads-Ansicht mit Belegung, WLAN-Schalter, Pausieren, Fortsetzen, Löschen — nach Serie gruppiert. Offline - Der Player bevorzugt die lokale Datei, auch ohne erreichbaren Server. - Beim Herunterladen wird ein Abzug der Serverangaben mitgesichert: offline lässt sich nichts nachfragen, und der PlaybackDecider braucht Container und Codecs. Ein Fehler, den die Tests aufgedeckt haben: der Koordinator hielt nur den ModelContext, nicht den ModelContainer. Ein ModelContext hält seinen Container nicht am Leben — wird der anderswo freigegeben, stürzt jeder Zugriff auf einen Eintrag ab, und die Meldung zeigt auf ModelContext.reset statt auf die Ursache. Jetzt liegt die Zuständigkeit beim Koordinator. 102 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
7.4 KiB
Swift
208 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)
|
|
}
|
|
|
|
DownloadButton(item: item, mediaIndex: selectedVersion)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|