Files
Flaro/Halo/Views/LiveTV/RecordingsView.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

259 lines
10 KiB
Swift

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()
}
}