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:
co-authored by
Claude Opus 5
parent
eebe9280cb
commit
982ae83e76
@@ -5,6 +5,10 @@
|
||||
"comment" : "A separator between different parts of the episode metadata.",
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -17,8 +21,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ 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.",
|
||||
"1080p" : {
|
||||
"comment" : "A label for a 1080p video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"4K" : {
|
||||
@@ -29,8 +33,8 @@
|
||||
"comment" : "A label for a 720p video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"1080p" : {
|
||||
"comment" : "A label for a 1080p video.",
|
||||
"SD" : {
|
||||
"comment" : "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"account.hint" : {
|
||||
@@ -1526,9 +1530,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"episode.number %@" : {
|
||||
|
||||
},
|
||||
"episode.number %@" : {},
|
||||
"episode.short %lld %lld" : {
|
||||
"comment" : "Staffel- und Folgennummer",
|
||||
"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 %@" : {
|
||||
"comment" : "A label displaying the number of channels. The argument is the number of channels.",
|
||||
"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" : {
|
||||
"extractionState" : "stale",
|
||||
"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" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -2733,6 +2905,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.scheduled" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Geplant"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Scheduled"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.selection" : {
|
||||
"localizations" : {
|
||||
"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 %@ %@" : {
|
||||
"comment" : "Platzhalter: Anfangs- und Endzeit",
|
||||
"localizations" : {
|
||||
@@ -3250,6 +3456,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Untertitel"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff" : {
|
||||
"comment" : "Eintrag, der die Untertitel abschaltet",
|
||||
"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" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -3368,6 +3574,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.autoShort" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.automatic" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -3385,22 +3607,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.autoShort" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.current" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3724,10 +3930,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SD" : {
|
||||
"comment" : "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"search.hint" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5961,4 +6163,4 @@
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,16 @@ struct LiveTVView: View {
|
||||
@State private var isTuning: String?
|
||||
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
||||
@State private var tuneFailure: TuneFailure?
|
||||
/// Raster oder Senderliste.
|
||||
/// Raster, Senderliste oder Aufnahmen.
|
||||
///
|
||||
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
|
||||
/// die Liste zum schnellen Umschalten. Die Wahl bleibt erhalten, weil
|
||||
/// niemand sie zweimal treffen will.
|
||||
@AppStorage("liveTV.showsGuide") private var showsGuide = true
|
||||
/// Alle drei sind berechtigt: das Raster zum Stöbern, die Liste zum
|
||||
/// schnellen Umschalten, die Aufnahmen für das, was schon da ist. Die
|
||||
/// Wahl bleibt erhalten, weil niemand sie zweimal treffen will.
|
||||
///
|
||||
/// 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.
|
||||
@AppStorage("liveTV.selection") private var storedSelection = ""
|
||||
@State private var showsSelection = false
|
||||
@@ -61,7 +65,7 @@ struct LiveTVView: View {
|
||||
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
|
||||
// Zustand als „nicht eingerichtet", und die Unterscheidung
|
||||
// zählt: sie sagt, ob es am Server liegt oder an der App.
|
||||
@@ -77,7 +81,8 @@ struct LiveTVView: View {
|
||||
}
|
||||
|
||||
case .available:
|
||||
if showsGuide {
|
||||
switch mode {
|
||||
case .guide:
|
||||
ProgramGuide(
|
||||
channels: shownChannels,
|
||||
programs: programs,
|
||||
@@ -85,10 +90,20 @@ struct LiveTVView: View {
|
||||
artwork: { channel in
|
||||
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
|
||||
case .recordings:
|
||||
RecordingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,16 +121,27 @@ struct LiveTVView: View {
|
||||
.accessibilityLabel(Text("liveTV.selection"))
|
||||
}
|
||||
ToolbarItem(placement: .flixyTrailing) {
|
||||
Picker("liveTV.mode", selection: $showsGuide) {
|
||||
Image(systemName: "calendar.day.timeline.left").tag(true)
|
||||
Image(systemName: "list.bullet").tag(false)
|
||||
Picker("liveTV.mode", selection: $mode) {
|
||||
Image(systemName: "calendar.day.timeline.left").tag(LiveTVMode.guide)
|
||||
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)
|
||||
.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() }
|
||||
.sheet(isPresented: $showsSelection) {
|
||||
ChannelSelectionSheet(
|
||||
@@ -264,6 +290,36 @@ struct LiveTVView: View {
|
||||
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.
|
||||
|
||||
@@ -26,6 +26,13 @@ struct ProgramGuide: View {
|
||||
/// Blick darauf nicht zu finden.
|
||||
let artwork: (LiveChannel) -> URL?
|
||||
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
|
||||
|
||||
@@ -65,7 +72,9 @@ struct ProgramGuide: View {
|
||||
artworkURL: artwork(selection.channel),
|
||||
program: selection.program,
|
||||
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
|
||||
@@ -287,6 +296,8 @@ private struct ProgramDetailSheet: View {
|
||||
let program: LiveProgram
|
||||
let isRunning: Bool
|
||||
let onTune: () -> Void
|
||||
let onRecord: (() -> Void)?
|
||||
let onRecordSeries: (() -> Void)?
|
||||
|
||||
@Environment(\.flixyAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -332,6 +343,34 @@ private struct ProgramDetailSheet: View {
|
||||
.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 {
|
||||
Text(summary)
|
||||
.font(.flixyBody)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user