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
|
||||
- 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
|
||||
build.
|
||||
|
||||
**A retired build must be told why it was signed out.** The destructive floor
|
||||
(`destructiveUpdateFloor`) deletes the session on the next authenticated request and answers
|
||||
401; a retired build's sign-in is then refused with 426. The television only saw the sign-out
|
||||
— so it drew a sign-in form the gateway would refuse, and the mandatory update screen was up
|
||||
to `UPDATE_CHECK_INTERVAL_MS` (an hour) away, force-closing the app being the only way
|
||||
through, since a fresh launch checks for updates before it draws anything. Both refusals
|
||||
carry `X-Memby-Update-Required`, so `RequiredUpdateInterceptor` on the gateway client
|
||||
publishes it through `update/RequiredUpdateSignal` and `AppRoot`'s check loop waits on
|
||||
*either* the hourly interval or that signal. Things to preserve:
|
||||
|
||||
- **It is an interceptor** because the refusal lands on whichever request happened to be in
|
||||
flight — the status poll, a home refresh, a sign-in — and only one of those has any reason
|
||||
to know about update policy. The signal replays one value, because it is commonly reported
|
||||
before the check loop is waiting on it.
|
||||
- **The refusal is written to disk** (`Settings.requiredUpdateVersion`, via
|
||||
`RequiredUpdateGuard` on the service locator), because it is announced exactly *once*: the
|
||||
401 that deletes the session carries the header and every 401 after it is an ordinary
|
||||
missing session. In-memory only, a television told and then restarted had nothing left to
|
||||
learn it from but the launch check — which is bounded by `UPDATE_CHECK_TIMEOUT_MS` (2.5s)
|
||||
and, on missing it, put the viewer back on the welcome and sign-in screens. It is written
|
||||
by the locator rather than by a screen for the same reason the interceptor exists: nothing
|
||||
that knows about update policy is necessarily composed when the refusal arrives.
|
||||
- **Only the gateway may withdraw it.** A verdict that is not a required update clears the
|
||||
flag; a failed check never does, because an unreachable server is no evidence about which
|
||||
builds it accepts. `requiredUpdateSatisfied` is the one exception and it is pure and
|
||||
tested — the build the refusal demanded is now the build running, which is what the moment
|
||||
after a successful self-update looks like.
|
||||
- **`RetiredBuildScreen` is what the television shows meanwhile**, ahead of sign-in,
|
||||
profiles, the launcher and the null-settings case alike. There is no attempt budget any
|
||||
more: giving up used to hand the viewer a sign-in form as the *final* answer, and every
|
||||
screen underneath this one is something the gateway would refuse. The loop asks every
|
||||
`UPDATE_REQUIRED_RETRY_MS` for `UPDATE_REQUIRED_FAST_ATTEMPTS`, then settles onto
|
||||
`UPDATE_REQUIRED_BACKOFF_MS`, and the screen's Try again wakes it through the same channel
|
||||
a refusal does.
|
||||
- **Every refusal wakes the loop, including a repeat.** The 401 that retires the session and
|
||||
the 426 that refuses the sign-in after it name the same version, and the second is the one
|
||||
a viewer is standing in front of — filtering it as already-handled is what left a
|
||||
television on a form it could not get through until the next hourly check.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore`
|
||||
@@ -1586,6 +1625,24 @@ three large files — new screens generally belong in `ui/<feature>/` rather tha
|
||||
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
|
||||
everything must be reachable by D-pad only.
|
||||
|
||||
**A keyed lazy list must never be handed a repeated key.** `LazyRow`/`LazyColumn` throw on
|
||||
one — *"Key … was already used"* — and every list on these screens is keyed by an id that
|
||||
came off a wire, where nothing promises distinctness. Emby lists the same person twice on a
|
||||
good fraction of a real cast; a "Because you watched" row built from two seeds can reach one
|
||||
title by both; a search that falls back to Emby before the import finishes can return what
|
||||
the library also matched; and a paging boundary is where a backend repeats a card by
|
||||
definition. `ui/ListKeys.kt` is the one rule: **deduplicate, never disambiguate.** Folding
|
||||
the index into the key would also stop the crash, but it keys an item by *where it is*, and
|
||||
position is exactly what changes when a row reorders — which the return-focus and
|
||||
scroll-restoration behaviour throughout this app depends on identity to survive. Apply it
|
||||
where the data enters state (`HomeViewModel.sanitisedRows`, `EmbyRepository.loadRelated` and
|
||||
`loadSeriesEpisodes`, the two genre pagers, `SearchViewModel.runSearch`) rather than in a
|
||||
composable; where a composable is the only place, keep it inside a `remember(list)`. And
|
||||
where a pager deduplicates, **how far it has read is counted in what the backend sent**, not
|
||||
in the length of the list (`SearchUiState.genreOffset`, `GenreBrowseUiState.readOffset`) — a
|
||||
dropped duplicate would otherwise make the next offset point before the end of the last
|
||||
page, and the shelf would stop growing while re-requesting the same page for ever.
|
||||
|
||||
**Animations must not recompose.** This app ships to weak TV boxes, so an animated value
|
||||
read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is
|
||||
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
|
||||
@@ -1775,6 +1832,19 @@ notices — because with the card gone that inset is the only thing holding the
|
||||
together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have
|
||||
seen"), not feature names.
|
||||
|
||||
**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first
|
||||
control, so that press did nothing at all on every page — and a remote that stops responding
|
||||
is not read as a list that has run out. About is where it was reported, its pane being a
|
||||
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
|
||||
`onKeyEvent` on the content column that makes the move the default handler would have made
|
||||
and falls back to the rail only when it fails, so a page's own vertical navigation is
|
||||
untouched: `focusProperties { up = … }` is inherited by every row and would take that
|
||||
navigation away, and `exit` is never consulted when the search finds nothing anywhere, which
|
||||
is the whole case. The rail item for the page being *drawn* carries a second requester for
|
||||
it — the selection rather than the highlight, so a press arriving mid-settle still lands on
|
||||
the page that is on screen. `SettingsRailFocusTest` pins both halves, the escape and the
|
||||
navigation between rows it must not disturb.
|
||||
|
||||
**One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`,
|
||||
`heroFacts`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the card metadata
|
||||
call them rather than carrying private copies. That is why the hero and the card directly
|
||||
@@ -2102,6 +2172,59 @@ availability badge above it answers a different question (has the household's co
|
||||
downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead
|
||||
of everything else on a followed show: nothing else on that card matters as much.
|
||||
|
||||
**The TV calendar is the schedule row's other shape.** The launcher's row answers "what is
|
||||
on this week"; `GET /v1/calendar` (`server/internal/api/calendar.go` → `ui/calendar/`)
|
||||
answers "what is on this month, and when does it come back", which is a question no shelf
|
||||
has a form for — so it is a rail destination with a weekly TV guide: seven days in a rail
|
||||
and the selected day's artwork-led programme list beside it. It reuses
|
||||
`toSonarrScheduleItem`, so a calendar card and a
|
||||
schedule card are the same card, with the same availability badges, lifecycle tag and Emby
|
||||
series link; pressing one makes the same substitution the row does (`scheduleSeriesStub` +
|
||||
`airingNoticeFor`), because an episode that has not aired has no page of its own. Things to
|
||||
preserve:
|
||||
|
||||
- **The television does no calendar arithmetic.** The gateway sends `firstWeekday`,
|
||||
`dayCount` and its own `today`; `calendarWeeks` lays out the month from those alone and
|
||||
`calendarAgendaWeeks` only pages those cells seven at a time. A
|
||||
set working out for itself which years are leap years, in its own zone rather than the
|
||||
household's, would be a second calendar free to disagree with the days the episodes were
|
||||
grouped into — which on the wrong side of midnight it would. The one thing the set does
|
||||
read its own clock for is *when to ask again*: `CalendarViewModel` drops every cached
|
||||
month once the device's local day changes, because everything else about a month is fixed
|
||||
and only `today` goes stale. Being wrong about that by an hour costs one request, where
|
||||
being wrong about the layout would draw a calendar that disagrees with itself.
|
||||
- **A month is claimed by what was asked for, not by what arrives.** Cancelling a coroutine
|
||||
already past its last suspension point does not stop it, and a held D-pad on a month arrow
|
||||
is exactly how two requests come to be in flight — so a response is dropped unless it is
|
||||
still the month `requestedMonth` names. The cache is bounded for the same reason: "held
|
||||
for the life of the page" and "grows while somebody holds the D-pad" are otherwise the
|
||||
same sentence, and a month is a list of episodes with artwork behind it.
|
||||
- **Focus is selection**, the stance the detail page's tab strip takes. A remote has no
|
||||
hover, and a calendar needing a press per day to say what is on it is one nobody reads. A
|
||||
press moves *into* the day panel; Back steps out of the panel before leaving the page.
|
||||
- **Month and week travel are explicit controls.** Left and Right inside the guide already
|
||||
mean moving between the day rail and its programmes, so those keys cannot also change the
|
||||
date range. An arrow at the end of either range is not drawn rather than drawn dead.
|
||||
`calendarMonthRange` (12) is what stops a
|
||||
held D-pad walking Sonarr into the 2050s one request at a time; `parseCalendarMonth` refuses
|
||||
an out-of-range month rather than clamping, or the header would disagree with the grid.
|
||||
- **The day rail summarises; the programme pane explains.** A day names its count and first
|
||||
show only. The pane has the space for Sonarr fanart (or a graphical monogram fallback), an
|
||||
Emby title logo when the series was matched, episode details, availability and a prominent
|
||||
season-premiere/finale label. Finale wording comes from Sonarr's `finaleType`; an absent
|
||||
value makes no claim. `CalendarScreenshotTest` renders a crowded day and the artwork-free
|
||||
fallback because only a screenshot can check that hierarchy at television distance.
|
||||
- **The rail entry is a server feature** (`tv_calendar`, capability `tv_calendar_v1`), because
|
||||
a household running no Sonarr would otherwise carry a destination that only ever opens an
|
||||
apology. A set standing on the page when it is switched off is moved to Home, or it is left
|
||||
somewhere nothing can navigate back to.
|
||||
- **A failed month is an empty month, not an error.** The page is informational, and somebody
|
||||
who pressed Right past a Sonarr hiccup must be able to press Left back out of it.
|
||||
- **There is no second implementation on the direct path.** Unlike subtitles or Continue
|
||||
Watching, the answer is Sonarr's, which a television holds no credential for and Emby knows
|
||||
nothing about — with no gateway there is genuinely no calendar, and the rail says so by
|
||||
omitting the entry.
|
||||
|
||||
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
|
||||
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
|
||||
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
|
||||
|
||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.45"
|
||||
val defaultVersionName = "0.2.48"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
|
||||
import com.ponzischeme89.memby.data.SettingsStore
|
||||
import com.ponzischeme89.memby.data.ThemeSync
|
||||
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateGuard
|
||||
|
||||
/**
|
||||
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
|
||||
@@ -41,12 +42,22 @@ object ServiceLocator {
|
||||
lateinit var themeSync: ThemeSync
|
||||
private set
|
||||
|
||||
/**
|
||||
* Held for the same reason [preferencesSync] is: it is a collector, and the refusal it
|
||||
* writes down can arrive on any request at any moment of a session.
|
||||
*/
|
||||
lateinit var requiredUpdateGuard: RequiredUpdateGuard
|
||||
private set
|
||||
|
||||
fun init(context: Context) {
|
||||
if (::repository.isInitialized) return
|
||||
// Only hands the probe an application context; it does no work until the first
|
||||
// request or playback negotiation asks what this television's receiver accepts.
|
||||
installAudioCapabilityProbe(context)
|
||||
settings = SettingsStore(context.applicationContext)
|
||||
// Started before the repository, so a refusal answering the very first request a
|
||||
// television makes has somewhere to be recorded.
|
||||
requiredUpdateGuard = RequiredUpdateGuard(settings)
|
||||
repository = EmbyRepository(settings)
|
||||
maintenance = MaintenanceMonitor(repository, settings)
|
||||
// Takes the revision channel from the status poll rather than polling itself: the
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
@@ -826,6 +827,23 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* One month of the TV calendar, or an unavailable month when there is nobody to ask.
|
||||
*
|
||||
* This is deliberately gateway-only and has no second implementation on the direct
|
||||
* path, unlike the subtitle and Continue Watching rules: the answer comes from Sonarr,
|
||||
* which a television holds no credential for and Emby knows nothing about. With no
|
||||
* gateway there is genuinely no calendar, and saying so is the honest degradation —
|
||||
* the rail entry is hidden in that case anyway.
|
||||
*
|
||||
* A blank [month] asks for the household's present month. The television never derives
|
||||
* one from its own clock, so the header can never disagree with the grid beneath it.
|
||||
*/
|
||||
suspend fun getCalendarMonth(month: String = ""): GatewayCalendar {
|
||||
if (!ServerConfig.isGateway) return GatewayCalendar()
|
||||
return requireGateway().calendar(month.trim().takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
return requireGateway().requestLookup(term.trim()).candidates
|
||||
@@ -987,8 +1005,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
)
|
||||
}
|
||||
|
||||
private val personCache = java.util.concurrent.ConcurrentHashMap<String, BaseItem>()
|
||||
private val filmographyCache = java.util.concurrent.ConcurrentHashMap<String, List<BaseItem>>()
|
||||
// Bounded, access-ordered and synchronised rather than a plain concurrent map. These
|
||||
// are process-lifetime caches on a device that is commonly left on for weeks, and an
|
||||
// unbounded one holds an entry for every person, filmography and title the household
|
||||
// has ever put focus on — which on a large library is the library.
|
||||
private val personCache = boundedCache<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. */
|
||||
suspend fun getPersonDetails(personId: String): BaseItem {
|
||||
@@ -1039,7 +1061,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return items
|
||||
}
|
||||
|
||||
private val ratingsCache = java.util.concurrent.ConcurrentHashMap<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
|
||||
* cache; this process cache makes repeated focus/detail visits free on the TV. */
|
||||
@@ -1156,12 +1179,21 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* The carousel under a detail page, from whichever backend there is.
|
||||
*
|
||||
* Both paths drop the subject itself and then deduplicate. The gateway's answer is
|
||||
* assembled from Emby's similarity list with the imported catalogue's genre
|
||||
* neighbours behind it, and the two can name the same title; the carousel is a keyed
|
||||
* LazyRow, where a repeat takes the detail page down rather than drawing a poster
|
||||
* twice.
|
||||
*/
|
||||
private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent {
|
||||
if (ServerConfig.isGateway) {
|
||||
val response = requireGateway().related(item.id)
|
||||
return RelatedContent(
|
||||
reasons = response.reasons.filter(String::isNotBlank),
|
||||
items = response.items.filter { it.id != item.id },
|
||||
items = response.items.filter { it.id != item.id }.distinctBy(BaseItem::id),
|
||||
)
|
||||
}
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
@@ -1177,7 +1209,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
"EnableImageTypes" to "Primary,Backdrop,Logo",
|
||||
"ImageTypeLimit" to "1",
|
||||
),
|
||||
).items.filter { it.id != item.id },
|
||||
).items.filter { it.id != item.id }.distinctBy(BaseItem::id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1222,9 +1254,18 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return request
|
||||
}
|
||||
|
||||
/**
|
||||
* Every episode of a series, deduplicated on the way in.
|
||||
*
|
||||
* The episode list is rendered as a keyed lazy list on the series page and again on an
|
||||
* episode's own page, so one id arriving twice — a file imported under two paths, a
|
||||
* gateway that concatenated a paged read — is a crash on both rather than a repeated
|
||||
* row. It is also the list the season scroller and the pace estimate are derived from,
|
||||
* and a duplicate would quietly overstate both.
|
||||
*/
|
||||
private suspend fun loadSeriesEpisodes(seriesId: String): List<BaseItem> {
|
||||
val now = System.currentTimeMillis()
|
||||
val loaded = if (ServerConfig.isGateway) {
|
||||
val loaded = (if (ServerConfig.isGateway) {
|
||||
requireGateway().seriesEpisodes(seriesId).items
|
||||
} else {
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
@@ -1240,7 +1281,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
"Limit" to "1000",
|
||||
),
|
||||
).items
|
||||
}
|
||||
}).distinctBy(BaseItem::id)
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
|
||||
episodes = loaded,
|
||||
@@ -2473,9 +2514,35 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
* changing it makes every existing install re-fetch its artwork once.
|
||||
*/
|
||||
const val ARTWORK_QUALITY = 80
|
||||
|
||||
/**
|
||||
* Bounds on the three process-lifetime caches that hold whatever the household has
|
||||
* browsed. Generous enough that a normal evening never evicts anything, and small
|
||||
* enough that a television left on for a month does not end up holding a copy of
|
||||
* the catalogue. Ratings has the largest because it is filled by merely putting
|
||||
* focus on a card, which is every card on the launcher.
|
||||
*/
|
||||
const val PERSON_CACHE_SIZE = 64
|
||||
const val FILMOGRAPHY_CACHE_SIZE = 32
|
||||
const val RATINGS_CACHE_SIZE = 512
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A bounded, access-ordered, thread-safe cache.
|
||||
*
|
||||
* The `LinkedHashMap` eviction pattern the repository already uses for playables, related
|
||||
* content and episodes, wrapped so the maps that were plain `ConcurrentHashMap`s can keep
|
||||
* their `get`/`put` call sites.
|
||||
*/
|
||||
private fun <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(
|
||||
val itemId: String,
|
||||
val mediaSourceId: String,
|
||||
|
||||
@@ -92,6 +92,7 @@ class MaintenanceMonitor(
|
||||
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
||||
private val _genreBrowserEnabled = MutableStateFlow(false)
|
||||
private val _tvCalendarEnabled = MutableStateFlow(false)
|
||||
private val _gatewayVersion = MutableStateFlow("")
|
||||
|
||||
/**
|
||||
@@ -123,10 +124,31 @@ class MaintenanceMonitor(
|
||||
/** Server-controlled because the browser layout is still being refined. */
|
||||
val genreBrowserEnabled: StateFlow<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. */
|
||||
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 shownAlertId: String? = null
|
||||
private var alertTimer: Job? = null
|
||||
@@ -182,6 +204,7 @@ class MaintenanceMonitor(
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
_gatewayVersion.value = ""
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
@@ -213,6 +236,7 @@ class MaintenanceMonitor(
|
||||
_installPermissionPrompt.value =
|
||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||
_genreBrowserEnabled.value = status.features[GENRE_BROWSER_FEATURE] == true
|
||||
_tvCalendarEnabled.value = status.features[TV_CALENDAR_FEATURE] == true
|
||||
_gatewayVersion.value = status.gatewayVersion
|
||||
// Emby's state is reported even during maintenance: an
|
||||
// operator taking Memby down while Emby is also unreachable
|
||||
@@ -236,6 +260,7 @@ class MaintenanceMonitor(
|
||||
_theme.value = GatewayThemeStatus()
|
||||
_installPermissionPrompt.value = false
|
||||
_genreBrowserEnabled.value = false
|
||||
_tvCalendarEnabled.value = false
|
||||
_gatewayVersion.value = ""
|
||||
dismissAlert()
|
||||
return@collectLatest
|
||||
@@ -339,6 +364,10 @@ class MaintenanceMonitor(
|
||||
|
||||
/** Matches `featureGenreBrowser` in the gateway's feature catalogue. */
|
||||
internal const val GENRE_BROWSER_FEATURE = "genre_browser"
|
||||
internal const val TV_CALENDAR_FEATURE = "tv_calendar"
|
||||
|
||||
/** Insertion-ordered ceiling on the in-memory seen set. See [seenAlertIds]. */
|
||||
private const val MAX_TRACKED_ALERT_IDS = 200
|
||||
|
||||
/**
|
||||
* How often Emby is retried during an outage. It matches the gateway's own
|
||||
|
||||
@@ -14,10 +14,12 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughMode
|
||||
import com.ponzischeme89.memby.data.playback.AudioPassthroughPreference
|
||||
import com.ponzischeme89.memby.data.playback.SurroundCodec
|
||||
import com.ponzischeme89.memby.data.playback.SurroundCodec.Companion.asSlugs
|
||||
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -401,6 +403,15 @@ data class Settings(
|
||||
* not seen that build's update notice.
|
||||
*/
|
||||
val whatsNewSeenVersion: String? = null,
|
||||
/**
|
||||
* The version the gateway last refused this build over, or null while it has said
|
||||
* nothing. Device state, and deliberately outlives both the session and the process:
|
||||
* the refusal is announced exactly once — the 401 that deletes the session carries the
|
||||
* header, every 401 after it is an ordinary missing session — so a television that was
|
||||
* told and then restarted has nothing left to learn it from but this. It is what stops
|
||||
* the launcher drawing a sign-in form the gateway is going to refuse.
|
||||
*/
|
||||
val requiredUpdateVersion: String? = null,
|
||||
/**
|
||||
* Revision of the active profile's settings as last agreed with the gateway. Zero
|
||||
* means this TV has never synced them, which is what tells [PreferencesSync] to push
|
||||
@@ -554,6 +565,7 @@ class SettingsStore(private val context: Context) {
|
||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
||||
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
|
||||
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
|
||||
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
|
||||
}
|
||||
|
||||
@@ -1039,6 +1051,36 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that the gateway has refused this build, so the refusal survives the process
|
||||
* it arrived in. Ignored once this television is already running the version being
|
||||
* demanded: the flag is about the APK, and one that has since been replaced is not the
|
||||
* one that was retired.
|
||||
*/
|
||||
suspend fun markRequiredUpdate(
|
||||
version: String,
|
||||
installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
) {
|
||||
val trimmed = version.trim()
|
||||
if (trimmed.isEmpty() || requiredUpdateSatisfied(installedVersion, trimmed)) return
|
||||
if (current?.requiredUpdateVersion == trimmed) return
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.REQUIRED_UPDATE_VERSION] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the refusal, once the gateway has answered with something that is not a
|
||||
* required update. Only the gateway's own verdict may do this — a failed check is the
|
||||
* server being unreachable, which is no evidence at all about which builds it accepts.
|
||||
*/
|
||||
suspend fun clearRequiredUpdate() {
|
||||
if (current?.requiredUpdateVersion == null) return
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences.remove(Keys.REQUIRED_UPDATE_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markForYouOpened() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HAS_OPENED_FOR_YOU] = true
|
||||
@@ -1439,6 +1481,7 @@ class SettingsStore(private val context: Context) {
|
||||
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
|
||||
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
|
||||
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
|
||||
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
||||
profiles = profiles,
|
||||
)
|
||||
|
||||
@@ -434,6 +434,8 @@ data class BaseItem(
|
||||
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||
// Sonarr-authored schedule milestone: Season premiere, Season finale or Series finale.
|
||||
@SerialName("MembyEpisodeEvent") val membyEpisodeEvent: String? = null,
|
||||
// Sonarr's or Radarr's own lifecycle for the title — whether more episodes are coming,
|
||||
// whether the film has actually been released. Distinct from availability, which is
|
||||
// about the household's copy. The slug is what the badge colours by; the text is the
|
||||
|
||||
@@ -757,3 +757,38 @@ data class GatewayPlaybackReport(
|
||||
data class GatewayPlaybackReportResponse(
|
||||
val autoFollowedShowTitle: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* One month of the Sonarr calendar, as `GET /v1/calendar` answers it.
|
||||
*
|
||||
* The grid's shape ([firstWeekday], [dayCount]) is the gateway's answer rather than the
|
||||
* television's arithmetic: the household's time zone is the server's, and a set working out
|
||||
* for itself which years are leap years is a second calendar that can disagree with the one
|
||||
* the cards were grouped by.
|
||||
*
|
||||
* [available] is false when the household runs no Sonarr, or the operator has the feature
|
||||
* off, and defaults that way — a missing field must never draw an empty month that looks
|
||||
* like a fortnight in which nothing airs.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayCalendar(
|
||||
val available: Boolean = false,
|
||||
val month: String = "",
|
||||
val label: String = "",
|
||||
val previous: String = "",
|
||||
val next: String = "",
|
||||
/** The household's today, only when it falls inside this month. */
|
||||
val today: String = "",
|
||||
/** Sunday is 0, matching the weekday header the grid draws. */
|
||||
val firstWeekday: Int = 0,
|
||||
val dayCount: Int = 0,
|
||||
val days: List<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,
|
||||
): com.ponzischeme89.memby.data.model.GatewayGenrePage
|
||||
|
||||
/**
|
||||
* One month of the TV calendar. An absent [month] is the household's present month,
|
||||
* which is what a television asks for when the page opens.
|
||||
*/
|
||||
@GET("v1/calendar")
|
||||
suspend fun calendar(
|
||||
@Query("month") month: String? = null,
|
||||
): com.ponzischeme89.memby.data.model.GatewayCalendar
|
||||
|
||||
@POST("v1/search/history")
|
||||
suspend fun recordSearch(@Body body: Map<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.gatewayAudioTokens
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateSignal
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -36,6 +37,7 @@ object GatewayServiceFactory {
|
||||
// read timeout here only ever means Emby itself is struggling behind it.
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.addInterceptor(GatewayAuthInterceptor(tokenProvider))
|
||||
.addInterceptor(RequiredUpdateInterceptor)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
@@ -90,6 +92,24 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches every gateway response for the header that says this build has been retired.
|
||||
*
|
||||
* It is an interceptor rather than a check at the call sites because the refusal arrives
|
||||
* on whichever request happened to be in flight — the status poll, a home refresh, a
|
||||
* sign-in — and only one of those has any reason to know about update policy. Reading the
|
||||
* header here is also what makes the 401 legible: on its own it is indistinguishable from
|
||||
* an expired session, which is why the app used to answer it with a sign-in form the
|
||||
* gateway would then refuse.
|
||||
*/
|
||||
private object RequiredUpdateInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val response = chain.proceed(chain.request())
|
||||
response.header(RequiredUpdateSignal.HEADER)?.let(RequiredUpdateSignal::report)
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
internal const val MEMBY_PROTOCOL_VERSION = 1
|
||||
|
||||
internal val MEMBY_CAPABILITIES = listOf(
|
||||
@@ -98,6 +118,9 @@ internal val MEMBY_CAPABILITIES = listOf(
|
||||
"sonarr_preroll_v1",
|
||||
"auto_my_shows_v1",
|
||||
"genre_browser_v1",
|
||||
// Declares that this build has the TV calendar destination, so the operator cannot
|
||||
// push a rail entry to a television that has nowhere to send it.
|
||||
"tv_calendar_v1",
|
||||
// Declares that this build can show the install-permission step. An older app never
|
||||
// receives the feature, so the operator cannot push a screen it does not have.
|
||||
"install_permission_v1",
|
||||
|
||||
@@ -122,6 +122,7 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
|
||||
import com.ponzischeme89.memby.ui.detail.channelLabel
|
||||
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
@@ -167,6 +168,9 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
||||
SEARCH("Search", Icons.Default.Search),
|
||||
MOVIES("Movies", Icons.Default.Movie),
|
||||
SHOWS("TV Shows", Icons.Default.Tv),
|
||||
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
||||
FAVORITES("Favourites", Icons.Default.Favorite),
|
||||
PROFILES("Switch user", Icons.Default.Person),
|
||||
SETTINGS("Settings", Icons.Default.Settings),
|
||||
@@ -239,6 +243,7 @@ fun TvNavigationRail(
|
||||
modifier: Modifier = Modifier,
|
||||
alertCount: Int = 0,
|
||||
activeUsername: String = "",
|
||||
calendarEnabled: Boolean = false,
|
||||
) {
|
||||
var railHasFocus by remember { mutableStateOf(false) }
|
||||
val logoScale = remember { Animatable(0.72f) }
|
||||
@@ -343,7 +348,15 @@ fun TvNavigationRail(
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
BrowseDestination.entries.forEach { destination ->
|
||||
// A destination with nothing behind it is worse than one fewer: the calendar
|
||||
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
||||
// carry a rail item that only ever opens an apology.
|
||||
val destinations = remember(calendarEnabled) {
|
||||
BrowseDestination.entries.filter {
|
||||
it != BrowseDestination.CALENDAR || calendarEnabled
|
||||
}
|
||||
}
|
||||
destinations.forEach { destination ->
|
||||
ExpandableNavigationItem(
|
||||
destination = destination,
|
||||
selected = destination == selected,
|
||||
@@ -1394,6 +1407,7 @@ internal fun mediaBadges(item: BaseItem): List<String> {
|
||||
dynamicRangeLabel(video?.videoRange, video?.videoRangeType, video?.title)
|
||||
?.let { add(it.uppercase(Locale.US)) }
|
||||
if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC")
|
||||
audio?.channels?.takeIf { it > 0 }?.let { add(channelLabel(it).uppercase(Locale.US)) }
|
||||
if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS")
|
||||
}.distinct()
|
||||
}
|
||||
@@ -1434,8 +1448,11 @@ internal fun MediaRow(
|
||||
?: return@LaunchedEffect
|
||||
rowState.scrollToItem(requestedEntryIndex!!)
|
||||
// LazyRow applies scrollToItem during layout. Wait for the requested card's focus
|
||||
// node to attach before transferring focus; retrying covers slower TV frames.
|
||||
repeat(3) {
|
||||
// node to attach before transferring focus; retrying covers slower TV frames. Six
|
||||
// attempts rather than three because giving up here is a press that visibly does
|
||||
// nothing, and the shelf this arrives at is commonly one being composed for the
|
||||
// first time — the frame budget on a Chromecast is not the one a warm row gets.
|
||||
repeat(6) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (runCatching { verticalEntryFocusRequester.requestFocus() }.isSuccess) {
|
||||
onVerticalFocusRequestConsumed(request.requestId)
|
||||
@@ -1605,8 +1622,12 @@ internal fun CastRail(
|
||||
compact: Boolean = false,
|
||||
showTitle: Boolean = true,
|
||||
) {
|
||||
val cast = remember(people) { people.filter(EmbyPerson::isCastMember).take(16) }
|
||||
if (cast.isEmpty()) return
|
||||
val distinctCast = remember(people) {
|
||||
people.filter(EmbyPerson::isCastMember)
|
||||
.distinctForKeys { "${it.id}:${it.name}" }
|
||||
.take(16)
|
||||
}
|
||||
if (distinctCast.isEmpty()) return
|
||||
Column(modifier) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
@@ -1624,7 +1645,11 @@ internal fun CastRail(
|
||||
bottom = 4.dp,
|
||||
),
|
||||
) {
|
||||
items(cast, key = { "${it.id}:${it.name}" }) { person ->
|
||||
// Emby lists the same person twice often enough that this is not a theoretical
|
||||
// guard: a title where one actor is credited under two roles, or where an
|
||||
// agent has written the cast twice, would otherwise hand this LazyRow two
|
||||
// items under one key and take the whole detail page down with it.
|
||||
items(distinctCast, key = { "${it.id}:${it.name}" }) { person ->
|
||||
CastCard(person, compact)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,13 @@ data class HomeUiState(
|
||||
// first refresh replaces it with a properly interleaved one.
|
||||
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
|
||||
.distinctBy(BaseItem::id),
|
||||
favorites = cache?.favorites.orEmpty(),
|
||||
latestMovies = cache?.latestMovies.orEmpty(),
|
||||
rows = cache?.rows.orEmpty(),
|
||||
favorites = cache?.favorites.orEmpty().distinctItems(),
|
||||
latestMovies = cache?.latestMovies.orEmpty().distinctItems(),
|
||||
// A cache is the previous build's output as much as this one's, so it is
|
||||
// sanitised on the way in for the same reason a response is — see
|
||||
// [sanitisedRows]. This one is drawn before any request has been made, which
|
||||
// makes it the copy a duplicate would crash the app on every single launch.
|
||||
rows = cache?.rows.orEmpty().sanitisedRows(),
|
||||
loading = buildSet {
|
||||
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
|
||||
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
|
||||
@@ -222,7 +226,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_forYou.update { it.copy(availableMinutes = minutes, loading = true, error = null) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
runCatching { repository.getForYou(minutes) }
|
||||
.onSuccess { rows ->
|
||||
.onSuccess { built ->
|
||||
val rows = built.sanitisedRows()
|
||||
_forYou.value = ForYouUiState(
|
||||
rows = rows,
|
||||
availableMinutes = minutes,
|
||||
@@ -272,13 +277,13 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val taggedHome = home.withAiringTodayTags()
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
continueWatching = taggedHome.continueWatching,
|
||||
favorites = taggedHome.favorites,
|
||||
latestMovies = taggedHome.latestMovies,
|
||||
continueWatching = taggedHome.continueWatching.distinctItems(),
|
||||
favorites = taggedHome.favorites.distinctItems(),
|
||||
latestMovies = taggedHome.latestMovies.distinctItems(),
|
||||
// Recommendation rows are built in the background by the gateway,
|
||||
// so an early response can arrive without them. Keeping the rows
|
||||
// we already had stops the strip flickering out and back in.
|
||||
rows = taggedHome.rows.ifEmpty { current.rows },
|
||||
rows = taggedHome.rows.sanitisedRows().ifEmpty { current.rows },
|
||||
loading = emptySet(),
|
||||
hasRefreshError = taggedHome.partial,
|
||||
statusMessage = null,
|
||||
@@ -313,12 +318,19 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update { state ->
|
||||
val airingTodayKeys = state.rows.airingTodayShowKeys()
|
||||
val taggedFresh = fresh.withAiringTodayRowTags(airingTodayKeys)
|
||||
// The three id shapes are what the recommendation build is *expected* to
|
||||
// replace. Anything else it returns under an id the launcher already holds is
|
||||
// still a replacement — and, left alone, would be a second LazyColumn item
|
||||
// under one key, which is a crash rather than a duplicate row. So the arriving
|
||||
// set wins by id, and the concatenation is sanitised regardless.
|
||||
val freshIds = taggedFresh.mapTo(mutableSetOf(), HomeRow::id)
|
||||
val fixedRows = state.rows.filterNot { row ->
|
||||
row.id == "recommended" ||
|
||||
row.id.startsWith("similar:") ||
|
||||
row.id.startsWith("curated:")
|
||||
row.id.startsWith("curated:") ||
|
||||
row.id in freshIds
|
||||
}
|
||||
state.copy(rows = fixedRows + taggedFresh)
|
||||
state.copy(rows = (fixedRows + taggedFresh).sanitisedRows())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +556,11 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
updateItems: (HomeUiState, List<BaseItem>) -> HomeUiState,
|
||||
) {
|
||||
runCatching { request() }
|
||||
.onSuccess { items ->
|
||||
.onSuccess { rawItems ->
|
||||
// The direct path's own fan-out gets the same treatment the gateway's
|
||||
// batch response does: whichever backend answered, two cards under one id
|
||||
// take the row's LazyRow down with them.
|
||||
val items = rawItems.distinctItems()
|
||||
_state.update { current ->
|
||||
updateItems(current, items).let {
|
||||
if (clearLoading) it.copy(loading = it.loading - section) else it
|
||||
|
||||
@@ -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.airingNoticeFor
|
||||
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
|
||||
import com.ponzischeme89.memby.ui.calendar.CalendarScreen
|
||||
import com.ponzischeme89.memby.ui.genre.GenreDiscoveryStrip
|
||||
import com.ponzischeme89.memby.ui.genre.GenreBrowseScreen
|
||||
import com.ponzischeme89.memby.ui.player.PlayerActivity
|
||||
@@ -163,11 +164,14 @@ import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||
import com.ponzischeme89.memby.ui.setup.SignInContent
|
||||
import com.ponzischeme89.memby.update.InstallPermission
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateSignal
|
||||
import com.ponzischeme89.memby.update.requiredUpdateSatisfied
|
||||
import com.ponzischeme89.memby.update.ServerUpdateService
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -245,6 +249,30 @@ private const val ONBOARDING_CHECK_TIMEOUT_MS = 2_500L
|
||||
private const val UPDATE_CHECK_TIMEOUT_MS = 2_500L
|
||||
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
|
||||
|
||||
/**
|
||||
* How long to wait before asking again while the gateway has this build retired.
|
||||
*
|
||||
* There is no attempt budget any more, and that is the point: a television in this state
|
||||
* has been signed out and every screen it could otherwise be shown is one the gateway will
|
||||
* refuse, so giving up used to mean handing the viewer a sign-in form as the *final*
|
||||
* answer. It asks quickly at first, in case the verdict is merely a moment behind the
|
||||
* refusal, then settles onto [UPDATE_REQUIRED_BACKOFF_MS] so an unreachable gateway is not
|
||||
* polled every few seconds for as long as the set is switched on.
|
||||
*/
|
||||
private const val UPDATE_REQUIRED_RETRY_MS = 5_000L
|
||||
private const val UPDATE_REQUIRED_BACKOFF_MS = 60_000L
|
||||
private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* How long one row-to-row focus move may hold the D-pad before the launcher assumes
|
||||
* nothing is going to answer for it.
|
||||
*
|
||||
* Comfortably longer than the scroll it waits on plus the destination row's own focus
|
||||
* retries, so an ordinary move is never cut short — and short enough that a move nothing
|
||||
* completes costs a press rather than the rest of the session's vertical navigation.
|
||||
*/
|
||||
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
val repo = ServiceLocator.repository
|
||||
@@ -254,6 +282,23 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
|
||||
var appUpdate by remember { mutableStateOf<GatewayUpdate?>(null) }
|
||||
var initialUpdateCheckComplete by remember { mutableStateOf(false) }
|
||||
// The refusal as it arrives, before the write of it has come back round through the
|
||||
// settings flow. Both halves are needed: this one closes the window in which the
|
||||
// sign-out has already landed and the persisted flag has not, and the persisted one
|
||||
// (below) is what remembers the refusal across a restart.
|
||||
var reportedRequiredUpdate by remember { mutableStateOf<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
|
||||
// rememberSaveable: an activity recreated behind the viewer (returning from the TV home
|
||||
// screen, a configuration change) is not an app launch, and LaunchIntro carries that
|
||||
@@ -292,10 +337,26 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
}
|
||||
var onboardingToken by remember { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(updateService) {
|
||||
// Every refusal wakes the loop, including a repeat of a version already handled:
|
||||
// the 401 that retires the session and the 426 that refuses the sign-in after it
|
||||
// name the same version, and the second is the one a viewer is standing in front
|
||||
// of. Filtering those out is what left a television on a form it could not get
|
||||
// through until the next hourly check.
|
||||
launch { RequiredUpdateSignal.required.collect { updateWake.trySend(Unit) } }
|
||||
var firstCheck = true
|
||||
// How many checks in a row have been made because this build is retired. Loop
|
||||
// state rather than app state: nothing outside this effect decides when to ask.
|
||||
var requiredAttempts = 0
|
||||
while (true) {
|
||||
val result = if (firstCheck) {
|
||||
// What the gateway has already said about this build, read from disk rather
|
||||
// than from this loop's memory — the refusal is commonly one process old.
|
||||
val retired = ServiceLocator.settings.current
|
||||
?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
|
||||
?: reportedRequiredUpdate
|
||||
val result = if (firstCheck && retired == null) {
|
||||
// A disconnected server must not strand an otherwise usable TV at boot.
|
||||
// A retired one is not usable, so it waits for the real answer instead of
|
||||
// being hurried past it onto a sign-in form.
|
||||
withTimeoutOrNull(UPDATE_CHECK_TIMEOUT_MS) { updateService.check() }
|
||||
} else {
|
||||
updateService.check()
|
||||
@@ -304,12 +365,35 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
appUpdate = decision?.takeUnless {
|
||||
it.isOptional && dismissedUpdateVersion == it.version
|
||||
}
|
||||
// Only the gateway may withdraw a refusal, and this is it answering. A
|
||||
// verdict that does not require an update is the evidence that whatever
|
||||
// retired this build no longer applies — a failed check is not, which is
|
||||
// why nothing outside this branch clears the flag.
|
||||
if (decision?.isMandatory != true) {
|
||||
reportedRequiredUpdate = null
|
||||
RequiredUpdateSignal.clear()
|
||||
ServiceLocator.settings.clearRequiredUpdate()
|
||||
}
|
||||
}
|
||||
if (firstCheck) {
|
||||
initialUpdateCheckComplete = true
|
||||
firstCheck = false
|
||||
}
|
||||
kotlinx.coroutines.delay(UPDATE_CHECK_INTERVAL_MS)
|
||||
requiredAttempts = if (retired != null && appUpdate == null) {
|
||||
requiredAttempts + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
// Whichever comes first: the next scheduled check, or something asking for one
|
||||
// now. While this build is retired and no verdict has arrived, that schedule is
|
||||
// seconds rather than an hour — the alternative is a television sitting on a
|
||||
// screen it cannot leave, waiting on an answer nobody has asked for.
|
||||
val wait = when {
|
||||
requiredAttempts == 0 -> UPDATE_CHECK_INTERVAL_MS
|
||||
requiredAttempts <= UPDATE_REQUIRED_FAST_ATTEMPTS -> UPDATE_REQUIRED_RETRY_MS
|
||||
else -> UPDATE_REQUIRED_BACKOFF_MS
|
||||
}
|
||||
withTimeoutOrNull(wait) { updateWake.receive() }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(repo) {
|
||||
@@ -404,6 +488,12 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
val loaded = settings
|
||||
// The gateway has refused this build. Until it answers with a verdict — or with a
|
||||
// verdict that no longer requires one — this is the only screen the television may
|
||||
// show: its session is gone, and sign-in, profiles and the launcher are all things
|
||||
// the server will refuse.
|
||||
val retiredVersion = loaded?.requiredUpdateVersion?.takeIf { it.isNotBlank() }
|
||||
?: reportedRequiredUpdate
|
||||
// The three states below that mean "still opening" used to be three separate calls
|
||||
// to MembyLoadingScreen, and Compose identifies a composable by where it is called
|
||||
// from — so passing between them disposed the screen and built it again. That was
|
||||
@@ -419,6 +509,11 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
introHolding -> loaded?.welcomeQuoteStyle.orEmpty()
|
||||
!initialUpdateCheckComplete -> loaded?.welcomeQuoteStyle.orEmpty()
|
||||
appUpdate != null -> null
|
||||
// Retired, verdict not here yet. The retired-build screen takes the frame
|
||||
// rather than the opening screen: it says what has happened and offers the one
|
||||
// thing there is to do about it, where a loading screen with nothing behind it
|
||||
// reads as a television that has stopped working.
|
||||
retiredVersion != null -> null
|
||||
loaded == null -> ServiceLocator.settings.current?.welcomeQuoteStyle.orEmpty()
|
||||
// Ordered exactly as the screens below are: adding a profile outranks waiting
|
||||
// on the onboarding answer, and swapping the two would put the loading screen
|
||||
@@ -449,6 +544,13 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
}
|
||||
},
|
||||
)
|
||||
// Ahead of every remaining screen, and of the null-settings case: a television
|
||||
// whose build has been retired must not reach sign-in, profiles or the
|
||||
// launcher, whatever else is true of it.
|
||||
retiredVersion != null -> RetiredBuildScreen(
|
||||
requiredVersion = retiredVersion,
|
||||
onRetry = { updateWake.trySend(Unit) },
|
||||
)
|
||||
loaded == null -> Unit
|
||||
addingProfile -> SetupScreen(
|
||||
onCancel = { addingProfile = false },
|
||||
@@ -1433,8 +1535,9 @@ private fun OnboardingTitleRow(
|
||||
previewArtwork: ImageBitmap? = null,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val distinctItems = remember(items) { items.distinctItems() }
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
itemsIndexed(items, key = { _, item -> item.id }) { index, item ->
|
||||
itemsIndexed(distinctItems, key = { _, item -> item.id }) { index, item ->
|
||||
val selected = ratings[item.id] == 5
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
@@ -1477,8 +1580,12 @@ private fun OnboardingPeopleRow(
|
||||
previewArtwork: ImageBitmap? = null,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
// Keyed by name because that is also what the selection map is keyed by — which means
|
||||
// two people sharing one would be one selection *and* a duplicate LazyRow key, and the
|
||||
// second of those is a crash on the first screen a new television ever draws.
|
||||
val distinctPeople = remember(people) { people.distinctForKeys(RecommendationPerson::name) }
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(18.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
itemsIndexed(people, key = { _, person -> person.name }) { index, person ->
|
||||
itemsIndexed(distinctPeople, key = { _, person -> person.name }) { index, person ->
|
||||
val selected = selectedPeople[person.name] == true
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
@@ -1743,6 +1850,7 @@ private fun HomeScreen(
|
||||
val liveMaintenance by ServiceLocator.maintenance.notice.collectAsStateWithLifecycle()
|
||||
val compatibilityNotice by ServiceLocator.maintenance.compatibility.collectAsStateWithLifecycle()
|
||||
val genreBrowserEnabled by ServiceLocator.maintenance.genreBrowserEnabled.collectAsStateWithLifecycle()
|
||||
val tvCalendarEnabled by ServiceLocator.maintenance.tvCalendarEnabled.collectAsStateWithLifecycle()
|
||||
// Only whether there is one, not its countdown — that is collected inside the banner
|
||||
// so a ticking second never reaches the launcher. This is read here purely to decide
|
||||
// which of the two top bars gets the strip.
|
||||
@@ -1760,6 +1868,14 @@ private fun HomeScreen(
|
||||
var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) }
|
||||
var genreBrowseItemType by remember { mutableStateOf<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) {
|
||||
if (!genreBrowserEnabled) {
|
||||
genreBrowseItemType = null
|
||||
@@ -1872,6 +1988,16 @@ private fun HomeScreen(
|
||||
homeViewModel.refreshAll()
|
||||
}
|
||||
}
|
||||
// The job resolving a stream, so the overlay it raises can be called off. Without it,
|
||||
// a Play press against a server that has stopped answering holds a full-screen loading
|
||||
// overlay for the length of the HTTP timeout with no way to leave it.
|
||||
var resolveJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
|
||||
val abandonLaunch: () -> Unit = {
|
||||
resolveJob?.cancel()
|
||||
resolveJob = null
|
||||
resolvingItem = null
|
||||
launchingItem = null
|
||||
}
|
||||
val playItem: (BaseItem) -> Unit = playItem@{ item ->
|
||||
if (launchingItem != null || !item.membyPlayable) return@playItem
|
||||
homeViewModel.trackJourney(
|
||||
@@ -1887,9 +2013,23 @@ private fun HomeScreen(
|
||||
// the activity, the layout and the decoder, none of which needed the answer. A cold
|
||||
// start still resolves first — the pre-roll it opens with needs a stream to run
|
||||
// behind it, and whether there is one to show is part of the same answer.
|
||||
// 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 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) {
|
||||
val launched = runCatching {
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.intent(
|
||||
context = context,
|
||||
@@ -1898,14 +2038,28 @@ private fun HomeScreen(
|
||||
requestStartedAtMs = playbackRequestedAtMs,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (launched.isFailure) {
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
launchingItem = null
|
||||
}
|
||||
return@playItem
|
||||
}
|
||||
// Only the route that waits on the server shows the launcher's own loading screen.
|
||||
// The route that hands over immediately would only be showing it behind the player.
|
||||
resolvingItem = item
|
||||
scope.launch {
|
||||
resolveJob = scope.launch {
|
||||
try {
|
||||
runCatching { ready ?: repo.resolvePlayableForLaunch(item) }
|
||||
runCatching {
|
||||
val playable = ready ?: repo.resolvePlayableForLaunch(item)
|
||||
// A resolution that came back without a stream is a failure with a
|
||||
// success's shape. Handed on, PlayerActivity finds no URL and no
|
||||
// request to resolve one from, closes itself in onCreate, and the
|
||||
// viewer sees the screen flicker and nothing else — the one playback
|
||||
// failure that arrives with no explanation at all.
|
||||
check(playable.url.isNotBlank()) { "resolved playable has no stream" }
|
||||
playable
|
||||
}
|
||||
.onSuccess { playable ->
|
||||
playbackLauncher.launch(
|
||||
PlayerActivity.intent(
|
||||
@@ -1935,13 +2089,17 @@ private fun HomeScreen(
|
||||
),
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
.onFailure { error ->
|
||||
// A cancellation is the viewer having pressed Back out of the
|
||||
// wait, which has already reopened the gate and said so on screen.
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
Toast.makeText(context, "Couldn’t start playback", Toast.LENGTH_SHORT).show()
|
||||
// Nothing was launched, so nothing will come back to reopen the gate.
|
||||
launchingItem = null
|
||||
}
|
||||
} finally {
|
||||
resolvingItem = null
|
||||
resolveJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2123,6 +2281,7 @@ private fun HomeScreen(
|
||||
},
|
||||
alertCount = notificationState.notifications.size,
|
||||
activeUsername = settings.username.orEmpty(),
|
||||
calendarEnabled = tvCalendarEnabled,
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
@@ -2202,6 +2361,46 @@ private fun HomeScreen(
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
if (selectedDestination == BrowseDestination.CALENDAR) {
|
||||
// Its own pane rather than a row list: a month grid needs the whole
|
||||
// content area, and there is no shelf shape that answers "what is on
|
||||
// in three weeks". The rail stays beside it as it does for Search.
|
||||
CalendarScreen(
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
onItemFocused = homeViewModel::focusItem,
|
||||
onItemSelected = { item ->
|
||||
homeViewModel.trackJourney(
|
||||
category = "content", action = "open", screen = "calendar",
|
||||
feature = "tv_calendar", source = "calendar_day",
|
||||
target = "details", itemId = item.id, itemType = item.type,
|
||||
)
|
||||
// The same substitution the schedule row makes: a calendar card
|
||||
// is an episode that has not aired, so what was asked for is the
|
||||
// show, carrying the air time across because that is why it was
|
||||
// pressed.
|
||||
val seriesStub = scheduleSeriesStub(item)
|
||||
if (seriesStub != null) {
|
||||
detailsAiringNotice = airingNoticeFor(item)
|
||||
homeViewModel.focusItem(seriesStub)
|
||||
detailsItem = seriesStub
|
||||
} else if (item.membyPlayable) {
|
||||
detailsAiringNotice = null
|
||||
homeViewModel.focusItem(item)
|
||||
detailsItem = item
|
||||
}
|
||||
},
|
||||
onExit = {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
scope.launch {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
},
|
||||
)
|
||||
return@BoxWithConstraints
|
||||
}
|
||||
|
||||
genreBrowseItemType?.let { itemType ->
|
||||
GenreBrowseScreen(
|
||||
itemType = itemType,
|
||||
@@ -2273,11 +2472,39 @@ private fun HomeScreen(
|
||||
}
|
||||
var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
|
||||
var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) }
|
||||
// The latch above is what stops a held D-pad stacking one move on top of
|
||||
// another, and until now the only thing that lifted it was the destination
|
||||
// row reporting the request consumed. A request nothing consumes therefore
|
||||
// took every later Up and Down press with it — the row list stopped moving
|
||||
// where it stood and the only way out was the rail. Nothing may consume it
|
||||
// when the destination row was not composed by the scroll, when that scroll
|
||||
// was interrupted by another one, or when the row is replaced by an arriving
|
||||
// refresh while the request is in flight. So the latch is bounded rather
|
||||
// than trusted: whatever happened, one press can hold vertical navigation
|
||||
// for ROW_FOCUS_MOVE_TIMEOUT_MS and no longer.
|
||||
LaunchedEffect(pendingRowFocus?.requestId, rowFocusMoving) {
|
||||
if (!rowFocusMoving) return@LaunchedEffect
|
||||
kotlinx.coroutines.delay(ROW_FOCUS_MOVE_TIMEOUT_MS)
|
||||
pendingRowFocus = null
|
||||
rowFocusMoving = false
|
||||
}
|
||||
val firstPopulatedRowId = remember(rows) {
|
||||
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
|
||||
}
|
||||
// Must agree exactly with the items placed above the rows in the LazyColumn
|
||||
// below, because it is what turns a row index into a scroll target. The
|
||||
// genre strip is only one of them while the gateway has that feature on —
|
||||
// counting it regardless scrolled a row short of the destination on Movies
|
||||
// and TV Shows, which lands the requested card outside the composed window
|
||||
// and leaves the move with nothing to complete it.
|
||||
val leadingItemCount =
|
||||
(if (selectedDestination == BrowseDestination.MOVIES || selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||
(
|
||||
if (
|
||||
genreBrowserEnabled &&
|
||||
(selectedDestination == BrowseDestination.MOVIES ||
|
||||
selectedDestination == BrowseDestination.SHOWS)
|
||||
) 1 else 0
|
||||
) +
|
||||
(if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
|
||||
if (
|
||||
selectedDestination == BrowseDestination.FAVORITES &&
|
||||
@@ -2540,11 +2767,22 @@ private fun HomeScreen(
|
||||
)
|
||||
rowFocusMoving = true
|
||||
scope.launch {
|
||||
// Compose the destination row before its MediaRow tries
|
||||
// to attach the requested card's focus node.
|
||||
try {
|
||||
// Compose the destination row before its MediaRow
|
||||
// tries to attach the requested card's focus node.
|
||||
verticalState.animateScrollToItem(
|
||||
leadingItemCount + destinationRowIndex,
|
||||
)
|
||||
} catch (cancelled: kotlinx.coroutines.CancellationException) {
|
||||
// Another scroll took the list over mid-animation, so
|
||||
// this move is never going to post its request and
|
||||
// nothing downstream would ever lift the latch. Ask
|
||||
// for the card anyway — the row it is in is where the
|
||||
// list has been left, and the worst case is the
|
||||
// request expiring like any other.
|
||||
pendingRowFocus = request
|
||||
throw cancelled
|
||||
}
|
||||
pendingRowFocus = request
|
||||
}
|
||||
true
|
||||
@@ -2941,26 +3179,33 @@ private fun HomeScreen(
|
||||
.onSuccess { notificationState = it }
|
||||
}
|
||||
},
|
||||
// Marked read locally first. This fires on *focus*, so on a slow
|
||||
// connection walking down the list and back up would send the same row's
|
||||
// request once per pass — clearing the flag immediately is what makes the
|
||||
// row stop asking.
|
||||
onRead = { notification ->
|
||||
scope.launch {
|
||||
runCatching { repo.markNotificationRead(notification.id) }.onSuccess {
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.map {
|
||||
if (it.id == notification.id) it.copy(readAt = "now") else it
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
scope.launch {
|
||||
runCatching { repo.dismissNotification(notification.id) }.onSuccess {
|
||||
val previous = notificationState
|
||||
notificationState = notificationState.copy(
|
||||
notifications = notificationState.notifications.filterNot {
|
||||
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
|
||||
@@ -3078,6 +3323,12 @@ private fun HomeScreen(
|
||||
)
|
||||
}
|
||||
resolvingItem?.let { item ->
|
||||
// Back gets out of the wait. This overlay covers the whole screen while the
|
||||
// gateway is asked for a stream, and against a server that has stopped
|
||||
// answering that is the length of an HTTP timeout — long enough that a viewer
|
||||
// reaches for the remote, and until now long enough that nothing answered
|
||||
// them. Cancelling is safe: nothing has been launched and nothing reported.
|
||||
BackHandler(onBack = abandonLaunch)
|
||||
PlaybackLaunchOverlay(
|
||||
item = item,
|
||||
modifier = Modifier.fillMaxSize().zIndex(9f),
|
||||
@@ -3688,6 +3939,7 @@ internal fun homeRowsFor(
|
||||
// Search draws its own pane; the rail destinations that open an overlay have no
|
||||
// rows of their own either.
|
||||
BrowseDestination.SEARCH -> emptyList()
|
||||
BrowseDestination.CALENDAR -> emptyList()
|
||||
BrowseDestination.PROFILES -> emptyList()
|
||||
BrowseDestination.SETTINGS -> emptyList()
|
||||
}
|
||||
|
||||
@@ -324,6 +324,74 @@ fun UpdateScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the gateway has retired this build but has not yet handed back the verdict
|
||||
* that carries the download.
|
||||
*
|
||||
* It is not a loading screen and not an error. The session is already gone, so the screens
|
||||
* underneath it — sign-in, the profile list, the launcher — are all things the server would
|
||||
* refuse, and a viewer looking at one of those has been told nothing about why nothing
|
||||
* works. The wording therefore states the situation and the only action there is, and Back
|
||||
* is swallowed for the same reason a mandatory update swallows it: there is nowhere else to
|
||||
* go.
|
||||
*
|
||||
* It is expected to be brief — the check loop asks again within seconds — so there is
|
||||
* deliberately no spinner and no failure count: a screen that reported every unsuccessful
|
||||
* attempt would be a stream of bad news about something the viewer cannot influence.
|
||||
*/
|
||||
@Composable
|
||||
fun RetiredBuildScreen(
|
||||
requiredVersion: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
BackHandler(enabled = true) { }
|
||||
|
||||
val retryFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { runCatching { retryFocus.requestFocus() } }
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize().background(MembySurface),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 60.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
"This TV needs a newer Memby",
|
||||
color = UpdateTitle,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
"Memby ${requiredVersion.trim()} is required before this TV can sign in " +
|
||||
"again. Fetching the update now — this only takes a moment.",
|
||||
color = UpdateBody,
|
||||
fontSize = 17.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.widthIn(max = 640.dp),
|
||||
)
|
||||
Spacer(Modifier.height(30.dp))
|
||||
UpdateButton(
|
||||
label = "Try again",
|
||||
primary = true,
|
||||
enabled = true,
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.focusRequester(retryFocus),
|
||||
)
|
||||
Spacer(Modifier.height(22.dp))
|
||||
Text(
|
||||
"Stuck? Ask whoever set up Memby for you.",
|
||||
color = UpdateFaint.copy(alpha = 0.75f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UpdateButton(
|
||||
label: String,
|
||||
|
||||
@@ -111,16 +111,17 @@ fun MyAlertsPage(
|
||||
}
|
||||
LaunchedEffect(notificationIds) {
|
||||
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
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (notifications.isEmpty()) return@LaunchedEffect
|
||||
val targetIndex = alertFocusIndexAfterRemoval(requestedIndex, notifications.size)
|
||||
?: return@LaunchedEffect
|
||||
listState.scrollToItem(targetIndex)
|
||||
runCatching { listState.scrollToItem(targetIndex) }
|
||||
delay(16)
|
||||
runCatching { rowFocusRequesters[targetIndex].requestFocus() }
|
||||
pendingFocusIndex = null
|
||||
runCatching { rowFocusRequesters.getOrNull(targetIndex)?.requestFocus() }
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
|
||||
|
||||
@@ -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"
|
||||
2 -> "Stereo"
|
||||
6 -> "5.1"
|
||||
|
||||
@@ -19,6 +19,17 @@ data class GenreBrowseUiState(
|
||||
val categories: List<GenreCategory> = emptyList(),
|
||||
val selectedCategoryId: String? = null,
|
||||
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 isLoadingMore: Boolean = false,
|
||||
val canLoadMore: Boolean = false,
|
||||
@@ -46,6 +57,7 @@ class GenreBrowseViewModel(
|
||||
it.copy(
|
||||
selectedCategoryId = selected.id,
|
||||
items = cached?.items.orEmpty(),
|
||||
readOffset = cached?.readOffset ?: 0,
|
||||
isLoading = cached == null,
|
||||
isLoadingMore = false,
|
||||
canLoadMore = cached?.canLoadMore ?: false,
|
||||
@@ -60,13 +72,13 @@ class GenreBrowseViewModel(
|
||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
||||
if (!current.canLoadMore || current.isLoading || current.isLoadingMore) return
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, current.items.size) }
|
||||
pageJob = viewModelScope.launch { loadPage(category, current.readOffset) }
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
val current = state.value
|
||||
val category = categories.firstOrNull { it.id == current.selectedCategoryId } ?: return
|
||||
val offset = current.items.size
|
||||
val offset = current.readOffset
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = offset == 0,
|
||||
@@ -136,19 +148,21 @@ class GenreBrowseViewModel(
|
||||
}
|
||||
}.onSuccess { page ->
|
||||
_state.update { current ->
|
||||
if (current.selectedCategoryId != category.id || current.items.size != page.offset) {
|
||||
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
||||
return@update current
|
||||
}
|
||||
val read = page.offset + page.items.size
|
||||
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
||||
val canLoadMore = hasMoreGenreItems(
|
||||
loaded = items.size,
|
||||
loaded = read,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
)
|
||||
categoryPages[category.id] = CachedCategoryPage(items, canLoadMore)
|
||||
categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
|
||||
current.copy(
|
||||
items = items,
|
||||
readOffset = read,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
canLoadMore = canLoadMore,
|
||||
@@ -170,6 +184,9 @@ class GenreBrowseViewModel(
|
||||
|
||||
private data class CachedCategoryPage(
|
||||
val items: List<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,
|
||||
)
|
||||
|
||||
|
||||
@@ -668,6 +668,25 @@ class PlayerActivity : ComponentActivity() {
|
||||
runCatching { ServiceLocator.repository.resolvePlayableForLaunch(request) }
|
||||
.onSuccess { playable ->
|
||||
if (isFinishing || isDestroyed) return@onSuccess
|
||||
// A resolution with no stream in it is a failure wearing a success's
|
||||
// shape: handed to the player it becomes an empty URI, and what the
|
||||
// viewer is told is whatever media3 makes of that rather than that the
|
||||
// server could not answer. The request is deliberately left in place,
|
||||
// so Retry asks again instead of re-preparing nothing.
|
||||
if (playable.url.isBlank()) {
|
||||
Log.e(
|
||||
PLAYBACK_LOG_TAG,
|
||||
"event=stream_resolve_empty item=${request.itemId}",
|
||||
)
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = getString(R.string.playback_server_unreachable),
|
||||
detail = getString(R.string.playback_server_unreachable_detail),
|
||||
canAutoRetry = false,
|
||||
),
|
||||
)
|
||||
return@onSuccess
|
||||
}
|
||||
pendingRequest = null
|
||||
adoptPlayable(playable)
|
||||
trace.mark(PlaybackTrace.STREAM_RESOLVED)
|
||||
@@ -1283,6 +1302,22 @@ class PlayerActivity : ComponentActivity() {
|
||||
)
|
||||
}.onSuccess { refreshed ->
|
||||
if (isFinishing || isDestroyed) return@onSuccess
|
||||
// Same rule as the launch path: a refresh that came back without a stream
|
||||
// is reported as the server being unreachable rather than re-prepared as
|
||||
// an empty URI, which would fail as a source error and be retried on the
|
||||
// automatic budget — several rounds of "Reconnecting…" for a server that
|
||||
// is answering perfectly well and has nothing to give.
|
||||
if (refreshed.url.isBlank()) {
|
||||
Log.e(PLAYBACK_LOG_TAG, "event=stream_refresh_empty item=$id")
|
||||
showPlaybackError(
|
||||
PlaybackFailure(
|
||||
title = getString(R.string.playback_server_unreachable),
|
||||
detail = getString(R.string.playback_server_unreachable_detail),
|
||||
canAutoRetry = false,
|
||||
),
|
||||
)
|
||||
return@onSuccess
|
||||
}
|
||||
itemId = refreshed.itemId
|
||||
mediaSourceId = refreshed.mediaSourceId
|
||||
playSessionId = refreshed.playSessionId
|
||||
@@ -2333,7 +2368,13 @@ class PlayerActivity : ComponentActivity() {
|
||||
lifecycleScope.launch {
|
||||
val enabled = ServiceLocator.repository.settingsFlow.first().autoPlayNextEpisode
|
||||
if (!enabled) return@launch
|
||||
// A next episode with no stream behind it is not a next episode. Kept as one
|
||||
// it would put up a banner and a countdown promising something that cannot be
|
||||
// played, and then — because the countdown runs itself out — swap it in
|
||||
// automatically and fail, with a viewer who pressed nothing having their
|
||||
// programme replaced by an error.
|
||||
val resolved = ServiceLocator.repository.nextEpisode(id, seriesId = null)
|
||||
?.takeIf { it.url.isNotBlank() }
|
||||
resolved?.imageUrl?.let { imageUrl ->
|
||||
imageLoader.enqueue(
|
||||
ImageRequest.Builder(this@PlayerActivity)
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.hasMoreGenreItems
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.distinctItems
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -46,6 +47,16 @@ data class SearchUiState(
|
||||
val genre: String? = null,
|
||||
/** A further page is on its way. The grid keeps what it has and adds a footer. */
|
||||
val isLoadingMore: Boolean = false,
|
||||
/**
|
||||
* How far into the genre the shelf has read, counted the way the *backend* counts it.
|
||||
*
|
||||
* Deliberately not `results.size`. A duplicate arriving across a page boundary is
|
||||
* dropped on the way in — the grid is keyed by item id, so a repeat would be a crash
|
||||
* rather than a doubled poster — and if the offset were then derived from the length
|
||||
* of the list, the next page would be requested from before where the last one ended
|
||||
* and would return the same cards again, for ever.
|
||||
*/
|
||||
val genreOffset: Int = 0,
|
||||
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */
|
||||
val canLoadMore: Boolean = false,
|
||||
val suggestions: List<SearchSuggestion> = emptyList(),
|
||||
@@ -160,7 +171,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", results = emptyList(), isLoading = false, hasSearched = false,
|
||||
query = "", results = emptyList(), genreOffset = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
requestMessageIsError = false, genre = null,
|
||||
@@ -180,7 +192,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", genre = name, results = emptyList(), isLoading = true,
|
||||
query = "", genre = name, results = emptyList(), genreOffset = 0,
|
||||
isLoading = true,
|
||||
hasSearched = false, errorMessage = null, isLoadingMore = false,
|
||||
canLoadMore = false, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
@@ -198,7 +211,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update {
|
||||
it.copy(
|
||||
genre = null, results = emptyList(), isLoading = false, hasSearched = false,
|
||||
genre = null, results = emptyList(), genreOffset = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null, isLoadingMore = false, canLoadMore = false,
|
||||
)
|
||||
}
|
||||
@@ -214,7 +228,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val genre = current.genre ?: return
|
||||
if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) }
|
||||
genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.genreOffset) }
|
||||
}
|
||||
|
||||
/** Retry after an error, without disturbing the query or the keyboard. */
|
||||
@@ -224,7 +238,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
genrePageJob?.cancel()
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
genrePageJob = viewModelScope.launch {
|
||||
loadGenrePage(genre, offset = current.results.size)
|
||||
loadGenrePage(genre, offset = current.genreOffset)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -301,16 +315,26 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
|
||||
.onSuccess { page ->
|
||||
_state.update { current ->
|
||||
if (current.genre != genre || current.results.size != page.offset) return@update current
|
||||
val items = current.results + page.items
|
||||
if (current.genre != genre || current.genreOffset != page.offset) return@update current
|
||||
// A paging boundary is the one place a backend realistically repeats a
|
||||
// card: the shelf is ordered by premiere date and sort name precisely
|
||||
// because two titles sharing a date could otherwise swap places between
|
||||
// two requests. If one slips through anyway, the grid is keyed by item
|
||||
// id and a repeat is a crash, not a duplicate poster — so the page is
|
||||
// appended minus anything already on the shelf, and how far the shelf
|
||||
// has read is counted in what the backend sent rather than in what
|
||||
// survived. See [SearchUiState.genreOffset].
|
||||
val read = page.offset + page.items.size
|
||||
val items = (current.results + page.items).distinctItems()
|
||||
current.copy(
|
||||
results = items,
|
||||
genreOffset = read,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
hasSearched = true,
|
||||
errorMessage = null,
|
||||
canLoadMore = hasMoreGenreItems(
|
||||
loaded = items.size,
|
||||
loaded = read,
|
||||
total = page.total,
|
||||
lastPageSize = page.items.size,
|
||||
pageSize = GENRE_PAGE_SIZE,
|
||||
@@ -346,7 +370,8 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(
|
||||
results = emptyList(), isLoading = false, hasSearched = false,
|
||||
results = emptyList(), genreOffset = 0,
|
||||
isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
requestMessageIsError = false,
|
||||
@@ -367,7 +392,12 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// between two letters reads as breakage, not as progress.
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
runCatching { repository.search(term) }
|
||||
.onSuccess { items ->
|
||||
.onSuccess { found ->
|
||||
// The gateway answers from the imported library and falls back to Emby
|
||||
// before the first import has finished, so one title reaching the pane by
|
||||
// both routes is a shape this search genuinely has. The grid is keyed by
|
||||
// item id, where that is a crash rather than a repeated poster.
|
||||
val items = found.distinctItems()
|
||||
// Gateway payloads carry the backend ranker's score. Preserve that
|
||||
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
|
||||
val ranked = if (items.any { it.membyRecommendationScore != null }) {
|
||||
|
||||
@@ -59,6 +59,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
@@ -71,9 +72,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -753,6 +756,14 @@ internal fun SettingsPanelContent(
|
||||
// nothing, which is what "stuck on that page" looks like from the sofa. The pane is a
|
||||
// focus group, so one requester names "whatever this page starts with".
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
// And the way back out of it. The pane's first control had nothing above it on any
|
||||
// page, so Up there did nothing at all — which from the sofa is a screen that has
|
||||
// stopped responding rather than a list that has run out. About is where it bites,
|
||||
// because its pane is a changelog long enough that walking back up is the ordinary
|
||||
// way to leave it. Left already returns to the page list; this makes Up say the same
|
||||
// thing once the pane has no row above.
|
||||
val railSelectionFocusRequester = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
LaunchedEffect(state.selectedPage) {
|
||||
contentScrollState.scrollTo(0)
|
||||
@@ -778,6 +789,7 @@ internal fun SettingsPanelContent(
|
||||
firstFocusRequester = firstFocusRequester,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
selectionFocusRequester = railSelectionFocusRequester,
|
||||
compact = overlay,
|
||||
modifier = Modifier
|
||||
.width(if (overlay) 190.dp else 224.dp)
|
||||
@@ -792,6 +804,21 @@ internal fun SettingsPanelContent(
|
||||
// page's first control, whatever that page turns out to be.
|
||||
.focusRequester(contentFocusRequester)
|
||||
.focusGroup()
|
||||
// Deliberately not focusProperties { up = … }, which every row in the
|
||||
// pane inherits and which would take a page's own vertical navigation
|
||||
// away from it; and not `exit`, which is never consulted when the search
|
||||
// finds nothing anywhere, and finding nothing is the whole case. So the
|
||||
// move the default handler would have made is made here first, and the
|
||||
// rail is reached only when it fails — which is the top control and
|
||||
// nothing else.
|
||||
.onKeyEvent { event ->
|
||||
when {
|
||||
event.type != KeyEventType.KeyDown -> false
|
||||
event.key != Key.DirectionUp -> false
|
||||
focusManager.moveFocus(FocusDirection.Up) -> true
|
||||
else -> runCatching { railSelectionFocusRequester.requestFocus() }.isSuccess
|
||||
}
|
||||
}
|
||||
.verticalScroll(contentScrollState)
|
||||
.padding(
|
||||
start = if (overlay) 26.dp else 42.dp,
|
||||
@@ -1282,6 +1309,7 @@ private fun SettingsSecondaryRail(
|
||||
firstFocusRequester: FocusRequester?,
|
||||
navigationFocusRequester: FocusRequester?,
|
||||
contentFocusRequester: FocusRequester,
|
||||
selectionFocusRequester: FocusRequester,
|
||||
compact: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -1374,6 +1402,17 @@ private fun SettingsSecondaryRail(
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
// Named twice: once by its fixed place in the rail, and — while it is
|
||||
// the page being drawn — as where Up out of the top of that page
|
||||
// lands. It follows the selection rather than the highlight, so a
|
||||
// press arriving mid-settle still returns to the page on screen.
|
||||
.then(
|
||||
if (page == selected) {
|
||||
Modifier.focusRequester(selectionFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusRequester(railFocusRequesters[index + 1])
|
||||
.focusProperties {
|
||||
up = railFocusRequesters[index]
|
||||
|
||||
@@ -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",
|
||||
title = "Dolby Vision HEVC",
|
||||
),
|
||||
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"),
|
||||
MediaStream(type = "Audio", title = "TrueHD Dolby Atmos", channels = 8),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"),
|
||||
listOf("4K", "DOLBY VISION", "HEVC", "7.1", "DOLBY ATMOS"),
|
||||
mediaBadges(item),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `includes surround and non-surround sound profiles`() {
|
||||
fun badgesFor(channels: Int) = mediaBadges(
|
||||
BaseItem(
|
||||
id = "movie-$channels",
|
||||
mediaStreams = listOf(MediaStream(type = "Audio", channels = channels)),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("MONO"), badgesFor(1))
|
||||
assertEquals(listOf("STEREO"), badgesFor(2))
|
||||
assertEquals(listOf("5.1"), badgesFor(6))
|
||||
assertEquals(listOf("7.1"), badgesFor(8))
|
||||
}
|
||||
|
||||
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
|
||||
@Test
|
||||
fun `names HDR10+ rather than collapsing it to HDR`() {
|
||||
|
||||
@@ -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.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
android.useAndroidX=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/for-you", s.authed(s.handleForYou))
|
||||
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
|
||||
v1.Handle("GET /v1/calendar", s.authed(s.handleCalendar))
|
||||
v1.Handle("GET /v1/my-shows", s.authed(s.handleMyShows))
|
||||
v1.Handle("POST /v1/my-shows", s.authed(s.handleMyShows))
|
||||
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
|
||||
|
||||
@@ -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"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
featureGenreBrowser = "genre_browser"
|
||||
featureTVCalendar = "tv_calendar"
|
||||
)
|
||||
|
||||
type featureDefinition struct {
|
||||
@@ -124,6 +125,13 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: false, MinimumProtocol: 1, Capability: "genre_browser_v1",
|
||||
Recovery: "Takes effect on the next status poll; the browser is hidden when off.",
|
||||
},
|
||||
{
|
||||
Key: featureTVCalendar, Name: "TV calendar", Area: "Presentation",
|
||||
Description: "Show the month-by-month Sonarr calendar on the navigation rail. " +
|
||||
"Turning it off hides the destination and stops the gateway reading months.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
|
||||
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
|
||||
},
|
||||
{
|
||||
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
||||
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
||||
|
||||
@@ -282,11 +282,11 @@ func sonarrPremiereEpisode(
|
||||
// about refusing.
|
||||
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
||||
index := seriesIndex{
|
||||
"newshow": "emby-new",
|
||||
"returning": "emby-returning",
|
||||
"notinlibrary": "",
|
||||
"specials": "emby-specials",
|
||||
"undownloaded": "emby-undownloaded",
|
||||
"newshow": {ID: "emby-new"},
|
||||
"returning": {ID: "emby-returning"},
|
||||
"notinlibrary": {},
|
||||
"specials": {ID: "emby-specials"},
|
||||
"undownloaded": {ID: "emby-undownloaded"},
|
||||
}
|
||||
delete(index, "notinlibrary")
|
||||
|
||||
@@ -320,7 +320,7 @@ func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
|
||||
// A show that premiered and then returned inside one window is one card about its newer
|
||||
// season, not two cards about the same show.
|
||||
func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) {
|
||||
index := seriesIndex{"show": "emby-show"}
|
||||
index := seriesIndex{"show": {ID: "emby-show"}}
|
||||
premieres := sonarrPremieres([]sonarr.Episode{
|
||||
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
|
||||
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
|
||||
|
||||
@@ -247,6 +247,7 @@ type sonarrScheduleItem struct {
|
||||
MembyAirLabel string `json:"MembyAirLabel"`
|
||||
MembyAvailability string `json:"MembyAvailability"`
|
||||
MembyAvailabilityText string `json:"MembyAvailabilityText"`
|
||||
MembyEpisodeEvent string `json:"MembyEpisodeEvent,omitempty"`
|
||||
// Sonarr's lifecycle for the *show*, distinct from this episode's availability: one
|
||||
// says whether more episodes are coming at all, the other whether this one is here yet.
|
||||
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
|
||||
@@ -256,12 +257,19 @@ type sonarrScheduleItem struct {
|
||||
// the card open the show's own page; a show Sonarr follows but Emby has never imported
|
||||
// simply carries none, and the card stays informational as it always was.
|
||||
MembySeriesItemID string `json:"MembySeriesItemId,omitempty"`
|
||||
ParentLogoItemID string `json:"ParentLogoItemId,omitempty"`
|
||||
ParentLogoImageTag string `json:"ParentLogoImageTag,omitempty"`
|
||||
}
|
||||
|
||||
// seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map
|
||||
// rather than a store call per episode: one row can carry a dozen episodes of the same
|
||||
// show, and the answer is the same for all of them.
|
||||
type seriesIndex map[string]string
|
||||
type seriesReference struct {
|
||||
ID string
|
||||
LogoTag string
|
||||
}
|
||||
|
||||
type seriesIndex map[string]seriesReference
|
||||
|
||||
// embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row
|
||||
// is about what is *about* to air, and losing the link only costs the card its page.
|
||||
@@ -282,13 +290,14 @@ func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex {
|
||||
}
|
||||
// The year-qualified key is written first and never overwritten, so a remake
|
||||
// cannot claim the original's page when both are in the library.
|
||||
value := seriesReference{ID: ref.ID, LogoTag: ref.LogoTag}
|
||||
if ref.Year > 0 {
|
||||
if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen {
|
||||
index[seriesIndexKey(key, ref.Year)] = ref.ID
|
||||
index[seriesIndexKey(key, ref.Year)] = value
|
||||
}
|
||||
}
|
||||
if _, seen := index[key]; !seen {
|
||||
index[key] = ref.ID
|
||||
index[key] = value
|
||||
}
|
||||
}
|
||||
return index
|
||||
@@ -306,11 +315,28 @@ func (index seriesIndex) lookup(title string, year int) string {
|
||||
return ""
|
||||
}
|
||||
if year > 0 {
|
||||
if id, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
return id
|
||||
if ref, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
return ref.ID
|
||||
}
|
||||
}
|
||||
return index[key]
|
||||
return index[key].ID
|
||||
}
|
||||
|
||||
func (index seriesIndex) logo(title string, year int) (string, string) {
|
||||
key := normalizedShowTitle(title)
|
||||
if key == "" || len(index) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
ref := index[key]
|
||||
if year > 0 {
|
||||
if qualified, ok := index[seriesIndexKey(key, year)]; ok {
|
||||
ref = qualified
|
||||
}
|
||||
}
|
||||
if ref.ID == "" || ref.LogoTag == "" {
|
||||
return "", ""
|
||||
}
|
||||
return ref.ID, ref.LogoTag
|
||||
}
|
||||
|
||||
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
|
||||
@@ -438,6 +464,10 @@ func toSonarrScheduleItem(
|
||||
MembyPlayable: false,
|
||||
}
|
||||
item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year)
|
||||
item.ParentLogoItemID, item.ParentLogoImageTag = series.logo(
|
||||
episode.Series.Title, episode.Series.Year,
|
||||
)
|
||||
item.MembyEpisodeEvent = scheduleEpisodeEvent(episode)
|
||||
lifecycle := seriesLifecycleTag(episode.Series.Status)
|
||||
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
|
||||
if hasCover(episode.Series.Images, "poster") {
|
||||
@@ -483,6 +513,24 @@ func toSonarrScheduleItem(
|
||||
return item
|
||||
}
|
||||
|
||||
func scheduleEpisodeEvent(episode sonarr.Episode) string {
|
||||
if episode.SeasonNumber <= 0 {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(episode.FinaleType)) {
|
||||
case "series", "seriesfinale":
|
||||
return "Series finale"
|
||||
case "season", "seasonfinale":
|
||||
return "Season finale"
|
||||
case "midseason", "midseasonfinale":
|
||||
return "Mid-season finale"
|
||||
}
|
||||
if episode.EpisodeNumber == 1 {
|
||||
return "Season premiere"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string {
|
||||
airTime = airTime.In(location)
|
||||
now = now.In(location)
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
|
||||
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
|
||||
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
|
||||
index := seriesIndex{
|
||||
"northbound": "emby-old",
|
||||
"northbound|2024": "emby-2024",
|
||||
"harbour": "emby-harbour",
|
||||
"northbound": {ID: "emby-old"},
|
||||
"northbound|2024": {ID: "emby-2024", LogoTag: "northbound-logo"},
|
||||
"harbour": {ID: "emby-harbour"},
|
||||
}
|
||||
episode := func(title string, year int) sonarr.Episode {
|
||||
utc := air
|
||||
@@ -96,6 +96,42 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleEpisodeEventNamesPremieresAndFinales(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
episode sonarr.Episode
|
||||
want string
|
||||
}{
|
||||
{"season premiere", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 1}, "Season premiere"},
|
||||
{"season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "seasonFinale"}, "Season finale"},
|
||||
{"series finale", sonarr.Episode{SeasonNumber: 5, EpisodeNumber: 10, FinaleType: "seriesFinale"}, "Series finale"},
|
||||
{"Sonarr season value", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 8, FinaleType: "season"}, "Season finale"},
|
||||
{"mid-season finale", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 5, FinaleType: "midseason"}, "Mid-season finale"},
|
||||
{"ordinary episode", sonarr.Episode{SeasonNumber: 3, EpisodeNumber: 4}, ""},
|
||||
{"special", sonarr.Episode{SeasonNumber: 0, EpisodeNumber: 1}, ""},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := scheduleEpisodeEvent(testCase.episode); got != testCase.want {
|
||||
t.Fatalf("event = %q, want %q", got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleItemCarriesMatchedEmbyLogo(t *testing.T) {
|
||||
episode := sonarr.Episode{
|
||||
ID: 1, SeriesID: 7, SeasonNumber: 2, EpisodeNumber: 1,
|
||||
Series: sonarr.Series{ID: 7, Title: "Northbound", Year: 2024},
|
||||
}
|
||||
item := toSonarrScheduleItem(episode, time.Now(), time.UTC, seriesIndex{
|
||||
"northbound|2024": {ID: "emby-series", LogoTag: "logo-tag"},
|
||||
})
|
||||
if item.ParentLogoItemID != "emby-series" || item.ParentLogoImageTag != "logo-tag" {
|
||||
t.Fatalf("matched logo was not carried: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.32
|
||||
0.1.33
|
||||
|
||||
@@ -96,6 +96,9 @@ type Episode struct {
|
||||
SeriesID int `json:"seriesId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
// Sonarr names confirmed endings (for example "seasonFinale" and
|
||||
// "seriesFinale"). Empty means it has made no finale claim.
|
||||
FinaleType string `json:"finaleType"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
AirDateUTC *time.Time `json:"airDateUtc"`
|
||||
|
||||
@@ -282,6 +282,7 @@ type SeriesRef struct {
|
||||
ID string
|
||||
Name string
|
||||
Year int
|
||||
LogoTag string
|
||||
}
|
||||
|
||||
// SeriesRefs lists every imported series. The catalogue is a household's, not a
|
||||
@@ -289,7 +290,8 @@ type SeriesRef struct {
|
||||
// all rather than querying per title.
|
||||
func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, name, COALESCE(production_year, 0)
|
||||
SELECT id, name, COALESCE(production_year, 0),
|
||||
COALESCE(payload->'ImageTags'->>'Logo', '')
|
||||
FROM library_items
|
||||
WHERE type = 'Series'`)
|
||||
if err != nil {
|
||||
@@ -299,7 +301,7 @@ func (s *Store) SeriesRefs(ctx context.Context) ([]SeriesRef, error) {
|
||||
out := []SeriesRef{}
|
||||
for rows.Next() {
|
||||
var ref SeriesRef
|
||||
if err := rows.Scan(&ref.ID, &ref.Name, &ref.Year); err != nil {
|
||||
if err := rows.Scan(&ref.ID, &ref.Name, &ref.Year, &ref.LogoTag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ref)
|
||||
|
||||
Reference in New Issue
Block a user