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] = [] @State private var programs: [String: [PlexProgram]] = [:] @State private var isTuning: String? 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: { Button("action.retry") { Task { await load() } } } case .available: guide } } .background(Color.flixyCanvas) .navigationTitle(Text("sidebar.liveTV")) .navigationBarTitleDisplayMode(.inline) .task { await load() } .refreshable { await load() } } private var guide: 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 } channels = (try? await server.channels(inDVR: dvr)) ?? [] // Der Programmführer kommt getrennt: er kann ausfallen, ohne dass // deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne // Programm ist immer noch nützlich. guard let provider = await server.electronicProgramGuideIdentifier() else { return } let now = Date() let entries = (try? await server.guide( providerIdentifier: provider, from: now.addingTimeInterval(-3_600), to: now.addingTimeInterval(6 * 3_600) )) ?? [] programs = Dictionary(grouping: entries.compactMap { program in program.channelID.map { (key: $0, program: program) } }, by: \.key).mapValues { pairs in pairs.map(\.program).sorted { $0.startsAt < $1.startsAt } } } private func tune(_ channel: PlexChannel) async { guard let server = session.server, isTuning == nil else { return } isTuning = channel.id defer { isTuning = nil } let policy = settings.policy(for: network) guard (try? await server.tune(channel: channel, maximumBitrate: policy.maximumBitrate)) != nil else { return } // Live-TV läuft über denselben Player wie alles andere. Der Schlüssel // ist der Sender, nicht ein Titel — der Player holt sich den Strom // über die Einschalt-Adresse. playback.request = PlaybackRequest( ratingKey: channel.id, title: channel.title, mediaIndex: 0, startTime: .zero ) } } /// 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) } }