Der Abzug vom Server zeigt beides schwarz auf weiß. Erstens: /media/grabbers/devices/1/channels antwortet mit 278 Sendern — unter dem Sammelnamen `DeviceChannel`. Die Hülle kannte nur `Channel` und hat die vollständige Liste als leer verworfen. Zweitens: das Programmraster antwortet, aber nicht auf `type=1`. Die 1 steht bei Plex für Film, und im Programm laufen auch Folgen, Sport und Nachrichten — der Filter hat das Raster geleert. Ohne ihn kommen die Sendungen samt Sendername, Nummer, Logo und Kennung. Das Raster hat jetzt Vorrang vor der Tunerliste, auch wenn beide antworten: nur dort gehören Sender und Programm zusammen, es enthält ohnehin nur die eingeschalteten Sender, und die Tunerliste führt alle 278. Einschalten läuft über die Adresse der laufenden Sendung beim Programmanbieter — Plex behandelt eine laufende Sendung wie einen Film. Die Adresse wird einmal entpackt, bevor sie in die Anfrage geht: sonst kodiert URLComponents ein zweites Mal, und aus %3A wird %253A. Die Tests stehen jetzt auf den echten Antworten aus dem Abzug, nicht auf erfundenen: DeviceChannel mit `name` und `key`, Rastereinträge mit channelVcn, channelID und einer Folge neben einem Film. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
338 lines
13 KiB
Swift
338 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, PlexProgram?) -> 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, 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)
|
|
.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)
|
|
}
|
|
|
|
/// 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: 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])
|
|
}
|
|
}
|