Drei geratene Adressen für die Senderliste antworteten auf einem echten Server alle mit 404. Statt ein viertes Mal zu raten, liest die App jetzt zwei Dinge: was /media/providers an Endpunkten angibt, und das Programmraster selbst — die Weboberfläche von Plex zeichnet ihren Guide daraus, und jeder Rastereintrag trägt Sendername, Nummer, Logo und Einschaltweg mit sich. Damit kommen Sender und Programm aus einer Abfrage, und ihre Kennungen passen zueinander. Getrennt geholt taten sie das nicht: das Programm landete in keiner Zeile. Dazu die Ansicht, für die das alles da ist — ein Programmraster mit stehender Senderspalte, mitlaufender Zeitachse und einer Jetzt-Linie. Antippen öffnet die Sendung; läuft sie gerade, schaltet ein Knopf um. Die Geometrie steht in GuideLayout und ist geprüft: in einer View wäre sie unsichtbar falsch. Die Trennlinien gehören in die Zeile statt zwischen die Zeilen — Divider ist auf 3x-Schirmen keinen ganzen Punkt hoch, und der Bruchteil summiert sich, bis die angetippte Zeile zum Nachbarsender gehört. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
333 lines
13 KiB
Swift
333 lines
13 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?
|
|
let onTune: (PlexChannel) -> Void
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@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) {
|
|
channelColumn
|
|
lanes
|
|
}
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
}
|
|
.background(Color.flixyCanvas)
|
|
.sheet(item: $selection) { selection in
|
|
ProgramDetailSheet(
|
|
channel: selection.channel,
|
|
program: selection.program,
|
|
isRunning: selection.program.isRunning(at: now),
|
|
onTune: { onTune(selection.channel) }
|
|
)
|
|
}
|
|
// 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)
|
|
.frame(width: channelWidth, alignment: .leading)
|
|
.padding(.leading, FlixySpacing.snug)
|
|
|
|
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)
|
|
}
|
|
}
|
|
.frame(width: layout.totalWidth, alignment: .leading)
|
|
.offset(x: -scrollX)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.clipped()
|
|
}
|
|
.frame(height: axisHeight)
|
|
.background(Color.flixyCanvas)
|
|
}
|
|
|
|
private var channelColumn: some View {
|
|
VStack(spacing: 0) {
|
|
ForEach(channels) { channel in
|
|
Button { onTune(channel) } label: {
|
|
HStack(spacing: FlixySpacing.snug) {
|
|
FlixyArtwork(
|
|
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
|
|
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 program: PlexProgram
|
|
let isRunning: Bool
|
|
let onTune: () -> Void
|
|
|
|
@Environment(FlixySession.self) private var session
|
|
@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: session.server?.artworkURL(path: channel.thumb, width: 160, height: 160),
|
|
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() }
|
|
}
|
|
}
|
|
}
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
}
|