Die Oberfläche weiß nicht mehr, dass es Plex gibt

Keine Datei unter Views/LiveTV importiert noch PlexKit. Sender, Programm
und Einschalten laufen über den neutralen Vertrag, und supportsLiveTV
fragt endlich die Fähigkeit ab statt "server != nil" — womit die Zusage
in JellyfinBackend.capabilities zum ersten Mal stimmt.

Der Player holt die laufende Sendung jetzt für genau einen Sender nach.
Vorher ging dafür das Raster über alle Sender hinaus, weil der Plex-Weg
keinen schmaleren kannte; im neutralen Vertrag ist die Senderliste ein
Parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 06:20:30 +02:00
co-authored by Claude Opus 5
parent 24765a5afc
commit eebe9280cb
8 changed files with 3930 additions and 3897 deletions
+127 -119
View File
@@ -1,6 +1,10 @@
{
"sourceLanguage" : "en",
"strings" : {
" · " : {
"comment" : "A separator between different parts of the episode metadata.",
"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,
@@ -1423,6 +1427,7 @@
}
},
"downloads.removeConfirm" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1503,6 +1508,26 @@
}
}
}
},
"episode.number" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Episode %lld"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Episode %lld"
}
}
}
},
"episode.number %@" : {
},
"episode.short %lld %lld" : {
"comment" : "Staffel- und Folgennummer",
@@ -2419,6 +2444,23 @@
}
}
},
"library.search" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Suchen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Search"
}
}
}
},
"library.toggleLayout" : {
"localizations" : {
"de" : {
@@ -3734,6 +3776,23 @@
}
}
},
"season.number" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Staffel %lld"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Season %lld"
}
}
}
},
"season.short %lld" : {
"comment" : "Platzhalter: Staffelnummer",
"extractionState" : "stale",
@@ -4757,6 +4816,57 @@
}
}
},
"sidebar.edit" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bearbeiten"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Edit"
}
}
}
},
"sidebar.editHint" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ziehen zum Umsortieren, Haken zum Ein- und Ausblenden."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Drag to reorder, tap the check to show or hide."
}
}
}
},
"sidebar.hide" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ausblenden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Hide"
}
}
}
},
"sidebar.home" : {
"comment" : "Eintrag der Startseite in der Seitenleiste",
"localizations" : {
@@ -4928,6 +5038,23 @@
}
}
},
"sidebar.show" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Einblenden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show"
}
}
}
},
"signIn.cancelled" : {
"extractionState" : "stale",
"localizations" : {
@@ -5831,125 +5958,6 @@
}
}
}
},
"sidebar.edit": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bearbeiten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Edit"
}
}
}
},
"sidebar.editHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ziehen zum Umsortieren, Haken zum Ein- und Ausblenden."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Drag to reorder, tap the check to show or hide."
}
}
}
},
"sidebar.show": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Einblenden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show"
}
}
}
},
"sidebar.hide": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ausblenden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Hide"
}
}
}
},
"library.search": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Suchen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Search"
}
}
}
},
"season.number": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Season %lld"
}
}
}
},
"episode.number": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Episode %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Episode %lld"
}
}
}
}
},
"version" : "1.1"
+17 -5
View File
@@ -158,13 +158,25 @@ final class FlixySession {
}
}
/// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**.
/// Ob der aktive Server Live-TV anbietet.
///
/// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der
/// Programmführer der App spricht aber noch unmittelbar mit Plex. Einen
/// Endlich die Fähigkeit selbst. Hier stand `server != nil` — also „nur
/// Plex" —, weil der Programmführer unmittelbar mit Plex sprach; einen
/// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine
/// Zusage, die niemand einlöst.
var supportsLiveTV: Bool { server != nil }
/// Zusage gewesen, die niemand einlöst. Seit Live-TV über den neutralen
/// Vertrag läuft, löst sie jeder ein, der sie gibt.
var supportsLiveTV: Bool { backend?.capabilities.contains(.liveTV) == true }
/// Ob er darüber hinaus aufnehmen kann.
///
/// Getrennt gefragt, weil Plex das eine kann und das andere nicht.
var supportsRecordings: Bool { backend?.capabilities.contains(.liveTVRecordings) == true }
/// Der Server als Live-TV-Server — sofern er einer ist.
var liveTV: (any LiveTVBackend)? { backend as? any LiveTVBackend }
/// Der Server als Aufnahme-Server — sofern er einer ist.
var recorder: (any LiveTVRecordingBackend)? { backend as? any LiveTVRecordingBackend }
/// Warum gerade nicht abgespielt werden kann.
///
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Welche Sender überhaupt angezeigt werden.
///
@@ -9,7 +9,7 @@ import PlexKit
/// Jeden davon zu zeichnen und sein Programm zu holen kostet Zeit und macht
/// die Ansicht unübersichtlich.
struct ChannelSelectionSheet: View {
let channels: [PlexChannel]
let channels: [LiveChannel]
@Binding var selection: Set<String>
@Environment(FlixySession.self) private var session
@@ -44,7 +44,7 @@ struct ChannelSelectionSheet: View {
}
}
private func row(for channel: PlexChannel) -> some View {
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
@@ -59,14 +59,14 @@ struct ChannelSelectionSheet: View {
} label: {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 200, height: 120),
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: FlixyAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
)
.frame(width: 48, height: 29)
if let number = channel.channelIdentifier {
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
@@ -86,7 +86,7 @@ struct ChannelSelectionSheet: View {
.buttonStyle(.plain)
}
private func isShown(_ channel: PlexChannel) -> Bool {
private func isShown(_ channel: LiveChannel) -> Bool {
ChannelSelection.shows(channel, selection: selection)
}
}
@@ -98,11 +98,11 @@ struct ChannelSelectionSheet: View {
/// „alle": so sieht man beim ersten Öffnen sein Programm, ohne vorher etwas
/// angehakt zu haben.
enum ChannelSelection {
static func shows(_ channel: PlexChannel, selection: Set<String>) -> Bool {
static func shows(_ channel: LiveChannel, selection: Set<String>) -> Bool {
selection.isEmpty || selection.contains(channel.id)
}
static func apply(_ selection: Set<String>, to channels: [PlexChannel]) -> [PlexChannel] {
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
+29 -29
View File
@@ -1,7 +1,7 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
import MediaKit
/// Live-TV mit Programmführer.
struct LiveTVView: View {
@@ -11,14 +11,12 @@ struct LiveTVView: View {
@Environment(NetworkConditions.self) private var network
@State private var availability: LiveTVAvailability?
/// Der Empfänger, über den eingeschaltet wird.
@State private var dvr: PlexDVR?
@State private var channels: [PlexChannel] = []
@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: [ChannelProbe] = []
@State private var programs: [String: [PlexProgram]] = [:]
@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?
@@ -34,7 +32,7 @@ struct LiveTVView: View {
/// Die Sender nach der Auswahl — überall dieselbe Liste, damit Raster und
/// Liste nicht auseinanderlaufen.
private var shownChannels: [PlexChannel] {
private var shownChannels: [LiveChannel] {
ChannelSelection.apply(ChannelSelection.decode(storedSelection), to: channels)
}
@@ -85,7 +83,7 @@ struct LiveTVView: View {
programs: programs,
tuning: isTuning,
artwork: { channel in
session.server?.artworkURL(path: channel.thumb, width: 120, height: 120)
session.backend?.artworkURL(path: channel.artworkPath, width: 120, height: 120)
},
onTune: { channel, program in Task { await tune(channel, program: program) } }
)
@@ -200,13 +198,12 @@ struct LiveTVView: View {
}
private func load() async {
guard let server = session.server else { return }
guard let liveTV = session.liveTV else { return }
let availability = await server.liveTVAvailability()
let availability = await liveTV.liveTVAvailability()
self.availability = availability
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
self.dvr = dvr
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
@@ -216,15 +213,21 @@ struct LiveTVView: View {
// 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 guide = await server.liveTVGuide(dvr: dvr, hours: 6)
channels = guide.channels
programs = guide.programs
channelSource = guide.source
trace = guide.trace
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: PlexChannel, program: PlexProgram? = nil) async {
guard let server = session.server, isTuning == nil else { return }
private func tune(_ channel: LiveChannel, program: LiveProgram? = nil) async {
guard let liveTV = session.liveTV, isTuning == nil else { return }
isTuning = channel.id
defer { isTuning = nil }
@@ -233,12 +236,9 @@ struct LiveTVView: View {
let running = program ?? programs[channel.id]?.first { $0.isRunning() }
let policy = settings.policy(for: network)
let result = await server.tune(
let result = await liveTV.tune(
channel: channel,
program: running,
// Der Programmanbieter kennt keinen Weg zum Einschalten — der
// Empfänger schon, und ohne ihn bleibt der halbe Suchraum zu.
dvr: dvr,
maximumBitrate: policy.maximumBitrate
)
@@ -268,16 +268,16 @@ struct LiveTVView: View {
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
private struct ChannelRow: View {
let channel: PlexChannel
let programs: [PlexProgram]
let channel: LiveChannel
let programs: [LiveProgram]
let isTuning: Bool
let onTune: () -> Void
@Environment(FlixySession.self) private var session
@Environment(\.flixyAccent) private var accent
private var current: PlexProgram? { programs.first { $0.isRunning() } }
private var next: PlexProgram? { programs.first { $0.startsAt > Date() } }
private var current: LiveProgram? { programs.first { $0.isRunning() } }
private var next: LiveProgram? { programs.first { $0.startsAt > Date() } }
var body: some View {
Button(action: onTune) {
@@ -286,7 +286,7 @@ private struct ChannelRow: View {
// fast nie quadratisch, und formatfüllend fehlten links und
// rechts genau die Teile, an denen man den Sender erkennt.
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 200, height: 120),
url: session.backend?.artworkURL(path: channel.artworkPath, width: 200, height: 120),
aspectRatio: FlixyAspectRatio.channelLogo,
fallbackSymbol: "tv",
contentMode: .fit
@@ -295,7 +295,7 @@ private struct ChannelRow: View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: FlixySpacing.snug) {
if let number = channel.channelIdentifier {
if let number = channel.number {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
+13 -13
View File
@@ -1,6 +1,6 @@
import SwiftUI
import FlixyDesign
import PlexKit
import MediaKit
/// Das Programmraster: Sender als Zeilen, Zeit als Achse.
///
@@ -15,8 +15,8 @@ import PlexKit
/// waagerechten Versatz von Hand — anders bekommt man zwei Achsen nicht
/// gleichzeitig festgenagelt.
struct ProgramGuide: View {
let channels: [PlexChannel]
let programs: [String: [PlexProgram]]
let channels: [LiveChannel]
let programs: [String: [LiveProgram]]
let tuning: String?
/// Die Adresse des Senderlogos.
///
@@ -24,8 +24,8 @@ struct ProgramGuide: View {
/// 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
let artwork: (LiveChannel) -> URL?
let onTune: (LiveChannel, LiveProgram?) -> Void
@Environment(\.flixyAccent) private var accent
@@ -130,7 +130,7 @@ struct ProgramGuide: View {
}
/// Was auf diesem Sender gerade läuft.
private func running(on channel: PlexChannel) -> PlexProgram? {
private func running(on channel: LiveChannel) -> LiveProgram? {
programs[channel.id]?.first { $0.isRunning(at: now) }
}
@@ -148,7 +148,7 @@ struct ProgramGuide: View {
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
if let number = channel.channelIdentifier {
if let number = channel.number {
Text(number)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
@@ -202,7 +202,7 @@ struct ProgramGuide: View {
}
}
private func lane(for channel: PlexChannel) -> some View {
private func lane(for channel: LiveChannel) -> some View {
let entries = programs[channel.id] ?? []
return ZStack(alignment: .topLeading) {
@@ -235,7 +235,7 @@ struct ProgramGuide: View {
.frame(height: 1)
}
private func block(_ program: PlexProgram, on channel: PlexChannel) -> some View {
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: {
@@ -274,17 +274,17 @@ struct ProgramGuide: View {
/// Die angetippte Sendung samt ihrem Sender.
private struct GuideSelection: Identifiable {
let channel: PlexChannel
let program: PlexProgram
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: PlexChannel
let channel: LiveChannel
let artworkURL: URL?
let program: PlexProgram
let program: LiveProgram
let isRunning: Bool
let onTune: () -> Void
+1 -2
View File
@@ -180,8 +180,7 @@ struct RootView: View {
let fresh = PlayerModel(
request: request,
backend: session.backend,
// Live-TV spricht als Einziges noch unmittelbar mit Plex.
liveTV: session.server,
liveTV: session.liveTV,
downloads: downloads,
diagnostics: diagnostics,
policy: settings.policy(for: network),
+15 -13
View File
@@ -122,8 +122,10 @@ final class PlayerModel {
/// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar
/// mit Plex spricht. Ein Tuner ist ein Stück Hardware, und die fünfhundert
/// Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen
/// gemeinsamen Vertrag. Bei Jellyfin ist er `nil`.
private let liveTV: PlexServer?
/// gemeinsamen Vertrag — wohl aber in einen **eigenen**. Seit es den
/// gibt, steht hier kein Plex-Server mehr, sondern wer auch immer
/// Live-TV kann.
private let liveTV: (any LiveTVBackend)?
private let downloads: DownloadCoordinator
private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy
@@ -173,7 +175,7 @@ final class PlayerModel {
init(
request: PlaybackRequest,
backend: (any MediaBackend)?,
liveTV: PlexServer?,
liveTV: (any LiveTVBackend)?,
downloads: DownloadCoordinator,
diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy,
@@ -498,27 +500,27 @@ final class PlayerModel {
/// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist
/// nichts, was man bei jedem Antippen tun will.
func refreshLiveProgrammeIfNeeded() async {
guard isLive, let server = liveTV else { return }
guard isLive, let liveTV else { return }
if let programmeEndsAt, programmeEndsAt > .now { return }
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
let now = Date()
let entries = (try? await server.guide(
providerIdentifier: provider,
// Nur dieser eine Sender. Vorher ging hier das Raster über **alle**
// Sender hinaus, weil der Plex-Weg keinen schmaleren kannte — beim
// neutralen Vertrag ist die Senderliste ein Parameter.
let programs = (try? await liveTV.livePrograms(
channelIDs: [request.ratingKey],
from: now,
to: now.addingTimeInterval(2 * 3_600)
)) ?? []
)) ?? [:]
guard let match = entries.first(where: {
$0.channel?.id == request.ratingKey && $0.program.isRunning()
}) else {
guard let match = programs[request.ratingKey]?.first(where: { $0.isRunning() }) else {
// Lieber gar nichts als etwas Falsches.
subtitle = nil
return
}
subtitle = match.program.title
programmeEndsAt = match.program.endsAt
subtitle = match.title
programmeEndsAt = match.endsAt
}
func togglePlayPause() {
@@ -126,6 +126,18 @@ public struct LiveProgram: Sendable, Equatable, Identifiable {
public func isRunning(at moment: Date = .now) -> Bool {
startsAt <= moment && endsAt > moment
}
/// Wie weit sie fortgeschritten ist, 0…1.
///
/// Gerechnet statt gemeldet: beide Server nennen Anfang und Ende, keiner
/// nennt den Fortschritt. Beim Umschalten ist er die nützlichste Angabe
/// überhaupt — er sagt, ob man noch einsteigt oder nur noch den Abspann
/// sieht.
public func progress(at moment: Date = .now) -> Double {
let total = endsAt.timeIntervalSince(startsAt)
guard total > 0 else { return 0 }
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
}
}
// MARK: - Ob es überhaupt etwas zu sehen gibt