Der dritte Modus neben Raster und Senderliste — und der einzige, den es nicht überall gibt. Bei Plex fehlt schlicht der Schalter dorthin, denn supportsRecordings fragt die eigene Fähigkeit ab. Vormerken sitzt im Blatt, das eine angetippte Sendung ohnehin schon öffnet, statt in einem zweiten Kontextmenü daneben. Trägt die Sendung bereits einen Serien-Timer, steht dort das statt der Knöpfe: zweimal vormerken ist kein Wunsch, sondern ein Versehen. Aufnahmen führen über die Detailseite statt unmittelbar in den Player. Eine Aufnahme ist auf dem Server ein gewöhnlicher Eintrag, und erst dort steht, in welchen Fassungen sie vorliegt — von der Liste aus zu starten hieße, das zu raten. Zwölf Schlüssel in DE und EN. Die Übersetzungsdatei ist dabei auf das Format zurückgeschrieben worden, das auch im letzten Commit steht; inhaltlich ist sie HEAD plus diese zwölf, nachgerechnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
470 lines
19 KiB
Swift
470 lines
19 KiB
Swift
import SwiftUI
|
|
import FlixyDesign
|
|
import FlixyPlayback
|
|
import MediaKit
|
|
|
|
/// 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: [LiveChannel] = []
|
|
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
|
@State private var channelSource: String?
|
|
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
|
@State private var trace: [LiveChannelProbe] = []
|
|
@State private var programs: [String: [LiveProgram]] = [:]
|
|
@State private var isTuning: String?
|
|
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
|
@State private var tuneFailure: TuneFailure?
|
|
/// Raster, Senderliste oder Aufnahmen.
|
|
///
|
|
/// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
|
|
/// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
|
|
/// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
|
|
///
|
|
/// Neuer Schlüssel statt des alten Wahrheitswerts: aus „zeigt das Raster"
|
|
/// ist eine Wahl aus dreien geworden, und die beiden ineinander zu
|
|
/// überführen wäre mehr Aufwand, als einmal beim Raster zu beginnen.
|
|
@AppStorage("liveTV.mode") private var mode: LiveTVMode = .guide
|
|
/// Welche Sender gezeigt werden. Leer heißt alle.
|
|
@AppStorage("liveTV.selection") private var storedSelection = ""
|
|
@State private var showsSelection = false
|
|
|
|
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
|
|
/// Liste nicht auseinanderlaufen.
|
|
private var shownChannels: [LiveChannel] {
|
|
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
|
|
}
|
|
|
|
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 && mode != .recordings:
|
|
// 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:
|
|
switch mode {
|
|
case .guide:
|
|
ProgramGuide(
|
|
channels: shownChannels,
|
|
programs: programs,
|
|
tuning: isTuning,
|
|
artwork: { channel in
|
|
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
|
|
},
|
|
onTune: { channel, program in Task { await tune(channel, program: program) } },
|
|
// Beide nur, wenn der Server aufnehmen kann. Sonst
|
|
// `nil`, und die Knöpfe erscheinen gar nicht erst.
|
|
onRecord: session.supportsRecordings
|
|
? { program in Task { await record(program, series: false) } }
|
|
: nil,
|
|
onRecordSeries: session.supportsRecordings
|
|
? { program in Task { await record(program, series: true) } }
|
|
: nil
|
|
)
|
|
case .channels:
|
|
channelList
|
|
case .recordings:
|
|
RecordingsView()
|
|
}
|
|
}
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.navigationTitle(Text("sidebar.liveTV"))
|
|
.flixyInlineTitle()
|
|
.toolbar {
|
|
if case .available = availability, !channels.isEmpty {
|
|
ToolbarItem(placement: .flixyTrailing) {
|
|
Button {
|
|
showsSelection = true
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
}
|
|
.accessibilityLabel(Text("liveTV.selection"))
|
|
}
|
|
ToolbarItem(placement: .flixyTrailing) {
|
|
Picker("liveTV.mode", selection: $mode) {
|
|
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
|
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
|
// Der dritte Schalter fehlt schlicht, wo der Server
|
|
// nicht aufnehmen kann — bei Plex etwa.
|
|
if session.supportsRecordings {
|
|
Image(systemName: "recordingtape").tag(LiveTVMode.recordings)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
// Ein gespeicherter Modus, den dieser Server nicht kennt, führte
|
|
// sonst auf einen Schalter, den es nicht gibt — und damit auf
|
|
// einen leeren Bildschirm ohne Weg zurück.
|
|
if mode == .recordings, !session.supportsRecordings { mode = .guide }
|
|
await load()
|
|
}
|
|
.refreshable { await load() }
|
|
.sheet(isPresented: $showsSelection) {
|
|
ChannelSelectionSheet(
|
|
channels: channels,
|
|
selection: Binding(
|
|
get: { ChannelSelection.decode(storedSelection) },
|
|
set: { storedSelection = ChannelSelection.encode($0) }
|
|
)
|
|
)
|
|
}
|
|
// 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 {
|
|
FlixyClipboard.copy(
|
|
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(shownChannels) { channel in
|
|
ChannelRow(
|
|
channel: channel,
|
|
programs: programs[channel.id] ?? [],
|
|
isTuning: isTuning == channel.id,
|
|
onTune: { Task { await tune(channel) } }
|
|
)
|
|
Divider().overlay(Color.flixySeparator)
|
|
}
|
|
}
|
|
}
|
|
.flixyRevealsTopChrome()
|
|
}
|
|
|
|
private func load() async {
|
|
guard let liveTV = session.liveTV else { return }
|
|
|
|
let availability = await liveTV.liveTVAvailability()
|
|
self.availability = availability
|
|
|
|
guard case .available = availability 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 listing = await liveTV.liveChannels()
|
|
channels = listing.channels
|
|
channelSource = listing.source
|
|
trace = listing.probes
|
|
|
|
let now = Date()
|
|
programs = (try? await liveTV.livePrograms(
|
|
channelIDs: listing.channels.map(\.id),
|
|
from: now.addingTimeInterval(-3_600),
|
|
to: now.addingTimeInterval(6 * 3_600)
|
|
)) ?? [:]
|
|
}
|
|
|
|
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
|
|
guard let liveTV = session.liveTV, 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 Stream.
|
|
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
|
let policy = settings.policy(for: network)
|
|
|
|
let result = await liveTV.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.playLive(
|
|
channelID: channel.id,
|
|
title: channel.title,
|
|
programme: running?.title,
|
|
programmeEndsAt: running?.endsAt,
|
|
stream: stream,
|
|
subscriptionKey: result.subscriptionKey
|
|
)
|
|
}
|
|
|
|
/// Merkt eine Sendung vor.
|
|
///
|
|
/// Ohne Rückmeldung bei Erfolg: der Knopf verschwindet, sobald die
|
|
/// Sendung einen Timer trägt, und das ist die Antwort. Eine Meldung
|
|
/// obendrauf wäre eine zweite.
|
|
private func record(_ program: LiveProgram, series: Bool) async {
|
|
guard let recorder = session.recorder else { return }
|
|
do {
|
|
if series {
|
|
try await recorder.scheduleSeriesTimer(for: program)
|
|
} else {
|
|
try await recorder.scheduleTimer(for: program)
|
|
}
|
|
await load()
|
|
} catch {
|
|
tuneFailure = TuneFailure(
|
|
channel: program.title,
|
|
reason: ErrorText.describe(error),
|
|
trace: []
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Was Live-TV gerade zeigt.
|
|
enum LiveTVMode: String {
|
|
case guide
|
|
case channels
|
|
case recordings
|
|
}
|
|
|
|
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
|
|
private struct ChannelRow: View {
|
|
let channel: LiveChannel
|
|
let programs: [LiveProgram]
|
|
let isTuning: Bool
|
|
let onTune: () -> Void
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@Environment(\.flixyAccent) private var accent
|
|
|
|
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
|
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
|
|
|
var body: some View {
|
|
Button(action: onTune) {
|
|
HStack(spacing: FlixySpacing.regular) {
|
|
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
|
// fast nie quadratisch, und formatfüllend fehlten links und
|
|
// rechts genau die Teile, an denen man den Sender erkennt.
|
|
FlixyArtwork(
|
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
|
aspectRatio: FlixyAspectRatio.channelLogo,
|
|
fallbackSymbol: "tv",
|
|
contentMode: .fit
|
|
)
|
|
.frame(width: 56, height: 34)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
if let number = channel.number {
|
|
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 {
|
|
FlixyClipboard.copy(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"))
|
|
.flixyInlineTitle()
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("action.close") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.flixyResizableSheet()
|
|
}
|
|
}
|