Flixy heißt ab hier Halo

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>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,121 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Welche Sender überhaupt angezeigt werden.
///
/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer
/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an.
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
/// die Ansicht unübersichtlich.
struct ChannelSelectionSheet: View {
let channels: [LiveChannel]
@Binding var selection: Set<String>
@Environment(HaloSession.self) private var session
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
ForEach(channels) { channel in
row(for: channel)
}
} footer: {
Text("liveTV.selection.hint")
}
}
.navigationTitle(Text("liveTV.selection"))
.haloInlineTitle()
.toolbar {
ToolbarItem(placement: .haloLeading) {
// „Alle" statt „keine": eine leere Auswahl bedeutet
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
// wäre verwirrend.
Button("liveTV.selection.all") { selection.removeAll() }
.disabled(selection.isEmpty)
}
ToolbarItem(placement: .confirmationAction) {
Button("action.done") { dismiss() }
}
}
}
}
private func row(for channel: LiveChannel) -> some View {
Button {
// Beim ersten Antippen aus „alle" eine echte Auswahl machen:
// sonst hakt man einen Sender an und alle anderen bleiben
// trotzdem sichtbar.
if selection.isEmpty {
selection = Set(channels.map(\.id)).subtracting([channel.id])
} else if selection.contains(channel.id) {
selection.remove(channel.id)
} else {
selection.insert(channel.id)
}
} label: {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: HaloAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 48, height: 29)
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
}
Text(channel.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
Spacer()
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
}
private func isShown(_ channel: LiveChannel) -> Bool {
ChannelSelection.shows(channel, selection: selection)
}
}
/// Die Regel, nach der ausgewählt wird.
///
/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der
/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
/// angehakt zu haben.
enum ChannelSelection {
static func shows(_ channel: LiveChannel, selection: Set<String>) -> Bool {
selection.isEmpty || selection.contains(channel.id)
}
static func apply(_ selection: Set<String>, to channels: [LiveChannel]) -> [LiveChannel] {
guard !selection.isEmpty else { return channels }
let kept = channels.filter { selection.contains($0.id) }
// Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine
// leere Liste stehen, ohne dass er es ändern könnte.
return kept.isEmpty ? channels : kept
}
/// Die Auswahl als Text, wie sie in den Einstellungen liegt.
static func decode(_ stored: String) -> Set<String> {
Set(stored.split(separator: "\n").map(String.init))
}
static func encode(_ selection: Set<String>) -> String {
selection.sorted().joined(separator: "\n")
}
}
+469
View File
@@ -0,0 +1,469 @@
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()
}
}
+392
View File
@@ -0,0 +1,392 @@
import SwiftUI
import HaloDesign
import MediaKit
/// 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: [LiveChannel]
let programs: [String: [LiveProgram]]
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: (LiveChannel) -> URL?
let onTune: (LiveChannel, LiveProgram?) -> Void
/// Eine Sendung vormerken — einmalig oder als ganze Reihe.
///
/// Beide optional: **nicht** jeder Server kann aufnehmen. `nil` heißt,
/// dass die Knöpfe gar nicht erst erscheinen — besser als ein Knopf, der
/// bei Plex nur eine Fehlermeldung auslöst.
var onRecord: ((LiveProgram) -> Void)?
var onRecordSeries: ((LiveProgram) -> Void)?
@Environment(\.haloAccent) 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.haloSeparator)
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(.never)
}
.background(Color.haloCanvas)
.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) },
onRecord: onRecord.map { record in { record(selection.program) } },
onRecordSeries: onRecordSeries.map { record in { record(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(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
// 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, HaloSpacing.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.haloTextMuted)
.frame(width: layout.tickWidth, alignment: .leading)
}
}
.fixedSize()
.offset(x: -scrollX)
}
.clipped()
}
.frame(height: axisHeight)
.background(Color.haloCanvas)
}
/// Was auf diesem Sender gerade läuft.
private func running(on channel: LiveChannel) -> LiveProgram? {
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: HaloSpacing.snug) {
HaloArtwork(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(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
if let number = channel.number {
Text(number)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.haloTextMuted)
}
}
Spacer(minLength: 0)
if tuning == channel.id {
ProgressView().controlSize(.mini)
}
}
.padding(.horizontal, HaloSpacing.snug)
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
.overlay(alignment: .bottom) { rowSeparator }
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
.frame(width: channelWidth)
.background(Color.haloElevated.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(.never)
.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: LiveChannel) -> some View {
let entries = programs[channel.id] ?? []
return ZStack(alignment: .topLeading) {
if entries.isEmpty {
Text("liveTV.noProgram")
.font(.haloCaption)
.foregroundStyle(Color.haloTextMuted)
.padding(.horizontal, HaloSpacing.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.haloSeparator)
.frame(height: 1)
}
private func block(_ program: LiveProgram, on channel: LiveChannel) -> 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(.haloCaption)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.haloTextMuted)
}
.padding(.horizontal, HaloSpacing.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.haloElevated,
in: .rect(cornerRadius: HaloRadius.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: LiveChannel
let program: LiveProgram
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: LiveChannel
let artworkURL: URL?
let program: LiveProgram
let isRunning: Bool
let onTune: () -> Void
let onRecord: (() -> Void)?
let onRecordSeries: (() -> Void)?
@Environment(\.haloAccent) private var accent
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(channel.title)
.font(.haloCardTitle)
.foregroundStyle(Color.haloTextPrimary)
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
Spacer()
}
Text(program.title)
.font(.haloRowHeader)
.foregroundStyle(Color.haloTextPrimary)
if let subtitle = program.subtitle {
Text(subtitle)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
if isRunning {
Button { onTune(); dismiss() } label: {
Label("liveTV.tune", systemImage: "play.fill")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
}
// Vormerken steht auch bei einer laufenden Sendung: der
// Rest davon lässt sich noch aufnehmen. Bei einem Server
// ohne Aufnahmen fehlt hier schlicht alles.
if program.seriesTimerID != nil {
Label("liveTV.alreadyScheduled", systemImage: "checkmark.circle")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
} else if onRecord != nil || onRecordSeries != nil {
HStack(spacing: HaloSpacing.regular) {
if let onRecord {
Button { onRecord(); dismiss() } label: {
Label("liveTV.record", systemImage: "record.circle")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
if let onRecordSeries {
Button { onRecordSeries(); dismiss() } label: {
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
.frame(maxWidth: .infinity)
.padding(.vertical, HaloSpacing.snug)
}
.buttonStyle(.bordered)
}
}
}
if let summary = program.summary, !summary.isEmpty {
Text(summary)
.font(.haloBody)
.foregroundStyle(Color.haloTextSecondary)
}
}
.padding(HaloSpacing.screenMargin)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(Color.haloCanvas)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("action.close") { dismiss() }
}
}
}
.haloResizableSheet()
}
}
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
import HaloDesign
import MediaKit
/// Aufnahmen und was noch vorgemerkt ist.
///
/// Der dritte Modus neben Raster und Senderliste — und der einzige, den es
/// nicht überall gibt. Wer hierher kommt, hat einen Server, der aufnehmen
/// kann; bei den anderen fehlt schon der Schalter dorthin.
///
/// Beides in **einer** Liste statt in zwei Ansichten: „was liegt da" und „was
/// kommt noch" sind dieselbe Frage in zwei Zeitformen, und wer die eine
/// stellt, stellt meist gleich die andere mit.
struct RecordingsView: View {
@Environment(HaloSession.self) private var session
@State private var recordings: [LiveRecording] = []
@State private var timers: [LiveTimer] = []
@State private var seriesTimers: [LiveSeriesTimer] = []
@State private var state: LoadState<Bool> = .loading
/// Was gerade gelöscht werden soll — die Rückfrage hängt daran.
@State private var pendingDeletion: LiveRecording?
var body: some View {
Group {
switch state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("action.retry") { Task { await load() } }
}
case .loaded where recordings.isEmpty && timers.isEmpty && seriesTimers.isEmpty:
ContentUnavailableView {
Label("liveTV.recordings.empty.title", systemImage: "recordingtape")
} description: {
Text("liveTV.recordings.empty.description")
}
case .loaded:
list
}
}
.background(Color.haloCanvas)
.task { await load() }
.refreshable { await load() }
.confirmationDialog(
Text("liveTV.deleteRecording.confirm"),
isPresented: Binding(
get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }
),
titleVisibility: .visible
) {
Button("action.delete", role: .destructive) {
if let target = pendingDeletion { Task { await delete(target) } }
}
Button("action.cancel", role: .cancel) { pendingDeletion = nil }
} message: {
// Der Name gehört in die Rückfrage: „Wirklich löschen?" allein
// beantwortet nicht, *was* — und eine Aufnahme ist weg, wenn sie
// weg ist.
if let pendingDeletion { Text(pendingDeletion.title) }
}
}
private var list: some View {
List {
if !recordings.isEmpty {
Section {
ForEach(recordings) { recording in
// Über die Detailseite statt unmittelbar abspielen:
// eine Aufnahme ist auf dem Server ein gewöhnlicher
// Eintrag, und dort steht, in welchen Fassungen sie
// vorliegt. Von hier aus zu starten hieße, das zu
// raten.
NavigationLink(value: MediaRoute(ratingKey: recording.id, title: recording.title)) {
row(recording)
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button(role: .destructive) { pendingDeletion = recording } label: {
Label("action.delete", systemImage: "trash")
}
}
}
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.recordings")
}
}
if !timers.isEmpty || !seriesTimers.isEmpty {
Section {
ForEach(seriesTimers) { timer in
seriesRow(timer)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await cancelSeries(timer) }
} label: {
Label("liveTV.cancelTimer", systemImage: "xmark")
}
}
}
ForEach(timers) { timer in
timerRow(timer)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await cancel(timer) }
} label: {
Label("liveTV.cancelTimer", systemImage: "xmark")
}
}
}
.listRowBackground(Color.haloSurface)
} header: {
Text("liveTV.scheduled")
}
.listRowBackground(Color.haloSurface)
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.haloRevealsTopChrome()
}
// MARK: - Zeilen
private func row(_ recording: LiveRecording) -> some View {
HStack(spacing: HaloSpacing.regular) {
HaloArtwork(
url: session.backend?.artworkURL(path: recording.artworkPath, width: 200, height: 120),
aspectRatio: 16 / 9,
fallbackSymbol: "recordingtape",
contentMode: .fit
)
.frame(width: 64, height: 36)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
Text(recording.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
HStack(spacing: HaloSpacing.tight) {
if recording.isInProgress {
// Eine laufende Aufnahme ist keine fertige: sie wächst
// noch, und wer sie jetzt öffnet, sieht ein Ende, das
// nicht das Ende ist.
Label("liveTV.recording.inProgress", systemImage: "record.circle")
.font(.haloCaption)
.foregroundStyle(Color.haloDestructive)
}
if let subtitle = subtitle(for: recording) {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
private func timerRow(_ timer: LiveTimer) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
Text(subtitle)
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
HStack(spacing: HaloSpacing.snug) {
Image(systemName: "rectangle.stack.badge.record")
.foregroundStyle(Color.haloTextSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(timer.name)
.font(.haloBody)
.foregroundStyle(Color.haloTextPrimary)
.lineLimit(1)
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
.font(.haloCaption)
.foregroundStyle(Color.haloTextSecondary)
}
}
}
/// Sender und Beginn in einer Zeile — was davon vorhanden ist.
private func subtitle(for recording: LiveRecording) -> String? {
subtitle(channel: recording.channelName, start: recording.startsAt)
}
private func subtitle(channel: String?, start: Date?) -> String? {
var pieces: [String] = []
if let channel { pieces.append(channel) }
if let start { pieces.append(start.formatted(date: .abbreviated, time: .shortened)) }
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
// MARK: - Laden und Ändern
private func load() async {
guard let recorder = session.recorder else {
state = .loaded(true)
return
}
do {
// Nebenläufig: drei Abfragen, die nichts voneinander wissen.
// Nacheinander wäre die Wartezeit ihre Summe.
async let fetchedRecordings = recorder.recordings()
async let fetchedTimers = recorder.timers()
async let fetchedSeries = recorder.seriesTimers()
recordings = try await fetchedRecordings
timers = try await fetchedTimers
seriesTimers = try await fetchedSeries
state = .loaded(true)
} catch {
state = .failed(message: ErrorText.describe(error))
}
}
private func delete(_ recording: LiveRecording) async {
pendingDeletion = nil
guard let recorder = session.recorder else { return }
try? await recorder.deleteRecording(id: recording.id)
await load()
}
private func cancel(_ timer: LiveTimer) async {
guard let recorder = session.recorder else { return }
try? await recorder.cancelTimer(id: timer.id)
await load()
}
private func cancelSeries(_ timer: LiveSeriesTimer) async {
guard let recorder = session.recorder else { return }
try? await recorder.cancelSeriesTimer(id: timer.id)
await load()
}
}