From 1907435e220934fe3d46a4f62352ad47044ad6c7 Mon Sep 17 00:00:00 2001 From: JonahMMay Date: Wed, 30 Sep 2026 13:25:54 +0000 Subject: [PATCH 1/2] fix(sync): restore Live TV navigation and other Prairie code lost in syncs Upstream syncs #27 and #28 resolved files wholesale to Silo and dropped Prairie wiring while the code behind it kept compiling: - Live TV: phone and TV screens existed but no route, destination or profile-menu entry reached them. Restore the phone routes, NavHost destinations and ProfileMenu item (provided via LocalLiveTvMenuAction so future menu refactors cannot drop it), and the TV shell destinations and profile dropdown row. Feature-gated by LiveTvFeatureStore as before. - LAN discovery: first run lands on the server list again (phone and TV), scans once, and the TV list shows Discovered servers and Scan network. - Phone Prairie Dusk palette tokens, the AVIF/WebP/PNG artwork cascade in ThumbhashImage and startup warmup, the app update status rows (phone and TV), the Quick Connect and Stats for nerds labels. - User-visible "Silo" strings in app code and the README (including a trademark paragraph that attributed the Silo marks to the wrong owner). Add scripts/check-prairie-invariants.sh + scripts/prairie-invariants.txt and a "Prairie invariants" CI job so the next sync that drops any of this fails CI, plus docs/upstream-sync.md. Route unit tests cover the Live TV route contract on both apps. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/android-build.yml | 19 ++ AGENTS.md | 2 + README.md | 43 ++--- .../prairie/common/startup/StartupWarmup.kt | 9 +- .../common/ui/components/ThumbhashImage.kt | 27 ++- .../prairie/android/MainActivity.kt | 4 +- .../android/ui/components/LiveTvMenuEntry.kt | 16 ++ .../android/ui/components/ProfileMenu.kt | 14 +- .../android/ui/navigation/AppNavigation.kt | 46 ++++- .../prairie/android/ui/navigation/Routes.kt | 22 +++ .../prairie/android/ui/screens/MainScreen.kt | 16 ++ .../screens/cast/PrairieCastRemoteScreen.kt | 2 +- .../ui/screens/player/PlayerSettingsSheet.kt | 2 +- .../ui/screens/settings/AccountSection.kt | 2 +- .../ui/screens/settings/PlaybackSettings.kt | 6 +- .../ui/screens/settings/ServerInfoSection.kt | 37 ++++ .../ui/screens/settings/SettingsScreen.kt | 2 +- .../ui/screens/settings/SubtitleSettings.kt | 8 +- .../diagnostics/DiagnosticsReportScreen.kt | 4 +- .../diagnostics/DiagnosticsSettingsScreen.kt | 2 +- .../prairie/android/ui/theme/Color.kt | 27 +-- .../src/androidMain/res/values/strings.xml | 2 +- .../android/ui/navigation/LiveTvRouteTest.kt | 38 ++++ .../prairie/tv/MainTvActivity.kt | 4 +- .../tv/ui/navigation/TvAppNavigation.kt | 10 +- .../prairie/tv/ui/navigation/TvRoute.kt | 4 +- .../tv/ui/screens/auth/TvPairDeviceScreen.kt | 2 +- .../tv/ui/screens/auth/TvServerSetupScreen.kt | 4 +- .../tv/ui/screens/auth/TvSetupScreen.kt | 2 +- .../tv/ui/screens/auth/TvSignupScreen.kt | 2 +- .../ui/screens/servers/TvServerListScreen.kt | 182 +++++++++++++++++- .../ui/screens/settings/TvSettingsScreen.kt | 37 +++- .../diagnostics/TvDiagnosticsPromptScreen.kt | 4 +- .../diagnostics/TvDiagnosticsReportScreen.kt | 4 +- .../diagnostics/TvDiagnosticsSettingsPane.kt | 2 +- .../prairie/tv/ui/shell/TvMainShell.kt | 53 +++++ .../tv/ui/navigation/TvLiveTvRouteTest.kt | 35 ++++ docs/upstream-sync.md | 40 ++++ scripts/check-prairie-invariants.sh | 66 +++++++ scripts/prairie-invariants.txt | 109 +++++++++++ 40 files changed, 831 insertions(+), 79 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/LiveTvMenuEntry.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/LiveTvRouteTest.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvLiveTvRouteTest.kt create mode 100644 docs/upstream-sync.md create mode 100755 scripts/check-prairie-invariants.sh create mode 100644 scripts/prairie-invariants.txt diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index ea54248de..70ae5f7d9 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -93,6 +93,25 @@ jobs: path: android-shared/libs/media3-decoder-ffmpeg-1.11.0.aar if-no-files-found: error + prairie-invariants: + name: Prairie invariants + if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check out sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + # Build-free tripwire for Prairie-only code that upstream Silo syncs have + # silently deleted (Live TV navigation, LAN discovery, rebrand strings). + # If this fails after a sync, restore the code; do not edit the manifest + # to make it pass. See docs/upstream-sync.md. + - name: Check Prairie invariants + run: ./scripts/check-prairie-invariants.sh + release-readiness: name: Release readiness if: github.event_name != 'workflow_dispatch' || !inputs.build_ffmpeg_aar diff --git a/AGENTS.md b/AGENTS.md index 31d048461..ce518f9a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,8 @@ This repository contains only the Prairie Android clients. Shared Kotlin logic l - Ebooks/Reading are phone-only. Do not expose ebooks or Reading on Android TV. - Android mobile navigation is Home, Libraries, For You, Calendar, and Downloads only when the active profile has downloads. Video, Audio, and Reading are library modes reached through Libraries, not bottom-nav tabs. - Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), and Calendar, plus search and profile actions. Reading/ebooks are excluded. +- Live TV (Prairie-only) is live on phone and TV, gated by `LiveTvFeatureStore` (the server has channels), and reached from the profile menu (phone `ProfileMenu` via `LocalLiveTvMenuAction`; TV profile dropdown). It is not a bottom-nav or top-menu tab. +- Upstream Silo syncs have silently dropped Prairie-only code. `scripts/check-prairie-invariants.sh` (CI job "Prairie invariants") anchors it; see `docs/upstream-sync.md`. When restoring dropped code, add an anchor. - Requests is live on phone and TV, server-gated by `requests_enabled` (profile menu + search entry points, matching Apple). Admin surfaces are not exposed in the Android clients — no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans) — and neither is Watch Together. Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision. ## Skills & review agents diff --git a/README.md b/README.md index d662fbad3..fa51ab162 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# Silo Android +# Prairie Android -Android **phone** and **Android TV** clients for the [Silo](https://github.com/Silo-Server/silo-server) self-hosted media server — stream and download your movies, shows, music, audiobooks, and ebooks, with quality-aware playback and multi-server/multi-profile support. +Android **phone** and **Android TV** clients for the [Prairie](https://github.com/Prairie-Server/prairie-server) self-hosted media server — stream and download your movies, shows, music, audiobooks, and ebooks, with quality-aware playback and multi-server/multi-profile support. -Built as a Kotlin Multiplatform project: one shared business-logic core, two Jetpack Compose apps (touch + 10-foot TV). This branch uses the full Silo namespace cut: Kotlin packages live under `org.prairieserver.prairie`, and both apps share a single application ID `org.prairieserver.prairie` so they publish as one Google Play listing (Play routes each build by manifest feature filtering). Installs under legacy IDs do not upgrade in place; users should expect a fresh app install, sign-in, and offline media download. +Built as a Kotlin Multiplatform project: one shared business-logic core, two Jetpack Compose apps (touch + 10-foot TV). This branch uses the full Prairie namespace cut: Kotlin packages live under `org.prairieserver.prairie`, and both apps share a single application ID `org.prairieserver.prairie` so they publish as one Google Play listing (Play routes each build by manifest feature filtering). Installs under legacy IDs do not upgrade in place; users should expect a fresh app install, sign-in, and offline media download. > **Status:** WIP (`v0.3.x`). Prairie Android is pre-1.0 and some areas remain under active development (see [Roadmap](#roadmap)). > @@ -41,7 +41,7 @@ Built as a Kotlin Multiplatform project: one shared business-logic core, two Jet | **Images** | Coil 3 (Ktor-backed) | | **SDK** | Android 7.0+ / minSdk 24 · targetSdk 36 · compileSdk 36 · JDK 21 | -The clients talk to a Silo server over its `/api/v1/*` REST + WebSocket API. The server owns the library, scanning, metadata, transcoding decisions, and auth; the clients render it and drive playback. +The clients talk to a Prairie server over its `/api/v1/*` REST + WebSocket API. The server owns the library, scanning, metadata, transcoding decisions, and auth; the clients render it and drive playback. Android 7.0 and 7.1 (API 24/25) are supported on both phone and Android TV. Video and audiobook playback use the same Media3 service on every supported Android version. @@ -53,8 +53,8 @@ Latest debug APKs are published on each tagged release. | App | Downloader code | Downloader link | Direct APK | |---|---:|---|---| -| Android | `1051382` | | | -| Android TV | `1636227` | | | +| Android | `1051382` | | | +| Android TV | `1636227` | | | Install **Downloader by AFTVnews** on Android TV / Google TV, enter the code for the app you want, then allow APK installs from Downloader when prompted. @@ -91,18 +91,18 @@ WorkManager-backed downloads of video, audiobooks, and books to public device st - **Audiobook player (phone + TV)** — cover/metadata, chapters, resume, playback speed, sleep timer (incl. end-of-chapter), and bookmarks, sharing the same Media3 engine as video. TV has a dedicated ten-foot audiobook detail/player flow. ### PrairieControl (phone + TV) -Android phone can discover PrairieCast receivers on the local network, launch movies/episodes on TV with the selected file/track/resume context, and act as a lightweight remote for play/pause, seek, quality, audio, and subtitle changes. TV advertises the local PrairieCast receiver only while authenticated and foregrounded. The channel is TLS-PSK and wire-compatible with the Apple clients' PrairieControl protocol (same `_silocast._tcp` service, hello/serverId authorization, heartbeat), so Android phones can cast to Apple TVs and iPhones to Android TVs. +Android phone can discover PrairieCast receivers on the local network, launch movies/episodes on TV with the selected file/track/resume context, and act as a lightweight remote for play/pause, seek, quality, audio, and subtitle changes. TV advertises the local PrairieCast receiver only while authenticated and foregrounded. The channel is TLS-PSK and wire-compatible with the Apple clients' PrairieControl protocol (same `_prairiecast._tcp` service, hello/serverId authorization, heartbeat), so Android phones can cast to Apple TVs and iPhones to Android TVs. ### 🔔 Personalization & engagement (phone + TV) -Multiple **household profiles** per account (PINs, child profiles, content-rating limits, per-profile language/subtitle prefs), favorites & watchlist, ratings, a release **calendar**, and an in-app **notifications inbox** with realtime updates. Android push uses Silo's content-free push relay when the server and app are configured for FCM. TV mirrors continue-watching into the system **Watch Next** row. +Multiple **household profiles** per account (PINs, child profiles, content-rating limits, per-profile language/subtitle prefs), favorites & watchlist, ratings, a release **calendar**, and an in-app **notifications inbox** with realtime updates. Android push uses Prairie's content-free push relay when the server and app are configured for FCM. TV mirrors continue-watching into the system **Watch Next** row. ### 🌐 Multi-server & accounts (phone + TV) -Add and switch between multiple Silo servers (encrypted per-server token slots), use username/password or device/QR sign-in, and manage household profiles. Admin screens are not currently exposed in the Android apps. +Add and switch between multiple Prairie servers (encrypted per-server token slots), use username/password or device/QR sign-in, and manage household profiles. Admin screens are not currently exposed in the Android apps. ### Client diagnostics (phone + TV) Android-native diagnostics can retain a bounded, redacted local report for crashes, ANRs, playback, networking, focus, cast, downloads, and lifecycle events. A two-segment journal keeps only curated, already-redacted lifecycle breadcrumbs so next-launch ANR/native-crash reports retain pre-exit context; identity transitions rotate it, and Never/sign-out purges it. Adult profiles can review and delete account-scoped reports on-device, choose consent, or run a timed diagnostic capture. Child profiles cannot capture, review, or upload reports. -The default destination is Silo's hosted collector at `diagnostics.prairieserver.org`; self-hosted Silo ingest remains an explicit compatibility choice. Hosted collection is manual/Ask-only, verifies the live collector identity before a new capture, and re-attests the authenticated source-server account before a first upload. Self-hosted collection supports Ask / Always / Never when the originating server advertises diagnostics support. Reports are never retargeted across destination, server, account, or profile boundaries. Profile transitions close the capture gate and rotate live evidence without discarding retained account reports; sign-out, server removal, and Never consent purge the applicable evidence. One-off manual reports remain available under Never without enabling persistent capture. +The default destination is Prairie's hosted collector at `diagnostics.prairieserver.org`; self-hosted Prairie ingest remains an explicit compatibility choice. Hosted collection is manual/Ask-only, verifies the live collector identity before a new capture, and re-attests the authenticated source-server account before a first upload. Self-hosted collection supports Ask / Always / Never when the originating server advertises diagnostics support. Reports are never retargeted across destination, server, account, or profile boundaries. Profile transitions close the capture gate and rotate live evidence without discarding retained account reports; sign-out, server removal, and Never consent purge the applicable evidence. One-off manual reports remain available under Never without enabling persistent capture. This feature does not use Sentry, GlitchTip, Crashlytics, OpenTelemetry, ACRA, or another hosted observability SDK. Crash-time work is local and bounded; exact credential values receive a bounded replacement before the app-private marker is written, structural redaction runs during next-launch report assembly, and archive construction and upload occur after restart. Hosted reports preserve the exact application version, build number, and OS version; privacy filtering targets server network identity, account or personal identity, and credentials rather than release metadata. @@ -149,7 +149,7 @@ Three library layers under two app shells. Dependencies only point downward. `sharedModules()` (network + repositories) is combined with the player and Android modules at app startup. The Android apps override the in-memory `TokenManager`/`ServerRegistry` with persistent implementations. ViewModels are resolved with `koinViewModel()`; nav arguments flow in through Koin parameters / `SavedStateHandle`. ### Navigation & boot -Each app computes a start destination from registry/token/profile/offline state (`ServerSetup → Login → ProfileSelection → main`), then runs a Compose nav graph. The phone uses an Apple-aligned bottom shell (`Home`, `Libraries`, `For You`, `Calendar`, and conditional `Downloads`). The TV uses a tvOS-aligned top menu (`Home`, available media-type tabs, `For You`, `Calendar`, plus search/profile actions). Deep links handle device pairing through `silo://device?...` and supported HTTPS `/device` or `/auth/device` URLs. +Each app computes a start destination from registry/token/profile/offline state (`ServerSetup → Login → ProfileSelection → main`), then runs a Compose nav graph. The phone uses an Apple-aligned bottom shell (`Home`, `Libraries`, `For You`, `Calendar`, and conditional `Downloads`). The TV uses a tvOS-aligned top menu (`Home`, available media-type tabs, `For You`, `Calendar`, plus search/profile actions). Deep links handle device pairing through `prairie://device?...` and supported HTTPS `/device` or `/auth/device` URLs. ### Playback pipeline The UI never owns the player directly — a `MediaController` drives the shared `PrairiePlaybackService` (a Media3 `MediaSessionService`), so there is exactly one player and system session. For video, `PlaybackSessionManager` sends protocol-v3 capabilities and executes the server's direct/remux/transcode plan; classified failures and track/output changes use the v3 replan endpoint. `PlaybackSessionLifecycle` owns progress and outage handling. Offline playback bypasses the server through a local `file://` URI. The normative contract is in [`docs/playback`](docs/playback/README.md). @@ -184,7 +184,7 @@ Tests live in each module's test source set (`commonTest`, `androidUnitTest`) us ### Prerequisites - **JDK 21** - Android SDK with the configured compile SDK (36) -- A running **Silo server** for auth, browsing, and playback validation — see [`Silo-Server/silo-server`](https://github.com/Silo-Server/silo-server) +- A running **Prairie server** for auth, browsing, and playback validation — see [`Prairie-Server/prairie-server`](https://github.com/Prairie-Server/prairie-server) ### Build @@ -200,7 +200,7 @@ Tests live in each module's test source set (`commonTest`, `androidUnitTest`) us ./gradlew :androidTvApp:installDebug ``` -On first launch, point the app at your Silo server URL, sign in, and pick a profile. (Android TV can't bootstrap first-time server setup — set the server up from the phone app or a web browser, then sign the TV in via username/password or QR/device pairing.) +On first launch, point the app at your Prairie server URL, sign in, and pick a profile. (Android TV can't bootstrap first-time server setup — set the server up from the phone app or a web browser, then sign the TV in via username/password or QR/device pairing.) --- @@ -222,7 +222,7 @@ On first launch, point the app at your Silo server URL, sign in, and pick a prof - **Shared first** — put platform-agnostic logic (models, networking, view-model logic, pure algorithms) in `shared`; keep Android-only concerns in `android-shared`; keep each app's module to its UI. New non-UI behavior that both apps need belongs in a shared module, not duplicated per app. - **Compose** screens are thin; logic lives in ViewModels (testable in `commonTest` where possible). - Design specs and implementation plans for larger efforts live under `docs/superpowers/{specs,plans}/`. -- This is part of a multi-repo Silo workspace — client-visible API/auth/playback changes often need coordinated work in `silo-server` (and the sibling `silo-apple` clients). +- This is part of a multi-repo Prairie workspace — client-visible API/auth/playback changes often need coordinated work in `prairie-server` (and the sibling `prairie-apple` / `prairie-smarttv` clients). ## Contributing @@ -248,17 +248,16 @@ Known gaps the docs track: TV has no reader/ebooks and no downloads management b - The Android phone and TV apps share one application ID, `org.prairieserver.prairie`, and publish as a single Google Play listing; Play delivers the right build per device via manifest feature filtering (phone requires a touchscreen, TV requires leanback). - The Android modules target Java 21. -- The server repo lives at [`Silo-Server/silo-server`](https://github.com/Silo-Server/silo-server). +- The server repo lives at [`Prairie-Server/prairie-server`](https://github.com/Prairie-Server/prairie-server). Prairie is a fork of [Silo](https://github.com/Silo-Server/silo-server). ## License & Trademarks -Silo Android is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). +Prairie Android is licensed under `AGPL-3.0-or-later`. See [LICENSE](LICENSE). -The **Silo name, logo, and wordmark are trademarks of Prairie L.L.C.** and -are **not** covered by the AGPL. You're free to fork and redistribute the code, -but forks and redistributions must not use the Silo brand as their identity and -must remove or replace the brand assets. Publishing a Silo-branded app to an app -store requires written permission. See [TRADEMARK.md](TRADEMARK.md) for what's -permitted — including referential use like "compatible with Silo." +Prairie is a rebranded fork of Silo. The AGPL covers the code, but it does not +license the Silo name, logo, wordmark, or other Silo Media L.L.C. marks. This +repository replaces Silo product identity with Prairie identifiers and assets; +factual references such as "fork of Silo" remain permitted when they are +truthful and non-confusing. See [TRADEMARK.md](TRADEMARK.md). The checked-in Media3 FFmpeg decoder AAR and other third-party dependencies retain their own licenses. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt index b36ba81fb..223ac3df3 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt @@ -18,6 +18,7 @@ import org.prairieserver.prairie.network.AuthScopeSnapshot import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.isActive import org.prairieserver.prairie.viewmodel.hydrateHomeSections +import org.prairieserver.prairie.util.ArtworkUrl import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.supervisorScope @@ -184,8 +185,12 @@ private suspend fun warmHomeArtwork( fun append(urlString: String?, widthPx: Int, heightPx: Int) { if (requests.size >= plan.maxUrls) return - val url = urlString?.trim().orEmpty() - if (url.isEmpty() || !seen.add(url)) return + val raw = urlString?.trim().orEmpty() + if (raw.isEmpty()) return + // Prairie: warm the device's preferred format sibling so + // ThumbhashImage's first request hits the Coil memory/disk cache. + val url = ArtworkUrl.preferred(raw) + if (!seen.add(url)) return requests.add( ImageRequest.Builder(context) .data(url) diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt index 2b0beb8b2..63cceb6b9 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt @@ -34,6 +34,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import org.prairieserver.prairie.util.ArtworkUrl import kotlinx.coroutines.withContext private val DefaultPlaceholderColor = Color(0xFF1A1D27) @@ -164,9 +165,27 @@ fun ThumbhashImage( return } - val model = remember(url, decodeSizePx, crossfadeMillis, cacheKey) { + // Prairie: walk the device's preferred format siblings of a canonical + // `.webp` (AVIF on API 31+, then WebP, then PNG — see ImageFormats) and + // fall through on a load error. A caller-supplied cacheKey marks a signed + // URL (profile avatars) whose signature covers the path, so a rewritten + // sibling would only 403; those load exactly as given. + val candidates = remember(url, cacheKey) { + if (cacheKey != null) listOf(url) else ArtworkUrl.candidates(url).ifEmpty { listOf(url) } + } + var failedCount by remember(url, cacheKey) { mutableStateOf(0) } + val current = candidates[failedCount.coerceIn(0, candidates.lastIndex)] + val onLoadError: () -> Unit = { + if (failedCount < candidates.lastIndex) { + failedCount += 1 + } else { + onError?.invoke() + } + } + + val model = remember(current, decodeSizePx, crossfadeMillis, cacheKey) { ImageRequest.Builder(context) - .data(url) + .data(current) .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) else crossfade(false) } @@ -189,7 +208,7 @@ fun ThumbhashImage( placeholder = placeholder, colorFilter = colorFilter, onSuccess = { onSuccess?.invoke() }, - onError = { onError?.invoke() }, + onError = { onLoadError() }, modifier = when { transparent || placeholder != null -> modifier else -> modifier.background(DefaultPlaceholderColor) @@ -265,7 +284,7 @@ fun ThumbhashImage( presentFullImage() } }, - onError = { onError?.invoke() }, + onError = { onLoadError() }, modifier = Modifier .fillMaxSize() .drawWithContent { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt index 0c4fc976b..edcc1c324 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt @@ -434,8 +434,10 @@ class MainActivity : ComponentActivity() { // screen arrives on top of an authenticated stack — which also means // its Back/Done has somewhere real to return to. + // Prairie: first run lands on the server list (LAN discovery; manual + // URL entry is the secondary path from there). val activeEntry = registry.activeEntry.value - ?: return Route.ServerSetup.route + ?: return Route.ServerList.route // Restored servers were probed by whichever build saved them (or never, // before the v2 pilot); re-establish the contract verdict once per launch. diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/LiveTvMenuEntry.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/LiveTvMenuEntry.kt new file mode 100644 index 000000000..c5f2d53d8 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/LiveTvMenuEntry.kt @@ -0,0 +1,16 @@ +package org.prairieserver.prairie.android.ui.components + +import androidx.compose.runtime.compositionLocalOf + +/** + * Prairie-only: the profile-menu "Live TV" action, or null when the active + * server has no Live TV channels (see `LiveTvFeatureStore`). + * + * `MainScreen` provides it once around the shell and [ProfileMenu] reads it as + * the default for its `onLiveTvClick` parameter. Using a local instead of a + * parameter threaded through Home, Libraries, Calendar and the shared top bar + * keeps the Prairie wiring to two files, so an upstream sync that rewrites + * those screens cannot silently drop the entry point again (an upstream sync + * did exactly that once). Anchored in scripts/prairie-invariants.txt. + */ +val LocalLiveTvMenuAction = compositionLocalOf<(() -> Unit)?> { null } diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt index eab68342e..ef76b99b9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt @@ -36,6 +36,8 @@ fun ProfileMenu( onSignOutClick: () -> Unit, onRequestsClick: (() -> Unit)? = null, onWatchTogetherClick: (() -> Unit)? = null, + // Prairie: Live TV, provided by MainScreen via LocalLiveTvMenuAction. + onLiveTvClick: (() -> Unit)? = LocalLiveTvMenuAction.current, ) { var confirmSignOut by rememberSaveable { mutableStateOf(false) } @@ -48,7 +50,8 @@ fun ProfileMenu( // jobs and the feature/account split would stop reading as a split. The // old menu drew its divider unconditionally, so a server with requests // disabled opened onto a stray rule above its first item. - val hasFeatureGroup = onRequestsClick != null || onWatchTogetherClick != null + val hasFeatureGroup = + onRequestsClick != null || onLiveTvClick != null || onWatchTogetherClick != null PrairieDropdownMenu( expanded = expanded, @@ -63,6 +66,15 @@ fun ProfileMenu( }, ) } + if (onLiveTvClick != null) { + PrairieMenuItem( + label = "Live TV", + onClick = { + onDismissRequest() + onLiveTvClick() + }, + ) + } if (onWatchTogetherClick != null) { PrairieMenuItem( label = "Watch together", diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index d6fa2b5b6..50cfa182a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -82,6 +82,8 @@ import org.prairieserver.prairie.android.ui.screens.player.PlayerViewModel import org.prairieserver.prairie.android.ui.screens.profiles.CreateProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.EditProfileScreen import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionScreen +import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvPlayerScreen +import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvScreen import org.prairieserver.prairie.android.ui.screens.requests.MyRequestsScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestDetailScreen import org.prairieserver.prairie.android.ui.screens.requests.RequestsScreen @@ -565,6 +567,7 @@ fun AppNavigation( // ---- Server list (multi-server management) ---- composable(Route.ServerList.route) { + val canGoBack = navController.previousBackStackEntry != null ServerListScreen( onAddServer = { navController.navigate(Route.ServerSetup.route) @@ -599,7 +602,14 @@ fun AppNavigation( launchSingleTop = true } }, - onBack = { navController.popBackStack() }, + // Prairie: as the start destination (empty registry) this is the + // first-run connect screen — no Back, and scan the LAN once. + onBack = if (canGoBack) { + { navController.popBackStack() } + } else { + null + }, + autoScan = !canGoBack, ) } @@ -813,6 +823,40 @@ fun AppNavigation( ) } + // ---- Live TV (Prairie-only; reached from the profile menu) ---- + composable(Route.LiveTv.route) { + LiveTvScreen( + onBackClick = { navController.popBackStack() }, + onChannelClick = { channel -> + navController.navigate( + Route.LiveTvPlayer(channel.id, channel.displayName).route, + ) + }, + onPlayLibraryItem = { contentId -> + navController.navigate(Route.ItemDetail(contentId).route) + }, + ) + } + composable( + route = Route.LiveTvPlayer.ROUTE, + arguments = listOf( + navArgument(Route.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, + navArgument(Route.LiveTvPlayer.ARG_NAME) { + type = NavType.StringType + nullable = true + defaultValue = "" + }, + ), + ) { backStackEntry -> + LiveTvPlayerScreen( + channelId = backStackEntry.arguments + ?.getString(Route.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty(), + channelName = backStackEntry.arguments + ?.getString(Route.LiveTvPlayer.ARG_NAME).orEmpty(), + onBackClick = { navController.popBackStack() }, + ) + } + // ---- Requests ---- composable(Route.Requests.route) { RequestsScreen( diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt index c6026b4c8..52d03a4bd 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt @@ -121,6 +121,28 @@ sealed class Route(val route: String) { } } + // --- Live TV (Prairie-only; profile-menu entry, not a bottom-nav tab) --- + // Gated by LiveTvFeatureStore (the server has channels). An upstream sync + // dropped this wiring once; it is anchored in scripts/prairie-invariants.txt. + data object LiveTv : Route("livetv") + data class LiveTvPlayer( + val channelId: String, + val channelName: String = "", + ) : Route( + buildString { + append("livetv/player/${channelId.routeEncode()}") + if (channelName.isNotBlank()) { + append("?name=${channelName.routeEncode()}") + } + }, + ) { + companion object { + const val ROUTE = "livetv/player/{channelId}?name={name}" + const val ARG_CHANNEL_ID = "channelId" + const val ARG_NAME = "name" + } + } + // --- Detail screens (back navigation, no bottom nav) --- data class ItemDetail( val contentId: String, diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt index b5a78cc08..55297449a 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import org.prairieserver.prairie.model.profile.ActiveProfileStore +import org.prairieserver.prairie.android.ui.components.LocalLiveTvMenuAction import org.prairieserver.prairie.android.ui.components.MainAppHeaderBodyHeight import org.prairieserver.prairie.android.ui.components.MainAppTopBar import org.prairieserver.prairie.android.ui.components.TabTopBarActions @@ -72,6 +73,7 @@ import org.prairieserver.prairie.model.navigation.MediaMode import org.prairieserver.prairie.model.navigation.MediaModeCapabilities import org.prairieserver.prairie.model.navigation.mobileMediaModeCapabilities import org.prairieserver.prairie.model.feature.MetadataAiFeatureStore +import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.common.network.ServerReachabilityMonitor import org.prairieserver.prairie.common.network.ServerReachabilityStatus @@ -156,6 +158,7 @@ fun MainScreen( val authRepository: AuthRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() + val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: MetadataAiFeatureStore = koinInject() val overlayPrefsStore: OverlayPrefsStore = koinInject() val activeProfileStore: ActiveProfileStore = koinInject() @@ -163,6 +166,7 @@ fun MainScreen( val seekIntervalStore: org.prairieserver.prairie.common.settings.SeekIntervalStore = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() + val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val reachabilityScope = rememberCoroutineScope() val activeEntry by serverRegistry.activeEntry.collectAsState() // Drives the initial load and every re-load. Keyed on the active server @@ -274,6 +278,8 @@ fun MainScreen( LaunchedEffect(activeEntry?.id, activeEntry?.profileId, headerState.activeProfile?.id) { requestsFeatureStore.reset() requestsFeatureStore.refresh() + liveTvFeatureStore.reset() + liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -282,6 +288,7 @@ fun MainScreen( reachabilityScope.launch { authRepository.logout() requestsFeatureStore.reset() + liveTvFeatureStore.reset() metadataAiFeatureStore.reset() // Per-profile card caches, same teardown the Settings sign-out // does — otherwise the next user's shell renders (and can write @@ -317,6 +324,11 @@ fun MainScreen( } else { null } + val liveTvMenuAction: (() -> Unit)? = if (liveTvEnabled) { + { navController.navigate(Route.LiveTv.route) } + } else { + null + } val watchTogetherMenuAction: (() -> Unit)? = if (CLIENT_WATCH_TOGETHER_SURFACE_ENABLED) { { showWatchTogetherEntry = true } @@ -336,6 +348,9 @@ fun MainScreen( // Instantiate with the shell, not when the lazy tab is first opened, so // Discover and taste-profile requests run alongside profile/header setup. val recommendationsViewModel = koinViewModel() + // Prairie: the profile menus read the Live TV entry from this local + // (see LocalLiveTvMenuAction) so it survives upstream menu refactors. + CompositionLocalProvider(LocalLiveTvMenuAction provides liveTvMenuAction) { Scaffold( bottomBar = { // The cast bar rests above the nav menu (iOS tabViewBottomAccessory @@ -611,6 +626,7 @@ fun MainScreen( } } } + } } @Composable diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt index bb0cd85e6..ef753face 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/cast/PrairieCastRemoteScreen.kt @@ -106,7 +106,7 @@ private val RemoteSurfaceElevated = Color(0xFF23252E) private val RemoteError = Color(0xFFB3261E) /** - * Native "now-playing" remote for controlling Silo playback on a TV. + * Native "now-playing" remote for controlling Prairie playback on a TV. * Mirrors silo-apple's `PrairieControlRemoteView`: blurred-artwork backdrop, * poster, scrubber with optimistic clock, transport, volume, and * capability-gated secondary menus — with distinct connecting / diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt index ed8df72c8..764f3c757 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt @@ -246,7 +246,7 @@ fun PlayerSettingsSheet( onCheckedChange = onSetDolbyVisionEnabled, ) ValueRow( - label = "Playback stats", + label = "Stats for nerds", value = stats.summaryLabel(), onClick = { leaveFor(onOpenPlaybackStats) }, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt index 9e2a816f5..a0950f7f9 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt @@ -127,7 +127,7 @@ fun AccountSection( } SettingsNavigationRow( - label = "Pair device", + label = "Quick Connect", description = "Link a TV or another device to this account.", onClick = onPairDevice, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt index 365a668fd..2bfefd2de 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/PlaybackSettings.kt @@ -79,7 +79,7 @@ fun PlaybackSettings( // silently showing the wrong entry. SettingsDropdownRow( label = "Preferred quality", - description = "The quality Silo requests when playback starts.", + description = "The quality Prairie requests when playback starts.", value = QualityPresets.describe(qualityResolution, maxBitrateKbps), options = QualityPresets.ALL.map { it.label }, onOptionSelected = { label -> @@ -90,7 +90,7 @@ fun PlaybackSettings( SettingsDropdownRow( label = "Audio language", - description = "Choose which spoken language Silo should prefer first.", + description = "Choose which spoken language Prairie should prefer first.", value = LanguageOptions.label(audioLanguage, SettingKeys.PLAYBACK_AUDIO_LANGUAGE), options = audioLanguageOptions.map { it.second }, onOptionSelected = { label -> @@ -172,7 +172,7 @@ fun PlaybackSettings( SettingsDropdownRow( label = "Still watching prompt", - description = "How many episodes auto-play before Silo asks whether you are still watching.", + description = "How many episodes auto-play before Prairie asks whether you are still watching.", value = passOutThresholdLabel(passOutThreshold), options = passOutThresholdOptions.map(::passOutThresholdLabel), onOptionSelected = { label -> diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt index c3baa88c5..24f705ee5 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt @@ -1,9 +1,19 @@ package org.prairieserver.prairie.android.ui.screens.settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import org.koin.compose.koinInject import org.prairieserver.prairie.android.BuildConfig import org.prairieserver.prairie.common.network.clientVersionLabel +import org.prairieserver.prairie.update.AppUpdateChecker +import org.prairieserver.prairie.update.AppUpdateStatus +import org.prairieserver.prairie.update.changelogUrlOrNull +import org.prairieserver.prairie.update.latestVersionLabel +import org.prairieserver.prairie.update.releaseUrlOrNull +import org.prairieserver.prairie.update.statusLabel /** * Connection section. Mirrors the iOS phone Settings `Server` row: a @@ -34,5 +44,32 @@ fun ServerInfoSection( // meaningless "(0)", matching what those builds report. value = clientVersionLabel(BuildConfig.VERSION_NAME, BuildConfig.BUILD_NUMBER), ) + AppUpdateStatusRow() } } + +/** + * Prairie-only (b3b04cbb): GitHub Releases update check for sideloaded builds. + * Self-contained (checker from Koin, no ViewModel plumbing) so an upstream + * rewrite of the settings ViewModel cannot silently drop it again. + */ +@Composable +private fun AppUpdateStatusRow() { + val checker: AppUpdateChecker = koinInject() + val uriHandler = LocalUriHandler.current + val status by produceState(AppUpdateStatus.Checking, checker) { + value = checker.check(BuildConfig.VERSION_NAME) + } + val targetUrl = status.releaseUrlOrNull() ?: status.changelogUrlOrNull() + val latest = status.latestVersionLabel() + SettingsNavigationRow( + label = "Updates", + description = if (status is AppUpdateStatus.UpdateAvailable && latest != null) { + "Prairie $latest is available." + } else { + "Checks Prairie releases on GitHub." + }, + value = status.statusLabel(), + onClick = targetUrl?.let { url -> { runCatching { uriHandler.openUri(url) } } }, + ) +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt index 02666ed91..ad1162e73 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SettingsScreen.kt @@ -330,7 +330,7 @@ fun SettingsScreen( SettingsSection(title = "Notifications") { SettingsSwitchRow( label = "In-app notifications", - description = "Show alerts inside Silo as new releases arrive.", + description = "Show alerts inside Prairie as new releases arrive.", checked = state.notificationsEnabled, onCheckedChange = viewModel::setNotificationsEnabled, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt index b29fa9feb..25b710fad 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/SubtitleSettings.kt @@ -45,7 +45,7 @@ fun SubtitleSettings( SettingsSection(title = "Subtitles", modifier = modifier) { SettingsDropdownRow( label = "Subtitle language", - description = "Choose which subtitle language Silo should prefer first.", + description = "Choose which subtitle language Prairie should prefer first.", value = LanguageOptions.label(subtitleLanguage, SettingKeys.PLAYBACK_SUBTITLE_LANGUAGE), options = subtitleLanguageOptions.map { it.second }, onOptionSelected = { label -> @@ -55,7 +55,7 @@ fun SubtitleSettings( SettingsDropdownRow( label = "Subtitle behavior", - description = "When Silo should turn subtitles on.", + description = "When Prairie should turn subtitles on.", value = subtitleMode.label, options = SubtitleMode.entries.map { it.label }, onOptionSelected = { label -> @@ -75,7 +75,7 @@ fun SubtitleSettings( // appearance editor (the same sheet the player uses). SettingsSwitchRow( label = "Match device caption settings", - description = "Use the operating system's caption style instead of Silo's.", + description = "Use the operating system's caption style instead of Prairie's.", checked = subtitleMatchesDevice, onCheckedChange = onSubtitleMatchesDeviceChanged, ) @@ -90,7 +90,7 @@ fun SubtitleSettings( if (metadataLanguageEnabled) { SettingsDropdownRow( label = "Metadata language", - description = "Fallback language Silo prefers for titles, descriptions, and artwork.", + description = "Fallback language Prairie prefers for titles, descriptions, and artwork.", value = LanguageOptions.label(metadataLanguage, SettingKeys.CATALOG_METADATA_LANGUAGE), options = metadataLanguageOptions.map { it.second }, onOptionSelected = { label -> diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt index 2c96ee58f..a17f44710 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsReportScreen.kt @@ -202,7 +202,7 @@ fun DiagnosticsReportScreen( Text( if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { "The local evidence will be removed from this device. If this report was already " + - "submitted, its copy in Silo Diagnostics will also be permanently deleted." + "submitted, its copy in Prairie Diagnostics will also be permanently deleted." } else { "The local evidence will be permanently removed from this device." }, @@ -250,7 +250,7 @@ private fun DiagnosticsSentConfirmation( Spacer(Modifier.height(12.dp)) Text( "The report was removed from this device after the destination accepted a copy. " + - "Share the reference ID with Silo Diagnostics or your server admin.", + "Share the reference ID with Prairie Diagnostics or your server admin.", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium, ) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt index 9b7bf2c71..c8371875d 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/diagnostics/DiagnosticsSettingsScreen.kt @@ -133,7 +133,7 @@ internal fun DiagnosticsSettingsContent( } SettingsProse( body = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "Reports include the Silo app version and build, Android version, device model, " + + "Reports include the Prairie app version and build, Android version, device model, " + "crash details, and diagnostic logs you review. A pseudonymous installation " + "credential is not linked to an account on your self-hosted server. Username, " + "email, profile, server address, and playback session IDs are omitted. Reports " + diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt index c3ed4ec97..8d688dd65 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt @@ -2,10 +2,12 @@ package org.prairieserver.prairie.android.ui.theme import androidx.compose.ui.graphics.Color -// Plezy OLED Dark palette — mirrors iosApp/iosApp/Theme/Colors.swift exactly. -// Pure-black backgrounds, EDEDED primary text, white-at-opacity for everything else. +// Prairie Dusk palette — mirrors prairie-server / prairie-smarttv / the TV +// app's Color.kt. Deep slate surfaces + amber wheat accent (#E0A84A). +// Prairie-only (99f1014c); an upstream sync reverted these tokens to the +// Silo OLED values once. Anchored in scripts/prairie-invariants.txt. -val PrairieBackground = Color(0xFF000000) +val PrairieBackground = Color(0xFF141820) /** * The signed-in page canvas. iOS paints `ContinuumPageBackdrop` (#111111) under @@ -14,17 +16,18 @@ val PrairieBackground = Color(0xFF000000) * `continuumBackground`. Settings keeps `PrairieSettingsBackground`. */ val PrairiePageBackground = Color(0xFF111111) -val PrairieSurface = Color(0xFF0A0A0A) -val PrairieSurfaceVariant = Color(0xFF0E0F12) -val PrairieSurfaceElevated = Color(0xFF15171C) -val PrairiePrimary = Color(0xFFEDEDED) -val PrairieOnSurface = Color(0xFFEDEDED) -val PrairieSecondaryText = Color(0xFFEDEDED).copy(alpha = 0.60f) +val PrairieSurface = Color(0xFF1C222C) +val PrairieSurfaceVariant = Color(0xFF0E1116) +val PrairieSurfaceElevated = Color(0xFF222B38) +/** Brand / Material primary — amber wheat */ +val PrairiePrimary = Color(0xFFE0A84A) +val PrairieOnSurface = Color(0xFFF2EEE6) +val PrairieSecondaryText = Color(0xFF9AA3B2) val PrairieDisabled = Color(0xFF4B5563) -val PrairieOutline = Color.White.copy(alpha = 0.12f) -val PrairieDivider = Color.White.copy(alpha = 0.12f) -val PrairieOverlay = Color.Black.copy(alpha = 0.60f) +val PrairieOutline = PrairieOnSurface.copy(alpha = 0.12f) +val PrairieDivider = PrairieOnSurface.copy(alpha = 0.12f) +val PrairieOverlay = PrairieBackground.copy(alpha = 0.72f) // Restrained Android substitute for iOS Liquid Glass. These shared washes keep // every migrated circle/capsule in one light material family without a live diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index 8aaea8c0f..bc058d497 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -19,7 +19,7 @@ Keep Remote Control connected - Allow Silo to run without battery restrictions so Remote Control can stay connected after you leave the app. In Battery optimization, choose Silo and set it to Not optimized. + Allow Prairie to run without battery restrictions so Remote Control can stay connected after you leave the app. In Battery optimization, choose Prairie and set it to Not optimized. Battery settings Not now diff --git a/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/LiveTvRouteTest.kt b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/LiveTvRouteTest.kt new file mode 100644 index 000000000..078be6531 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/android/ui/navigation/LiveTvRouteTest.kt @@ -0,0 +1,38 @@ +package org.prairieserver.prairie.android.ui.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Live TV is Prairie-only and has been dropped by an upstream sync once. These + * cover the route contract the profile-menu entry and the channel list rely + * on; scripts/check-prairie-invariants.sh covers the menu/NavHost wiring. + */ +class LiveTvRouteTest { + + @Test + fun liveTvRouteIsStable() { + assertEquals("livetv", Route.LiveTv.route) + } + + @Test + fun playerRouteOmitsBlankName() { + assertEquals("livetv/player/ch-1", Route.LiveTvPlayer("ch-1").route) + assertEquals("livetv/player/ch-1", Route.LiveTvPlayer("ch-1", " ").route) + } + + @Test + fun playerRouteEncodesIdAndNameAsSingleArguments() { + val route = Route.LiveTvPlayer(channelId = "a/b?c", channelName = "News & Weather 7").route + assertEquals("livetv/player/a%2Fb%3Fc?name=News%20%26%20Weather%207", route) + } + + @Test + fun playerRoutePatternDeclaresBothArguments() { + val pattern = Route.LiveTvPlayer.ROUTE + assertTrue(pattern.startsWith("livetv/player/")) + assertTrue(pattern.contains("{${Route.LiveTvPlayer.ARG_CHANNEL_ID}}")) + assertTrue(pattern.contains("{${Route.LiveTvPlayer.ARG_NAME}}")) + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt index 8e9ed4b03..711669cdb 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt @@ -301,7 +301,9 @@ class MainTvActivity : ComponentActivity() { val tokenManager = get(TokenManager::class.java) val activeEntry = registry.activeEntry.value - ?: return TvRoute.ServerSetup.route + // Prairie: first run lands on the server list (LAN discovery; + // manual URL entry is the secondary path from there). + ?: return TvRoute.ServerList.route // Restored servers were probed by whichever build saved them (or never, // before the v2 pilot); re-establish the contract verdict once per launch. diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt index 766bb64fd..413dfbe5f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt @@ -627,6 +627,7 @@ fun TvAppNavigation( } composable(TvRoute.ServerList.route) { + val canGoBack = navController.previousBackStackEntry != null TvServerListScreen( onAddServer = { navController.navigate(TvRoute.ServerSetup.route) @@ -664,7 +665,14 @@ fun TvAppNavigation( launchSingleTop = true } }, - onBack = { navController.popBackStack() }, + // Prairie: as the start destination (empty registry) this is the + // first-run connect screen — no Back, and scan the LAN once. + onBack = if (canGoBack) { + { navController.popBackStack() } + } else { + null + }, + autoScan = !canGoBack, ) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt index f182a23a2..fe606607f 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt @@ -326,9 +326,9 @@ sealed class TvMainRoute(val route: String) { val channelName: String = "", ) : TvMainRoute( buildString { - append("main/livetv/player/${android.net.Uri.encode(channelId)}") + append("main/livetv/player/${channelId.routeEncode()}") if (channelName.isNotBlank()) { - append("?name=${android.net.Uri.encode(channelName)}") + append("?name=${channelName.routeEncode()}") } }, ) { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt index 74fc4b139..393bed712 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt @@ -139,7 +139,7 @@ fun TvPairDeviceScreen( verticalArrangement = Arrangement.Center, ) { Text( - text = "Pair Device", + text = "Quick Connect", style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.SemiBold, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt index 66862c235..53af5b34e 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvServerSetupScreen.kt @@ -415,8 +415,8 @@ private fun PhoneSetupBody(modifier: Modifier = Modifier) { color = Color.White, ) Text( - text = "Open Silo on a phone connected to the same Wi-Fi. Accept the " + - "setup card and Silo will securely bring over the server and account.", + text = "Open Prairie on a phone connected to the same Wi-Fi. Accept the " + + "setup card and Prairie will securely bring over the server and account.", style = TvServerSetupTextStyles.PairingDetail, color = Color.White.copy(alpha = 0.72f), maxLines = 4, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt index ab998fab7..5fe37cc21 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSetupScreen.kt @@ -117,7 +117,7 @@ fun TvSetupScreen( Spacer(modifier = Modifier.height(Spacing.xs)) Text( - text = "Welcome to Silo", + text = "Welcome to Prairie", style = TvAuthFormTextStyles.Title, color = Color.White, ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt index b73abe886..1a5b580c1 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvSignupScreen.kt @@ -121,7 +121,7 @@ fun TvSignupScreen( color = Color.White, ) Text( - text = "Join this Silo server with your invite.", + text = "Join this Prairie server with your invite.", style = TvAuthFormTextStyles.Body, color = Color.White.copy(alpha = 0.72f), ) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt index 695ab64c1..93271b519 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -46,6 +47,8 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text +import org.prairieserver.prairie.discovery.DiscoveryHit +import org.prairieserver.prairie.discovery.normalizeDiscoveryUrl import org.prairieserver.prairie.model.server.ServerEntry import org.prairieserver.prairie.tv.ui.focus.rememberTvContentInitialFocus import org.prairieserver.prairie.tv.ui.components.TvDialogOption @@ -62,20 +65,52 @@ import org.koin.compose.viewmodel.koinViewModel * with an "Add Server" tile at the top. Long-press / Menu opens an action * sheet with Remove (rename is intentionally omitted on TV: easier to * remove + re-add than to edit a string with the on-screen keyboard). + * + * Prairie (691fcfe1): this is also the first-run connect screen. With + * [autoScan] it probes the LAN once for Prairie servers (shared + * `LanDiscovery`, same algorithm as the phone and prairie-smarttv) and lists + * them under "Discovered"; manual URL entry stays behind [onAddServer]. An + * upstream sync dropped the discovery UI once; anchored in + * scripts/prairie-invariants.txt. */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable fun TvServerListScreen( onAddServer: () -> Unit, onSwitched: (TvServerSwitchDestination) -> Unit, - onBack: () -> Unit, + onBack: (() -> Unit)?, + autoScan: Boolean = false, viewModel: TvServerListViewModel = koinViewModel(), ) { val state by viewModel.uiState.collectAsState() val firstFocus = remember { FocusRequester() } var confirmRemove by remember { mutableStateOf(null) } + // Start destination with nothing behind it: first-run connect. + val isFirstRun = onBack == null - BackHandler(enabled = true) { onBack() } + if (onBack != null) { + BackHandler(enabled = true) { onBack() } + } + + LaunchedEffect(autoScan) { + if (autoScan) viewModel.maybeAutoScan() + } + + LaunchedEffect(state.emptyRegistry) { + // Active server removed with none left — stay here and scan again. + if (state.emptyRegistry) { + viewModel.onEmptyRegistryConsumed() + viewModel.startScan(includeDeep = true) + } + } + + val savedUrls = remember(state.servers) { + state.servers.map { normalizeDiscoveryUrl(it.url) }.toSet() + } + val freshHits = remember(state.discovered, savedUrls) { + state.discovered.filter { it.url !in savedUrls } + } + val busy = state.isScanning || state.isConnecting LaunchedEffect(state.switchedTo) { val destination = state.switchedTo @@ -107,18 +142,22 @@ fun TvServerListScreen( verticalArrangement = Arrangement.spacedBy(Spacing.sm), ) { Text( - text = "CONNECTION", + text = if (isFirstRun) "CONNECT" else "CONNECTION", style = MaterialTheme.typography.labelSmall, color = Color.White.copy(alpha = 0.55f), ) Text( - text = "Manage Servers", + text = if (isFirstRun) "Choose a server" else "Manage Servers", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = Color.White, ) Text( - text = "Choose, rename, add, or remove a Silo server.", + text = if (isFirstRun) { + "Pick a saved server or one found on your network. Sign-in comes next." + } else { + "Choose, add, or remove a Prairie server." + }, style = MaterialTheme.typography.bodySmall, color = Color.White.copy(alpha = 0.62f), ) @@ -139,6 +178,25 @@ fun TvServerListScreen( if (state.servers.isEmpty()) firstFocus else FocusRequester.Default, ), ) + ScanTile( + isScanning = state.isScanning, + enabled = !busy, + onClick = { viewModel.startScan(includeDeep = true) }, + ) + state.scanStatus?.takeIf { it.isNotBlank() }?.let { status -> + Text( + text = status, + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.62f), + ) + } + state.scanError?.takeIf { it.isNotBlank() }?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } LazyColumn( modifier = Modifier.fillMaxWidth(), @@ -158,6 +216,32 @@ fun TvServerListScreen( modifier = rowModifier, ) } + if (freshHits.isNotEmpty() || state.isScanning) { + item(key = "discovered-header") { + Text( + text = "Discovered", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.62f), + modifier = Modifier.padding(top = 8.dp), + ) + } + } + if (freshHits.isEmpty() && state.isScanning) { + item(key = "discovered-scanning") { + Text( + text = "Scanning your network for Prairie…", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.62f), + ) + } + } + items(freshHits, key = { "hit:" + it.url }) { hit -> + DiscoveredRow( + hit = hit, + enabled = !busy, + onSelect = { viewModel.selectDiscovered(hit.url, hit.serverName) }, + ) + } } } } @@ -200,6 +284,94 @@ fun TvServerListScreen( } +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ScanTile( + isScanning: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = (if (isFocused) FocusedContent else Color.White) + .let { if (enabled) it else it.copy(alpha = 0.4f) } + Card( + onClick = { if (enabled) onClick() }, + interactionSource = interactionSource, + colors = CardDefaults.colors( + containerColor = Color.White.copy(alpha = 0.055f), + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + ), + shape = CardDefaults.shape(shape = ServerRowShape), + scale = CardDefaults.scale(focusedScale = 1f), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + tint = foreground, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(Spacing.sm)) + Text( + text = if (isScanning) "Scanning…" else "Scan network", + style = MaterialTheme.typography.bodyMedium, + fontFamily = InterFamily, + color = foreground, + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun DiscoveredRow( + hit: DiscoveryHit, + enabled: Boolean, + onSelect: () -> Unit, +) { + val interactionSource = remember(hit.url) { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val foreground = if (isFocused) FocusedContent else Color.White + Card( + onClick = { if (enabled) onSelect() }, + interactionSource = interactionSource, + colors = CardDefaults.colors( + containerColor = Color.White.copy(alpha = 0.055f), + focusedContainerColor = FocusedContainer, + focusedContentColor = FocusedContent, + ), + shape = CardDefaults.shape(shape = ServerRowShape), + scale = CardDefaults.scale(focusedScale = 1f), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { + Text( + text = hit.serverName.trim().ifBlank { hit.url }, + style = MaterialTheme.typography.bodyMedium, + fontFamily = InterFamily, + color = foreground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Found · ${hit.url}", + style = MaterialTheme.typography.labelSmall, + fontFamily = InterFamily, + color = foreground.copy(alpha = if (isFocused) 0.68f else 0.62f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + private val ServerSettingsBackground = Color(0xFF17181A) private val ServerListMaxWidth = 620.dp private val ServerRowShape = RoundedCornerShape(8.dp) diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt index 43ecb2bba..197fab1ed 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt @@ -95,6 +95,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Surface import androidx.tv.material3.Text import org.prairieserver.prairie.common.network.clientVersionLabel +import org.prairieserver.prairie.update.latestVersionLabel +import org.prairieserver.prairie.update.releaseUrlOrNull +import org.prairieserver.prairie.update.statusLabel import org.prairieserver.prairie.common.settings.CardPresentationSource import org.prairieserver.prairie.common.settings.CardPresentationSupport import org.prairieserver.prairie.model.settings.CardCaption @@ -347,7 +350,7 @@ internal enum class TvSettingsCategory( Diagnostics( title = "Diagnostics", eyebrow = "SUPPORT", - blurb = "Review and send diagnostics to this Silo server.", + blurb = "Review and send diagnostics to this Prairie server.", icon = Icons.Filled.MonitorHeart, ), Server( @@ -601,7 +604,7 @@ private fun SettingsRail( onFocused = { railActionHasFocus = true }, ) Text( - text = "Silo ${clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER)}", + text = "Prairie ${clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER)}", style = MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, fontSize = 14.sp, @@ -1887,11 +1890,41 @@ private fun TvServerSettingsPane( label = "Version", value = clientVersionLabel(BuildConfig.DISPLAY_VERSION, BuildConfig.BUILD_NUMBER), ) + TvAppUpdateStatusRows() } } } } +/** + * Prairie-only (b3b04cbb): GitHub Releases update check for sideloaded TV + * builds. Self-contained (checker from Koin, no ViewModel plumbing) so an + * upstream rewrite of the settings ViewModel cannot silently drop it again. + */ +@Composable +private fun TvAppUpdateStatusRows() { + val checker: org.prairieserver.prairie.update.AppUpdateChecker = org.koin.compose.koinInject() + val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current + val status by androidx.compose.runtime.produceState( + org.prairieserver.prairie.update.AppUpdateStatus.Checking, + checker, + ) { + value = checker.check(BuildConfig.VERSION_NAME) + } + SettingsInfoRow(label = "Updates", value = status.statusLabel()) + if (status is org.prairieserver.prairie.update.AppUpdateStatus.UpdateAvailable) { + status.latestVersionLabel()?.let { latest -> + SettingsInfoRow(label = "Latest version", value = latest) + } + status.releaseUrlOrNull()?.let { url -> + SettingsActionRow( + label = "View update", + onClick = { runCatching { uriHandler.openUri(url) } }, + ) + } + } +} + private const val SubtitlePreviewText = "Subtitles will look like this" private fun settingsHexColor(value: String): Color = runCatching { diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt index 8075f067a..8e9834a35 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsPromptScreen.kt @@ -102,7 +102,7 @@ fun TvDiagnosticsPromptScreen( ) } else { Text( - "Silo encountered a problem", + "Prairie encountered a problem", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.SemiBold, ) @@ -119,7 +119,7 @@ fun TvDiagnosticsPromptScreen( ) if (!allowAlwaysSend) { Text( - "The report includes the Silo app version and build, Android version, device " + + "The report includes the Prairie app version and build, Android version, device " + "model, crash details, and diagnostic logs. Its pseudonymous credential is " + "not linked to an account on your self-hosted server. Username, email, " + "profile, server address, and playback session IDs are omitted. It never " + diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt index 8604f9515..ffa1a6b42 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsReportScreen.kt @@ -50,7 +50,7 @@ fun TvDiagnosticsReportScreen( TvReportLine("Processing state", sentState.replace('_', ' ')) Text( "The report was removed from this device after the destination accepted a copy. " + - "Share the reference ID with Silo Diagnostics or your server admin.", + "Share the reference ID with Prairie Diagnostics or your server admin.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) TvDiagnosticsAction(label = "Done", onClick = onBack) @@ -153,7 +153,7 @@ fun TvDiagnosticsReportScreen( title = "Delete this report?", message = if (report.destinationKind == DiagnosticsDestinationKind.HOSTED) { "The local evidence will be removed from this device. If this report was already submitted, " + - "its copy in Silo Diagnostics will also be permanently deleted." + "its copy in Prairie Diagnostics will also be permanently deleted." } else { "The local evidence will be permanently removed from this device." }, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt index d12fe81a7..1f7d728f0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/diagnostics/TvDiagnosticsSettingsPane.kt @@ -243,7 +243,7 @@ internal fun TvDiagnosticsSettingsPane( ) SettingsFooterText( text = if (state.destinationKind == DiagnosticsDestinationKind.HOSTED) { - "Reports include the Silo app version and build, Android version, device model, " + + "Reports include the Prairie app version and build, Android version, device model, " + "crash details, and diagnostic logs you review. A pseudonymous installation " + "credential is not linked to an account on your self-hosted server. Username, " + "email, profile, server address, and playback session IDs are omitted. Reports " + diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index 82e6dbb1b..b866d81b0 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -33,6 +33,7 @@ import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable @@ -117,6 +118,7 @@ import org.prairieserver.prairie.common.ui.components.rememberProfileAvatarImage import org.prairieserver.prairie.common.ui.components.rememberProfileServerUrl import org.prairieserver.prairie.model.catalog.BrowseItem import org.prairieserver.prairie.model.feature.CLIENT_WATCH_TOGETHER_SURFACE_ENABLED +import org.prairieserver.prairie.model.feature.LiveTvFeatureStore import org.prairieserver.prairie.model.feature.RequestsFeatureStore import org.prairieserver.prairie.model.personal.UserLibrary import org.prairieserver.prairie.model.watchtogether.RoomSnapshot @@ -150,6 +152,8 @@ import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequ import org.prairieserver.prairie.tv.ui.screens.recommendations.TvForYouEntryRequestSaver import org.prairieserver.prairie.tv.ui.screens.requests.TvMyRequestsScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestDetailScreen +import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvPlayerScreen +import org.prairieserver.prairie.tv.ui.screens.livetv.TvLiveTvScreen import org.prairieserver.prairie.tv.ui.screens.requests.TvRequestsScreen import org.prairieserver.prairie.tv.ui.screens.search.TvSearchScreen import org.prairieserver.prairie.tv.ui.screens.settings.TvSettingsScreen @@ -221,10 +225,13 @@ fun TvMainShell( val profileRepository: ProfileRepository = koinInject() val reachabilityMonitor: ServerReachabilityMonitor = koinInject() val requestsFeatureStore: RequestsFeatureStore = koinInject() + // Prairie: Live TV is gated on the server having channels. + val liveTvFeatureStore: LiveTvFeatureStore = koinInject() val metadataAiFeatureStore: org.prairieserver.prairie.model.feature.MetadataAiFeatureStore = koinInject() val serverRegistry: ServerRegistry = koinInject() val reachabilityState by reachabilityMonitor.state.collectAsState() val requestsEnabled by requestsFeatureStore.isEnabled.collectAsState() + val liveTvEnabled by liveTvFeatureStore.isEnabled.collectAsState() val activeServerEntry by serverRegistry.activeEntry.collectAsState() val tvLibraryScopeStore: TvLibraryScopeStore = koinInject() // One shell-scoped Home owner feeds both the Home screen and General's @@ -348,6 +355,8 @@ fun TvMainShell( LaunchedEffect(activeServerEntry?.id, activeServerEntry?.profileId) { requestsFeatureStore.reset() requestsFeatureStore.refresh() + liveTvFeatureStore.reset() + liveTvFeatureStore.refresh() metadataAiFeatureStore.reset() metadataAiFeatureStore.refresh() } @@ -1317,6 +1326,36 @@ fun TvMainShell( entryRequest = forYouEntryRequest, ) } + // ---- Live TV (Prairie-only; reached from the profile menu) ---- + shellComposable(TvMainRoute.LiveTv.route) { + TvLiveTvScreen( + onChannelClick = { channel -> + navigateToSecondary( + TvMainRoute.LiveTvPlayer(channel.id, channel.displayName).route, + ) + }, + onInitialContentFocus = { focusState.closeProfileMenuForContent() }, + ) + } + shellComposable( + route = TvMainRoute.LiveTvPlayer.ROUTE, + arguments = listOf( + navArgument(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID) { type = NavType.StringType }, + navArgument(TvMainRoute.LiveTvPlayer.ARG_NAME) { + type = NavType.StringType + nullable = true + defaultValue = "" + }, + ), + ) { entry -> + TvLiveTvPlayerScreen( + channelId = entry.arguments + ?.getString(TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID).orEmpty(), + channelName = entry.arguments + ?.getString(TvMainRoute.LiveTvPlayer.ARG_NAME).orEmpty(), + onBack = { if (nestedNav.previousBackStackEntry != null) nestedNav.popBackStack() }, + ) + } shellComposable(TvMainRoute.Requests.route) { TvRequestsScreen( onOpenLibraryItem = onOpenItemDetail, @@ -1632,6 +1671,11 @@ fun TvMainShell( navigateToSecondary(TvMainRoute.Requests.route) moveFocusToContent(TvMainRoute.Requests.route) }, + showLiveTv = liveTvEnabled, + onLiveTv = closeMenuAnd { + navigateToSecondary(TvMainRoute.LiveTv.route) + moveFocusToContent(TvMainRoute.LiveTv.route) + }, showWatchTogether = CLIENT_WATCH_TOGETHER_SURFACE_ENABLED, onWatchTogether = { focusState.closeProfileMenuForContent() @@ -1889,6 +1933,8 @@ private fun TvProfileDropdown( onHistory: () -> Unit, showRequests: Boolean, onRequests: () -> Unit, + showLiveTv: Boolean, + onLiveTv: () -> Unit, showWatchTogether: Boolean, onWatchTogether: () -> Unit, onSettings: () -> Unit, @@ -1943,6 +1989,13 @@ private fun TvProfileDropdown( onClick = onRequests, ) } + if (showLiveTv) { + ProfileDropdownRow( + label = "Live TV", + icon = Icons.Filled.LiveTv, + onClick = onLiveTv, + ) + } if (showWatchTogether) { ProfileDropdownRow( label = "Watch Together", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvLiveTvRouteTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvLiveTvRouteTest.kt new file mode 100644 index 000000000..667f2f0c3 --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvLiveTvRouteTest.kt @@ -0,0 +1,35 @@ +package org.prairieserver.prairie.tv.ui.navigation + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Live TV is Prairie-only; the shell's profile dropdown and channel list rely + * on these routes. Plain JVM: the route encoder is java.net based, so no + * Robolectric (android.net.Uri would be stubbed and yield "null"). + */ +class TvLiveTvRouteTest { + + @Test + fun liveTvRouteIsStable() { + assertEquals("main/livetv", TvMainRoute.LiveTv.route) + } + + @Test + fun playerRouteEncodesIdAndName() { + assertEquals("main/livetv/player/ch-1", TvMainRoute.LiveTvPlayer("ch-1").route) + assertEquals( + "main/livetv/player/a%2Fb?name=News%20%26%20Weather", + TvMainRoute.LiveTvPlayer("a/b", "News & Weather").route, + ) + } + + @Test + fun playerRoutePatternDeclaresBothArguments() { + val pattern = TvMainRoute.LiveTvPlayer.ROUTE + assertTrue(pattern.startsWith("main/livetv/player/")) + assertTrue(pattern.contains("{${TvMainRoute.LiveTvPlayer.ARG_CHANNEL_ID}}")) + assertTrue(pattern.contains("{${TvMainRoute.LiveTvPlayer.ARG_NAME}}")) + } +} diff --git a/docs/upstream-sync.md b/docs/upstream-sync.md new file mode 100644 index 000000000..9b41d9324 --- /dev/null +++ b/docs/upstream-sync.md @@ -0,0 +1,40 @@ +# Syncing upstream Silo into Prairie Android + +Prairie Android is an AGPL fork of `Silo-Server/silo-android`. 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 while the Prairie +code behind them keeps compiling. Past losses include the phone and TV Live TV +navigation (screens compiled but nothing reached them), the TV LAN-discovery +server list, the phone Dusk palette, the artwork format cascade, the app update +row, the Quick Connect label and dozens of user-visible "Silo" strings. + +## 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 `Prairie invariants` + job (`scripts/check-prairie-invariants.sh`). If it fails, restore the code. + Do not edit the manifest to make it pass. +- Always pass `--repo Prairie-Server/prairie-android` to `gh`; it can resolve to + the Silo upstream otherwise. + +## After resolving conflicts + +1. Rebrand fallout: rename new `Silo*` symbols, `silo://` links, `X-Silo-*` + headers and `silo.*` WebSocket subprotocols to the Prairie names. The + invariants job fails on user-visible `"…Silo…"` string literals in app code. +2. Hunk audit: list Prairie-only commits with + `git log --no-merges upstream/main..origin/main` and confirm each one's + added lines still exist in the merge result, and that new screens are still + reachable from navigation (a composable nobody navigates to is lost wiring). +3. README and TRADEMARK: keep the Prairie identity and the Silo Media L.L.C. + attribution; upstream README edits bring the Silo title back. + +## When you restore something a sync dropped + +Add an anchor for it to `scripts/prairie-invariants.txt` in the same PR: the +path, the minimum number of matching lines (or `absent`), an extended regex on +a stable identifier, and where it came from. Fields are tab-separated so regex +alternation stays usable. That is what stops the next sync from deleting it +again. diff --git a/scripts/check-prairie-invariants.sh b/scripts/check-prairie-invariants.sh new file mode 100755 index 000000000..ddee6cc77 --- /dev/null +++ b/scripts/check-prairie-invariants.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Fails when Prairie-only code listed in scripts/prairie-invariants.txt is gone. +# +# Upstream Silo syncs have resolved files wholesale to upstream and silently +# deleted Prairie wiring (Live TV navigation, LAN discovery, the Dusk palette, +# user-visible rebrand strings) while the Prairie code behind it kept +# compiling. This is a cheap, build-free tripwire that runs on every PR. It +# mirrors prairie-server's scripts/check-prairie-invariants.sh. +# +# Manifest format: one invariant per line, TAB-separated (tabs, not pipes, so +# regex alternation stays usable): +# +# is a file, or a directory searched recursively through *.kt and *.xml. +# is the minimum number of matching lines, or "absent" for a pattern that +# must not match at all (e.g. a user-visible "Silo" string). +# +# Portable bash (3.2+) with POSIX grep -E; no GNU-only flags. +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +manifest="${1:-scripts/prairie-invariants.txt}" +tab="$(printf '\t')" +failures=0 +checked=0 + +count_matches() { + # $1 path, $2 pattern + if [ -d "$1" ]; then + { grep -rhE --include='*.kt' --include='*.xml' -e "$2" "$1" || true; } | wc -l | tr -d ' ' + else + { grep -cE -e "$2" "$1" || true; } | tr -d ' ' + fi +} + +while IFS="$tab" read -r path min pattern why || [ -n "${path:-}" ]; do + case "$path" in ''|\#*) continue ;; esac + checked=$((checked + 1)) + if [ -z "${pattern:-}" ] || [ -z "${min:-}" ]; then + echo "::error file=$manifest::Prairie invariant: malformed line for $path (need 4 tab-separated fields)" + failures=$((failures + 1)) + continue + fi + if [ ! -e "$path" ]; then + echo "::error file=$path::Prairie invariant: path missing ($why)" + failures=$((failures + 1)) + continue + fi + count="$(count_matches "$path" "$pattern")" + if [ "$min" = "absent" ]; then + if [ "$count" -ne 0 ]; then + echo "::error file=$path::Prairie invariant: /$pattern/ must not match but matched $count ($why)" + if [ -d "$path" ]; then + grep -rnE --include='*.kt' --include='*.xml' -e "$pattern" "$path" | head -n 10 || true + fi + failures=$((failures + 1)) + fi + elif [ "$count" -lt "$min" ]; then + echo "::error file=$path::Prairie invariant: /$pattern/ matched $count, need $min ($why)" + failures=$((failures + 1)) + fi +done < "$manifest" + +if [ "$failures" -gt 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/prairie-invariants.txt b/scripts/prairie-invariants.txt new file mode 100644 index 000000000..383c3da33 --- /dev/null +++ b/scripts/prairie-invariants.txt @@ -0,0 +1,109 @@ +# Prairie-only code that upstream Silo syncs have silently deleted before. +# +# One invariant per line, TAB-separated: +# +# +# scripts/check-prairie-invariants.sh fails CI ("Prairie invariants") when a +# path is missing or a pattern matches fewer lines than required. A directory +# path is searched recursively through *.kt and *.xml. When you restore +# something a sync dropped, add its anchor here in the same PR. Keep patterns +# on stable identifiers, not formatting. See docs/upstream-sync.md. + +# --- Live TV (fe4f1227, f4c2d25c, 99f1014c, c0de40cc, c33eeb8b). Phone and TV +# nav/menu wiring was dropped by sync #27 while the screens kept compiling. +shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt 1 DefaultLiveTvApi\( Live TV API binding +shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt 1 LiveTvRepository\( Live TV repository binding +shared/src/commonMain/kotlin/org/prairieserver/prairie/di/RepositoryModule.kt 1 LiveTvFeatureStore\( Live TV feature gate binding +shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt 1 fun scheduleRecording DVR schedule (99f1014c) +shared/src/commonMain/kotlin/org/prairieserver/prairie/viewmodel/LiveTvViewModels.kt 1 Program already ended refuse recording ended programmes (c33eeb8b) +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt 1 LiveTvViewModel\( phone Live TV ViewModel DI +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt 1 LiveTvPlayerViewModel\( phone Live TV player ViewModel DI +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt 1 data object LiveTv : Route\("livetv"\) phone Live TV route +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt 1 data class LiveTvPlayer phone Live TV player route +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 composable\(Route\.LiveTv\.route\) phone Live TV destination +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 route = Route\.LiveTvPlayer\.ROUTE phone Live TV player destination +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 onPlayLibraryItem = Live TV recordings open the library item (f4c2d25c) +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt 1 liveTvFeatureStore\.refresh\(\) phone Live TV gate refresh per server/profile +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt 1 navigate\(Route\.LiveTv\.route\) phone profile-menu Live TV action +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt 1 LocalLiveTvMenuAction provides liveTvMenuAction phone shell provides the Live TV menu action +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt 1 onLiveTvClick: \(\(\) -> Unit\)\? = LocalLiveTvMenuAction\.current phone profile menu reads the Live TV action +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/ProfileMenu.kt 1 label = "Live TV" phone profile-menu Live TV item +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/di/AndroidTvModule.kt 1 LiveTvViewModel\( TV Live TV ViewModel DI +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvRoute.kt 1 data object LiveTv : TvMainRoute\("main/livetv"\) TV Live TV route +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 shellComposable\(TvMainRoute\.LiveTv\.route\) TV Live TV destination +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 route = TvMainRoute\.LiveTvPlayer\.ROUTE TV Live TV player destination +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 liveTvFeatureStore\.refresh\(\) TV Live TV gate refresh +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 showLiveTv = liveTvEnabled TV profile dropdown gate +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 label = "Live TV" TV profile dropdown item +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt 1 requestFocusUntilObserved\( TV Live TV initial focus (edb7e8cd) + +# --- Identity / rebrand (f8a795ff and follow-ups). Durable upgrade identity: +# changing these without a migration logs every user out. +shared/src/androidMain/kotlin/org/prairieserver/prairie/network/SecureSharedPrefs.kt 1 SECURE_PREFS_NAME = "prairie_secure_tokens" encrypted prefs name +androidApp/build.gradle.kts 1 applicationId = "org\.prairieserver\.prairie" phone applicationId +androidTvApp/build.gradle.kts 1 applicationId = "org\.prairieserver\.prairie" TV applicationId +androidApp/src/androidMain/AndroidManifest.xml 1 android:scheme="prairie" prairie:// deep links +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt 1 X-Prairie- X-Prairie-* client headers +README.md 1 ^# Prairie Android README rebrand (reverted to Silo by a sync once) +README.md 1 Silo Media L\.L\.C\. README must attribute the Silo marks to Silo Media (TRADEMARK.md) +# User-visible "Silo" in string literals. Upstream files bring these back on +# every sync; say Prairie (factual "fork of Silo" references belong in docs). +androidApp/src/androidMain absent "([^"]*[^A-Za-z"])?Silo([^A-Za-z]|$) phone user-visible Silo string +androidTvApp/src/androidMain absent "([^"]*[^A-Za-z"])?Silo([^A-Za-z]|$) TV user-visible Silo string +android-shared/src/androidMain/kotlin absent "([^"]*[^A-Za-z"])?Silo([^A-Za-z]|$) shared UI user-visible Silo string +androidApp/src/androidMain/res/values absent >[^<]*Silo phone string resources + +# --- Realtime wire names (47100b06): prairie.* WebSocket subprotocols. +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt 1 "prairie\.playback-control\.v2" playback control subprotocol +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/WatchTogetherRealtimeClient.kt 1 "prairie\.room\.v2" room subprotocol +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/EventsSocketV2Api.kt 1 "prairie\.events\.v2" events subprotocol +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/PlaybackRealtimeClient.kt 1 prairie\.ticket\. ticket subprotocol prefix + +# --- Brand palette (99f1014c): Prairie Dusk, amber wheat #E0A84A. +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt 1 val PrairiePrimary = Color\(0xFFE0A84A\) phone Dusk accent (reverted to OLED by a sync once) +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/theme/Color.kt 1 val PrairieBackground = Color\(0xFF141820\) phone Dusk background +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/theme/Color.kt 1 val PrairiePrimary = Color\(0xFFE0A84A\) TV Dusk accent +androidApp/src/androidMain/res/values/colors.xml 1 #FFE0A84A phone Dusk XML accent + +# --- Quick Connect naming (12925ada). +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/AccountSection.kt 1 "Quick Connect" phone settings row +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/DevicePairingScreen.kt 1 "Quick Connect" phone approval screen title +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/auth/TvPairDeviceScreen.kt 1 "Quick Connect" TV approval screen title + +# --- Artwork format negotiation (3eb78ac2, 74f216e4). +shared/src/commonMain/kotlin/org/prairieserver/prairie/network/AuthInterceptorImpl.kt 1 X-Prairie-Image-Formats advertise decodable image formats +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt 1 ImageFormats\.configureForApiLevel phone format detection +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/PrairieTvApplication.kt 1 ImageFormats\.configureForApiLevel TV format detection +android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt 1 ArtworkUrl\.candidates\( AVIF/WebP/PNG sibling cascade (dropped by a sync once) +android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt 1 ArtworkUrl\.preferred\( warm the preferred format sibling + +# --- Server-list-first connect with LAN discovery (691fcfe1, 0eb652a5). +shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt 1 LanDiscovery\( LAN discovery binding +shared/src/commonMain/kotlin/org/prairieserver/prairie/discovery/LanCandidates.kt 1 prairie\.local mDNS-style host candidates +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity.kt 1 return Route\.ServerList\.route phone first run lands on the server list +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt 1 return TvRoute\.ServerList\.route TV first run lands on the server list +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 autoScan = !canGoBack phone first-run LAN scan +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt 1 autoScan = !canGoBack TV first-run LAN scan +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt 1 viewModel\.maybeAutoScan\(\) phone scan trigger +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt 1 viewModel\.maybeAutoScan\(\) TV scan trigger (UI dropped by sync #28) +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt 1 DiscoveredRow\( TV discovered-server rows + +# --- App update status (b3b04cbb). +shared/src/commonMain/kotlin/org/prairieserver/prairie/di/NetworkModule.kt 1 AppUpdateChecker\( update checker binding +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/settings/ServerInfoSection.kt 1 AppUpdateStatusRow\(\) phone settings update row (dropped by a sync once) +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/settings/TvSettingsScreen.kt 1 TvAppUpdateStatusRows\(\) TV settings update rows + +# --- Player: quality ladder, trickplay, stats for nerds (63055d17, 261d6f81). +shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/QualityLadder.kt 1 fun toV3QualityPreference quality preference mapping +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt 1 toV3QualityPreference\( TV maps quality through V3 ladder ids (261d6f81) +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt 1 qualityLadderClient = get\(\) phone quality ladder DI +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerProgressBar.kt 1 TrickplayTileImage\( phone trickplay scrub preview +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt 1 trickplay = state\.trickplay TV trickplay scrub preview +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerSettingsSheet.kt 1 label = "Stats for nerds" phone settings entry label +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt 1 "Stats for nerds" phone stats sheet +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt 1 Stats\("Stats for nerds"\) TV stats panel + +# --- Security hardening (5c2734fc, f4375468). +androidApp/src/androidMain/AndroidManifest.xml 1 networkSecurityConfig phone network security config +androidTvApp/src/androidMain/AndroidManifest.xml 1 networkSecurityConfig TV network security config +shared/src/androidMain/kotlin/org/prairieserver/prairie/network/AndroidServerRegistry.kt 2 clearLegacyUnprefixedKeys\( drop orphan legacy tokens From a1725fce89c5caf9c20030659f19717575210419 Mon Sep 17 00:00:00 2001 From: JonahMMay Date: Wed, 30 Sep 2026 13:54:31 +0000 Subject: [PATCH 2/2] fix(nav): clear first-run server list on manual setup; keep Live TV D-pad Up - With the server list as the first-run root, Add Server pushed setup on top of it and the auth chain's popUpTo(ServerSetup) left the list under Home/Main, so Back returned to the connect screen. Hand the root to setup on first run (phone and TV). - The TV shell's preview handler consumed D-pad Up on every route; skip it on the Live TV player so the PlayerView controls receive it. - check-prairie-invariants.sh: propagate grep errors (status > 1) instead of treating them as zero matches, so an invalid regex on an "absent" invariant cannot pass. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../android/ui/navigation/AppNavigation.kt | 8 ++++++- .../tv/ui/navigation/TvAppNavigation.kt | 8 ++++++- .../prairie/tv/ui/shell/TvMainShell.kt | 3 +++ scripts/check-prairie-invariants.sh | 22 ++++++++++++++++--- scripts/prairie-invariants.txt | 3 +++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt index 50cfa182a..3677cc3f3 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt @@ -570,7 +570,13 @@ fun AppNavigation( val canGoBack = navController.previousBackStackEntry != null ServerListScreen( onAddServer = { - navController.navigate(Route.ServerSetup.route) + navController.navigate(Route.ServerSetup.route) { + // Prairie: on first run this list is the graph root. Hand + // the root to setup so the auth chain's + // popUpTo(ServerSetup) clears it, and Back from Home + // cannot land on the connect list. + if (!canGoBack) popUpTo(Route.ServerList.route) { inclusive = true } + } }, onSwitched = { destination -> // Route to whichever screen the new server's stored diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt index 413dfbe5f..d630666e2 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt @@ -630,7 +630,13 @@ fun TvAppNavigation( val canGoBack = navController.previousBackStackEntry != null TvServerListScreen( onAddServer = { - navController.navigate(TvRoute.ServerSetup.route) + navController.navigate(TvRoute.ServerSetup.route) { + // Prairie: on first run this list is the graph root. Hand + // the root to setup so the auth chain's + // popUpTo(ServerSetup) clears it, and Back from Main + // cannot land on the connect list. + if (!canGoBack) popUpTo(TvRoute.ServerList.route) { inclusive = true } + } }, onSwitched = { destination -> // Land on the deepest route the new server's stored diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt index b866d81b0..504b0904a 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt @@ -1092,6 +1092,9 @@ fun TvMainShell( .focusGroup() .onPreviewKeyEvent { ev -> when { + // Prairie: the Live TV player's PlayerView controls own + // D-pad Up; the shell must not steal it for the menu bar. + currentRoute == TvMainRoute.LiveTvPlayer.ROUTE -> false ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionUp -> { val isRepeat = ev.nativeKeyEvent.repeatCount > 0 val contentHandledUp = contentUpFallback?.invoke(isRepeat) diff --git a/scripts/check-prairie-invariants.sh b/scripts/check-prairie-invariants.sh index ddee6cc77..24dccb5b1 100755 --- a/scripts/check-prairie-invariants.sh +++ b/scripts/check-prairie-invariants.sh @@ -23,11 +23,22 @@ failures=0 checked=0 count_matches() { - # $1 path, $2 pattern + # $1 path, $2 pattern. Prints the number of matching lines, or "error" when + # grep itself fails (status > 1, e.g. an invalid regex), so a broken + # "absent" invariant cannot pass by matching nothing. + out="" + status=0 if [ -d "$1" ]; then - { grep -rhE --include='*.kt' --include='*.xml' -e "$2" "$1" || true; } | wc -l | tr -d ' ' + out="$(grep -rhE --include='*.kt' --include='*.xml' -e "$2" "$1")" || status=$? else - { grep -cE -e "$2" "$1" || true; } | tr -d ' ' + out="$(grep -hE -e "$2" "$1")" || status=$? + fi + if [ "$status" -gt 1 ]; then + echo error + elif [ -z "$out" ]; then + echo 0 + else + printf '%s\n' "$out" | wc -l | tr -d ' ' fi } @@ -45,6 +56,11 @@ while IFS="$tab" read -r path min pattern why || [ -n "${path:-}" ]; do continue fi count="$(count_matches "$path" "$pattern")" + if [ "$count" = "error" ]; then + echo "::error file=$path::Prairie invariant: grep failed for /$pattern/ (invalid regex?) ($why)" + failures=$((failures + 1)) + continue + fi if [ "$min" = "absent" ]; then if [ "$count" -ne 0 ]; then echo "::error file=$path::Prairie invariant: /$pattern/ must not match but matched $count ($why)" diff --git a/scripts/prairie-invariants.txt b/scripts/prairie-invariants.txt index 383c3da33..019092f07 100644 --- a/scripts/prairie-invariants.txt +++ b/scripts/prairie-invariants.txt @@ -35,6 +35,7 @@ androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMain androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 liveTvFeatureStore\.refresh\(\) TV Live TV gate refresh androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 showLiveTv = liveTvEnabled TV profile dropdown gate androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 label = "Live TV" TV profile dropdown item +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/shell/TvMainShell.kt 1 currentRoute == TvMainRoute\.LiveTvPlayer\.ROUTE -> false Live TV player keeps D-pad Up androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/livetv/TvLiveTvScreen.kt 1 requestFocusUntilObserved\( TV Live TV initial focus (edb7e8cd) # --- Identity / rebrand (f8a795ff and follow-ups). Durable upgrade identity: @@ -84,6 +85,8 @@ androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/MainActivity androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/MainTvActivity.kt 1 return TvRoute\.ServerList\.route TV first run lands on the server list androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 autoScan = !canGoBack phone first-run LAN scan androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt 1 autoScan = !canGoBack TV first-run LAN scan +androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt 1 if \(!canGoBack\) popUpTo\(Route\.ServerList\.route\) first-run list does not linger under Home +androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/navigation/TvAppNavigation.kt 1 if \(!canGoBack\) popUpTo\(TvRoute\.ServerList\.route\) first-run list does not linger under Main androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/servers/ServerListScreen.kt 1 viewModel\.maybeAutoScan\(\) phone scan trigger androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt 1 viewModel\.maybeAutoScan\(\) TV scan trigger (UI dropped by sync #28) androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/servers/TvServerListScreen.kt 1 DiscoveredRow\( TV discovered-server rows