Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Welche Sender überhaupt angezeigt werden.
|
||||
///
|
||||
/// Der Empfänger liefert alles, was die Senderliste hergibt — bei einer
|
||||
/// Swisscom-Liste sind das mehr als zwanzig, und die meisten sieht man nie an.
|
||||
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
|
||||
/// die Ansicht unübersichtlich.
|
||||
struct ChannelSelectionSheet: View {
|
||||
let channels: [LiveChannel]
|
||||
@Binding var selection: Set<String>
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
ForEach(channels) { channel in
|
||||
row(for: channel)
|
||||
}
|
||||
} footer: {
|
||||
Text("liveTV.selection.hint")
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text("liveTV.selection"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .haloLeading) {
|
||||
// „Alle" statt „keine": eine leere Auswahl bedeutet
|
||||
// ohnehin alle, und ein Knopf, der scheinbar nichts tut,
|
||||
// wäre verwirrend.
|
||||
Button("liveTV.selection.all") { selection.removeAll() }
|
||||
.disabled(selection.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func row(for channel: LiveChannel) -> some View {
|
||||
Button {
|
||||
// Beim ersten Antippen aus „alle" eine echte Auswahl machen:
|
||||
// sonst hakt man einen Sender an und alle anderen bleiben
|
||||
// trotzdem sichtbar.
|
||||
if selection.isEmpty {
|
||||
selection = Set(channels.map(\.id)).subtracting([channel.id])
|
||||
} else if selection.contains(channel.id) {
|
||||
selection.remove(channel.id)
|
||||
} else {
|
||||
selection.insert(channel.id)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: HaloAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 48, height: 29)
|
||||
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
Text(channel.title)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isShown(channel) ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isShown(channel) ? accent : Color.haloTextMuted)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func isShown(_ channel: LiveChannel) -> Bool {
|
||||
ChannelSelection.shows(channel, selection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Regel, nach der ausgewählt wird.
|
||||
///
|
||||
/// Eigener Typ, weil sie an zwei Stellen gilt — in der Auswahl und in der
|
||||
/// Anzeige — und beide dasselbe meinen müssen. Eine **leere** Auswahl bedeutet
|
||||
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
|
||||
/// angehakt zu haben.
|
||||
enum ChannelSelection {
|
||||
static func shows(_ channel: LiveChannel, selection: Set<String>) -> Bool {
|
||||
selection.isEmpty || selection.contains(channel.id)
|
||||
}
|
||||
|
||||
static func apply(_ selection: Set<String>, to channels: [LiveChannel]) -> [LiveChannel] {
|
||||
guard !selection.isEmpty else { return channels }
|
||||
let kept = channels.filter { selection.contains($0.id) }
|
||||
// Wählt jemand Sender ab, die es nicht mehr gibt, bliebe sonst eine
|
||||
// leere Liste stehen, ohne dass er es ändern könnte.
|
||||
return kept.isEmpty ? channels : kept
|
||||
}
|
||||
|
||||
/// Die Auswahl als Text, wie sie in den Einstellungen liegt.
|
||||
static func decode(_ stored: String) -> Set<String> {
|
||||
Set(stored.split(separator: "\n").map(String.init))
|
||||
}
|
||||
|
||||
static func encode(_ selection: Set<String>) -> String {
|
||||
selection.sorted().joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import HaloPlayback
|
||||
import MediaKit
|
||||
|
||||
/// Live-TV mit Programmführer.
|
||||
struct LiveTVView: View {
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(PlaybackCoordinator.self) private var playback
|
||||
@Environment(PlaybackSettings.self) private var settings
|
||||
@Environment(NetworkConditions.self) private var network
|
||||
|
||||
@State private var availability: LiveTVAvailability?
|
||||
@State private var channels: [LiveChannel] = []
|
||||
/// Woher die Senderliste kam — steht in der leeren Ansicht.
|
||||
@State private var channelSource: String?
|
||||
/// Jede abgeklopfte Adresse mit ihrer Antwort.
|
||||
@State private var trace: [LiveChannelProbe] = []
|
||||
@State private var programs: [String: [LiveProgram]] = [:]
|
||||
@State private var isTuning: String?
|
||||
/// Warum das Einschalten nicht geklappt hat — mit jedem versuchten Weg.
|
||||
@State private var tuneFailure: TuneFailure?
|
||||
/// Raster, Senderliste oder Aufnahmen.
|
||||
///
|
||||
/// 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
|
||||
|
||||
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
|
||||
/// Liste nicht auseinanderlaufen.
|
||||
private var shownChannels: [LiveChannel] {
|
||||
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch availability {
|
||||
case nil:
|
||||
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
case .notConfigured:
|
||||
// Der häufigste Fall — Live-TV verlangt eine Fernsehkarte oder
|
||||
// einen Netzwerk-Tuner am Server. Ein Fehlerdialog wäre hier
|
||||
// irreführend: es ist nichts kaputt, es ist nur nichts da.
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.notConfigured.title", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
} description: {
|
||||
Text("liveTV.notConfigured.description")
|
||||
}
|
||||
|
||||
case .failed(let message):
|
||||
ContentUnavailableView {
|
||||
Label("error.title", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(message)
|
||||
} actions: {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
}
|
||||
|
||||
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.
|
||||
ContentUnavailableView {
|
||||
Label("liveTV.noChannels.title", systemImage: "tv")
|
||||
} description: {
|
||||
Text("liveTV.noChannels.description")
|
||||
} actions: {
|
||||
VStack(spacing: HaloSpacing.regular) {
|
||||
Button("action.retry") { Task { await load() } }
|
||||
searchTrace
|
||||
}
|
||||
}
|
||||
|
||||
case .available:
|
||||
switch mode {
|
||||
case .guide:
|
||||
ProgramGuide(
|
||||
channels: shownChannels,
|
||||
programs: programs,
|
||||
tuning: isTuning,
|
||||
artwork: { channel in
|
||||
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
|
||||
},
|
||||
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
|
||||
)
|
||||
case .channels:
|
||||
channelList
|
||||
case .recordings:
|
||||
RecordingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("sidebar.liveTV"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
if case .available = availability, !channels.isEmpty {
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
Button {
|
||||
showsSelection = true
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
.accessibilityLabel(Text("liveTV.selection"))
|
||||
}
|
||||
ToolbarItem(placement: .haloTrailing) {
|
||||
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 {
|
||||
// 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(
|
||||
channels: channels,
|
||||
selection: Binding(
|
||||
get: { ChannelSelection.decode(storedSelection) },
|
||||
set: { storedSelection = ChannelSelection.encode($0) }
|
||||
)
|
||||
)
|
||||
}
|
||||
// Ein Sheet statt eines Dialogs: die Antwort des Servers ist
|
||||
// mehrzeilig, und ein Dialog schneidet sie ab — dann steht dort
|
||||
// wieder nur, dass es nicht ging.
|
||||
.sheet(item: $tuneFailure) { failure in
|
||||
TuneFailureSheet(failure: failure)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wo überall gesucht wurde, und was dort stand.
|
||||
///
|
||||
/// Unbequem, aber nützlich: die Live-TV-Schnittstelle von Plex hat keine
|
||||
/// Beschreibung, und „keine Sender" allein ist eine Sackgasse. Hier steht
|
||||
/// jede abgeklopfte Adresse mit ihrer Antwort — das beantwortet die Frage
|
||||
/// „liegt es am Server oder an der App" ohne eine weitere Runde.
|
||||
@ViewBuilder
|
||||
private var searchTrace: some View {
|
||||
if !trace.isEmpty {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.snug) {
|
||||
if let channelSource {
|
||||
Text(channelSource)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(trace) { probe in
|
||||
Text("\(probe.path) → \(probe.result)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(2)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 200)
|
||||
|
||||
Button {
|
||||
HaloClipboard.copy(
|
||||
trace
|
||||
.map { "\($0.path) → \($0.result)" }
|
||||
.joined(separator: "\n")
|
||||
)
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
}
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(shownChannels) { channel in
|
||||
ChannelRow(
|
||||
channel: channel,
|
||||
programs: programs[channel.id] ?? [],
|
||||
isTuning: isTuning == channel.id,
|
||||
onTune: { Task { await tune(channel) } }
|
||||
)
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloRevealsTopChrome()
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard let liveTV = session.liveTV else { return }
|
||||
|
||||
let availability = await liveTV.liveTVAvailability()
|
||||
self.availability = availability
|
||||
|
||||
guard case .available = availability else { return }
|
||||
|
||||
// Sender und Programm kommen zusammen: die Senderliste des Anbieters
|
||||
// antwortet auf manchen Servern gar nicht, und dann tragen die
|
||||
// Rastereinträge die Sender selbst. Getrennt geholt passten die
|
||||
// Kennungen zudem nicht zueinander, und das Programm landete in
|
||||
// keiner Zeile.
|
||||
// Sechs Stunden: ein ganzer Fernsehabend. Mehr wäre bei einer Liste
|
||||
// mit fünfhundert Sendern eine Antwort im zweistelligen Megabereich,
|
||||
// und über Mobilfunk wartet man darauf.
|
||||
let listing = await liveTV.liveChannels()
|
||||
channels = listing.channels
|
||||
channelSource = listing.source
|
||||
trace = listing.probes
|
||||
|
||||
let now = Date()
|
||||
programs = (try? await liveTV.livePrograms(
|
||||
channelIDs: listing.channels.map(\.id),
|
||||
from: now.addingTimeInterval(-3_600),
|
||||
to: now.addingTimeInterval(6 * 3_600)
|
||||
)) ?? [:]
|
||||
}
|
||||
|
||||
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
|
||||
guard let liveTV = session.liveTV, isTuning == nil else { return }
|
||||
isTuning = channel.id
|
||||
defer { isTuning = nil }
|
||||
|
||||
// Die laufende Sendung mitgeben: über ihre Adresse beim
|
||||
// Programmanbieter führt der direkte Weg zum Stream.
|
||||
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
|
||||
let policy = settings.policy(for: network)
|
||||
|
||||
let result = await liveTV.tune(
|
||||
channel: channel,
|
||||
program: running,
|
||||
maximumBitrate: policy.maximumBitrate
|
||||
)
|
||||
|
||||
guard let stream = result.url else {
|
||||
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
|
||||
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
|
||||
tuneFailure = TuneFailure(
|
||||
channel: channel.title,
|
||||
reason: String(localized: "liveTV.noStream"),
|
||||
trace: result.trace.map { "\($0.path)\n → \($0.result)" }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Live-TV läuft über denselben Player wie alles andere, bekommt aber
|
||||
// die fertige Adresse mit: nachschlagen ließe sich ein Sender nicht.
|
||||
playback.playLive(
|
||||
channelID: channel.id,
|
||||
title: channel.title,
|
||||
programme: running?.title,
|
||||
programmeEndsAt: running?.endsAt,
|
||||
stream: stream,
|
||||
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.
|
||||
private struct ChannelRow: View {
|
||||
let channel: LiveChannel
|
||||
let programs: [LiveProgram]
|
||||
let isTuning: Bool
|
||||
let onTune: () -> Void
|
||||
|
||||
@Environment(HaloSession.self) private var session
|
||||
@Environment(\.haloAccent) private var accent
|
||||
|
||||
private var current: LiveProgram? { programs.first { $0.isRunning() } }
|
||||
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTune) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
// Breiter als hoch und ohne Beschneiden: Sendersymbole sind
|
||||
// fast nie quadratisch, und formatfüllend fehlten links und
|
||||
// rechts genau die Teile, an denen man den Sender erkennt.
|
||||
HaloArtwork(
|
||||
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
|
||||
aspectRatio: HaloAspectRatio.channelLogo,
|
||||
fallbackSymbol: "tv",
|
||||
contentMode: .fit
|
||||
)
|
||||
.frame(width: 56, height: 34)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
Text(channel.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let current {
|
||||
Text(current.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
.lineLimit(1)
|
||||
// Der Balken zeigt, wie weit die Sendung ist — beim
|
||||
// Umschalten die nützlichste Angabe überhaupt.
|
||||
WatchProgressBar(fraction: current.progress())
|
||||
.padding(.top, 2)
|
||||
} else {
|
||||
Text("liveTV.noProgram")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
|
||||
if let next {
|
||||
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isTuning {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.screenMargin)
|
||||
.padding(.vertical, HaloSpacing.regular)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Was beim Einschalten schiefging, in voller Länge.
|
||||
struct TuneFailure: Identifiable {
|
||||
let id = UUID()
|
||||
let channel: String
|
||||
let reason: String
|
||||
/// Jeder versuchte Weg mit der Antwort des Servers.
|
||||
let trace: [String]
|
||||
}
|
||||
|
||||
/// Zeigt die Antwort des Servers, statt sie zu verschlucken.
|
||||
///
|
||||
/// Die Live-Schnittstelle von Plex ist nirgends beschrieben. Ohne den
|
||||
/// Wortlaut dessen, was der Server abgelehnt hat, bleibt nur Raten — und
|
||||
/// jede Runde davon kostet Tage.
|
||||
private struct TuneFailureSheet: View {
|
||||
let failure: TuneFailure
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
Text(failure.channel)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
Text(failure.reason)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
|
||||
ForEach(Array(failure.trace.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Button {
|
||||
HaloClipboard.copy(failure.trace.joined(separator: "\n"))
|
||||
} label: {
|
||||
Label("diagnostics.copy", systemImage: "doc.on.doc")
|
||||
.font(.haloCaption)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(HaloSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.navigationTitle(Text("liveTV.tuneFailed"))
|
||||
.haloInlineTitle()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import SwiftUI
|
||||
import HaloDesign
|
||||
import MediaKit
|
||||
|
||||
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
|
||||
///
|
||||
/// Warum ein Raster und nicht eine Liste mit „läuft gerade": beim Fernsehen
|
||||
/// sucht man nicht einen Sender, sondern eine Sendung — und die findet man
|
||||
/// nur, wenn man sieht, was auf allen Sendern gleichzeitig läuft. Genau das
|
||||
/// ist die Achse.
|
||||
///
|
||||
/// Der Aufbau: die Senderspalte steht **außerhalb** des waagerechten
|
||||
/// Rollbereichs und bleibt deshalb stehen, während das Programm daran
|
||||
/// vorbeizieht. Die Zeitachse steht außerhalb des senkrechten und folgt dem
|
||||
/// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht
|
||||
/// gleichzeitig festgenagelt.
|
||||
struct ProgramGuide: View {
|
||||
let channels: [LiveChannel]
|
||||
let programs: [String: [LiveProgram]]
|
||||
let tuning: String?
|
||||
/// Die Adresse des Senderlogos.
|
||||
///
|
||||
/// Als Abschluss übergeben statt aus der Umgebung gelesen: so lässt sich
|
||||
/// das Raster außerhalb der App mit erfundenen Sendern zeichnen und
|
||||
/// ansehen. Die Senderspalte hat einmal gefehlt, und das war ohne einen
|
||||
/// 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(\.haloAccent) private var accent
|
||||
|
||||
@State private var scrollX: CGFloat = 0
|
||||
@State private var selection: GuideSelection?
|
||||
@State private var now = Date()
|
||||
|
||||
private let channelWidth: CGFloat = 116
|
||||
private let rowHeight: CGFloat = 64
|
||||
/// Eine halbe Stunde ist gut 130 Punkte breit — schmal genug, dass ein
|
||||
/// Abend auf den Schirm passt, breit genug für einen lesbaren Titel.
|
||||
private let pointsPerMinute: CGFloat = 4.4
|
||||
private let axisHeight: CGFloat = 26
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
axis
|
||||
Divider().overlay(Color.haloSeparator)
|
||||
|
||||
ScrollView(.vertical) {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Ohne Vorrang verliert die Spalte den Platzstreit gegen
|
||||
// das Raster: dessen Breite ist die volle Zeitachse —
|
||||
// mehrere tausend Punkte —, und der HStack drückt den
|
||||
// Nachbarn dafür auf null. Genau so war die Senderspalte
|
||||
// verschwunden.
|
||||
channelColumn.layoutPriority(1)
|
||||
lanes
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.sheet(item: $selection) { selection in
|
||||
ProgramDetailSheet(
|
||||
channel: selection.channel,
|
||||
artworkURL: artwork(selection.channel),
|
||||
program: selection.program,
|
||||
isRunning: selection.program.isRunning(at: now),
|
||||
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
|
||||
// paar Minuten nicht mehr. Einmal pro Minute reicht dafür.
|
||||
.task {
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(60))
|
||||
now = Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zeitfenster
|
||||
|
||||
/// Zeit in Punkte. Die Rechnungen dahinter stehen in `GuideLayout` und
|
||||
/// sind dort geprüft — hier wären sie unsichtbar falsch.
|
||||
private var layout: GuideLayout {
|
||||
GuideLayout(
|
||||
now: now,
|
||||
latestEnd: programs.values.flatMap { $0 }.map(\.endsAt).max(),
|
||||
pointsPerMinute: pointsPerMinute
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teile
|
||||
|
||||
private var axis: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text("liveTV.channelCount \(channels.count)")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
// Der Abstand gehört in die Spaltenbreite hinein, nicht
|
||||
// dazu: außen addiert er sich, und die ganze Zeitachse steht
|
||||
// acht Punkte neben dem Programm darunter.
|
||||
.padding(.leading, HaloSpacing.snug)
|
||||
.frame(width: channelWidth, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
|
||||
// Der Streifen liegt als Auflage über einer durchsichtigen
|
||||
// Fläche, statt selbst im Stapel zu stehen: eine Auflage zählt
|
||||
// bei der Größenrechnung nicht mit. Stünde er selbst darin,
|
||||
// verlangte er die Breite der ganzen Zeitachse und schöbe die
|
||||
// Beschriftung links aus dem Bild.
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .leading) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(layout.ticks, id: \.self) { tick in
|
||||
Text(tick.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.frame(width: layout.tickWidth, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.fixedSize()
|
||||
.offset(x: -scrollX)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: axisHeight)
|
||||
.background(Color.haloCanvas)
|
||||
}
|
||||
|
||||
/// Was auf diesem Sender gerade läuft.
|
||||
private func running(on channel: LiveChannel) -> LiveProgram? {
|
||||
programs[channel.id]?.first { $0.isRunning(at: now) }
|
||||
}
|
||||
|
||||
private var channelColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
Button { onTune(channel, running(on: channel)) } label: {
|
||||
HStack(spacing: HaloSpacing.snug) {
|
||||
HaloArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 30, height: 30)
|
||||
.clipShape(.rect(cornerRadius: 4))
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(channel.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
if let number = channel.number {
|
||||
Text(number)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if tuning == channel.id {
|
||||
ProgressView().controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.frame(width: channelWidth, height: rowHeight, alignment: .leading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: channelWidth)
|
||||
.background(Color.haloElevated.opacity(0.4))
|
||||
}
|
||||
|
||||
private var lanes: some View {
|
||||
ScrollView(.horizontal) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(channels) { channel in
|
||||
lane(for: channel)
|
||||
}
|
||||
}
|
||||
|
||||
// Die Jetzt-Linie läuft mit dem Inhalt mit, nicht mit dem
|
||||
// Blickfeld — sie markiert einen Zeitpunkt, keine Stelle auf
|
||||
// dem Schirm.
|
||||
Rectangle()
|
||||
.fill(accent)
|
||||
.frame(width: 2)
|
||||
.offset(x: layout.x(for: now))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(width: layout.totalWidth, alignment: .topLeading)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.frame(height: CGFloat(channels.count) * rowHeight)
|
||||
.onScrollGeometryChange(for: CGFloat.self) { geometry in
|
||||
geometry.contentOffset.x
|
||||
} action: { _, offset in
|
||||
scrollX = offset
|
||||
}
|
||||
}
|
||||
|
||||
private func lane(for channel: LiveChannel) -> some View {
|
||||
let entries = programs[channel.id] ?? []
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
if entries.isEmpty {
|
||||
Text("liveTV.noProgram")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
.padding(.horizontal, HaloSpacing.regular)
|
||||
.frame(height: rowHeight, alignment: .leading)
|
||||
}
|
||||
|
||||
ForEach(entries) { program in
|
||||
block(program, on: channel)
|
||||
.offset(x: layout.x(for: program.startsAt))
|
||||
}
|
||||
}
|
||||
.frame(width: layout.totalWidth, height: rowHeight, alignment: .topLeading)
|
||||
.overlay(alignment: .bottom) { rowSeparator }
|
||||
}
|
||||
|
||||
/// Die Trennlinie gehört in die Zeile, nicht zwischen die Zeilen.
|
||||
///
|
||||
/// `Divider()` ist auf einem 3x-Schirm keinen ganzen Punkt hoch. Zwischen
|
||||
/// den Zeilen gestapelt summiert sich dieser Bruchteil, und Senderspalte
|
||||
/// und Programm stehen nach zwanzig Sendern sichtbar versetzt — die
|
||||
/// Zeile, die man antippt, gehört dann zum Nachbarsender.
|
||||
private var rowSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color.haloSeparator)
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
private func block(_ program: LiveProgram, on channel: LiveChannel) -> some View {
|
||||
let running = program.isRunning(at: now)
|
||||
|
||||
return Button { selection = GuideSelection(channel: channel, program: program) } label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(program.title)
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
Text(program.startsAt.formatted(date: .omitted, time: .shortened))
|
||||
.font(.system(.caption2, design: .rounded))
|
||||
.foregroundStyle(Color.haloTextMuted)
|
||||
}
|
||||
.padding(.horizontal, HaloSpacing.snug)
|
||||
.padding(.vertical, 6)
|
||||
.frame(width: layout.width(from: program.startsAt, to: program.endsAt) - 2, height: rowHeight - 2, alignment: .topLeading)
|
||||
.background(
|
||||
running ? accent.opacity(0.28) : Color.haloElevated,
|
||||
in: .rect(cornerRadius: HaloRadius.control)
|
||||
)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// Der Balken zeigt, wie weit die laufende Sendung ist — beim
|
||||
// Umschalten die nützlichste Angabe überhaupt.
|
||||
if running {
|
||||
WatchProgressBar(fraction: program.progress(at: now))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Die angetippte Sendung samt ihrem Sender.
|
||||
private struct GuideSelection: Identifiable {
|
||||
let channel: LiveChannel
|
||||
let program: LiveProgram
|
||||
|
||||
var id: String { "\(channel.id)-\(program.id)" }
|
||||
}
|
||||
|
||||
/// Was zu einer Sendung im Raster gehört, in voller Länge.
|
||||
private struct ProgramDetailSheet: View {
|
||||
let channel: LiveChannel
|
||||
let artworkURL: URL?
|
||||
let program: LiveProgram
|
||||
let isRunning: Bool
|
||||
let onTune: () -> Void
|
||||
let onRecord: (() -> Void)?
|
||||
let onRecordSeries: (() -> Void)?
|
||||
|
||||
@Environment(\.haloAccent) private var accent
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: HaloSpacing.regular) {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
HaloArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
|
||||
.frame(width: 44, height: 44)
|
||||
.clipShape(.rect(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(channel.title)
|
||||
.font(.haloCardTitle)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
Text("liveTV.timeRange \(program.startsAt.formatted(date: .omitted, time: .shortened)) \(program.endsAt.formatted(date: .omitted, time: .shortened))")
|
||||
.font(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(program.title)
|
||||
.font(.haloRowHeader)
|
||||
.foregroundStyle(Color.haloTextPrimary)
|
||||
|
||||
if let subtitle = program.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
Button { onTune(); dismiss() } label: {
|
||||
Label("liveTV.tune", systemImage: "play.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.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(.haloCaption)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
} else if onRecord != nil || onRecordSeries != nil {
|
||||
HStack(spacing: HaloSpacing.regular) {
|
||||
if let onRecord {
|
||||
Button { onRecord(); dismiss() } label: {
|
||||
Label("liveTV.record", systemImage: "record.circle")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
if let onRecordSeries {
|
||||
Button { onRecordSeries(); dismiss() } label: {
|
||||
Label("liveTV.recordSeries", systemImage: "rectangle.stack.badge.record")
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, HaloSpacing.snug)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let summary = program.summary, !summary.isEmpty {
|
||||
Text(summary)
|
||||
.font(.haloBody)
|
||||
.foregroundStyle(Color.haloTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(HaloSpacing.screenMargin)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(Color.haloCanvas)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("action.close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.haloResizableSheet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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<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.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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user