Files
Flaro/Flixy/Views/LiveTV/ProgramGuide.swift
T
ScarriffleandClaude Opus 5 d54954b9c2 macOS: die App läuft jetzt auf dem Mac
Und dein Argument dafür war das beste: auf dem Mac kann ich selbst hinsehen.
Kein Simulator-Tunnel, der mitten im Prüfen abstürzt — `screencapture` auf das
Fenster der App, und zwar nur auf ihres, nicht auf den Bildschirm.

**Ein eigenes Ziel, nicht ein gemeinsames.** Die Info.plist der iOS-App ist
voller iOS-Angaben: Startbildschirm, erlaubte Ausrichtungen, Live Activities.
Die alle bedingt zu machen hieße, dass jede spätere Änderung an der iOS-Seite
über eine Stelle geht, die auch den Mac betrifft. Zwei Ziele sind ein paar
Zeilen mehr und eine Sorge weniger.

**Was den Unterschied ausmacht, steht an einer Stelle.** Ein neuer
`PlatformShims` in FlixyDesign übersetzt, was auf beiden Seiten verschieden
heißt: schmale Titelzeile, halbhohes Blatt, Eingabefelder ohne
Großschreibung, die Plätze in der Werkzeugleiste, die Zwischenablage. Zwanzig
Ansichten benutzen ihn, statt zwanzigmal `#if os(iOS)` zu tragen — sonst
wandert die Fallunterscheidung in jede neue Datei und wird beim übernächsten
Bildschirm vergessen.

Wirklich verschieden sind nur fünf Stellen: die Bildfläche (`UIView` gegen
`NSView`), der AirPlay-Knopf, die Ausrichtungssperre (auf dem Mac gibt es
nichts zu sperren), der App-Delegat für Hintergrund-Downloads und das
Anmeldefenster, das ein anderes Fenster als Anker braucht.

**Das Karussell blättert jetzt mit einem einrastenden Streifen** statt mit
`TabView(.page)` — den Seitenstil gibt es auf dem Mac nicht. Das ist nebenbei
die bessere Lösung: das verschachtelte TabView hatte die Eigenschaft, beim
Wechsel kurz zwei Seiten gleichzeitig zu zeigen.

**Was auf dem Mac fehlt und warum:** VLC. Das Binärpaket ist auf iOS
festgelegt; sein xcframework enthält zwar Mac-Teile, aber das ist eine eigene
Runde. Solange fällt die Wiedergabe dort auf den Systemplayer zurück — MKV und
DTS gehen also noch nicht. Live Activities gibt es auf dem Mac nicht, Bild-in-
Bild startet von Hand statt beim Verlassen der App.

Gesehen: die App startet, der Anmeldebildschirm steht, beide Wege sind da.
Angemeldet habe ich mich auf dem Mac **nicht** — das ginge nur, indem ich
einen Token in deinen Schlüsselbund schreibe, und dann fragt macOS dich beim
ersten Zugriff nach dem Passwort. Das machst du mit einem Klick selbst.

iOS baut unverändert, 263 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:45:13 +02:00

354 lines
14 KiB
Swift

import SwiftUI
import FlixyDesign
import PlexKit
/// 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: [PlexChannel]
let programs: [String: [PlexProgram]]
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: (PlexChannel) -> URL?
let onTune: (PlexChannel, PlexProgram?) -> 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(.hidden)
}
.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: PlexChannel) -> PlexProgram? {
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.channelIdentifier {
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(.hidden)
.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: PlexChannel) -> 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: PlexProgram, on channel: PlexChannel) -> 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: PlexChannel
let program: PlexProgram
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: PlexChannel
let artworkURL: URL?
let program: PlexProgram
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()
}
}