import SwiftUI import FlixyDesign import MediaKit /// Das Programmraster: Sender als Zeilen, Zeit als Achse. /// /// Warum ein Raster und nicht eine Liste mit „läuft gerade": beim Fernsehen /// sucht man nicht einen Sender, sondern eine Sendung — und die findet man /// nur, wenn man sieht, was auf allen Sendern gleichzeitig läuft. Genau das /// ist die Achse. /// /// Der Aufbau: die Senderspalte steht **außerhalb** des waagerechten /// Rollbereichs und bleibt deshalb stehen, während das Programm daran /// vorbeizieht. Die Zeitachse steht außerhalb des senkrechten und folgt dem /// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht /// gleichzeitig festgenagelt. struct ProgramGuide: View { let channels: [LiveChannel] let programs: [String: [LiveProgram]] let tuning: String? /// Die Adresse des Senderlogos. /// /// Als Abschluss übergeben statt aus der Umgebung gelesen: so lässt sich /// das Raster außerhalb der App mit erfundenen Sendern zeichnen und /// ansehen. Die Senderspalte hat einmal gefehlt, und das war ohne einen /// Blick darauf nicht zu finden. let artwork: (LiveChannel) -> URL? let onTune: (LiveChannel, LiveProgram?) -> Void /// Eine Sendung vormerken — einmalig oder als ganze Reihe. /// /// Beide optional: **nicht** jeder Server kann aufnehmen. `nil` heißt, /// dass die Knöpfe gar nicht erst erscheinen — besser als ein Knopf, der /// bei Plex nur eine Fehlermeldung auslöst. var onRecord: ((LiveProgram) -> Void)? var onRecordSeries: ((LiveProgram) -> Void)? @Environment(\.flixyAccent) private var accent @State private var scrollX: CGFloat = 0 @State private var selection: GuideSelection? @State private var now = Date() private let channelWidth: CGFloat = 116 private let rowHeight: CGFloat = 64 /// Eine halbe Stunde ist gut 130 Punkte breit — schmal genug, dass ein /// Abend auf den Schirm passt, breit genug für einen lesbaren Titel. private let pointsPerMinute: CGFloat = 4.4 private let axisHeight: CGFloat = 26 var body: some View { VStack(spacing: 0) { axis Divider().overlay(Color.flixySeparator) ScrollView(.vertical) { HStack(alignment: .top, spacing: 0) { // Ohne Vorrang verliert die Spalte den Platzstreit gegen // das Raster: dessen Breite ist die volle Zeitachse — // mehrere tausend Punkte —, und der HStack drückt den // Nachbarn dafür auf null. Genau so war die Senderspalte // verschwunden. channelColumn.layoutPriority(1) lanes } } .scrollIndicators(.never) } .background(Color.flixyCanvas) .sheet(item: $selection) { selection in ProgramDetailSheet( channel: selection.channel, artworkURL: artwork(selection.channel), program: selection.program, isRunning: selection.program.isRunning(at: now), onTune: { onTune(selection.channel, selection.program) }, onRecord: onRecord.map { record in { record(selection.program) } }, onRecordSeries: onRecordSeries.map { record in { record(selection.program) } } ) } // Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein // paar Minuten nicht mehr. Einmal pro Minute reicht dafür. .task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) now = Date() } } } // MARK: - Zeitfenster /// Zeit in Punkte. Die Rechnungen dahinter stehen in `GuideLayout` und /// sind dort geprüft — hier wären sie unsichtbar falsch. private var layout: GuideLayout { GuideLayout( now: now, latestEnd: programs.values.flatMap { $0 }.map(\.endsAt).max(), pointsPerMinute: pointsPerMinute ) } // MARK: - Teile private var axis: some View { HStack(spacing: 0) { Text("liveTV.channelCount \(channels.count)") .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) // Der Abstand gehört in die Spaltenbreite hinein, nicht // dazu: außen addiert er sich, und die ganze Zeitachse steht // acht Punkte neben dem Programm darunter. .padding(.leading, FlixySpacing.snug) .frame(width: channelWidth, alignment: .leading) .layoutPriority(1) // Der Streifen liegt als Auflage über einer durchsichtigen // Fläche, statt selbst im Stapel zu stehen: eine Auflage zählt // bei der Größenrechnung nicht mit. Stünde er selbst darin, // verlangte er die Breite der ganzen Zeitachse und schöbe die // Beschriftung links aus dem Bild. Color.clear .frame(maxWidth: .infinity) .overlay(alignment: .leading) { HStack(spacing: 0) { ForEach(layout.ticks, id: \.self) { tick in Text(tick.formatted(date: .omitted, time: .shortened)) .font(.system(.caption2, design: .rounded)) .foregroundStyle(Color.flixyTextMuted) .frame(width: layout.tickWidth, alignment: .leading) } } .fixedSize() .offset(x: -scrollX) } .clipped() } .frame(height: axisHeight) .background(Color.flixyCanvas) } /// Was auf diesem Sender gerade läuft. private func running(on channel: LiveChannel) -> LiveProgram? { programs[channel.id]?.first { $0.isRunning(at: now) } } private var channelColumn: some View { VStack(spacing: 0) { ForEach(channels) { channel in Button { onTune(channel, running(on: channel)) } label: { HStack(spacing: FlixySpacing.snug) { FlixyArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv") .frame(width: 30, height: 30) .clipShape(.rect(cornerRadius: 4)) VStack(alignment: .leading, spacing: 1) { Text(channel.title) .font(.flixyCaption) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) if let number = channel.number { Text(number) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(Color.flixyTextMuted) } } Spacer(minLength: 0) if tuning == channel.id { ProgressView().controlSize(.mini) } } .padding(.horizontal, FlixySpacing.snug) .frame(width: channelWidth, height: rowHeight, alignment: .leading) .overlay(alignment: .bottom) { rowSeparator } .contentShape(.rect) } .buttonStyle(.plain) } } .frame(width: channelWidth) .background(Color.flixyElevated.opacity(0.4)) } private var lanes: some View { ScrollView(.horizontal) { ZStack(alignment: .topLeading) { VStack(spacing: 0) { ForEach(channels) { channel in lane(for: channel) } } // Die Jetzt-Linie läuft mit dem Inhalt mit, nicht mit dem // Blickfeld — sie markiert einen Zeitpunkt, keine Stelle auf // dem Schirm. Rectangle() .fill(accent) .frame(width: 2) .offset(x: layout.x(for: now)) .allowsHitTesting(false) } .frame(width: layout.totalWidth, alignment: .topLeading) } .scrollIndicators(.never) .frame(height: CGFloat(channels.count) * rowHeight) .onScrollGeometryChange(for: CGFloat.self) { geometry in geometry.contentOffset.x } action: { _, offset in scrollX = offset } } private func lane(for channel: LiveChannel) -> some View { let entries = programs[channel.id] ?? [] return ZStack(alignment: .topLeading) { if entries.isEmpty { Text("liveTV.noProgram") .font(.flixyCaption) .foregroundStyle(Color.flixyTextMuted) .padding(.horizontal, FlixySpacing.regular) .frame(height: rowHeight, alignment: .leading) } ForEach(entries) { program in block(program, on: channel) .offset(x: layout.x(for: program.startsAt)) } } .frame(width: layout.totalWidth, height: rowHeight, alignment: .topLeading) .overlay(alignment: .bottom) { rowSeparator } } /// Die Trennlinie gehört in die Zeile, nicht zwischen die Zeilen. /// /// `Divider()` ist auf einem 3x-Schirm keinen ganzen Punkt hoch. Zwischen /// den Zeilen gestapelt summiert sich dieser Bruchteil, und Senderspalte /// und Programm stehen nach zwanzig Sendern sichtbar versetzt — die /// Zeile, die man antippt, gehört dann zum Nachbarsender. private var rowSeparator: some View { Rectangle() .fill(Color.flixySeparator) .frame(height: 1) } private func block(_ program: LiveProgram, on channel: LiveChannel) -> some View { let running = program.isRunning(at: now) return Button { selection = GuideSelection(channel: channel, program: program) } label: { VStack(alignment: .leading, spacing: 2) { Text(program.title) .font(.flixyCaption) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) .multilineTextAlignment(.leading) Text(program.startsAt.formatted(date: .omitted, time: .shortened)) .font(.system(.caption2, design: .rounded)) .foregroundStyle(Color.flixyTextMuted) } .padding(.horizontal, FlixySpacing.snug) .padding(.vertical, 6) .frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading) .background( running ? accent.opacity(0.28) : Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control) ) .overlay(alignment: .bottomLeading) { // Der Balken zeigt, wie weit die laufende Sendung ist — beim // Umschalten die nützlichste Angabe überhaupt. if running { WatchProgressBar(fraction: program.progress(at: now)) .padding(.horizontal, 6) .padding(.bottom, 5) } } .contentShape(.rect) } .buttonStyle(.plain) } } /// Die angetippte Sendung samt ihrem Sender. private struct GuideSelection: Identifiable { let channel: LiveChannel let program: LiveProgram var id: String { "\(channel.id)-\(program.id)" } } /// Was zu einer Sendung im Raster gehört, in voller Länge. private struct ProgramDetailSheet: View { let channel: LiveChannel let artworkURL: URL? let program: LiveProgram let isRunning: Bool let onTune: () -> Void let onRecord: (() -> Void)? let onRecordSeries: (() -> Void)? @Environment(\.flixyAccent) private var accent @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: FlixySpacing.regular) { HStack(spacing: FlixySpacing.regular) { FlixyArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv") .frame(width: 44, height: 44) .clipShape(.rect(cornerRadius: 6)) VStack(alignment: .leading, spacing: 2) { Text(channel.title) .font(.flixyCardTitle) .foregroundStyle(Color.flixyTextPrimary) Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))") .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) } Spacer() } Text(program.title) .font(.flixyRowHeader) .foregroundStyle(Color.flixyTextPrimary) if let subtitle = program.subtitle { Text(subtitle) .font(.flixyBody) .foregroundStyle(Color.flixyTextSecondary) } if isRunning { Button { onTune(); dismiss() } label: { Label("liveTV.tune", systemImage: "play.fill") .frame(maxWidth: .infinity) .padding(.vertical, FlixySpacing.snug) } .buttonStyle(.borderedProminent) .tint(accent) } // Vormerken steht auch bei einer laufenden Sendung: der // Rest davon lässt sich noch aufnehmen. Bei einem Server // ohne Aufnahmen fehlt hier schlicht alles. if program.seriesTimerID != nil { Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle") .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) } else if onRecord != nil || onRecordSeries != nil { HStack(spacing: FlixySpacing.regular) { if let onRecord { Button { onRecord(); dismiss() } label: { Label("liveTV.record", systemImage: "record.circle") .frame(maxWidth: .infinity) .padding(.vertical, FlixySpacing.snug) } .buttonStyle(.bordered) } if let onRecordSeries { Button { onRecordSeries(); dismiss() } label: { Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record") .frame(maxWidth: .infinity) .padding(.vertical, FlixySpacing.snug) } .buttonStyle(.bordered) } } } if let summary = program.summary, !summary.isEmpty { Text(summary) .font(.flixyBody) .foregroundStyle(Color.flixyTextSecondary) } } .padding(FlixySpacing.screenMargin) .frame(maxWidth: .infinity, alignment: .leading) } .background(Color.flixyCanvas) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("action.close") { dismiss() } } } } .flixyResizableSheet() } }