diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 01342d1..6ef509d 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -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" -} \ No newline at end of file +} diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift index f5b3820..ba871b2 100644 --- a/Flixy/Views/LiveTV/LiveTVView.swift +++ b/Flixy/Views/LiveTV/LiveTVView.swift @@ -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. diff --git a/Flixy/Views/LiveTV/ProgramGuide.swift b/Flixy/Views/LiveTV/ProgramGuide.swift index e9f5d41..c559bb2 100644 --- a/Flixy/Views/LiveTV/ProgramGuide.swift +++ b/Flixy/Views/LiveTV/ProgramGuide.swift @@ -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) diff --git a/Flixy/Views/LiveTV/RecordingsView.swift b/Flixy/Views/LiveTV/RecordingsView.swift new file mode 100644 index 0000000..a8916ad --- /dev/null +++ b/Flixy/Views/LiveTV/RecordingsView.swift @@ -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 = .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() + } +}