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. @State private var tuneFailure: String? /// 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() } .alert( Text("liveTV.tuneFailed"), isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } }) ) { Button("action.close", role: .cancel) { tuneFailure = nil } } message: { if let tuneFailure { Text(tuneFailure) } } } /// 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 = ([String(localized: "liveTV.noStream")] + result.trace.map { "\($0.path) → \($0.result)" }) .joined(separator: "\n") return } // Den Server fragen, bevor der Player es versucht. Lehnt er ab, sagt // er im Klartext warum — der Player meldet nur, dass es nicht ging. if let verdict = await server.liveTVVerdict(for: stream) { tuneFailure = verdict 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) } }