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>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Die Detailseite eines Titels.
|
||||
///
|
||||
/// Holt die Metadaten frisch, statt den Eintrag aus der Liste weiterzureichen:
|
||||
/// Einträge in Reihen und Rastern kommen ohne Dateiliste, ohne Besetzung und
|
||||
/// ohne Marker. Für das Abspielen braucht es aber genau diese Angaben.
|
||||
struct MediaDetailView: View {
|
||||
let route: MediaRoute
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@State private var state: LoadState<PlexItem> = .loading
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
case .loaded(let item):
|
||||
switch item.kind {
|
||||
case .show, .season:
|
||||
SeriesDetailView(item: item)
|
||||
default:
|
||||
PlayableDetailView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(state.value?.title ?? route.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task(id: route.ratingKey) { await load() }
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let server = session.server else { return }
|
||||
state = await LoadState.load {
|
||||
try await server.metadata(ratingKey: route.ratingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// 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: PlexItem
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@State private var children: LoadState<[PlexItem]> = .loading
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
header
|
||||
|
||||
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) }
|
||||
|
||||
childList
|
||||
|
||||
if !item.roles.isEmpty { CastRow(roles: item.roles) }
|
||||
}
|
||||
.padding(.bottom, FlixySpacing.section)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.task(id: item.ratingKey) { await loadChildren() }
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HeroBanner(
|
||||
title: item.title,
|
||||
metadata: metadataLine,
|
||||
summary: nil,
|
||||
artworkURL: session.server?.artworkURL(
|
||||
path: item.art ?? item.thumb,
|
||||
width: 900,
|
||||
height: 1200
|
||||
)
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
|
||||
.font(.flixyRowHeader)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(entries) { entry in
|
||||
NavigationLink(value: MediaRoute(ratingKey: entry.ratingKey, title: entry.title)) {
|
||||
ChildRow(item: entry)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider().overlay(Color.flixySeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var metadataLine: String? {
|
||||
var pieces: [String] = []
|
||||
if let year = item.year { pieces.append(String(year)) }
|
||||
if let leafCount = item.leafCount {
|
||||
pieces.append(String(localized: "detail.episodeCount \(leafCount)"))
|
||||
}
|
||||
if let viewed = item.viewedLeafCount, let total = item.leafCount, 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.server else { return }
|
||||
children = await LoadState.load {
|
||||
try await server.children(of: item.ratingKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Staffel- oder Folgenzeile.
|
||||
private struct ChildRow: View {
|
||||
let item: PlexItem
|
||||
@Environment(FlixySession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.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"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
.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.leafCount.map { String(localized: "detail.episodeCount \($0)") }
|
||||
case .episode:
|
||||
item.duration.map { Duration.milliseconds($0).runtimeLabel }
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Die aufklappbaren technischen Angaben zu einer Datei.
|
||||
///
|
||||
/// Zeigt nicht nur, was in der Datei steckt, sondern auch, wie Flixy sie
|
||||
/// abspielen wird und warum. Bei der Frage „warum ruckelt das" ist das die
|
||||
/// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding
|
||||
/// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird.
|
||||
struct TechnicalDetails: View {
|
||||
let media: PlexMedia
|
||||
let part: PlexPart
|
||||
@Binding var isExpanded: Bool
|
||||
|
||||
private var decision: PlaybackDecision {
|
||||
PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup(isExpanded: $isExpanded) {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
||||
row("technical.playbackMethod", playbackMethodLabel)
|
||||
if let reason = rationaleLabel {
|
||||
row("technical.reason", reason)
|
||||
}
|
||||
|
||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
||||
|
||||
if let container = part.container { row("technical.container", container.uppercased()) }
|
||||
if let codec = media.videoCodec { row("technical.videoCodec", codec.uppercased()) }
|
||||
if let width = media.width, let height = media.height {
|
||||
row("technical.resolution", "\(width) × \(height)")
|
||||
}
|
||||
if let bitrate = media.bitrate {
|
||||
row("technical.bitrate", "\(bitrate / 1000) Mbit/s")
|
||||
}
|
||||
if let size = part.size {
|
||||
row("technical.size", size.formatted(.byteCount(style: .file)))
|
||||
}
|
||||
|
||||
if !part.audioStreams.isEmpty {
|
||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
||||
ForEach(part.audioStreams) { stream in
|
||||
row("technical.audio", trackLabel(stream))
|
||||
}
|
||||
}
|
||||
|
||||
if !part.subtitleStreams.isEmpty {
|
||||
ForEach(part.subtitleStreams) { stream in
|
||||
row("technical.subtitle", trackLabel(stream))
|
||||
}
|
||||
}
|
||||
|
||||
if let file = part.file {
|
||||
Divider().overlay(Color.flixySeparator).padding(.vertical, FlixySpacing.tight)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("technical.file")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
Text(file)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, FlixySpacing.snug)
|
||||
} label: {
|
||||
Text("technical.title")
|
||||
.font(.flixyRowHeader)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
}
|
||||
.tint(Color.flixyTextSecondary)
|
||||
}
|
||||
|
||||
private func row(_ label: LocalizedStringKey, _ value: String) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Text(label)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func trackLabel(_ stream: PlexStream) -> String {
|
||||
stream.displayTitle ?? [stream.language, stream.codec?.uppercased()]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var playbackMethodLabel: String {
|
||||
switch decision.method {
|
||||
case .directPlay(let engine):
|
||||
switch engine {
|
||||
case .avPlayer: String(localized: "technical.directPlayNative")
|
||||
case .vlc: String(localized: "technical.directPlayVLC")
|
||||
}
|
||||
case .transcode:
|
||||
String(localized: "technical.transcode")
|
||||
}
|
||||
}
|
||||
|
||||
/// Der erste Grund, der gegen den Systemplayer sprach — mehrere
|
||||
/// aufzuzählen hilft niemandem, es reicht einer.
|
||||
private var rationaleLabel: String? {
|
||||
for reason in decision.rationale {
|
||||
switch reason {
|
||||
case .containerUnsupported(let container):
|
||||
return String(localized: "technical.reason.container \(container.uppercased())")
|
||||
case .videoCodecUnsupported(let codec):
|
||||
return String(localized: "technical.reason.videoCodec \(codec.uppercased())")
|
||||
case .audioCodecUnsupported(let codec):
|
||||
return String(localized: "technical.reason.audioCodec \(codec.uppercased())")
|
||||
case .streamsUnknown:
|
||||
return String(localized: "technical.reason.unanalyzed")
|
||||
case .nativelySupported, .bitrateExceedsLimit, .transcodingUnavailable:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Der Aufmacher oben auf der Startseite.
|
||||
struct HeroSection: View {
|
||||
let item: PlexItem
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
HeroBanner(
|
||||
title: item.rowTitle,
|
||||
metadata: metadata,
|
||||
summary: item.summary,
|
||||
artworkURL: session.server?.artworkURL(
|
||||
path: item.art ?? item.grandparentArt ?? item.thumb,
|
||||
width: 900,
|
||||
height: 1200
|
||||
)
|
||||
) {
|
||||
Button {
|
||||
playback.play(item)
|
||||
} label: {
|
||||
Label(playLabel, systemImage: "play.fill")
|
||||
.font(.flixyCardTitle)
|
||||
.padding(.horizontal, FlixySpacing.loose)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
|
||||
Label("hero.details", systemImage: "info.circle")
|
||||
.font(.flixyCardTitle)
|
||||
.padding(.horizontal, FlixySpacing.loose)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
|
||||
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
|
||||
private var playLabel: LocalizedStringKey {
|
||||
item.watchProgress == nil ? "hero.play" : "hero.resume"
|
||||
}
|
||||
|
||||
private var metadata: 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) }
|
||||
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Laufzeit als „2 Std 46 Min" bzw. „46 Min".
|
||||
var runtimeLabel: String {
|
||||
let totalMinutes = Int(components.seconds / 60)
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
if hours > 0 {
|
||||
return String(localized: "runtime.hoursMinutes \(hours) \(minutes)")
|
||||
}
|
||||
return String(localized: "runtime.minutes \(minutes)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
|
||||
struct HomeView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@State private var state: LoadState<[PlexHub]> = .loading
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
case .loaded(let hubs):
|
||||
if hubs.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("home.empty.title", systemImage: "film.stack")
|
||||
} description: {
|
||||
Text("home.empty.description")
|
||||
}
|
||||
} else {
|
||||
content(hubs: hubs)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Branding.appName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { SearchToolbarItem() }
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
.task(id: session.server?.name) { await load() }
|
||||
.refreshable { await load() }
|
||||
}
|
||||
|
||||
private func content(hubs: [PlexHub]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
if let featured = heroItem(in: hubs) {
|
||||
HeroSection(item: featured)
|
||||
}
|
||||
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(hub: hub)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, FlixySpacing.section)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
|
||||
/// Was oben groß gezeigt wird.
|
||||
///
|
||||
/// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens
|
||||
/// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der
|
||||
/// Neuzugang an die Stelle.
|
||||
private func heroItem(in hubs: [PlexHub]) -> PlexItem? {
|
||||
let continuing = hubs.first { $0.hubIdentifier.contains("continue") }
|
||||
return continuing?.items.first ?? hubs.first?.items.first
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let server = session.server else { return }
|
||||
state = await LoadState.load(keepingPrevious: state) {
|
||||
try await server.homeHubs()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Eine waagerechte Reihe von Titeln.
|
||||
struct MediaRow: View {
|
||||
let hub: PlexHub
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
|
||||
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
|
||||
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
|
||||
private let cardWidth: CGFloat = 220
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
||||
Text(hub.title)
|
||||
.font(.flixyRowHeader)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: FlixySpacing.regular) {
|
||||
ForEach(hub.items) { item in
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
|
||||
LandscapeCard(
|
||||
title: item.rowTitle,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.server?.artworkURL(
|
||||
path: item.rowArtworkPath,
|
||||
width: 440,
|
||||
height: 248
|
||||
),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
.frame(width: cardWidth)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
// Ohne dieses Ziel rasten die Kacheln nicht ein und die Reihe
|
||||
// bleibt an beliebiger Stelle stehen.
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexItem {
|
||||
/// Der Titel auf einer Kachel in einer Reihe.
|
||||
///
|
||||
/// Bei Folgen der Serientitel: in „Weiterschauen" stehen Folgen
|
||||
/// verschiedener Serien nebeneinander, und der Folgentitel allein sagt
|
||||
/// nicht, worum es geht.
|
||||
var rowTitle: String {
|
||||
kind == .episode ? (grandparentTitle ?? title) : title
|
||||
}
|
||||
|
||||
/// Welches Bild auf die Querformat-Kachel gehört.
|
||||
///
|
||||
/// Bei Folgen ist `thumb` das Szenenbild der Folge — genau richtig. Bei
|
||||
/// Filmen ist `thumb` das Hochkant-Plakat, das im Querformat beschnitten
|
||||
/// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl.
|
||||
var rowArtworkPath: String? {
|
||||
switch kind {
|
||||
case .episode: thumb ?? grandparentThumb
|
||||
default: art ?? thumb
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Filter und Sortierung einer Mediathek.
|
||||
struct LibraryFilterSheet: View {
|
||||
@Binding var query: LibraryQuery
|
||||
let libraryKind: PlexLibrary.Kind?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(FlixySession.self) private var session
|
||||
@State private var genres: [PlexFilterOption] = []
|
||||
|
||||
/// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird.
|
||||
///
|
||||
/// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus —
|
||||
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
|
||||
@State private var draft: LibraryQuery
|
||||
|
||||
init(query: Binding<LibraryQuery>, libraryKind: PlexLibrary.Kind?) {
|
||||
self._query = query
|
||||
self.libraryKind = libraryKind
|
||||
self._draft = State(initialValue: query.wrappedValue)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("filter.sort") {
|
||||
Picker("filter.sort", selection: $draft.sort) {
|
||||
ForEach(LibraryQuery.Sort.allCases, id: \.self) { sort in
|
||||
Text(sort.label).tag(sort)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
|
||||
Section("filter.filters") {
|
||||
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
|
||||
|
||||
Picker("filter.genre", selection: $draft.genre) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
ForEach(genres) { genre in
|
||||
Text(genre.title).tag(String?.some(genre.key))
|
||||
}
|
||||
}
|
||||
|
||||
Picker("filter.resolution", selection: $draft.resolution) {
|
||||
Text("filter.any").tag(String?.none)
|
||||
Text("4K").tag(String?.some("4k"))
|
||||
Text("1080p").tag(String?.some("1080"))
|
||||
Text("720p").tag(String?.some("720"))
|
||||
Text("SD").tag(String?.some("sd"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("filter.reset", role: .destructive) {
|
||||
draft = LibraryQuery(sort: draft.sort)
|
||||
}
|
||||
.disabled(!draft.isFiltering)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("filter.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("action.cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.apply") {
|
||||
query = draft
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadGenres() }
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private func loadGenres() async {
|
||||
guard let server = session.server,
|
||||
let library = session.libraries.first(where: { $0.kind == libraryKind })
|
||||
else { return }
|
||||
genres = (try? await server.filterOptions(inLibrary: library, field: "genre")) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
extension LibraryQuery.Sort {
|
||||
var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .titleAscending: "sort.titleAscending"
|
||||
case .titleDescending: "sort.titleDescending"
|
||||
case .recentlyAdded: "sort.recentlyAdded"
|
||||
case .releaseDate: "sort.releaseDate"
|
||||
case .rating: "sort.rating"
|
||||
case .lastViewed: "sort.lastViewed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Eine Mediathek zum Durchblättern.
|
||||
struct LibraryView: View {
|
||||
let library: PlexLibrary
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid
|
||||
|
||||
@State private var items: [PlexItem] = []
|
||||
@State private var state: LoadState<Void> = .loading
|
||||
@State private var query = LibraryQuery()
|
||||
@State private var page: PlexPage<PlexItem>?
|
||||
@State private var isLoadingMore = false
|
||||
@State private var showsFilters = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch state {
|
||||
case .loading where items.isEmpty:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .failed(let message) where items.isEmpty:
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await reload() } }
|
||||
}
|
||||
|
||||
default:
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("library.empty.title", systemImage: "line.3.horizontal.decrease.circle")
|
||||
} description: {
|
||||
Text("library.empty.description")
|
||||
} actions: {
|
||||
if query.isFiltering {
|
||||
Button("library.clearFilters") {
|
||||
query = LibraryQuery(sort: query.sort)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contents
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(library.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.sheet(isPresented: $showsFilters) {
|
||||
LibraryFilterSheet(query: $query, libraryKind: library.kind)
|
||||
}
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
.task(id: library.key) { await reload() }
|
||||
// Jede Änderung an Filter oder Sortierung verwirft die bisherige
|
||||
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
|
||||
// verschiedenen Abfragen vermischen.
|
||||
.onChange(of: query) { _, _ in Task { await reload() } }
|
||||
.refreshable { await reload() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contents: some View {
|
||||
switch layout {
|
||||
case .grid: grid
|
||||
case .list: list
|
||||
}
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(
|
||||
// Mindestbreite statt fester Spaltenzahl: dieselbe Ansicht
|
||||
// ergibt auf dem iPhone drei und auf dem iPad sechs Spalten,
|
||||
// ohne dass die Größenklasse abgefragt werden muss.
|
||||
columns: [GridItem(.adaptive(minimum: 110), spacing: FlixySpacing.regular)],
|
||||
spacing: FlixySpacing.loose
|
||||
) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
|
||||
PosterCard(
|
||||
title: item.title,
|
||||
subtitle: item.cardSubtitle,
|
||||
artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450),
|
||||
progress: item.watchProgress
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task { await loadMoreIfNeeded(reaching: item) }
|
||||
}
|
||||
}
|
||||
.padding(FlixySpacing.screenMargin)
|
||||
|
||||
if isLoadingMore { ProgressView().padding(.bottom, FlixySpacing.section) }
|
||||
}
|
||||
}
|
||||
|
||||
private var list: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(items) { item in
|
||||
NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) {
|
||||
LibraryListRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task { await loadMoreIfNeeded(reaching: item) }
|
||||
|
||||
Divider().overlay(Color.flixySeparator)
|
||||
}
|
||||
}
|
||||
|
||||
if isLoadingMore { ProgressView().padding(.vertical, FlixySpacing.section) }
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showsFilters = true
|
||||
} label: {
|
||||
Image(systemName: query.isFiltering
|
||||
? "line.3.horizontal.decrease.circle.fill"
|
||||
: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("library.filters"))
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
layout = layout == .grid ? .list : .grid
|
||||
} label: {
|
||||
Image(systemName: layout == .grid ? "list.bullet" : "square.grid.2x2")
|
||||
}
|
||||
.accessibilityLabel(Text("library.toggleLayout"))
|
||||
}
|
||||
|
||||
SearchToolbarItem()
|
||||
}
|
||||
|
||||
// MARK: - Laden
|
||||
|
||||
private func reload() async {
|
||||
guard let server = session.server else { return }
|
||||
state = .loading
|
||||
|
||||
do {
|
||||
let first = try await server.items(inLibrary: library, query: query)
|
||||
items = first.items
|
||||
page = first
|
||||
state = .loaded(())
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
state = .failed(message: PlexErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
|
||||
private func loadMoreIfNeeded(reaching item: PlexItem) async {
|
||||
guard let server = session.server,
|
||||
let page, page.hasMore,
|
||||
!isLoadingMore,
|
||||
// Fünf Einträge vor Schluss: früh genug, dass der Nachschub da
|
||||
// ist, bevor jemand am Ende ankommt, und spät genug, dass nicht
|
||||
// jedes Scrollen eine Anfrage auslöst.
|
||||
items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey })
|
||||
else { return }
|
||||
|
||||
isLoadingMore = true
|
||||
defer { isLoadingMore = false }
|
||||
|
||||
do {
|
||||
let next = try await server.items(
|
||||
inLibrary: library,
|
||||
query: query,
|
||||
offset: page.nextOffset
|
||||
)
|
||||
// Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei
|
||||
// Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster —
|
||||
// und SwiftUI beschwert sich über doppelte Kennungen.
|
||||
let known = Set(items.map(\.ratingKey))
|
||||
items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) })
|
||||
self.page = next
|
||||
} catch {
|
||||
// Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten
|
||||
// im Scrollen wäre störender als ein paar fehlende Einträge.
|
||||
self.page = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LibraryLayout: String, CaseIterable {
|
||||
case grid, list
|
||||
}
|
||||
|
||||
/// Eine Zeile in der Listenansicht.
|
||||
private struct LibraryListRow: View {
|
||||
let item: PlexItem
|
||||
@Environment(FlixySession.self) private var session
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
FlixyArtwork(
|
||||
url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180),
|
||||
aspectRatio: PosterCard.aspectRatio
|
||||
)
|
||||
.frame(width: 44)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.lineLimit(1)
|
||||
if let subtitle = item.cardSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if item.isWatched {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
.accessibilityLabel(Text("item.watched"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.screenMargin)
|
||||
.padding(.vertical, FlixySpacing.snug)
|
||||
.contentShape(.rect)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import PlexKit
|
||||
|
||||
/// Der Ladezustand eines Bildschirms.
|
||||
///
|
||||
/// Ein gemeinsamer Typ statt dreier Einzelfelder (`isLoading`, `items`,
|
||||
/// `errorMessage`): die lassen sich widersprüchlich besetzen — lädt noch und
|
||||
/// hat gleichzeitig einen Fehler — und jede View müsste selbst entscheiden,
|
||||
/// was dann gilt.
|
||||
enum LoadState<Value: Sendable>: Sendable {
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(message: String)
|
||||
|
||||
var value: Value? {
|
||||
if case .loaded(let value) = self { return value }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension LoadState {
|
||||
/// Führt eine Ladeoperation aus und übersetzt Fehler in Text.
|
||||
///
|
||||
/// `keepingPrevious` ist für „Nach unten ziehen zum Aktualisieren": dabei
|
||||
/// soll die Liste stehen bleiben, statt für die Dauer der Anfrage durch
|
||||
/// einen Ladekreis ersetzt zu werden.
|
||||
///
|
||||
/// Auf dem Hauptakteur, weil sie ausschließlich aus Views heraus gerufen
|
||||
/// wird. Ohne diese Festlegung müsste der übergebene Ausdruck `Sendable`
|
||||
/// sein — was er nie ist, weil er den Server aus der Umgebung mitnimmt.
|
||||
@MainActor
|
||||
static func load(
|
||||
keepingPrevious previous: Self? = nil,
|
||||
_ operation: () async throws -> Value
|
||||
) async -> Self {
|
||||
do {
|
||||
return .loaded(try await operation())
|
||||
} catch is CancellationError {
|
||||
// Der Bildschirm wurde verlassen, während geladen wurde. Ein
|
||||
// Fehlertext wäre hier falsch — es ist niemand mehr da, den er
|
||||
// interessiert.
|
||||
return previous ?? .loading
|
||||
} catch {
|
||||
return .failed(message: PlexErrorText.describe(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann.
|
||||
enum PlexErrorText {
|
||||
static func describe(_ error: any Error) -> String {
|
||||
guard let plexError = error as? PlexError else { return error.localizedDescription }
|
||||
return switch plexError {
|
||||
case .unauthorized: String(localized: "error.unauthorized")
|
||||
case .notFound: String(localized: "error.notFound")
|
||||
case .noReachableConnection: String(localized: "error.serverUnreachable")
|
||||
case .server(let status): String(localized: "error.server \(status)")
|
||||
case .transport(let description): description
|
||||
case .decoding: String(localized: "error.unexpectedResponse")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wohin ein Tipp auf eine Kachel führt.
|
||||
///
|
||||
/// Nur der Schlüssel, nicht der ganze Titel: die Einträge aus den Reihen der
|
||||
/// Startseite sind unvollständig — ohne Dateiliste, ohne Besetzung. Die
|
||||
/// Detailseite muss den Titel ohnehin frisch holen.
|
||||
struct MediaRoute: Hashable {
|
||||
let ratingKey: String
|
||||
/// Für die Titelzeile, solange noch geladen wird.
|
||||
let title: String
|
||||
}
|
||||
@@ -1,49 +1,103 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Die Wurzelansicht.
|
||||
///
|
||||
/// Legt fest, in welcher Form die Seitenleiste erscheint: auf dem iPhone als
|
||||
/// ausfahrbares Blatt, auf dem iPad dauerhaft neben dem Inhalt. Beide Wege
|
||||
/// nutzen dieselbe `SidebarView` — der Unterschied liegt nur im Rahmen.
|
||||
/// Entscheidet zuerst anhand des Sitzungszustands, ob überhaupt etwas zu
|
||||
/// zeigen ist — und erst danach, in welcher Form die Seitenleiste erscheint:
|
||||
/// auf dem iPhone als ausfahrbares Blatt, auf dem iPad dauerhaft neben dem
|
||||
/// Inhalt. Beide Wege nutzen dieselbe `SidebarView`.
|
||||
struct RootView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
|
||||
@State private var selection: SidebarDestination = .home
|
||||
@State private var isSidebarOpen = false
|
||||
/// Kommt in Phase 1 vom Server. Solange leer, blendet die Seitenleiste den
|
||||
/// Abschnitt aus, statt eine leere Überschrift zu zeigen.
|
||||
@State private var libraries: [SidebarDestination] = []
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if sizeClass == .regular {
|
||||
splitLayout
|
||||
} else {
|
||||
drawerLayout
|
||||
switch session.phase {
|
||||
case .launching:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flixyCanvas)
|
||||
|
||||
case .signedOut, .awaitingAuthorization:
|
||||
SignInView(
|
||||
phase: session.phase,
|
||||
clientIdentifier: session.clientIdentifier,
|
||||
onSignIn: { Task { await session.beginSignIn() } },
|
||||
onCancel: { session.cancelSignIn() }
|
||||
)
|
||||
|
||||
case .choosingServer(let servers):
|
||||
NavigationStack {
|
||||
ServerPickerView(servers: servers) { resource in
|
||||
Task { await session.connect(to: resource) }
|
||||
}
|
||||
}
|
||||
|
||||
case .connecting(let name):
|
||||
ConnectingView(serverName: name)
|
||||
|
||||
case .failed(let message):
|
||||
ConnectionFailureView(
|
||||
message: message,
|
||||
onRetry: { Task { await session.retry() } },
|
||||
onSignOut: { session.signOut() }
|
||||
)
|
||||
|
||||
case .ready:
|
||||
library
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
// Einmal an der Wurzel statt in jedem Bildschirm, von dem aus sich
|
||||
// etwas starten lässt — sonst gäbe es vier Kopien desselben Übergangs.
|
||||
.fullScreenCover(item: Bindable(playback).request) { request in
|
||||
PlayerView(request: request)
|
||||
}
|
||||
.task { await session.restore() }
|
||||
.onChange(of: session.phase) { _, newPhase in
|
||||
// Nach einer Neuanmeldung darf nicht eine Mediathek ausgewählt
|
||||
// bleiben, die es auf dem neuen Server gar nicht gibt.
|
||||
if newPhase == .ready, case .library = selection { selection = .home }
|
||||
}
|
||||
}
|
||||
|
||||
/// iPad: die Seitenleiste ist Teil des Fensters und lässt sich über den
|
||||
/// Systemknopf einklappen.
|
||||
@ViewBuilder
|
||||
private var library: some View {
|
||||
if sizeClass == .regular {
|
||||
splitLayout
|
||||
} else {
|
||||
drawerLayout
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDestinations: [SidebarDestination] {
|
||||
session.libraries.map { .library(key: $0.key, title: $0.title) }
|
||||
}
|
||||
|
||||
/// iPad: die Seitenleiste ist Teil des Fensters.
|
||||
private var splitLayout: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(libraries: libraries, selection: $selection, showsHeader: false) {}
|
||||
.navigationTitle(Branding.appName)
|
||||
.toolbar(removing: .sidebarToggle)
|
||||
SidebarView(
|
||||
libraries: sidebarDestinations,
|
||||
selection: $selection,
|
||||
showsHeader: false
|
||||
) {}
|
||||
.navigationTitle(Branding.appName)
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
destination
|
||||
}
|
||||
NavigationStack { destination }
|
||||
}
|
||||
}
|
||||
|
||||
/// iPhone: die Seitenleiste fährt über den Inhalt.
|
||||
private var drawerLayout: some View {
|
||||
DrawerContainer(isOpen: $isSidebarOpen) {
|
||||
SidebarView(libraries: libraries, selection: $selection) {
|
||||
SidebarView(libraries: sidebarDestinations, selection: $selection) {
|
||||
isSidebarOpen = false
|
||||
}
|
||||
} content: {
|
||||
@@ -67,15 +121,19 @@ struct RootView: View {
|
||||
private var destination: some View {
|
||||
switch selection {
|
||||
case .home:
|
||||
PlaceholderScreen(destination: .home)
|
||||
HomeView()
|
||||
case .library(let key, let title):
|
||||
PlaceholderScreen(destination: .library(key: key, title: title))
|
||||
if let library = session.libraries.first(where: { $0.key == key }) {
|
||||
LibraryView(library: library)
|
||||
} else {
|
||||
PlaceholderScreen(destination: .library(key: key, title: title))
|
||||
}
|
||||
case .liveTV:
|
||||
PlaceholderScreen(destination: .liveTV)
|
||||
case .downloads:
|
||||
PlaceholderScreen(destination: .downloads)
|
||||
case .settings:
|
||||
PlaceholderScreen(destination: .settings)
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import FlixyPlayback
|
||||
import FlixyVLC
|
||||
import PlexKit
|
||||
|
||||
/// Führt einen Abspielwunsch aus.
|
||||
///
|
||||
/// Holt die Metadaten, lässt den `PlaybackDecider` den Weg bestimmen, baut den
|
||||
/// passenden Motor und hält dessen Zustand für die Ansicht bereit. Die
|
||||
/// Ansicht selbst weiß nicht, welcher Motor läuft — bis auf die eine Stelle,
|
||||
/// an der die Bildfläche gebaut wird.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PlayerModel {
|
||||
enum Phase: Equatable {
|
||||
case preparing
|
||||
case playing
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private(set) var phase: Phase = .preparing
|
||||
private(set) var engine: (any VideoPlayerEngine)?
|
||||
private(set) var decision: PlaybackDecision?
|
||||
private(set) var title: String
|
||||
private(set) var subtitle: String?
|
||||
|
||||
private(set) var currentTime: Duration = .zero
|
||||
private(set) var duration: Duration?
|
||||
private(set) var isPlaying = false
|
||||
|
||||
private let request: PlaybackRequest
|
||||
private let server: PlexServer?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
|
||||
init(request: PlaybackRequest, server: PlexServer?) {
|
||||
self.request = request
|
||||
self.server = server
|
||||
self.title = request.title
|
||||
}
|
||||
|
||||
func start() async {
|
||||
guard let server else {
|
||||
phase = .failed(message: String(localized: "error.serverUnreachable"))
|
||||
return
|
||||
}
|
||||
|
||||
let item: PlexItem
|
||||
do {
|
||||
item = try await server.metadata(ratingKey: request.ratingKey)
|
||||
} catch {
|
||||
phase = .failed(message: PlexErrorText.describe(error))
|
||||
return
|
||||
}
|
||||
|
||||
title = item.rowTitle
|
||||
subtitle = playerSubtitle(for: item)
|
||||
|
||||
guard let media = version(of: item),
|
||||
let part = media.parts.first,
|
||||
let url = server.directPlayURL(part: part)
|
||||
else {
|
||||
phase = .failed(message: String(localized: "player.noPlayableFile"))
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 1 kennt nur DirectPlay. Sobald der Transcoder steht, wird hier
|
||||
// eine andere Richtlinie übergeben — sonst ändert sich nichts.
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
self.decision = decision
|
||||
|
||||
let engine = makeEngine(for: decision)
|
||||
self.engine = engine
|
||||
observe(engine)
|
||||
|
||||
do {
|
||||
try await engine.load(PlaybackSource(
|
||||
url: url,
|
||||
origin: .remote,
|
||||
startTime: request.startTime
|
||||
))
|
||||
engine.play()
|
||||
phase = .playing
|
||||
} catch {
|
||||
phase = .failed(message: String(localized: "player.loadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
guard let engine else { return }
|
||||
if isPlaying { engine.pause() } else { engine.play() }
|
||||
}
|
||||
|
||||
func seek(to time: Duration) {
|
||||
guard let engine else { return }
|
||||
Task { await engine.seek(to: time) }
|
||||
}
|
||||
|
||||
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
|
||||
func skip(by offset: Duration) {
|
||||
guard let duration else { return }
|
||||
let target = currentTime + offset
|
||||
seek(to: max(.zero, min(target, duration)))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
eventTask?.cancel()
|
||||
engine?.teardown()
|
||||
engine = nil
|
||||
}
|
||||
|
||||
// MARK: - Innereien
|
||||
|
||||
private func version(of item: PlexItem) -> PlexMedia? {
|
||||
guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia }
|
||||
return item.media[request.mediaIndex]
|
||||
}
|
||||
|
||||
private func makeEngine(for decision: PlaybackDecision) -> any VideoPlayerEngine {
|
||||
switch decision.engine {
|
||||
case .vlc: VLCPlayerEngine()
|
||||
// Auch der Transcode-Weg endet beim Systemplayer: er liefert HLS.
|
||||
case .avPlayer, .none: AVPlayerEngine()
|
||||
}
|
||||
}
|
||||
|
||||
private func observe(_ engine: any VideoPlayerEngine) {
|
||||
eventTask?.cancel()
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in engine.events {
|
||||
guard let self else { return }
|
||||
switch event {
|
||||
case .stateChanged(let state):
|
||||
isPlaying = state == .playing
|
||||
if case .failed = state {
|
||||
phase = .failed(message: String(localized: "player.playbackFailed"))
|
||||
}
|
||||
case .timeChanged(let time):
|
||||
currentTime = time
|
||||
case .durationResolved(let value):
|
||||
duration = value
|
||||
case .reachedEnd:
|
||||
isPlaying = false
|
||||
case .tracksResolved:
|
||||
// Spurwahl kommt in Phase 2. Die Motoren melden sie jetzt
|
||||
// schon — die Ansicht bietet sie nur noch nicht an.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func playerSubtitle(for item: PlexItem) -> String? {
|
||||
guard item.kind == .episode else { return nil }
|
||||
if let season = item.parentIndex, let episode = item.index {
|
||||
return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)"
|
||||
}
|
||||
return item.title
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import FlixyPlayback
|
||||
import PlexKit
|
||||
|
||||
/// Der Vollbildplayer.
|
||||
struct PlayerView: View {
|
||||
let request: PlaybackRequest
|
||||
|
||||
@Environment(FlixySession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
@State private var model: PlayerModel?
|
||||
@State private var showsControls = true
|
||||
@State private var hideTask: Task<Void, Never>?
|
||||
@State private var scrubbing: Double?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
if let model {
|
||||
VideoSurface(engine: model.engine)
|
||||
.ignoresSafeArea()
|
||||
|
||||
switch model.phase {
|
||||
case .preparing:
|
||||
ProgressView().tint(.white)
|
||||
|
||||
case .failed(let message):
|
||||
failure(message: message)
|
||||
|
||||
case .playing:
|
||||
if showsControls {
|
||||
controls(for: model)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.statusBarHidden(showsControls == false)
|
||||
// Die Wiedergabe soll den Bildschirm wachhalten — bei einem Film ohne
|
||||
// Berührung schaltet das Gerät sonst nach zwei Minuten ab.
|
||||
.persistentSystemOverlays(showsControls ? .automatic : .hidden)
|
||||
.contentShape(.rect)
|
||||
.onTapGesture { toggleControls() }
|
||||
.task {
|
||||
let model = PlayerModel(request: request, server: session.server)
|
||||
self.model = model
|
||||
await model.start()
|
||||
scheduleControlsHide()
|
||||
}
|
||||
.onDisappear {
|
||||
hideTask?.cancel()
|
||||
model?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bedienelemente
|
||||
|
||||
private func controls(for model: PlayerModel) -> some View {
|
||||
VStack {
|
||||
topBar(for: model)
|
||||
Spacer()
|
||||
transport(for: model)
|
||||
Spacer()
|
||||
scrubber(for: model)
|
||||
}
|
||||
.padding(FlixySpacing.loose)
|
||||
.background {
|
||||
// Nur ein Hauch Abdunklung: genug, damit weiße Schrift auf hellen
|
||||
// Szenen lesbar bleibt, wenig genug, dass das Bild nicht verfälscht
|
||||
// wirkt.
|
||||
Color.black.opacity(0.35).ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private func topBar(for model: PlayerModel) -> some View {
|
||||
HStack(alignment: .top) {
|
||||
Button {
|
||||
playback.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.title3)
|
||||
.padding(FlixySpacing.snug)
|
||||
}
|
||||
.accessibilityLabel(Text("player.close"))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(model.title)
|
||||
.font(.flixyCardTitle)
|
||||
.lineLimit(1)
|
||||
if let subtitle = model.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let decision = model.decision {
|
||||
PlaybackMethodBadge(decision: decision)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private func transport(for model: PlayerModel) -> some View {
|
||||
HStack(spacing: FlixySpacing.section * 2) {
|
||||
Button {
|
||||
model.skip(by: .seconds(-10))
|
||||
scheduleControlsHide()
|
||||
} label: {
|
||||
Image(systemName: "gobackward.10").font(.largeTitle)
|
||||
}
|
||||
.accessibilityLabel(Text("player.back10"))
|
||||
|
||||
Button {
|
||||
model.togglePlayPause()
|
||||
scheduleControlsHide()
|
||||
} label: {
|
||||
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 52))
|
||||
}
|
||||
.accessibilityLabel(Text(model.isPlaying ? "player.pause" : "player.play"))
|
||||
|
||||
Button {
|
||||
model.skip(by: .seconds(10))
|
||||
scheduleControlsHide()
|
||||
} label: {
|
||||
Image(systemName: "goforward.10").font(.largeTitle)
|
||||
}
|
||||
.accessibilityLabel(Text("player.forward10"))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private func scrubber(for model: PlayerModel) -> some View {
|
||||
VStack(spacing: FlixySpacing.tight) {
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { scrubbing ?? model.currentTime.seconds },
|
||||
set: { scrubbing = $0 }
|
||||
),
|
||||
in: 0...max(model.duration?.seconds ?? 1, 1),
|
||||
onEditingChanged: { editing in
|
||||
// Erst beim Loslassen springen: bei jedem Zwischenwert zu
|
||||
// springen lässt das Bild flackern und hält bei großen
|
||||
// Dateien den Puffer dauerhaft leer.
|
||||
if !editing, let target = scrubbing {
|
||||
model.seek(to: .seconds(target))
|
||||
scrubbing = nil
|
||||
}
|
||||
scheduleControlsHide()
|
||||
}
|
||||
)
|
||||
.tint(accent)
|
||||
|
||||
HStack {
|
||||
Text((scrubbing.map(Duration.seconds) ?? model.currentTime).timecode)
|
||||
Spacer()
|
||||
if let duration = model.duration {
|
||||
Text("−" + (duration - (scrubbing.map(Duration.seconds) ?? model.currentTime)).timecode)
|
||||
}
|
||||
}
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
private func failure(message: String) -> some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
Text(message)
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(.white)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("action.close") { playback.dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
.padding(FlixySpacing.section)
|
||||
}
|
||||
|
||||
// MARK: - Ein- und Ausblenden
|
||||
|
||||
private func toggleControls() {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showsControls.toggle() }
|
||||
if showsControls { scheduleControlsHide() }
|
||||
}
|
||||
|
||||
/// Blendet die Bedienelemente nach kurzer Ruhe aus — aber nur, solange
|
||||
/// tatsächlich etwas läuft. Im pausierten Zustand wäre ein leerer
|
||||
/// Bildschirm ohne jede Bedienmöglichkeit das Ergebnis.
|
||||
private func scheduleControlsHide() {
|
||||
hideTask?.cancel()
|
||||
hideTask = Task {
|
||||
try? await Task.sleep(for: .seconds(3.5))
|
||||
guard !Task.isCancelled, model?.isPlaying == true else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) { showsControls = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt an, auf welchem Weg gerade abgespielt wird.
|
||||
///
|
||||
/// In Phase 1 vor allem eine Hilfe beim Prüfen: an ihr lässt sich ablesen, ob
|
||||
/// eine Datei wirklich direkt läuft oder ob die Entscheidung danebenlag.
|
||||
struct PlaybackMethodBadge: View {
|
||||
let decision: PlaybackDecision
|
||||
|
||||
var body: some View {
|
||||
Text(label)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
.padding(.horizontal, FlixySpacing.snug)
|
||||
.padding(.vertical, FlixySpacing.tight)
|
||||
.background(.white.opacity(0.15), in: .capsule)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
switch decision.method {
|
||||
case .directPlay(let engine):
|
||||
switch engine {
|
||||
case .avPlayer: "AVPlayer"
|
||||
case .vlc: "VLC"
|
||||
}
|
||||
case .transcode:
|
||||
"Transcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Als „1:23:45" bzw. „23:45".
|
||||
var timecode: String {
|
||||
let total = max(0, Int(components.seconds))
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
let seconds = total % 60
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import FlixyPlayback
|
||||
import FlixyVLC
|
||||
|
||||
/// Die Bildfläche des laufenden Motors.
|
||||
///
|
||||
/// Die einzige Stelle in der App, an der der Unterschied zwischen den beiden
|
||||
/// Motoren sichtbar wird — weil beide ihr Bild auf unterschiedliche Weise
|
||||
/// ausliefern: AVPlayer über eine `AVPlayerLayer`, VLC in eine gewöhnliche
|
||||
/// `UIView`.
|
||||
struct VideoSurface: View {
|
||||
let engine: (any VideoPlayerEngine)?
|
||||
|
||||
var body: some View {
|
||||
switch engine {
|
||||
case let avEngine as AVPlayerEngine:
|
||||
AVPlayerSurface(player: avEngine.player)
|
||||
case let vlcEngine as VLCPlayerEngine:
|
||||
UIViewContainer(view: vlcEngine.videoView)
|
||||
default:
|
||||
Color.black
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Bildebene des Systemplayers, samt Bild-in-Bild.
|
||||
private struct AVPlayerSurface: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerLayerView {
|
||||
let view = PlayerLayerView()
|
||||
view.playerLayer.player = player
|
||||
view.playerLayer.videoGravity = .resizeAspect
|
||||
context.coordinator.setUp(with: view.playerLayer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerLayerView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator {
|
||||
private var pictureInPicture: AVPictureInPictureController?
|
||||
|
||||
func setUp(with layer: AVPlayerLayer) {
|
||||
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
|
||||
pictureInPicture = AVPictureInPictureController(playerLayer: layer)
|
||||
// Beim Verlassen der App ins Bild-in-Bild wechseln, statt die
|
||||
// Wiedergabe anzuhalten.
|
||||
pictureInPicture?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine `UIView`, deren Ebene eine `AVPlayerLayer` ist.
|
||||
///
|
||||
/// Der übliche Weg: eine Ebene von Hand als Unterebene einzuhängen bedeutet,
|
||||
/// ihre Größe bei jeder Drehung selbst nachzuführen. Als Hauptebene macht das
|
||||
/// UIKit.
|
||||
final class PlayerLayerView: UIView {
|
||||
override static var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
}
|
||||
|
||||
/// Hängt eine bestehende `UIView` in den SwiftUI-Baum.
|
||||
private struct UIViewContainer: UIViewRepresentable {
|
||||
let view: UIView
|
||||
|
||||
func makeUIView(context: Context) -> UIView { view }
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten.
|
||||
struct SearchToolbarItem: ToolbarContent {
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
NavigationLink(value: SearchRoute()) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.accessibilityLabel(Text("search.title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchRoute: Hashable {}
|
||||
|
||||
/// Sucht über alle Mediatheken des Servers.
|
||||
struct SearchView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
|
||||
@State private var text = ""
|
||||
@State private var hubs: [PlexHub] = []
|
||||
@State private var isSearching = false
|
||||
@State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? []
|
||||
|
||||
private static let recentKey = "flixy.search.recent"
|
||||
private static let recentLimit = 8
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if text.isEmpty {
|
||||
recentSearches
|
||||
} else if hubs.isEmpty && !isSearching {
|
||||
ContentUnavailableView.search(text: text)
|
||||
} else {
|
||||
results
|
||||
}
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("search.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $text, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) }
|
||||
// Jeder Tastendruck löst eine Anfrage aus — aber erst nach einer
|
||||
// kurzen Pause. Ohne sie schickt „Blade Runner" zwölf Anfragen los,
|
||||
// von denen elf verworfen werden.
|
||||
.task(id: text) { await search() }
|
||||
}
|
||||
|
||||
private var results: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: FlixySpacing.section) {
|
||||
ForEach(hubs) { hub in
|
||||
MediaRow(hub: hub)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, FlixySpacing.loose)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentSearches: some View {
|
||||
if recent.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("search.title", systemImage: "magnifyingglass")
|
||||
} description: {
|
||||
Text("search.hint")
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
ForEach(recent, id: \.self) { entry in
|
||||
Button {
|
||||
text = entry
|
||||
} label: {
|
||||
Label(entry, systemImage: "clock.arrow.circlepath")
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
recent.remove(atOffsets: offsets)
|
||||
persistRecent()
|
||||
}
|
||||
} header: {
|
||||
Text("search.recent")
|
||||
}
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
private func search() async {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let server = session.server, trimmed.count >= 2 else {
|
||||
hubs = []
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
} catch {
|
||||
// Ein neuer Tastendruck hat diese Aufgabe abgebrochen — genau der
|
||||
// Zweck der Pause.
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
defer { isSearching = false }
|
||||
|
||||
do {
|
||||
hubs = try await server.search(trimmed)
|
||||
if !hubs.isEmpty { remember(trimmed) }
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
hubs = []
|
||||
}
|
||||
}
|
||||
|
||||
private func remember(_ entry: String) {
|
||||
recent.removeAll { $0.caseInsensitiveCompare(entry) == .orderedSame }
|
||||
recent.insert(entry, at: 0)
|
||||
recent = Array(recent.prefix(Self.recentLimit))
|
||||
persistRecent()
|
||||
}
|
||||
|
||||
private func persistRecent() {
|
||||
UserDefaults.standard.set(recent, forKey: Self.recentKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Die Einstellungen.
|
||||
///
|
||||
/// In Phase 1 bewusst schmal: Serverangaben und Abmelden. Akzentfarbe,
|
||||
/// Qualitätsregler und Download-Einstellungen kommen mit den Phasen, in denen
|
||||
/// es das Geregelte auch gibt.
|
||||
struct SettingsView: View {
|
||||
@Environment(FlixySession.self) private var session
|
||||
@State private var showsSignOutConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if let server = session.server {
|
||||
Section("settings.server") {
|
||||
LabeledContent("settings.serverName", value: server.name)
|
||||
LabeledContent("settings.connection", value: connectionLabel(server.connection))
|
||||
LabeledContent("settings.address", value: server.connection.uri)
|
||||
.font(.flixyCaption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Section("settings.libraries") {
|
||||
ForEach(session.libraries) { library in
|
||||
LabeledContent(library.title, value: library.type.capitalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("settings.app") {
|
||||
LabeledContent("settings.version", value: Branding.version)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("action.signOut", role: .destructive) {
|
||||
showsSignOutConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("sidebar.settings"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
Text("settings.signOutConfirm"),
|
||||
isPresented: $showsSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("action.signOut", role: .destructive) { session.signOut() }
|
||||
Button("action.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.signOutExplanation")
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach
|
||||
/// Ruckeln ist das die erste Frage.
|
||||
private func connectionLabel(_ connection: PlexConnection) -> String {
|
||||
if connection.local { return String(localized: "settings.connectionLocal") }
|
||||
if connection.relay { return String(localized: "settings.connectionRelay") }
|
||||
return String(localized: "settings.connectionRemote")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Während ein Weg zum Server gesucht wird.
|
||||
struct ConnectingView: View {
|
||||
let serverName: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
ProgressView()
|
||||
Text(serverName.isEmpty
|
||||
? String(localized: "connecting.searching")
|
||||
: String(localized: "connecting.to \(serverName)"))
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flixyCanvas)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn am Konto mehrere Server hängen.
|
||||
struct ServerPickerView: View {
|
||||
let servers: [PlexResource]
|
||||
let onSelect: (PlexResource) -> Void
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
List(servers) { server in
|
||||
Button {
|
||||
onSelect(server)
|
||||
} label: {
|
||||
HStack(spacing: FlixySpacing.regular) {
|
||||
Image(systemName: server.owned ? "server.rack" : "person.2")
|
||||
.foregroundStyle(accent)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.flixyCardTitle)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
Text(subtitle(for: server))
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(Color.flixySurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.flixyCanvas)
|
||||
.navigationTitle(Text("serverPicker.title"))
|
||||
}
|
||||
|
||||
private func subtitle(for server: PlexResource) -> String {
|
||||
var pieces: [String] = []
|
||||
pieces.append(server.owned
|
||||
? String(localized: "serverPicker.owned")
|
||||
: String(localized: "serverPicker.shared"))
|
||||
if let platform = server.platform { pieces.append(platform) }
|
||||
if let version = server.productVersion { pieces.append(version) }
|
||||
return pieces.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Wenn etwas schiefgegangen ist.
|
||||
struct ConnectionFailureView: View {
|
||||
let message: String
|
||||
let onRetry: () -> Void
|
||||
let onSignOut: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry", action: onRetry).buttonStyle(.borderedProminent)
|
||||
Button("action.signOut", action: onSignOut)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
.background(Color.flixyCanvas)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import SwiftUI
|
||||
import FlixyDesign
|
||||
import PlexKit
|
||||
|
||||
/// Der Anmeldebildschirm.
|
||||
///
|
||||
/// Zeigt den Code, den Plex vergeben hat, und schickt in den Browser. Die App
|
||||
/// bekommt Benutzername und Passwort nie zu sehen — sie fragt nur regelmäßig
|
||||
/// nach, ob der Code bestätigt wurde.
|
||||
struct SignInView: View {
|
||||
let phase: FlixySession.Phase
|
||||
let clientIdentifier: String
|
||||
let onSignIn: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: FlixySpacing.section) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: FlixySpacing.regular) {
|
||||
Image(systemName: "play.rectangle.on.rectangle")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(accent)
|
||||
|
||||
Text(Branding.appName)
|
||||
.font(.flixyHero)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
|
||||
Text("signIn.tagline")
|
||||
.font(.flixyBody)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if case .awaitingAuthorization(let pin) = phase {
|
||||
awaitingAuthorization(pin: pin)
|
||||
} else {
|
||||
Button(action: onSignIn) {
|
||||
Text("signIn.action")
|
||||
.font(.flixyRowHeader)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
}
|
||||
|
||||
Spacer().frame(height: FlixySpacing.section)
|
||||
}
|
||||
.padding(.horizontal, FlixySpacing.section)
|
||||
.frame(maxWidth: 420)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.flixyCanvas)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func awaitingAuthorization(pin: PlexPin) -> some View {
|
||||
VStack(spacing: FlixySpacing.loose) {
|
||||
Text("signIn.codeHint")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(pin.code)
|
||||
// Feste Zeichenbreite, damit der Code beim Abtippen nicht
|
||||
// springt und Ziffern nicht mit Buchstaben verschwimmen.
|
||||
.font(.system(.largeTitle, design: .monospaced, weight: .semibold))
|
||||
.tracking(6)
|
||||
.foregroundStyle(Color.flixyTextPrimary)
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.sheet))
|
||||
|
||||
Button {
|
||||
if let url = pin.authorizationURL(
|
||||
clientIdentifier: clientIdentifier,
|
||||
product: Branding.plexProduct
|
||||
) {
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
Label("signIn.openBrowser", systemImage: "safari")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, FlixySpacing.regular)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(accent)
|
||||
|
||||
HStack(spacing: FlixySpacing.snug) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("signIn.waiting")
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextMuted)
|
||||
}
|
||||
|
||||
Button("action.cancel", action: onCancel)
|
||||
.font(.flixyCaption)
|
||||
.foregroundStyle(Color.flixyTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user