Files
Flaro/Flixy/Views/LiveTV/LiveTVView.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

413 lines
16 KiB
Swift

import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Live-TV mit Programmführer.
struct LiveTVView: View {
@Environment(FlixySession.self) private var session
@Environment(PlaybackCoordinator.self) private var playback
@Environment(PlaybackSettings.self) private var settings
@Environment(NetworkConditions.self) private var network
@State private var availability: LiveTVAvailability?
/// Der Empfänger, über den eingeschaltet wird.
@State private var dvr: PlexDVR?
@State private var channels: [PlexChannel] = []
/// Woher die Senderliste kam — steht in der leeren Ansicht.
@State private var channelSource: String?
/// Jede abgeklopfte Adresse mit ihrer Antwort.
@State private var trace: [ChannelProbe] = []
@State private var programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
@State private var tuneFailure: TuneFailure?
/// Raster oder Senderliste.
///
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil
/// niemand sie zweimal treffen will.
@AppStorage("liveTV.showsGuide") private var showsGuide = true
/// Welche Sender gezeigt werden. Leer heißt alle.
@AppStorage("liveTV.selection") private var storedSelection = ""
@State private var showsSelection = false
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
/// Liste nicht auseinanderlaufen.
private var shownChannels: [PlexChannel] {
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
}
var body: some View {
Group {
switch availability {
case nil:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .notConfigured:
// Der häufigste Fall — Live-TV verlangt eine Fernsehkarte oder
// einen Netzwerk-Tuner am Server. Ein Fehlerdialog wäre hier
// irreführend: es ist nichts kaputt, es ist nur nichts da.
ContentUnavailableView {
Label("liveTV.notConfigured.title", systemImage: "antenna.radiowaves.left.and.right.slash")
} description: {
Text("liveTV.notConfigured.description")
}
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .available where channels.isEmpty:
// Empfänger gefunden, aber keine Sender. Ein anderer
// Zustand als „nicht eingerichtet", und die Unterscheidung
// zählt: sie sagt, ob es am Server liegt oder an der App.
ContentUnavailableView {
Label("liveTV.noChannels.title", systemImage: "tv")
} description: {
Text("liveTV.noChannels.description")
} actions: {
VStack(spacing: FlixySpacing.regular) {
Button("action.retry") { Task { await load() } }
searchTrace
}
}
case .available:
if showsGuide {
ProgramGuide(
channels: shownChannels,
programs: programs,
tuning: isTuning,
artwork: { channel in
session.server?.artworkURL(path: channel.thumb, width: 120, height: 120)
},
onTune: { channel, program in Task { await tune(channel, program: program) } }
)
} else {
channelList
}
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.liveTV"))
.flixyInlineTitle()
.toolbar {
if case .available = availability, !channels.isEmpty {
ToolbarItem(placement: .flixyTrailing) {
Button {
showsSelection = true
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(Text("liveTV.selection"))
}
ToolbarItem(placement: .flixyTrailing) {
Picker("liveTV.mode", selection: $showsGuide) {
Image(systemName: "calendar.day.timeline.left").tag(true)
Image(systemName: "list.bullet").tag(false)
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
}
.task { await load() }
.refreshable { await load() }
.sheet(isPresented: $showsSelection) {
ChannelSelectionSheet(
channels: channels,
selection: Binding(
get: { ChannelSelection.decode(storedSelection) },
set: { storedSelection = ChannelSelection.encode($0) }
)
)
}
// Ein Blatt statt eines Dialogs: die Antwort des Servers ist
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
// wieder nur, dass es nicht ging.
.sheet(item: $tuneFailure) { failure in
TuneFailureSheet(failure: failure)
}
}
/// Wo überall gesucht wurde, und was dort stand.
///
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
@ViewBuilder
private var searchTrace: some View {
if !trace.isEmpty {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
if let channelSource {
Text(channelSource)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
ScrollView {
VStack(alignment: .leading, spacing: 3) {
ForEach(trace) { probe in
Text("\(probe.path) → \(probe.result)")
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(2)
.textSelection(.enabled)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 200)
Button {
FlixyClipboard.copy(
trace
.map { "\($0.path) → \($0.result)" }
.joined(separator: "\n")
)
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption)
}
.buttonStyle(.bordered)
}
.padding(.horizontal, FlixySpacing.screenMargin)
}
}
private var channelList: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(shownChannels) { channel in
ChannelRow(
channel: channel,
programs: programs[channel.id] ?? [],
isTuning: isTuning == channel.id,
onTune: { Task { await tune(channel) } }
)
Divider().overlay(Color.flixySeparator)
}
}
}
}
private func load() async {
guard let server = session.server else { return }
let availability = await server.liveTVAvailability()
self.availability = availability
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
self.dvr = dvr
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
// antwortet auf manchen Servern gar nicht, und dann tragen die
// Rastereinträge die Sender selbst. Getrennt geholt passten die
// Kennungen zudem nicht zueinander, und das Programm landete in
// keiner Zeile.
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
// und über Mobilfunk wartet man darauf.
let guide = await server.liveTVGuide(dvr: dvr, hours: 6)
channels = guide.channels
programs = guide.programs
channelSource = guide.source
trace = guide.trace
}
private func tune(_ channel: PlexChannel, program: PlexProgram? = nil) async {
guard let server = session.server, isTuning == nil else { return }
isTuning = channel.id
defer { isTuning = nil }
// Die laufende Sendung mitgeben: über ihre Adresse beim
// Programmanbieter führt der direkte Weg zum Stream.
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
let result = await server.tune(
channel: channel,
program: running,
// Der Programmanbieter kennt keinen Weg zum Einschalten — der
// Empfänger schon, und ohne ihn bleibt der halbe Suchraum zu.
dvr: dvr,
maximumBitrate: policy.maximumBitrate
)
guard let stream = result.url else {
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
tuneFailure = TuneFailure(
channel: channel.title,
reason: String(localized: "liveTV.noStream"),
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
)
return
}
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
playback.playLive(
channelID: channel.id,
title: channel.title,
programme: running?.title,
programmeEndsAt: running?.endsAt,
stream: stream,
subscriptionKey: result.subscriptionKey
)
}
}
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
private struct ChannelRow: View {
let channel: PlexChannel
let programs: [PlexProgram]
let isTuning: Bool
let onTune: () -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
private var current: PlexProgram? { programs.first { $0.isRunning() } }
private var next: PlexProgram? { programs.first { $0.startsAt > Date() } }
var body: some View {
Button(action: onTune) {
HStack(spacing: FlixySpacing.regular) {
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
// fast nie quadratisch, und formatfüllend fehlten links und
// rechts genau die Teile, an denen man den Sender erkennt.
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 200, height: 120),
aspectRatio: FlixyAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 56, height: 34)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: FlixySpacing.snug) {
if let number = channel.channelIdentifier {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
}
Text(channel.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
}
if let current {
Text(current.title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(1)
// Der Balken zeigt, wie weit die Sendung ist — beim
// Umschalten die nützlichste Angabe überhaupt.
WatchProgressBar(fraction: current.progress())
.padding(.top, 2)
} else {
Text("liveTV.noProgram")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
if let next {
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
Spacer()
if isTuning {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.circle")
.font(.title3)
.foregroundStyle(accent)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
}
}
/// Was beim Einschalten schiefging, in voller Länge.
struct TuneFailure: Identifiable {
let id = UUID()
let channel: String
let reason: String
/// Jeder versuchte Weg mit der Antwort des Servers.
let trace: [String]
}
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
///
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
/// jede Runde davon kostet Tage.
private struct TuneFailureSheet: View {
let failure: TuneFailure
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
Text(failure.channel)
.font(.flixyRowHeader)
.foregroundStyle(Color.flixyTextPrimary)
Text(failure.reason)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextSecondary)
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
Text(line)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
FlixyClipboard.copy(failure.trace.joined(separator: "\n"))
} label: {
Label("diagnostics.copy", systemImage: "doc.on.doc")
.font(.flixyCaption)
}
.buttonStyle(.bordered)
}
.padding(FlixySpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.flixyCanvas)
.navigationTitle(Text("liveTV.tuneFailed"))
.flixyInlineTitle()
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.flixyResizableSheet()
}
}