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>
259 lines
10 KiB
Swift
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()
|
|
}
|
|
}
|