Files
Flaro/Flixy/Views/LiveTV/ProgramGuide.swift
T
ScarriffleandClaude Opus 5 982ae83e76 Aufnehmen, was noch gar nicht läuft
Der dritte Modus neben Raster und Senderliste — und der einzige, den es
nicht überall gibt. Bei Plex fehlt schlicht der Schalter dorthin, denn
supportsRecordings fragt die eigene Fähigkeit ab.

Vormerken sitzt im Blatt, das eine angetippte Sendung ohnehin schon
öffnet, statt in einem zweiten Kontextmenü daneben. Trägt die Sendung
bereits einen Serien-Timer, steht dort das statt der Knöpfe: zweimal
vormerken ist kein Wunsch, sondern ein Versehen.

Aufnahmen führen über die Detailseite statt unmittelbar in den Player.
Eine Aufnahme ist auf dem Server ein gewöhnlicher Eintrag, und erst dort
steht, in welchen Fassungen sie vorliegt — von der Liste aus zu starten
hieße, das zu raten.

Zwölf Schlüssel in DE und EN. Die Übersetzungsdatei ist dabei auf das
Format zurückgeschrieben worden, das auch im letzten Commit steht;
inhaltlich ist sie HEAD plus diese zwölf, nachgerechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:26:34 +02:00

393 lines
16 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
/// 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()
}
}