Wieder HTTP 400, und wieder ohne Begründung. Der Grund, dass das dreimal passieren konnte: eine gebaute Adresse galt schon als Erfolg. Ob der Server sie überhaupt annimmt, stellte sich erst im Player heraus — und dort steht nur „HTTP 400 (unhandled)". Jetzt ruft die App die Adresse einmal selbst ab, ohne Plex-Kopfzeilen, genau so wie AVPlayer es tut. Nur was mit 200 antwortet, geht an den Player. Was nicht, steht mit dem Wortlaut des Servers in der Spur. Weil weder der Schlüssel für die Sitzung noch die anschließend umzurechnende Adresse für diesen Teil der Plex-Schnittstelle beschrieben sind, werden die Kombinationen der Reihe nach durchprobiert — Adresse der Sitzung, Adresse aus der Antwort, Adresse des Teils darin, je mit mediaIndex -1 und 0, höchstens acht Versuche. Antwortet keiner, wird der Transcoder wieder angehalten, statt für nichts weiterzurechnen. Die Spur steht in einem Blatt statt in einem Dialog: die Antwort des Servers ist mehrzeilig, und ein Dialog schneidet sie ab — dann stünde dort wieder nur, dass es nicht ging. Dazu ein Nebeneffekt, der beim zweiten Sender zugeschlagen hätte: der Player kannte die Sitzungskennung des Live-Stroms nicht und hat den Tuner beim Schließen nicht freigegeben. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
375 lines
15 KiB
Swift
375 lines
15 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyPlayback
|
|
import PlexKit
|
|
|
|
/// Live-TV mit Programmführer.
|
|
struct LiveTVView: View {
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(PlaybackCoordinator.self) private var playback
|
|
@Environment(PlaybackSettings.self) private var settings
|
|
@Environment(NetworkConditions.self) private var network
|
|
|
|
@State private var availability: LiveTVAvailability?
|
|
@State private var channels: [PlexChannel] = []
|
|
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
|
@State private var channelSource: String?
|
|
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
|
@State private var trace: [ChannelProbe] = []
|
|
@State private var programs: [String: [PlexProgram]] = [:]
|
|
@State private var isTuning: String?
|
|
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
|
@State private var tuneFailure: TuneFailure?
|
|
/// Raster oder Senderliste.
|
|
///
|
|
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
|
|
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil
|
|
/// niemand sie zweimal treffen will.
|
|
@AppStorage("liveTV.showsGuide") private var showsGuide = true
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch availability {
|
|
case nil:
|
|
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
case .notConfigured:
|
|
// Der häufigste Fall — Live-TV verlangt eine Fernsehkarte oder
|
|
// einen Netzwerk-Tuner am Server. Ein Fehlerdialog wäre hier
|
|
// irreführend: es ist nichts kaputt, es ist nur nichts da.
|
|
ContentUnavailableView {
|
|
Label("liveTV.notConfigured.title", systemImage: "antenna.radiowaves.left.and.right.slash")
|
|
} description: {
|
|
Text("liveTV.notConfigured.description")
|
|
}
|
|
|
|
case .failed(let message):
|
|
ContentUnavailableView {
|
|
Label("error.title", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(message)
|
|
} actions: {
|
|
Button("action.retry") { Task { await load() } }
|
|
}
|
|
|
|
case .available where channels.isEmpty:
|
|
// Empfänger gefunden, aber keine Sender. Ein anderer
|
|
// Zustand als „nicht eingerichtet", und die Unterscheidung
|
|
// zählt: sie sagt, ob es am Server liegt oder an der App.
|
|
ContentUnavailableView {
|
|
Label("liveTV.noChannels.title", systemImage: "tv")
|
|
} description: {
|
|
Text("liveTV.noChannels.description")
|
|
} actions: {
|
|
VStack(spacing: FlixySpacing.regular) {
|
|
Button("action.retry") { Task { await load() } }
|
|
searchTrace
|
|
}
|
|
}
|
|
|
|
case .available:
|
|
if showsGuide {
|
|
ProgramGuide(
|
|
channels: channels,
|
|
programs: programs,
|
|
tuning: isTuning,
|
|
artwork: { channel in
|
|
session.server?.artworkURL(path: channel.thumb, width: 120, height: 120)
|
|
},
|
|
onTune: { channel, program in Task { await tune(channel, program: program) } }
|
|
)
|
|
} else {
|
|
channelList
|
|
}
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("sidebar.liveTV"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if case .available = availability, !channels.isEmpty {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Picker("liveTV.mode", selection: $showsGuide) {
|
|
Image(systemName: "calendar.day.timeline.left").tag(true)
|
|
Image(systemName: "list.bullet").tag(false)
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
.task { await load() }
|
|
.refreshable { await load() }
|
|
// Ein Blatt statt eines Dialogs: die Antwort des Servers ist
|
|
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
|
|
// wieder nur, dass es nicht ging.
|
|
.sheet(item: $tuneFailure) { failure in
|
|
TuneFailureSheet(failure: failure)
|
|
}
|
|
}
|
|
|
|
/// Wo überall gesucht wurde, und was dort stand.
|
|
///
|
|
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
|
|
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
|
|
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
|
|
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
|
|
@ViewBuilder
|
|
private var searchTrace: some View {
|
|
if !trace.isEmpty {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
|
|
if let channelSource {
|
|
Text(channelSource)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
}
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(trace) { probe in
|
|
Text("\(probe.path) → \(probe.result)")
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.lineLimit(2)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 200)
|
|
|
|
Button {
|
|
UIPasteboard.general.string = trace
|
|
.map { "\($0.path) → \($0.result)" }
|
|
.joined(separator: "\n")
|
|
} label: {
|
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
|
.font(.flixyCaption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
}
|
|
}
|
|
|
|
private var channelList: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 0) {
|
|
ForEach(channels) { channel in
|
|
ChannelRow(
|
|
channel: channel,
|
|
programs: programs[channel.id] ?? [],
|
|
isTuning: isTuning == channel.id,
|
|
onTune: { Task { await tune(channel) } }
|
|
)
|
|
Divider().overlay(Color.flixySeparator)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func load() async {
|
|
guard let server = session.server else { return }
|
|
|
|
let availability = await server.liveTVAvailability()
|
|
self.availability = availability
|
|
|
|
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
|
|
|
|
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
|
|
// antwortet auf manchen Servern gar nicht, und dann tragen die
|
|
// Rastereinträge die Sender selbst. Getrennt geholt passten die
|
|
// Kennungen zudem nicht zueinander, und das Programm landete in
|
|
// keiner Zeile.
|
|
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
|
|
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
|
|
// und über Mobilfunk wartet man darauf.
|
|
let guide = await server.liveTVGuide(dvr: dvr, hours: 6)
|
|
channels = guide.channels
|
|
programs = guide.programs
|
|
channelSource = guide.source
|
|
trace = guide.trace
|
|
}
|
|
|
|
private func tune(_ channel: PlexChannel, program: PlexProgram? = nil) async {
|
|
guard let server = session.server, isTuning == nil else { return }
|
|
isTuning = channel.id
|
|
defer { isTuning = nil }
|
|
|
|
// Die laufende Sendung mitgeben: über ihre Adresse beim
|
|
// Programmanbieter führt der direkte Weg zum Strom.
|
|
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
|
let policy = settings.policy(for: network)
|
|
|
|
let result = await server.tune(
|
|
channel: channel,
|
|
program: running,
|
|
maximumBitrate: policy.maximumBitrate
|
|
)
|
|
|
|
guard let stream = result.url else {
|
|
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
|
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
|
tuneFailure = TuneFailure(
|
|
channel: channel.title,
|
|
reason: String(localized: "liveTV.noStream"),
|
|
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
|
|
)
|
|
return
|
|
}
|
|
|
|
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
|
|
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
|
|
playback.request = PlaybackRequest(
|
|
ratingKey: channel.id,
|
|
title: channel.title,
|
|
mediaIndex: 0,
|
|
startTime: .zero,
|
|
streamURL: stream
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
|
|
private struct ChannelRow: View {
|
|
let channel: PlexChannel
|
|
let programs: [PlexProgram]
|
|
let isTuning: Bool
|
|
let onTune: () -> Void
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
private var current: PlexProgram? { programs.first { $0.isRunning() } }
|
|
private var next: PlexProgram? { programs.first { $0.startsAt > Date() } }
|
|
|
|
var body: some View {
|
|
Button(action: onTune) {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
FlixyArtwork(
|
|
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
|
aspectRatio: 1,
|
|
fallbackSymbol: "tv"
|
|
)
|
|
.frame(width: 44, height: 44)
|
|
.clipShape(.rect(cornerRadius: 4))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
if let number = channel.channelIdentifier {
|
|
Text(number)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
Text(channel.title)
|
|
.font(.flixyCardTitle)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if let current {
|
|
Text(current.title)
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
.lineLimit(1)
|
|
// Der Balken zeigt, wie weit die Sendung ist — beim
|
|
// Umschalten die nützlichste Angabe überhaupt.
|
|
WatchProgressBar(fraction: current.progress())
|
|
.padding(.top, 2)
|
|
} else {
|
|
Text("liveTV.noProgram")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
}
|
|
|
|
if let next {
|
|
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
|
.font(.flixyCaption)
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isTuning {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Image(systemName: "play.circle")
|
|
.font(.title3)
|
|
.foregroundStyle(accent)
|
|
}
|
|
}
|
|
.padding(.horizontal, FlixySpacing.screenMargin)
|
|
.padding(.vertical, FlixySpacing.regular)
|
|
.contentShape(.rect)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
|
|
/// Was beim Einschalten schiefging, in voller Länge.
|
|
struct TuneFailure: Identifiable {
|
|
let id = UUID()
|
|
let channel: String
|
|
let reason: String
|
|
/// Jeder versuchte Weg mit der Antwort des Servers.
|
|
let trace: [String]
|
|
}
|
|
|
|
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
|
|
///
|
|
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
|
|
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
|
|
/// jede Runde davon kostet Tage.
|
|
private struct TuneFailureSheet: View {
|
|
let failure: TuneFailure
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
|
|
Text(failure.channel)
|
|
.font(.flixyRowHeader)
|
|
.foregroundStyle(Color.flixyTextPrimary)
|
|
|
|
Text(failure.reason)
|
|
.font(.flixyBody)
|
|
.foregroundStyle(Color.flixyTextSecondary)
|
|
|
|
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Color.flixyTextMuted)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
Button {
|
|
UIPasteboard.general.string = failure.trace.joined(separator: "\n")
|
|
} label: {
|
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
|
.font(.flixyCaption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(FlixySpacing.screenMargin)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("liveTV.tuneFailed"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("action.close") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|