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