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>
354 lines
14 KiB
Swift
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()
|
|
}
|
|
}
|