diff --git a/.github/workflows/prairie-invariants.yml b/.github/workflows/prairie-invariants.yml new file mode 100644 index 000000000..ce14f9a1e --- /dev/null +++ b/.github/workflows/prairie-invariants.yml @@ -0,0 +1,28 @@ +name: Prairie invariants + +# Build-free tripwire for Prairie-only code that upstream Silo syncs have +# silently deleted or unwired before (Live TV entries, Quick Connect, the +# connect list, trickplay, image-format header). Unfiltered so it runs on +# every PR, including a sync that only touches files it resolves to upstream. +# See docs/upstream-sync.md. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + prairie-invariants: + name: Prairie invariants + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Check Prairie invariants + run: scripts/check-prairie-invariants.sh diff --git a/.gitignore b/.gitignore index c26ac7770..e4459345f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ Thumbs.db docs/* !docs/tvos-focus.md !docs/mac-builder.md +!docs/upstream-sync.md !docs/tvos-player/ docs/tvos-player/* !docs/tvos-player/aetherengine-replacement-spec.md diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md new file mode 100644 index 000000000..e3f3839d2 --- /dev/null +++ b/docs/upstream-sync.md @@ -0,0 +1,44 @@ +# Syncing upstream Silo into Prairie Apple + +Prairie Apple is an AGPL fork of `Silo-Server/silo-apple`. Upstream syncs are +routine, and they are also the main way Prairie loses work. A conflicted file +resolved wholesale to upstream deletes Prairie's hunks, and the code often +still compiles because the Prairie type survives while nothing calls it. +Past losses: the first-run connect list (`ConnectServerListView`), the iOS +Quick Connect and update-status rows, the `X-Prairie-Image-Formats` header on +API requests, iOS trickplay scrub tiles, and Silo's diagnostics host coming +back. + +## Rules + +- Sync on a `sync/upstream-` branch with a real `git merge upstream/main` + and land it with **"Create a merge commit"**. Never squash; it drops upstream + ancestry and the next sync re-conflicts everything. +- **A sync PR merges only with CI green**, including the macOS `Apple + regression` and `Unit Tests` jobs. A failing test after a sync usually means + Prairie code was dropped while its test was kept. +- `Prairie invariants` (`scripts/check-prairie-invariants.sh`) must pass. If it + fails, restore the code. Do not edit the manifest to make it pass. + +## After resolving conflicts + +1. Rebrand fallout: `SiloAPI` → `PrairieAPI`, `silo*` colors and theme names → + `prairie*`, `X-Silo-*` → `X-Prairie-*`, `siloserver.org` hosts → + `prairieserver.org`. Keep wire tokens the server and other clients share + (`x-silo-original`, `_silocast._tcp`, `_silopair._tcp`, + `silo-public-diagnostics-v1`) unless the server changes them. +2. Hunk audit: for each Prairie PR, check its added lines still exist in the + merge result (`gh pr diff `). Upstream moves files, so search the whole + tree, not only the original path. +3. Reachability: every Prairie screen must still have a caller. Grep for + `LiveTVChannelListView(`, `QuickConnectView()`, `ConnectServerListView(`. + A view type with no call site means lost wiring. +4. New upstream tests that assert Silo strings or hosts need the Prairie value. +5. Run `scripts/check-prairie-invariants.sh` and `scripts/ci/check-no-api-v1.sh` + locally; both run on Linux. + +## When you restore something a sync dropped + +Add an anchor for it to `scripts/prairie-invariants.txt` in the same PR: the +file, the minimum number of matches, a regex on a stable identifier, and where +it came from. That is what stops the next sync from deleting it again. diff --git a/iosApp/Tests/HostedDiagnosticsAPITests.swift b/iosApp/Tests/HostedDiagnosticsAPITests.swift index d2549ce78..22886948d 100644 --- a/iosApp/Tests/HostedDiagnosticsAPITests.swift +++ b/iosApp/Tests/HostedDiagnosticsAPITests.swift @@ -9,7 +9,7 @@ final class HostedDiagnosticsAPITests: XCTestCase { func testDefaultCollectorSessionHasNoSharedCookiesOrCredentials() throws { XCTAssertEqual( HostedDiagnosticsAPI.defaultBaseURL, - try XCTUnwrap(URL(string: "https://diagnostics.siloserver.org")) + try XCTUnwrap(URL(string: "https://diagnostics.prairieserver.org")) ) let session = HostedDiagnosticsAPI.makeIsolatedSession() diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index ffdb3479a..5857f8123 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -656,11 +656,15 @@ struct ContentView: View { } case .needsServerSetup: - #if os(tvOS) - TVServerSetupView(router: router) - #else - ServerSetupView(router: router) - #endif + // Prairie: first-run connect list (Saved + LAN discovery, PR #8). + // Manual URL entry, and tvOS phone pairing, are one push away via + // "Add manually" (`.serverSetup`). + NavigationStack(path: $router.path) { + ConnectServerListView(router: router) + .navigationDestination(for: Route.self) { route in + destinationView(for: route) + } + } case .needsLogin: NavigationStack(path: $router.path) { diff --git a/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift b/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift index 555cde278..4a5c13144 100644 --- a/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift +++ b/iosApp/iosApp/Downloads/DownloadSessionDelegate.swift @@ -377,6 +377,7 @@ enum DownloadAuthHeaders { request.setValue(profileToken, forHTTPHeaderField: "X-Profile-Token") } AppleDeviceIdentity.current.applyHeaders(to: &request) + ImageFormats.apply(to: &request) return request } } diff --git a/iosApp/iosApp/Networking/HTTPClient.swift b/iosApp/iosApp/Networking/HTTPClient.swift index a0cb6ffd1..77882b6a8 100644 --- a/iosApp/iosApp/Networking/HTTPClient.swift +++ b/iosApp/iosApp/Networking/HTTPClient.swift @@ -1251,6 +1251,8 @@ actor HTTPClient { ) { let device = AppleDeviceIdentity.current device.applyHeaders(to: &request) + // Prairie: one-time raster capability for artwork URLs (PR #23). + ImageFormats.apply(to: &request) let trace = credentials + [ "device=\(device.platform)/\(device.clientFamily)", // Channel included deliberately: it is the only field that @@ -1279,6 +1281,7 @@ actor HTTPClient { request.setValue(profileToken, forHTTPHeaderField: "X-Profile-Token") } AppleDeviceIdentity.current.applyHeaders(to: &request) + ImageFormats.apply(to: &request) } // MARK: - Response handling diff --git a/iosApp/iosApp/Networking/HostedDiagnosticsAPI.swift b/iosApp/iosApp/Networking/HostedDiagnosticsAPI.swift index 439099a64..f9a224a67 100644 --- a/iosApp/iosApp/Networking/HostedDiagnosticsAPI.swift +++ b/iosApp/iosApp/Networking/HostedDiagnosticsAPI.swift @@ -122,8 +122,10 @@ enum HostedDiagnosticsAPIError: Error, Equatable { } actor HostedDiagnosticsAPI { + /// Prairie's collector, matching Android `DEFAULT_HOSTED_DIAGNOSTICS_BASE_URL`. + /// Upstream syncs bring back Silo's host; never send Prairie reports there. static let defaultBaseURL: URL = { - guard let url = URL(string: "https://diagnostics.siloserver.org") else { + guard let url = URL(string: "https://diagnostics.prairieserver.org") else { fatalError("The hosted diagnostics URL must be valid.") } return url diff --git a/iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift b/iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift index b54676e5d..32e567302 100644 --- a/iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift +++ b/iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift @@ -54,6 +54,13 @@ extension PrairieAPI { try await liveTVSend("DELETE", "/api/v1/livetv/sessions/\(try Self.encodePathSegment(sessionId))") } + /// Keep the tuner claimed while the player is open, including while + /// paused (no segment fetches). The server reclaims a session after 90 s + /// with neither a segment fetch nor a heartbeat. + func heartbeatLiveTVSession(sessionId: String) async throws { + try await liveTVSend("POST", "/api/v1/livetv/sessions/\(try Self.encodePathSegment(sessionId))/heartbeat") + } + func liveTVRecordings(status: String? = nil) async throws -> [LiveTVRecording] { var query: [String: String] = [:] if let status, !status.isEmpty { diff --git a/iosApp/iosApp/Pairing/PairingDeviceAPI.swift b/iosApp/iosApp/Pairing/PairingDeviceAPI.swift index 663ff8942..654371fe8 100644 --- a/iosApp/iosApp/Pairing/PairingDeviceAPI.swift +++ b/iosApp/iosApp/Pairing/PairingDeviceAPI.swift @@ -149,6 +149,7 @@ struct PairingDeviceAPI: PairingDeviceAuthorizing { request.setValue("application/json", forHTTPHeaderField: "Accept") if let bearer { request.setValue("Bearer \(bearer)", forHTTPHeaderField: "Authorization") } AppleDeviceIdentity.current.applyHeaders(to: &request) + ImageFormats.apply(to: &request) } private func send(_ request: URLRequest, expectedStatus: Int) async throws -> R { diff --git a/iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift b/iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift index a773ea7b8..f09ee9b94 100644 --- a/iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift +++ b/iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift @@ -13,6 +13,10 @@ struct LiveTVPlayerView: View { @State private var didRelease = false @State private var itemStatusObservation: NSKeyValueObservation? + /// Heartbeat cadence. The server reclaims a session after 90 s with no + /// segment fetch or heartbeat, and a paused player fetches nothing. + static let heartbeatInterval: Duration = .seconds(30) + var body: some View { ZStack(alignment: .topLeading) { Color.black.ignoresSafeArea() @@ -43,6 +47,9 @@ struct LiveTVPlayerView: View { .task { await startPlaybackIfPossible() } + .task(id: session.sessionId) { + await keepSessionAlive() + } .onDisappear { itemStatusObservation?.invalidate() itemStatusObservation = nil @@ -111,6 +118,22 @@ struct LiveTVPlayerView: View { return headers } + /// Heartbeats until the view goes away (task cancellation) or the + /// session is released. Failures are ignored: a missed beat is recovered + /// by the next one, and segment fetches also keep the session alive. + private func keepSessionAlive() async { + let sessionId = session.sessionId + while !Task.isCancelled { + do { + try await Task.sleep(for: Self.heartbeatInterval) + } catch { + return + } + guard !didRelease else { return } + try? await PrairieAPI.shared.heartbeatLiveTVSession(sessionId: sessionId) + } + } + private func dismissAndRelease() { releaseSessionIfNeeded() player?.pause() diff --git a/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift b/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift index 2f7acbd0b..1404ca15e 100644 --- a/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift +++ b/iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift @@ -473,14 +473,15 @@ struct MobilePlayerControls: View { ) .overlay(alignment: .topLeading) { if viewModel.isScrubbing { - let previewInset: CGFloat = viewModel.scrubPreviewImage == nil ? 80 : 102 + let hasArtwork = hasScrubPreviewArtwork + let previewInset: CGFloat = hasArtwork ? 102 : 80 scrubPreviewBubble .position( x: min( max(width * progress, previewInset), max(width - previewInset, previewInset) ), - y: viewModel.scrubPreviewImage == nil ? -36 : -92 + y: hasArtwork ? -92 : -36 ) .transition(.opacity) .allowsHitTesting(false) @@ -513,14 +514,29 @@ struct MobilePlayerControls: View { /// Floating time + chapter readout pinned above the touch point while /// scrubbing. Presentation-only: reads the same `scrubPreviewTime` the /// seek machinery already maintains. + /// + /// Prairie: the server's trickplay sprite tile wins (PR #28, same as the + /// tvOS scrubber), then Aether's decoded frame, then the chapter still. private var scrubPreviewBubble: some View { VStack(spacing: 6) { - if let image = viewModel.scrubPreviewImage { + if let tile = scrubTrickplayTile { + TrickplayTileImage(tile: tile, displayWidth: 176) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } else if let image = viewModel.scrubPreviewImage { Image(decorative: image, scale: 1) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 176, height: 99) .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + } else if let chapterURL = scrubChapterThumbnailURL { + CachedAsyncImage( + url: chapterURL, + targetSize: CGSize(width: 176, height: 99), + contentMode: .fill, + placeholderStyle: .clear + ) + .frame(width: 176, height: 99) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } Text(PlayerTimeFormatter.formatHMS(viewModel.scrubPreviewTime)) .font(.system(size: 19, weight: .bold)) @@ -538,6 +554,22 @@ struct MobilePlayerControls: View { .fixedSize() } + private var scrubTrickplayTile: TrickplayTilePreview? { + Trickplay.resolveTile(viewModel.trickplay, seconds: viewModel.scrubPreviewTime) + } + + private var scrubChapterThumbnailURL: String? { + guard let url = viewModel.chapterThumbnailURL(at: viewModel.scrubPreviewTime), + !url.isEmpty else { return nil } + return url + } + + private var hasScrubPreviewArtwork: Bool { + scrubTrickplayTile != nil + || viewModel.scrubPreviewImage != nil + || scrubChapterThumbnailURL != nil + } + private func chapterTitle(at time: Double) -> String? { guard let chapter = viewModel.chapters.last(where: { $0.time <= time }) else { return nil } return chapter.title ?? "Chapter \(chapter.index + 1)" diff --git a/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift b/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift index 6bd538cae..a3d03e56a 100644 --- a/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift +++ b/iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift @@ -36,6 +36,11 @@ struct IOSSettingsOverview: View { .listRowSeparator(.hidden) if hasSearchResults { + // Prairie: Quick Connect sits near the top (PR #22 / #24). + if matchesQuickConnect { + quickConnectSection + } + preferencesSection playbackSection @@ -173,6 +178,23 @@ struct IOSSettingsOverview: View { } } + /// Prairie: approve a sign-in code shown on a TV, Roku, Smart TV or the + /// web login screen (`QuickConnectView`). + private var quickConnectSection: some View { + Section { + NavigationLink { + QuickConnectView() + } label: { + SettingsOverviewRow( + title: "Quick Connect", + subtitle: "Sign in another device with its on-screen code", + systemImage: "qrcode.viewfinder" + ) + } + .accessibilityIdentifier("settings-quick-connect") + } + } + private var connectionSection: some View { Section("Connection") { Button { @@ -198,6 +220,46 @@ struct IOSSettingsOverview: View { value: versionString ) + // Prairie: update check against the Prairie release feed (PR #18). + SettingsOverviewRow( + title: "Update Status", + subtitle: "Compares this build with the latest Prairie release", + systemImage: "arrow.triangle.2.circlepath", + value: viewModel.appUpdateStatus.statusLabel + ) + .accessibilityIdentifier("settings-update-status") + + if let latest = viewModel.appUpdateStatus.latestVersionLabel { + SettingsOverviewRow( + title: "Latest Version", + subtitle: "Newest published Prairie release", + systemImage: "tag.fill", + value: latest + ) + } + + if let changelogURL = viewModel.appUpdateStatus.changelogURL { + Link(destination: changelogURL) { + SettingsOverviewRow( + title: "Changelog", + subtitle: "What changed in recent Prairie releases", + systemImage: "doc.text.fill", + showsChevron: true + ) + } + } + + if let releaseURL = viewModel.appUpdateStatus.releaseURL { + Link(destination: releaseURL) { + SettingsOverviewRow( + title: "View Update", + subtitle: "Open the newer Prairie release", + systemImage: "arrow.down.app.fill", + showsChevron: true + ) + } + } + Link(destination: PrairieLegalLinks.privacyPolicy) { SettingsOverviewRow( title: "Privacy Policy", @@ -351,6 +413,9 @@ struct IOSSettingsOverview: View { "about", "version", versionString, + "update", + "changelog", + "release", "privacy", "policy", "information", @@ -360,6 +425,10 @@ struct IOSSettingsOverview: View { ) } + private var matchesQuickConnect: Bool { + matches("quick connect", "code", "sign in", "device", "tv", "pair") + } + private var matchesExperimentalName: Bool { matches("experimental", "beta", "testing") } @@ -379,7 +448,8 @@ struct IOSSettingsOverview: View { } private var hasSearchResults: Bool { - matchesGeneral + matchesQuickConnect + || matchesGeneral || matchesInterface || matchesPlaybackSection || (diagnosticsModel.shouldShowSettings && matchesDiagnostics) diff --git a/iosApp/iosApp/Screens/Settings/SettingsView.swift b/iosApp/iosApp/Screens/Settings/SettingsView.swift index d1bea61f7..1c4cf266d 100644 --- a/iosApp/iosApp/Screens/Settings/SettingsView.swift +++ b/iosApp/iosApp/Screens/Settings/SettingsView.swift @@ -227,6 +227,17 @@ struct SettingsView: View { private var connectionSection: some View { Section { + // Prairie: Quick Connect approver (PR #22). + NavigationLink { + QuickConnectView() + } label: { + SettingsRowLabel( + title: "Quick Connect", + systemImage: "qrcode.viewfinder", + color: .green + ) + } + Button { router.navigate(to: .serverList) } label: { @@ -257,6 +268,30 @@ struct SettingsView: View { .foregroundStyle(Color.prairieOnSurface) } + // Prairie: update check against the Prairie release feed (PR #18). + LabeledContent { + Text(viewModel.appUpdateStatus.statusLabel) + .foregroundStyle(Color.prairieSecondaryText) + } label: { + Text("Update status") + .foregroundStyle(Color.prairieOnSurface) + } + if let latest = viewModel.appUpdateStatus.latestVersionLabel { + LabeledContent { + Text(latest) + .foregroundStyle(Color.prairieSecondaryText) + } label: { + Text("Latest version") + .foregroundStyle(Color.prairieOnSurface) + } + } + if let changelogURL = viewModel.appUpdateStatus.changelogURL { + Link("Changelog", destination: changelogURL) + } + if let releaseURL = viewModel.appUpdateStatus.releaseURL { + Link("View update", destination: releaseURL) + } + Link("Privacy Policy", destination: PrairieLegalLinks.privacyPolicy) NavigationLink { diff --git a/iosApp/iosApp/tvOS/Caching/ImageFormats.swift b/iosApp/iosApp/tvOS/Caching/ImageFormats.swift index f285c27d8..ebec3c60e 100644 --- a/iosApp/iosApp/tvOS/Caching/ImageFormats.swift +++ b/iosApp/iosApp/tvOS/Caching/ImageFormats.swift @@ -10,9 +10,17 @@ enum ImageFormats { /// Ordered best-first format tokens for this process. static var preferred: [String] = [avif, webp, png] + /// Request header the server reads to pick AVIF / WebP / PNG artwork. + static let headerField = "X-Prairie-Image-Formats" + /// Value for the `X-Prairie-Image-Formats` request header. static var headerValue: String { preferred.joined(separator: ",") } + /// Stamp the raster preference on an outgoing Prairie request. + static func apply(to request: inout URLRequest) { + request.setValue(headerValue, forHTTPHeaderField: headerField) + } + /// Replace the process-wide preference list (tests / future OS gates). static func configure(_ formats: [String]) { let next = formats diff --git a/scripts/check-prairie-invariants.sh b/scripts/check-prairie-invariants.sh new file mode 100755 index 000000000..e8815eb00 --- /dev/null +++ b/scripts/check-prairie-invariants.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Fails when Prairie-only code listed in scripts/prairie-invariants.txt is gone. +# Upstream syncs have repeatedly resolved files wholesale to upstream and +# deleted Prairie wiring while keeping its tests; this is a cheap, build-free +# tripwire that runs on every PR. See docs/upstream-sync.md. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +manifest=scripts/prairie-invariants.txt +failures=0 +checked=0 +while IFS=$'\t' read -r path min pattern why || [[ -n "${path:-}" ]]; do + [[ -z "${path// }" || "$path" == \#* ]] && continue + checked=$((checked + 1)) + if [[ ! -f "$path" ]]; then + echo "::error file=$path::Prairie invariant: file missing ($why)" + failures=$((failures + 1)) + continue + fi + count=$(grep -cE -- "$pattern" "$path" || true) + if (( count < min )); then + echo "::error file=$path::Prairie invariant: /$pattern/ matched $count, need $min ($why)" + failures=$((failures + 1)) + fi +done < "$manifest" +if (( failures > 0 )); then + echo "$failures of $checked Prairie invariants failed. If an upstream sync dropped this code, restore it; do not delete the invariant." >&2 + exit 1 +fi +echo "All $checked Prairie invariants hold." diff --git a/scripts/ci/api-v1-allowlist.txt b/scripts/ci/api-v1-allowlist.txt index 4d2b4d992..2b720fb79 100644 --- a/scripts/ci/api-v1-allowlist.txt +++ b/scripts/ci/api-v1-allowlist.txt @@ -12,4 +12,4 @@ iosApp/iosApp/Discovery/LanDiscovery.swift 3 iosApp/iosApp/Networking/ConnectionMonitor.swift 1 iosApp/iosApp/Networking/HTTPClient.swift 1 iosApp/iosApp/Networking/LiveTVModels.swift 4 -iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 10 +iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 11 diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt new file mode 100644 index 000000000..421e2f090 --- /dev/null +++ b/scripts/prairie-invariants.txt @@ -0,0 +1,73 @@ +# Prairie-only Apple client code that upstream (Silo) syncs have silently +# deleted or unwired before. +# +# One invariant per line, tab-separated: +# +# +# scripts/check-prairie-invariants.sh fails CI when a file is missing or the +# pattern matches fewer times than required. When you restore something a sync +# dropped, add its anchor here in the same PR so the next sync cannot drop it +# unnoticed. Keep patterns on stable identifiers, not on formatting. + +# --- Rebrand (PR #1). Wire names that must stay Prairie. +iosApp/project.yml 1 bundleIdPrefix: org\.prairieserver\.prairie Prairie bundle IDs (#1) +iosApp/iosApp/Networking/PrairieAPI.swift 1 ^actor PrairieAPI PrairieAPI client name (#1; upstream calls it SiloAPI) +iosApp/iosApp/Shared/AppleDeviceIdentity.swift 1 X-Prairie-Device-Id X-Prairie-* identity headers +iosApp/iosApp/Networking/HostedDiagnosticsAPI.swift 1 diagnostics\.prairieserver\.org hosted diagnostics collector; sync #31 brought back Silo's host +iosApp/iosApp/tvOS/Navigation/TVTopMenuBar.swift 1 PrairieWordmarkView tvOS top bar wordmark (#7) +iosApp/iosApp/Theme/Colors.swift 1 prairieBackground = Color\(hex: \"#141820\"\) Prairie dusk palette (#10, #12) + +# --- Live TV (PR #3, #10, #12, #26): API, gating, iOS tab and tvOS root. +iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 1 func liveTVChannels\( channel lineup / feature probe +iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 1 func startLiveTVSession\( live tuner session start +iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 1 func heartbeatLiveTVSession\( tuner heartbeat; server reclaims idle sessions after 90 s +iosApp/iosApp/Networking/PrairieAPI+LiveTV.swift 1 func scheduleLiveTVRecording\( DVR scheduling (#10) +iosApp/iosApp/Networking/LiveTVFeatureStore.swift 1 final class LiveTVFeatureStore Live TV availability gate +iosApp/iosApp/Navigation/TabRouter.swift 1 case liveTV iOS Live TV tab id +iosApp/iosApp/ContentView.swift 1 LiveTVFeatureStore\.shared\.isEnabled iOS Live TV tab appears when channels exist +iosApp/iosApp/ContentView.swift 2 LiveTVFeatureStore\.shared\.refresh\(\) Live TV probe on launch and foreground +iosApp/iosApp/ContentView.swift 1 LiveTVChannelListView\( iOS Live TV screen +iosApp/iosApp/ContentView.swift 1 LiveTVPlayerView\( iOS Live TV player +iosApp/iosApp/tvOS/Navigation/TVMainTabView.swift 1 LiveTVFeatureStore\.shared\.isEnabled tvOS Live TV root gating +iosApp/iosApp/tvOS/Navigation/TVMainTabView.swift 1 LiveTVChannelListView\( tvOS Live TV screen +iosApp/iosApp/tvOS/Navigation/TVMainTabView.swift 1 LiveTVPlayerView\( tvOS Live TV player +iosApp/iosApp/tvOS/Navigation/TVTopMenuBar.swift 1 case liveTV tvOS top bar Live TV entry +iosApp/iosApp/Screens/Auth/AuthService.swift 1 LiveTVFeatureStore\.shared\.reset\(\) reset Live TV gate on sign-out / profile switch +iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift 1 releaseLiveTVSession\( free the tuner on dismiss +iosApp/iosApp/Screens/LiveTV/LiveTVPlayerView.swift 1 heartbeatLiveTVSession\( keep the tuner while paused +iosApp/iosApp/Screens/LiveTV/LiveTVChannelListView.swift 1 livetv-tab- Guide / Channels / My recordings tabs (#26) + +# --- Stats for nerds (upstream panel + Prairie wiring). +iosApp/iosApp/Screens/Player/tvOS/TVPlayerInfoHUD.swift 1 PlaybackStatsPanel\( tvOS Info HUD stats pane +iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift 1 MobilePlaybackStatsOverlay\( iOS stats overlay +iosApp/iosApp/Screens/Player/PlayerViewModel.swift 1 AetherPlaybackStatsProjection\.make\( stats refresh from Aether + +# --- Trickplay scrub previews (PR #28). +iosApp/iosApp/Networking/Models.swift 1 let trickplay: VersionTrickplay\? trickplay sprite metadata on file versions +iosApp/iosApp/Screens/Player/PlayerViewModel.swift 1 var trickplay: VersionTrickplay\? active version trickplay for scrubbers +iosApp/iosApp/Screens/Player/tvOS/TVPlayerScrubber.swift 1 ScrubPreviewBubble\( tvOS scrub preview bubble +iosApp/iosApp/Screens/Player/iOS/MobilePlayerControls.swift 1 Trickplay\.resolveTile\( iOS scrub bubble uses trickplay tiles; lost in the Aether sync + +# --- Artwork formats and width variants (PR #9, #23, #28). +iosApp/iosApp/tvOS/Caching/ArtworkVariantURL.swift 1 static func widthVariant\( w200/w300/w500 poster rungs +iosApp/iosApp/tvOS/Caching/CachedAsyncImage.swift 1 ArtworkVariantURL\.candidates\( AVIF/WebP/PNG candidate cascade +iosApp/iosApp/tvOS/Caching/ImageFormats.swift 1 X-Prairie-Image-Formats raster capability header name +iosApp/iosApp/Networking/HTTPClient.swift 2 ImageFormats\.apply\(to: image-format header on API requests; lost in a sync +iosApp/iosApp/Downloads/DownloadSessionDelegate.swift 1 ImageFormats\.apply\(to: image-format header on downloads +iosApp/iosApp/Pairing/PairingDeviceAPI.swift 1 ImageFormats\.apply\(to: image-format header on pairing requests + +# --- Connect flow: server list first, with LAN discovery (PR #8, #21). +iosApp/iosApp/ContentView.swift 1 ConnectServerListView\(router: router\) first-run connect list; lost in a sync +iosApp/iosApp/Discovery/LanDiscovery.swift 1 ^enum LanDiscovery LAN server discovery + +# --- Settings (PR #18, #22, #24). +iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift 1 QuickConnectView\(\) iOS Quick Connect entry; lost in the settings restyle sync +iosApp/iosApp/Screens/Settings/SettingsView.swift 1 QuickConnectView\(\) macOS Quick Connect entry +iosApp/iosApp/Screens/Settings/IOSSettingsOverview.swift 1 appUpdateStatus iOS About update status; lost in the settings restyle sync +iosApp/iosApp/tvOS/Screens/Settings/TVSettingsView.swift 1 appUpdateStatus tvOS About update status +iosApp/iosApp/Screens/Settings/SettingsViewModel.swift 1 AppUpdateChecker\.check\(\) update check + +# --- CI gates (PR #2, #13, #17, #20). +.github/workflows/unit-tests.yml 1 check-xccov-coverage\.sh Networking coverage gate +.github/workflows/unit-tests.yml 1 LiveTVModelDecodingTests Live TV decoding tests run in CI +.github/workflows/prairie-invariants.yml 1 check-prairie-invariants\.sh this guard