Aufnehmen, was noch gar nicht läuft

Der dritte Modus neben Raster und Senderliste — und der einzige, den es
nicht überall gibt. Bei Plex fehlt schlicht der Schalter dorthin, denn
supportsRecordings fragt die eigene Fähigkeit ab.

Vormerken sitzt im Blatt, das eine angetippte Sendung ohnehin schon
öffnet, statt in einem zweiten Kontextmenü daneben. Trägt die Sendung
bereits einen Serien-Timer, steht dort das statt der Knöpfe: zweimal
vormerken ist kein Wunsch, sondern ein Versehen.

Aufnahmen führen über die Detailseite statt unmittelbar in den Player.
Eine Aufnahme ist auf dem Server ein gewöhnlicher Eintrag, und erst dort
steht, in welchen Fassungen sie vorliegt — von der Liste aus zu starten
hieße, das zu raten.

Zwölf Schlüssel in DE und EN. Die Übersetzungsdatei ist dabei auf das
Format zurückgeschrieben worden, das auch im letzten Commit steht;
inhaltlich ist sie HEAD plus diese zwölf, nachgerechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 06:26:34 +02:00
co-authored by Claude Opus 5
parent eebe9280cb
commit 982ae83e76
4 changed files with 614 additions and 59 deletions
+246 -44
View File
@@ -5,6 +5,10 @@
"comment" : "A separator between different parts of the episode metadata.", "comment" : "A separator between different parts of the episode metadata.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"%@ GB" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
"isCommentAutoGenerated" : true
},
"%@ → %@" : { "%@ → %@" : {
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@@ -17,8 +21,8 @@
} }
} }
}, },
"%@ GB" : { "1080p" : {
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", "comment" : "A label for a 1080p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"4K" : { "4K" : {
@@ -29,8 +33,8 @@
"comment" : "A label for a 720p video.", "comment" : "A label for a 720p video.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"1080p" : { "SD" : {
"comment" : "A label for a 1080p video.", "comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"account.hint" : { "account.hint" : {
@@ -1526,9 +1530,7 @@
} }
} }
}, },
"episode.number %@" : { "episode.number %@" : {},
},
"episode.short %lld %lld" : { "episode.short %lld %lld" : {
"comment" : "Staffel- und Folgennummer", "comment" : "Staffel- und Folgennummer",
"extractionState" : "stale", "extractionState" : "stale",
@@ -2493,6 +2495,57 @@
} }
} }
}, },
"liveTV.alreadyScheduled" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zur Aufnahme vorgemerkt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scheduled to record"
}
}
}
},
"liveTV.anyChannel" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auf allen Sendern"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "On any channel"
}
}
}
},
"liveTV.cancelTimer" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Absagen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cancel"
}
}
}
},
"liveTV.channelCount %@" : { "liveTV.channelCount %@" : {
"comment" : "A label displaying the number of channels. The argument is the number of channels.", "comment" : "A label displaying the number of channels. The argument is the number of channels.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -2515,6 +2568,23 @@
} }
} }
}, },
"liveTV.deleteRecording.confirm" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aufnahme löschen?"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delete recording?"
}
}
}
},
"liveTV.gap" : { "liveTV.gap" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -2716,6 +2786,108 @@
} }
} }
}, },
"liveTV.record" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record"
}
}
}
},
"liveTV.recordSeries" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serie aufnehmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Record series"
}
}
}
},
"liveTV.recording.inProgress" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nimmt auf"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recording"
}
}
}
},
"liveTV.recordings" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aufnahmen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recordings"
}
}
}
},
"liveTV.recordings.empty.description" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Programm eine Sendung antippen und „Aufnehmen“ wählen — sie steht dann hier."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tap a programme in the guide and choose “Record” — it will show up here."
}
}
}
},
"liveTV.recordings.empty.title" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Noch nichts aufgenommen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nothing recorded yet"
}
}
}
},
"liveTV.runningNow" : { "liveTV.runningNow" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -2733,6 +2905,23 @@
} }
} }
}, },
"liveTV.scheduled" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Geplant"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scheduled"
}
}
}
},
"liveTV.selection" : { "liveTV.selection" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2782,6 +2971,23 @@
} }
} }
}, },
"liveTV.series" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ganze Reihe"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Whole series"
}
}
}
},
"liveTV.timeRange %@ %@" : { "liveTV.timeRange %@ %@" : {
"comment" : "Platzhalter: Anfangs- und Endzeit", "comment" : "Platzhalter: Anfangs- und Endzeit",
"localizations" : { "localizations" : {
@@ -3250,6 +3456,23 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.subtitlesOff" : { "player.subtitlesOff" : {
"comment" : "Eintrag, der die Untertitel abschaltet", "comment" : "Eintrag, der die Untertitel abschaltet",
"extractionState" : "stale", "extractionState" : "stale",
@@ -3268,23 +3491,6 @@
} }
} }
}, },
"player.subtitleTracks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Subtitles"
}
}
}
},
"player.tracks" : { "player.tracks" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3368,6 +3574,22 @@
} }
} }
}, },
"quality.autoShort" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
}
}
},
"quality.automatic" : { "quality.automatic" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -3385,22 +3607,6 @@
} }
} }
}, },
"quality.autoShort" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto"
}
}
}
},
"quality.current" : { "quality.current" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -3724,10 +3930,6 @@
} }
} }
}, },
"SD" : {
"comment" : "A label for a 720p movie.",
"isCommentAutoGenerated" : true
},
"search.hint" : { "search.hint" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
+69 -13
View File
@@ -20,12 +20,16 @@ struct LiveTVView: View {
@State private var isTuning: String? @State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg. /// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
@State private var tuneFailure: TuneFailure? @State private var tuneFailure: TuneFailure?
/// Raster oder Senderliste. /// Raster, Senderliste oder Aufnahmen.
/// ///
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern, /// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil /// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
/// niemand sie zweimal treffen will. /// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
@AppStorage("liveTV.showsGuide") private var showsGuide = true ///
/// 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. /// Welche Sender gezeigt werden. Leer heißt alle.
@AppStorage("liveTV.selection") private var storedSelection = "" @AppStorage("liveTV.selection") private var storedSelection = ""
@State private var showsSelection = false @State private var showsSelection = false
@@ -61,7 +65,7 @@ struct LiveTVView: View {
Button("action.retry") { Task { await load() } } Button("action.retry") { Task { await load() } }
} }
case .available where channels.isEmpty: case .available where channels.isEmpty && mode != .recordings:
// Empfänger gefunden, aber keine Sender. Ein anderer // Empfänger gefunden, aber keine Sender. Ein anderer
// Zustand als „nicht eingerichtet", und die Unterscheidung // Zustand als „nicht eingerichtet", und die Unterscheidung
// zählt: sie sagt, ob es am Server liegt oder an der App. // zählt: sie sagt, ob es am Server liegt oder an der App.
@@ -77,7 +81,8 @@ struct LiveTVView: View {
} }
case .available: case .available:
if showsGuide { switch mode {
case .guide:
ProgramGuide( ProgramGuide(
channels: shownChannels, channels: shownChannels,
programs: programs, programs: programs,
@@ -85,10 +90,20 @@ struct LiveTVView: View {
artwork: { channel in artwork: { channel in
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120) session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
}, },
onTune: { channel, program in Task { await tune(channel, program: program) } } 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
) )
} else { case .channels:
channelList channelList
case .recordings:
RecordingsView()
} }
} }
} }
@@ -106,16 +121,27 @@ struct LiveTVView: View {
.accessibilityLabel(Text("liveTV.selection")) .accessibilityLabel(Text("liveTV.selection"))
} }
ToolbarItem(placement: .flixyTrailing) { ToolbarItem(placement: .flixyTrailing) {
Picker("liveTV.mode", selection: $showsGuide) { Picker("liveTV.mode", selection: $mode) {
Image(systemName: "calendar.day.timeline.left").tag(true) Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
Image(systemName: "list.bullet").tag(false) 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) .pickerStyle(.segmented)
.labelsHidden() .labelsHidden()
} }
} }
} }
.task { await load() } .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() } .refreshable { await load() }
.sheet(isPresented: $showsSelection) { .sheet(isPresented: $showsSelection) {
ChannelSelectionSheet( ChannelSelectionSheet(
@@ -264,6 +290,36 @@ struct LiveTVView: View {
subscriptionKey: result.subscriptionKey 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. /// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
+40 -1
View File
@@ -26,6 +26,13 @@ struct ProgramGuide: View {
/// Blick darauf nicht zu finden. /// Blick darauf nicht zu finden.
let artwork: (LiveChannel) -> URL? let artwork: (LiveChannel) -> URL?
let onTune: (LiveChannel, LiveProgram?) -> Void 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(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@@ -65,7 +72,9 @@ struct ProgramGuide: View {
artworkURL: artwork(selection.channel), artworkURL: artwork(selection.channel),
program: selection.program, program: selection.program,
isRunning: selection.program.isRunning(at: now), isRunning: selection.program.isRunning(at: now),
onTune: { onTune(selection.channel, selection.program) } 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 // Die Fortschrittsbalken und die Jetzt-Linie stimmen sonst nach ein
@@ -287,6 +296,8 @@ private struct ProgramDetailSheet: View {
let program: LiveProgram let program: LiveProgram
let isRunning: Bool let isRunning: Bool
let onTune: () -> Void let onTune: () -> Void
let onRecord: (() -> Void)?
let onRecordSeries: (() -> Void)?
@Environment(\.flixyAccent) private var accent @Environment(\.flixyAccent) private var accent
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -332,6 +343,34 @@ private struct ProgramDetailSheet: View {
.tint(accent) .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(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
} else if onRecord != nil || onRecordSeries != nil {
HStack(spacing: FlixySpacing.regular) {
if let onRecord {
Button { onRecord(); dismiss() } label: {
Label("liveTV.record", systemImage: "record.circle")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.bordered)
}
if let onRecordSeries {
Button { onRecordSeries(); dismiss() } label: {
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
.frame(maxWidth: .infinity)
.padding(.vertical, FlixySpacing.snug)
}
.buttonStyle(.bordered)
}
}
}
if let summary = program.summary, !summary.isEmpty { if let summary = program.summary, !summary.isEmpty {
Text(summary) Text(summary)
.font(.flixyBody) .font(.flixyBody)
+258
View File
@@ -0,0 +1,258 @@
import SwiftUI
import FlixyDesign
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(FlixySession.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.flixyCanvas)
.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.flixySurface)
} 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.flixySurface)
} header: {
Text("liveTV.scheduled")
}
.listRowBackground(Color.flixySurface)
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.flixyRevealsTopChrome()
}
// MARK: - Zeilen
private func row(_ recording: LiveRecording) -> some View {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
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(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
HStack(spacing: FlixySpacing.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(.flixyCaption)
.foregroundStyle(Color.flixyDestructive)
}
if let subtitle = subtitle(for: recording) {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(1)
}
}
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
private func timerRow(_ timer: LiveTimer) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(timer.title)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
if let subtitle = subtitle(channel: timer.channelName, start: timer.startsAt) {
Text(subtitle)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
}
}
private func seriesRow(_ timer: LiveSeriesTimer) -> some View {
HStack(spacing: FlixySpacing.snug) {
Image(systemName: "rectangle.stack.badge.record")
.foregroundStyle(Color.flixyTextSecondary)
VStack(alignment: .leading, spacing: 2) {
Text(timer.name)
.font(.flixyBody)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
Text(timer.recordsAnyChannel ? "liveTV.anyChannel" : "liveTV.series")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
}
}
/// 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()
}
}