Phase 6: Live-TV, plus Live Activity für Downloads

Live-TV
- An keinem echten Server geprüft: die DVR- und EPG-Endpunkte sind der am
  schlechtesten dokumentierte Teil der Plex-API. Die Modelle sind deshalb
  durchgehend nachsichtig — fehlende Felder ergeben leere Listen, nicht
  Fehler, und die Senderkennung wird als Zahl, Zeichenkette oder uuid
  akzeptiert.
- Ohne eingerichteten Empfänger erklärt die App das, statt einen Fehler
  zu zeigen. Das ist der häufigste Fall.
- Senderliste mit laufender Sendung, Fortschrittsbalken und „danach".
  Der Programmführer wird getrennt geholt: fällt er aus, bleibt das
  Umschalten möglich.
- Die Anbieterkennung wird aus /media/providers gelesen statt fest
  verdrahtet — sie wechselt je nach Server.

Live Activity
- Eigene Widget-Erweiterung; iOS zeichnet den Sperrbildschirm in einem
  getrennten Prozess, während die App meist gar nicht läuft.
- Der Presenter drosselt auf 1,5 Sekunden. ActivityKit verwirft zu
  häufige Aktualisierungen kommentarlos, und der Fortschritt kommt
  mehrmals je Sekunde — ungebremst bliebe die Anzeige stehen.
- @preconcurrency import: Activity und ActivityContent tragen keine
  Sendable-Zusage.

Zwei Stolpersteine unterwegs: canImport(ActivityKit) ist auf macOS wahr,
die Typen darin aber nicht verfügbar — es muss os(iOS) sein. Und eine
von Hand geschriebene Info.plist ohne CFBundleIdentifier führt zu einer
Erweiterung ganz ohne Kennung; der Fehler lautet dann „not prefixed with
the parent app's bundle identifier" und zeigt auf die falsche Stelle.

126 Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 17:54:17 +02:00
co-authored by Claude Opus 5
parent b7fc18823a
commit 5c0c1d651d
14 changed files with 924 additions and 7 deletions
+87
View File
@@ -0,0 +1,87 @@
import ActivityKit
import SwiftUI
import WidgetKit
import FlixyDesign
import FlixyDownloads
/// Der Download-Fortschritt im Sperrbildschirm und in der Dynamic Island.
struct DownloadLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: DownloadActivityAttributes.self) { context in
lockScreen(context.state)
// Die Erweiterung liest die Akzentfarbe nicht: sie läuft in
// einem eigenen Prozess ohne Zugriff auf die Voreinstellungen
// der App. Ein Gruppen-Container wäre der Weg dafür — für
// eine Fortschrittsanzeige ist das mehr Aufwand als Nutzen.
.tint(.white)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.white)
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.title)
.font(.caption)
.lineLimit(1)
}
DynamicIslandExpandedRegion(.bottom) {
progressBar(context.state)
}
} compactLeading: {
Image(systemName: "arrow.down")
} compactTrailing: {
if let progress = context.state.progress {
Text(progress.formatted(.percent.precision(.fractionLength(0))))
.font(.caption2)
.monospacedDigit()
}
} minimal: {
Image(systemName: "arrow.down")
}
}
}
private func lockScreen(_ state: DownloadActivityAttributes.ContentState) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label(state.title, systemImage: "arrow.down.circle")
.font(.headline)
.lineLimit(1)
Spacer()
if state.remainingCount > 0 {
Text("+\(state.remainingCount)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
progressBar(state)
HStack {
Text(state.receivedBytes.formatted(.byteCount(style: .file)))
Spacer()
if state.totalBytes > 0 {
Text(state.totalBytes.formatted(.byteCount(style: .file)))
}
}
.font(.caption2)
.monospacedDigit()
.foregroundStyle(.secondary)
}
.padding()
.activityBackgroundTint(Color(red: 0.05, green: 0.05, blue: 0.06))
}
@ViewBuilder
private func progressBar(_ state: DownloadActivityAttributes.ContentState) -> some View {
// Ohne bekannte Gesamtgröße ein unbestimmter Balken: einer bei null
// Prozent, der sich nie bewegt, sieht im Sperrbildschirm aus wie ein
// hängender Download.
if let progress = state.progress {
ProgressView(value: progress)
} else {
ProgressView()
}
}
}
@@ -0,0 +1,9 @@
import SwiftUI
import WidgetKit
@main
struct DownloadWidgetBundle: WidgetBundle {
var body: some Widget {
DownloadLiveActivity()
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Flixy Downloads</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
+2
View File
@@ -27,6 +27,8 @@
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
+97
View File
@@ -3010,6 +3010,103 @@
}
}
}
},
"liveTV.notConfigured.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Empfänger eingerichtet"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No tuner set up"
}
}
}
},
"liveTV.notConfigured.description": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here."
}
}
}
},
"liveTV.noChannels.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine Sender"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No channels"
}
}
}
},
"liveTV.noChannels.description": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Empfänger ist eingerichtet, liefert aber keine Sender."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The tuner is set up but isn't providing any channels."
}
}
}
},
"liveTV.noProgram": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Programm verfügbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No guide data"
}
}
}
},
"liveTV.next %@ %@": {
"comment": "Platzhalter: Uhrzeit, Sendungstitel",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Danach %@ · %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next %@ · %@"
}
}
}
}
},
"version": "1.1"
+202
View File
@@ -0,0 +1,202 @@
import SwiftUI
import FlixyDesign
import FlixyPlayback
import PlexKit
/// Live-TV mit Programmführer.
struct LiveTVView: View {
@Environment(FlixySession.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: [PlexChannel] = []
@State private var programs: [String: [PlexProgram]] = [:]
@State private var isTuning: String?
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:
ContentUnavailableView {
Label("liveTV.noChannels.title", systemImage: "tv")
} description: {
Text("liveTV.noChannels.description")
}
case .available:
guide
}
}
.background(Color.flixyCanvas)
.navigationTitle(Text("sidebar.liveTV"))
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
.refreshable { await load() }
}
private var guide: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(channels) { channel in
ChannelRow(
channel: channel,
programs: programs[channel.id] ?? [],
isTuning: isTuning == channel.id,
onTune: { Task { await tune(channel) } }
)
Divider().overlay(Color.flixySeparator)
}
}
}
}
private func load() async {
guard let server = session.server else { return }
let availability = await server.liveTVAvailability()
self.availability = availability
guard case .available(let dvrs) = availability, let dvr = dvrs.first else { return }
channels = (try? await server.channels(inDVR: dvr)) ?? []
// Der Programmführer kommt getrennt: er kann ausfallen, ohne dass
// deshalb das Umschalten unmöglich wäre. Eine Senderliste ohne
// Programm ist immer noch nützlich.
guard let provider = await server.electronicProgramGuideIdentifier() else { return }
let now = Date()
let entries = (try? await server.guide(
providerIdentifier: provider,
from: now.addingTimeInterval(-3_600),
to: now.addingTimeInterval(6 * 3_600)
)) ?? []
programs = Dictionary(grouping: entries.compactMap { program in
program.channelID.map { (key: $0, program: program) }
}, by: \.key).mapValues { pairs in
pairs.map(\.program).sorted { $0.startsAt < $1.startsAt }
}
}
private func tune(_ channel: PlexChannel) async {
guard let server = session.server, isTuning == nil else { return }
isTuning = channel.id
defer { isTuning = nil }
let policy = settings.policy(for: network)
guard (try? await server.tune(channel: channel, 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.
playback.request = PlaybackRequest(
ratingKey: channel.id,
title: channel.title,
mediaIndex: 0,
startTime: .zero
)
}
}
/// Eine Senderzeile mit dem, was gerade und als Nächstes läuft.
private struct ChannelRow: View {
let channel: PlexChannel
let programs: [PlexProgram]
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() } }
var body: some View {
Button(action: onTune) {
HStack(spacing: FlixySpacing.regular) {
FlixyArtwork(
url: session.server?.artworkURL(path: channel.thumb, width: 120, height: 120),
aspectRatio: 1,
fallbackSymbol: "tv"
)
.frame(width: 44, height: 44)
.clipShape(.rect(cornerRadius: 4))
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: FlixySpacing.snug) {
if let number = channel.channelIdentifier {
Text(number)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(Color.flixyTextMuted)
}
Text(channel.title)
.font(.flixyCardTitle)
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(1)
}
if let current {
Text(current.title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.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(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
if let next {
Text("liveTV.next \(next.startsAt.formatted(date: .omitted, time: .shortened)) \(next.title)")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.lineLimit(1)
}
}
Spacer()
if isTuning {
ProgressView().controlSize(.small)
} else {
Image(systemName: "play.circle")
.font(.title3)
.foregroundStyle(accent)
}
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.vertical, FlixySpacing.regular)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityElement(children: .combine)
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ struct RootView: View {
PlaceholderScreen(destination: .library(key: key, title: title))
}
case .liveTV:
PlaceholderScreen(destination: .liveTV)
LiveTVView()
case .downloads:
DownloadsView()
case .settings:
@@ -0,0 +1,76 @@
import Foundation
#if os(iOS)
// `@preconcurrency`, weil `Activity` und `ActivityContent` keine
// Sendable-Zusage tragen. Beide bleiben hier ohnehin auf dem Hauptakteur —
// die Klasse ist darauf festgelegt, und ActivityKit erwartet seine Aufrufe
// von dort. Ohne diese Zeile lehnt Swift 6 jeden Aufruf ab.
@preconcurrency import ActivityKit
/// Zeigt den Download-Fortschritt im Sperrbildschirm.
///
/// Getrennt vom Koordinator, weil Live Activities eine Reihe eigener Fallen
/// haben — sie können vom Nutzer abgeschaltet sein, ihre Zahl ist begrenzt,
/// und eine nicht beendete Activity bleibt stundenlang stehen. Der Koordinator
/// meldet nur „das läuft gerade"; was daraus wird, entscheidet sich hier.
@MainActor
public final class DownloadActivityPresenter {
private var activity: Activity<DownloadActivityAttributes>?
/// Wann zuletzt aktualisiert wurde.
///
/// ActivityKit drosselt häufige Aktualisierungen und verwirft sie
/// irgendwann kommentarlos. Der Fortschritt kommt mehrmals je Sekunde —
/// ungebremst weitergereicht bliebe die Anzeige irgendwann stehen.
private var lastUpdate = Date.distantPast
private let minimumInterval: TimeInterval = 1.5
public init() {}
/// Bringt die Anzeige auf den neuesten Stand.
///
/// `nil` beendet sie — das ist der Fall, wenn nichts mehr läuft.
/// `async`, nicht `Task { … }` im Inneren: `Activity` und
/// `ActivityContent` sind nicht `Sendable`, und eine Aufgabe würde sie über
/// die Isolationsgrenze schicken. So bleibt alles auf dem Hauptakteur.
public func update(with state: DownloadActivityAttributes.ContentState?) async {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard let state else {
await end()
return
}
if let activity {
guard Date().timeIntervalSince(lastUpdate) >= minimumInterval else { return }
lastUpdate = Date()
await activity.update(ActivityContent(state: state, staleDate: nil))
} else {
start(with: state)
}
}
private func start(with state: DownloadActivityAttributes.ContentState) {
do {
activity = try Activity.request(
attributes: DownloadActivityAttributes(),
content: ActivityContent(state: state, staleDate: nil)
)
lastUpdate = Date()
} catch {
// Scheitert meist, weil die Höchstzahl gleichzeitiger Activities
// erreicht ist. Kein Grund, den Download anzuhalten — die Anzeige
// ist Beiwerk.
activity = nil
}
}
/// Beendet die Anzeige.
public func end() async {
guard let activity else { return }
self.activity = nil
// `.immediate`: eine Download-Anzeige, die nach dem letzten Byte noch
// eine Viertelstunde im Sperrbildschirm klebt, ist Müll.
await activity.end(nil, dismissalPolicy: .immediate)
}
}
#endif
@@ -119,6 +119,10 @@ public final class DownloadCoordinator {
private var session: URLSession!
private let delegate = DownloadSessionDelegate()
#if os(iOS)
private let activityPresenter = DownloadActivityPresenter()
#endif
/// Die Konfiguration der Hintergrundsitzung.
///
/// Als eigene Funktion, damit Tests stattdessen eine flüchtige Sitzung
@@ -424,6 +428,7 @@ public final class DownloadCoordinator {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
item.receivedBytes = received
if expected > 0 { item.totalBytes = expected }
updateActivity()
// Bewusst ohne `save()`: der Fortschritt kommt mehrmals je Sekunde, und
// jedes Schreiben wäre eine Plattenoperation. Für die Anzeige reicht
// die Beobachtung; gespeichert wird bei Zustandswechseln.
@@ -444,6 +449,7 @@ public final class DownloadCoordinator {
item.completedAt = Date()
save()
refreshUsage()
updateActivity()
} catch {
fail(item, message: error.localizedDescription)
}
@@ -469,6 +475,33 @@ public final class DownloadCoordinator {
item.state = .failed
item.failureMessage = message
save()
updateActivity()
}
/// Hält die Anzeige im Sperrbildschirm auf dem Stand.
///
/// Zeigt den am weitesten fortgeschrittenen Download — bei zwei
/// gleichzeitigen ist das der, der zuerst fertig wird, und damit der, auf
/// den man wartet.
private func updateActivity() {
#if os(iOS)
let running = items.filter { $0.state == .downloading }
guard let leading = running.max(by: { ($0.progress ?? 0) < ($1.progress ?? 0) }) else {
Task { [activityPresenter] in await activityPresenter.update(with: nil) }
return
}
// Der Zustand wird hier gebaut, nicht in der Aufgabe: SwiftData-Objekte
// gehören dem Hauptakteur, der reine Wertetyp darf wandern.
let state = DownloadActivityAttributes.ContentState(
title: leading.title,
progress: leading.progress,
receivedBytes: leading.receivedBytes,
totalBytes: leading.totalBytes,
remainingCount: items.filter { $0.state.isTransferable }.count + running.count - 1
)
Task { [activityPresenter] in await activityPresenter.update(with: state) }
#endif
}
// MARK: - Innereien
@@ -0,0 +1,45 @@
import Foundation
// `os(iOS)` statt `canImport(ActivityKit)`: der Import gelingt auch auf
// macOS, die Typen darin sind dort aber als nicht verfügbar markiert. Eine
// Prüfung auf den Import allein übersetzt deshalb nicht.
#if os(iOS)
import ActivityKit
/// Was im Sperrbildschirm und in der Dynamic Island steht.
///
/// Liegt in FlixyDownloads, weil App und Widget-Erweiterung denselben Typ
/// brauchen — zwei getrennte Kopien wären die klassische Quelle für eine Live
/// Activity, die sich nicht mehr aktualisieren lässt: ActivityKit gleicht die
/// Typen über ihren vollen Namen ab.
public struct DownloadActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable, Sendable {
/// Der Titel, der gerade lädt.
public var title: String
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
public var progress: Double?
public var receivedBytes: Int64
public var totalBytes: Int64
/// Wie viele weitere in der Warteschlange stehen.
public var remainingCount: Int
public init(
title: String,
progress: Double?,
receivedBytes: Int64,
totalBytes: Int64,
remainingCount: Int
) {
self.title = title
self.progress = progress
self.receivedBytes = receivedBytes
self.totalBytes = totalBytes
self.remainingCount = remainingCount
}
}
/// Bleibt über die Laufzeit gleich. Hier leer: welcher Titel lädt, wechselt
/// im Laufe einer Staffel und gehört deshalb in den Zustand.
public init() {}
}
#endif
@@ -0,0 +1,126 @@
import Foundation
/// Was beim Live-TV herauskommen kann.
public enum LiveTVAvailability: Sendable, Equatable {
/// Es gibt einen Empfänger und Sender.
case available(dvrs: [PlexDVR])
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
///
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
/// einen Fehler zu zeigen.
case notConfigured
case failed(message: String)
}
extension PlexServer {
/// Prüft, ob der Server überhaupt Live-TV kann.
public func liveTVAvailability() async -> LiveTVAvailability {
do {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs", timeout: .seconds(10)),
as: PlexMediaContainer<PlexDVR>.self
)
return container.content.isEmpty ? .notConfigured : .available(dvrs: container.content)
} catch PlexError.notFound {
// Ältere Serverfassungen kennen den Endpunkt gar nicht. Das ist
// dasselbe Ergebnis wie „kein Empfänger": es gibt nichts zu sehen.
return .notConfigured
} catch {
return .failed(message: String(describing: error))
}
}
/// Die Sender eines Empfängers.
public func channels(inDVR dvr: PlexDVR) async throws(PlexError) -> [PlexChannel] {
let container = try await client.send(
PlexRequest(path: "/livetv/dvrs/\(dvr.key)/channels"),
as: PlexMediaContainer<PlexChannel>.self
)
return container.content
}
/// Die Kennung des Programmanbieters.
///
/// Sie wechselt je nach Server und Einrichtung (etwa
/// `tv.plex.providers.epg.cloud:2`) und steckt in der Anbieterliste. Fest
/// verdrahtet wäre sie auf der Hälfte der Server falsch.
public func electronicProgramGuideIdentifier() async -> String? {
guard let container = try? await client.send(
PlexRequest(path: "/media/providers"),
as: PlexMediaContainer<PlexMediaProvider>.self
) else { return nil }
return container.content.first { $0.identifier.contains("epg") }?.identifier
}
/// Das Programmraster für einen Zeitraum.
///
/// Bewusst über einen Zeitraum statt „alles": ein Tag über hundert Sender
/// sind mehrere tausend Sendungen, und mehr als die nächsten Stunden
/// braucht niemand beim Umschalten.
public func guide(
providerIdentifier: String,
from start: Date,
to end: Date
) async throws(PlexError) -> [PlexProgram] {
let container = try await client.send(
PlexRequest(
path: "/\(providerIdentifier)/grid",
query: [
// 1 steht bei Plex für „Sendung".
URLQueryItem(name: "type", value: "1"),
URLQueryItem(name: "beginsAt>", value: String(Int(start.timeIntervalSince1970))),
URLQueryItem(name: "endsAt<", value: String(Int(end.timeIntervalSince1970))),
],
timeout: .seconds(30)
),
as: PlexMediaContainer<PlexGridEntry>.self
)
return container.content.compactMap { entry in
// Die Senderzuordnung steckt im Medien-Block. Fehlt sie, lässt
// sich die Sendung keiner Spalte zuordnen — dann ist sie für ein
// Raster wertlos.
let channelID = entry.media?.first?.parts.first?.key
return entry.program(channelID: channelID)
}
}
/// Schaltet einen Sender ein.
///
/// Live-TV kommt immer als umgerechneter Strom: der Tuner liefert einen
/// Transportstrom, den kein iPhone direkt abspielt. Deshalb führt der Weg
/// hier zwangsläufig über den Transcoder — anders als bei Dateien, wo
/// DirectPlay der Normalfall ist.
public func tune(channel: PlexChannel, maximumBitrate: Int?) async throws(PlexError) -> URL? {
guard let key = channel.key else { return nil }
let session = UUID().uuidString
let container = try await client.send(
PlexRequest(
path: "/livetv/sessions/\(session)/\(key)",
query: [URLQueryItem(name: "X-Plex-Session-Identifier", value: session)],
timeout: .seconds(30)
),
as: PlexMediaContainer<PlexItem>.self
)
guard let item = container.content.first else { throw .notFound }
return transcodeURL(for: TranscodeRequest(
ratingKey: item.ratingKey,
maximumBitrate: maximumBitrate,
isLocal: connection.local,
sessionIdentifier: session
))
}
}
/// Ein Anbieter am Server — Mediathek, Programmführer, Entdecken.
public struct PlexMediaProvider: Sendable, Equatable, Decodable, Identifiable {
public let identifier: String
public let title: String?
public let types: String?
public var id: String { identifier }
}
@@ -0,0 +1,157 @@
import Foundation
/// Ein Fernsehempfänger am Server.
///
/// **Dieser ganze Bereich ist an keinem echten Server geprüft.** Die
/// Live-TV-Endpunkte sind der am schlechtesten dokumentierte Teil der
/// Plex-API — es gibt keine offizielle Beschreibung, nur das, was sich aus den
/// Anfragen der offiziellen Apps ablesen lässt. Die Modelle sind deshalb
/// durchgehend nachsichtig: fehlende Felder führen zu leeren Listen, nicht zu
/// Fehlern.
public struct PlexDVR: Sendable, Equatable, Decodable, Identifiable {
public let key: String
public let uuid: String?
public let language: String?
public let lineupTitle: String?
public let lineup: String?
public var id: String { key }
public init(key: String, uuid: String? = nil, lineupTitle: String? = nil, lineup: String? = nil, language: String? = nil) {
self.key = key
self.uuid = uuid
self.lineupTitle = lineupTitle
self.lineup = lineup
self.language = language
}
}
/// Ein Sender.
public struct PlexChannel: Sendable, Equatable, Decodable, Identifiable {
/// Die Nummer, unter der der Sender läuft — etwa `"3.1"`.
public let channelIdentifier: String?
public let title: String
public let thumb: String?
/// Der Schlüssel, über den sich der Sender einschalten lässt.
public let key: String?
public let id: String
private enum CodingKeys: String, CodingKey {
case channelIdentifier, title, thumb, key
case identifier = "id"
case uuid
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
self.title = try values.decodeIfPresent(String.self, forKey: .title) ?? "—"
self.channelIdentifier = try values.decodeIfPresent(String.self, forKey: .channelIdentifier)
self.thumb = try values.decodeIfPresent(String.self, forKey: .thumb)
self.key = try values.decodeIfPresent(String.self, forKey: .key)
// Plex liefert die Kennung mal als Zahl, mal als Zeichenkette, mal als
// `uuid`. Ein einzelner erwarteter Typ hätte hier bei manchen
// Serverfassungen die ganze Senderliste unlesbar gemacht.
if let text = try? values.decodeIfPresent(String.self, forKey: .identifier) {
self.id = text
} else if let number = try? values.decodeIfPresent(Int.self, forKey: .identifier) {
self.id = String(number)
} else if let uuid = try values.decodeIfPresent(String.self, forKey: .uuid) {
self.id = uuid
} else {
self.id = channelIdentifier ?? title
}
}
public init(id: String, title: String, channelIdentifier: String? = nil, thumb: String? = nil, key: String? = nil) {
self.id = id
self.title = title
self.channelIdentifier = channelIdentifier
self.thumb = thumb
self.key = key
}
}
/// Eine Sendung im Programm.
public struct PlexProgram: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let subtitle: String?
public let summary: String?
public let startsAt: Date
public let endsAt: Date
public let channelID: String?
public let thumb: String?
public init(
id: String,
title: String,
subtitle: String? = nil,
summary: String? = nil,
startsAt: Date,
endsAt: Date,
channelID: String? = nil,
thumb: String? = nil
) {
self.id = id
self.title = title
self.subtitle = subtitle
self.summary = summary
self.startsAt = startsAt
self.endsAt = endsAt
self.channelID = channelID
self.thumb = thumb
}
public var duration: Duration {
.seconds(endsAt.timeIntervalSince(startsAt))
}
public func isRunning(at moment: Date = Date()) -> Bool {
moment >= startsAt && moment < endsAt
}
/// Wie weit die Sendung fortgeschritten ist, 0…1.
public func progress(at moment: Date = Date()) -> Double {
let total = endsAt.timeIntervalSince(startsAt)
guard total > 0 else { return 0 }
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
}
}
/// Ein Eintrag aus dem Programmraster.
///
/// Plex liefert das Raster als gewöhnliche Metadaten mit den Zeitangaben in
/// Sekunden seit 1970. Die Übersetzung in `PlexProgram` passiert hier, damit
/// die Oberfläche keine Zeitstempel rechnen muss.
struct PlexGridEntry: Decodable {
let ratingKey: String?
let title: String?
let summary: String?
let grandparentTitle: String?
let thumb: String?
let beginsAt: Int?
let endsAt: Int?
/// Die Sendermedien; darin steht, zu welchem Sender die Sendung gehört.
let media: [PlexMedia]?
private enum CodingKeys: String, CodingKey {
case ratingKey, title, summary, grandparentTitle, thumb, beginsAt, endsAt
case media = "Media"
}
func program(channelID: String?) -> PlexProgram? {
guard let beginsAt, let endsAt, let title else { return nil }
return PlexProgram(
id: ratingKey ?? "\(channelID ?? "")-\(beginsAt)",
title: title,
subtitle: grandparentTitle,
summary: summary,
startsAt: Date(timeIntervalSince1970: TimeInterval(beginsAt)),
endsAt: Date(timeIntervalSince1970: TimeInterval(endsAt)),
channelID: channelID,
thumb: thumb
)
}
}
+25 -6
View File
@@ -16,12 +16,15 @@ Der Fahrplan steht in
| Phase | Inhalt | Stand |
|---|---|---|
| 0 | Projektgerüst, Design-Tokens, Navigation | ✅ |
| 1 | Anmeldung, Bibliothek, DirectPlay | offen |
| 2 | Transcoding, adaptive Bitrate, Spuren, Sync | offen |
| 3 | Download-Engine und Offline-Wiedergabe | offen |
| 4 | Download-Intelligenz | offen |
| 5 | Feinschliff und Personalisierung | offen |
| 6 | Live-TV mit EPG | offen |
| 1 | Anmeldung, Bibliothek, DirectPlay | ✅ Anmeldung am Server geprüft, der Rest nicht |
| 2 | Transcoding, adaptive Bitrate, Spuren, Sync | ✅ gebaut, ungeprüft |
| 3 | Download-Engine und Offline-Wiedergabe | ✅ gebaut, ungeprüft |
| 4 | Download-Intelligenz | ✅ gebaut; komprimierte Downloads ungeprüft |
| 5 | Feinschliff und Personalisierung | ✅ gebaut, ungeprüft |
| 6 | Live-TV mit EPG | ✅ gebaut, an keinem DVR geprüft |
„Ungeprüft" heißt: die Logik ist von Tests abgedeckt, die Oberfläche wurde aber
noch nie mit einem echten Bestand gesehen.
## Aufbau
@@ -72,6 +75,22 @@ erreichbar ist — siehe das README dort.
Die Oberfläche wird von Hand im Simulator geprüft.
## Was noch am echten Server zu prüfen ist
1. **Komprimierte Downloads.** Sie laufen über den Universal-Transcoder mit
`protocol=http` statt über die umständliche Sync-API. Offen: ob Plex eine
`Content-Length` mitschickt (ohne sie gibt es keinen Fortschrittsbalken und
kein Fortsetzen) und ob die Datei durchsuchbar ist. Original bleibt solange
Voreinstellung. Siehe `transcodedDownloadURL` in `PlexServer+Playback.swift`.
2. **Live-TV.** Der am schlechtesten dokumentierte Teil der Plex-API — es gibt
keine offizielle Beschreibung. Die Modelle sind durchgehend nachsichtig
gebaut, und ohne eingerichteten Empfänger erklärt die App das, statt einen
Fehler zu zeigen.
3. **App-Größe.** Die iOS-Slice von VLCKit ist unstripped 239 MB. Was davon im
fertigen Archiv übrig bleibt, ist noch nicht gemessen.
4. **Große Dateien im Hintergrund.** Das Verhalten mit einem 40-GB-Remux — vor
allem Pausieren und Fortsetzen — ist nur gegen Testdaten geprüft.
## Voraussetzungen
- Xcode 26 oder neuer
+35
View File
@@ -31,6 +31,7 @@ targets:
sources:
- path: Flixy
dependencies:
- target: DownloadWidget
- package: FlixyKit
product: PlexKit
- package: FlixyKit
@@ -75,6 +76,10 @@ targets:
UIBackgroundModes:
- audio
# Ohne diesen Schlüssel lehnt ActivityKit jede Anfrage ab — und
# zwar wortlos, was die Suche nach dem Grund unangenehm macht.
NSSupportsLiveActivities: true
NSAppTransportSecurity:
# Plex-Server im Heimnetz laufen überwiegend auf blankem HTTP an
# Port 32400. Diese Ausnahme erlaubt genau das, ohne die
@@ -87,3 +92,33 @@ targets:
NSLocalNetworkUsageDescription: >-
Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen
Plex-Server zu finden und Medien direkt von ihm zu laden.
# Die Live Activity für Downloads. Eine eigene Erweiterung, weil iOS die
# Darstellung im Sperrbildschirm in einem getrennten Prozess zeichnet — die
# App selbst läuft zu dem Zeitpunkt meist gar nicht.
DownloadWidget:
type: app-extension
platform: iOS
sources:
- path: DownloadWidget
dependencies:
- package: FlixyKit
product: FlixyDownloads
- package: FlixyKit
product: FlixyDesign
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.flixy.downloadwidget
PRODUCT_NAME: DownloadWidget
CODE_SIGN_STYLE: Automatic
SKIP_INSTALL: YES
# Von XcodeGen erzeugt statt von Hand geschrieben. Eine eigene Datei ohne
# CFBundleIdentifier führt zu einer Erweiterung ganz ohne Kennung — und
# der Fehler lautet dann „not prefixed with the parent app's bundle
# identifier", was auf eine falsche Fährte führt.
info:
path: DownloadWidget/Info.plist
properties:
CFBundleDisplayName: Flixy Downloads
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension