From f46d6596c561f3d03a6425d0a9c71eddc90638bc Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Tue, 11 Aug 2026 12:08:51 +1200 Subject: [PATCH] 0.2.48 - Tv calender fixes --- CHANGELOG.md | 20 + CLAUDE.md | 123 ++++ app/build.gradle.kts | 2 +- .../com/ponzischeme89/memby/ServiceLocator.kt | 11 + .../memby/data/EmbyRepository.kt | 81 ++- .../memby/data/MaintenanceMonitor.kt | 31 +- .../ponzischeme89/memby/data/SettingsStore.kt | 43 ++ .../memby/data/model/EmbyModels.kt | 2 + .../memby/data/model/GatewayModels.kt | 35 + .../memby/data/remote/GatewayApi.kt | 9 + .../data/remote/GatewayServiceFactory.kt | 23 + .../ponzischeme89/memby/ui/HomeComponents.kt | 37 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 38 +- .../com/ponzischeme89/memby/ui/ListKeys.kt | 58 ++ .../ponzischeme89/memby/ui/MainActivity.kt | 330 ++++++++- .../ponzischeme89/memby/ui/UpdateScreen.kt | 68 ++ .../memby/ui/alerts/AlertsPage.kt | 15 +- .../memby/ui/calendar/CalendarAgenda.kt | 682 ++++++++++++++++++ .../memby/ui/calendar/CalendarGrid.kt | 164 +++++ .../memby/ui/calendar/CalendarScreen.kt | 40 + .../memby/ui/calendar/CalendarViewModel.kt | 181 +++++ .../memby/ui/detail/DetailFacts.kt | 2 +- .../memby/ui/genre/GenreBrowseViewModel.kt | 27 +- .../memby/ui/player/PlayerActivity.kt | 41 ++ .../memby/ui/search/SearchViewModel.kt | 50 +- .../memby/ui/settings/SettingsSheet.kt | 39 + .../memby/update/RequiredUpdateGuard.kt | 28 + .../memby/update/RequiredUpdateSignal.kt | 60 ++ .../memby/update/RequiredUpdateState.kt | 37 + .../memby/ui/CalendarGridTest.kt | 239 ++++++ .../ponzischeme89/memby/ui/ListKeysTest.kt | 72 ++ .../ponzischeme89/memby/ui/MediaBadgesTest.kt | 19 +- .../ui/calendar/CalendarScreenshotTest.kt | 169 +++++ .../ui/settings/SettingsRailFocusTest.kt | 129 ++++ .../memby/update/RequiredUpdateSignalTest.kt | 28 + .../memby/update/RequiredUpdateStateTest.kt | 42 ++ gradle.properties | 1 + server/internal/api/api.go | 1 + server/internal/api/calendar.go | 247 +++++++ server/internal/api/calendar_test.go | 149 ++++ server/internal/api/features.go | 8 + server/internal/api/hero_test.go | 12 +- server/internal/api/sonarr.go | 62 +- server/internal/api/sonarr_test.go | 42 +- server/internal/buildinfo/VERSION | 2 +- server/internal/sonarr/client.go | 29 +- server/internal/store/library.go | 12 +- 47 files changed, 3415 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarGrid.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarScreen.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarViewModel.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateGuard.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateSignal.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateState.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/CalendarGridTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarScreenshotTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateSignalTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateStateTest.kt create mode 100644 server/internal/api/calendar.go create mode 100644 server/internal/api/calendar_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 369dfd6..2527d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## 0.2.48 — 2026-08-11 +- Improved: The TV calendar is now a modern weekly guide with artwork, title logos and clear season-premiere and finale labels. +- Added: Home-screen metadata now shows the sound profile, including mono, stereo, 5.1 and 7.1. + +## 0.2.47 — 2026-08-11 +- Fixed: A television signed out because its version has been retired now stays on the update screen — across a restart, and while the server is slow to answer — instead of falling back to a welcome and sign-in it could never get past. + +## 0.2.46 — 2026-08-11 +- Added: A TV calendar on the navigation rail — the month ahead as a grid, with the focused day's episodes beside it, so you can see when a show comes back. +- Added: A television signed out because its version has been retired now goes straight to the update screen, instead of showing a sign-in that could never succeed. +- Improved: In Settings, pressing Up at the top of a page returns to the page list rather than doing nothing. +- Fixed: The home screen could close itself when a row or a card arrived twice from the server. The same fault could close the cast list, search results, similar titles and episode lists. +- Fixed: Pressing down through the rows on Home and TV Shows could stop part way and refuse to go any further until you left the page. +- Fixed: Play could stop responding for the rest of the session if starting a title failed early, with nothing on screen to say why. +- Fixed: A title the server could not supply a stream for now says so, instead of flickering and returning to where you were. +- Fixed: Back now leaves the "starting playback" screen, rather than waiting out a server that has stopped answering. +- Fixed: Dismissing one of your alerts takes effect immediately, so a slow connection no longer invites a second press. +- Improved: Genre and search shelves no longer stop loading further pages when the server repeats a title across a page boundary. +- Improved: Memby uses less memory on a television left on for weeks at a time. + ## 0.2.45 - 2026-08-10 - Bug fixes diff --git a/CLAUDE.md b/CLAUDE.md index 5802917..0c7e993 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,45 @@ half-configured policy cannot produce a blocking screen with a dead button. The must stay out of `/v1/home`, which is cached per user while this answer varies per client build. +**A retired build must be told why it was signed out.** The destructive floor +(`destructiveUpdateFloor`) deletes the session on the next authenticated request and answers +401; a retired build's sign-in is then refused with 426. The television only saw the sign-out +— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up +to `UPDATE_CHECK_INTERVAL_MS` (an hour) away, force-closing the app being the only way +through, since a fresh launch checks for updates before it draws anything. Both refusals +carry `X-Memby-Update-Required`, so `RequiredUpdateInterceptor` on the gateway client +publishes it through `update/RequiredUpdateSignal` and `AppRoot`'s check loop waits on +*either* the hourly interval or that signal. Things to preserve: + +- **It is an interceptor** because the refusal lands on whichever request happened to be in + flight — the status poll, a home refresh, a sign-in — and only one of those has any reason + to know about update policy. The signal replays one value, because it is commonly reported + before the check loop is waiting on it. +- **The refusal is written to disk** (`Settings.requiredUpdateVersion`, via + `RequiredUpdateGuard` on the service locator), because it is announced exactly *once*: the + 401 that deletes the session carries the header and every 401 after it is an ordinary + missing session. In-memory only, a television told and then restarted had nothing left to + learn it from but the launch check — which is bounded by `UPDATE_CHECK_TIMEOUT_MS` (2.5s) + and, on missing it, put the viewer back on the welcome and sign-in screens. It is written + by the locator rather than by a screen for the same reason the interceptor exists: nothing + that knows about update policy is necessarily composed when the refusal arrives. +- **Only the gateway may withdraw it.** A verdict that is not a required update clears the + flag; a failed check never does, because an unreachable server is no evidence about which + builds it accepts. `requiredUpdateSatisfied` is the one exception and it is pure and + tested — the build the refusal demanded is now the build running, which is what the moment + after a successful self-update looks like. +- **`RetiredBuildScreen` is what the television shows meanwhile**, ahead of sign-in, + profiles, the launcher and the null-settings case alike. There is no attempt budget any + more: giving up used to hand the viewer a sign-in form as the *final* answer, and every + screen underneath this one is something the gateway would refuse. The loop asks every + `UPDATE_REQUIRED_RETRY_MS` for `UPDATE_REQUIRED_FAST_ATTEMPTS`, then settles onto + `UPDATE_REQUIRED_BACKOFF_MS`, and the screen's Try again wakes it through the same channel + a refusal does. +- **Every refusal wakes the loop, including a repeat.** The 401 that retires the session and + the 426 that refuses the sign-in after it name the same version, and the second is the one + a viewer is standing in front of — filtering it as already-handled is what left a + television on a form it could not get through until the next hourly check. + ## Architecture **Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore` @@ -1586,6 +1625,24 @@ three large files — new screens generally belong in `ui//` rather tha them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); everything must be reachable by D-pad only. +**A keyed lazy list must never be handed a repeated key.** `LazyRow`/`LazyColumn` throw on +one — *"Key … was already used"* — and every list on these screens is keyed by an id that +came off a wire, where nothing promises distinctness. Emby lists the same person twice on a +good fraction of a real cast; a "Because you watched" row built from two seeds can reach one +title by both; a search that falls back to Emby before the import finishes can return what +the library also matched; and a paging boundary is where a backend repeats a card by +definition. `ui/ListKeys.kt` is the one rule: **deduplicate, never disambiguate.** Folding +the index into the key would also stop the crash, but it keys an item by *where it is*, and +position is exactly what changes when a row reorders — which the return-focus and +scroll-restoration behaviour throughout this app depends on identity to survive. Apply it +where the data enters state (`HomeViewModel.sanitisedRows`, `EmbyRepository.loadRelated` and +`loadSeriesEpisodes`, the two genre pagers, `SearchViewModel.runSearch`) rather than in a +composable; where a composable is the only place, keep it inside a `remember(list)`. And +where a pager deduplicates, **how far it has read is counted in what the backend sent**, not +in the length of the list (`SearchUiState.genreOffset`, `GenreBrowseUiState.readOffset`) — a +dropped duplicate would otherwise make the next offset point before the end of the last +page, and the shelf would stop growing while re-requesting the same page for ever. + **Animations must not recompose.** This app ships to weak TV boxes, so an animated value read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and @@ -1775,6 +1832,19 @@ notices — because with the card gone that inset is the only thing holding the together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have seen"), not feature names. +**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first +control, so that press did nothing at all on every page — and a remote that stops responding +is not read as a list that has run out. About is where it was reported, its pane being a +changelog long enough that walking back up it is the ordinary way to leave. The escape is an +`onKeyEvent` on the content column that makes the move the default handler would have made +and falls back to the rail only when it fails, so a page's own vertical navigation is +untouched: `focusProperties { up = … }` is inherited by every row and would take that +navigation away, and `exit` is never consulted when the search finds nothing anywhere, which +is the whole case. The rail item for the page being *drawn* carries a second requester for +it — the selection rather than the highlight, so a press arriving mid-settle still lands on +the page that is on screen. `SettingsRailFocusTest` pins both halves, the escape and the +navigation between rows it must not disturb. + **One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`, `heroFacts`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the card metadata call them rather than carrying private copies. That is why the hero and the card directly @@ -2102,6 +2172,59 @@ availability badge above it answers a different question (has the household's co downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead of everything else on a followed show: nothing else on that card matters as much. +**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is +on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`) +answers "what is on this month, and when does it come back", which is a question no shelf +has a form for — so it is a rail destination with a weekly TV guide: seven days in a rail +and the selected day's artwork-led programme list beside it. It reuses +`toSonarrScheduleItem`, so a calendar card and a +schedule card are the same card, with the same availability badges, lifecycle tag and Emby +series link; pressing one makes the same substitution the row does (`scheduleSeriesStub` + +`airingNoticeFor`), because an episode that has not aired has no page of its own. Things to +preserve: + +- **The television does no calendar arithmetic.** The gateway sends `firstWeekday`, + `dayCount` and its own `today`; `calendarWeeks` lays out the month from those alone and + `calendarAgendaWeeks` only pages those cells seven at a time. A + set working out for itself which years are leap years, in its own zone rather than the + household's, would be a second calendar free to disagree with the days the episodes were + grouped into — which on the wrong side of midnight it would. The one thing the set does + read its own clock for is *when to ask again*: `CalendarViewModel` drops every cached + month once the device's local day changes, because everything else about a month is fixed + and only `today` goes stale. Being wrong about that by an hour costs one request, where + being wrong about the layout would draw a calendar that disagrees with itself. +- **A month is claimed by what was asked for, not by what arrives.** Cancelling a coroutine + already past its last suspension point does not stop it, and a held D-pad on a month arrow + is exactly how two requests come to be in flight — so a response is dropped unless it is + still the month `requestedMonth` names. The cache is bounded for the same reason: "held + for the life of the page" and "grows while somebody holds the D-pad" are otherwise the + same sentence, and a month is a list of episodes with artwork behind it. +- **Focus is selection**, the stance the detail page's tab strip takes. A remote has no + hover, and a calendar needing a press per day to say what is on it is one nobody reads. A + press moves *into* the day panel; Back steps out of the panel before leaving the page. +- **Month and week travel are explicit controls.** Left and Right inside the guide already + mean moving between the day rail and its programmes, so those keys cannot also change the + date range. An arrow at the end of either range is not drawn rather than drawn dead. + `calendarMonthRange` (12) is what stops a + held D-pad walking Sonarr into the 2050s one request at a time; `parseCalendarMonth` refuses + an out-of-range month rather than clamping, or the header would disagree with the grid. +- **The day rail summarises; the programme pane explains.** A day names its count and first + show only. The pane has the space for Sonarr fanart (or a graphical monogram fallback), an + Emby title logo when the series was matched, episode details, availability and a prominent + season-premiere/finale label. Finale wording comes from Sonarr's `finaleType`; an absent + value makes no claim. `CalendarScreenshotTest` renders a crowded day and the artwork-free + fallback because only a screenshot can check that hierarchy at television distance. +- **The rail entry is a server feature** (`tv_calendar`, capability `tv_calendar_v1`), because + a household running no Sonarr would otherwise carry a destination that only ever opens an + apology. A set standing on the page when it is switched off is moved to Home, or it is left + somewhere nothing can navigate back to. +- **A failed month is an empty month, not an error.** The page is informational, and somebody + who pressed Right past a Sonarr hiccup must be able to press Left back out of it. +- **There is no second implementation on the direct path.** Unlike subtitles or Continue + Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows + nothing about — with no gateway there is genuinely no calendar, and the rail says so by + omitting the entry. + **Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV, landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather than a bare `@Preview`, which defaults to a phone and misrepresents every layout here. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2ee985..2bdbec8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.45" +val defaultVersionName = "0.2.48" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index 10cca20..a4ec7f9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync import com.ponzischeme89.memby.data.SettingsStore import com.ponzischeme89.memby.data.ThemeSync import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe +import com.ponzischeme89.memby.update.RequiredUpdateGuard /** * Tiny manual dependency container. Initialised once from [MembyApp] so that the @@ -41,12 +42,22 @@ object ServiceLocator { lateinit var themeSync: ThemeSync private set + /** + * Held for the same reason [preferencesSync] is: it is a collector, and the refusal it + * writes down can arrive on any request at any moment of a session. + */ + lateinit var requiredUpdateGuard: RequiredUpdateGuard + private set + fun init(context: Context) { if (::repository.isInitialized) return // Only hands the probe an application context; it does no work until the first // request or playback negotiation asks what this television's receiver accepts. installAudioCapabilityProbe(context) settings = SettingsStore(context.applicationContext) + // Started before the repository, so a refusal answering the very first request a + // television makes has somewhere to be recorded. + requiredUpdateGuard = RequiredUpdateGuard(settings) repository = EmbyRepository(settings) maintenance = MaintenanceMonitor(repository, settings) // Takes the revision channel from the status poll rather than polling itself: the diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index 7414c38..48d5fc7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data import com.ponzischeme89.memby.data.model.AuthRequest import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayCalendar import com.ponzischeme89.memby.data.model.GatewayFlagRequest import com.ponzischeme89.memby.data.model.GatewayDevice import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest @@ -826,6 +827,23 @@ class EmbyRepository(private val settings: SettingsStore) { .getOrDefault(emptyList()) } + /** + * One month of the TV calendar, or an unavailable month when there is nobody to ask. + * + * This is deliberately gateway-only and has no second implementation on the direct + * path, unlike the subtitle and Continue Watching rules: the answer comes from Sonarr, + * which a television holds no credential for and Emby knows nothing about. With no + * gateway there is genuinely no calendar, and saying so is the honest degradation — + * the rail entry is hidden in that case anyway. + * + * A blank [month] asks for the household's present month. The television never derives + * one from its own clock, so the header can never disagree with the grid beneath it. + */ + suspend fun getCalendarMonth(month: String = ""): GatewayCalendar { + if (!ServerConfig.isGateway) return GatewayCalendar() + return requireGateway().calendar(month.trim().takeIf { it.isNotEmpty() }) + } + suspend fun lookupMediaRequests(term: String): List { if (!ServerConfig.isGateway) return emptyList() return requireGateway().requestLookup(term.trim()).candidates @@ -987,8 +1005,12 @@ class EmbyRepository(private val settings: SettingsStore) { ) } - private val personCache = java.util.concurrent.ConcurrentHashMap() - private val filmographyCache = java.util.concurrent.ConcurrentHashMap>() + // Bounded, access-ordered and synchronised rather than a plain concurrent map. These + // are process-lifetime caches on a device that is commonly left on for weeks, and an + // unbounded one holds an entry for every person, filmography and title the household + // has ever put focus on — which on a large library is the library. + private val personCache = boundedCache(PERSON_CACHE_SIZE) + private val filmographyCache = boundedCache>(FILMOGRAPHY_CACHE_SIZE) /** Biography and life dates for a cast member. Emby models a person as an item. */ suspend fun getPersonDetails(personId: String): BaseItem { @@ -1039,7 +1061,8 @@ class EmbyRepository(private val settings: SettingsStore) { return items } - private val ratingsCache = java.util.concurrent.ConcurrentHashMap>() + private val ratingsCache = + boundedCache>(RATINGS_CACHE_SIZE) /** Optional ratings that never block essential metadata. The gateway owns a durable * cache; this process cache makes repeated focus/detail visits free on the TV. */ @@ -1156,12 +1179,21 @@ class EmbyRepository(private val settings: SettingsStore) { return request } + /** + * The carousel under a detail page, from whichever backend there is. + * + * Both paths drop the subject itself and then deduplicate. The gateway's answer is + * assembled from Emby's similarity list with the imported catalogue's genre + * neighbours behind it, and the two can name the same title; the carousel is a keyed + * LazyRow, where a repeat takes the detail page down rather than drawing a poster + * twice. + */ private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent { if (ServerConfig.isGateway) { val response = requireGateway().related(item.id) return RelatedContent( reasons = response.reasons.filter(String::isNotBlank), - items = response.items.filter { it.id != item.id }, + items = response.items.filter { it.id != item.id }.distinctBy(BaseItem::id), ) } val userId = snapshot.userId ?: error("Not connected") @@ -1177,7 +1209,7 @@ class EmbyRepository(private val settings: SettingsStore) { "EnableImageTypes" to "Primary,Backdrop,Logo", "ImageTypeLimit" to "1", ), - ).items.filter { it.id != item.id }, + ).items.filter { it.id != item.id }.distinctBy(BaseItem::id), ) } @@ -1222,9 +1254,18 @@ class EmbyRepository(private val settings: SettingsStore) { return request } + /** + * Every episode of a series, deduplicated on the way in. + * + * The episode list is rendered as a keyed lazy list on the series page and again on an + * episode's own page, so one id arriving twice — a file imported under two paths, a + * gateway that concatenated a paged read — is a crash on both rather than a repeated + * row. It is also the list the season scroller and the pace estimate are derived from, + * and a duplicate would quietly overstate both. + */ private suspend fun loadSeriesEpisodes(seriesId: String): List { val now = System.currentTimeMillis() - val loaded = if (ServerConfig.isGateway) { + val loaded = (if (ServerConfig.isGateway) { requireGateway().seriesEpisodes(seriesId).items } else { val userId = snapshot.userId ?: error("Not connected") @@ -1240,7 +1281,7 @@ class EmbyRepository(private val settings: SettingsStore) { "Limit" to "1000", ), ).items - } + }).distinctBy(BaseItem::id) seriesEpisodesMutex.withLock { seriesEpisodesCache[seriesId] = CachedSeriesEpisodes( episodes = loaded, @@ -2473,9 +2514,35 @@ class EmbyRepository(private val settings: SettingsStore) { * changing it makes every existing install re-fetch its artwork once. */ const val ARTWORK_QUALITY = 80 + + /** + * Bounds on the three process-lifetime caches that hold whatever the household has + * browsed. Generous enough that a normal evening never evicts anything, and small + * enough that a television left on for a month does not end up holding a copy of + * the catalogue. Ratings has the largest because it is filled by merely putting + * focus on a card, which is every card on the launcher. + */ + const val PERSON_CACHE_SIZE = 64 + const val FILMOGRAPHY_CACHE_SIZE = 32 + const val RATINGS_CACHE_SIZE = 512 } } +/** + * A bounded, access-ordered, thread-safe cache. + * + * The `LinkedHashMap` eviction pattern the repository already uses for playables, related + * content and episodes, wrapped so the maps that were plain `ConcurrentHashMap`s can keep + * their `get`/`put` call sites. + */ +private fun boundedCache(maxEntries: Int): MutableMap = + java.util.Collections.synchronizedMap( + object : LinkedHashMap(maxEntries.coerceAtLeast(1), 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > maxEntries + }, + ) + data class PlaybackSession( val itemId: String, val mediaSourceId: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index 5d8cbb5..b7b7436 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -92,6 +92,7 @@ class MaintenanceMonitor( private val _theme = MutableStateFlow(GatewayThemeStatus()) private val _installPermissionPrompt = MutableStateFlow(false) private val _genreBrowserEnabled = MutableStateFlow(false) + private val _tvCalendarEnabled = MutableStateFlow(false) private val _gatewayVersion = MutableStateFlow("") /** @@ -123,10 +124,31 @@ class MaintenanceMonitor( /** Server-controlled because the browser layout is still being refined. */ val genreBrowserEnabled: StateFlow = _genreBrowserEnabled.asStateFlow() + /** + * Whether the TV calendar destination belongs on the rail. False whenever the server has + * not said otherwise, which is what keeps the entry off a household running no Sonarr: + * a rail item leading to a page with nothing behind it is worse than no item at all. + */ + val tvCalendarEnabled: StateFlow = _tvCalendarEnabled.asStateFlow() + /** Build reported by the connected gateway, for Settings → About. */ val gatewayVersion: StateFlow = _gatewayVersion.asStateFlow() - private val seenAlertIds = mutableSetOf() + /** + * Alerts this television has already shown. + * + * Bounded, and by rather more than the persisted list keeps: what is on disk is the + * record that has to survive a relaunch, while this one only has to outlive the + * gateway's own alert window. Unbounded it grows by an entry per announcement for as + * long as the process lives, and a television in a living room is a process that lives + * for months. + */ + private val seenAlertIds = java.util.Collections.newSetFromMap( + object : LinkedHashMap(MAX_TRACKED_ALERT_IDS, 0.75f, false) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > MAX_TRACKED_ALERT_IDS + }, + ) private var seenAlertsLoaded = false private var shownAlertId: String? = null private var alertTimer: Job? = null @@ -182,6 +204,7 @@ class MaintenanceMonitor( _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false _genreBrowserEnabled.value = false + _tvCalendarEnabled.value = false _gatewayVersion.value = "" dismissAlert() return@collectLatest @@ -213,6 +236,7 @@ class MaintenanceMonitor( _installPermissionPrompt.value = status.features[INSTALL_PERMISSION_FEATURE] == true _genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true + _tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true _gatewayVersion.value = status.gatewayVersion // Emby's state is reported even during maintenance: an // operator taking Memby down while Emby is also unreachable @@ -236,6 +260,7 @@ class MaintenanceMonitor( _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false _genreBrowserEnabled.value = false + _tvCalendarEnabled.value = false _gatewayVersion.value = "" dismissAlert() return@collectLatest @@ -339,6 +364,10 @@ class MaintenanceMonitor( /** Matches `featureGenreBrowser` in the gateway's feature catalogue. */ internal const val GENRE_BROWSER_FEATURE = "genre_browser" + internal const val TV_CALENDAR_FEATURE = "tv_calendar" + + /** Insertion-ordered ceiling on the in-memory seen set. See [seenAlertIds]. */ + private const val MAX_TRACKED_ALERT_IDS = 200 /** * How often Emby is retried during an outage. It matches the gateway's own diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 9b4d97f..8316b40 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -14,10 +14,12 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore +import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.data.playback.AudioPassthroughMode import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference import com.ponzischeme89.memby.data.playback.SurroundCodec import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs +import com.ponzischeme89.memby.update.requiredUpdateSatisfied import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch @@ -401,6 +403,15 @@ data class Settings( * not seen that build's update notice. */ val whatsNewSeenVersion: String? = null, + /** + * The version the gateway last refused this build over, or null while it has said + * nothing. Device state, and deliberately outlives both the session and the process: + * the refusal is announced exactly once — the 401 that deletes the session carries the + * header, every 401 after it is an ordinary missing session — so a television that was + * told and then restarted has nothing left to learn it from but this. It is what stops + * the launcher drawing a sign-in form the gateway is going to refuse. + */ + val requiredUpdateVersion: String? = null, /** * Revision of the active profile's settings as last agreed with the gateway. Zero * means this TV has never synced them, which is what tells [PreferencesSync] to push @@ -554,6 +565,7 @@ class SettingsStore(private val context: Context) { val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids") val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids") val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version") + val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version") val PREFERENCES_REVISION = longPreferencesKey("preferences_revision") } @@ -1039,6 +1051,36 @@ class SettingsStore(private val context: Context) { } } + /** + * Records that the gateway has refused this build, so the refusal survives the process + * it arrived in. Ignored once this television is already running the version being + * demanded: the flag is about the APK, and one that has since been replaced is not the + * one that was retired. + */ + suspend fun markRequiredUpdate( + version: String, + installedVersion: String = BuildConfig.VERSION_NAME, + ) { + val trimmed = version.trim() + if (trimmed.isEmpty() || requiredUpdateSatisfied(installedVersion, trimmed)) return + if (current?.requiredUpdateVersion == trimmed) return + context.dataStore.edit { preferences -> + preferences[Keys.REQUIRED_UPDATE_VERSION] = trimmed + } + } + + /** + * Forgets the refusal, once the gateway has answered with something that is not a + * required update. Only the gateway's own verdict may do this — a failed check is the + * server being unreachable, which is no evidence at all about which builds it accepts. + */ + suspend fun clearRequiredUpdate() { + if (current?.requiredUpdateVersion == null) return + context.dataStore.edit { preferences -> + preferences.remove(Keys.REQUIRED_UPDATE_VERSION) + } + } + suspend fun markForYouOpened() { context.dataStore.edit { preferences -> preferences[Keys.HAS_OPENED_FOR_YOU] = true @@ -1439,6 +1481,7 @@ class SettingsStore(private val context: Context) { themeRevision = preferences[Keys.THEME_REVISION].orEmpty(), onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION], + requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION], preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, profiles = profiles, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 9c5fc4e..5ff7dd2 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -434,6 +434,8 @@ data class BaseItem( @SerialName("MembyAirLabel") val membyAirLabel: String? = null, @SerialName("MembyAvailability") val membyAvailability: String? = null, @SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null, + // Sonarr-authored schedule milestone: Season premiere, Season finale or Series finale. + @SerialName("MembyEpisodeEvent") val membyEpisodeEvent: String? = null, // Sonarr's or Radarr's own lifecycle for the title — whether more episodes are coming, // whether the film has actually been released. Distinct from availability, which is // about the household's copy. The slug is what the badge colours by; the text is the diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 0ad252b..7754068 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -757,3 +757,38 @@ data class GatewayPlaybackReport( data class GatewayPlaybackReportResponse( val autoFollowedShowTitle: String = "", ) + +/** + * One month of the Sonarr calendar, as `GET /v1/calendar` answers it. + * + * The grid's shape ([firstWeekday], [dayCount]) is the gateway's answer rather than the + * television's arithmetic: the household's time zone is the server's, and a set working out + * for itself which years are leap years is a second calendar that can disagree with the one + * the cards were grouped by. + * + * [available] is false when the household runs no Sonarr, or the operator has the feature + * off, and defaults that way — a missing field must never draw an empty month that looks + * like a fortnight in which nothing airs. + */ +@Serializable +data class GatewayCalendar( + val available: Boolean = false, + val month: String = "", + val label: String = "", + val previous: String = "", + val next: String = "", + /** The household's today, only when it falls inside this month. */ + val today: String = "", + /** Sunday is 0, matching the weekday header the grid draws. */ + val firstWeekday: Int = 0, + val dayCount: Int = 0, + val days: List = emptyList(), +) + +/** A day that has something on it. Empty days are the grid's to draw, not the wire's. */ +@Serializable +data class GatewayCalendarDay( + val date: String = "", + val day: Int = 0, + val items: List = emptyList(), +) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index 706836e..a00678e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -94,6 +94,15 @@ interface GatewayApi { @Query("type") itemType: String, ): com.ponzischeme89.memby.data.model.GatewayGenrePage + /** + * One month of the TV calendar. An absent [month] is the household's present month, + * which is what a television asks for when the page opens. + */ + @GET("v1/calendar") + suspend fun calendar( + @Query("month") month: String? = null, + ): com.ponzischeme89.memby.data.model.GatewayCalendar + @POST("v1/search/history") suspend fun recordSearch(@Body body: Map) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index c24d0f2..f686a9b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.gatewayAudioTokens import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens +import com.ponzischeme89.memby.update.RequiredUpdateSignal import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -36,6 +37,7 @@ object GatewayServiceFactory { // read timeout here only ever means Emby itself is struggling behind it. .readTimeout(20, TimeUnit.SECONDS) .addInterceptor(GatewayAuthInterceptor(tokenProvider)) + .addInterceptor(RequiredUpdateInterceptor) .build() return Retrofit.Builder() @@ -90,6 +92,24 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : }.getOrDefault(emptyList()) } +/** + * Watches every gateway response for the header that says this build has been retired. + * + * It is an interceptor rather than a check at the call sites because the refusal arrives + * on whichever request happened to be in flight — the status poll, a home refresh, a + * sign-in — and only one of those has any reason to know about update policy. Reading the + * header here is also what makes the 401 legible: on its own it is indistinguishable from + * an expired session, which is why the app used to answer it with a sign-in form the + * gateway would then refuse. + */ +private object RequiredUpdateInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + response.header(RequiredUpdateSignal.HEADER)?.let(RequiredUpdateSignal::report) + return response + } +} + internal const val MEMBY_PROTOCOL_VERSION = 1 internal val MEMBY_CAPABILITIES = listOf( @@ -98,6 +118,9 @@ internal val MEMBY_CAPABILITIES = listOf( "sonarr_preroll_v1", "auto_my_shows_v1", "genre_browser_v1", + // Declares that this build has the TV calendar destination, so the operator cannot + // push a rail entry to a television that has nowhere to send it. + "tv_calendar_v1", // Declares that this build can show the install-permission step. An older app never // receives the feature, so the operator cannot push a screen it does not have. "install_permission_v1", diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 0b1b5b4..6b10085 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -122,6 +122,7 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH +import com.ponzischeme89.memby.ui.detail.channelLabel import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.theme.FactSeparator @@ -167,6 +168,9 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) { SEARCH("Search", Icons.Default.Search), MOVIES("Movies", Icons.Default.Movie), SHOWS("TV Shows", Icons.Default.Tv), + // Sonarr's schedule, a month at a time. Hidden unless the gateway says the household + // has one — see [TvNavigationRail]'s calendarEnabled. + CALENDAR("TV Calendar", Icons.Default.CalendarMonth), FAVORITES("Favourites", Icons.Default.Favorite), PROFILES("Switch user", Icons.Default.Person), SETTINGS("Settings", Icons.Default.Settings), @@ -239,6 +243,7 @@ fun TvNavigationRail( modifier: Modifier = Modifier, alertCount: Int = 0, activeUsername: String = "", + calendarEnabled: Boolean = false, ) { var railHasFocus by remember { mutableStateOf(false) } val logoScale = remember { Animatable(0.72f) } @@ -343,7 +348,15 @@ fun TvNavigationRail( } } Spacer(Modifier.height(16.dp)) - BrowseDestination.entries.forEach { destination -> + // A destination with nothing behind it is worse than one fewer: the calendar + // needs the gateway and a Sonarr, and a household with neither would otherwise + // carry a rail item that only ever opens an apology. + val destinations = remember(calendarEnabled) { + BrowseDestination.entries.filter { + it != BrowseDestination.CALENDAR || calendarEnabled + } + } + destinations.forEach { destination -> ExpandableNavigationItem( destination = destination, selected = destination == selected, @@ -1394,6 +1407,7 @@ internal fun mediaBadges(item: BaseItem): List { dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title) ?.let { add(it.uppercase(Locale.US)) } if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") + audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) } if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS") }.distinct() } @@ -1434,8 +1448,11 @@ internal fun MediaRow( ?: return@LaunchedEffect rowState.scrollToItem(requestedEntryIndex!!) // LazyRow applies scrollToItem during layout. Wait for the requested card's focus - // node to attach before transferring focus; retrying covers slower TV frames. - repeat(3) { + // node to attach before transferring focus; retrying covers slower TV frames. Six + // attempts rather than three because giving up here is a press that visibly does + // nothing, and the shelf this arrives at is commonly one being composed for the + // first time — the frame budget on a Chromecast is not the one a warm row gets. + repeat(6) { kotlinx.coroutines.delay(16L) if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { onVerticalFocusRequestConsumed(request.requestId) @@ -1605,8 +1622,12 @@ internal fun CastRail( compact: Boolean = false, showTitle: Boolean = true, ) { - val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) } - if (cast.isEmpty()) return + val distinctCast = remember(people) { + people.filter(EmbyPerson::isCastMember) + .distinctForKeys { "${it.id}:${it.name}" } + .take(16) + } + if (distinctCast.isEmpty()) return Column(modifier) { if (showTitle) { Text( @@ -1624,7 +1645,11 @@ internal fun CastRail( bottom = 4.dp, ), ) { - items(cast, key = { "${it.id}:${it.name}" }) { person -> + // Emby lists the same person twice often enough that this is not a theoretical + // guard: a title where one actor is credited under two roles, or where an + // agent has written the cast twice, would otherwise hand this LazyRow two + // items under one key and take the whole detail page down with it. + items(distinctCast, key = { "${it.id}:${it.name}" }) { person -> CastCard(person, compact) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 465a3c6..9c9cf60 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -92,9 +92,13 @@ data class HomeUiState( // first refresh replaces it with a properly interleaved one. continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty()) .distinctBy(BaseItem::id), - favorites = cache?.favorites.orEmpty(), - latestMovies = cache?.latestMovies.orEmpty(), - rows = cache?.rows.orEmpty(), + favorites = cache?.favorites.orEmpty().distinctItems(), + latestMovies = cache?.latestMovies.orEmpty().distinctItems(), + // A cache is the previous build's output as much as this one's, so it is + // sanitised on the way in for the same reason a response is — see + // [sanitisedRows]. This one is drawn before any request has been made, which + // makes it the copy a duplicate would crash the app on every single launch. + rows = cache?.rows.orEmpty().sanitisedRows(), loading = buildSet { if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE) if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES) @@ -222,7 +226,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) } viewModelScope.launch(Dispatchers.IO) { runCatching { repository.getForYou(minutes) } - .onSuccess { rows -> + .onSuccess { built -> + val rows = built.sanitisedRows() _forYou.value = ForYouUiState( rows = rows, availableMinutes = minutes, @@ -272,13 +277,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { val taggedHome = home.withAiringTodayTags() _state.update { current -> current.copy( - continueWatching = taggedHome.continueWatching, - favorites = taggedHome.favorites, - latestMovies = taggedHome.latestMovies, + continueWatching = taggedHome.continueWatching.distinctItems(), + favorites = taggedHome.favorites.distinctItems(), + latestMovies = taggedHome.latestMovies.distinctItems(), // Recommendation rows are built in the background by the gateway, // so an early response can arrive without them. Keeping the rows // we already had stops the strip flickering out and back in. - rows = taggedHome.rows.ifEmpty { current.rows }, + rows = taggedHome.rows.sanitisedRows().ifEmpty { current.rows }, loading = emptySet(), hasRefreshError = taggedHome.partial, statusMessage = null, @@ -313,12 +318,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { _state.update { state -> val airingTodayKeys = state.rows.airingTodayShowKeys() val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys) + // The three id shapes are what the recommendation build is *expected* to + // replace. Anything else it returns under an id the launcher already holds is + // still a replacement — and, left alone, would be a second LazyColumn item + // under one key, which is a crash rather than a duplicate row. So the arriving + // set wins by id, and the concatenation is sanitised regardless. + val freshIds = taggedFresh.mapTo(mutableSetOf(), HomeRow::id) val fixedRows = state.rows.filterNot { row -> row.id == "recommended" || row.id.startsWith("similar:") || - row.id.startsWith("curated:") + row.id.startsWith("curated:") || + row.id in freshIds } - state.copy(rows = fixedRows + taggedFresh) + state.copy(rows = (fixedRows + taggedFresh).sanitisedRows()) } } @@ -544,7 +556,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { updateItems: (HomeUiState, List) -> HomeUiState, ) { runCatching { request() } - .onSuccess { items -> + .onSuccess { rawItems -> + // The direct path's own fan-out gets the same treatment the gateway's + // batch response does: whichever backend answered, two cards under one id + // take the row's LazyRow down with them. + val items = rawItems.distinctItems() _state.update { current -> updateItems(current, items).let { if (clearLoading) it.copy(loading = it.loading - section) else it diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt new file mode 100644 index 0000000..d647877 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ListKeys.kt @@ -0,0 +1,58 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.HomeRow + +/** + * Lists made safe to hand a keyed `LazyRow`/`LazyColumn`. + * + * A keyed lazy list throws — hard, taking the whole launcher with it — the moment two + * items yield the same key: *"Key … was already used"*. Every list on this app's screens + * is keyed by an id that came off a wire, and nothing on either wire promises those ids + * are distinct. Emby lists the same person twice on a good fraction of a real library's + * cast; a "Because you watched" row built from two seeds can reach the same title by both; + * a search that falls back to Emby before the import has finished can return an item the + * library also matched; and a row assembled from two sorted lists is one merge bug away + * from carrying an item twice. None of those are the television's fault and none of them + * are worth a crash: a duplicate card is at worst a card drawn once instead of twice. + * + * So the rule is deduplicate, never disambiguate. Making the key unique by folding the + * index into it would also stop the crash, but it would key an item by *where it is*, and + * position is exactly what changes when a row reorders — which is what the return-focus + * and scroll-restoration behaviour up and down this app depends on identity to survive. + * + * Call it where the data enters state (the view models) in preference to inside a + * composable; where a composable is the only place it can go, keep it inside a + * `remember(list)` so a scroll does not re-run it per frame. + */ +internal inline fun List.distinctForKeys(selector: (T) -> K): List { + if (size < 2) return this + val seen = HashSet(size) + // The overwhelmingly common case by far is a list that is already distinct, and this + // runs on every home response and every page appended to a shelf. Returning the + // receiver rather than a copy of it keeps that case free — and keeps it *identical*, + // which matters downstream where `remember` and `distinctUntilChanged` compare what + // they are given. + if (all { seen.add(selector(it)) }) return this + seen.clear() + return filter { seen.add(selector(it)) } +} + +/** [distinctForKeys] for the one shape most of these lists have. */ +internal fun List.distinctItems(): List = distinctForKeys(BaseItem::id) + +/** + * A home payload safe to render: rows unique by row id, and each row's cards unique by + * item id. + * + * Both halves are needed and they fail in different places. Two rows sharing an id crash + * the launcher's own `LazyColumn` — which `refreshRecommendationRows` can produce on its + * own, since it appends a freshly built set of rows to the ones already held and only + * three id shapes are filtered out of the old list first. Two cards sharing an id crash + * whichever row holds them, taking the launcher down just the same. + */ +internal fun List.sanitisedRows(): List = + distinctForKeys(HomeRow::id).map { row -> + val items = row.items.distinctItems() + if (items.size == row.items.size) row else row.copy(items = items) + } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index 940dfb7..926b614 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -135,6 +135,7 @@ import com.ponzischeme89.memby.ui.alerts.MyAlertsPage import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.airingNoticeFor import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub +import com.ponzischeme89.memby.ui.calendar.CalendarScreen import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen import com.ponzischeme89.memby.ui.player.PlayerActivity @@ -163,11 +164,14 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.setup.SignInContent import com.ponzischeme89.memby.update.InstallPermission +import com.ponzischeme89.memby.update.RequiredUpdateSignal +import com.ponzischeme89.memby.update.requiredUpdateSatisfied import com.ponzischeme89.memby.update.ServerUpdateService import androidx.tv.material3.Button import androidx.tv.material3.Card import androidx.tv.material3.Icon import androidx.tv.material3.Text +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch @@ -245,6 +249,30 @@ private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L +/** + * How long to wait before asking again while the gateway has this build retired. + * + * There is no attempt budget any more, and that is the point: a television in this state + * has been signed out and every screen it could otherwise be shown is one the gateway will + * refuse, so giving up used to mean handing the viewer a sign-in form as the *final* + * answer. It asks quickly at first, in case the verdict is merely a moment behind the + * refusal, then settles onto [UPDATE_REQUIRED_BACKOFF_MS] so an unreachable gateway is not + * polled every few seconds for as long as the set is switched on. + */ +private const val UPDATE_REQUIRED_RETRY_MS = 5_000L +private const val UPDATE_REQUIRED_BACKOFF_MS = 60_000L +private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4 + +/** + * How long one row-to-row focus move may hold the D-pad before the launcher assumes + * nothing is going to answer for it. + * + * Comfortably longer than the scroll it waits on plus the destination row's own focus + * retries, so an ordinary move is never cut short — and short enough that a move nothing + * completes costs a press rather than the rest of the session's vertical navigation. + */ +private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L + @Composable private fun AppRoot(onCloseSettings: () -> Unit) { val repo = ServiceLocator.repository @@ -254,6 +282,23 @@ private fun AppRoot(onCloseSettings: () -> Unit) { val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } var appUpdate by remember { mutableStateOf(null) } var initialUpdateCheckComplete by remember { mutableStateOf(false) } + // The refusal as it arrives, before the write of it has come back round through the + // settings flow. Both halves are needed: this one closes the window in which the + // sign-out has already landed and the persisted flag has not, and the persisted one + // (below) is what remembers the refusal across a restart. + var reportedRequiredUpdate by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + RequiredUpdateSignal.required.collect { + // Same rule the persisted copy applies: a refusal this build already satisfies + // describes an APK that is no longer here. + if (!requiredUpdateSatisfied(BuildConfig.VERSION_NAME, it)) { + reportedRequiredUpdate = it + } + } + } + // Wakes the check loop: a refusal arriving, or the viewer pressing Try again on the + // retired-build screen. Conflated because the only thing either says is "ask now". + val updateWake = remember { Channel(Channel.CONFLATED) } // Held for the length of Memby's opening clip plus its pause, once per process. Not a // rememberSaveable: an activity recreated behind the viewer (returning from the TV home // screen, a configuration change) is not an app launch, and LaunchIntro carries that @@ -292,10 +337,26 @@ private fun AppRoot(onCloseSettings: () -> Unit) { } var onboardingToken by remember { mutableStateOf(null) } LaunchedEffect(updateService) { + // Every refusal wakes the loop, including a repeat of a version already handled: + // the 401 that retires the session and the 426 that refuses the sign-in after it + // name the same version, and the second is the one a viewer is standing in front + // of. Filtering those out is what left a television on a form it could not get + // through until the next hourly check. + launch { RequiredUpdateSignal.required.collect { updateWake.trySend(Unit) } } var firstCheck = true + // How many checks in a row have been made because this build is retired. Loop + // state rather than app state: nothing outside this effect decides when to ask. + var requiredAttempts = 0 while (true) { - val result = if (firstCheck) { + // What the gateway has already said about this build, read from disk rather + // than from this loop's memory — the refusal is commonly one process old. + val retired = ServiceLocator.settings.current + ?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + ?: reportedRequiredUpdate + val result = if (firstCheck && retired == null) { // A disconnected server must not strand an otherwise usable TV at boot. + // A retired one is not usable, so it waits for the real answer instead of + // being hurried past it onto a sign-in form. withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() } } else { updateService.check() @@ -304,12 +365,35 @@ private fun AppRoot(onCloseSettings: () -> Unit) { appUpdate = decision?.takeUnless { it.isOptional && dismissedUpdateVersion == it.version } + // Only the gateway may withdraw a refusal, and this is it answering. A + // verdict that does not require an update is the evidence that whatever + // retired this build no longer applies — a failed check is not, which is + // why nothing outside this branch clears the flag. + if (decision?.isMandatory != true) { + reportedRequiredUpdate = null + RequiredUpdateSignal.clear() + ServiceLocator.settings.clearRequiredUpdate() + } } if (firstCheck) { initialUpdateCheckComplete = true firstCheck = false } - kotlinx.coroutines.delay(UPDATE_CHECK_INTERVAL_MS) + requiredAttempts = if (retired != null && appUpdate == null) { + requiredAttempts + 1 + } else { + 0 + } + // Whichever comes first: the next scheduled check, or something asking for one + // now. While this build is retired and no verdict has arrived, that schedule is + // seconds rather than an hour — the alternative is a television sitting on a + // screen it cannot leave, waiting on an answer nobody has asked for. + val wait = when { + requiredAttempts == 0 -> UPDATE_CHECK_INTERVAL_MS + requiredAttempts <= UPDATE_REQUIRED_FAST_ATTEMPTS -> UPDATE_REQUIRED_RETRY_MS + else -> UPDATE_REQUIRED_BACKOFF_MS + } + withTimeoutOrNull(wait) { updateWake.receive() } } } LaunchedEffect(repo) { @@ -404,6 +488,12 @@ private fun AppRoot(onCloseSettings: () -> Unit) { Box(Modifier.fillMaxSize().background(MembySurface)) { val loaded = settings + // The gateway has refused this build. Until it answers with a verdict — or with a + // verdict that no longer requires one — this is the only screen the television may + // show: its session is gone, and sign-in, profiles and the launcher are all things + // the server will refuse. + val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() } + ?: reportedRequiredUpdate // The three states below that mean "still opening" used to be three separate calls // to MembyLoadingScreen, and Compose identifies a composable by where it is called // from — so passing between them disposed the screen and built it again. That was @@ -419,6 +509,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) { introHolding -> loaded?.welcomeQuoteStyle.orEmpty() !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty() appUpdate != null -> null + // Retired, verdict not here yet. The retired-build screen takes the frame + // rather than the opening screen: it says what has happened and offers the one + // thing there is to do about it, where a loading screen with nothing behind it + // reads as a television that has stopped working. + retiredVersion != null -> null loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty() // Ordered exactly as the screens below are: adding a profile outranks waiting // on the onboarding answer, and swapping the two would put the loading screen @@ -449,6 +544,13 @@ private fun AppRoot(onCloseSettings: () -> Unit) { } }, ) + // Ahead of every remaining screen, and of the null-settings case: a television + // whose build has been retired must not reach sign-in, profiles or the + // launcher, whatever else is true of it. + retiredVersion != null -> RetiredBuildScreen( + requiredVersion = retiredVersion, + onRetry = { updateWake.trySend(Unit) }, + ) loaded == null -> Unit addingProfile -> SetupScreen( onCancel = { addingProfile = false }, @@ -1433,8 +1535,9 @@ private fun OnboardingTitleRow( previewArtwork: ImageBitmap? = null, ) { val repo = ServiceLocator.repository + val distinctItems = remember(items) { items.distinctItems() } LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { - itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + itemsIndexed(distinctItems, key = { _, item -> item.id }) { index, item -> val selected = ratings[item.id] == 5 FocusScaleContainer( onFocused = {}, @@ -1477,8 +1580,12 @@ private fun OnboardingPeopleRow( previewArtwork: ImageBitmap? = null, ) { val repo = ServiceLocator.repository + // Keyed by name because that is also what the selection map is keyed by — which means + // two people sharing one would be one selection *and* a duplicate LazyRow key, and the + // second of those is a crash on the first screen a new television ever draws. + val distinctPeople = remember(people) { people.distinctForKeys(RecommendationPerson::name) } LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { - itemsIndexed(people, key = { _, person -> person.name }) { index, person -> + itemsIndexed(distinctPeople, key = { _, person -> person.name }) { index, person -> val selected = selectedPeople[person.name] == true FocusScaleContainer( onFocused = {}, @@ -1743,6 +1850,7 @@ private fun HomeScreen( val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle() + val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle() // Only whether there is one, not its countdown — that is collected inside the banner // so a ticking second never reaches the launcher. This is read here purely to decide // which of the two top bars gets the strip. @@ -1760,6 +1868,14 @@ private fun HomeScreen( var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var genreBrowseItemType by remember { mutableStateOf(null) } var genreBrowseInitialCategoryId by remember { mutableStateOf(null) } + // The rail entry disappears the moment the operator turns the feature off; a television + // that happened to be standing on the page has to be moved off it too, or it is left on + // a destination nothing can navigate back to. + LaunchedEffect(tvCalendarEnabled) { + if (!tvCalendarEnabled && selectedDestination == BrowseDestination.CALENDAR) { + selectedDestination = BrowseDestination.HOME + } + } LaunchedEffect(genreBrowserEnabled) { if (!genreBrowserEnabled) { genreBrowseItemType = null @@ -1872,6 +1988,16 @@ private fun HomeScreen( homeViewModel.refreshAll() } } + // The job resolving a stream, so the overlay it raises can be called off. Without it, + // a Play press against a server that has stopped answering holds a full-screen loading + // overlay for the length of the HTTP timeout with no way to leave it. + var resolveJob by remember { mutableStateOf(null) } + val abandonLaunch: () -> Unit = { + resolveJob?.cancel() + resolveJob = null + resolvingItem = null + launchingItem = null + } val playItem: (BaseItem) -> Unit = playItem@{ item -> if (launchingItem != null || !item.membyPlayable) return@playItem homeViewModel.trackJourney( @@ -1887,25 +2013,53 @@ private fun HomeScreen( // the activity, the layout and the decoder, none of which needed the answer. A cold // start still resolves first — the pre-roll it opens with needs a stream to run // behind it, and whether there is one to show is part of the same answer. - val request = repo.playbackRequest(item) - val ready = repo.readyPlayableForLaunch(request) + // Everything up to the hand-over runs inside a catch that reopens the gate. The + // gate is otherwise cleared only by the player coming back, so anything that + // throws before one is started — a malformed cached item, an activity result + // registry that has already been torn down — would leave Play dead for the rest + // of the session with nothing on screen to say why. + val prepared = runCatching { + val request = repo.playbackRequest(item) + request to repo.readyPlayableForLaunch(request) + }.getOrElse { + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + return@playItem + } + val request = prepared.first + val ready = prepared.second if (ready == null && request.resumePositionMs > 0L) { - playbackLauncher.launch( - PlayerActivity.intent( - context = context, - request = request, - posterUrl = repo.primaryUrl(item, maxWidth = 500), - requestStartedAtMs = playbackRequestedAtMs, - ), - ) + val launched = runCatching { + playbackLauncher.launch( + PlayerActivity.intent( + context = context, + request = request, + posterUrl = repo.primaryUrl(item, maxWidth = 500), + requestStartedAtMs = playbackRequestedAtMs, + ), + ) + } + if (launched.isFailure) { + Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() + launchingItem = null + } return@playItem } // Only the route that waits on the server shows the launcher's own loading screen. // The route that hands over immediately would only be showing it behind the player. resolvingItem = item - scope.launch { + resolveJob = scope.launch { try { - runCatching { ready ?: repo.resolvePlayableForLaunch(item) } + runCatching { + val playable = ready ?: repo.resolvePlayableForLaunch(item) + // A resolution that came back without a stream is a failure with a + // success's shape. Handed on, PlayerActivity finds no URL and no + // request to resolve one from, closes itself in onCreate, and the + // viewer sees the screen flicker and nothing else — the one playback + // failure that arrives with no explanation at all. + check(playable.url.isNotBlank()) { "resolved playable has no stream" } + playable + } .onSuccess { playable -> playbackLauncher.launch( PlayerActivity.intent( @@ -1935,13 +2089,17 @@ private fun HomeScreen( ), ) } - .onFailure { + .onFailure { error -> + // A cancellation is the viewer having pressed Back out of the + // wait, which has already reopened the gate and said so on screen. + if (error is kotlinx.coroutines.CancellationException) throw error Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show() // Nothing was launched, so nothing will come back to reopen the gate. launchingItem = null } } finally { resolvingItem = null + resolveJob = null } } } @@ -2123,6 +2281,7 @@ private fun HomeScreen( }, alertCount = notificationState.notifications.size, activeUsername = settings.username.orEmpty(), + calendarEnabled = tvCalendarEnabled, ) androidx.compose.foundation.layout.BoxWithConstraints( modifier = Modifier @@ -2202,6 +2361,46 @@ private fun HomeScreen( return@BoxWithConstraints } + if (selectedDestination == BrowseDestination.CALENDAR) { + // Its own pane rather than a row list: a month grid needs the whole + // content area, and there is no shelf shape that answers "what is on + // in three weeks". The rail stays beside it as it does for Search. + CalendarScreen( + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + onItemFocused = homeViewModel::focusItem, + onItemSelected = { item -> + homeViewModel.trackJourney( + category = "content", action = "open", screen = "calendar", + feature = "tv_calendar", source = "calendar_day", + target = "details", itemId = item.id, itemType = item.type, + ) + // The same substitution the schedule row makes: a calendar card + // is an episode that has not aired, so what was asked for is the + // show, carrying the air time across because that is why it was + // pressed. + val seriesStub = scheduleSeriesStub(item) + if (seriesStub != null) { + detailsAiringNotice = airingNoticeFor(item) + homeViewModel.focusItem(seriesStub) + detailsItem = seriesStub + } else if (item.membyPlayable) { + detailsAiringNotice = null + homeViewModel.focusItem(item) + detailsItem = item + } + }, + onExit = { + selectedDestination = BrowseDestination.HOME + scope.launch { + kotlinx.coroutines.delay(16L) + runCatching { contentFocusRequester.requestFocus() } + } + }, + ) + return@BoxWithConstraints + } + genreBrowseItemType?.let { itemType -> GenreBrowseScreen( itemType = itemType, @@ -2273,11 +2472,39 @@ private fun HomeScreen( } var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } + // The latch above is what stops a held D-pad stacking one move on top of + // another, and until now the only thing that lifted it was the destination + // row reporting the request consumed. A request nothing consumes therefore + // took every later Up and Down press with it — the row list stopped moving + // where it stood and the only way out was the rail. Nothing may consume it + // when the destination row was not composed by the scroll, when that scroll + // was interrupted by another one, or when the row is replaced by an arriving + // refresh while the request is in flight. So the latch is bounded rather + // than trusted: whatever happened, one press can hold vertical navigation + // for ROW_FOCUS_MOVE_TIMEOUT_MS and no longer. + LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) { + if (!rowFocusMoving) return@LaunchedEffect + kotlinx.coroutines.delay(ROW_FOCUS_MOVE_TIMEOUT_MS) + pendingRowFocus = null + rowFocusMoving = false + } val firstPopulatedRowId = remember(rows) { rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id } + // Must agree exactly with the items placed above the rows in the LazyColumn + // below, because it is what turns a row index into a scroll target. The + // genre strip is only one of them while the gateway has that feature on — + // counting it regardless scrolled a row short of the destination on Movies + // and TV Shows, which lands the requested card outside the composed window + // and leaves the move with nothing to complete it. val leadingItemCount = - (if (selectedDestination == BrowseDestination.MOVIES || selectedDestination == BrowseDestination.SHOWS) 1 else 0) + + ( + if ( + genreBrowserEnabled && + (selectedDestination == BrowseDestination.MOVIES || + selectedDestination == BrowseDestination.SHOWS) + ) 1 else 0 + ) + (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + if ( selectedDestination == BrowseDestination.FAVORITES && @@ -2540,11 +2767,22 @@ private fun HomeScreen( ) rowFocusMoving = true scope.launch { - // Compose the destination row before its MediaRow tries - // to attach the requested card's focus node. - verticalState.animateScrollToItem( - leadingItemCount + destinationRowIndex, - ) + try { + // Compose the destination row before its MediaRow + // tries to attach the requested card's focus node. + verticalState.animateScrollToItem( + leadingItemCount + destinationRowIndex, + ) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + // Another scroll took the list over mid-animation, so + // this move is never going to post its request and + // nothing downstream would ever lift the latch. Ask + // for the card anyway — the row it is in is where the + // list has been left, and the worst case is the + // request expiring like any other. + pendingRowFocus = request + throw cancelled + } pendingRowFocus = request } true @@ -2941,26 +3179,33 @@ private fun HomeScreen( .onSuccess { notificationState = it } } }, + // Marked read locally first. This fires on *focus*, so on a slow + // connection walking down the list and back up would send the same row's + // request once per pass — clearing the flag immediately is what makes the + // row stop asking. onRead = { notification -> - scope.launch { - runCatching { repo.markNotificationRead(notification.id) }.onSuccess { - notificationState = notificationState.copy( - notifications = notificationState.notifications.map { - if (it.id == notification.id) it.copy(readAt = "now") else it - }, - ) - } - } + notificationState = notificationState.copy( + notifications = notificationState.notifications.map { + if (it.id == notification.id) it.copy(readAt = "now") else it + }, + ) + scope.launch { runCatching { repo.markNotificationRead(notification.id) } } }, + // Optimistic, for the reason "Dismiss all" beneath it already is: this + // page is judged entirely on emptying itself, and a row that stayed put + // while its request was in flight is a row pressed again — which on this + // page also re-aims where focus lands afterwards. A failure puts the row + // back where it was rather than quietly losing somebody's alert. onDismiss = { notification -> + val previous = notificationState + notificationState = notificationState.copy( + notifications = notificationState.notifications.filterNot { + it.id == notification.id + }, + ) scope.launch { - runCatching { repo.dismissNotification(notification.id) }.onSuccess { - notificationState = notificationState.copy( - notifications = notificationState.notifications.filterNot { - it.id == notification.id - }, - ) - } + runCatching { repo.dismissNotification(notification.id) } + .onFailure { notificationState = previous } } }, // The gateway has no bulk route, so this is the same call per alert. The @@ -3078,6 +3323,12 @@ private fun HomeScreen( ) } resolvingItem?.let { item -> + // Back gets out of the wait. This overlay covers the whole screen while the + // gateway is asked for a stream, and against a server that has stopped + // answering that is the length of an HTTP timeout — long enough that a viewer + // reaches for the remote, and until now long enough that nothing answered + // them. Cancelling is safe: nothing has been launched and nothing reported. + BackHandler(onBack = abandonLaunch) PlaybackLaunchOverlay( item = item, modifier = Modifier.fillMaxSize().zIndex(9f), @@ -3688,6 +3939,7 @@ internal fun homeRowsFor( // Search draws its own pane; the rail destinations that open an overlay have no // rows of their own either. BrowseDestination.SEARCH -> emptyList() + BrowseDestination.CALENDAR -> emptyList() BrowseDestination.PROFILES -> emptyList() BrowseDestination.SETTINGS -> emptyList() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index e100ce4..2ec1150 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -324,6 +324,74 @@ fun UpdateScreen( } } +/** + * Shown when the gateway has retired this build but has not yet handed back the verdict + * that carries the download. + * + * It is not a loading screen and not an error. The session is already gone, so the screens + * underneath it — sign-in, the profile list, the launcher — are all things the server would + * refuse, and a viewer looking at one of those has been told nothing about why nothing + * works. The wording therefore states the situation and the only action there is, and Back + * is swallowed for the same reason a mandatory update swallows it: there is nowhere else to + * go. + * + * It is expected to be brief — the check loop asks again within seconds — so there is + * deliberately no spinner and no failure count: a screen that reported every unsuccessful + * attempt would be a stream of bad news about something the viewer cannot influence. + */ +@Composable +fun RetiredBuildScreen( + requiredVersion: String, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler(enabled = true) { } + + val retryFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { retryFocus.requestFocus() } } + + Box( + modifier = modifier.fillMaxSize().background(MembySurface), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.padding(horizontal = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "This TV needs a newer Memby", + color = UpdateTitle, + fontSize = 32.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "Memby ${requiredVersion.trim()} is required before this TV can sign in " + + "again. Fetching the update now — this only takes a moment.", + color = UpdateBody, + fontSize = 17.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 640.dp), + ) + Spacer(Modifier.height(30.dp)) + UpdateButton( + label = "Try again", + primary = true, + enabled = true, + onClick = onRetry, + modifier = Modifier.focusRequester(retryFocus), + ) + Spacer(Modifier.height(22.dp)) + Text( + "Stuck? Ask whoever set up Memby for you.", + color = UpdateFaint.copy(alpha = 0.75f), + fontSize = 13.sp, + ) + } + } +} + @Composable internal fun UpdateButton( label: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index 4fbd1c3..d8a45e5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -111,16 +111,17 @@ fun MyAlertsPage( } LaunchedEffect(notificationIds) { val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect - if (notifications.isEmpty()) { - pendingFocusIndex = null - return@LaunchedEffect - } + // Spent on this list change however it turns out. Left set, a request that could + // not be honoured — an empty list, a dismissal the server refused and put back — + // would be honoured against the *next* change instead, which is commonly an alert + // arriving on its own: focus would jump for a press made minutes ago. + pendingFocusIndex = null + if (notifications.isEmpty()) return@LaunchedEffect val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size) ?: return@LaunchedEffect - listState.scrollToItem(targetIndex) + runCatching { listState.scrollToItem(targetIndex) } delay(16) - runCatching { rowFocusRequesters[targetIndex].requestFocus() } - pendingFocusIndex = null + runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() } } Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt new file mode 100644 index 0000000..54eaab9 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarAgenda.kt @@ -0,0 +1,682 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + +package com.ponzischeme89.memby.ui.calendar + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Event +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.FocusScaleContainer +import com.ponzischeme89.memby.ui.distinctItems +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembyCardCorner +import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembyMutedText +import com.ponzischeme89.memby.ui.theme.MembyPanelCorner +import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised + +/** + * A television guide rather than a shrunk wall calendar. + * + * One week fills the page. The day rail answers when something is on; the programme pane + * answers what it is with fanart, title logos and episode milestones. Month travel remains + * server-authored, while week travel only pages through the seven-cell groups the server's + * month shape already supplied. + */ +@Composable +internal fun CalendarContent( + state: CalendarUiState, + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + onShowMonth: (String) -> Unit, + onSelectDate: (String) -> Unit, + onRetry: () -> Unit, + onItemFocused: (BaseItem) -> Unit, + onItemSelected: (BaseItem) -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, + posterUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.primaryUrl(it, 360) }, + backdropUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.backdropUrl(it, 720) }, + logoUrlFor: (BaseItem) -> String? = { ServiceLocator.repository.logoUrl(it, 420) }, +) { + val weeks = remember(state.calendar) { calendarAgendaWeeks(state.calendar) } + val weekIndex = remember(weeks, state.selectedDate) { + calendarAgendaWeekIndex(weeks, state.selectedDate) + } + val week = weeks.getOrNull(weekIndex) + val selectedDay = week?.cells?.firstOrNull { it.date == state.selectedDate } + ?: week?.cells?.firstOrNull { !it.isPad } + val selectedItems = remember(selectedDay) { selectedDay?.items.orEmpty().distinctItems() } + + val monthFocusRequester = remember { FocusRequester() } + val weekFocusRequester = remember { FocusRequester() } + val selectedDayFocusRequester = remember { FocusRequester() } + val programmeFocusRequester = remember { FocusRequester() } + var programmeFocused by remember { mutableStateOf(false) } + var pageHasFocus by remember { mutableStateOf(true) } + + LaunchedEffect(state.calendar.month, state.isLoading) { + if (state.isLoading || !pageHasFocus) return@LaunchedEffect + kotlinx.coroutines.delay(32L) + runCatching { selectedDayFocusRequester.requestFocus() } + } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(32L) + runCatching { contentFocusRequester.requestFocus() } + } + BackHandler(enabled = programmeFocused) { + programmeFocused = false + runCatching { selectedDayFocusRequester.requestFocus() } + } + BackHandler(enabled = !programmeFocused, onBack = onExit) + + BoxWithConstraints( + modifier + .fillMaxSize() + .onFocusChanged { pageHasFocus = it.hasFocus } + .background( + Brush.linearGradient( + listOf(Color(0xFF070B0D), MembySurface, Color(0xFF07110D)), + ), + ), + ) { + val dayRailWidth = if (maxWidth >= 1100.dp) 270.dp else 224.dp + Column( + Modifier.fillMaxSize().padding(start = 38.dp, end = 30.dp, top = 22.dp, bottom = 22.dp), + ) { + AgendaHeader( + monthLabel = state.calendar.label.ifEmpty { "TV calendar" }, + episodeCount = calendarEpisodeCount(state.calendar), + previousMonth = state.calendar.previous, + nextMonth = state.calendar.next, + monthFocusRequester = monthFocusRequester, + weekFocusRequester = weekFocusRequester, + onShowMonth = onShowMonth, + ) + Spacer(Modifier.height(12.dp)) + + when { + state.isLoading && weeks.isEmpty() -> AgendaNotice("Loading the schedule…") + state.errorMessage != null && weeks.isEmpty() -> AgendaNotice( + state.errorMessage.orEmpty(), "Try again", onRetry, + ) + !state.calendar.available -> AgendaNotice( + "The TV calendar is not available. It needs the Memby gateway with Sonarr configured.", + ) + week == null -> AgendaNotice("Nothing scheduled.") + else -> { + WeekSwitcher( + week = week, + weekIndex = weekIndex, + weekCount = weeks.size, + focusRequester = weekFocusRequester, + monthFocusRequester = monthFocusRequester, + onPrevious = { + weeks.getOrNull(weekIndex - 1)?.let { + onSelectDate(calendarAgendaWeekDate(it)) + } + }, + onNext = { + weeks.getOrNull(weekIndex + 1)?.let { + onSelectDate(calendarAgendaWeekDate(it)) + } + }, + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxSize()) { + WeekDayRail( + week = week, + selectedDate = selectedDay?.date.orEmpty(), + navigationFocusRequester = navigationFocusRequester, + weekFocusRequester = weekFocusRequester, + contentFocusRequester = contentFocusRequester, + selectedDayFocusRequester = selectedDayFocusRequester, + programmeFocusRequester = programmeFocusRequester, + onSelectDate = { + programmeFocused = false + onSelectDate(it) + }, + modifier = Modifier.width(dayRailWidth).fillMaxHeight(), + ) + Spacer(Modifier.width(20.dp)) + ProgrammePane( + heading = calendarDayHeading(state.calendar, selectedDay?.date.orEmpty()), + items = selectedItems, + programmeFocusRequester = programmeFocusRequester, + selectedDayFocusRequester = selectedDayFocusRequester, + artworkUrlFor = { backdropUrlFor(it) ?: posterUrlFor(it) }, + logoUrlFor = logoUrlFor, + onFocused = { + programmeFocused = true + onItemFocused(it) + }, + onSelected = onItemSelected, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + } + } + } +} + +@Composable +private fun AgendaHeader( + monthLabel: String, + episodeCount: Int, + previousMonth: String, + nextMonth: String, + monthFocusRequester: FocusRequester, + weekFocusRequester: FocusRequester, + onShowMonth: (String) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(38.dp).background(MembyAccent.copy(alpha = 0.14f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.CalendarMonth, null, tint = MembyAccent, modifier = Modifier.size(21.dp)) + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text("TV GUIDE", color = MembyAccent, fontSize = 11.sp, fontWeight = FontWeight.Bold) + Text(monthLabel, color = Color.White, fontSize = 26.sp, fontWeight = FontWeight.SemiBold) + } + Text( + calendarEpisodeCountLabel(episodeCount), + color = MembyMutedText, + fontSize = 13.sp, + modifier = Modifier.padding(end = 14.dp), + ) + AgendaIconButton( + icon = Icons.AutoMirrored.Filled.ArrowBack, + description = "Previous month", + enabled = previousMonth.isNotEmpty(), + onClick = { onShowMonth(previousMonth) }, + modifier = Modifier.focusProperties { down = weekFocusRequester }, + ) + Spacer(Modifier.width(8.dp)) + AgendaIconButton( + icon = Icons.AutoMirrored.Filled.ArrowForward, + description = "Next month", + enabled = nextMonth.isNotEmpty(), + onClick = { onShowMonth(nextMonth) }, + modifier = Modifier + .focusRequester(monthFocusRequester) + .focusProperties { down = weekFocusRequester }, + ) + } +} + +@Composable +private fun WeekSwitcher( + week: CalendarAgendaWeek, + weekIndex: Int, + weekCount: Int, + focusRequester: FocusRequester, + monthFocusRequester: FocusRequester, + onPrevious: () -> Unit, + onNext: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + AgendaIconButton( + Icons.AutoMirrored.Filled.ArrowBack, + "Previous week", + weekIndex > 0, + onPrevious, + ) + Spacer(Modifier.width(10.dp)) + FocusScaleContainer( + onFocused = {}, + onClick = {}, + contentDescription = "Week ${week.label}", + modifier = Modifier + .focusRequester(focusRequester) + .focusProperties { up = monthFocusRequester } + .background(MembySurfaceRaised.copy(alpha = 0.62f), RoundedCornerShape(MembyChipCorner)), + ) { focused -> + Row( + Modifier + .background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(MembyChipCorner)) + .padding(horizontal = 18.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Event, null, tint = if (focused) MembySurface else MembyAccent, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text( + week.label, + color = if (focused) MembySurface else Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.width(10.dp)) + Text( + calendarEpisodeCountLabel(week.episodeCount), + color = if (focused) MembySurface.copy(alpha = 0.65f) else MembyQuietText, + fontSize = 12.sp, + ) + } + } + Spacer(Modifier.width(10.dp)) + AgendaIconButton( + Icons.AutoMirrored.Filled.ArrowForward, + "Next week", + weekIndex < weekCount - 1, + onNext, + ) + Spacer(Modifier.weight(1f)) + Text("WEEK ${weekIndex + 1} OF $weekCount", color = MembyQuietText, fontSize = 11.sp) + } +} + +@Composable +private fun WeekDayRail( + week: CalendarAgendaWeek, + selectedDate: String, + navigationFocusRequester: FocusRequester, + weekFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + selectedDayFocusRequester: FocusRequester, + programmeFocusRequester: FocusRequester, + onSelectDate: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .background(MembySurfaceRaised.copy(alpha = 0.30f), RoundedCornerShape(MembyPanelCorner)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + week.cells.forEachIndexed { index, cell -> + if (cell.isPad) { + Spacer(Modifier.weight(1f)) + } else { + val selected = cell.date == selectedDate + FocusScaleContainer( + onFocused = { onSelectDate(cell.date) }, + onClick = { + if (cell.items.isNotEmpty()) runCatching { programmeFocusRequester.requestFocus() } + }, + contentDescription = "${CALENDAR_WEEKDAYS[index]} ${cell.day}, ${calendarEpisodeCountLabel(cell.items.size)}", + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .then( + if (selected) { + Modifier + .focusRequester(contentFocusRequester) + .focusRequester(selectedDayFocusRequester) + } else Modifier, + ) + .focusProperties { + left = navigationFocusRequester + if (index == 0) up = weekFocusRequester + if (cell.items.isNotEmpty()) right = programmeFocusRequester + }, + ) { focused -> + Row( + Modifier + .fillMaxSize() + .background( + when { + focused -> Color.White + selected -> MembyAccent.copy(alpha = 0.16f) + else -> Color.Transparent + }, + RoundedCornerShape(MembyCardCorner), + ) + .then( + if (selected && !focused) { + Modifier.border( + 1.dp, + MembyAccent.copy(alpha = 0.55f), + RoundedCornerShape(MembyCardCorner), + ) + } else Modifier, + ) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.width(54.dp)) { + Text( + CALENDAR_WEEKDAYS[index].uppercase(), + color = if (focused) MembySurface.copy(alpha = 0.65f) else if (cell.today) MembyAccent else MembyQuietText, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + ) + Text( + cell.day.toString(), + color = if (focused) MembySurface else Color.White, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + Column(Modifier.weight(1f)) { + Text( + when { + cell.today -> "TODAY" + cell.items.isEmpty() -> "No programmes" + cell.items.size == 1 -> "1 programme" + else -> "${cell.items.size} programmes" + }, + color = if (focused) MembySurface else if (cell.items.isEmpty()) MembyQuietText else Color.White, + fontSize = 12.sp, + fontWeight = if (cell.items.isNotEmpty()) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + ) + cell.items.firstOrNull()?.name?.let { + Text( + it, + color = if (focused) MembySurface.copy(alpha = 0.62f) else MembyMutedText, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ProgrammePane( + heading: String, + items: List, + programmeFocusRequester: FocusRequester, + selectedDayFocusRequester: FocusRequester, + artworkUrlFor: (BaseItem) -> String?, + logoUrlFor: (BaseItem) -> String?, + onFocused: (BaseItem) -> Unit, + onSelected: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Row(verticalAlignment = Alignment.Bottom) { + Column(Modifier.weight(1f)) { + Text("AIRING", color = MembyAccent, fontSize = 10.sp, fontWeight = FontWeight.Bold) + Text(heading.ifEmpty { "Select a day" }, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + } + if (items.isNotEmpty()) { + Text(calendarEpisodeCountLabel(items.size), color = MembyQuietText, fontSize = 12.sp) + } + } + Spacer(Modifier.height(9.dp)) + if (items.isEmpty()) { + Box( + Modifier + .fillMaxSize() + .background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner)) + .border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(MembyPanelCorner)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Default.Event, null, tint = MembyQuietText, modifier = Modifier.size(30.dp)) + Spacer(Modifier.height(8.dp)) + Text("Nothing airing", color = MembyMutedText, fontSize = 15.sp) + Text("Choose another day or week", color = MembyQuietText, fontSize = 12.sp) + } + } + return@Column + } + val listState = rememberLazyListState() + LazyColumn( + state = listState, + contentPadding = PaddingValues(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + ProgrammeCard( + item = item, + artworkUrl = artworkUrlFor(item), + logoUrl = logoUrlFor(item), + onFocused = { onFocused(item) }, + onClick = { onSelected(item) }, + modifier = Modifier + .then(if (index == 0) Modifier.focusRequester(programmeFocusRequester) else Modifier) + .focusProperties { left = selectedDayFocusRequester }, + ) + } + } + } +} + +@Composable +private fun ProgrammeCard( + item: BaseItem, + artworkUrl: String?, + logoUrl: String?, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(MembyCardCorner) + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + contentDescription = "${item.name} ${item.membyEpisodeCode.orEmpty()}", + modifier = modifier.fillMaxWidth(), + ) { focused -> + Row( + Modifier + .fillMaxWidth() + .height(118.dp) + .clip(shape) + .background(MembySurfaceRaised.copy(alpha = 0.72f)) + .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.07f), shape), + ) { + Box(Modifier.width(214.dp).fillMaxHeight().background(MembySurface)) { + Box( + Modifier.fillMaxSize().background( + Brush.linearGradient( + listOf(MembyAccent.copy(alpha = 0.30f), Color(0xFF102523), MembySurfaceRaised), + ), + ), + contentAlignment = Alignment.Center, + ) { + Text( + item.name.trim().firstOrNull()?.uppercase().orEmpty(), + color = Color.White.copy(alpha = 0.16f), + fontSize = 52.sp, + fontWeight = FontWeight.Black, + ) + } + if (artworkUrl != null) { + AsyncImage( + model = artworkUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + listOf(Color.Transparent, MembySurfaceRaised.copy(alpha = 0.12f), MembySurfaceRaised), + ), + ), + ) + item.membyEpisodeEvent?.takeIf(String::isNotBlank)?.let { event -> + EventBadge(event, Modifier.align(Alignment.TopStart).padding(9.dp)) + } + } + Column( + Modifier.weight(1f).fillMaxHeight().padding(start = 14.dp, end = 14.dp, top = 11.dp, bottom = 10.dp), + verticalArrangement = Arrangement.Center, + ) { + if (logoUrl != null) { + AsyncImage( + model = logoUrl, + contentDescription = item.name, + contentScale = ContentScale.Fit, + modifier = Modifier.widthIn(max = 190.dp).height(34.dp), + ) + } else { + Text( + item.name, + color = Color.White, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(5.dp)) + Text( + listOfNotNull( + item.membyEpisodeCode?.takeIf(String::isNotBlank), + item.membyEpisodeTitle?.takeIf(String::isNotBlank), + ).joinToString(" · "), + color = Color.White.copy(alpha = 0.82f), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + item.membyAirLabel.orEmpty(), + color = MembyAccent, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + item.membyAvailabilityText?.takeIf(String::isNotBlank)?.let { + Text(" · $it", color = MembyQuietText, fontSize = 11.sp, maxLines = 1) + } + } + } + } + } +} + +@Composable +private fun EventBadge(event: String, modifier: Modifier = Modifier) { + val colour = when { + event.equals("Series finale", true) -> Color(0xFFE66D65) + event.contains("finale", true) -> Color(0xFFF1A34F) + else -> MembyAccent + } + Text( + event.uppercase(), + color = Color.White, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + modifier = modifier + .background(colour.copy(alpha = 0.92f), RoundedCornerShape(MembyChipCorner)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) +} + +@Composable +private fun AgendaIconButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (!enabled) { + Spacer(Modifier.size(36.dp)) + return + } + FocusScaleContainer( + onFocused = {}, + onClick = onClick, + contentDescription = description, + modifier = modifier.size(36.dp), + ) { focused -> + Box( + Modifier.fillMaxSize().background(if (focused) Color.White else MembySurfaceRaised, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon(icon, description, tint = if (focused) MembySurface else Color.White, modifier = Modifier.size(18.dp)) + } + } +} + +@Composable +private fun AgendaNotice(message: String, action: String? = null, onAction: () -> Unit = {}) { + Column( + Modifier + .fillMaxSize() + .background(MembySurfaceRaised.copy(alpha = 0.22f), RoundedCornerShape(MembyPanelCorner)) + .padding(28.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Center, + ) { + Text(message, color = MembyMutedText, fontSize = 16.sp) + if (action != null) { + Spacer(Modifier.height(14.dp)) + FocusScaleContainer( + onFocused = {}, + onClick = onAction, + contentDescription = action, + modifier = Modifier.background(MembySurfaceRaised, RoundedCornerShape(MembyChipCorner)), + ) { focused -> + Text( + action, + color = if (focused) MembySurface else Color.White, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(MembyChipCorner)) + .padding(horizontal = 18.dp, vertical = 10.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarGrid.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarGrid.kt new file mode 100644 index 0000000..2d365c1 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarGrid.kt @@ -0,0 +1,164 @@ +package com.ponzischeme89.memby.ui.calendar + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayCalendar + +/** + * The month grid, derived from what the gateway sent and nothing else. + * + * The television does no calendar arithmetic of its own: the leading blanks and the number + * of cells come from `firstWeekday`/`dayCount`, and which cell wears the ring comes from + * `today`. A set working those out for itself would be a second calendar, kept in the + * viewer's zone rather than the household's, free to disagree with the days the episodes + * were grouped into — which on the wrong side of midnight it would. + * + * Everything here is pure so the awkward months (one starting on a Sunday, a February, a + * month with nothing in it at all) are testable with no server and no Compose. + */ + +/** One cell. A pad — the blanks before the 1st and after the last — has [day] of zero. */ +data class CalendarCell( + val date: String = "", + val day: Int = 0, + val items: List = emptyList(), + val today: Boolean = false, +) { + val isPad: Boolean get() = day <= 0 + + /** Pads are skipped by the D-pad: there is nothing there to be told about. */ + val isFocusable: Boolean get() = !isPad +} + +const val CALENDAR_COLUMNS = 7 + +/** Sunday first, matching the `firstWeekday` the gateway sends. */ +val CALENDAR_WEEKDAYS = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + +data class CalendarAgendaWeek( + val cells: List, + val label: String, + val episodeCount: Int, +) + +/** The month payload as television-sized weekly pages. No device date arithmetic. */ +fun calendarAgendaWeeks(calendar: GatewayCalendar): List { + val month = calendar.label.substringBeforeLast(' ').trim().ifEmpty { "Month" } + return calendarWeeks(calendar).map { cells -> + val numbered = cells.filterNot(CalendarCell::isPad) + val first = numbered.firstOrNull()?.day ?: 0 + val last = numbered.lastOrNull()?.day ?: first + CalendarAgendaWeek( + cells = cells, + label = if (first == last) "$first $month" else "$first–$last $month", + episodeCount = numbered.sumOf { it.items.size }, + ) + } +} + +fun calendarAgendaWeekIndex(weeks: List, date: String): Int = + weeks.indexOfFirst { week -> week.cells.any { it.date == date } }.coerceAtLeast(0) + +/** Today, then the first airing, then the first real day in that week. */ +fun calendarAgendaWeekDate(week: CalendarAgendaWeek): String = + week.cells.firstOrNull { it.today }?.date + ?: week.cells.firstOrNull { !it.isPad && it.items.isNotEmpty() }?.date + ?: week.cells.firstOrNull { !it.isPad }?.date + ?: "" + +/** + * Lays the month out as whole weeks. The trailing pad is deliberate: a ragged last row + * would leave the grid's cells different widths from one month to the next, and the eye + * reads a calendar by its columns. + */ +fun calendarWeeks(calendar: GatewayCalendar): List> { + val dayCount = calendar.dayCount.coerceIn(0, 31) + if (dayCount == 0) return emptyList() + val leading = calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1) + val byDay = calendar.days.associateBy { it.day } + + val cells = ArrayList(leading + dayCount) + repeat(leading) { cells += CalendarCell() } + for (day in 1..dayCount) { + val date = calendarDate(calendar.month, day) + cells += CalendarCell( + date = date, + day = day, + items = byDay[day]?.items.orEmpty(), + today = calendar.today.isNotEmpty() && calendar.today == date, + ) + } + while (cells.size % CALENDAR_COLUMNS != 0) cells += CalendarCell() + return cells.chunked(CALENDAR_COLUMNS) +} + +/** + * Which cell the page opens on. + * + * Today wins whenever the household is in this month — it is the day somebody opening a TV + * guide is asking about, whether or not anything airs on it. Travelling to another month + * lands on its first airing instead, because arriving on the 1st of a month whose news is + * on the 14th means scrolling past a fortnight of nothing to find out there was any. + */ +fun calendarInitialDate(calendar: GatewayCalendar): String { + if (calendar.today.isNotEmpty()) return calendar.today + calendar.days.firstOrNull { it.items.isNotEmpty() }?.let { return calendarDate(calendar.month, it.day) } + if (calendar.dayCount <= 0) return "" + return calendarDate(calendar.month, 1) +} + +/** How many episodes the month holds, for the line under the header. */ +fun calendarEpisodeCount(calendar: GatewayCalendar): Int = + calendar.days.sumOf { it.items.size } + +fun calendarEpisodeCountLabel(count: Int): String = when (count) { + 0 -> "Nothing scheduled" + 1 -> "1 episode" + else -> "$count episodes" +} + +/** The heading over the day panel: "Tuesday 12 August", from the wire's own date string. */ +fun calendarDayHeading(calendar: GatewayCalendar, date: String): String { + val day = calendarDayOf(date) ?: return "" + val weekday = calendarWeekdayName(calendar, day) ?: return "" + val month = calendar.label.substringBefore(' ').trim() + return if (month.isEmpty()) "$weekday $day" else "$weekday $day $month" +} + +/** + * The weekday a numbered day falls on, counted from the grid's own offset rather than from + * a date library — the same reason the grid is built from `firstWeekday` at all. + */ +private fun calendarWeekdayName(calendar: GatewayCalendar, day: Int): String? { + if (day < 1 || day > calendar.dayCount.coerceIn(0, 31)) return null + val leading = calendar.firstWeekday.coerceIn(0, CALENDAR_COLUMNS - 1) + val fullNames = listOf( + "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", + ) + return fullNames[(leading + day - 1) % CALENDAR_COLUMNS] +} + +/** + * "2026-08" + 12 → "2026-08-12". An empty or malformed month yields no date at all. + * + * The shape is checked rather than only the length, because everything downstream compares + * these strings for equality: a month the gateway sent as something unreadable would + * otherwise produce thirty-one plausible-looking dates that agree with nothing, instead of + * thirty-one blanks that visibly select nothing. + */ +internal fun calendarDate(month: String, day: Int): String { + if (day <= 0 || day > 31 || !isCalendarMonth(month)) return "" + return month + "-" + if (day < 10) "0$day" else "$day" +} + +/** `YYYY-MM`, and nothing else. */ +internal fun isCalendarMonth(month: String): Boolean = + month.length == 7 && + month[4] == '-' && + (0..3).all { month[it].isDigit() } && + month[5].isDigit() && + month[6].isDigit() + +internal fun calendarDayOf(date: String): Int? { + if (date.length != 10) return null + return date.substring(8).toIntOrNull() +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarScreen.kt new file mode 100644 index 0000000..f21242a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarScreen.kt @@ -0,0 +1,40 @@ +package com.ponzischeme89.memby.ui.calendar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem + +/** Connects the weekly television guide to its month-caching view model. */ +@Composable +fun CalendarScreen( + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + onItemFocused: (BaseItem) -> Unit, + onItemSelected: (BaseItem) -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + val calendarViewModel: CalendarViewModel = viewModel( + key = "tv-calendar", + factory = remember { CalendarViewModelFactory(ServiceLocator.repository) }, + ) + val state by calendarViewModel.state.collectAsStateWithLifecycle() + CalendarContent( + state = state, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + onShowMonth = calendarViewModel::showMonth, + onSelectDate = calendarViewModel::selectDate, + onRetry = calendarViewModel::retry, + onItemFocused = onItemFocused, + onItemSelected = onItemSelected, + onExit = onExit, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarViewModel.kt new file mode 100644 index 0000000..e2f3f5b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/calendar/CalendarViewModel.kt @@ -0,0 +1,181 @@ +package com.ponzischeme89.memby.ui.calendar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.localEpochDay +import com.ponzischeme89.memby.data.model.GatewayCalendar +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class CalendarUiState( + val calendar: GatewayCalendar = GatewayCalendar(), + val selectedDate: String = "", + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + val weeks: List> get() = calendarWeeks(calendar) + + /** + * The focused day's episodes. + * + * A blank selection matches nothing rather than matching the first day that also + * failed to produce a date: [calendarDate] answers with an empty string for a month it + * cannot read, and without this guard a malformed month would list one arbitrary day's + * episodes under every cell in the grid. + */ + val selectedItems get() = if (selectedDate.isEmpty()) { + emptyList() + } else { + calendar.days.firstOrNull { + calendarDate(calendar.month, it.day) == selectedDate + }?.items.orEmpty() + } +} + +/** + * One month at a time, with the months already visited kept in memory. + * + * Walking back and forth across a season boundary is the ordinary way this page is used — + * "when does it come back" is answered by stepping forward and then checking what that did + * to next week — and re-fetching a month somebody looked at ten seconds ago costs a Sonarr + * round trip in front of a viewer holding the D-pad. + */ +class CalendarViewModel( + private val repository: EmbyRepository, + private val nowMillis: () -> Long = System::currentTimeMillis, +) : ViewModel() { + private val _state = MutableStateFlow(CalendarUiState()) + val state: StateFlow = _state.asStateFlow() + private val months = LinkedHashMap() + + /** + * The device's local day the cache was filled on. + * + * The television is not allowed to *lay out* a calendar from its own clock — that is + * the gateway's answer, and the whole reason the wire carries `firstWeekday`, + * `dayCount` and `today`. Noticing that midnight has passed is a different question: + * it decides only whether to ask again, and being wrong about it by an hour costs one + * request. Without it a set left on this page overnight keeps drawing the ring on + * yesterday, and every month it had visited stays wrong until the app is restarted. + */ + private var cachedOnDay: Long = localDay() + private var loadJob: Job? = null + + /** What was most recently asked for, so a slower earlier answer cannot land after it. */ + private var requestedMonth: String? = null + + init { + load("") + } + + /** Moves to a neighbouring month. A blank key is the header's arrow at the end of the range. */ + fun showMonth(month: String) { + if (month.isBlank()) return + // Compared against what was *asked for* as well as what is on screen: while a month + // is in flight the two differ, and without this a viewer holding the D-pad on an + // arrow re-requests the month already coming. + if (month == requestedMonth) return + if (requestedMonth == null && month == _state.value.calendar.month) return + load(month) + } + + fun retry() { + load(requestedMonth ?: _state.value.calendar.month) + } + + /** Focus is selection here: the panel beside the grid describes whatever cell holds it. */ + fun selectDate(date: String) { + if (date == _state.value.selectedDate) return + _state.update { it.copy(selectedDate = date) } + } + + private fun load(month: String) { + expireCacheAtMidnight() + loadJob?.cancel() + months[month]?.let { cached -> + requestedMonth = null + _state.value = CalendarUiState( + calendar = cached, + selectedDate = calendarInitialDate(cached), + isLoading = false, + ) + return + } + requestedMonth = month + _state.update { it.copy(isLoading = true, errorMessage = null) } + loadJob = viewModelScope.launch { + runCatching { repository.getCalendarMonth(month) } + .onSuccess { calendar -> + // Cancelling a job is not the same as stopping it: a coroutine already + // past its last suspension point runs this block to the end, and this + // block has none. Holding a D-pad on a month arrow is exactly how two + // requests come to be in flight, so the answer to a question nobody is + // asking any more is dropped rather than drawn over the newer month. + if (requestedMonth != month) return@onSuccess + requestedMonth = null + // Keyed by what was asked for as well as by what came back, so the + // opening request — which asks for no month in particular — is not + // fetched again the moment somebody steps back to it. + cache(month, calendar) + cache(calendar.month, calendar) + _state.value = CalendarUiState( + calendar = calendar, + selectedDate = calendarInitialDate(calendar), + isLoading = false, + ) + } + .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + if (requestedMonth != month) return@onFailure + requestedMonth = null + _state.update { + it.copy(isLoading = false, errorMessage = friendlyEmbyError(error)) + } + } + } + } + + private fun cache(key: String, calendar: GatewayCalendar) { + months[key] = calendar + // The gateway will only travel [calendarMonthRange] months either way, so this is + // a ceiling nothing reaches by ordinary use. It is here because "held in memory + // for the life of the page" and "grows without limit while somebody holds the + // D-pad" are the same sentence otherwise, and a month is a list of episodes with + // artwork references, not a small object. + while (months.size > MAX_CACHED_MONTHS) { + months.remove(months.keys.first()) + } + } + + private fun expireCacheAtMidnight() { + val today = localDay() + if (today == cachedOnDay) return + cachedOnDay = today + months.clear() + } + + /** Whole local days since the epoch. Only ever compared with itself. */ + private fun localDay(): Long { + val millis = nowMillis() + return localEpochDay(millis, java.util.TimeZone.getDefault().getOffset(millis)) + } + + private companion object { + const val MAX_CACHED_MONTHS = 8 + } +} + +class CalendarViewModelFactory( + private val repository: EmbyRepository, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + CalendarViewModel(repository) as T +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt index 367baaf..6aca26e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/detail/DetailFacts.kt @@ -240,7 +240,7 @@ internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String } } -private fun channelLabel(channels: Int): String = when (channels) { +internal fun channelLabel(channels: Int): String = when (channels) { 1 -> "Mono" 2 -> "Stereo" 6 -> "5.1" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 075aa9d..04419a5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -19,6 +19,17 @@ data class GenreBrowseUiState( val categories: List = emptyList(), val selectedCategoryId: String? = null, val items: List = emptyList(), + /** + * How far into this category the shelf has read, counted the way the backend counts + * it rather than by the length of [items]. + * + * The two differ the moment a page repeats a card the shelf already holds, which the + * append below drops — the grid is keyed by item id, so a repeat is a crash and not a + * doubled poster. Deriving the next offset from the list length would then ask for the + * page just read a second time, and the shelf would stop growing while quietly + * re-requesting the same page every time somebody scrolled to the end of it. + */ + val readOffset: Int = 0, val isLoading: Boolean = false, val isLoadingMore: Boolean = false, val canLoadMore: Boolean = false, @@ -46,6 +57,7 @@ class GenreBrowseViewModel( it.copy( selectedCategoryId = selected.id, items = cached?.items.orEmpty(), + readOffset = cached?.readOffset ?: 0, isLoading = cached == null, isLoadingMore = false, canLoadMore = cached?.canLoadMore ?: false, @@ -60,13 +72,13 @@ class GenreBrowseViewModel( val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return _state.update { it.copy(isLoadingMore = true) } - pageJob = viewModelScope.launch { loadPage(category, current.items.size) } + pageJob = viewModelScope.launch { loadPage(category, current.readOffset) } } fun retry() { val current = state.value val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return - val offset = current.items.size + val offset = current.readOffset _state.update { it.copy( isLoading = offset == 0, @@ -136,19 +148,21 @@ class GenreBrowseViewModel( } }.onSuccess { page -> _state.update { current -> - if (current.selectedCategoryId != category.id || current.items.size != page.offset) { + if (current.selectedCategoryId != category.id || current.readOffset != page.offset) { return@update current } + val read = page.offset + page.items.size val items = (current.items + page.items).distinctBy(BaseItem::id) val canLoadMore = hasMoreGenreItems( - loaded = items.size, + loaded = read, total = page.total, lastPageSize = page.items.size, pageSize = GENRE_PAGE_SIZE, ) - categoryPages[category.id] = CachedCategoryPage(items, canLoadMore) + categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore) current.copy( items = items, + readOffset = read, isLoading = false, isLoadingMore = false, canLoadMore = canLoadMore, @@ -170,6 +184,9 @@ class GenreBrowseViewModel( private data class CachedCategoryPage( val items: List, + /** See [GenreBrowseUiState.readOffset]: restored with the items, or a category + * returned to would page from the wrong place. */ + val readOffset: Int, val canLoadMore: Boolean, ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index ca2791d..58fbaac 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -668,6 +668,25 @@ class PlayerActivity : ComponentActivity() { runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) } .onSuccess { playable -> if (isFinishing || isDestroyed) return@onSuccess + // A resolution with no stream in it is a failure wearing a success's + // shape: handed to the player it becomes an empty URI, and what the + // viewer is told is whatever media3 makes of that rather than that the + // server could not answer. The request is deliberately left in place, + // so Retry asks again instead of re-preparing nothing. + if (playable.url.isBlank()) { + Log.e( + PLAYBACK_LOG_TAG, + "event=stream_resolve_empty item=${request.itemId}", + ) + showPlaybackError( + PlaybackFailure( + title = getString(R.string.playback_server_unreachable), + detail = getString(R.string.playback_server_unreachable_detail), + canAutoRetry = false, + ), + ) + return@onSuccess + } pendingRequest = null adoptPlayable(playable) trace.mark(PlaybackTrace.STREAM_RESOLVED) @@ -1283,6 +1302,22 @@ class PlayerActivity : ComponentActivity() { ) }.onSuccess { refreshed -> if (isFinishing || isDestroyed) return@onSuccess + // Same rule as the launch path: a refresh that came back without a stream + // is reported as the server being unreachable rather than re-prepared as + // an empty URI, which would fail as a source error and be retried on the + // automatic budget — several rounds of "Reconnecting…" for a server that + // is answering perfectly well and has nothing to give. + if (refreshed.url.isBlank()) { + Log.e(PLAYBACK_LOG_TAG, "event=stream_refresh_empty item=$id") + showPlaybackError( + PlaybackFailure( + title = getString(R.string.playback_server_unreachable), + detail = getString(R.string.playback_server_unreachable_detail), + canAutoRetry = false, + ), + ) + return@onSuccess + } itemId = refreshed.itemId mediaSourceId = refreshed.mediaSourceId playSessionId = refreshed.playSessionId @@ -2333,7 +2368,13 @@ class PlayerActivity : ComponentActivity() { lifecycleScope.launch { val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode if (!enabled) return@launch + // A next episode with no stream behind it is not a next episode. Kept as one + // it would put up a banner and a countdown promising something that cannot be + // played, and then — because the countdown runs itself out — swap it in + // automatically and fail, with a viewer who pressed nothing having their + // programme replaced by an error. val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null) + ?.takeIf { it.url.isNotBlank() } resolved?.imageUrl?.let { imageUrl -> imageLoader.enqueue( ImageRequest.Builder(this@PlayerActivity) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index 35ef06e..741ff47 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.hasMoreGenreItems import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate +import com.ponzischeme89.memby.ui.distinctItems import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -46,6 +47,16 @@ data class SearchUiState( val genre: String? = null, /** A further page is on its way. The grid keeps what it has and adds a footer. */ val isLoadingMore: Boolean = false, + /** + * How far into the genre the shelf has read, counted the way the *backend* counts it. + * + * Deliberately not `results.size`. A duplicate arriving across a page boundary is + * dropped on the way in — the grid is keyed by item id, so a repeat would be a crash + * rather than a doubled poster — and if the offset were then derived from the length + * of the list, the next page would be requested from before where the last one ended + * and would return the same cards again, for ever. + */ + val genreOffset: Int = 0, /** There is more of this genre to ask for. See [hasMoreGenreItems]. */ val canLoadMore: Boolean = false, val suggestions: List = emptyList(), @@ -160,7 +171,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - query = "", results = emptyList(), isLoading = false, hasSearched = false, + query = "", results = emptyList(), genreOffset = 0, + isLoading = false, hasSearched = false, errorMessage = null, requestCandidates = emptyList(), requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, genre = null, @@ -180,7 +192,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - query = "", genre = name, results = emptyList(), isLoading = true, + query = "", genre = name, results = emptyList(), genreOffset = 0, + isLoading = true, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, requestCandidates = emptyList(), requestLookupLoading = false, requestMessage = null, @@ -198,7 +211,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy( - genre = null, results = emptyList(), isLoading = false, hasSearched = false, + genre = null, results = emptyList(), genreOffset = 0, + isLoading = false, hasSearched = false, errorMessage = null, isLoadingMore = false, canLoadMore = false, ) } @@ -214,7 +228,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { val genre = current.genre ?: return if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return _state.update { it.copy(isLoadingMore = true) } - genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) } + genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.genreOffset) } } /** Retry after an error, without disturbing the query or the keyboard. */ @@ -224,7 +238,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { genrePageJob?.cancel() _state.update { it.copy(isLoading = true, errorMessage = null) } genrePageJob = viewModelScope.launch { - loadGenrePage(genre, offset = current.results.size) + loadGenrePage(genre, offset = current.genreOffset) } return } @@ -301,16 +315,26 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) } .onSuccess { page -> _state.update { current -> - if (current.genre != genre || current.results.size != page.offset) return@update current - val items = current.results + page.items + if (current.genre != genre || current.genreOffset != page.offset) return@update current + // A paging boundary is the one place a backend realistically repeats a + // card: the shelf is ordered by premiere date and sort name precisely + // because two titles sharing a date could otherwise swap places between + // two requests. If one slips through anyway, the grid is keyed by item + // id and a repeat is a crash, not a duplicate poster — so the page is + // appended minus anything already on the shelf, and how far the shelf + // has read is counted in what the backend sent rather than in what + // survived. See [SearchUiState.genreOffset]. + val read = page.offset + page.items.size + val items = (current.results + page.items).distinctItems() current.copy( results = items, + genreOffset = read, isLoading = false, isLoadingMore = false, hasSearched = true, errorMessage = null, canLoadMore = hasMoreGenreItems( - loaded = items.size, + loaded = read, total = page.total, lastPageSize = page.items.size, pageSize = GENRE_PAGE_SIZE, @@ -346,7 +370,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // than left behind a shorter query they no longer match. _state.update { it.copy( - results = emptyList(), isLoading = false, hasSearched = false, + results = emptyList(), genreOffset = 0, + isLoading = false, hasSearched = false, errorMessage = null, requestCandidates = emptyList(), requestLookupLoading = false, requestMessage = null, requestMessageIsError = false, @@ -367,7 +392,12 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { // between two letters reads as breakage, not as progress. _state.update { it.copy(isLoading = true, errorMessage = null) } runCatching { repository.search(term) } - .onSuccess { items -> + .onSuccess { found -> + // The gateway answers from the imported library and falls back to Emby + // before the first import has finished, so one title reaching the pane by + // both routes is a shape this search genuinely has. The grid is keyed by + // item id, where that is a crash rather than a repeated poster. + val items = found.distinctItems() // Gateway payloads carry the backend ranker's score. Preserve that // ordering exactly; direct-to-Emby mode keeps the local textual fallback. val ranked = if (items.any { it.membyRecommendationScore != null }) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 4c87836..6419372 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester @@ -71,9 +72,11 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle @@ -753,6 +756,14 @@ internal fun SettingsPanelContent( // nothing, which is what "stuck on that page" looks like from the sofa. The pane is a // focus group, so one requester names "whatever this page starts with". val contentFocusRequester = remember { FocusRequester() } + // And the way back out of it. The pane's first control had nothing above it on any + // page, so Up there did nothing at all — which from the sofa is a screen that has + // stopped responding rather than a list that has run out. About is where it bites, + // because its pane is a changelog long enough that walking back up is the ordinary + // way to leave it. Left already returns to the page list; this makes Up say the same + // thing once the pane has no row above. + val railSelectionFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current LaunchedEffect(state.selectedPage) { contentScrollState.scrollTo(0) @@ -778,6 +789,7 @@ internal fun SettingsPanelContent( firstFocusRequester = firstFocusRequester, navigationFocusRequester = navigationFocusRequester, contentFocusRequester = contentFocusRequester, + selectionFocusRequester = railSelectionFocusRequester, compact = overlay, modifier = Modifier .width(if (overlay) 190.dp else 224.dp) @@ -792,6 +804,21 @@ internal fun SettingsPanelContent( // page's first control, whatever that page turns out to be. .focusRequester(contentFocusRequester) .focusGroup() + // Deliberately not focusProperties { up = … }, which every row in the + // pane inherits and which would take a page's own vertical navigation + // away from it; and not `exit`, which is never consulted when the search + // finds nothing anywhere, and finding nothing is the whole case. So the + // move the default handler would have made is made here first, and the + // rail is reached only when it fails — which is the top control and + // nothing else. + .onKeyEvent { event -> + when { + event.type != KeyEventType.KeyDown -> false + event.key != Key.DirectionUp -> false + focusManager.moveFocus(FocusDirection.Up) -> true + else -> runCatching { railSelectionFocusRequester.requestFocus() }.isSuccess + } + } .verticalScroll(contentScrollState) .padding( start = if (overlay) 26.dp else 42.dp, @@ -1282,6 +1309,7 @@ private fun SettingsSecondaryRail( firstFocusRequester: FocusRequester?, navigationFocusRequester: FocusRequester?, contentFocusRequester: FocusRequester, + selectionFocusRequester: FocusRequester, compact: Boolean, modifier: Modifier = Modifier, ) { @@ -1374,6 +1402,17 @@ private fun SettingsSecondaryRail( }, shape = RoundedCornerShape(10.dp), ) + // Named twice: once by its fixed place in the rail, and — while it is + // the page being drawn — as where Up out of the top of that page + // lands. It follows the selection rather than the highlight, so a + // press arriving mid-settle still returns to the page on screen. + .then( + if (page == selected) { + Modifier.focusRequester(selectionFocusRequester) + } else { + Modifier + }, + ) .focusRequester(railFocusRequesters[index + 1]) .focusProperties { up = railFocusRequesters[index] diff --git a/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateGuard.kt b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateGuard.kt new file mode 100644 index 0000000..9ce9a48 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateGuard.kt @@ -0,0 +1,28 @@ +package com.ponzischeme89.memby.update + +import com.ponzischeme89.memby.data.SettingsStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Writes every refusal to disk the moment it arrives. + * + * It is owned by the service locator rather than by a screen because the refusal lands on + * whichever request happened to be in flight — the status poll, a home refresh, a sign-in + * — and any of those can be running while nothing that knows about update policy is + * composed. It is also the half that survives a restart: the gateway announces the + * retirement exactly once, on the 401 that deletes the session, and every 401 after that + * is an ordinary missing session carrying no header at all. + */ +class RequiredUpdateGuard( + private val settings: SettingsStore, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + init { + scope.launch { + RequiredUpdateSignal.required.collect { settings.markRequiredUpdate(it) } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateSignal.kt b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateSignal.kt new file mode 100644 index 0000000..1e5cbd1 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateSignal.kt @@ -0,0 +1,60 @@ +package com.ponzischeme89.memby.update + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * The gateway telling *this running build* that it may no longer be used. + * + * A destructive update floor deletes the session on the next authenticated request and + * answers 401; a retired build's sign-in attempt is refused with 426. Both carry + * [HEADER], and without it the television only learns it has been signed out — so it + * drew a sign-in form that could never succeed, and the mandatory update screen was up + * to an hour away on the ordinary check interval. Force-closing the app was the only way + * through, because a fresh launch checks for updates before it draws anything. + * + * Reporting the header instead brings that check forward to the moment of the refusal, + * so the sequence a viewer sees is signed out, then the update screen. + * + * Process-scoped and replayed once: the refusal can arrive during startup, before + * `AppRoot`'s check loop is waiting on it, and a signal nobody was listening for is + * exactly the one that must not be lost. + */ +object RequiredUpdateSignal { + + /** Set by the gateway on both the retiring 401 and the refused sign-in. */ + const val HEADER = "X-Memby-Update-Required" + + private val _required = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Emits the version the gateway says is required, newest last. */ + val required: SharedFlow = _required.asSharedFlow() + + fun report(version: String) { + val trimmed = version.trim() + if (trimmed.isEmpty()) return + _required.tryEmit(trimmed) + } + + /** + * Forgets the replayed refusal, once the gateway has been asked and did not in fact + * demand an update. + * + * Without this the replay outlives the thing it describes. The value is process-scoped + * and `AppRoot`'s check loop starts with no memory of what it has already handled, so + * an activity Android recreates — returning from the TV's home screen, a + * configuration change — replays a refusal that has since been answered, and holds the + * opening screen through the retry budget before letting the launcher through. A + * verdict that says this build is fine is exactly the evidence that the refusal is + * spent. + */ + fun clear() { + _required.resetReplayCache() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateState.kt b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateState.kt new file mode 100644 index 0000000..1adfd0f --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/RequiredUpdateState.kt @@ -0,0 +1,37 @@ +package com.ponzischeme89.memby.update + +/** + * Whether a remembered refusal has been answered by the build now running. + * + * The refusal is stored as the version the gateway demanded, and the only thing that can + * genuinely satisfy it without asking the server is this television having reached that + * version — which is exactly what happens after the update installs and the app restarts + * before its first check comes back. Without this the freshly updated build would open on + * the retired-build screen it had just escaped. + * + * A version this build cannot parse is treated as *not* satisfied: the refusal stands + * until the gateway itself withdraws it, because guessing the other way would let a + * malformed policy value put a retired television back on the launcher. + */ +fun requiredUpdateSatisfied(installedVersion: String, requiredVersion: String): Boolean { + val required = parseAppVersion(requiredVersion) + val installed = parseAppVersion(installedVersion) + if (required.isEmpty() || installed.isEmpty()) return false + for (index in 0 until maxOf(required.size, installed.size)) { + val demanded = required.getOrElse(index) { 0 } + val running = installed.getOrElse(index) { 0 } + if (demanded != running) return running > demanded + } + return true +} + +/** Stops at the first non-numeric component, so `0.2.46-beta` reads as 0.2.46. */ +private fun parseAppVersion(version: String): List { + val parts = version.trim().trimStart('v', 'V').split('.', '-', '+', ' ') + val out = mutableListOf() + for (part in parts) { + val value = part.toIntOrNull() ?: break + out += value + } + return out +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/CalendarGridTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/CalendarGridTest.kt new file mode 100644 index 0000000..4970f01 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/CalendarGridTest.kt @@ -0,0 +1,239 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayCalendar +import com.ponzischeme89.memby.data.model.GatewayCalendarDay +import com.ponzischeme89.memby.ui.calendar.CALENDAR_COLUMNS +import com.ponzischeme89.memby.ui.calendar.CalendarUiState +import com.ponzischeme89.memby.ui.calendar.calendarDate +import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekDate +import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeekIndex +import com.ponzischeme89.memby.ui.calendar.calendarAgendaWeeks +import com.ponzischeme89.memby.ui.calendar.calendarDayHeading +import com.ponzischeme89.memby.ui.calendar.calendarEpisodeCount +import com.ponzischeme89.memby.ui.calendar.calendarEpisodeCountLabel +import com.ponzischeme89.memby.ui.calendar.calendarInitialDate +import com.ponzischeme89.memby.ui.calendar.calendarWeeks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CalendarGridTest { + + private fun episode(id: String, name: String) = BaseItem(id = id, name = name, type = "MembySonarrEpisode") + + // August 2026: 31 days, beginning on a Saturday. + private fun august(days: List = emptyList(), today: String = "2026-08-11") = + GatewayCalendar( + available = true, + month = "2026-08", + label = "August 2026", + previous = "2026-07", + next = "2026-09", + today = today, + firstWeekday = 6, + dayCount = 31, + days = days, + ) + + @Test + fun `the grid pads to whole weeks`() { + val weeks = calendarWeeks(august()) + // Six leading blanks plus thirty-one days is thirty-seven cells: six rows of seven. + assertEquals(6, weeks.size) + assertTrue(weeks.all { it.size == CALENDAR_COLUMNS }) + assertTrue(weeks.first().take(6).all { it.isPad }) + assertEquals(1, weeks.first().last().day) + assertEquals(31, weeks.flatten().count { !it.isPad }) + } + + @Test + fun `the weekly guide uses the gateway month shape`() { + val weeks = calendarAgendaWeeks( + august( + days = listOf( + GatewayCalendarDay( + date = "2026-08-12", + day = 12, + items = listOf(episode("a", "Northbound"), episode("b", "Harbour")), + ), + ), + ), + ) + + assertEquals(6, weeks.size) + assertEquals("9–15 August", weeks[2].label) + assertEquals(2, weeks[2].episodeCount) + assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12")) + assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2])) + } + + @Test + fun `another weekly page opens on its first programme`() { + val weeks = calendarAgendaWeeks( + august( + today = "", + days = listOf( + GatewayCalendarDay( + date = "2026-08-18", + day = 18, + items = listOf(episode("a", "Northbound")), + ), + ), + ), + ) + + assertEquals("2026-08-18", calendarAgendaWeekDate(weeks[3])) + } + + // A pad is drawn but never focusable: there is nothing on the 0th of August to be told + // about, and a D-pad that stopped there would read as the grid having stuck. + @Test + fun `pads are never focusable`() { + val weeks = calendarWeeks(august()) + assertFalse(weeks.first().first().isFocusable) + assertTrue(weeks.first().last().isFocusable) + } + + @Test + fun `days carry their episodes and their date`() { + val weeks = calendarWeeks( + august( + days = listOf( + GatewayCalendarDay( + date = "2026-08-12", + day = 12, + items = listOf(episode("a", "Northbound"), episode("b", "Harbour")), + ), + ), + ), + ) + val twelfth = weeks.flatten().first { it.day == 12 } + assertEquals("2026-08-12", twelfth.date) + assertEquals(2, twelfth.items.size) + assertTrue(weeks.flatten().first { it.day == 13 }.items.isEmpty()) + } + + @Test + fun `today is marked and only today`() { + val cells = calendarWeeks(august()).flatten() + assertEquals(listOf(11), cells.filter { it.today }.map { it.day }) + } + + @Test + fun `a month the household is not in marks no day`() { + val cells = calendarWeeks(august(today = "")).flatten() + assertTrue(cells.none { it.today }) + } + + @Test + fun `a leap February still lays out`() { + val weeks = calendarWeeks( + GatewayCalendar(available = true, month = "2028-02", dayCount = 29, firstWeekday = 2), + ) + assertEquals(29, weeks.flatten().count { !it.isPad }) + assertTrue(weeks.all { it.size == CALENDAR_COLUMNS }) + } + + @Test + fun `an unavailable month lays out nothing`() { + assertTrue(calendarWeeks(GatewayCalendar()).isEmpty()) + } + + // Today is what somebody opening a guide is asking about, whether or not anything airs. + @Test + fun `the page opens on today when the household is in this month`() { + assertEquals("2026-08-11", calendarInitialDate(august())) + } + + // Travelling forward has no today to land on, and landing on the 1st of a month whose + // news is on the 14th means walking a fortnight of empty cells to find out there was any. + @Test + fun `another month opens on its first airing`() { + val calendar = august( + today = "", + days = listOf( + GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "Northbound"))), + ), + ) + assertEquals("2026-08-14", calendarInitialDate(calendar)) + } + + @Test + fun `a month with nothing on opens on its first day`() { + assertEquals("2026-08-01", calendarInitialDate(august(today = ""))) + } + + @Test + fun `counts describe the month`() { + val calendar = august( + days = listOf( + GatewayCalendarDay(date = "2026-08-12", day = 12, items = listOf(episode("a", "A"), episode("b", "B"))), + GatewayCalendarDay(date = "2026-08-13", day = 13, items = listOf(episode("c", "C"))), + ), + ) + assertEquals(3, calendarEpisodeCount(calendar)) + assertEquals("3 episodes", calendarEpisodeCountLabel(3)) + assertEquals("1 episode", calendarEpisodeCountLabel(1)) + assertEquals("Nothing scheduled", calendarEpisodeCountLabel(0)) + } + + // The weekday is counted off the grid's own offset rather than from a date library, for + // the same reason the grid is built from firstWeekday at all. + @Test + fun `the day heading names the weekday`() { + assertEquals("Wednesday 12 August", calendarDayHeading(august(), "2026-08-12")) + assertEquals("Saturday 1 August", calendarDayHeading(august(), "2026-08-01")) + assertEquals("Monday 31 August", calendarDayHeading(august(), "2026-08-31")) + } + + @Test + fun `a heading is refused for a day outside the month`() { + assertEquals("", calendarDayHeading(august(), "")) + assertEquals("", calendarDayHeading(august(), "2026-08-45")) + } + + // Every date on this page is compared for equality with every other, so a month the + // gateway sent as something unreadable has to produce *nothing* rather than a set of + // plausible-looking strings that agree with none of the days the episodes are in. + @Test + fun `a month that is not a month yields no dates`() { + assertEquals("", calendarDate("", 12)) + assertEquals("", calendarDate("2026", 12)) + assertEquals("", calendarDate("not-a-month", 12)) + assertEquals("", calendarDate("20xx-08", 12)) + assertEquals("", calendarDate("2026/08", 12)) + assertEquals("2026-08-12", calendarDate("2026-08", 12)) + assertEquals("2026-08-01", calendarDate("2026-08", 1)) + } + + @Test + fun `no cell is dated when the month is unreadable`() { + val broken = august().copy(month = "nonsense", today = "") + assertTrue(calendarWeeks(broken).flatten().none { it.date.isNotEmpty() }) + assertEquals("", calendarInitialDate(broken)) + } + + // Selection is by date string, so a blank one has to match nothing at all. Matching the + // first day that also failed to produce a date would list one arbitrary day's episodes + // under every cell in the grid. + @Test + fun `a blank selection lists nothing`() { + val state = CalendarUiState( + calendar = august( + today = "", + days = listOf( + GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "A"))), + ), + ), + selectedDate = "", + isLoading = false, + ) + assertTrue(state.selectedItems.isEmpty()) + assertEquals( + listOf("a"), + state.copy(selectedDate = "2026-08-14").selectedItems.map { it.id }, + ) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt new file mode 100644 index 0000000..844e317 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ListKeysTest.kt @@ -0,0 +1,72 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.HomeRow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** + * The keyed lazy lists up and down this app are keyed by ids that came off a wire, and a + * repeated key is a crash that takes the whole screen with it. These pin the rule that + * stands between a duplicate and that crash — see [distinctForKeys]. + */ +class ListKeysTest { + + private fun item(id: String, name: String = id) = BaseItem(id = id, name = name) + + private fun row(id: String, vararg items: BaseItem) = + HomeRow(id = id, title = id, kind = "latest", items = items.toList()) + + @Test + fun `a list already distinct is handed back untouched`() { + val items = listOf(item("a"), item("b")) + assertSame(items, items.distinctForKeys(BaseItem::id)) + } + + // The first wins, deliberately: on the launcher that is the copy already drawn, and + // pulling a card out from under somebody mid-scroll is the thing being avoided. + @Test + fun `a duplicate key keeps the first occurrence`() { + val first = item("a", name = "Original") + val items = listOf(first, item("b"), item("a", name = "Repeat")) + val distinct = items.distinctForKeys(BaseItem::id) + assertEquals(listOf("a", "b"), distinct.map(BaseItem::id)) + assertEquals("Original", distinct.first().name) + } + + @Test + fun `an empty list is safe`() { + assertEquals(emptyList(), emptyList().distinctForKeys(BaseItem::id)) + } + + // Two rows under one id crash the launcher's own LazyColumn — which the recommendation + // refresh could produce by appending a freshly built row beside the one it was meant to + // replace. + @Test + fun `rows are made unique by row id`() { + val rows = listOf( + row("continue", item("a")), + row("latest", item("b")), + row("continue", item("c")), + ) + assertEquals(listOf("continue", "latest"), rows.sanitisedRows().map(HomeRow::id)) + } + + // And two cards under one id crash whichever row holds them. + @Test + fun `cards within a row are made unique by item id`() { + val sanitised = row("continue", item("a"), item("b"), item("a")).let { + listOf(it).sanitisedRows() + } + assertEquals(listOf("a", "b"), sanitised.single().items.map(BaseItem::id)) + } + + // Sanitising runs on every home response, so the ordinary case must not rebuild the + // whole payload: an untouched row is the same instance it arrived as. + @Test + fun `a clean payload is not copied`() { + val clean = row("continue", item("a"), item("b")) + assertSame(clean, listOf(clean).sanitisedRows().single()) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt index 18d108a..08a1ae4 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt @@ -18,16 +18,31 @@ class MediaBadgesTest { videoRangeType = "DOVI", title = "Dolby Vision HEVC", ), - MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"), + MediaStream(type = "Audio", title = "TrueHD Dolby Atmos", channels = 8), ), ) assertEquals( - listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"), + listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"), mediaBadges(item), ) } + @Test + fun `includes surround and non-surround sound profiles`() { + fun badgesFor(channels: Int) = mediaBadges( + BaseItem( + id = "movie-$channels", + mediaStreams = listOf(MediaStream(type = "Audio", channels = channels)), + ), + ) + + assertEquals(listOf("MONO"), badgesFor(1)) + assertEquals(listOf("STEREO"), badgesFor(2)) + assertEquals(listOf("5.1"), badgesFor(6)) + assertEquals(listOf("7.1"), badgesFor(8)) + } + /** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */ @Test fun `names HDR10+ rather than collapsing it to HDR`() { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarScreenshotTest.kt new file mode 100644 index 0000000..aeb850e --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/calendar/CalendarScreenshotTest.kt @@ -0,0 +1,169 @@ +package com.ponzischeme89.memby.ui.calendar + +import androidx.compose.runtime.Composable +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.test.core.app.ApplicationProvider +import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayCalendar +import com.ponzischeme89.memby.data.model.GatewayCalendarDay +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The TV calendar as it actually sits on a television, to `build/screenshots/tv-calendar/`. + * + * ```powershell + * .\gradlew.bat :app:testDebugUnitTest --tests "*CalendarScreenshotTest" + * ``` + * + * A unit test can check that thirty-one days lay out as six weeks; it cannot check whether + * a cell two centimetres across can carry a day number, two show titles and a "+2 more" + * without any of them becoming unreadable, which is the whole question this layout has. So + * the busy month is the one worth looking at: a household that follows twenty shows is what + * this page is for, and an empty February proves nothing about it. + * + * Poster artwork is injected as absent rather than stubbed. There is no network here, and a + * row that reads correctly with no picture in it is the state a title Sonarr has no cover + * for is drawn in anyway. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +class CalendarScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Before + fun locator() { + ServiceLocator.init(ApplicationProvider.getApplicationContext()) + } + + /** A month with something on most weeks, opened on today — the ordinary case. */ + @Test + fun `a busy month`() { + capture("calendar_busy-month") { CalendarPage(busyMonth()) } + } + + /** The day panel filled past what one cell could ever show. */ + @Test + fun `a crowded day`() { + capture("calendar_crowded-day") { + CalendarPage(busyMonth(), selectedDate = "2026-08-12") + } + } + + /** Nothing scheduled: the grid still reads as a month rather than as a failure. */ + @Test + fun `a quiet month`() { + capture("calendar_quiet-month") { + CalendarPage( + CalendarUiState( + calendar = august(days = emptyList()), + selectedDate = "2026-08-11", + isLoading = false, + ), + ) + } + } + + /** No gateway, or no Sonarr behind it. The page says so instead of drawing a month. */ + @Test + fun `no calendar available`() { + capture("calendar_unavailable") { + CalendarPage(CalendarUiState(calendar = GatewayCalendar(), isLoading = false)) + } + } + + @Composable + private fun CalendarPage(state: CalendarUiState, selectedDate: String = state.selectedDate) { + CalendarContent( + state = state.copy(selectedDate = selectedDate), + navigationFocusRequester = FocusRequester(), + contentFocusRequester = FocusRequester(), + onShowMonth = {}, + onSelectDate = {}, + onRetry = {}, + onItemFocused = {}, + onItemSelected = {}, + onExit = {}, + posterUrlFor = { null }, + ) + } + + private fun capture(name: String, content: @Composable () -> Unit) { + compose.setContent(content) + compose.onRoot().captureRoboImage("build/screenshots/tv-calendar/$name.png") + } + + private fun busyMonth() = CalendarUiState( + calendar = august( + days = listOf( + day(3, episode("Northbound", "S02E04", "The Crossing", "Monday: 8:30 PM")), + day( + 12, + episode("Northbound", "S02E05", "Slack Water", "Wednesday: 8:30 PM", "Season finale"), + episode("Harbour Lights", "S01E02", "Low Tide", "Wednesday: 9:00 PM"), + episode("The Long Paddock", "S04E11", "Shearing", "Wednesday: 9:30 PM"), + episode("Deep Water", "S03E01", "First Light", "Wednesday: 10:00 PM", "Season premiere"), + episode("Kererū", "S01E06", "The Nesting", "Wednesday: 10:30 PM"), + ), + day( + 18, + episode("Harbour Lights", "S01E03", "Spring Tide", "Tuesday: 9:00 PM"), + episode("Deep Water", "S03E02", "The Drop", "Tuesday: 10:00 PM"), + ), + day(26, episode("The Long Paddock", "S04E12", "Muster", "Wednesday: 9:30 PM")), + ), + ), + selectedDate = "2026-08-11", + isLoading = false, + ) + + // August 2026: 31 days, beginning on a Saturday. + private fun august(days: List) = GatewayCalendar( + available = true, + month = "2026-08", + label = "August 2026", + previous = "2026-07", + next = "2026-09", + today = "2026-08-11", + firstWeekday = 6, + dayCount = 31, + days = days, + ) + + private fun day(number: Int, vararg items: BaseItem) = GatewayCalendarDay( + date = "2026-08-" + if (number < 10) "0$number" else "$number", + day = number, + items = items.toList(), + ) + + private fun episode( + series: String, + code: String, + title: String, + airs: String, + event: String? = null, + ) = BaseItem( + id = "sonarr:$series:$code", + name = series, + type = "MembySonarrEpisode", + membySource = "sonarr", + membyEpisodeCode = code, + membyEpisodeTitle = title, + membyAirLabel = airs, + membyEpisodeEvent = event, + membyAvailabilityText = "Upcoming", + membyPlayable = false, + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt new file mode 100644 index 0000000..923052e --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/settings/SettingsRailFocusTest.kt @@ -0,0 +1,129 @@ +package com.ponzischeme89.memby.ui.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import com.ponzischeme89.memby.ui.PreviewSurface +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi") +@OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) +class SettingsRailFocusTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun `up from about reaches storage`() { + compose.setContent { Fixture() } + compose.waitForIdle() + + compose.onNodeWithTag("settings-rail-appearance").requestFocus() + compose.waitForIdle() + SettingsPage.entries.drop(1).forEach { page -> + compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + compose.waitForIdle() + compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").assertIsFocused() + } + compose.onNodeWithTag("settings-rail-about").assertIsFocused() + // The rail settles onto the page a beat after the focus lands on it; the press + // worth testing is the one made after the About page is actually drawn. + compose.waitUntil { + compose.onAllNodesWithTag("settings-page-about").fetchSemanticsNodes().isNotEmpty() + } + + compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } + compose.waitForIdle() + compose.onNodeWithTag("settings-rail-storage").assertIsFocused() + } + + @Test + fun `up from the top of the about pane leaves the pane`() { + compose.setContent { Fixture() } + compose.waitForIdle() + + compose.onNodeWithTag("settings-rail-about").requestFocus() + compose.waitForIdle() + compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + compose.waitForIdle() + val top = MembyReleaseHistory.first().version + compose.onNodeWithTag("settings-release-$top").assertIsFocused() + + compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } + compose.waitForIdle() + compose.onNodeWithTag("settings-release-$top").assertIsNotFocused() + compose.onNodeWithTag("settings-rail-about").assertIsFocused() + } + + @Test + fun `up from the top of the playback pane leaves the pane`() { + compose.setContent { Fixture() } + compose.waitForIdle() + + compose.onNodeWithTag("settings-rail-playback").requestFocus() + compose.waitForIdle() + compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + compose.waitForIdle() + compose.onNodeWithTag("settings-rail-playback").assertIsNotFocused() + + compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } + compose.waitForIdle() + compose.onNodeWithTag("settings-rail-playback").assertIsFocused() + } + + /** The pane's own vertical navigation is untouched by the escape above it. */ + @Test + fun `down and up move between rows inside a pane`() { + compose.setContent { Fixture() } + compose.waitForIdle() + + compose.onNodeWithTag("settings-rail-about").requestFocus() + compose.waitForIdle() + compose.onRoot().performKeyInput { pressKey(Key.DirectionRight) } + compose.waitForIdle() + val releases = MembyReleaseHistory + compose.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + compose.waitForIdle() + compose.onNodeWithTag("settings-release-${releases[1].version}").assertIsFocused() + + compose.onRoot().performKeyInput { pressKey(Key.DirectionUp) } + compose.waitForIdle() + compose.onNodeWithTag("settings-release-${releases[0].version}").assertIsFocused() + } + + @Composable + private fun Fixture() { + var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) } + val firstFocus = remember { FocusRequester() } + PreviewSurface { + SettingsPanelContent( + state = SettingsPanelState( + selectedPage = selectedPage, + installedVersion = "0.1.60", + releaseHistory = MembyReleaseHistory, + ), + actions = SettingsPanelActions(onPageSelected = { selectedPage = it }), + overlay = false, + firstFocusRequester = firstFocus, + ) + } + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateSignalTest.kt b/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateSignalTest.kt new file mode 100644 index 0000000..5b78f2a --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateSignalTest.kt @@ -0,0 +1,28 @@ +package com.ponzischeme89.memby.update + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class RequiredUpdateSignalTest { + + @Test + fun `a refusal reported before anybody listens is still delivered`() = runTest { + // The gateway retires a build on whichever request is in flight, which on a cold + // start can be before AppRoot's check loop is waiting. A signal nobody heard would + // leave the television on a sign-in form the gateway is about to refuse. + RequiredUpdateSignal.report("0.2.46") + + assertEquals("0.2.46", RequiredUpdateSignal.required.first()) + } + + @Test + fun `the newest refusal wins and a blank header says nothing`() = runTest { + RequiredUpdateSignal.report("0.2.46") + RequiredUpdateSignal.report(" ") + RequiredUpdateSignal.report(" 0.2.47 ") + + assertEquals("0.2.47", RequiredUpdateSignal.required.first()) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateStateTest.kt b/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateStateTest.kt new file mode 100644 index 0000000..f29146c --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/update/RequiredUpdateStateTest.kt @@ -0,0 +1,42 @@ +package com.ponzischeme89.memby.update + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RequiredUpdateStateTest { + + @Test + fun `the build the gateway asked for satisfies the refusal`() { + assertTrue(requiredUpdateSatisfied("0.2.46", "0.2.46")) + } + + @Test + fun `a later build satisfies it too`() { + // The update installs and the app restarts before its first check comes back. The + // refusal is about the APK that was retired, and that APK is gone. + assertTrue(requiredUpdateSatisfied("0.2.47", "0.2.46")) + assertTrue(requiredUpdateSatisfied("0.3.0", "0.2.46")) + } + + @Test + fun `the build that was retired does not`() { + assertFalse(requiredUpdateSatisfied("0.2.45", "0.2.46")) + assertFalse(requiredUpdateSatisfied("0.2.9", "0.2.46")) + } + + @Test + fun `missing components count as zero`() { + assertTrue(requiredUpdateSatisfied("0.2", "0.2.0")) + assertFalse(requiredUpdateSatisfied("0.2", "0.2.1")) + } + + @Test + fun `a suffix is ignored, and an unreadable version keeps the refusal standing`() { + assertTrue(requiredUpdateSatisfied("0.2.46-beta", "0.2.46")) + // Refusing to guess: a version neither end can parse must not be what lets a + // retired television back onto the launcher. + assertFalse(requiredUpdateSatisfied("0.2.46", "latest")) + assertFalse(requiredUpdateSatisfied("", "0.2.46")) + } +} diff --git a/gradle.properties b/gradle.properties index a62ef07..8cb13fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true +org.gradle.configuration-cache=true android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/server/internal/api/api.go b/server/internal/api/api.go index e2c79c8..1bce62e 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -152,6 +152,7 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences)) v1.Handle("GET /v1/for-you", s.authed(s.handleForYou)) v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll)) + v1.Handle("GET /v1/calendar", s.authed(s.handleCalendar)) v1.Handle("GET /v1/my-shows", s.authed(s.handleMyShows)) v1.Handle("POST /v1/my-shows", s.authed(s.handleMyShows)) v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow)) diff --git a/server/internal/api/calendar.go b/server/internal/api/calendar.go new file mode 100644 index 0000000..6ef76c1 --- /dev/null +++ b/server/internal/api/calendar.go @@ -0,0 +1,247 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "sort" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/sonarr" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// The TV calendar is the schedule row's other shape: the same Sonarr episodes, laid out a +// month at a time rather than as the next five days. It shares the row's item builder on +// purpose — a card is a card, and the availability badges, lifecycle tags and Emby series +// link have to mean the same thing on both screens or the calendar becomes a second, +// slightly different account of the same week. +// +// Bump the prefix when the item contract changes, so an older layout cannot survive a +// deployment until the cached months expire. +const calendarCachePrefix = "sonarr:calendar:month:v2:" + +// How far either side of the present a viewer may travel. Sonarr answers for any date it +// has been asked about, and a held D-pad on the month header would otherwise walk it into +// the 2050s one request at a time. A year each way covers "when does the new season start" +// and "what did we miss in March" and stops there. +const calendarMonthRange = 12 + +type calendarResponse struct { + // Available is false when the household runs no Sonarr. The page says so rather than + // drawing an empty grid that looks like a month in which nothing airs. + Available bool `json:"available"` + // Month is the machine key ("2026-08"); Label is what the header prints. + Month string `json:"month"` + Label string `json:"label"` + // Empty when the month is at the end of the travelable range, which is what makes the + // header's arrows disappear rather than fail. + Previous string `json:"previous,omitempty"` + Next string `json:"next,omitempty"` + // The household's today, sent only when it falls inside this month — the client has no + // business deciding which cell to ring from a television's own clock when the gateway + // already knows the household's zone. + Today string `json:"today,omitempty"` + // The grid's shape. Sending it rather than a date per cell keeps civil-calendar + // arithmetic in one place: the television draws DayCount cells after FirstWeekday + // blanks and never has to know which years are leap years. + FirstWeekday int `json:"firstWeekday"` + DayCount int `json:"dayCount"` + Days []calendarDay `json:"days"` +} + +// calendarDay carries only the days that have something on them. A month of empty cells is +// the client's to draw, and sending thirty-one mostly-empty objects would triple the +// response for nothing. +type calendarDay struct { + Date string `json:"date"` + Day int `json:"day"` + Items []json.RawMessage `json:"items"` +} + +func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request, _ store.Session) { + ctx := r.Context() + if s.sonarr == nil || !s.featureEnabled(ctx, featureTVCalendar) { + writeJSON(w, http.StatusOK, emptyCalendar()) + return + } + location := s.sonarrLocation() + now := time.Now().In(location) + month, ok := parseCalendarMonth(r.URL.Query().Get("month"), now, location) + if !ok { + writeError(w, http.StatusBadRequest, "invalid month") + return + } + calendar, err := s.sonarrCalendarMonth(ctx, month, now, location) + if err != nil { + // A month nobody can fetch is reported as an empty month rather than as a failure: + // the page is informational, and a viewer pressing Right past a Sonarr hiccup + // should land on a quiet month and be able to press Left back out of it. + s.loggerFor(ctx).Warn("calendar month unavailable", + "month", month.Format("2006-01"), "error", err) + calendar = buildCalendarMonth(nil, month, now, location, nil) + } + writeJSON(w, http.StatusOK, calendar) +} + +func (s *Server) sonarrLocation() *time.Location { + if s.cfg.SonarrLocation != nil { + return s.cfg.SonarrLocation + } + return time.Local +} + +func (s *Server) sonarrCalendarMonth( + ctx context.Context, + month, now time.Time, + location *time.Location, +) (calendarResponse, error) { + key := calendarCachePrefix + month.Format("2006-01") + if cached := s.cachedCalendar(ctx, key); cached != nil { + return *cached, nil + } + + // The same shared lock the schedule row takes, for the same reason: several televisions + // opening the calendar together must not each stampede Sonarr on the one miss. + s.sonarrMu.Lock() + defer s.sonarrMu.Unlock() + if cached := s.cachedCalendar(ctx, key); cached != nil { + return *cached, nil + } + + episodes, err := s.sonarr.Calendar(ctx, month, month.AddDate(0, 1, 0)) + if err != nil { + return calendarResponse{}, err + } + calendar := buildCalendarMonth(episodes, month, now, location, s.embySeriesIndex(ctx)) + if body, marshalErr := json.Marshal(calendar); marshalErr == nil { + if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil { + s.loggerFor(ctx).Warn("calendar cache write failed", "error", cacheErr) + } + } + return calendar, nil +} + +// cachedCalendar refuses an entry whose Today no longer agrees with the household's, so a +// month cached before midnight cannot leave the ring on yesterday. Everything else in the +// response is fixed for the month and safely reusable. +func (s *Server) cachedCalendar(ctx context.Context, key string) *calendarResponse { + raw, err := s.cache.Get(ctx, key) + if err != nil { + return nil + } + var cached calendarResponse + if json.Unmarshal(raw, &cached) != nil { + return nil + } + now := time.Now().In(s.sonarrLocation()) + if cached.Today != "" && cached.Today != now.Format("2006-01-02") { + return nil + } + return &cached +} + +// parseCalendarMonth turns the client's `month` parameter into the first instant of that +// month in the household's zone. An empty parameter is the present month, which is what a +// television asks for when it opens the page and the only request an older client makes. +// +// Out-of-range months are refused rather than clamped: a clamp would answer a request for +// 2031 with this year's August and the header would then disagree with the grid. +func parseCalendarMonth(value string, now time.Time, location *time.Location) (time.Time, bool) { + now = now.In(location) + current := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return current, true + } + parsed, err := time.ParseInLocation("2006-01", trimmed, location) + if err != nil { + return time.Time{}, false + } + month := time.Date(parsed.Year(), parsed.Month(), 1, 0, 0, 0, 0, location) + if month.Before(current.AddDate(0, -calendarMonthRange, 0)) || + month.After(current.AddDate(0, calendarMonthRange, 0)) { + return time.Time{}, false + } + return month, true +} + +// buildCalendarMonth is the whole layout rule, pure so the edges — a month starting on a +// Sunday, a February, the day either side of the travel limit — are testable without a +// Sonarr to hand. +func buildCalendarMonth( + episodes []sonarr.Episode, + month, now time.Time, + location *time.Location, + series seriesIndex, +) calendarResponse { + month = month.In(location) + monthEnd := month.AddDate(0, 1, 0) + current := time.Date(now.In(location).Year(), now.In(location).Month(), 1, 0, 0, 0, 0, location) + + calendar := calendarResponse{ + Available: true, + Month: month.Format("2006-01"), + Label: month.Format("January 2006"), + // Sunday is 0, matching the weekday header the grid draws. + FirstWeekday: int(month.Weekday()), + DayCount: int(monthEnd.Sub(month).Hours() / 24), + Days: []calendarDay{}, + } + if previous := month.AddDate(0, -1, 0); !previous.Before(current.AddDate(0, -calendarMonthRange, 0)) { + calendar.Previous = previous.Format("2006-01") + } + if next := month.AddDate(0, 1, 0); !next.After(current.AddDate(0, calendarMonthRange, 0)) { + calendar.Next = next.Format("2006-01") + } + if today := now.In(location); !today.Before(month) && today.Before(monthEnd) { + calendar.Today = today.Format("2006-01-02") + } + + sort.SliceStable(episodes, func(i, j int) bool { + if episodes[i].AirDateUTC == nil { + return false + } + if episodes[j].AirDateUTC == nil { + return true + } + return episodes[i].AirDateUTC.Before(*episodes[j].AirDateUTC) + }) + + // A map plus one ordered slice, rather than a slice searched per episode: a busy month + // is a few hundred episodes and the days they land on are already in order. + byDate := make(map[string]int, 31) + for _, episode := range episodes { + if episode.AirDateUTC == nil { + continue + } + // Sonarr is asked in the household's zone but answers in UTC, so an episode airing + // late on the 31st elsewhere can fall outside this month once converted back. + airTime := episode.AirDateUTC.In(location) + if airTime.Before(month) || !airTime.Before(monthEnd) { + continue + } + raw, err := json.Marshal(toSonarrScheduleItem(episode, now, location, series)) + if err != nil { + continue + } + date := airTime.Format("2006-01-02") + index, seen := byDate[date] + if !seen { + calendar.Days = append(calendar.Days, calendarDay{ + Date: date, + Day: airTime.Day(), + Items: []json.RawMessage{}, + }) + index = len(calendar.Days) - 1 + byDate[date] = index + } + calendar.Days[index].Items = append(calendar.Days[index].Items, raw) + } + return calendar +} + +func emptyCalendar() calendarResponse { + return calendarResponse{Days: []calendarDay{}} +} diff --git a/server/internal/api/calendar_test.go b/server/internal/api/calendar_test.go new file mode 100644 index 0000000..b2c2b55 --- /dev/null +++ b/server/internal/api/calendar_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "encoding/json" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/sonarr" +) + +func calendarEpisode(id int, air time.Time, title string) sonarr.Episode { + utc := air.UTC() + return sonarr.Episode{ + ID: id, + SeriesID: id, + SeasonNumber: 1, + EpisodeNumber: id, + Title: title, + AirDateUTC: &utc, + Monitored: true, + Series: sonarr.Series{ID: id, Title: title}, + } +} + +func TestBuildCalendarMonthDescribesTheGrid(t *testing.T) { + location := time.UTC + // August 2026 begins on a Saturday and runs 31 days. + month := time.Date(2026, 8, 1, 0, 0, 0, 0, location) + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + + calendar := buildCalendarMonth(nil, month, now, location, nil) + + if !calendar.Available { + t.Fatal("a configured Sonarr must report the month as available") + } + if calendar.Month != "2026-08" || calendar.Label != "August 2026" { + t.Fatalf("unexpected identity: %+v", calendar) + } + if calendar.FirstWeekday != int(time.Saturday) || calendar.DayCount != 31 { + t.Fatalf("unexpected grid shape: %+v", calendar) + } + if calendar.Previous != "2026-07" || calendar.Next != "2026-09" { + t.Fatalf("unexpected neighbours: %+v", calendar) + } + if calendar.Today != "2026-08-11" { + t.Fatalf("today was not marked: %+v", calendar) + } + if calendar.Days == nil { + t.Fatal("days must encode as an array rather than null") + } +} + +func TestBuildCalendarMonthCountsFebruaryInALeapYear(t *testing.T) { + location := time.UTC + now := time.Date(2028, 2, 1, 0, 0, 0, 0, location) + calendar := buildCalendarMonth(nil, now, now, location, nil) + if calendar.DayCount != 29 { + t.Fatalf("expected 29 days, got %d", calendar.DayCount) + } +} + +func TestBuildCalendarMonthOmitsTodayOutsideTheMonth(t *testing.T) { + location := time.UTC + month := time.Date(2026, 10, 1, 0, 0, 0, 0, location) + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + if today := buildCalendarMonth(nil, month, now, location, nil).Today; today != "" { + t.Fatalf("a month the household is not in must carry no today: %q", today) + } +} + +// The arrows are what stop a held D-pad walking Sonarr into the next decade, so the edge of +// the range must be reported as having no neighbour rather than as one more request. +func TestBuildCalendarMonthStopsAtTheTravelLimit(t *testing.T) { + location := time.UTC + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + last := time.Date(2027, 8, 1, 0, 0, 0, 0, location) + if next := buildCalendarMonth(nil, last, now, location, nil).Next; next != "" { + t.Fatalf("expected no next month at the limit, got %q", next) + } + first := time.Date(2025, 8, 1, 0, 0, 0, 0, location) + if previous := buildCalendarMonth(nil, first, now, location, nil).Previous; previous != "" { + t.Fatalf("expected no previous month at the limit, got %q", previous) + } +} + +func TestBuildCalendarMonthGroupsEpisodesByLocalDay(t *testing.T) { + location := time.FixedZone("NZST", 12*60*60) + month := time.Date(2026, 8, 1, 0, 0, 0, 0, location) + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + + // Two on the 12th local, one on the 13th, and one that is the 31st in UTC but already + // September where the household lives. + episodes := []sonarr.Episode{ + calendarEpisode(2, time.Date(2026, 8, 12, 21, 0, 0, 0, location), "Harbour"), + calendarEpisode(1, time.Date(2026, 8, 12, 20, 0, 0, 0, location), "Northbound"), + calendarEpisode(3, time.Date(2026, 8, 13, 20, 0, 0, 0, location), "Deep Water"), + calendarEpisode(4, time.Date(2026, 8, 31, 20, 0, 0, 0, time.UTC), "Rolled Over"), + } + + calendar := buildCalendarMonth(episodes, month, now, location, nil) + + if len(calendar.Days) != 2 { + t.Fatalf("expected two populated days, got %d: %+v", len(calendar.Days), calendar.Days) + } + if calendar.Days[0].Date != "2026-08-12" || calendar.Days[0].Day != 12 { + t.Fatalf("unexpected first day: %+v", calendar.Days[0]) + } + if len(calendar.Days[0].Items) != 2 { + t.Fatalf("expected two episodes on the 12th: %+v", calendar.Days[0]) + } + // Within a day the order is Sonarr's air time, which is the order they will be watched. + var first sonarrScheduleItem + if err := json.Unmarshal(calendar.Days[0].Items[0], &first); err != nil { + t.Fatal(err) + } + if first.Name != "Northbound" { + t.Fatalf("episodes within a day must be ordered by air time: %+v", first) + } + if first.Type != "MembySonarrEpisode" || first.MembyPlayable { + t.Fatalf("a calendar card must be the schedule row's informational item: %+v", first) + } + if calendar.Days[1].Date != "2026-08-13" { + t.Fatalf("unexpected second day: %+v", calendar.Days[1]) + } +} + +func TestParseCalendarMonthDefaultsToThePresent(t *testing.T) { + location := time.UTC + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + month, ok := parseCalendarMonth(" ", now, location) + if !ok || month.Format("2006-01") != "2026-08" { + t.Fatalf("unexpected default month: %v %v", month, ok) + } +} + +func TestParseCalendarMonthRefusesNonsenseAndDistantMonths(t *testing.T) { + location := time.UTC + now := time.Date(2026, 8, 11, 9, 0, 0, 0, location) + for _, value := range []string{"2026-13", "August", "2026-08-11", "2031-01", "2020-01"} { + if _, ok := parseCalendarMonth(value, now, location); ok { + t.Fatalf("expected %q to be refused", value) + } + } + for _, value := range []string{"2026-09", "2027-08", "2025-08"} { + if _, ok := parseCalendarMonth(value, now, location); !ok { + t.Fatalf("expected %q to be accepted", value) + } + } +} diff --git a/server/internal/api/features.go b/server/internal/api/features.go index 2db1065..eca5fe1 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -25,6 +25,7 @@ const ( featureSeasonalThemes = "seasonal_themes" featureSeasonalDecorations = "seasonal_decorations" featureGenreBrowser = "genre_browser" + featureTVCalendar = "tv_calendar" ) type featureDefinition struct { @@ -124,6 +125,13 @@ var featureCatalogue = []featureDefinition{ DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1", Recovery: "Takes effect on the next status poll; the browser is hidden when off.", }, + { + Key: featureTVCalendar, Name: "TV calendar", Area: "Presentation", + Description: "Show the month-by-month Sonarr calendar on the navigation rail. " + + "Turning it off hides the destination and stops the gateway reading months.", + DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1", + Recovery: "Takes effect on the next status poll; the rail entry simply disappears.", + }, { Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup", Description: "Ask a signed-in TV that cannot install its own updates to grant the " + diff --git a/server/internal/api/hero_test.go b/server/internal/api/hero_test.go index 680a4b6..57956af 100644 --- a/server/internal/api/hero_test.go +++ b/server/internal/api/hero_test.go @@ -282,11 +282,11 @@ func sonarrPremiereEpisode( // about refusing. func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) { index := seriesIndex{ - "newshow": "emby-new", - "returning": "emby-returning", - "notinlibrary": "", - "specials": "emby-specials", - "undownloaded": "emby-undownloaded", + "newshow": {ID: "emby-new"}, + "returning": {ID: "emby-returning"}, + "notinlibrary": {}, + "specials": {ID: "emby-specials"}, + "undownloaded": {ID: "emby-undownloaded"}, } delete(index, "notinlibrary") @@ -320,7 +320,7 @@ func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) { // A show that premiered and then returned inside one window is one card about its newer // season, not two cards about the same show. func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) { - index := seriesIndex{"show": "emby-show"} + index := seriesIndex{"show": {ID: "emby-show"}} premieres := sonarrPremieres([]sonarr.Episode{ sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true), sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true), diff --git a/server/internal/api/sonarr.go b/server/internal/api/sonarr.go index 328c334..b8972fe 100644 --- a/server/internal/api/sonarr.go +++ b/server/internal/api/sonarr.go @@ -247,6 +247,7 @@ type sonarrScheduleItem struct { MembyAirLabel string `json:"MembyAirLabel"` MembyAvailability string `json:"MembyAvailability"` MembyAvailabilityText string `json:"MembyAvailabilityText"` + MembyEpisodeEvent string `json:"MembyEpisodeEvent,omitempty"` // Sonarr's lifecycle for the *show*, distinct from this episode's availability: one // says whether more episodes are coming at all, the other whether this one is here yet. MembyLifecycle string `json:"MembyLifecycle,omitempty"` @@ -255,13 +256,20 @@ type sonarrScheduleItem struct { // The Emby series this episode belongs to, when the library holds it. It is what lets // the card open the show's own page; a show Sonarr follows but Emby has never imported // simply carries none, and the card stays informational as it always was. - MembySeriesItemID string `json:"MembySeriesItemId,omitempty"` + MembySeriesItemID string `json:"MembySeriesItemId,omitempty"` + ParentLogoItemID string `json:"ParentLogoItemId,omitempty"` + ParentLogoImageTag string `json:"ParentLogoImageTag,omitempty"` } // seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map // rather than a store call per episode: one row can carry a dozen episodes of the same // show, and the answer is the same for all of them. -type seriesIndex map[string]string +type seriesReference struct { + ID string + LogoTag string +} + +type seriesIndex map[string]seriesReference // embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row // is about what is *about* to air, and losing the link only costs the card its page. @@ -282,13 +290,14 @@ func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex { } // The year-qualified key is written first and never overwritten, so a remake // cannot claim the original's page when both are in the library. + value := seriesReference{ID: ref.ID, LogoTag: ref.LogoTag} if ref.Year > 0 { if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen { - index[seriesIndexKey(key, ref.Year)] = ref.ID + index[seriesIndexKey(key, ref.Year)] = value } } if _, seen := index[key]; !seen { - index[key] = ref.ID + index[key] = value } } return index @@ -306,11 +315,28 @@ func (index seriesIndex) lookup(title string, year int) string { return "" } if year > 0 { - if id, ok := index[seriesIndexKey(key, year)]; ok { - return id + if ref, ok := index[seriesIndexKey(key, year)]; ok { + return ref.ID } } - return index[key] + return index[key].ID +} + +func (index seriesIndex) logo(title string, year int) (string, string) { + key := normalizedShowTitle(title) + if key == "" || len(index) == 0 { + return "", "" + } + ref := index[key] + if year > 0 { + if qualified, ok := index[seriesIndexKey(key, year)]; ok { + ref = qualified + } + } + if ref.ID == "" || ref.LogoTag == "" { + return "", "" + } + return ref.ID, ref.LogoTag } func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) { @@ -438,6 +464,10 @@ func toSonarrScheduleItem( MembyPlayable: false, } item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year) + item.ParentLogoItemID, item.ParentLogoImageTag = series.logo( + episode.Series.Title, episode.Series.Year, + ) + item.MembyEpisodeEvent = scheduleEpisodeEvent(episode) lifecycle := seriesLifecycleTag(episode.Series.Status) item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label if hasCover(episode.Series.Images, "poster") { @@ -483,6 +513,24 @@ func toSonarrScheduleItem( return item } +func scheduleEpisodeEvent(episode sonarr.Episode) string { + if episode.SeasonNumber <= 0 { + return "" + } + switch strings.ToLower(strings.TrimSpace(episode.FinaleType)) { + case "series", "seriesfinale": + return "Series finale" + case "season", "seasonfinale": + return "Season finale" + case "midseason", "midseasonfinale": + return "Mid-season finale" + } + if episode.EpisodeNumber == 1 { + return "Season premiere" + } + return "" +} + func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string { airTime = airTime.In(location) now = now.In(location) diff --git a/server/internal/api/sonarr_test.go b/server/internal/api/sonarr_test.go index 39d4f08..f7b16fe 100644 --- a/server/internal/api/sonarr_test.go +++ b/server/internal/api/sonarr_test.go @@ -57,9 +57,9 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) { now := time.Date(2026, 7, 27, 8, 0, 0, 0, location) air := time.Date(2026, 7, 27, 20, 0, 0, 0, location) index := seriesIndex{ - "northbound": "emby-old", - "northbound|2024": "emby-2024", - "harbour": "emby-harbour", + "northbound": {ID: "emby-old"}, + "northbound|2024": {ID: "emby-2024", LogoTag: "northbound-logo"}, + "harbour": {ID: "emby-harbour"}, } episode := func(title string, year int) sonarr.Episode { utc := air @@ -96,6 +96,42 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) { } } +func TestScheduleEpisodeEventNamesPremieresAndFinales(t *testing.T) { + tests := []struct { + name string + episode sonarr.Episode + want string + }{ + {"season premiere", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 1}, "Season premiere"}, + {"season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "seasonFinale"}, "Season finale"}, + {"series finale", sonarr.Episode{SeasonNumber: 5, EpisodeNumber: 10, FinaleType: "seriesFinale"}, "Series finale"}, + {"Sonarr season value", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "season"}, "Season finale"}, + {"mid-season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, FinaleType: "midseason"}, "Mid-season finale"}, + {"ordinary episode", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 4}, ""}, + {"special", sonarr.Episode{SeasonNumber: 0, EpisodeNumber: 1}, ""}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + if got := scheduleEpisodeEvent(testCase.episode); got != testCase.want { + t.Fatalf("event = %q, want %q", got, testCase.want) + } + }) + } +} + +func TestScheduleItemCarriesMatchedEmbyLogo(t *testing.T) { + episode := sonarr.Episode{ + ID: 1, SeriesID: 7, SeasonNumber: 2, EpisodeNumber: 1, + Series: sonarr.Series{ID: 7, Title: "Northbound", Year: 2024}, + } + item := toSonarrScheduleItem(episode, time.Now(), time.UTC, seriesIndex{ + "northbound|2024": {ID: "emby-series", LogoTag: "logo-tag"}, + }) + if item.ParentLogoItemID != "emby-series" || item.ParentLogoImageTag != "logo-tag" { + t.Fatalf("matched logo was not carried: %+v", item) + } +} + func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) { location := time.FixedZone("NZST", 12*60*60) now := time.Date(2026, 7, 28, 10, 0, 0, 0, location) diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 28d0075..50140e3 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.32 +0.1.33 diff --git a/server/internal/sonarr/client.go b/server/internal/sonarr/client.go index 562a532..5d4962b 100644 --- a/server/internal/sonarr/client.go +++ b/server/internal/sonarr/client.go @@ -92,19 +92,22 @@ type EpisodeFile struct { } type Episode struct { - ID int `json:"id"` - SeriesID int `json:"seriesId"` - SeasonNumber int `json:"seasonNumber"` - EpisodeNumber int `json:"episodeNumber"` - Title string `json:"title"` - Overview string `json:"overview"` - AirDateUTC *time.Time `json:"airDateUtc"` - Runtime int `json:"runtime"` - HasFile bool `json:"hasFile"` - Monitored bool `json:"monitored"` - Grabbed bool `json:"grabbed"` - Series Series `json:"series"` - EpisodeFile *EpisodeFile `json:"episodeFile"` + ID int `json:"id"` + SeriesID int `json:"seriesId"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + // Sonarr names confirmed endings (for example "seasonFinale" and + // "seriesFinale"). Empty means it has made no finale claim. + FinaleType string `json:"finaleType"` + Title string `json:"title"` + Overview string `json:"overview"` + AirDateUTC *time.Time `json:"airDateUtc"` + Runtime int `json:"runtime"` + HasFile bool `json:"hasFile"` + Monitored bool `json:"monitored"` + Grabbed bool `json:"grabbed"` + Series Series `json:"series"` + EpisodeFile *EpisodeFile `json:"episodeFile"` } type APIError struct { diff --git a/server/internal/store/library.go b/server/internal/store/library.go index 7a58f3b..e069da9 100644 --- a/server/internal/store/library.go +++ b/server/internal/store/library.go @@ -279,9 +279,10 @@ func (s *Store) LibraryGenres( // practice — to the Emby series the library holds, so a card built from that catalogue // can open the show's own page. Year is 0 when Emby does not know it. type SeriesRef struct { - ID string - Name string - Year int + ID string + Name string + Year int + LogoTag string } // SeriesRefs lists every imported series. The catalogue is a household's, not a @@ -289,7 +290,8 @@ type SeriesRef struct { // all rather than querying per title. func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) { rows, err := s.pool.Query(ctx, ` - SELECT id, name, COALESCE(production_year, 0) + SELECT id, name, COALESCE(production_year, 0), + COALESCE(payload->'ImageTags'->>'Logo', '') FROM library_items WHERE type = 'Series'`) if err != nil { @@ -299,7 +301,7 @@ func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) { out := []SeriesRef{} for rows.Next() { var ref SeriesRef - if err := rows.Scan(&ref.ID, &ref.Name, &ref.Year); err != nil { + if err := rows.Scan(&ref.ID, &ref.Name, &ref.Year, &ref.LogoTag); err != nil { return nil, err } out = append(out, ref)