Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
10 KiB
Swift
259 lines
10 KiB
Swift
import SwiftUI
|
|
import FlaroDesign
|
|
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(FlaroSession.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.flaroCanvas)
|
|
.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.flaroSurface)
|
|
} 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.flaroSurface)
|
|
} header: {
|
|
Text("liveTV.scheduled")
|
|
}
|
|
.listRowBackground(Color.flaroSurface)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.scrollContentBackground(.hidden)
|
|
.flaroRevealsTopChrome()
|
|
}
|
|
|
|
// MARK: - Zeilen
|
|
|
|
private func row(_ recording: LiveRecording) -> some View {
|
|
HStack(spacing: FlaroSpacing.regular) {
|
|
FlaroArtwork(
|
|
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(.flaroBody)
|
|
.foregroundStyle(Color.flaroTextPrimary)
|
|
.lineLimit(1)
|
|
|
|
HStack(spacing: FlaroSpacing.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(.flaroCaption)
|
|
.foregroundStyle(Color.flaroDestructive)
|
|
}
|
|
if let subtitle = subtitle(for: recording) {
|
|
Text(subtitle)
|
|
.font(.flaroCaption)
|
|
.foregroundStyle(Color.flaroTextSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.contentShape(.rect)
|
|
}
|
|
|
|
private func timerRow(_ timer: LiveTimer) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(timer.title)
|
|
.font(.flaroBody)
|
|
.foregroundStyle(Color.flaroTextPrimary)
|
|
.lineLimit(1)
|
|
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
|
|
Text(subtitle)
|
|
.font(.flaroCaption)
|
|
.foregroundStyle(Color.flaroTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
|
|
HStack(spacing: FlaroSpacing.snug) {
|
|
Image(systemName: "rectangle.stack.badge.record")
|
|
.foregroundStyle(Color.flaroTextSecondary)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(timer.name)
|
|
.font(.flaroBody)
|
|
.foregroundStyle(Color.flaroTextPrimary)
|
|
.lineLimit(1)
|
|
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
|
|
.font(.flaroCaption)
|
|
.foregroundStyle(Color.flaroTextSecondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
}
|