Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
470 lines
19 KiB
Swift
470 lines
19 KiB
Swift
import SwiftUI
|
|
import HaloDesign
|
|
import HaloPlayback
|
|
import MediaKit
|
|
|
|
/// Live-TV mit Programmführer.
|
|
struct LiveTVView: View {
|
|
@Environment(HaloSession.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?
|
|
@State private var channels: [LiveChannel] = []
|
|
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
|
@State private var channelSource: String?
|
|
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
|
@State private var trace: [LiveChannelProbe] = []
|
|
@State private var programs: [String: [LiveProgram]] = [:]
|
|
@State private var isTuning: String?
|
|
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
|
@State private var tuneFailure: TuneFailure?
|
|
/// Raster, Senderliste oder Aufnahmen.
|
|
///
|
|
/// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
|
|
/// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
|
|
/// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
|
|
///
|
|
/// Neuer Schlüssel statt des alten Wahrheitswerts: aus „zeigt das Raster"
|
|
/// ist eine Wahl aus dreien geworden, und die beiden ineinander zu
|
|
/// überführen wäre mehr Aufwand, als einmal beim Raster zu beginnen.
|
|
@AppStorage("liveTV.mode") private var mode: LiveTVMode = .guide
|
|
/// 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: [LiveChannel] {
|
|
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 && mode != .recordings:
|
|
// 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: HaloSpacing.regular) {
|
|
Button("action.retry") { Task { await load() } }
|
|
searchTrace
|
|
}
|
|
}
|
|
|
|
case .available:
|
|
switch mode {
|
|
case .guide:
|
|
ProgramGuide(
|
|
channels: shownChannels,
|
|
programs: programs,
|
|
tuning: isTuning,
|
|
artwork: { channel in
|
|
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
|
|
},
|
|
onTune: { channel, program in Task { await tune(channel, program: program) } },
|
|
// Beide nur, wenn der Server aufnehmen kann. Sonst
|
|
// `nil`, und die Knöpfe erscheinen gar nicht erst.
|
|
onRecord: session.supportsRecordings
|
|
? { program in Task { await record(program, series: false) } }
|
|
: nil,
|
|
onRecordSeries: session.supportsRecordings
|
|
? { program in Task { await record(program, series: true) } }
|
|
: nil
|
|
)
|
|
case .channels:
|
|
channelList
|
|
case .recordings:
|
|
RecordingsView()
|
|
}
|
|
}
|
|
}
|
|
.background(Color.haloCanvas)
|
|
.navigationTitle(Text("sidebar.liveTV"))
|
|
.haloInlineTitle()
|
|
.toolbar {
|
|
if case .available = availability, !channels.isEmpty {
|
|
ToolbarItem(placement: .haloTrailing) {
|
|
Button {
|
|
showsSelection = true
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
}
|
|
.accessibilityLabel(Text("liveTV.selection"))
|
|
}
|
|
ToolbarItem(placement: .haloTrailing) {
|
|
Picker("liveTV.mode", selection: $mode) {
|
|
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
|
Image(systemName: "list.bullet").tag(LiveTVMode.channels)
|
|
// Der dritte Schalter fehlt schlicht, wo der Server
|
|
// nicht aufnehmen kann — bei Plex etwa.
|
|
if session.supportsRecordings {
|
|
Image(systemName: "recordingtape").tag(LiveTVMode.recordings)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
// Ein gespeicherter Modus, den dieser Server nicht kennt, führte
|
|
// sonst auf einen Schalter, den es nicht gibt — und damit auf
|
|
// einen leeren Bildschirm ohne Weg zurück.
|
|
if mode == .recordings, !session.supportsRecordings { mode = .guide }
|
|
await load()
|
|
}
|
|
.refreshable { await load() }
|
|
.sheet(isPresented: $showsSelection) {
|
|
ChannelSelectionSheet(
|
|
channels: channels,
|
|
selection: Binding(
|
|
get: { ChannelSelection.decode(storedSelection) },
|
|
set: { storedSelection = ChannelSelection.encode($0) }
|
|
)
|
|
)
|
|
}
|
|
// Ein Sheet 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: HaloSpacing.snug) {
|
|
if let channelSource {
|
|
Text(channelSource)
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextSecondary)
|
|
}
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
ForEach(trace) { probe in
|
|
Text("\(probe.path) → \(probe.result)")
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
.lineLimit(2)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.frame(maxHeight: 200)
|
|
|
|
Button {
|
|
HaloClipboard.copy(
|
|
trace
|
|
.map { "\($0.path) → \($0.result)" }
|
|
.joined(separator: "\n")
|
|
)
|
|
} label: {
|
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
|
.font(.haloCaption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.horizontal, HaloSpacing.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.haloSeparator)
|
|
}
|
|
}
|
|
}
|
|
.haloRevealsTopChrome()
|
|
}
|
|
|
|
private func load() async {
|
|
guard let liveTV = session.liveTV else { return }
|
|
|
|
let availability = await liveTV.liveTVAvailability()
|
|
self.availability = availability
|
|
|
|
guard case .available = availability else { return }
|
|
|
|
// 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 listing = await liveTV.liveChannels()
|
|
channels = listing.channels
|
|
channelSource = listing.source
|
|
trace = listing.probes
|
|
|
|
let now = Date()
|
|
programs = (try? await liveTV.livePrograms(
|
|
channelIDs: listing.channels.map(\.id),
|
|
from: now.addingTimeInterval(-3_600),
|
|
to: now.addingTimeInterval(6 * 3_600)
|
|
)) ?? [:]
|
|
}
|
|
|
|
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
|
|
guard let liveTV = session.liveTV, 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 liveTV.tune(
|
|
channel: channel,
|
|
program: running,
|
|
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
|
|
)
|
|
}
|
|
|
|
/// Merkt eine Sendung vor.
|
|
///
|
|
/// Ohne Rückmeldung bei Erfolg: der Knopf verschwindet, sobald die
|
|
/// Sendung einen Timer trägt, und das ist die Antwort. Eine Meldung
|
|
/// obendrauf wäre eine zweite.
|
|
private func record(_ program: LiveProgram, series: Bool) async {
|
|
guard let recorder = session.recorder else { return }
|
|
do {
|
|
if series {
|
|
try await recorder.scheduleSeriesTimer(for: program)
|
|
} else {
|
|
try await recorder.scheduleTimer(for: program)
|
|
}
|
|
await load()
|
|
} catch {
|
|
tuneFailure = TuneFailure(
|
|
channel: program.title,
|
|
reason: ErrorText.describe(error),
|
|
trace: []
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Was Live-TV gerade zeigt.
|
|
enum LiveTVMode: String {
|
|
case guide
|
|
case channels
|
|
case recordings
|
|
}
|
|
|
|
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
|
|
private struct ChannelRow: View {
|
|
let channel: LiveChannel
|
|
let programs: [LiveProgram]
|
|
let isTuning: Bool
|
|
let onTune: () -> Void
|
|
|
|
@Environment(HaloSession.self) private var session
|
|
@Environment(\.haloAccent) private var accent
|
|
|
|
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
|
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
|
|
|
var body: some View {
|
|
Button(action: onTune) {
|
|
HStack(spacing: HaloSpacing.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.
|
|
HaloArtwork(
|
|
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
|
aspectRatio: HaloAspectRatio.channelLogo,
|
|
fallbackSymbol: "tv",
|
|
contentMode: .fit
|
|
)
|
|
.frame(width: 56, height: 34)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: HaloSpacing.snug) {
|
|
if let number = channel.number {
|
|
Text(number)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
}
|
|
Text(channel.title)
|
|
.font(.haloCardTitle)
|
|
.foregroundStyle(Color.haloTextPrimary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if let current {
|
|
Text(current.title)
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextSecondary)
|
|
.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(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
}
|
|
|
|
if let next {
|
|
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
|
.font(.haloCaption)
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isTuning {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Image(systemName: "play.circle")
|
|
.font(.title3)
|
|
.foregroundStyle(accent)
|
|
}
|
|
}
|
|
.padding(.horizontal, HaloSpacing.screenMargin)
|
|
.padding(.vertical, HaloSpacing.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: HaloSpacing.regular) {
|
|
Text(failure.channel)
|
|
.font(.haloRowHeader)
|
|
.foregroundStyle(Color.haloTextPrimary)
|
|
|
|
Text(failure.reason)
|
|
.font(.haloBody)
|
|
.foregroundStyle(Color.haloTextSecondary)
|
|
|
|
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(Color.haloTextMuted)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
Button {
|
|
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
|
|
} label: {
|
|
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
|
.font(.haloCaption)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(HaloSpacing.screenMargin)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.background(Color.haloCanvas)
|
|
.navigationTitle(Text("liveTV.tuneFailed"))
|
|
.haloInlineTitle()
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("action.close") { dismiss() }
|
|
}
|
|
}
|
|
}
|
|
.haloResizableSheet()
|
|
}
|
|
}
|