diff --git a/DownloadWidget/DownloadLiveActivity.swift b/DownloadWidget/DownloadLiveActivity.swift
new file mode 100644
index 0000000..30c2562
--- /dev/null
+++ b/DownloadWidget/DownloadLiveActivity.swift
@@ -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()
+ }
+ }
+}
diff --git a/DownloadWidget/DownloadWidgetBundle.swift b/DownloadWidget/DownloadWidgetBundle.swift
new file mode 100644
index 0000000..7ffb9c9
--- /dev/null
+++ b/DownloadWidget/DownloadWidgetBundle.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct DownloadWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ DownloadLiveActivity()
+ }
+}
diff --git a/DownloadWidget/Info.plist b/DownloadWidget/Info.plist
new file mode 100644
index 0000000..74c0a10
--- /dev/null
+++ b/DownloadWidget/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Flixy Downloads
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/Flixy/Info.plist b/Flixy/Info.plist
index 2a7d6f9..5adb7c9 100644
--- a/Flixy/Info.plist
+++ b/Flixy/Info.plist
@@ -27,6 +27,8 @@
NSLocalNetworkUsageDescription
Flixy benötigt Zugriff auf das lokale Netzwerk, um deinen Plex-Server zu finden und Medien direkt von ihm zu laden.
+ NSSupportsLiveActivities
+
UIBackgroundModes
audio
diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings
index c135abf..b73cd69 100644
--- a/Flixy/Resources/Localizable.xcstrings
+++ b/Flixy/Resources/Localizable.xcstrings
@@ -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"
diff --git a/Flixy/Views/LiveTV/LiveTVView.swift b/Flixy/Views/LiveTV/LiveTVView.swift
new file mode 100644
index 0000000..b4a3f18
--- /dev/null
+++ b/Flixy/Views/LiveTV/LiveTVView.swift
@@ -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)
+ }
+}
diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift
index f771078..41012a6 100644
--- a/Flixy/Views/Navigation/RootView.swift
+++ b/Flixy/Views/Navigation/RootView.swift
@@ -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:
diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadActivityPresenter.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadActivityPresenter.swift
new file mode 100644
index 0000000..e4397f0
--- /dev/null
+++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadActivityPresenter.swift
@@ -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?
+ /// 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
diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift
index 7b81c4d..4dd4267 100644
--- a/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift
+++ b/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadCoordinator.swift
@@ -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
diff --git a/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadActivityAttributes.swift b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadActivityAttributes.swift
new file mode 100644
index 0000000..c3fa53c
--- /dev/null
+++ b/Packages/FlixyKit/Sources/FlixyDownloads/Model/DownloadActivityAttributes.swift
@@ -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
diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
new file mode 100644
index 0000000..23c0c81
--- /dev/null
+++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+LiveTV.swift
@@ -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.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.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.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.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.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 }
+}
diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
new file mode 100644
index 0000000..19e8d1f
--- /dev/null
+++ b/Packages/FlixyKit/Sources/PlexKit/Models/PlexLiveTV.swift
@@ -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
+ )
+ }
+}
diff --git a/README.md b/README.md
index 4e0b7e2..8f5fbbb 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/project.yml b/project.yml
index f770f60..2cac1eb 100644
--- a/project.yml
+++ b/project.yml
@@ -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