0.2.48 - Tv calender fixes

This commit is contained in:
ponzischeme89
2026-08-11 12:08:51 +12:00
parent dd2dd497f2
commit f46d6596c5
47 changed files with 3415 additions and 125 deletions
+20
View File
@@ -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 ## 0.2.45 - 2026-08-10
- Bug fixes - Bug fixes
+123
View File
@@ -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 must stay out of `/v1/home`, which is cached per user while this answer varies per client
build. 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 ## Architecture
**Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore` **Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore`
@@ -1586,6 +1625,24 @@ three large files — new screens generally belong in `ui/<feature>/` rather tha
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
everything must be reachable by D-pad only. 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 **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 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 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 together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have
seen"), not feature names. 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`, **One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`,
`heroFacts`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the card metadata `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 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 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. 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, **Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather 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. than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.45" val defaultVersionName = "0.2.48"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.trim()
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync import com.ponzischeme89.memby.data.ThemeSync
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
import com.ponzischeme89.memby.update.RequiredUpdateGuard
/** /**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the * Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -41,12 +42,22 @@ object ServiceLocator {
lateinit var themeSync: ThemeSync lateinit var themeSync: ThemeSync
private set 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) { fun init(context: Context) {
if (::repository.isInitialized) return if (::repository.isInitialized) return
// Only hands the probe an application context; it does no work until the first // 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. // request or playback negotiation asks what this television's receiver accepts.
installAudioCapabilityProbe(context) installAudioCapabilityProbe(context)
settings = SettingsStore(context.applicationContext) 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) repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, settings) maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the // Takes the revision channel from the status poll rather than polling itself: the
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.AuthRequest import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem 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.GatewayFlagRequest
import com.ponzischeme89.memby.data.model.GatewayDevice import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
@@ -826,6 +827,23 @@ class EmbyRepository(private val settings: SettingsStore) {
.getOrDefault(emptyList()) .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<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> { suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
if (!ServerConfig.isGateway) return emptyList() if (!ServerConfig.isGateway) return emptyList()
return requireGateway().requestLookup(term.trim()).candidates return requireGateway().requestLookup(term.trim()).candidates
@@ -987,8 +1005,12 @@ class EmbyRepository(private val settings: SettingsStore) {
) )
} }
private val personCache = java.util.concurrent.ConcurrentHashMap<String, BaseItem>() // Bounded, access-ordered and synchronised rather than a plain concurrent map. These
private val filmographyCache = java.util.concurrent.ConcurrentHashMap<String, List<BaseItem>>() // 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<BaseItem>(PERSON_CACHE_SIZE)
private val filmographyCache = boundedCache<List<BaseItem>>(FILMOGRAPHY_CACHE_SIZE)
/** Biography and life dates for a cast member. Emby models a person as an item. */ /** Biography and life dates for a cast member. Emby models a person as an item. */
suspend fun getPersonDetails(personId: String): BaseItem { suspend fun getPersonDetails(personId: String): BaseItem {
@@ -1039,7 +1061,8 @@ class EmbyRepository(private val settings: SettingsStore) {
return items return items
} }
private val ratingsCache = java.util.concurrent.ConcurrentHashMap<String, List<com.ponzischeme89.memby.data.model.MediaRating>>() private val ratingsCache =
boundedCache<List<com.ponzischeme89.memby.data.model.MediaRating>>(RATINGS_CACHE_SIZE)
/** Optional ratings that never block essential metadata. The gateway owns a durable /** 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. */ * 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 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 { private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent {
if (ServerConfig.isGateway) { if (ServerConfig.isGateway) {
val response = requireGateway().related(item.id) val response = requireGateway().related(item.id)
return RelatedContent( return RelatedContent(
reasons = response.reasons.filter(String::isNotBlank), 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") val userId = snapshot.userId ?: error("Not connected")
@@ -1177,7 +1209,7 @@ class EmbyRepository(private val settings: SettingsStore) {
"EnableImageTypes" to "Primary,Backdrop,Logo", "EnableImageTypes" to "Primary,Backdrop,Logo",
"ImageTypeLimit" to "1", "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 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<BaseItem> { private suspend fun loadSeriesEpisodes(seriesId: String): List<BaseItem> {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val loaded = if (ServerConfig.isGateway) { val loaded = (if (ServerConfig.isGateway) {
requireGateway().seriesEpisodes(seriesId).items requireGateway().seriesEpisodes(seriesId).items
} else { } else {
val userId = snapshot.userId ?: error("Not connected") val userId = snapshot.userId ?: error("Not connected")
@@ -1240,7 +1281,7 @@ class EmbyRepository(private val settings: SettingsStore) {
"Limit" to "1000", "Limit" to "1000",
), ),
).items ).items
} }).distinctBy(BaseItem::id)
seriesEpisodesMutex.withLock { seriesEpisodesMutex.withLock {
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes( seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
episodes = loaded, episodes = loaded,
@@ -2473,9 +2514,35 @@ class EmbyRepository(private val settings: SettingsStore) {
* changing it makes every existing install re-fetch its artwork once. * changing it makes every existing install re-fetch its artwork once.
*/ */
const val ARTWORK_QUALITY = 80 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 <V> boundedCache(maxEntries: Int): MutableMap<String, V> =
java.util.Collections.synchronizedMap(
object : LinkedHashMap<String, V>(maxEntries.coerceAtLeast(1), 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, V>?): Boolean =
size > maxEntries
},
)
data class PlaybackSession( data class PlaybackSession(
val itemId: String, val itemId: String,
val mediaSourceId: String, val mediaSourceId: String,
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
private val _theme = MutableStateFlow(GatewayThemeStatus()) private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _installPermissionPrompt = MutableStateFlow(false) private val _installPermissionPrompt = MutableStateFlow(false)
private val _genreBrowserEnabled = MutableStateFlow(false) private val _genreBrowserEnabled = MutableStateFlow(false)
private val _tvCalendarEnabled = MutableStateFlow(false)
private val _gatewayVersion = MutableStateFlow("") private val _gatewayVersion = MutableStateFlow("")
/** /**
@@ -123,10 +124,31 @@ class MaintenanceMonitor(
/** Server-controlled because the browser layout is still being refined. */ /** Server-controlled because the browser layout is still being refined. */
val genreBrowserEnabled: StateFlow<Boolean> = _genreBrowserEnabled.asStateFlow() val genreBrowserEnabled: StateFlow<Boolean> = _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<Boolean> = _tvCalendarEnabled.asStateFlow()
/** Build reported by the connected gateway, for Settings → About. */ /** Build reported by the connected gateway, for Settings → About. */
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow() val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
private val seenAlertIds = mutableSetOf<String>() /**
* 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<String, Boolean>(MAX_TRACKED_ALERT_IDS, 0.75f, false) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Boolean>?): Boolean =
size > MAX_TRACKED_ALERT_IDS
},
)
private var seenAlertsLoaded = false private var seenAlertsLoaded = false
private var shownAlertId: String? = null private var shownAlertId: String? = null
private var alertTimer: Job? = null private var alertTimer: Job? = null
@@ -182,6 +204,7 @@ class MaintenanceMonitor(
_theme.value = GatewayThemeStatus() _theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false _installPermissionPrompt.value = false
_genreBrowserEnabled.value = false _genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
_gatewayVersion.value = "" _gatewayVersion.value = ""
dismissAlert() dismissAlert()
return@collectLatest return@collectLatest
@@ -213,6 +236,7 @@ class MaintenanceMonitor(
_installPermissionPrompt.value = _installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true status.features[INSTALL_PERMISSION_FEATURE] == true
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true _genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
_gatewayVersion.value = status.gatewayVersion _gatewayVersion.value = status.gatewayVersion
// Emby's state is reported even during maintenance: an // Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable // operator taking Memby down while Emby is also unreachable
@@ -236,6 +260,7 @@ class MaintenanceMonitor(
_theme.value = GatewayThemeStatus() _theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false _installPermissionPrompt.value = false
_genreBrowserEnabled.value = false _genreBrowserEnabled.value = false
_tvCalendarEnabled.value = false
_gatewayVersion.value = "" _gatewayVersion.value = ""
dismissAlert() dismissAlert()
return@collectLatest return@collectLatest
@@ -339,6 +364,10 @@ class MaintenanceMonitor(
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */ /** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
internal const val GENRE_BROWSER_FEATURE = "genre_browser" 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 * How often Emby is retried during an outage. It matches the gateway's own
@@ -14,10 +14,12 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
import com.ponzischeme89.memby.data.playback.SurroundCodec import com.ponzischeme89.memby.data.playback.SurroundCodec
import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -401,6 +403,15 @@ data class Settings(
* not seen that build's update notice. * not seen that build's update notice.
*/ */
val whatsNewSeenVersion: String? = null, 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 * 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 * 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 SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids") val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version") val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision") 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() { suspend fun markForYouOpened() {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true preferences[Keys.HAS_OPENED_FOR_YOU] = true
@@ -1439,6 +1481,7 @@ class SettingsStore(private val context: Context) {
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(), themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION], whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles, profiles = profiles,
) )
@@ -434,6 +434,8 @@ data class BaseItem(
@SerialName("MembyAirLabel") val membyAirLabel: String? = null, @SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null, @SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: 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, // 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 // 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 // about the household's copy. The slug is what the badge colours by; the text is the
@@ -757,3 +757,38 @@ data class GatewayPlaybackReport(
data class GatewayPlaybackReportResponse( data class GatewayPlaybackReportResponse(
val autoFollowedShowTitle: String = "", 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<GatewayCalendarDay> = 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<BaseItem> = emptyList(),
)
@@ -94,6 +94,15 @@ interface GatewayApi {
@Query("type") itemType: String, @Query("type") itemType: String,
): com.ponzischeme89.memby.data.model.GatewayGenrePage ): 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") @POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>) suspend fun recordSearch(@Body body: Map<String, String>)
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.playback.deviceAudioCapabilities
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import com.ponzischeme89.memby.data.playback.gatewayAudioTokens import com.ponzischeme89.memby.data.playback.gatewayAudioTokens
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
import com.ponzischeme89.memby.update.RequiredUpdateSignal
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -36,6 +37,7 @@ object GatewayServiceFactory {
// read timeout here only ever means Emby itself is struggling behind it. // read timeout here only ever means Emby itself is struggling behind it.
.readTimeout(20, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS)
.addInterceptor(GatewayAuthInterceptor(tokenProvider)) .addInterceptor(GatewayAuthInterceptor(tokenProvider))
.addInterceptor(RequiredUpdateInterceptor)
.build() .build()
return Retrofit.Builder() return Retrofit.Builder()
@@ -90,6 +92,24 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
}.getOrDefault(emptyList()) }.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 const val MEMBY_PROTOCOL_VERSION = 1
internal val MEMBY_CAPABILITIES = listOf( internal val MEMBY_CAPABILITIES = listOf(
@@ -98,6 +118,9 @@ internal val MEMBY_CAPABILITIES = listOf(
"sonarr_preroll_v1", "sonarr_preroll_v1",
"auto_my_shows_v1", "auto_my_shows_v1",
"genre_browser_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 // 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. // receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1", "install_permission_v1",
@@ -122,6 +122,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH 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.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator 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), SEARCH("Search", Icons.Default.Search),
MOVIES("Movies", Icons.Default.Movie), MOVIES("Movies", Icons.Default.Movie),
SHOWS("TV Shows", Icons.Default.Tv), 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), FAVORITES("Favourites", Icons.Default.Favorite),
PROFILES("Switch user", Icons.Default.Person), PROFILES("Switch user", Icons.Default.Person),
SETTINGS("Settings", Icons.Default.Settings), SETTINGS("Settings", Icons.Default.Settings),
@@ -239,6 +243,7 @@ fun TvNavigationRail(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
alertCount: Int = 0, alertCount: Int = 0,
activeUsername: String = "", activeUsername: String = "",
calendarEnabled: Boolean = false,
) { ) {
var railHasFocus by remember { mutableStateOf(false) } var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) } val logoScale = remember { Animatable(0.72f) }
@@ -343,7 +348,15 @@ fun TvNavigationRail(
} }
} }
Spacer(Modifier.height(16.dp)) 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( ExpandableNavigationItem(
destination = destination, destination = destination,
selected = destination == selected, selected = destination == selected,
@@ -1394,6 +1407,7 @@ internal fun mediaBadges(item: BaseItem): List<String> {
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title) dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
?.let { add(it.uppercase(Locale.US)) } ?.let { add(it.uppercase(Locale.US)) }
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") 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") if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
}.distinct() }.distinct()
} }
@@ -1434,8 +1448,11 @@ internal fun MediaRow(
?: return@LaunchedEffect ?: return@LaunchedEffect
rowState.scrollToItem(requestedEntryIndex!!) rowState.scrollToItem(requestedEntryIndex!!)
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus // LazyRow applies scrollToItem during layout. Wait for the requested card's focus
// node to attach before transferring focus; retrying covers slower TV frames. // node to attach before transferring focus; retrying covers slower TV frames. Six
repeat(3) { // 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) kotlinx.coroutines.delay(16L)
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) { if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
onVerticalFocusRequestConsumed(request.requestId) onVerticalFocusRequestConsumed(request.requestId)
@@ -1605,8 +1622,12 @@ internal fun CastRail(
compact: Boolean = false, compact: Boolean = false,
showTitle: Boolean = true, showTitle: Boolean = true,
) { ) {
val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) } val distinctCast = remember(people) {
if (cast.isEmpty()) return people.filter(EmbyPerson::isCastMember)
.distinctForKeys { "${it.id}:${it.name}" }
.take(16)
}
if (distinctCast.isEmpty()) return
Column(modifier) { Column(modifier) {
if (showTitle) { if (showTitle) {
Text( Text(
@@ -1624,7 +1645,11 @@ internal fun CastRail(
bottom = 4.dp, 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) CastCard(person, compact)
} }
} }
@@ -92,9 +92,13 @@ data class HomeUiState(
// first refresh replaces it with a properly interleaved one. // first refresh replaces it with a properly interleaved one.
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty()) continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
.distinctBy(BaseItem::id), .distinctBy(BaseItem::id),
favorites = cache?.favorites.orEmpty(), favorites = cache?.favorites.orEmpty().distinctItems(),
latestMovies = cache?.latestMovies.orEmpty(), latestMovies = cache?.latestMovies.orEmpty().distinctItems(),
rows = cache?.rows.orEmpty(), // 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 { loading = buildSet {
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE) if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES) 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) } _forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.getForYou(minutes) } runCatching { repository.getForYou(minutes) }
.onSuccess { rows -> .onSuccess { built ->
val rows = built.sanitisedRows()
_forYou.value = ForYouUiState( _forYou.value = ForYouUiState(
rows = rows, rows = rows,
availableMinutes = minutes, availableMinutes = minutes,
@@ -272,13 +277,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
val taggedHome = home.withAiringTodayTags() val taggedHome = home.withAiringTodayTags()
_state.update { current -> _state.update { current ->
current.copy( current.copy(
continueWatching = taggedHome.continueWatching, continueWatching = taggedHome.continueWatching.distinctItems(),
favorites = taggedHome.favorites, favorites = taggedHome.favorites.distinctItems(),
latestMovies = taggedHome.latestMovies, latestMovies = taggedHome.latestMovies.distinctItems(),
// Recommendation rows are built in the background by the gateway, // Recommendation rows are built in the background by the gateway,
// so an early response can arrive without them. Keeping the rows // so an early response can arrive without them. Keeping the rows
// we already had stops the strip flickering out and back in. // 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(), loading = emptySet(),
hasRefreshError = taggedHome.partial, hasRefreshError = taggedHome.partial,
statusMessage = null, statusMessage = null,
@@ -313,12 +318,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { state -> _state.update { state ->
val airingTodayKeys = state.rows.airingTodayShowKeys() val airingTodayKeys = state.rows.airingTodayShowKeys()
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys) 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 -> val fixedRows = state.rows.filterNot { row ->
row.id == "recommended" || row.id == "recommended" ||
row.id.startsWith("similar:") || 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<BaseItem>) -> HomeUiState, updateItems: (HomeUiState, List<BaseItem>) -> HomeUiState,
) { ) {
runCatching { request() } 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 -> _state.update { current ->
updateItems(current, items).let { updateItems(current, items).let {
if (clearLoading) it.copy(loading = it.loading - section) else it if (clearLoading) it.copy(loading = it.loading - section) else it
@@ -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 <T, K> List<T>.distinctForKeys(selector: (T) -> K): List<T> {
if (size < 2) return this
val seen = HashSet<K>(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<BaseItem>.distinctItems(): List<BaseItem> = 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<HomeRow>.sanitisedRows(): List<HomeRow> =
distinctForKeys(HomeRow::id).map { row ->
val items = row.items.distinctItems()
if (items.size == row.items.size) row else row.copy(items = items)
}
@@ -135,6 +135,7 @@ import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub 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.GenreDiscoveryStrip
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
import com.ponzischeme89.memby.ui.player.PlayerActivity 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.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.InstallPermission 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 com.ponzischeme89.memby.update.ServerUpdateService
import androidx.tv.material3.Button import androidx.tv.material3.Button
import androidx.tv.material3.Card import androidx.tv.material3.Card
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch 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_TIMEOUT_MS = 2_500L
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L 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 @Composable
private fun AppRoot(onCloseSettings: () -> Unit) { private fun AppRoot(onCloseSettings: () -> Unit) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
@@ -254,6 +282,23 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) } val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) } var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
var initialUpdateCheckComplete by remember { mutableStateOf(false) } 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<String?>(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<Unit>(Channel.CONFLATED) }
// Held for the length of Memby's opening clip plus its pause, once per process. Not a // 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 // 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 // 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<String?>(null) } var onboardingToken by remember { mutableStateOf<String?>(null) }
LaunchedEffect(updateService) { 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 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) { 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 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() } withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() }
} else { } else {
updateService.check() updateService.check()
@@ -304,12 +365,35 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
appUpdate = decision?.takeUnless { appUpdate = decision?.takeUnless {
it.isOptional && dismissedUpdateVersion == it.version 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) { if (firstCheck) {
initialUpdateCheckComplete = true initialUpdateCheckComplete = true
firstCheck = false 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) { LaunchedEffect(repo) {
@@ -404,6 +488,12 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
Box(Modifier.fillMaxSize().background(MembySurface)) { Box(Modifier.fillMaxSize().background(MembySurface)) {
val loaded = settings 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 // 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 // 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 // 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() introHolding -> loaded?.welcomeQuoteStyle.orEmpty()
!initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty() !initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
appUpdate != null -> null 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() loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
// Ordered exactly as the screens below are: adding a profile outranks waiting // 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 // 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 loaded == null -> Unit
addingProfile -> SetupScreen( addingProfile -> SetupScreen(
onCancel = { addingProfile = false }, onCancel = { addingProfile = false },
@@ -1433,8 +1535,9 @@ private fun OnboardingTitleRow(
previewArtwork: ImageBitmap? = null, previewArtwork: ImageBitmap? = null,
) { ) {
val repo = ServiceLocator.repository val repo = ServiceLocator.repository
val distinctItems = remember(items) { items.distinctItems() }
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) { 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 val selected = ratings[item.id] == 5
FocusScaleContainer( FocusScaleContainer(
onFocused = {}, onFocused = {},
@@ -1477,8 +1580,12 @@ private fun OnboardingPeopleRow(
previewArtwork: ImageBitmap? = null, previewArtwork: ImageBitmap? = null,
) { ) {
val repo = ServiceLocator.repository 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()) { 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 val selected = selectedPeople[person.name] == true
FocusScaleContainer( FocusScaleContainer(
onFocused = {}, onFocused = {},
@@ -1743,6 +1850,7 @@ private fun HomeScreen(
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle() val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle() val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.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 // 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 // so a ticking second never reaches the launcher. This is read here purely to decide
// which of the two top bars gets the strip. // which of the two top bars gets the strip.
@@ -1760,6 +1868,14 @@ private fun HomeScreen(
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
var genreBrowseItemType by remember { mutableStateOf<String?>(null) } var genreBrowseItemType by remember { mutableStateOf<String?>(null) }
var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(null) } var genreBrowseInitialCategoryId by remember { mutableStateOf<String?>(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) { LaunchedEffect(genreBrowserEnabled) {
if (!genreBrowserEnabled) { if (!genreBrowserEnabled) {
genreBrowseItemType = null genreBrowseItemType = null
@@ -1872,6 +1988,16 @@ private fun HomeScreen(
homeViewModel.refreshAll() 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<kotlinx.coroutines.Job?>(null) }
val abandonLaunch: () -> Unit = {
resolveJob?.cancel()
resolveJob = null
resolvingItem = null
launchingItem = null
}
val playItem: (BaseItem) -> Unit = playItem@{ item -> val playItem: (BaseItem) -> Unit = playItem@{ item ->
if (launchingItem != null || !item.membyPlayable) return@playItem if (launchingItem != null || !item.membyPlayable) return@playItem
homeViewModel.trackJourney( homeViewModel.trackJourney(
@@ -1887,9 +2013,23 @@ private fun HomeScreen(
// the activity, the layout and the decoder, none of which needed the answer. A cold // 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 // 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. // behind it, and whether there is one to show is part of the same answer.
// 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) val request = repo.playbackRequest(item)
val ready = repo.readyPlayableForLaunch(request) request to repo.readyPlayableForLaunch(request)
}.getOrElse {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
return@playItem
}
val request = prepared.first
val ready = prepared.second
if (ready == null && request.resumePositionMs > 0L) { if (ready == null && request.resumePositionMs > 0L) {
val launched = runCatching {
playbackLauncher.launch( playbackLauncher.launch(
PlayerActivity.intent( PlayerActivity.intent(
context = context, context = context,
@@ -1898,14 +2038,28 @@ private fun HomeScreen(
requestStartedAtMs = playbackRequestedAtMs, requestStartedAtMs = playbackRequestedAtMs,
), ),
) )
}
if (launched.isFailure) {
Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
launchingItem = null
}
return@playItem return@playItem
} }
// Only the route that waits on the server shows the launcher's own loading screen. // 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. // The route that hands over immediately would only be showing it behind the player.
resolvingItem = item resolvingItem = item
scope.launch { resolveJob = scope.launch {
try { 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 -> .onSuccess { playable ->
playbackLauncher.launch( playbackLauncher.launch(
PlayerActivity.intent( 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, "Couldnt start playback", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Couldnt start playback", Toast.LENGTH_SHORT).show()
// Nothing was launched, so nothing will come back to reopen the gate. // Nothing was launched, so nothing will come back to reopen the gate.
launchingItem = null launchingItem = null
} }
} finally { } finally {
resolvingItem = null resolvingItem = null
resolveJob = null
} }
} }
} }
@@ -2123,6 +2281,7 @@ private fun HomeScreen(
}, },
alertCount = notificationState.notifications.size, alertCount = notificationState.notifications.size,
activeUsername = settings.username.orEmpty(), activeUsername = settings.username.orEmpty(),
calendarEnabled = tvCalendarEnabled,
) )
androidx.compose.foundation.layout.BoxWithConstraints( androidx.compose.foundation.layout.BoxWithConstraints(
modifier = Modifier modifier = Modifier
@@ -2202,6 +2361,46 @@ private fun HomeScreen(
return@BoxWithConstraints 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 -> genreBrowseItemType?.let { itemType ->
GenreBrowseScreen( GenreBrowseScreen(
itemType = itemType, itemType = itemType,
@@ -2273,11 +2472,39 @@ private fun HomeScreen(
} }
var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } 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) { val firstPopulatedRowId = remember(rows) {
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id 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 = 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.SHOWS) 1 else 0) +
if ( if (
selectedDestination == BrowseDestination.FAVORITES && selectedDestination == BrowseDestination.FAVORITES &&
@@ -2540,11 +2767,22 @@ private fun HomeScreen(
) )
rowFocusMoving = true rowFocusMoving = true
scope.launch { scope.launch {
// Compose the destination row before its MediaRow tries try {
// to attach the requested card's focus node. // Compose the destination row before its MediaRow
// tries to attach the requested card's focus node.
verticalState.animateScrollToItem( verticalState.animateScrollToItem(
leadingItemCount + destinationRowIndex, 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 pendingRowFocus = request
} }
true true
@@ -2941,26 +3179,33 @@ private fun HomeScreen(
.onSuccess { notificationState = it } .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 -> onRead = { notification ->
scope.launch {
runCatching { repo.markNotificationRead(notification.id) }.onSuccess {
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.map { notifications = notificationState.notifications.map {
if (it.id == notification.id) it.copy(readAt = "now") else it 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 -> onDismiss = { notification ->
scope.launch { val previous = notificationState
runCatching { repo.dismissNotification(notification.id) }.onSuccess {
notificationState = notificationState.copy( notificationState = notificationState.copy(
notifications = notificationState.notifications.filterNot { notifications = notificationState.notifications.filterNot {
it.id == notification.id it.id == notification.id
}, },
) )
} scope.launch {
runCatching { repo.dismissNotification(notification.id) }
.onFailure { notificationState = previous }
} }
}, },
// The gateway has no bulk route, so this is the same call per alert. The // 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 -> 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( PlaybackLaunchOverlay(
item = item, item = item,
modifier = Modifier.fillMaxSize().zIndex(9f), 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 // Search draws its own pane; the rail destinations that open an overlay have no
// rows of their own either. // rows of their own either.
BrowseDestination.SEARCH -> emptyList() BrowseDestination.SEARCH -> emptyList()
BrowseDestination.CALENDAR -> emptyList()
BrowseDestination.PROFILES -> emptyList() BrowseDestination.PROFILES -> emptyList()
BrowseDestination.SETTINGS -> emptyList() BrowseDestination.SETTINGS -> emptyList()
} }
@@ -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 @Composable
internal fun UpdateButton( internal fun UpdateButton(
label: String, label: String,
@@ -111,16 +111,17 @@ fun MyAlertsPage(
} }
LaunchedEffect(notificationIds) { LaunchedEffect(notificationIds) {
val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect val requestedIndex = pendingFocusIndex ?: return@LaunchedEffect
if (notifications.isEmpty()) { // 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 pendingFocusIndex = null
return@LaunchedEffect if (notifications.isEmpty()) return@LaunchedEffect
}
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size) val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
?: return@LaunchedEffect ?: return@LaunchedEffect
listState.scrollToItem(targetIndex) runCatching { listState.scrollToItem(targetIndex) }
delay(16) delay(16)
runCatching { rowFocusRequesters[targetIndex].requestFocus() } runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() }
pendingFocusIndex = null
} }
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) { Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
@@ -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<BaseItem>,
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),
)
}
}
}
}
@@ -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<BaseItem> = 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<CalendarCell>,
val label: String,
val episodeCount: Int,
)
/** The month payload as television-sized weekly pages. No device date arithmetic. */
fun calendarAgendaWeeks(calendar: GatewayCalendar): List<CalendarAgendaWeek> {
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<CalendarAgendaWeek>, 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<List<CalendarCell>> {
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<CalendarCell>(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()
}
@@ -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,
)
}
@@ -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<List<CalendarCell>> 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<CalendarUiState> = _state.asStateFlow()
private val months = LinkedHashMap<String, GatewayCalendar>()
/**
* 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 <T : ViewModel> create(modelClass: Class<T>): T =
CalendarViewModel(repository) as T
}
@@ -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" 1 -> "Mono"
2 -> "Stereo" 2 -> "Stereo"
6 -> "5.1" 6 -> "5.1"
@@ -19,6 +19,17 @@ data class GenreBrowseUiState(
val categories: List<GenreCategory> = emptyList(), val categories: List<GenreCategory> = emptyList(),
val selectedCategoryId: String? = null, val selectedCategoryId: String? = null,
val items: List<BaseItem> = emptyList(), val items: List<BaseItem> = 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 isLoading: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = false, val canLoadMore: Boolean = false,
@@ -46,6 +57,7 @@ class GenreBrowseViewModel(
it.copy( it.copy(
selectedCategoryId = selected.id, selectedCategoryId = selected.id,
items = cached?.items.orEmpty(), items = cached?.items.orEmpty(),
readOffset = cached?.readOffset ?: 0,
isLoading = cached == null, isLoading = cached == null,
isLoadingMore = false, isLoadingMore = false,
canLoadMore = cached?.canLoadMore ?: false, canLoadMore = cached?.canLoadMore ?: false,
@@ -60,13 +72,13 @@ class GenreBrowseViewModel(
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
_state.update { it.copy(isLoadingMore = true) } _state.update { it.copy(isLoadingMore = true) }
pageJob = viewModelScope.launch { loadPage(category, current.items.size) } pageJob = viewModelScope.launch { loadPage(category, current.readOffset) }
} }
fun retry() { fun retry() {
val current = state.value val current = state.value
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
val offset = current.items.size val offset = current.readOffset
_state.update { _state.update {
it.copy( it.copy(
isLoading = offset == 0, isLoading = offset == 0,
@@ -136,19 +148,21 @@ class GenreBrowseViewModel(
} }
}.onSuccess { page -> }.onSuccess { page ->
_state.update { current -> _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 return@update current
} }
val read = page.offset + page.items.size
val items = (current.items + page.items).distinctBy(BaseItem::id) val items = (current.items + page.items).distinctBy(BaseItem::id)
val canLoadMore = hasMoreGenreItems( val canLoadMore = hasMoreGenreItems(
loaded = items.size, loaded = read,
total = page.total, total = page.total,
lastPageSize = page.items.size, lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE, pageSize = GENRE_PAGE_SIZE,
) )
categoryPages[category.id] = CachedCategoryPage(items, canLoadMore) categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
current.copy( current.copy(
items = items, items = items,
readOffset = read,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
canLoadMore = canLoadMore, canLoadMore = canLoadMore,
@@ -170,6 +184,9 @@ class GenreBrowseViewModel(
private data class CachedCategoryPage( private data class CachedCategoryPage(
val items: List<BaseItem>, val items: List<BaseItem>,
/** See [GenreBrowseUiState.readOffset]: restored with the items, or a category
* returned to would page from the wrong place. */
val readOffset: Int,
val canLoadMore: Boolean, val canLoadMore: Boolean,
) )
@@ -668,6 +668,25 @@ class PlayerActivity : ComponentActivity() {
runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) } runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) }
.onSuccess { playable -> .onSuccess { playable ->
if (isFinishing || isDestroyed) return@onSuccess 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 pendingRequest = null
adoptPlayable(playable) adoptPlayable(playable)
trace.mark(PlaybackTrace.STREAM_RESOLVED) trace.mark(PlaybackTrace.STREAM_RESOLVED)
@@ -1283,6 +1302,22 @@ class PlayerActivity : ComponentActivity() {
) )
}.onSuccess { refreshed -> }.onSuccess { refreshed ->
if (isFinishing || isDestroyed) return@onSuccess 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 itemId = refreshed.itemId
mediaSourceId = refreshed.mediaSourceId mediaSourceId = refreshed.mediaSourceId
playSessionId = refreshed.playSessionId playSessionId = refreshed.playSessionId
@@ -2333,7 +2368,13 @@ class PlayerActivity : ComponentActivity() {
lifecycleScope.launch { lifecycleScope.launch {
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode
if (!enabled) return@launch 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) val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null)
?.takeIf { it.url.isNotBlank() }
resolved?.imageUrl?.let { imageUrl -> resolved?.imageUrl?.let { imageUrl ->
imageLoader.enqueue( imageLoader.enqueue(
ImageRequest.Builder(this@PlayerActivity) ImageRequest.Builder(this@PlayerActivity)
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.distinctItems
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -46,6 +47,16 @@ data class SearchUiState(
val genre: String? = null, val genre: String? = null,
/** A further page is on its way. The grid keeps what it has and adds a footer. */ /** A further page is on its way. The grid keeps what it has and adds a footer. */
val isLoadingMore: Boolean = false, 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]. */ /** There is more of this genre to ask for. See [hasMoreGenreItems]. */
val canLoadMore: Boolean = false, val canLoadMore: Boolean = false,
val suggestions: List<SearchSuggestion> = emptyList(), val suggestions: List<SearchSuggestion> = emptyList(),
@@ -160,7 +171,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
query = "", results = emptyList(), isLoading = false, hasSearched = false, query = "", results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(), errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null, requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false, genre = null, requestMessageIsError = false, genre = null,
@@ -180,7 +192,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( it.copy(
query = "", genre = name, results = emptyList(), isLoading = true, query = "", genre = name, results = emptyList(), genreOffset = 0,
isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false, hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(), canLoadMore = false, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null, requestLookupLoading = false, requestMessage = null,
@@ -198,7 +211,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { _state.update {
it.copy( 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, errorMessage = null, isLoadingMore = false, canLoadMore = false,
) )
} }
@@ -214,7 +228,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
val genre = current.genre ?: return val genre = current.genre ?: return
if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return
_state.update { it.copy(isLoadingMore = true) } _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. */ /** Retry after an error, without disturbing the query or the keyboard. */
@@ -224,7 +238,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
genrePageJob?.cancel() genrePageJob?.cancel()
_state.update { it.copy(isLoading = true, errorMessage = null) } _state.update { it.copy(isLoading = true, errorMessage = null) }
genrePageJob = viewModelScope.launch { genrePageJob = viewModelScope.launch {
loadGenrePage(genre, offset = current.results.size) loadGenrePage(genre, offset = current.genreOffset)
} }
return return
} }
@@ -301,16 +315,26 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) } runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
.onSuccess { page -> .onSuccess { page ->
_state.update { current -> _state.update { current ->
if (current.genre != genre || current.results.size != page.offset) return@update current if (current.genre != genre || current.genreOffset != page.offset) return@update current
val items = current.results + page.items // 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( current.copy(
results = items, results = items,
genreOffset = read,
isLoading = false, isLoading = false,
isLoadingMore = false, isLoadingMore = false,
hasSearched = true, hasSearched = true,
errorMessage = null, errorMessage = null,
canLoadMore = hasMoreGenreItems( canLoadMore = hasMoreGenreItems(
loaded = items.size, loaded = read,
total = page.total, total = page.total,
lastPageSize = page.items.size, lastPageSize = page.items.size,
pageSize = GENRE_PAGE_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. // than left behind a shorter query they no longer match.
_state.update { _state.update {
it.copy( it.copy(
results = emptyList(), isLoading = false, hasSearched = false, results = emptyList(), genreOffset = 0,
isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(), errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null, requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false, requestMessageIsError = false,
@@ -367,7 +392,12 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
// between two letters reads as breakage, not as progress. // between two letters reads as breakage, not as progress.
_state.update { it.copy(isLoading = true, errorMessage = null) } _state.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.search(term) } 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 // Gateway payloads carry the backend ranker's score. Preserve that
// ordering exactly; direct-to-Emby mode keeps the local textual fallback. // ordering exactly; direct-to-Emby mode keeps the local textual fallback.
val ranked = if (items.any { it.membyRecommendationScore != null }) { val ranked = if (items.any { it.membyRecommendationScore != null }) {
@@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester 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.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key 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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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 // 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". // focus group, so one requester names "whatever this page starts with".
val contentFocusRequester = remember { FocusRequester() } 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) { LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0) contentScrollState.scrollTo(0)
@@ -778,6 +789,7 @@ internal fun SettingsPanelContent(
firstFocusRequester = firstFocusRequester, firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester, navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester, contentFocusRequester = contentFocusRequester,
selectionFocusRequester = railSelectionFocusRequester,
compact = overlay, compact = overlay,
modifier = Modifier modifier = Modifier
.width(if (overlay) 190.dp else 224.dp) .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. // page's first control, whatever that page turns out to be.
.focusRequester(contentFocusRequester) .focusRequester(contentFocusRequester)
.focusGroup() .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) .verticalScroll(contentScrollState)
.padding( .padding(
start = if (overlay) 26.dp else 42.dp, start = if (overlay) 26.dp else 42.dp,
@@ -1282,6 +1309,7 @@ private fun SettingsSecondaryRail(
firstFocusRequester: FocusRequester?, firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?, navigationFocusRequester: FocusRequester?,
contentFocusRequester: FocusRequester, contentFocusRequester: FocusRequester,
selectionFocusRequester: FocusRequester,
compact: Boolean, compact: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -1374,6 +1402,17 @@ private fun SettingsSecondaryRail(
}, },
shape = RoundedCornerShape(10.dp), 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]) .focusRequester(railFocusRequesters[index + 1])
.focusProperties { .focusProperties {
up = railFocusRequesters[index] up = railFocusRequesters[index]
@@ -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) }
}
}
}
@@ -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<String>(
replay = 1,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Emits the version the gateway says is required, newest last. */
val required: SharedFlow<String> = _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()
}
}
@@ -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<Int> {
val parts = version.trim().trimStart('v', 'V').split('.', '-', '+', ' ')
val out = mutableListOf<Int>()
for (part in parts) {
val value = part.toIntOrNull() ?: break
out += value
}
return out
}
@@ -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<GatewayCalendarDay> = 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("915 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 },
)
}
}
@@ -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<BaseItem>(), emptyList<BaseItem>().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())
}
}
@@ -18,16 +18,31 @@ class MediaBadgesTest {
videoRangeType = "DOVI", videoRangeType = "DOVI",
title = "Dolby Vision HEVC", title = "Dolby Vision HEVC",
), ),
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"), MediaStream(type = "Audio", title = "TrueHD Dolby Atmos", channels = 8),
), ),
) )
assertEquals( assertEquals(
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"), listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"),
mediaBadges(item), 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. */ /** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
@Test @Test
fun `names HDR10+ rather than collapsing it to HDR`() { fun `names HDR10+ rather than collapsing it to HDR`() {
@@ -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<GatewayCalendarDay>) = 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,
)
}
@@ -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,
)
}
}
}
@@ -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())
}
}
@@ -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"))
}
}
+1
View File
@@ -1,6 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
+1
View File
@@ -152,6 +152,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences)) v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou)) v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll)) 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("GET /v1/my-shows", s.authed(s.handleMyShows))
v1.Handle("POST /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)) v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
+247
View File
@@ -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{}}
}
+149
View File
@@ -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)
}
}
}
+8
View File
@@ -25,6 +25,7 @@ const (
featureSeasonalThemes = "seasonal_themes" featureSeasonalThemes = "seasonal_themes"
featureSeasonalDecorations = "seasonal_decorations" featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser" featureGenreBrowser = "genre_browser"
featureTVCalendar = "tv_calendar"
) )
type featureDefinition struct { type featureDefinition struct {
@@ -124,6 +125,13 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1", DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1",
Recovery: "Takes effect on the next status poll; the browser is hidden when off.", 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", 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 " + Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
+6 -6
View File
@@ -282,11 +282,11 @@ func sonarrPremiereEpisode(
// about refusing. // about refusing.
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) { func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
index := seriesIndex{ index := seriesIndex{
"newshow": "emby-new", "newshow": {ID: "emby-new"},
"returning": "emby-returning", "returning": {ID: "emby-returning"},
"notinlibrary": "", "notinlibrary": {},
"specials": "emby-specials", "specials": {ID: "emby-specials"},
"undownloaded": "emby-undownloaded", "undownloaded": {ID: "emby-undownloaded"},
} }
delete(index, "notinlibrary") 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 // A show that premiered and then returned inside one window is one card about its newer
// season, not two cards about the same show. // season, not two cards about the same show.
func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) { func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) {
index := seriesIndex{"show": "emby-show"} index := seriesIndex{"show": {ID: "emby-show"}}
premieres := sonarrPremieres([]sonarr.Episode{ premieres := sonarrPremieres([]sonarr.Episode{
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true), sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true), sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
+54 -6
View File
@@ -247,6 +247,7 @@ type sonarrScheduleItem struct {
MembyAirLabel string `json:"MembyAirLabel"` MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"` MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"` MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyEpisodeEvent string `json:"MembyEpisodeEvent,omitempty"`
// Sonarr's lifecycle for the *show*, distinct from this episode's availability: one // 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. // says whether more episodes are coming at all, the other whether this one is here yet.
MembyLifecycle string `json:"MembyLifecycle,omitempty"` MembyLifecycle string `json:"MembyLifecycle,omitempty"`
@@ -256,12 +257,19 @@ type sonarrScheduleItem struct {
// the card open the show's own page; a show Sonarr follows but Emby has never imported // 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. // 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 // 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 // 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. // 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 // 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. // 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 // 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. // 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 ref.Year > 0 {
if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen { 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 { if _, seen := index[key]; !seen {
index[key] = ref.ID index[key] = value
} }
} }
return index return index
@@ -306,11 +315,28 @@ func (index seriesIndex) lookup(title string, year int) string {
return "" return ""
} }
if year > 0 { if year > 0 {
if id, ok := index[seriesIndexKey(key, year)]; ok { if ref, ok := index[seriesIndexKey(key, year)]; ok {
return id 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) { func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
@@ -438,6 +464,10 @@ func toSonarrScheduleItem(
MembyPlayable: false, MembyPlayable: false,
} }
item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year) 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) lifecycle := seriesLifecycleTag(episode.Series.Status)
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
if hasCover(episode.Series.Images, "poster") { if hasCover(episode.Series.Images, "poster") {
@@ -483,6 +513,24 @@ func toSonarrScheduleItem(
return item 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 { func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string {
airTime = airTime.In(location) airTime = airTime.In(location)
now = now.In(location) now = now.In(location)
+39 -3
View File
@@ -57,9 +57,9 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location) now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location) air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
index := seriesIndex{ index := seriesIndex{
"northbound": "emby-old", "northbound": {ID: "emby-old"},
"northbound|2024": "emby-2024", "northbound|2024": {ID: "emby-2024", LogoTag: "northbound-logo"},
"harbour": "emby-harbour", "harbour": {ID: "emby-harbour"},
} }
episode := func(title string, year int) sonarr.Episode { episode := func(title string, year int) sonarr.Episode {
utc := air 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) { func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60) location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location) now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
+1 -1
View File
@@ -1 +1 @@
0.1.32 0.1.33
+3
View File
@@ -96,6 +96,9 @@ type Episode struct {
SeriesID int `json:"seriesId"` SeriesID int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"` SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"` 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"` Title string `json:"title"`
Overview string `json:"overview"` Overview string `json:"overview"`
AirDateUTC *time.Time `json:"airDateUtc"` AirDateUTC *time.Time `json:"airDateUtc"`
+4 -2
View File
@@ -282,6 +282,7 @@ type SeriesRef struct {
ID string ID string
Name string Name string
Year int Year int
LogoTag string
} }
// SeriesRefs lists every imported series. The catalogue is a household's, not a // 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. // all rather than querying per title.
func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) { func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) {
rows, err := s.pool.Query(ctx, ` 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 FROM library_items
WHERE type = 'Series'`) WHERE type = 'Series'`)
if err != nil { if err != nil {
@@ -299,7 +301,7 @@ func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) {
out := []SeriesRef{} out := []SeriesRef{}
for rows.Next() { for rows.Next() {
var ref SeriesRef 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 return nil, err
} }
out = append(out, ref) out = append(out, ref)