0.2.48 - Tv calender fixes
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||||
|
launchingItem = null
|
||||||
|
return@playItem
|
||||||
|
}
|
||||||
|
val request = prepared.first
|
||||||
|
val ready = prepared.second
|
||||||
if (ready == null && request.resumePositionMs > 0L) {
|
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, "Couldn’t 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, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Couldn’t 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("9–15 August", weeks[2].label)
|
||||||
|
assertEquals(2, weeks[2].episodeCount)
|
||||||
|
assertEquals(2, calendarAgendaWeekIndex(weeks, "2026-08-12"))
|
||||||
|
assertEquals("2026-08-11", calendarAgendaWeekDate(weeks[2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `another weekly page opens on its first programme`() {
|
||||||
|
val weeks = calendarAgendaWeeks(
|
||||||
|
august(
|
||||||
|
today = "",
|
||||||
|
days = listOf(
|
||||||
|
GatewayCalendarDay(
|
||||||
|
date = "2026-08-18",
|
||||||
|
day = 18,
|
||||||
|
items = listOf(episode("a", "Northbound")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("2026-08-18", calendarAgendaWeekDate(weeks[3]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pad is drawn but never focusable: there is nothing on the 0th of August to be told
|
||||||
|
// about, and a D-pad that stopped there would read as the grid having stuck.
|
||||||
|
@Test
|
||||||
|
fun `pads are never focusable`() {
|
||||||
|
val weeks = calendarWeeks(august())
|
||||||
|
assertFalse(weeks.first().first().isFocusable)
|
||||||
|
assertTrue(weeks.first().last().isFocusable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `days carry their episodes and their date`() {
|
||||||
|
val weeks = calendarWeeks(
|
||||||
|
august(
|
||||||
|
days = listOf(
|
||||||
|
GatewayCalendarDay(
|
||||||
|
date = "2026-08-12",
|
||||||
|
day = 12,
|
||||||
|
items = listOf(episode("a", "Northbound"), episode("b", "Harbour")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val twelfth = weeks.flatten().first { it.day == 12 }
|
||||||
|
assertEquals("2026-08-12", twelfth.date)
|
||||||
|
assertEquals(2, twelfth.items.size)
|
||||||
|
assertTrue(weeks.flatten().first { it.day == 13 }.items.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `today is marked and only today`() {
|
||||||
|
val cells = calendarWeeks(august()).flatten()
|
||||||
|
assertEquals(listOf(11), cells.filter { it.today }.map { it.day })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month the household is not in marks no day`() {
|
||||||
|
val cells = calendarWeeks(august(today = "")).flatten()
|
||||||
|
assertTrue(cells.none { it.today })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a leap February still lays out`() {
|
||||||
|
val weeks = calendarWeeks(
|
||||||
|
GatewayCalendar(available = true, month = "2028-02", dayCount = 29, firstWeekday = 2),
|
||||||
|
)
|
||||||
|
assertEquals(29, weeks.flatten().count { !it.isPad })
|
||||||
|
assertTrue(weeks.all { it.size == CALENDAR_COLUMNS })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unavailable month lays out nothing`() {
|
||||||
|
assertTrue(calendarWeeks(GatewayCalendar()).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Today is what somebody opening a guide is asking about, whether or not anything airs.
|
||||||
|
@Test
|
||||||
|
fun `the page opens on today when the household is in this month`() {
|
||||||
|
assertEquals("2026-08-11", calendarInitialDate(august()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travelling forward has no today to land on, and landing on the 1st of a month whose
|
||||||
|
// news is on the 14th means walking a fortnight of empty cells to find out there was any.
|
||||||
|
@Test
|
||||||
|
fun `another month opens on its first airing`() {
|
||||||
|
val calendar = august(
|
||||||
|
today = "",
|
||||||
|
days = listOf(
|
||||||
|
GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "Northbound"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals("2026-08-14", calendarInitialDate(calendar))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month with nothing on opens on its first day`() {
|
||||||
|
assertEquals("2026-08-01", calendarInitialDate(august(today = "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `counts describe the month`() {
|
||||||
|
val calendar = august(
|
||||||
|
days = listOf(
|
||||||
|
GatewayCalendarDay(date = "2026-08-12", day = 12, items = listOf(episode("a", "A"), episode("b", "B"))),
|
||||||
|
GatewayCalendarDay(date = "2026-08-13", day = 13, items = listOf(episode("c", "C"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(3, calendarEpisodeCount(calendar))
|
||||||
|
assertEquals("3 episodes", calendarEpisodeCountLabel(3))
|
||||||
|
assertEquals("1 episode", calendarEpisodeCountLabel(1))
|
||||||
|
assertEquals("Nothing scheduled", calendarEpisodeCountLabel(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The weekday is counted off the grid's own offset rather than from a date library, for
|
||||||
|
// the same reason the grid is built from firstWeekday at all.
|
||||||
|
@Test
|
||||||
|
fun `the day heading names the weekday`() {
|
||||||
|
assertEquals("Wednesday 12 August", calendarDayHeading(august(), "2026-08-12"))
|
||||||
|
assertEquals("Saturday 1 August", calendarDayHeading(august(), "2026-08-01"))
|
||||||
|
assertEquals("Monday 31 August", calendarDayHeading(august(), "2026-08-31"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a heading is refused for a day outside the month`() {
|
||||||
|
assertEquals("", calendarDayHeading(august(), ""))
|
||||||
|
assertEquals("", calendarDayHeading(august(), "2026-08-45"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every date on this page is compared for equality with every other, so a month the
|
||||||
|
// gateway sent as something unreadable has to produce *nothing* rather than a set of
|
||||||
|
// plausible-looking strings that agree with none of the days the episodes are in.
|
||||||
|
@Test
|
||||||
|
fun `a month that is not a month yields no dates`() {
|
||||||
|
assertEquals("", calendarDate("", 12))
|
||||||
|
assertEquals("", calendarDate("2026", 12))
|
||||||
|
assertEquals("", calendarDate("not-a-month", 12))
|
||||||
|
assertEquals("", calendarDate("20xx-08", 12))
|
||||||
|
assertEquals("", calendarDate("2026/08", 12))
|
||||||
|
assertEquals("2026-08-12", calendarDate("2026-08", 12))
|
||||||
|
assertEquals("2026-08-01", calendarDate("2026-08", 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no cell is dated when the month is unreadable`() {
|
||||||
|
val broken = august().copy(month = "nonsense", today = "")
|
||||||
|
assertTrue(calendarWeeks(broken).flatten().none { it.date.isNotEmpty() })
|
||||||
|
assertEquals("", calendarInitialDate(broken))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selection is by date string, so a blank one has to match nothing at all. Matching the
|
||||||
|
// first day that also failed to produce a date would list one arbitrary day's episodes
|
||||||
|
// under every cell in the grid.
|
||||||
|
@Test
|
||||||
|
fun `a blank selection lists nothing`() {
|
||||||
|
val state = CalendarUiState(
|
||||||
|
calendar = august(
|
||||||
|
today = "",
|
||||||
|
days = listOf(
|
||||||
|
GatewayCalendarDay(date = "2026-08-14", day = 14, items = listOf(episode("a", "A"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
selectedDate = "",
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
assertTrue(state.selectedItems.isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf("a"),
|
||||||
|
state.copy(selectedDate = "2026-08-14").selectedItems.map { it.id },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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{}}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 " +
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
0.1.32
|
0.1.33
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user