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