Keine Datei unter Views/LiveTV importiert noch PlexKit. Sender, Programm und Einschalten laufen über den neutralen Vertrag, und supportsLiveTV fragt endlich die Fähigkeit ab statt "server != nil" — womit die Zusage in JellyfinBackend.capabilities zum ersten Mal stimmt. Der Player holt die laufende Sendung jetzt für genau einen Sender nach. Vorher ging dafür das Raster über alle Sender hinaus, weil der Plex-Weg keinen schmaleren kannte; im neutralen Vertrag ist die Senderliste ein Parameter. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
14 KiB
Swift
354 lines
14 KiB
Swift
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
|
|
|
|
@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) }
|
|
)
|
|
}
|
|
// 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
|
|
|
|
@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)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|