Live-TV: das Programm kam nie an, und die Einschalt-Adresse wurde weggeworfen

Drei Fehler, alle im eigenen Code.

Die Sendezeiten stehen beim Programmanbieter im Medien-Block, nicht am
Eintrag darüber. Ich habe sie oben gesucht, nicht gefunden und jede
Sendung verworfen — übrig blieb „Kein Programm verfügbar" auf jedem
Sender, und ohne Sendungen fiel auch die Senderliste auf die Tunerliste
zurück.

Die Senderspalte im Raster fehlte ganz. Die Zeitachse ist mehrere tausend
Punkte breit, und der HStack hat den Nachbarn dafür auf null gedrückt.
Jetzt hat die Spalte Vorrang, und die Achse liegt als Auflage über einer
durchsichtigen Fläche — eine Auflage zählt bei der Größenrechnung nicht
mit. Der Abstand der Achsenbeschriftung gehört in die Spaltenbreite
hinein statt daneben, sonst steht die ganze Achse acht Punkte neben dem
Programm darunter.

Und das Einschalten: die Adresse wurde berechnet und verworfen, dann
bekam der Player eine Senderkennung, zu der er einen Film in der
Mediathek suchte. Den gibt es nicht. Jetzt reicht die Anfrage die fertige
Adresse durch, der Player spielt sie ohne Umweg, und meldet weder
Fortschritt noch sucht er Metadaten. Scheitert es doch, steht ab jetzt
eine Meldung da statt gar nichts.

Geprüft wurde das Raster diesmal mit den Augen: ein Gerüst im Simulator
zeichnet es mit erfundenen Sendern. Dafür bekommt es die Bildadresse als
Abschluss übergeben, statt sie aus der Sitzung zu lesen.

Dazu: Downloads zeigen jetzt an, wenn sie auf WLAN warten. Die
Hintergrund-Sitzung wartet in dem Fall von sich aus — kein Fehler, nur
Stillstand, und das sah bisher aus wie ein hängender Download.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-12 13:34:48 +02:00
co-authored by Claude Opus 5
parent 2362865ba9
commit bbe47b83be
10 changed files with 2893 additions and 2565 deletions
+36 -10
View File
@@ -18,6 +18,8 @@ struct LiveTVView: View {
@State private var trace: [ChannelProbe] = []
@State private var programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String?
/// Warum das Einschalten nicht geklappt hat.
@State private var tuneFailure: String?
/// Raster oder Senderliste.
///
/// Auf dem Handy sind beide Ansichten berechtigt: das Raster zum Stöbern,
@@ -71,6 +73,9 @@ struct LiveTVView: View {
channels: channels,
programs: programs,
tuning: isTuning,
artwork: { channel in
session.server?.artworkURL(path: channel.thumb, width: 120, height: 120)
},
onTune: { channel, program in Task { await tune(channel, program: program) } }
)
} else {
@@ -95,6 +100,14 @@ struct LiveTVView: View {
}
.task { await load() }
.refreshable { await load() }
.alert(
Text("liveTV.tuneFailed"),
isPresented: Binding(get: { tuneFailure != nil }, set: { if !$0 { tuneFailure = nil } })
) {
Button("action.close", role: .cancel) { tuneFailure = nil }
} message: {
if let tuneFailure { Text(tuneFailure) }
}
}
/// Wo überall gesucht wurde, und was dort stand.
@@ -189,21 +202,34 @@ struct LiveTVView: View {
// Programmanbieter führt der direkte Weg zum Strom.
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
guard (try? await server.tune(
channel: channel,
program: running,
maximumBitrate: policy.maximumBitrate
)) != nil
else { return }
// Live-TV läuft über denselben Player wie alles andere. Der Schlüssel
// ist der Sender, nicht ein Titel — der Player holt sich den Strom
// über die Einschalt-Adresse.
let stream: URL?
do {
stream = try await server.tune(
channel: channel,
program: running,
maximumBitrate: policy.maximumBitrate
)
} catch {
// Vorher verschwand jeder Fehler hier lautlos, und ein Tippen auf
// den Sender blieb ohne jede Wirkung — nicht einmal eine Meldung.
tuneFailure = PlexErrorText.describe(error)
return
}
guard let stream else {
tuneFailure = String(localized: "liveTV.noStream")
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.request = PlaybackRequest(
ratingKey: channel.id,
title: channel.title,
mediaIndex: 0,
startTime: .zero
startTime: .zero,
streamURL: stream
)
}
}
+41 -25
View File
@@ -18,9 +18,15 @@ struct ProgramGuide: View {
let channels: [PlexChannel]
let programs: [String: [PlexProgram]]
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: (PlexChannel) -> URL?
let onTune: (PlexChannel, PlexProgram?) -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@State private var scrollX: CGFloat = 0
@@ -41,7 +47,12 @@ struct ProgramGuide: View {
ScrollView(.vertical) {
HStack(alignment: .top, spacing: 0) {
channelColumn
// 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
}
}
@@ -51,6 +62,7 @@ struct ProgramGuide: View {
.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) }
@@ -85,21 +97,33 @@ struct ProgramGuide: View {
Text("liveTV.channelCount \(channels.count)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.frame(width: channelWidth, alignment: .leading)
// 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, FlixySpacing.snug)
.frame(width: channelWidth, alignment: .leading)
.layoutPriority(1)
HStack(spacing: 0) {
ForEach(layout.ticks, id: \.self) { tick in
Text(tick.formatted(date: .omitted, time: .shortened))
.font(.system(.caption2, design: .rounded))
.foregroundStyle(Color.flixyTextMuted)
.frame(width: layout.tickWidth, alignment: .leading)
// 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.flixyTextMuted)
.frame(width: layout.tickWidth, alignment: .leading)
}
}
.fixedSize()
.offset(x: -scrollX)
}
}
.frame(width: layout.totalWidth, alignment: .leading)
.offset(x: -scrollX)
.frame(maxWidth: .infinity, alignment: .leading)
.clipped()
.clipped()
}
.frame(height: axisHeight)
.background(Color.flixyCanvas)
@@ -115,11 +139,7 @@ struct ProgramGuide: View {
ForEach(channels) { channel in
Button { onTune(channel, running(on: channel)) } label: {
HStack(spacing: FlixySpacing.snug) {
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
aspectRatio: 1,
fallbackSymbol: "tv"
)
FlixyArtwork(url: artwork(channel), aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 30, height: 30)
.clipShape(.rect(cornerRadius: 4))
@@ -263,11 +283,11 @@ private struct GuideSelection: Identifiable {
/// Was zu einer Sendung im Raster gehört, in voller Länge.
private struct ProgramDetailSheet: View {
let channel: PlexChannel
let artworkURL: URL?
let program: PlexProgram
let isRunning: Bool
let onTune: () -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
@Environment(\.dismiss) private var dismiss
@@ -276,11 +296,7 @@ private struct ProgramDetailSheet: View {
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 160, height: 160),
aspectRatio: 1,
fallbackSymbol: "tv"
)
FlixyArtwork(url: artworkURL, aspectRatio: 1, fallbackSymbol: "tv")
.frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 6))