Senderliste
Plex liefert die Sender verschachtelt in der Antwort auf /livetv/dvrs
mit — jeder Empfänger bringt seine eigenen unter `Device` → `Channel`.
Mein getrennter Endpunkt /livetv/dvrs/{id}/channels war eine Vermutung
und lieferte nichts. Bei einem Server mit über dreißig eingerichteten
Sendern stand deshalb „Keine Sender".
Die verschachtelte Liste hat jetzt Vorrang, der Endpunkt bleibt als
Rückfalllinie. `ChannelMapping` wird als ältere Schreibweise ebenfalls
gelesen, und der Empfänger-Schlüssel kommt je nach Serverfassung als
Zahl oder als Zeichenkette.
Bleibt die Liste leer, sagt die Ansicht jetzt welcher Weg was ergeben
hat. In einem Bereich ohne offizielle Beschreibung ist das die Angabe,
mit der sich der nächste Fehler finden lässt.
Schriftzüge
Plex liefert bei vielen Filmen ein `clearLogo` — den Schriftzug in
seiner eigenen Type, freigestellt. Der tritt jetzt im Aufmacher an die
Stelle des gesetzten Titels.
Dabei ein Fallstrick: /photo/:/transcode liefert JPEG, und JPEG kennt
keine Transparenz. Über den Bild-Transcoder geladen wäre aus dem
freigestellten Logo ein schwarzer Kasten geworden. Es wird deshalb
unverändert geladen. Fehlt der Schriftzug oder scheitert das Laden,
bleibt es beim Titel.
161 Tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
7.5 KiB
Swift
209 lines
7.5 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
|
|
),
|
|
logoURL: session.server?.rawImageURL(path: item.clearLogoPath)
|
|
) {
|
|
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)
|
|
}
|
|
}
|
|
}
|