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 Sheet 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() } }