0.2.67 - Detail pages improvements

This commit is contained in:
ponzischeme89
2026-08-16 09:02:17 +12:00
parent 4bc4b075ec
commit bd3732fba5
31 changed files with 2479 additions and 562 deletions
+7
View File
@@ -1,3 +1,10 @@
## 0.2.67 — 2026-08-16
- Improved: Movie and show pages. The header now moves out of the way as you go down into the tabs, so the content gets the whole screen instead of a strip at the bottom.
- Added: Cast is its own tab, as a full grid of everybody in it rather than six faces in a corner. It remembers which face you were on.
- Added: An Extras tab for trailers, featurettes, deleted scenes and interviews, on the titles that have them.
- Added: A Details tab for release date, studios, directors, writers, countries, languages and the file's own particulars.
- Improved: Tabs only appear when there is something in them, and no longer shuffle sideways while a page is still loading.
## 0.2.66 - 2026-08-15
- Improved: Design of skip end credits display.
+127 -48
View File
@@ -1938,6 +1938,22 @@ with the same look and different metrics, and raw `androidx.tv.material3.Button`
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
nothing around them uses.
**"Close Memby?" is the one full-stop dialog**, `ui/ExitConfirmation.kt`, drawn when the
viewer has asked to be asked (`confirmExitMemby`). It is the panel that appears *over*
Memby, so it was the one thing on screen that must not look like somebody else's app — and
it was two identical raw `androidx.tv.material3.Button`s wearing Material's own colours,
which meant the destructive answer and the safe one were the same shape at three metres.
Things to preserve: the two actions **do not look alike** (Stay is the accent fill and takes
focus first, closing is a quiet outline), the entrance is read only inside `graphicsLayer`
lambdas so nothing recomposes while the panel arrives, and **Back means stay** — it is the
key that raised the panel and pressing it again must not be what closes the app. The scrim
is a wash rather than a flat black, so the library underneath stays faintly readable and
this reads as a question asked over it. `focusedForCapture` exists because Robolectric's
window never takes focus and the focus ring is the whole of what says which action a press
would take; `ExitConfirmationScreenshotTest` renders both answers under it, plus one
capture under a foreign palette — this is the only dialog drawn entirely from the tokens,
so a theme that could not reach it would show here and nowhere else.
**Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
rows inside it — and the card's title repeated the page header, which repeated the rail item
@@ -2088,50 +2104,72 @@ midnight, which is why the zone offset is a parameter to the pure `localEpochDay
arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand.
**Detail pages** are one editorial layout shared by movies and series: `DetailPageScaffold`
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`. It
is a full-bleed cinematic hero — backdrop under two scrims, logo or title, `heroFacts` line
(year · length · certificate) with the score and format badges trailing it, genres, three
lines of synopsis, one recommendation reason, then Play and the circular secondary actions —
with an uppercase tab strip on a hairline rule anchored under it and the tab's content
beginning below the fold. `MediaDetailContent` is the movie page, `SeriesDetailContent` the
series one; they differ only in which tabs they offer. The tabs are Overview, Episodes
(series only), More Like This and Cast & Details.
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt` and
`ui/detail/DetailTabs.kt`. It is a full-bleed cinematic hero — backdrop under two scrims,
logo or title, `heroFacts` line (year · length · certificate) with the score and format
badges trailing it, genres, three lines of synopsis, one recommendation reason, then Play
and the circular secondary actions — with an uppercase tab strip on a hairline rule anchored
under it and the tab's content beginning below the fold. `MediaDetailContent` is the movie
page, `SeriesDetailContent` the series one. A movie's sections are Overview, Cast, Extras,
More Like This and Details; a series' are the same with **Episodes** in Overview's place,
because a show's synopsis is already in the hero and what a show is *for* is its episodes.
- **The page is a `LazyColumn` of exactly three items** — hero, tabs, content — and the hero
owns the opening frame: while focus is in it the list is pinned to offset 0 (see
`detailHeroScrollTarget` and the `snapshotFlow` beside it), because LazyColumn's own focus
relocation would otherwise leave Play visible with the title scrolled off the top. Moving
to the strip or the content releases the pin.
- **A pane never scrolls.** Every section is a tab and every tab fits its slot, so adding
content means adding a tab: a page that scrolls *and* has tabs gives the D-pad two
meanings for Down. The slot is `detailPaneHeight(viewportHeight)`, derived from the screen
rather than fixed — it was a hard 250dp, and everything `technicalSpecs()` produces fell
off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane
needs more than the budget, cut rows; do not add a scroller.
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge,
where overscan was cutting the selection underline in half, and leaves the top of the pane
showing beneath it. That peek and the chevron at the end of the strip are the only things
on screen saying that Down reveals anything.
- **The page is a `Column` and nothing scrolls it.** Hero, strip, pane, footer. It used to be
a `LazyColumn` of three items with the list pinned to offset 0 while focus was in the hero,
which meant the hero's own height decided where the strip ended up — so a two-line title
pushed the tabs down and the content with them, and every press into the content was a
scroll the viewer had to watch land.
- **The hero collapses instead.** `detailHeroCollapsed(zone)` is the whole rule and it is
pure and tested: whole while focus is on Play, out of the way the moment focus is below it.
A single `animateFloatAsState` drives it and is read **only inside layout and draw
lambdas** — `Modifier.collapsingHeight` for the band and `graphicsLayer` for the cross-fade
— so a collapse costs a measure pass rather than recomposing the page sixty times a second.
Two details hold the promise that no focused thing ever resizes: the hero is measured at
its *full* height and slid upward under a clip, so nothing inside it re-wraps; and the pane
takes `weight(1f)`, so it simply receives what the hero gives up.
- **What is left is `DetailCollapsedHeader`** — the title and the fact line and nothing else.
Not a shrunken hero: the synopsis, the ratings, the reason and the actions all answer "is
this worth watching", which somebody down in the Cast grid has already answered. What
remains is only what stops the page becoming anonymous.
- **A pane fits its slot, or is a grid.** Prose panes (Overview, Details) do not scroll — a
page that scrolls *and* has tabs gives the D-pad two meanings for Down — and now have
roughly three times the room they had, since `detailPaneHeight(viewportHeight)` is what the
collapsed hero and the strip leave rather than a 250dp budget. Cast, Extras and More Like
This *are* grids and scroll inside themselves, which is a different thing: Down inside a
grid still means "next row".
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge while
the hero is whole, where overscan was cutting the selection underline in half, and leaves
the top of the pane showing beneath it. That peek and the chevron at the end of the strip
are the only things on screen saying that Down reveals anything.
- **Focus is selection** in the tab strip. A remote has no hover, so a strip that highlights
one tab while a different one stays open would need a second press to mean anything and
would show content that contradicts the highlight.
- **The strip is decided by what the item is, never by what has loaded.**
`detailTabs(isSeries)` returns a fixed list, and a section with nothing in it yet says so
in its own pane. It used to offer only the sections that already had content, which meant
a movie opened with one tab and grew two more when its detail record landed, moving the
strip under the viewer's thumb. `detailTab(key, available)` still resolves a remembered
key, but now only has to catch a key carried over from the other kind of item.
would show content that contradicts the highlight. A *click* reports itself as arriving in
the strip too, or a mouse or a test could change the pane and leave the hero over it.
- **The strip is content-driven, and frozen once the viewer is inside it.** `detailTabs` is
pure over a `DetailTabAvailability`, and `rememberDetailTabs` recomputes it only while
focus is still in the hero. That is the seam between two real failures: a tab leading to an
apology is dead weight on every set in the house for ever, and a tab appearing a second
after the page opens shoves every tab to its right under a moving thumb. Three rules make
it safe — the landing pane is structural and always first, so the tab under focus on frame
one can never move; optional tabs are only ever *appended* in one order, so one arriving
cannot reorder the rest; and each flag's "still loading" value is chosen by which way it is
usually wrong (related titles exist for nearly everything, so the tab is there while the
request runs; extras exist for a small minority, so the tab waits until one is found).
- **`detailTab(key, available)` falls back to the landing pane, not to Overview.** Three
things arrive with a key that is not on offer and all are ordinary: a series' Episodes
carried to a movie, a tab whose content has gone away, and `cast-details` remembered by a
build that predates the Cast/Details split.
- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps
the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview
and the Cast & Details pane is attached to two live nodes, and a Down press landing in that
window can focus the pane that is disappearing.
the outgoing pane composed for its 80ms fade, so a requester attached by two panes is
attached to two live nodes, and a Down press landing in that window can focus the pane that
is disappearing.
- **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which
band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store
outside the composition, because closing a detail overlay destroys `rememberSaveable` with
it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live)
and then restores the band, once, only if that band has something placed to land on.
Deliberately not persisted: a TV switched on the next morning should open a show where the
*show* is up to.
band held focus, the episode rail's offset, and the cast grid's *two* numbers — in a
process-scoped, capped, LRU store outside the composition, because closing a detail overlay
destroys `rememberSaveable` with it. `RestoreDetailFocus` focuses Play first (it exists on
frame one, so the remote is live) and then restores the band, once, only if that band has
something placed to land on. Deliberately not persisted: a TV switched on the next morning
should open a show where the *show* is up to.
- Every `focusProperties { up/down/left/right = … }` target must be attached **on the
current frame**. Season chips and episode cards do not exist while the episode request is
in flight, on a one-season show, or on any tab but Episodes — pointing at their
@@ -2165,11 +2203,44 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
`GET /v1/items/{id}/related`. It comes from the same profile the home rows are built from
(`recommend.Why`), so the page can only claim a taste the engine actually learned; a cold
profile falls back to catalogue facts. Never focusable.
- **More Like This** is the same response's `items`, as its own tab. Selecting one opens
*its* detail page, and `MainActivity.detailsTrail` walks Back home one page at a time. On
the direct path there is no engine, so `EmbyRepository.getRelated` returns Emby's
`Items/{id}/Similar` with no reasons at all — both halves are allowed to be empty and the
page still opens.
- **More Like This** is the same response's `items`, as its own tab, and a poster *grid*.
Selecting one opens *its* detail page, and `MainActivity.detailsTrail` walks Back home one
page at a time. On the direct path there is no engine, so `EmbyRepository.getRelated`
returns Emby's `Items/{id}/Similar` with no reasons at all — both halves are allowed to be
empty and the page still opens.
- **Cast is a grid, in `ui/detail/CastGrid.kt`.** It replaced a compact rail wedged into the
corner of a combined "Cast & Details" pane, which showed six faces of a forty-strong cast
and gave the other thirty-four no way of being reached. Things to preserve: the column
count is **fixed** rather than `GridCells.Adaptive`, because Up out of the first row has to
return to the tab strip and the only way to know which cards are in the first row is to
know how many there are — `castColumns` is that arithmetic, pure and tested at every
television width; the card width is tuned so the **second row peeks**, since one row filling
the pane with nothing under it reads as a shelf that failed rather than as a grid;
`castMembers` deduplicates rather than disambiguating, the `ui/ListKeys.kt` rule, because
position is what has to survive coming back to the page; the character line is **reserved,
not conditional**, or a card with no role recorded sits a line higher than the one beside
it; and the initials are drawn *behind* the portrait, the stance the player's cast panel
takes, so nothing has to decide in advance whether artwork will arrive. Selecting a card is
inert for now — a person page is the obvious next thing and the grid already remembers
which card to come back to.
- **Extras is `GET /v1/items/{id}/extras`**, and it is a *join*: Emby answers the question in
two places and neither includes the other, `SpecialFeatures` holding the featurettes and
deleted scenes and `LocalTrailers` holding the trailer. `server/internal/api/extras.go`
joins them (only *both* lookups failing is a failure — a title can perfectly well have one
and not the other) and `dedupeExtras` drops a file filed in both, because the television
renders these into a keyed grid and a repeated key throws. The direct path makes the same
two calls itself. Two client rules: the repository caches the **empty** answer, since most
of a library has none and every page open asks; and a 404 is read as "no extras" rather
than as an error, so a new APK against a gateway that predates the route simply does not
offer the tab. `extraKindLabel` names the kind from Emby's `Type`, spacing out anything it
does not recognise so a category added tomorrow still reads correctly.
- **Details is `detailRows(item)`** beside `technicalSpecs(item)` — catalogue facts on the
left, what the file is on the right. Its vocabulary (Studios, Taglines, PremiereDate,
OriginalTitle, ProductionLocations) is read nowhere else and had to be added to
`fieldsDetail`, which is why the gateway's item cache key moved to `item:v6:` — entries
written before it cannot hide a newly requested field. A row is omitted rather than printed
empty, and Studio is dropped from the file column when the catalogue column already said
it: printed twice side by side it reads as a page that cannot make up its mind.
- **Nothing about related titles is allowed to fail.** It is asked for on *focus*
(`HomeViewModel.focusItem` warms it while the card is highlighted), so it is the most
frequently made request on the launcher and was by some way the loudest thing in the
@@ -2186,10 +2257,18 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
client-side `getRelated` is single-flighted on the repository's own scope, so the
cancelled focus prefetch neither aborts the request the detail page is about to want nor
caches its own failure as an answer.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer) and
delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be
screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by clicking it and
also renders each pane on its own at `detailPaneHeight`) without a server.
- **Nothing is loaded for a tab nobody has opened.** Each request is its own
`LaunchedEffect` in the overlay and none blocks the page: episodes, related, trailer,
extras and ratings all arrive independently, and every one of them is single-flighted and
cached in the repository, so warming a detail page on D-pad focus and then opening it costs
one request rather than two. The pane itself is the second half of that — while the hero is
whole the pane is 34dp, so a grid composes one row until the viewer actually goes there.
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer,
extras) and delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so
they can be screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by
clicking it and also renders each pane on its own at `detailPaneHeight`) without a server.
Because a click now reports itself as arriving in the strip, every tab capture is also the
check that the hero collapsed and the strip moved to the top of the usable area.
**"Estimated finish: 18 August"** is `data/SeriesPace.kt`, a quiet line under the series
hero's progress bar. It is derived entirely from the episode list the detail page already
+1 -1
View File
@@ -46,7 +46,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.66"
val defaultVersionName = "0.2.67"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -295,6 +295,15 @@ class EmbyRepository(private val settings: SettingsStore) {
private val trailerAvailabilityCache =
LinkedHashMap<String, Boolean>(TRAILER_CACHE_SIZE, 0.75f, true)
private val trailerAvailabilityInFlight = mutableMapOf<String, Deferred<Boolean?>>()
private val extrasMutex = Mutex()
/**
* A title's special features, empty list included. Most of a library has none, and the
* Extras tab asks on every page open without keeping the empty answer, walking a
* "More like this" trail would repeat the same question all the way down it.
*/
private val extrasCache =
LinkedHashMap<String, List<BaseItem>>(EXTRAS_CACHE_SIZE, 0.75f, true)
private val extrasInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -1649,6 +1658,76 @@ class EmbyRepository(private val settings: SettingsStore) {
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
}
/**
* A title's extras: trailers, featurettes, deleted scenes, behind-the-scenes material.
*
* Cached **including the empty answer** and single-flighted on the repository's own
* scope, for the same two reasons [getLocalTrailer] is. Most of a library has no special
* features, so an absent entry and "none" have to be different things or every page open
* re-asks; and the caller is a detail page that may be warmed on focus and cancelled
* when the D-pad moves on, so a request tied to the caller's scope would be abandoned by
* exactly the navigation that is about to want its answer.
*
* Never throws. A gateway that predates the route, an Emby that will not answer and a
* title with genuinely nothing are all "no extras", and the tab is simply not offered
* an Extras tab that opens onto an error message is worse than no tab at all.
*/
suspend fun getExtras(itemId: String): List<BaseItem> {
if (itemId.isBlank()) return emptyList()
val inFlight = extrasMutex.withLock {
extrasCache[itemId]?.let { return it }
extrasInFlight[itemId] ?: newExtrasRequest(itemId)
}
// A failure is deliberately not cached: one bad minute must not hide a title's
// extras for the rest of the session.
return inFlight.await().orEmpty()
}
private fun newExtrasRequest(itemId: String): Deferred<List<BaseItem>?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { loadExtras(itemId) }.getOrNull() ?: return@async null
extrasMutex.withLock {
extrasCache[itemId] = loaded
while (extrasCache.size > EXTRAS_CACHE_SIZE) {
extrasCache.entries.iterator().run {
next()
remove()
}
}
loaded
}
} finally {
extrasMutex.withLock { extrasInFlight.remove(itemId) }
}
}
extrasInFlight[itemId] = request
request.start()
return request
}
private suspend fun loadExtras(itemId: String): List<BaseItem> {
val loaded = if (ServerConfig.isGateway) {
// 404 is a gateway that predates the route. It is the same answer as a title
// with nothing, and treating it as one is what lets a new APK talk to an old
// container without the tab appearing and then failing.
runCatching { requireGateway().extras(itemId).items }
.getOrElse { if (it is HttpException && it.code() == 404) emptyList() else throw it }
} else {
val userId = snapshot.userId ?: error("Not connected")
val api = requireApi()
// Two calls on this path because Emby keeps trailers out of SpecialFeatures.
// The gateway does the same join server-side and returns one list.
val features = runCatching { api.getSpecialFeatures(userId, itemId) }.getOrDefault(emptyList())
val trailers = runCatching { api.getLocalTrailers(userId, itemId).items }
.getOrDefault(emptyList())
trailers + features
}
// A keyed grid must never be handed a repeated key, and the two Emby lists can name
// the same file — a trailer that has also been filed as a special feature.
return loaded.filter { it.id.isNotBlank() }.distinctBy(BaseItem::id)
}
/**
* Uploads a batch of row-engagement events. Silent on the direct path (nothing is
* listening) and silent on failure telemetry must never surface on a TV.
@@ -2103,6 +2182,11 @@ class EmbyRepository(private val settings: SettingsStore) {
trailerInFlight.values.forEach { it.cancel() }
trailerInFlight.clear()
}
extrasMutex.withLock {
extrasCache.clear()
extrasInFlight.values.forEach { it.cancel() }
extrasInFlight.clear()
}
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
@@ -2967,6 +3051,7 @@ private const val RELATED_LIMIT = 12
* point of the cache is that walking back into a page never asks again.
*/
private const val TRAILER_CACHE_SIZE = 64
private const val EXTRAS_CACHE_SIZE = 64
private const val MAX_TRAILER_CANDIDATES = 12
// Long enough that walking back and forth between a row and a detail page never re-asks,
@@ -403,6 +403,11 @@ data class BaseItem(
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("CollectionName") val collectionName: String? = null,
// Catalogue facts the Details tab prints and nothing else reads. Both are absent from
// every list query and from any cached payload written before the tab existed, so both
// default — an older home cache decodes unchanged and the rows simply do not appear.
@SerialName("OriginalTitle") val originalTitle: String? = null,
@SerialName("ProductionLocations") val productionLocations: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data.remote
import com.ponzischeme89.memby.data.model.AuthRequest
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.AuthResult
import com.ponzischeme89.memby.data.model.ItemsResult
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -61,6 +62,17 @@ interface EmbyApi {
@Path("itemId") itemId: String,
): ItemsResult
/**
* Featurettes, deleted scenes, interviews whatever Emby has filed beside the media
* file as a special feature. Separate from [getLocalTrailers], which is only ever asked
* for the one trailer the detail page's Trailer button needs.
*/
@GET("Users/{userId}/Items/{itemId}/SpecialFeatures")
suspend fun getSpecialFeatures(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): List<BaseItem>
/**
* Emby's own similarity ranking. The direct path has no recommendation engine behind
* it, so this is the whole of "More like this" when the gateway is not in play.
@@ -233,6 +233,15 @@ interface GatewayApi {
@GET("v1/items/{id}/episodes")
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
/**
* The item's special features featurettes, deleted scenes, interviews with any
* local trailer at the front. A gateway that predates the route answers 404, which the
* repository reads as "no extras" rather than as an error: the Extras tab is simply not
* offered, which is the correct answer for a household on an older container.
*/
@GET("v1/items/{id}/extras")
suspend fun extras(@Path("id") itemId: String): GatewayItems
/** Why this viewer might enjoy the item, and what else in the library is like it. */
@GET("v1/items/{id}/related")
suspend fun related(
@@ -29,27 +29,25 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
@@ -65,11 +63,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
@@ -79,11 +79,15 @@ 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.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.CastGrid
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
import com.ponzischeme89.memby.ui.detail.castColumns
import com.ponzischeme89.memby.ui.detail.extraKindLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -96,7 +100,6 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import kotlinx.coroutines.launch
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
// here than on the launcher, which is visible the moment a page opens from a row.
@@ -121,7 +124,7 @@ internal val DetailSideGutter = 58.dp
internal val DetailStripHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip.
* How much of the content pane is left showing under the tab strip while the hero is whole.
*
* It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
* TV's overscan ate the selection underline and part of the labels this is the safe-area
@@ -132,14 +135,39 @@ internal val DetailStripHeight = 66.dp
private val DetailFoldPeek = 34.dp
/**
* The height one tab's pane gets, from the viewport it has to fit inside.
* What is left of the hero once the viewer has moved down into the tabs.
*
* It was a hard 250dp, and `technicalSpecs()` Video, Codec, Audio, Subtitles, Studio
* fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
* of the screen once the pane has been scrolled to its resting position under the strip.
* Enough for the title and the fact line and nothing else. The hero's job on the opening
* frame is to say what this is and offer Play; once somebody is browsing the content it is
* context, and context that keeps two thirds of the screen is in the way.
*/
internal val DetailCollapsedHeroHeight = 104.dp
/** The bottom safe-area strip under the pane, where the footer control sits when there is one. */
private val DetailFooterHeight = 46.dp
/**
* The height one tab's pane gets once the hero has collapsed out of its way.
*
* It was a hard 250dp with a scrolling page above it, and `technicalSpecs()` Video, Codec,
* Audio, Subtitles, Studio fell off the bottom of a pane that deliberately cannot scroll.
* The pane now takes whatever the collapsed hero and the strip leave, which on a 540dp
* television is roughly three times what it used to get. Kept as a function because the
* screenshot tests render a pane on its own and have to place it at the real geometry.
*/
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
(viewportHeight - DetailCollapsedHeroHeight - DetailStripHeight - DetailFooterHeight)
.coerceAtLeast(200.dp)
/**
* Whether the hero is out of the way.
*
* The whole hero/tab relationship is this one rule, so it is pure and pinned by a test: the
* complete opening frame while focus is on Play, and collapsed the moment focus is anywhere
* below it. Nothing about scroll position is involved any more the page does not scroll,
* which is what stopped the strip being pushed around by the height of a two-line title.
*/
internal fun detailHeroCollapsed(zone: DetailZone): Boolean = zone != DetailZone.PLAY
/**
* Hands focus to the first target that is actually on screen, and says whether any took it.
@@ -287,12 +315,10 @@ internal fun DetailPageScaffold(
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
onZoneFocused: (DetailZone) -> Unit = {},
footer: (@Composable () -> Unit)? = null,
content: @Composable BoxScope.(DetailTab) -> Unit,
) {
val scope = rememberCoroutineScope()
// Keep the same requesters when an async trailer action appears. Replacing the list
// while the viewer is already on Favourites would detach the focused node.
val allActionRequesters = remember(item.id) {
@@ -317,44 +343,35 @@ internal fun DetailPageScaffold(
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
fun reveal(index: Int, offset: Int = 0) {
scope.launch { pageListState.animateScrollToItem(index, offset) }
}
fun revealHero() {
scope.launch {
// LazyColumn also scrolls a newly focused descendant into view. That request
// can land after onFocusChanged and used to win over reveal(0), leaving Play
// focused with the logo and metadata above the viewport. Snap once now and
// once after focus relocation has completed so hero focus always means the
// complete opening frame.
pageListState.scrollToItem(0)
withFrameNanos { }
pageListState.scrollToItem(0)
}
}
// Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
// the opening frame pinned for as long as focus remains in the hero, regardless of
// which relocation wins a particular frame. Moving to Tabs or Content releases it.
androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
snapshotFlow {
pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
}.collect { (index, offset) ->
if (index != 0 || offset != 0) pageListState.scrollToItem(0)
}
}
// The one number the whole layout is driven by: 0 is the complete opening frame, 1 is
// the hero out of the way with the strip at the top of the usable area. It is read
// *only* inside layout and draw lambdas below — never in a composable body — so the
// transition costs re-layout of three boxes rather than a recomposition of the page on
// every frame of it. 190ms because this is context getting out of the way, not an
// effect: fast enough not to be waited on, slow enough not to read as a jump.
val collapse = animateFloatAsState(
targetValue = if (detailHeroCollapsed(focusedZone)) 1f else 0f,
animationSpec = tween(190),
label = "detail-hero-collapse",
)
BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
// The opening composition is one deliberate TV frame: hero above, tabs anchored
// to its bottom edge. Content begins below the fold and only enters when the
// viewer presses Down from the tabs.
val heroHeight = (maxHeight - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
modifier = Modifier.fillMaxSize(),
) {
item(key = "hero") {
// The opening composition is one deliberate TV frame: hero above, tabs anchored to
// its bottom edge, the top of the content pane peeking under them. Nothing scrolls
// — the three bands are a Column, and moving between them changes the hero's height
// rather than the page's offset. That is the whole of the fix: a page that scrolled
// let the hero's own height decide where the strip ended up, so a two-line title
// pushed the tabs down and the content with them.
val expandedHero = (maxHeight - DetailStripHeight - DetailFoldPeek - DetailFooterHeight)
.coerceAtLeast(320.dp)
Column(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxWidth()
.collapsingHeight(expandedHero, DetailCollapsedHeroHeight) { collapse.value }
.clipToBounds(),
) {
DetailHero(
item = item,
facts = facts,
@@ -375,89 +392,86 @@ internal fun DetailPageScaffold(
showRatingsStrip = showRatingsStrip,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
expandedHeight = expandedHero,
collapsedHeight = DetailCollapsedHeroHeight,
collapse = { collapse.value },
onActionFocused = { index ->
lastHeroIndex = index
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
onPlayFocused = {
lastHeroIndex = -1
focusedZone = DetailZone.PLAY
onZoneFocused(DetailZone.PLAY)
revealHero()
},
)
}
item(key = "tabs") {
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
}
// The band as one focus group, so a press that cannot reach the exact
// stop it wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
}
// The band as one focus group, so a press that cannot reach the exact stop it
// wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
}
item(key = "content") {
AnimatedContent(
targetState = selectedTab,
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
label = "detail-tab-content",
modifier = Modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
.height(paneHeight)
.onFocusChanged {
if (it.hasFocus) {
focusedZone = DetailZone.CONTENT
onZoneFocused(DetailZone.CONTENT)
reveal(2, -72)
}
AnimatedContent(
targetState = selectedTab,
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
label = "detail-tab-content",
modifier = Modifier
.fillMaxWidth()
// Takes whatever the hero has given up. While the hero is whole this is
// the fold peek; once it collapses the pane is most of the screen, which
// is the point — the content takes over rather than living in a slot.
.weight(1f)
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 14.dp)
.onFocusChanged {
if (it.hasFocus) {
focusedZone = DetailZone.CONTENT
onZoneFocused(DetailZone.CONTENT)
}
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes
// navigate vertically inside themselves (an episode list, its
// season chips) and override this where they do. A blanket
// handler here would take Up off every card in that list.
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
}
}
footer?.let { footerContent ->
item(key = "footer") {
Box(
Modifier.fillMaxWidth().padding(
start = DetailSideGutter, end = DetailSideGutter, bottom = 64.dp,
),
) { footerContent() }
}
}
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes navigate
// vertically inside themselves (an episode list, its season chips) and
// override this where they do. A blanket handler here would take Up off
// every card in that list.
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
}
// Always the same height whether or not there is a footer, so the pane above it
// does not change size when an async control appears inside it.
Box(
Modifier
.fillMaxWidth()
.height(DetailFooterHeight)
.padding(start = DetailSideGutter, end = DetailSideGutter),
contentAlignment = Alignment.CenterStart,
) { footer?.invoke() }
}
confirmation?.let {
@@ -479,9 +493,31 @@ internal fun DetailPageScaffold(
}
}
/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
if (zone == DetailZone.PLAY) 0 else null
/**
* Gives the node a height between [expanded] and [collapsed], read from [fraction].
*
* A `Modifier.height(animatedDp)` would read the animation in composition and recompose the
* hero and with it the whole Column sixty times a second for the length of the
* transition. This reads it in the layout lambda instead, so a collapse costs a measure pass
* and nothing above it. Same rule as the launcher's countdown ring, one phase further in.
*
* The child is measured at its *expanded* height and simply clipped by the parent, which is
* what keeps the hero's own layout still while the box around it shrinks: nothing inside it
* re-wraps, so no focused control ever changes size or moves under a thumb.
*/
private fun Modifier.collapsingHeight(
expanded: Dp,
collapsed: Dp,
fraction: () -> Float,
): Modifier = layout { measurable, constraints ->
val expandedPx = expanded.roundToPx()
val collapsedPx = collapsed.roundToPx()
val height = expandedPx + ((collapsedPx - expandedPx) * fraction().coerceIn(0f, 1f)).toInt()
val placeable = measurable.measure(
constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity),
)
layout(placeable.width, height) { placeable.place(0, 0) }
}
@Composable
private fun DetailHero(
@@ -504,7 +540,14 @@ private fun DetailHero(
showRatingsStrip: Boolean,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
expandedHeight: Dp,
collapsedHeight: Dp,
/**
* 0 while the hero owns the screen, 1 once it is out of the way. A lambda rather than a
* value so nothing here recomposes while it runs every reader of it below is inside a
* `graphicsLayer` block, which is the draw phase.
*/
collapse: () -> Float,
onPlayFocused: () -> Unit,
onActionFocused: (Int) -> Unit,
) {
@@ -518,11 +561,46 @@ private fun DetailHero(
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
// Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
// in here holds focus, the press means "take me to the band under this".
Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
//
// Always laid out at its full height and slid upward as it collapses, so its bottom edge
// rides the shrinking band while nothing inside it re-wraps. Measuring it at the animated
// height instead would re-flow the title, the synopsis and the action row on every frame
// of the transition — which is the "suddenly resize a focused item" failure, sixty times
// a second.
Box(
Modifier
.fillMaxWidth()
.height(expandedHeight)
.graphicsLayer {
translationY = -(expandedHeight.toPx() - collapsedHeight.toPx()) * collapse()
}
.onVerticalNavigation(down = onNavigateDown),
) {
DetailBackdrop(item, Modifier.fillMaxSize())
// Deepened as the hero gives way, so the strip and the top of the content pane are
// read against something closer to the launcher's near-black than to artwork.
Box(
Modifier
.fillMaxSize()
.graphicsLayer { alpha = collapse() }
.background(DetailBackground.copy(alpha = 0.72f)),
)
DetailCollapsedHeader(
title = title,
facts = facts,
logo = logo,
modifier = Modifier
.align(Alignment.BottomStart)
// Comes in over the second half of the transition, after the full hero has
// finished going. Two headings crossing through each other reads as a fault.
.graphicsLayer { alpha = ((collapse() - 0.5f) * 2f).coerceIn(0f, 1f) },
)
Column(
modifier = Modifier
.align(Alignment.BottomStart)
// Out over the first half, and comfortably before the action row would be
// clipped by the shrinking band.
.graphicsLayer { alpha = (1f - collapse() * 2f).coerceIn(0f, 1f) }
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
.fillMaxWidth(0.58f),
) {
@@ -650,6 +728,60 @@ private fun DetailHero(
}
}
/**
* What is left of the hero once the viewer is browsing: the title and the fact line.
*
* It is deliberately not a shrunken copy of the hero. Everything else up there the
* synopsis, the ratings, the reason, the actions answers "is this worth watching", which
* is a question somebody who has moved down into the Cast grid has already answered. What
* remains is only what stops the page becoming anonymous: which title these tabs belong to.
*/
@Composable
private fun DetailCollapsedHeader(
title: String,
facts: List<String>,
logo: String?,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (logo != null) {
AsyncImage(
model = logo,
contentDescription = title,
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
modifier = Modifier.height(38.dp).width(160.dp),
)
} else {
Text(
text = title,
color = Color.White,
fontSize = 24.sp,
lineHeight = 27.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
if (facts.isNotEmpty()) {
Spacer(Modifier.width(16.dp))
Text(
text = facts.joinToString(FactSeparator),
color = DetailMutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* The schedule the viewer pressed, restated on the page it opened.
*
@@ -804,7 +936,10 @@ private fun DetailTabStrip(
focused = it.isFocused
if (it.isFocused) { onSelect(tab); onFocused() }
}
.clickable { onSelect(tab) },
// Both halves, because a click is somebody arriving in the strip
// just as a D-pad press is. Selecting without reporting it would
// change the pane and leave the hero standing over it.
.clickable { onSelect(tab); onFocused() },
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
@@ -855,7 +990,9 @@ internal fun DetailFocusablePane(
var focused by remember { mutableStateOf(false) }
Box(
modifier
.fillMaxSize()
// Width only. The pane is most of the screen once the hero has collapsed, and a
// bordered box filling all of it to hold one sentence reads as a failed load.
.fillMaxWidth()
.focusRequester(focusRequester)
.onFocusChanged { focused = it.isFocused }
.focusable()
@@ -878,6 +1015,13 @@ internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modi
}
}
/**
* A movie's landing pane: the whole synopsis, and the four credit lines worth leading with.
*
* Two columns because the pane is now most of the screen. A single column of prose across
* 1200 dp is a line length nobody reads at three metres, and the credits sitting beside it
* rather than beneath it means neither has to be cut.
*/
@Composable
internal fun DetailOverviewPane(
item: BaseItem,
@@ -887,45 +1031,187 @@ internal fun DetailOverviewPane(
supportingText: String? = null,
) {
DetailFocusablePane(focusRequester, modifier) {
Column {
Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
Spacer(Modifier.height(16.dp))
DetailMetaRows(credits.take(4))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
Column(Modifier.weight(1.5f)) {
item.taglines.firstOrNull()?.takeIf(String::isNotBlank)?.let { tagline ->
Text(
text = tagline,
color = DetailAccent,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(10.dp))
}
Text(
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
color = DetailText,
fontSize = 16.sp,
lineHeight = 24.sp,
// The hero already shows three lines; the whole reason to press Down
// onto Overview is the rest of them.
maxLines = 12,
overflow = TextOverflow.Ellipsis,
)
supportingText?.let {
Spacer(Modifier.height(12.dp))
Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
}
}
DetailMetaRows(credits, Modifier.weight(1f), labelWidth = 96.dp)
}
}
}
/**
* The Details tab: catalogue facts on the left, what the file actually is on the right.
*
* Two vocabularies that answer different questions "what is this?" and "will it play?"
* so they are two columns rather than one list. Everything here is deliberately the
* lowest-priority information on the page; it is a tab precisely so it does not have to
* compete for the hero.
*/
@Composable
internal fun DetailCastAndDetailsPane(
item: BaseItem,
credits: List<TechnicalSpec>,
internal fun DetailDetailsPane(
rows: List<TechnicalSpec>,
specs: List<TechnicalSpec>,
detailsLoaded: Boolean,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val releaseAndTechnical = remember(item.id, specs, credits) {
val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
buildList {
item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
// Studio is in both vocabularies. It only became visible as a duplicate once
// the pane stopped clipping its own second half.
addAll(specs.filterNot { it.label in alreadyCredited })
}
// Studio is in both vocabularies — the catalogue's because it is who made the thing, the
// file's because Emby writes it onto the stream record. Printed twice, side by side, it
// reads as a page that cannot make its mind up.
val fileSpecs = remember(rows, specs) {
val alreadySaid = rows.map { it.value }.toSet()
specs.filterNot { it.value in alreadySaid }
}
DetailFocusablePane(focusRequester, modifier) {
if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
if (rows.isEmpty() && specs.isEmpty()) {
Text(
text = if (detailsLoaded) "No further details are recorded." else "Loading details…",
color = DetailQuietText,
fontSize = 15.sp,
)
} else {
Column {
if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
DetailMetaRows(credits, Modifier.weight(1f))
DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
DetailMetaRows(rows, Modifier.weight(1f))
Column(Modifier.weight(1f)) {
if (fileSpecs.isNotEmpty()) {
Text(
text = "FILE",
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
)
Spacer(Modifier.height(10.dp))
DetailMetaRows(fileSpecs)
}
}
}
}
}
}
/**
* The Cast tab. A grid rather than the rail this replaced see `ui/detail/CastGrid.kt`.
*
* The empty state distinguishes "still coming" from "nobody recorded", because a page that
* says "no cast" a quarter of a second before the cast arrives is worse than one that says
* nothing: the viewer has already pressed Right to leave.
*/
@Composable
internal fun DetailCastPane(
people: List<EmbyPerson>,
detailsLoaded: Boolean,
state: LazyGridState,
entryIndex: Int,
entryFocusRequester: FocusRequester,
emptyFocusRequester: FocusRequester,
aboveGrid: FocusRequester,
onFocusIndex: (Int) -> Unit,
onSelect: (EmbyPerson) -> Unit,
modifier: Modifier = Modifier,
) {
if (people.isEmpty()) {
DetailFocusablePane(emptyFocusRequester, modifier) {
Text(
text = if (detailsLoaded) "No cast is recorded for this title." else "Loading cast…",
color = DetailQuietText,
fontSize = 15.sp,
)
}
return
}
val repository = ServiceLocator.repository
CastGrid(
people = people,
state = state,
entryIndex = entryIndex,
entryFocusRequester = entryFocusRequester,
aboveGrid = aboveGrid,
onFocusIndex = onFocusIndex,
onSelect = onSelect,
modifier = modifier,
// Coil is handed a plain URL and fetches it lazily as the row scrolls into view;
// nothing here pre-resolves an image for a card nobody has reached.
imageUrlFor = { person -> repository.personImageUrl(person, 300) },
)
}
/**
* More Like This: a poster grid, on the same rules as the cast grid.
*
* It was a single LazyRow, which was right when the pane was a 250dp slot below the fold. In
* a pane that now owns most of the screen one row of posters with three hundred empty pixels
* under it reads as content that failed to arrive and a household with forty neighbours
* for a title had to walk right through all of them.
*/
@Composable
internal fun DetailMoreLikeThisPane(
items: List<BaseItem>,
loading: Boolean,
onSelect: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
gridState: LazyGridState,
aboveGrid: FocusRequester,
entryIndex: Int,
onFocusIndex: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
when {
loading -> DetailFocusablePane(firstFocusRequester, modifier) {
Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp)
}
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) {
Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp)
}
else -> BoxWithConstraints(modifier.fillMaxSize()) {
val columns = remember(maxWidth) { castColumns(maxWidth) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(items, key = { _, related -> related.id }) { index, related ->
DetailPosterCard(
item = related,
onClick = { onSelect(related) },
onFocused = { onFocusIndex(index) },
modifier = Modifier
.then(
if (index == entryIndex) Modifier.focusRequester(firstFocusRequester)
else Modifier,
)
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
@@ -933,41 +1219,22 @@ internal fun DetailCastAndDetailsPane(
}
@Composable
internal fun DetailMoreLikeThisPane(
items: List<BaseItem>,
loading: Boolean,
onSelect: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
listState: LazyListState,
private fun DetailPosterCard(
item: BaseItem,
onClick: () -> Unit,
onFocused: () -> Unit = {},
modifier: Modifier = Modifier,
) {
when {
loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) }
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) }
else -> LazyRow(
state = listState,
modifier = modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
) {
itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
DetailPosterCard(
item = related,
onClick = { onSelect(related) },
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
)
}
}
}
}
@Composable
private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
Column(
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.zIndex(if (focused) 1f else 0f)
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
.clickable(onClick = onClick),
) {
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(MembySurfaceRaised).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
@@ -976,7 +1243,128 @@ private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modi
}
}
/**
* The Extras tab: featurettes, deleted scenes, interviews, the trailer.
*
* Landscape thumbnails rather than portraits, because that is the shape the material is
* an extra has a frame grab, never a poster and because the kind of thing it is ("Deleted
* Scene") matters as much as its name, which needs the width.
*
* The tab is only offered when [items] is non-empty, so the only empty state it can reach is
* the one where the list emptied under the viewer; it still says something rather than
* drawing a blank pane.
*/
@Composable
internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
internal fun DetailExtrasPane(
items: List<BaseItem>,
onPlay: (BaseItem) -> Unit,
firstFocusRequester: FocusRequester,
gridState: LazyGridState,
aboveGrid: FocusRequester,
modifier: Modifier = Modifier,
) {
if (items.isEmpty()) {
DetailFocusablePane(firstFocusRequester, modifier) {
Text("No extras are available for this title.", color = DetailQuietText, fontSize = 15.sp)
}
return
}
BoxWithConstraints(modifier.fillMaxSize()) {
// Landscape cards, so fewer of them fit across than the portrait grids allow — but
// the same rule holds: one row filling the pane with nothing under it reads as a
// shelf that failed rather than as a grid.
val columns = remember(maxWidth) { (maxWidth / 210.dp).toInt().coerceIn(2, 5) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = gridState,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(items, key = { _, extra -> extra.id }) { index, extra ->
DetailExtraCard(
item = extra,
onClick = { onPlay(extra) },
modifier = Modifier
.then(if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier)
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
}
@Composable
private fun DetailExtraCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
val repository = ServiceLocator.repository
val artwork = remember(item.id, item.imageTags) {
repository.primaryUrl(item, 480) ?: repository.backdropUrl(item, 480)
}
val kind = remember(item.type) { extraKindLabel(item) }
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (focused) 1.05f else 1f, tween(100), label = "extra-card-focus")
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.zIndex(if (focused) 1f else 0f)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick),
) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(shape)
.background(MembySurfaceRaised)
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, shape),
contentAlignment = Alignment.Center,
) {
if (artwork != null) {
AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Icon(
Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier
.size(if (focused) 40.dp else 32.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = if (focused) 0.74f else 0.45f))
.padding(6.dp),
)
}
Text(
text = kind,
color = DetailAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.1.sp,
maxLines = 1,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = item.name.takeIf(String::isNotBlank) ?: kind,
color = if (focused) Color.White else DetailText,
fontSize = 13.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
item.runtimeMinutes?.let {
Text(
text = formatRuntime(it),
color = DetailQuietText,
fontSize = 11.sp,
maxLines = 1,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@@ -0,0 +1,299 @@
package com.ponzischeme89.memby.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PowerSettingsNew
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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyAccentBright
import com.ponzischeme89.memby.ui.theme.MembyAccentMuted
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyOutline
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySplashTint
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
/** The two answers the panel offers, named so a capture can say which one is under focus. */
internal enum class ExitChoice { STAY, CLOSE }
/**
* "Close Memby?" the last thing between a press of Back on the launcher and the TV's own
* home screen. Only drawn when the viewer has asked for it (Settings `confirmExitMemby`).
*
* It is deliberately the only dialog in the app drawn from the design tokens rather than
* from raw `androidx.tv.material3.Button`s. Those buttons carry Material's own colour
* scheme, which nothing else on the launcher uses, so the one panel that appears *over*
* Memby was the one thing on screen that did not look like it and the destructive choice
* and the safe one were drawn identically, on a screen read from three metres away by
* somebody who pressed a key by accident.
*
* Three things are worth keeping:
*
* - **The two actions do not look alike.** Stay is the accent fill and takes focus first;
* closing is a quiet outline. A remote has no pointer, so the loud shape is the whole of
* what says which one the viewer probably wants.
* - **Nothing here recomposes while it appears.** The entrance is one `animateFloatAsState`
* read inside `graphicsLayer`/`drawBehind` lambdas the draw and layer phases never in
* a composable body. This app ships to weak TV boxes and a dialog that recomposed the
* panel per frame would stutter on the way in.
* - **Back means stay.** It is the key that raised this panel, and pressing it again must
* not be the thing that closes the app.
*
* The last two parameters exist only so the panel can be captured: `ExitConfirmationScreenshotTest`
* needs the settled frame rather than whatever the animation clock happened to be at, and
* Robolectric's window never takes focus, so the focus ring the whole of what says which
* action a press would take cannot be photographed without being asked for.
*/
@Composable
@OptIn(ExperimentalComposeUiApi::class)
internal fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
animateIn: Boolean = true,
focusedForCapture: ExitChoice? = null,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
var appeared by remember { mutableStateOf(!animateIn) }
val appear by animateFloatAsState(
targetValue = if (appeared) 1f else 0f,
animationSpec = tween(durationMillis = 180),
label = "exit-dialog-appear",
)
BackHandler(onBack = onStay)
// The delay is the one the panel this replaced used: a FocusRequester attached on the
// frame it is requested on is not placed yet and the request is dropped — which on a
// dialog leaves a television with nothing focused and no way out of it.
LaunchedEffect(Unit) {
appeared = true
delay(16L)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
// A wash rather than a flat 82% black: the launcher stays faintly readable
// underneath, so this reads as a question asked over your library rather than
// as a screen the app has navigated to.
.graphicsLayer { alpha = appear }
.background(
Brush.radialGradient(
listOf(
MembySplashTint.copy(alpha = 0.80f),
Color.Black.copy(alpha = 0.93f),
),
),
),
contentAlignment = Alignment.Center,
) {
val panelShape = RoundedCornerShape(MembyPanelCorner)
Column(
modifier = Modifier
.width(520.dp)
// Settles the last few pixels down onto the scrim as it fades in. Read in
// the layer phase, so the panel itself is composed once.
.graphicsLayer {
val t = appear
scaleX = 0.96f + 0.04f * t
scaleY = 0.96f + 0.04f * t
translationY = (1f - t) * 14.dp.toPx()
}
.shadow(30.dp, panelShape)
.clip(panelShape)
.background(MembySurfaceRaised)
.border(1.dp, MembyHairline, panelShape)
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(58.dp)
.clip(CircleShape)
.background(MembyAccentMuted)
.border(1.dp, MembyAccentBright.copy(alpha = 0.45f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.PowerSettingsNew,
contentDescription = null,
tint = MembyAccentBright,
modifier = Modifier.size(27.dp),
)
}
Spacer(Modifier.height(20.dp))
Text(
"Close Memby?",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(10.dp))
Text(
"Your place is saved. You can pick up where you left off next time you " +
"open Memby.",
color = MembyMutedText,
fontSize = 16.sp,
lineHeight = 23.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
DialogAction(
label = "Stay in Memby",
primary = true,
onClick = onStay,
focusedForCapture = focusedForCapture == ExitChoice.STAY,
modifier = Modifier
.weight(1f)
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
DialogAction(
label = "Close Memby",
primary = false,
onClick = onExit,
focusedForCapture = focusedForCapture == ExitChoice.CLOSE,
modifier = Modifier
.weight(1f)
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
)
}
Spacer(Modifier.height(16.dp))
Text(
"Press Back to stay",
color = MembyQuietText,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
/**
* One dialog action. The same corner, lift and focus ring as [MembyPlayButton], because a
* viewer should not have to learn a second focus language for the panel that appears over
* the one they were just using.
*/
@Composable
private fun DialogAction(
label: String,
primary: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
focusedForCapture: Boolean = false,
) {
var hasFocus by remember { mutableStateOf(false) }
val focused = hasFocus || focusedForCapture
val lift by animateFloatAsState(
targetValue = if (focused) 1f else 0f,
animationSpec = tween(durationMillis = 110),
label = "exit-dialog-action",
)
val shape = RoundedCornerShape(MembyCardCorner)
Box(
modifier = modifier
.graphicsLayer {
val t = lift
scaleX = 1f + 0.04f * t
scaleY = 1f + 0.04f * t
translationY = -3f * t
}
.shadow(if (focused) 16.dp else 0.dp, shape)
.clip(shape)
.background(
when {
focused && primary -> MembyAccent
focused -> MembyOutline
primary -> MembyAccentMuted
else -> Color.Transparent
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Color.White
primary -> MembyAccent.copy(alpha = 0.55f)
else -> MembyOutline
},
shape = shape,
)
.onFocusChanged { hasFocus = it.isFocused }
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = when {
focused -> Color.White
primary -> MembyAccentBright
else -> MembyMutedText
},
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
@@ -1691,122 +1691,6 @@ internal fun MediaRow(
}
}
@Composable
internal fun CastRail(
people: List<EmbyPerson>,
modifier: Modifier = Modifier,
compact: Boolean = false,
showTitle: Boolean = true,
) {
val distinctCast = remember(people) {
people.filter(EmbyPerson::isCastMember)
.distinctForKeys { "${it.id}:${it.name}" }
.take(16)
}
if (distinctCast.isEmpty()) return
Column(modifier) {
if (showTitle) {
Text(
"Cast",
color = Color.White,
fontSize = if (compact) 16.sp else 21.sp,
fontWeight = FontWeight.SemiBold,
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(if (compact) 10.dp else 16.dp),
contentPadding = PaddingValues(
top = if (showTitle) 7.dp else 3.dp,
end = 24.dp,
bottom = 4.dp,
),
) {
// 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)
}
}
}
}
@Composable
private fun CastCard(person: EmbyPerson, compact: Boolean) {
val repository = ServiceLocator.repository
val portrait = remember(person.id, person.primaryImageTag) {
repository.personImageUrl(person, if (compact) 180 else 280)
}
var focused by remember { mutableStateOf(false) }
val width = if (compact) 72.dp else 112.dp
val height = if (compact) 76.dp else 142.dp
val shape = RoundedCornerShape(MembyCardCorner)
val scale by animateFloatAsState(
targetValue = if (focused) 1.045f else 1f,
animationSpec = tween(110),
label = "cast-card-focus",
)
Column(
Modifier
.width(width)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.onFocusChanged { focused = it.isFocused }
.focusable(),
) {
Box(
Modifier
.fillMaxWidth()
.height(height)
.clip(shape)
.background(MembyControlSurface)
.border(
if (focused) 2.dp else 1.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape,
),
contentAlignment = Alignment.Center,
) {
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person,
contentDescription = null,
tint = QuietText,
modifier = Modifier.size(if (compact) 28.dp else 40.dp),
)
}
}
Text(
person.name,
color = Color.White,
fontSize = if (compact) 11.sp else 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 5.dp),
)
person.role?.takeIf(String::isNotBlank)?.let { role ->
Text(
role,
color = QuietText,
fontSize = if (compact) 9.sp else 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun FavoriteShowsEmptyState(
navigationFocusRequester: FocusRequester,
@@ -690,73 +690,8 @@ private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit
}
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
private fun ExitMembyConfirmation(
onStay: () -> Unit,
onExit: () -> Unit,
) {
val stayFocus = remember { FocusRequester() }
val exitFocus = remember { FocusRequester() }
BackHandler(onBack = onStay)
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(16L)
runCatching { stayFocus.requestFocus() }
}
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(20f)
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.width(480.dp)
.background(MembyControlSurface, RoundedCornerShape(18.dp))
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Close Memby?",
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"Choose Stay to keep browsing, or close Memby and return to your TV.",
color = MembyMutedText,
fontSize = 16.sp,
textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
Button(
onClick = onStay,
modifier = Modifier
.focusRequester(stayFocus)
.focusProperties {
left = FocusRequester.Cancel
right = exitFocus
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Stay in Memby") }
Button(
onClick = onExit,
modifier = Modifier
.focusRequester(exitFocus)
.focusProperties {
left = stayFocus
right = FocusRequester.Cancel
up = FocusRequester.Cancel
down = FocusRequester.Cancel
},
) { Text("Close Memby") }
}
}
}
}
// ExitMembyConfirmation lives in ui/ExitConfirmation.kt — it is drawn from the design
// tokens rather than from raw Material buttons, and is screenshot-tested on its own.
@androidx.media3.common.util.UnstableApi
@Composable
@@ -1,6 +1,6 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -23,9 +24,12 @@ import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.castMembers
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.franchiseStart
@@ -58,6 +62,7 @@ fun MediaDetailsOverlay(
.collectAsStateWithLifecycle(initialValue = ServiceLocator.repository.currentSettings)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var extras by remember(item.id) { mutableStateOf<List<BaseItem>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
@@ -65,6 +70,13 @@ fun MediaDetailsOverlay(
LaunchedEffect(item.id) {
trailer = item.takeIf { ServiceLocator.repository.hasTrailer(item.id) }
}
// Its own request, and never blocking: the Extras tab appears when the answer arrives
// and simply does not exist for the great majority of titles that have none. The
// repository caches the empty answer, so walking a "More like this" trail asks once per
// title rather than once per visit.
LaunchedEffect(item.id) {
extras = ServiceLocator.repository.getExtras(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
}
@@ -77,6 +89,7 @@ fun MediaDetailsOverlay(
onOpenItem = onOpenItem,
related = related,
trailer = trailer,
extras = extras,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
@@ -99,6 +112,7 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
extras: List<BaseItem> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
@@ -107,6 +121,8 @@ internal fun MediaDetailContent(
) {
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
val facts = remember(item.id, item.people, item.mediaStreams) { detailRows(item) }
val cast = remember(item.id, item.people) { castMembers(item) }
val visibleRelated = remember(related, hideWatchedMovies) {
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
}
@@ -114,39 +130,69 @@ internal fun MediaDetailContent(
val franchise = remember(item.id, item.collectionName, related?.items) {
franchiseStart(item, related?.items.orEmpty())
}
val tabs = remember(item.id) { detailTabs(isSeries = false) }
// The item arrives with whatever a home row asked for and is replaced by the full
// record moments later. Panes say "loading" rather than "nothing here" until then.
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
val remembered = remember(item.id) { detailPositions.get(item.id) }
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
val tabs = rememberDetailTabs(
itemId = item.id,
availability = DetailTabAvailability(
isSeries = false,
// Cast is optimistic only until the full record lands: a title that genuinely
// has nobody credited loses the tab rather than offering an apology.
hasCast = cast.isNotEmpty() || !detailsLoaded,
hasExtras = extras.isNotEmpty(),
// Answered for nearly every title, so the tab is there while the request runs
// and the pane says it is looking. Appearing late would be the common case.
hasRelated = related == null || visibleRelated.isNotEmpty(),
hasDetails = facts.isNotEmpty() || specs.isNotEmpty() || !detailsLoaded,
),
frozen = focusedZone != DetailZone.PLAY,
)
// Every newly opened title starts as a complete hero frame. Remembering a content
// tab also restored its focus and caused the page to reopen below the artwork.
var tabKey by remember(item.id, restorePosition) {
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key)
}
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane. Sharing a single "information pane" requester between the
// Overview and Cast & Details panes attached it to two nodes at once for the 80ms
// AnimatedContent spends fading the outgoing pane out, and a Down press landing in that
// window could request focus on the pane that is disappearing.
// One requester per pane. Sharing a single "information pane" requester between two
// panes attached it to two nodes at once for the 80ms AnimatedContent spends fading the
// outgoing pane out, and a Down press landing in that window could request focus on the
// pane that is disappearing.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
val detailsPane = remember(item.id) { FocusRequester() }
val castEntry = remember(item.id) { FocusRequester() }
val castEmpty = remember(item.id) { FocusRequester() }
val firstExtra = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val relatedListState = rememberLazyListState(remembered.relatedIndex)
val relatedGridState = rememberLazyGridState(remembered.relatedIndex)
val castGridState = rememberLazyGridState(remembered.castScrollIndex)
val extrasGridState = rememberLazyGridState()
// Which card the grid hands focus back to when the page is reopened. Held here rather
// than read out of the store on every recomposition so that moving around the grid does
// not re-key the card carrying the entry requester.
var castIndex by remember(item.id) {
mutableIntStateOf(if (restorePosition) remembered.castIndex else 0)
}
val contentEntry = when (selectedTab) {
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
DetailTab.CAST -> if (cast.isEmpty()) castEmpty else castEntry
DetailTab.EXTRAS -> firstExtra
DetailTab.DETAILS -> detailsPane
else -> overviewPane
}
DetailPositionMemory(
itemId = item.id,
tabKey = tabKey,
relatedIndex = { relatedListState.firstVisibleItemIndex },
relatedIndex = { relatedGridState.firstVisibleItemIndex },
castIndex = { castIndex },
castScrollIndex = { castGridState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
@@ -187,7 +233,10 @@ internal fun MediaDetailContent(
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
onZoneFocused = { zone ->
focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) }
},
heroActions = buildList {
add(
DetailHeroAction(
@@ -231,25 +280,71 @@ internal fun MediaDetailContent(
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
DetailTab.CAST -> DetailCastPane(
people = cast,
detailsLoaded = detailsLoaded,
state = castGridState,
entryIndex = castIndex,
entryFocusRequester = castEntry,
emptyFocusRequester = castEmpty,
aboveGrid = tabStrip,
onFocusIndex = { castIndex = it },
onSelect = { /* Person pages are not built yet; the card stays inert. */ },
)
DetailTab.EXTRAS -> DetailExtrasPane(
items = extras,
onPlay = onPlay,
firstFocusRequester = firstExtra,
gridState = extrasGridState,
aboveGrid = tabStrip,
)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
gridState = relatedGridState,
aboveGrid = tabStrip,
entryIndex = 0,
onFocusIndex = {},
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
DetailTab.DETAILS -> DetailDetailsPane(
rows = facts,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
focusRequester = detailsPane,
)
DetailTab.EPISODES -> Unit
}
}
}
/**
* The tab strip, recomputed as content arrives but held still once the viewer is inside it.
*
* Both failures this sits between are real. A tab that leads to an apology is dead weight on
* every set in the house; a tab appearing a second after the page opens shoves every tab to
* its right sideways under a thumb that is already moving. The rule is that the strip is
* live only while focus is in the hero which is where it is during the whole of the first
* second, and where somebody who has not started navigating still is.
*
* A tab is therefore never *removed* from under a viewer either, which matters more than it
* looks: if related titles come back empty while the Cast grid has focus, dropping the tab
* would renumber the strip mid-browse.
*/
@Composable
internal fun rememberDetailTabs(
itemId: String,
availability: DetailTabAvailability,
frozen: Boolean,
): List<DetailTab> {
var tabs by remember(itemId) { mutableStateOf(detailTabs(availability)) }
LaunchedEffect(itemId, availability, frozen) {
if (!frozen) tabs = detailTabs(availability)
}
return tabs
}
/**
* Writes the page's position back to [detailPositions] as it changes.
*
@@ -264,14 +359,23 @@ internal fun DetailPositionMemory(
season: Int? = null,
episodeIndex: () -> Int = { 0 },
relatedIndex: () -> Int = { 0 },
castIndex: () -> Int = { 0 },
castScrollIndex: () -> Int = { 0 },
) {
LaunchedEffect(itemId, tabKey, season) {
detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) }
}
LaunchedEffect(itemId) {
snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) ->
snapshotFlow {
listOf(episodeIndex(), relatedIndex(), castIndex(), castScrollIndex())
}.collect { (episode, relatedCard, castCard, castScroll) ->
detailPositions.update(itemId) {
it.copy(episodeIndex = episode, relatedIndex = relatedCard)
it.copy(
episodeIndex = episode,
relatedIndex = relatedCard,
castIndex = castCard,
castScrollIndex = castScroll,
)
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@@ -68,14 +70,15 @@ import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.availableSeasons
import com.ponzischeme89.memby.ui.detail.castMembers
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.defaultSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.detailTabs
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.formatAirDate
import com.ponzischeme89.memby.ui.detail.heroFacts
@@ -121,6 +124,7 @@ fun SeriesDetailsOverlay(
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var extras by remember(item.id) { mutableStateOf<List<BaseItem>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
@@ -148,6 +152,11 @@ fun SeriesDetailsOverlay(
LaunchedEffect(item.id) {
trailer = item.takeIf { repository.hasTrailer(item.id) }
}
// Independent of the episode request, like "more like this" above it: a show's specials
// and featurettes must not wait on its catalogue of episodes, nor hold it up.
LaunchedEffect(item.id) {
extras = repository.getExtras(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
@@ -163,6 +172,7 @@ fun SeriesDetailsOverlay(
onToggleMyShow = onToggleMyShow,
related = related,
trailer = trailer,
extras = extras,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
@@ -187,6 +197,7 @@ internal fun SeriesDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
extras: List<BaseItem> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
@@ -223,35 +234,55 @@ internal fun SeriesDetailContent(
}
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
val facts = remember(item.id, item.people, item.mediaStreams) { detailRows(item) }
val cast = remember(item.id, item.people) { castMembers(item) }
val visibleRelated = remember(related, hideWatchedMovies) {
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
}
// Fixed on the first frame from what the item *is*. A series has episodes, a cast and
// technical details whether or not they have arrived yet, and a strip that waits for
// the network is a strip that moves under the viewer's thumb.
val tabs = remember(item.id) { detailTabs(isSeries = true) }
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
// A series opens on its cinematic hero and Overview just like a movie. Season and
// rail positions are still remembered once the viewer enters Episodes.
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
// Episodes leads, because that is what a show is for; a series' synopsis is already in
// the hero, so there is no Overview tab to duplicate it. See `detailTabs` for why each
// of these reads the way it does while the request behind it is still in flight.
val tabs = rememberDetailTabs(
itemId = item.id,
availability = DetailTabAvailability(
isSeries = true,
hasCast = cast.isNotEmpty() || !detailsLoaded,
hasExtras = extras.isNotEmpty(),
hasRelated = related == null || visibleRelated.isNotEmpty(),
hasDetails = facts.isNotEmpty() || specs.isNotEmpty() || !detailsLoaded,
),
frozen = focusedZone != DetailZone.PLAY,
)
// A series opens on its cinematic hero and its episodes. Season and grid positions are
// still remembered once the viewer has been inside a tab.
var tabKey by rememberSaveable(item.id, restorePosition) {
mutableStateOf(if (restorePosition) remembered.tabKey else DetailTab.OVERVIEW.key)
mutableStateOf(if (restorePosition) remembered.tabKey else tabs.first().key)
}
val selectedTab = detailTab(tabKey, tabs)
val play = remember(item.id) { FocusRequester() }
val tabStrip = remember(item.id) { FocusRequester() }
// One requester per pane: Overview, Cast & Details and the Episodes empty states all
// shared a single "information pane" requester, which left it attached to two live
// nodes while AnimatedContent faded the outgoing one out.
val overviewPane = remember(item.id) { FocusRequester() }
val castPane = remember(item.id) { FocusRequester() }
// One requester per pane: they used to share a single "information pane" requester,
// which left it attached to two live nodes while AnimatedContent faded the outgoing one
// out.
val detailsPane = remember(item.id) { FocusRequester() }
val castEntry = remember(item.id) { FocusRequester() }
val castEmpty = remember(item.id) { FocusRequester() }
val firstExtra = remember(item.id) { FocusRequester() }
val episodesPane = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val firstRelated = remember(item.id) { FocusRequester() }
val seasonFocusRequesters = remember(seasons) { seasons.associateWith { FocusRequester() } }
val episodeListState = rememberLazyListState(remembered.episodeIndex)
val relatedListState = rememberLazyListState(remembered.relatedIndex)
val relatedGridState = rememberLazyGridState(remembered.relatedIndex)
val castGridState = rememberLazyGridState(remembered.castScrollIndex)
val extrasGridState = rememberLazyGridState()
var castIndex by remember(item.id) {
mutableIntStateOf(if (restorePosition) remembered.castIndex else 0)
}
// Every one of these targets has to be attached to something on screen *right now*. A
// `focusProperties` pointing at a requester that was never placed throws the moment the
@@ -269,10 +300,11 @@ internal fun SeriesDetailContent(
val belowSeasons = if (hasEpisodeCards) firstEpisode else FocusRequester.Default
val aboveEpisodes = selectedSeasonChip ?: tabStrip
val contentEntry = when (selectedTab) {
DetailTab.EPISODES -> episodeEntry
DetailTab.MORE_LIKE_THIS -> firstRelated
DetailTab.CAST_DETAILS -> castPane
else -> overviewPane
DetailTab.CAST -> if (cast.isEmpty()) castEmpty else castEntry
DetailTab.EXTRAS -> firstExtra
DetailTab.DETAILS -> detailsPane
else -> episodeEntry
}
DetailPositionMemory(
@@ -280,7 +312,9 @@ internal fun SeriesDetailContent(
tabKey = tabKey,
season = selectedSeason,
episodeIndex = { episodeListState.firstVisibleItemIndex },
relatedIndex = { relatedListState.firstVisibleItemIndex },
relatedIndex = { relatedGridState.firstVisibleItemIndex },
castIndex = { castIndex },
castScrollIndex = { castGridState.firstVisibleItemIndex },
)
RestoreDetailFocus(
itemId = item.id,
@@ -326,7 +360,10 @@ internal fun SeriesDetailContent(
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
onZoneFocused = { zone ->
focusedZone = zone
detailPositions.update(item.id) { it.copy(zone = zone) }
},
heroActions = buildList {
add(
DetailHeroAction(
@@ -361,14 +398,7 @@ internal fun SeriesDetailContent(
},
) { visibleTab ->
when (visibleTab) {
DetailTab.OVERVIEW -> DetailOverviewPane(
item = item,
credits = credits,
focusRequester = overviewPane,
// The progress bar carries the position, so this only names the episode.
supportingText = nextEpisode?.let { "Up next ${episodeHeadline(it)}" },
)
DetailTab.EPISODES -> EpisodesPane(
DetailTab.EPISODES, DetailTab.OVERVIEW -> EpisodesPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
seasons = seasons,
@@ -386,19 +416,39 @@ internal fun SeriesDetailContent(
onSelectSeason = { selectedSeason = it },
onPlay = onPlay,
)
DetailTab.CAST -> DetailCastPane(
people = cast,
detailsLoaded = detailsLoaded,
state = castGridState,
entryIndex = castIndex,
entryFocusRequester = castEntry,
emptyFocusRequester = castEmpty,
aboveGrid = tabStrip,
onFocusIndex = { castIndex = it },
onSelect = { /* Person pages are not built yet; the card stays inert. */ },
)
DetailTab.EXTRAS -> DetailExtrasPane(
items = extras,
onPlay = onPlay,
firstFocusRequester = firstExtra,
gridState = extrasGridState,
aboveGrid = tabStrip,
)
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
items = visibleRelated,
loading = related == null,
onSelect = onOpenItem,
firstFocusRequester = firstRelated,
listState = relatedListState,
gridState = relatedGridState,
aboveGrid = tabStrip,
entryIndex = 0,
onFocusIndex = {},
)
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
item = item,
credits = credits,
DetailTab.DETAILS -> DetailDetailsPane(
rows = facts,
specs = specs,
detailsLoaded = detailsLoaded,
focusRequester = castPane,
focusRequester = detailsPane,
)
}
}
@@ -0,0 +1,249 @@
package com.ponzischeme89.memby.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
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.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.player.castInitials
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
/**
* The Cast tab: every credited actor as a portrait card, in a grid that fills the pane.
*
* It replaces a compact rail squeezed into the corner of a combined "Cast & Details" pane,
* which showed six faces of a forty-strong cast and gave the other thirty-four no way of
* being reached. A grid is the right shape because the question "who is that?" is
* answered by scanning faces, and a row of six is not a scan.
*/
/**
* The card's width at its narrowest.
*
* Tuned by looking rather than reasoned about, at the one geometry that matters: a 1080p
* television is 540dp tall, and once the collapsed hero and the strip have taken their share
* the pane has about 320dp. A portrait card much wider than this fills that with a single row
* and nothing under it which reads as a shelf that failed to load rather than as a grid
* with more below. At this width the second row peeks, which is the only thing on screen
* saying Down goes somewhere.
*/
private val CastCardMinWidth = 112.dp
/** Widest a card is allowed to grow to when the columns divide generously. */
private val CastCardMaxWidth = 150.dp
private val CastGridSpacing = 16.dp
/**
* How many cards fit across [available], as a whole number.
*
* A fixed column count rather than `GridCells.Adaptive` on purpose: Up out of the first row
* has to return to the tab strip, and the only way to know which cards are in the first row
* is to know how many there are. Adaptive decides that during measurement, where the focus
* properties have already been composed.
*
* Pure so the arithmetic is checkable a television at 960dp and one at 1280dp must both
* land on a whole number of sensibly sized cards rather than four fat ones or nine slivers.
*/
fun castColumns(available: Dp, minWidth: Dp = CastCardMinWidth, maxWidth: Dp = CastCardMaxWidth): Int {
if (available <= 0.dp) return 1
val spacing = CastGridSpacing
// The most cards that still leave each one at least minWidth, then walked back until
// none of them exceeds maxWidth — a five-card row of 220dp portraits reads as a poster
// shelf rather than as a cast list.
var columns = ((available + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
while (columns < 12 && (available - spacing * (columns - 1)) / columns > maxWidth) {
columns += 1
}
return columns
}
/**
* @param entryIndex the card focus returns to when the viewer comes back to this page.
* @param entryFocusRequester attached to that card, so the scaffold can hand focus to it.
* @param aboveGrid where Up out of the first row goes the tab strip.
*/
@Composable
internal fun CastGrid(
people: List<EmbyPerson>,
state: LazyGridState,
entryIndex: Int,
entryFocusRequester: FocusRequester,
aboveGrid: FocusRequester,
onFocusIndex: (Int) -> Unit,
onSelect: (EmbyPerson) -> Unit,
modifier: Modifier = Modifier,
imageUrlFor: (EmbyPerson) -> String? = { null },
) {
BoxWithConstraints(modifier.fillMaxSize()) {
val columns = remember(maxWidth) { castColumns(maxWidth) }
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
state = state,
modifier = Modifier.fillMaxSize().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(CastGridSpacing),
verticalArrangement = Arrangement.spacedBy(18.dp),
// The focus ring is drawn outside the card's own bounds, and a card scaled up
// against the edge of the pane loses its highlight to the clip. This is the
// room it needs, top and bottom included.
contentPadding = PaddingValues(start = 4.dp, end = 12.dp, top = 6.dp, bottom = 24.dp),
) {
itemsIndexed(people, key = { _, person -> castKey(person) }) { index, person ->
CastGridCard(
person = person,
portrait = imageUrlFor(person),
onClick = { onSelect(person) },
onFocused = { onFocusIndex(index) },
modifier = Modifier
.then(
if (index == entryIndex) Modifier.focusRequester(entryFocusRequester)
else Modifier,
)
// Only the first row escapes upward. Everything below it keeps the
// grid's own vertical navigation, which is what scrolls the pane.
.focusProperties {
up = if (index < columns) aboveGrid else FocusRequester.Default
},
)
}
}
}
}
/**
* One face.
*
* The initials are drawn *behind* the portrait rather than instead of it, the stance the
* player's cast panel takes: Emby has no photo for a good part of a typical cast, and
* nothing has to decide in advance whether artwork will arrive.
*/
@Composable
private fun CastGridCard(
person: EmbyPerson,
portrait: String?,
onClick: () -> Unit,
onFocused: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (focused) 1.06f else 1f,
animationSpec = tween(110),
label = "cast-grid-focus",
)
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = scale
scaleY = scale
translationY = if (focused) -3f else 0f
}
.zIndex(if (focused) 1f else 0f)
.testTag("cast-card-${person.name}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clickable(onClick = onClick),
) {
Box(
Modifier
.fillMaxWidth()
// Portrait, and the same ratio for every card whatever Emby returns, so the
// grid reads as rows rather than as a ragged wall.
.aspectRatio(2f / 3f)
.clip(shape)
.background(MembyControlSurface)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
shape = shape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = castInitials(person.name),
color = MembyQuietText,
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
)
if (portrait != null) {
AsyncImage(
model = portrait,
contentDescription = person.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(shape),
)
}
}
Spacer(Modifier.height(7.dp))
Text(
text = person.name,
color = if (focused) Color.White else MembyOnSurface,
fontSize = 13.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
// Reserved rather than conditional: a card with no role recorded must not sit a
// line higher than the one beside it, or the grid's baselines go ragged.
Text(
text = person.role?.takeIf(String::isNotBlank) ?: " ",
color = MembyMutedText,
fontSize = 11.sp,
lineHeight = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
@@ -137,46 +137,6 @@ private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
/**
* The sections a detail page can show, in the order the strip lists them.
*
* Overview is always present and always first: it is the only one guaranteed to have
* something in it, so it is what the page can safely open on.
*/
enum class DetailTab(val key: String, val label: String) {
OVERVIEW("overview", "Overview"),
MORE_LIKE_THIS("more-like-this", "More Like This"),
EPISODES("episodes", "Episodes"),
CAST_DETAILS("cast-details", "Cast & Details"),
}
/**
* The strip for one item, decided by *what the item is* never by what has arrived from
* the network so far.
*
* This used to offer only the sections that already had content, on the reasoning that an
* empty tab is worse than a missing one. On a real TV it was worse than either: a movie
* opens with its list-row metadata, so Cast and Details appeared a second later and shoved
* the strip sideways under the viewer's thumb, and a series that failed to load its
* episodes lost a tab the household knows is there. A strip that depends only on
* [isSeries] is decided on the first frame and never moves again; a section with nothing
* in it yet says so in its own pane, where saying so costs nobody a keypress.
*/
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
add(DetailTab.OVERVIEW)
if (isSeries) add(DetailTab.EPISODES)
add(DetailTab.MORE_LIKE_THIS)
add(DetailTab.CAST_DETAILS)
}
/**
* Resolves a remembered tab key against what is on offer. The strip no longer changes
* under a page, so this only has to catch a key remembered from an item of the other kind
* a series' Episodes tab carried over to a movie.
*/
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
/**
* Resolution, codecs and studio the things a viewer checks before settling in, kept out
* of the headline because none of them decide what to watch.
@@ -19,6 +19,17 @@ data class DetailPosition(
val zone: DetailZone = DetailZone.PLAY,
val episodeIndex: Int = 0,
val relatedIndex: Int = 0,
/**
* Which cast card was under focus, and how far the grid had been scrolled.
*
* Two numbers rather than one because a grid can be scrolled past its focused card and
* back: [castIndex] is what the viewer was standing on and is what focus returns to,
* [castScrollIndex] is the first visible row and is what stops the grid jumping to put
* that card at the top. Selecting an actor is the case this exists for a fifty-strong
* cast is several screens, and coming back to the top of it is coming back to nothing.
*/
val castIndex: Int = 0,
val castScrollIndex: Int = 0,
)
/**
@@ -0,0 +1,245 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import java.util.Locale
/**
* What a detail page is divided into, and which of those divisions this particular title
* has anything to put in.
*
* Everything here is pure. The strip is the one part of the page a viewer navigates
* blindly they learn where Cast is and press Right twice so the rules deciding what is
* in it and in what order have to be checkable without a television.
*/
/**
* The sections a detail page can show, in the order the strip lists them.
*
* The `key` is what [DetailPosition] remembers, so it outlives the enum's ordinal and must
* not be renamed casually. `cast-details` is deliberately absent: the combined pane it named
* has been split, and a key remembered from a build that had it resolves through
* [detailTab] to whatever the page now opens on.
*/
enum class DetailTab(val key: String, val label: String) {
/** A movie's landing pane: the full synopsis and the handful of facts worth leading with. */
OVERVIEW("overview", "Overview"),
/** A series' landing pane. The seasons and their episodes are what a show is *for*. */
EPISODES("episodes", "Episodes"),
CAST("cast", "Cast"),
/** Trailers, featurettes, deleted scenes — whatever Emby files as a special feature. */
EXTRAS("extras", "Extras"),
MORE_LIKE_THIS("more-like-this", "More Like This"),
/** Everything catalogue and technical: release, studios, writers, codecs. */
DETAILS("details", "Details"),
}
/**
* What this title actually has, as the strip needs to know it.
*
* Deliberately four booleans rather than the content itself: the availability rule is
* separable from the panes, and it is the half that has to be right the instant the page
* opens. Each flag carries its own answer for the "not known yet" case and they are not the
* same answer see [detailTabs].
*/
data class DetailTabAvailability(
val isSeries: Boolean,
val hasCast: Boolean = false,
val hasExtras: Boolean = false,
val hasRelated: Boolean = true,
val hasDetails: Boolean = true,
)
/**
* The strip for one item: its landing pane, then only the sections with something in them.
*
* A tab that opens onto an apology is worse than one that is not there it costs a keypress
* on every set in the house, for ever, to learn nothing. But the opposite failure is real
* too and was what the previous fixed strip existed to avoid: a tab appearing a second after
* the page opens shoves every tab to its right sideways under a viewer's thumb. Three rules
* keep both at bay:
*
* - **The landing pane is structural** and always first, so the tab under focus on the
* opening frame can never move: Episodes for a series, Overview for everything else.
* - **Optional tabs are only ever appended in this fixed order**, so one arriving can shift
* the tabs after it but can never reorder them.
* - **Each flag's "still loading" value is chosen by which way it is usually wrong.** Related
* titles are answered for nearly everything, so the caller passes `true` while the request
* is in flight and the pane says it is looking; extras exist for a small minority, so the
* caller passes `false` and the tab appears once one is actually found. The alternative in
* each case is a strip that flickers on the majority of titles.
*
* The caller freezes the result once focus has left the hero see `rememberDetailTabs`
* which is what stops any of this moving while somebody is reading it.
*/
fun detailTabs(availability: DetailTabAvailability): List<DetailTab> = buildList {
add(if (availability.isSeries) DetailTab.EPISODES else DetailTab.OVERVIEW)
if (availability.hasCast) add(DetailTab.CAST)
if (availability.hasExtras) add(DetailTab.EXTRAS)
if (availability.hasRelated) add(DetailTab.MORE_LIKE_THIS)
if (availability.hasDetails) add(DetailTab.DETAILS)
}
/**
* Resolves a remembered tab key against what is on offer, falling back to the page's landing
* pane.
*
* Three things reach here with a key that is not in the list and all of them are ordinary: a
* series' Episodes carried over to a movie, a tab whose content has since gone away, and
* `cast-details` remembered by a build that predates the split. [available] is never empty
* [detailTabs] always adds a landing pane but an empty list still has to answer something.
*/
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
available.firstOrNull { it.key == key }
?: available.firstOrNull()
?: DetailTab.OVERVIEW
/**
* The cast, as the grid draws it: actors only, no duplicates, in Emby's billing order.
*
* Emby lists the same person twice on a good fraction of a real cast credited under two
* roles, or written twice by an agent and a keyed grid handed one key twice throws. The
* rule is `ui/ListKeys.kt`'s: deduplicate, never disambiguate, because folding the index in
* would key a card by where it is and position is exactly what has to survive returning to
* the page.
*
* Unbounded, unlike the rail this replaces: a grid that scrolls has no reason to stop at
* sixteen, and a large ensemble is precisely the case the grid exists for.
*/
fun castMembers(item: BaseItem): List<EmbyPerson> {
val seen = HashSet<String>()
return item.people.filter { person ->
person.isCastMember &&
person.name.isNotBlank() &&
seen.add(castKey(person))
}
}
/** The grid's key for one card: an id when Emby gave one, the name when it did not. */
fun castKey(person: EmbyPerson): String =
"${person.id}:${person.name.lowercase(Locale.US)}"
/**
* The catalogue half of the Details tab: what this title *is*, as label/value pairs.
*
* Kept apart from [technicalSpecs], which answers "will it play?", and from [creditRows],
* which is the short billing summary a pane leads with. Every row is omitted rather than
* printed empty a Details tab full of dashes says the library is broken.
*/
fun detailRows(item: BaseItem): List<TechnicalSpec> = buildList {
// The premiere date when Emby has one, because "12 Mar 2024" is strictly more than the
// year it contains; the year alone when it does not.
val released = formatAirDate(item.premiereDate) ?: item.productionYear?.toString()
released?.let { add(TechnicalSpec("Released", it)) }
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
item.genres.filter(String::isNotBlank).takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec("Genres", it.joinToString(", "))) }
peopleJoined(item, "Director", limit = 3)?.let {
add(TechnicalSpec(if (it.contains(",")) "Directors" else "Director", it))
}
peopleJoined(item, "Writer", limit = 3)?.let {
add(TechnicalSpec(if (it.contains(",")) "Writers" else "Writer", it))
}
item.studios.map { it.name }.filter(String::isNotBlank).distinct().take(3)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec(if (it.size > 1) "Studios" else "Studio", it.joinToString(", "))) }
// Only when it says something the heading does not. A title whose original name is its
// name is the common case, and printing it is a row that reads as a mistake.
item.originalTitle?.trim()
?.takeIf { it.isNotEmpty() && !it.equals(item.name.trim(), ignoreCase = true) }
?.let { add(TechnicalSpec("Original title", it)) }
item.productionLocations.filter(String::isNotBlank).distinct().take(3)
.takeIf(List<String>::isNotEmpty)
?.let { add(TechnicalSpec(if (it.size > 1) "Countries" else "Country", it.joinToString(", "))) }
audioLanguages(item)?.let {
add(TechnicalSpec(if (it.contains(",")) "Languages" else "Language", it))
}
}
private fun peopleJoined(item: BaseItem, type: String, limit: Int): String? =
item.people
.filter { it.type.equals(type, ignoreCase = true) }
.map { it.name }
.filter(String::isNotBlank)
.distinct()
.take(limit)
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
/**
* The spoken languages a file carries, from its audio tracks.
*
* Emby writes three-letter codes; the ones a household actually meets are named and anything
* else is passed through uppercased, which is more use than dropping it. Deliberately not
* the subtitle tracks those are a count in [technicalSpecs], and a title with fourteen
* subtitle languages would fill this row with a paragraph.
*/
private fun audioLanguages(item: BaseItem): String? =
item.mediaStreams
.filter { it.type.equals("Audio", ignoreCase = true) }
.mapNotNull { it.language?.trim()?.takeIf(String::isNotEmpty) }
.map { code -> LANGUAGE_NAMES[code.lowercase(Locale.US)] ?: code.uppercase(Locale.US) }
.distinct()
.take(4)
.takeIf(List<String>::isNotEmpty)
?.joinToString(", ")
private val LANGUAGE_NAMES = mapOf(
"eng" to "English", "en" to "English",
"fra" to "French", "fre" to "French", "fr" to "French",
"deu" to "German", "ger" to "German", "de" to "German",
"spa" to "Spanish", "es" to "Spanish",
"ita" to "Italian", "it" to "Italian",
"jpn" to "Japanese", "ja" to "Japanese",
"kor" to "Korean", "ko" to "Korean",
"zho" to "Chinese", "chi" to "Chinese", "zh" to "Chinese",
"por" to "Portuguese", "pt" to "Portuguese",
"rus" to "Russian", "ru" to "Russian",
"nld" to "Dutch", "dut" to "Dutch", "nl" to "Dutch",
"swe" to "Swedish", "sv" to "Swedish",
"nor" to "Norwegian", "no" to "Norwegian",
"dan" to "Danish", "da" to "Danish",
"fin" to "Finnish", "fi" to "Finnish",
"pol" to "Polish", "pl" to "Polish",
"hin" to "Hindi", "hi" to "Hindi",
"ara" to "Arabic", "ar" to "Arabic",
"mri" to "Māori", "mao" to "Māori", "mi" to "Māori",
)
/**
* How an extra is described under its thumbnail "Trailer", "Behind the Scenes".
*
* Emby's `Type` on a special feature is one of a small vocabulary, and it is written in
* PascalCase; anything unrecognised is spaced out rather than dropped, so a category added
* to Emby tomorrow reads correctly on today's build.
*/
fun extraKindLabel(item: BaseItem): String = when (item.type.lowercase(Locale.US)) {
"trailer" -> "Trailer"
"teaser" -> "Teaser"
"clip" -> "Clip"
"featurette" -> "Featurette"
"short" -> "Short"
"deletedscene" -> "Deleted Scene"
"behindthescenes" -> "Behind the Scenes"
"interview" -> "Interview"
"scene" -> "Scene"
"sample" -> "Sample"
"themevideo", "thememedia" -> "Theme"
"" -> "Extra"
else -> spacedPascalCase(item.type)
}
private fun spacedPascalCase(raw: String): String {
val builder = StringBuilder(raw.length + 4)
raw.forEachIndexed { index, character ->
if (index > 0 && character.isUpperCase() && !raw[index - 1].isUpperCase()) builder.append(' ')
builder.append(character)
}
return builder.toString()
}
@@ -1,62 +1,142 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.ui.detail.DetailPosition
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailTabAvailability
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.detailTab
import com.ponzischeme89.memby.ui.detail.castColumns
import com.ponzischeme89.memby.ui.detail.detailTabs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
* decided by what the item *is*, and a page's position survives being closed.
* The rules the detail pages depend on and cannot check on a device: what the tab strip
* offers, whether the hero is out of the way, and whether a page's position survives being
* closed.
*/
class DetailNavigationTest {
private val everything = DetailTabAvailability(
isSeries = false,
hasCast = true,
hasExtras = true,
hasRelated = true,
hasDetails = true,
)
@Test
fun `a movie always offers the same three tabs`() {
fun `a movie leads with overview and lists its sections in a fixed order`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = false),
listOf(
DetailTab.OVERVIEW,
DetailTab.CAST,
DetailTab.EXTRAS,
DetailTab.MORE_LIKE_THIS,
DetailTab.DETAILS,
),
detailTabs(everything),
)
}
/** A show's synopsis is already in the hero, so Episodes leads and there is no Overview. */
@Test
fun `a series always offers episodes`() {
fun `a series leads with episodes`() {
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
detailTabs(isSeries = true),
listOf(
DetailTab.EPISODES,
DetailTab.CAST,
DetailTab.EXTRAS,
DetailTab.MORE_LIKE_THIS,
DetailTab.DETAILS,
),
detailTabs(everything.copy(isSeries = true)),
)
}
/** A tab that would open onto an apology is not offered at all. */
@Test
fun `a section with nothing in it is left out`() {
assertEquals(
listOf(DetailTab.EPISODES, DetailTab.CAST, DetailTab.MORE_LIKE_THIS, DetailTab.DETAILS),
detailTabs(everything.copy(isSeries = true, hasExtras = false)),
)
assertEquals(
listOf(DetailTab.OVERVIEW, DetailTab.DETAILS),
detailTabs(everything.copy(hasCast = false, hasExtras = false, hasRelated = false)),
)
}
/**
* The regression this replaced: the strip used to be built from what had loaded, so a
* movie opened with one tab and grew two more when its metadata arrived moving the
* strip under whatever the viewer was already pressing.
* The landing pane is structural. Whatever else is missing, the tab under focus on the
* opening frame is there and is first so a section arriving late can shift the tabs
* after it but can never move the one the viewer is already standing on.
*/
@Test
fun `the strip does not depend on loaded metadata`() {
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
fun `the landing pane is always present and always first`() {
val nothing = DetailTabAvailability(
isSeries = false,
hasCast = false,
hasExtras = false,
hasRelated = false,
hasDetails = false,
)
assertEquals(listOf(DetailTab.OVERVIEW), detailTabs(nothing))
assertEquals(listOf(DetailTab.EPISODES), detailTabs(nothing.copy(isSeries = true)))
assertEquals(DetailTab.OVERVIEW, detailTabs(everything).first())
assertEquals(DetailTab.EPISODES, detailTabs(everything.copy(isSeries = true)).first())
}
/** Optional tabs are only appended, so their relative order never changes. */
@Test
fun `optional tabs keep their order as they appear`() {
val order = detailTabs(everything)
listOf(
everything.copy(hasCast = false),
everything.copy(hasExtras = false),
everything.copy(hasRelated = false),
everything.copy(hasDetails = false),
).forEach { partial ->
val offered = detailTabs(partial)
assertEquals(offered, order.filter { it in offered })
}
}
@Test
fun `an episodes key remembered from a series falls back on a movie`() {
assertEquals(
DetailTab.OVERVIEW,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
)
assertEquals(DetailTab.OVERVIEW, detailTab(DetailTab.EPISODES.key, detailTabs(everything)))
assertEquals(
DetailTab.EPISODES,
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
detailTab(DetailTab.EPISODES.key, detailTabs(everything.copy(isSeries = true))),
)
}
/** A build that predates the split remembered one key for what is now two tabs. */
@Test
fun `an unknown key falls back to overview`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
fun `the retired cast-details key resolves to the landing pane`() {
assertEquals(DetailTab.OVERVIEW, detailTab("cast-details", detailTabs(everything)))
assertEquals(
DetailTab.EPISODES,
detailTab("cast-details", detailTabs(everything.copy(isSeries = true))),
)
}
/** A tab whose content has since gone away is the same case as one that never existed. */
@Test
fun `an unknown key falls back to whatever the page leads with`() {
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(everything)))
assertEquals(DetailTab.OVERVIEW, detailTab(DetailTab.EXTRAS.key, listOf()))
}
/** Two tabs must never share a key: the key is what a reopened page is restored by. */
@Test
fun `every tab key is distinct`() {
val keys = DetailTab.entries.map(DetailTab::key)
assertEquals(keys.size, keys.distinct().size)
}
@Test
@@ -87,26 +167,52 @@ class DetailNavigationTest {
assertEquals(null, position.season)
}
/**
* The whole hero/tab relationship. Focus on Play is the complete opening frame; anywhere
* below it and the hero is out of the way with the strip at the top of the usable area.
*/
@Test
fun `play focus pins the complete hero while lower zones release scrolling`() {
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
fun `the hero is whole on play and collapsed everywhere below it`() {
assertFalse(detailHeroCollapsed(DetailZone.PLAY))
assertTrue(detailHeroCollapsed(DetailZone.TABS))
assertTrue(detailHeroCollapsed(DetailZone.CONTENT))
assertTrue(detailHeroCollapsed(DetailZone.RELATED))
}
/**
* A whole number of sensibly sized cards at every television width this ships to, and a
* count that never drops below one a grid asked for zero columns crashes.
*
* The bounds are passed in rather than taken from the defaults so this pins the *rule*
* and not the tuning: changing how wide a cast card is must not need this edited, but a
* change that starts producing cards outside the range it was asked for must fail.
*/
@Test
fun `the cast grid divides the width into whole cards`() {
val min = 112.dp
val max = 150.dp
listOf(0, 100, 480, 720, 840, 960, 1180, 1280, 1600, 1920).forEach { width ->
val columns = castColumns(width.dp, min, max)
assertTrue("$width dp gave $columns columns", columns >= 1)
if (width.dp >= min) {
val card = (width.dp - 16.dp * (columns - 1)) / columns
assertTrue("$width dp gave cards of $card", card >= min && card <= max)
}
}
}
@Test
fun `the store is capped and keeps the most recently used`() {
val store = DetailPositionStore(maxEntries = 3)
listOf("a", "b", "c").forEach { id ->
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
store.update(id) { it.copy(tabKey = DetailTab.CAST.key) }
}
// Touching "a" makes it the newest, so "b" is what falls out.
store.get("a")
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
assertEquals(3, store.size())
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
assertEquals(DetailTab.CAST.key, store.get("a").tabKey)
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
}
@@ -114,7 +220,7 @@ class DetailNavigationTest {
@Test
fun `a blank item id is never stored`() {
val store = DetailPositionStore()
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
store.update("") { it.copy(tabKey = DetailTab.CAST.key) }
assertEquals(0, store.size())
}
@@ -24,6 +24,7 @@ import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.model.UserItemData
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.detailRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import org.junit.Before
import org.junit.Rule
@@ -171,9 +172,14 @@ class DetailPageScreenshotTest {
}
}
/**
* The collapsed hero. Clicking a tab is the same path a remote takes, so every one of
* these captures is also the check that the hero has got out of the way and the strip
* has moved to the top of the usable area which is what the whole redesign is for.
*/
@Test
fun `series cast tab`() {
captureTab("df_detail-series-cast-details", "Cast & Details") {
captureTab("df_detail-series-cast", "Cast") {
SeriesDetailContent(
item = series,
episodes = episodes,
@@ -187,8 +193,22 @@ class DetailPageScreenshotTest {
}
}
/** A large ensemble: the case the grid exists for, and the one a rail could not show. */
@Test
fun `movie details tab`() {
fun `movie cast tab with a large ensemble`() {
captureTab("df_detail-movie-cast-large", "Cast") {
MediaDetailContent(
item = movie.copy(people = largeCast),
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
@Test
fun `movie more like this tab`() {
captureTab("df_detail-movie-more-like-this", "More Like This") {
MediaDetailContent(
item = movie,
@@ -200,18 +220,45 @@ class DetailPageScreenshotTest {
}
}
@Test
fun `movie details tab`() {
captureTab("df_detail-movie-details", "Details") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
)
}
}
/** Only offered when there is something in it, which is why the fixture supplies some. */
@Test
fun `movie extras tab`() {
captureTab("df_detail-movie-extras", "Extras") {
MediaDetailContent(
item = movie,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
related = related,
extras = extras,
)
}
}
/**
* The tab panes at the geometry the scaffold gives them on a 540dp TV the slot the
* audit found clipping every technical spec off the bottom of Cast & Details, which is
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
* has to be inside this box.
* The tab panes at the geometry the scaffold gives them on a 540dp TV. A tab does not
* scroll vertically except where it is a grid, so everything a pane has to say has to be
* inside this box the audit that produced the Details tab found every technical spec
* clipped off the bottom of the pane this replaced.
*/
@Test
fun `cast and details pane at its real slot geometry`() {
capturePane("df_detail-pane-cast-details") {
DetailCastAndDetailsPane(
item = movie,
credits = creditRows(movie),
fun `details pane at its real slot geometry`() {
capturePane("df_detail-pane-details") {
DetailDetailsPane(
rows = detailRows(movie),
specs = technicalSpecs(movie),
detailsLoaded = true,
focusRequester = FocusRequester(),
@@ -223,10 +270,9 @@ class DetailPageScreenshotTest {
fun `overview pane at its real slot geometry`() {
capturePane("df_detail-pane-overview") {
DetailOverviewPane(
item = series,
credits = creditRows(series),
item = movie,
credits = creditRows(movie),
focusRequester = FocusRequester(),
supportingText = "Up next S1 E3 · The Long Count",
)
}
}
@@ -299,6 +345,33 @@ class DetailPageScreenshotTest {
person("Daniel Cho", "Weatherman"),
)
/**
* Thirty-one names, one of them with no role recorded. The grid has to stay on its
* baselines through both a card missing its character line used to sit a row higher
* than the ones beside it.
*/
private val largeCast = List(31) { index ->
person(
name = "${FIRST_NAMES[index % FIRST_NAMES.size]} ${LAST_NAMES[index % LAST_NAMES.size]}",
role = if (index == 4) "" else "${ROLES[index % ROLES.size]} ${index + 1}",
)
}
private val extras = listOf(
extra("extra-1", "Official Trailer", "Trailer", 2),
extra("extra-2", "Four Winters in the Valley", "Featurette", 21),
extra("extra-3", "The River That Was", "BehindTheScenes", 14),
extra("extra-4", "Deleted: The Second Crossing", "DeletedScene", 4),
extra("extra-5", "Interview with the Cartographer", "Interview", 11),
)
private fun extra(id: String, name: String, type: String, minutes: Int) = BaseItem(
id = id,
name = name,
type = type,
runTimeTicks = minutes * 600_000_000L,
)
private val streams = listOf(
MediaStream(
type = "Video",
@@ -383,4 +456,16 @@ class DetailPageScreenshotTest {
role = role,
type = "Actor",
)
private companion object {
val FIRST_NAMES = listOf(
"Aria", "Marcus", "Nina", "Tomas", "Ines", "Daniel", "Rangi", "Hanna",
"Petra", "Osian", "Mei", "Levi", "Sofia", "Amara", "Juno", "Kofi",
)
val LAST_NAMES = listOf(
"Vance", "Oyelaran", "Kowalczyk", "Brandt", "Ferreira", "Cho", "Whitiora",
"Lindqvist", "Novak", "Ellery", "Tanaka", "Marchetti", "Okonjo",
)
val ROLES = listOf("Detective", "Doctor", "Captain", "Surveyor", "Radio Operator")
}
}
@@ -0,0 +1,129 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
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
/**
* Renders the "Close Memby?" panel to PNGs under `build/screenshots/exit-confirmation/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ExitConfirmationScreenshotTest"
* ```
*
* It is drawn over a stand-in launcher rather than over black, because the whole point of
* the wash behind it is that the library stays faintly readable underneath a capture on
* a blank background would prove nothing about the one thing worth looking at.
*
* The second capture repaints under a different palette. This panel is the only full-stop
* dialog in the app and it is now drawn entirely from the tokens, so a theme that cannot
* reach it would be visible here and nowhere else.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ExitConfirmationScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `over the launcher`() {
capture("exit-confirmation")
}
/**
* The destructive action under the focus ring the one state a remote can reach that
* the default capture cannot show, and the one worth checking reads as deliberate
* rather than as the safe choice.
*/
@Test
fun `closing under focus`() {
capture("exit-confirmation-close-focused", focused = ExitChoice.CLOSE)
}
/** How the panel opens: the safe answer already holding the ring. */
@Test
fun `staying under focus`() {
capture("exit-confirmation-stay-focused", focused = ExitChoice.STAY)
}
@Test
fun `under a themed palette`() {
applyMembyPalette(
MembyPalette(
surface = androidx.compose.ui.graphics.Color(0xFF120A16),
surfaceRaised = androidx.compose.ui.graphics.Color(0xFF1D1224),
accent = androidx.compose.ui.graphics.Color(0xFFE0803A),
),
)
try {
capture("exit-confirmation-themed")
} finally {
applyMembyPalette(MembyPalette())
}
}
private fun capture(name: String, focused: ExitChoice? = null) {
compose.setContent {
Box(Modifier.fillMaxSize().background(MembySurface)) {
StandInLauncher()
// Settled rather than mid-entrance: the frame worth looking at is the one
// somebody actually reads.
ExitMembyConfirmation(
onStay = {},
onExit = {},
animateIn = false,
// Robolectric's window never takes focus, so a capture that pressed
// its way onto a button would photograph every action unfocused.
focusedForCapture = focused,
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/exit-confirmation/$name.png")
}
/** Enough of a shelf behind the wash to judge how much of it still shows through. */
@Composable
private fun StandInLauncher() {
Column(Modifier.fillMaxWidth().padding(40.dp)) {
Box(
Modifier.width(220.dp).height(26.dp)
.clip(RoundedCornerShape(6.dp)).background(MembyControlSurface),
)
Box(Modifier.height(24.dp))
Row {
repeat(6) {
Box(
Modifier.padding(end = 18.dp).width(160.dp).height(240.dp)
.clip(RoundedCornerShape(10.dp)).background(MembyControlSurface),
)
}
}
}
}
}
+5
View File
@@ -277,6 +277,11 @@ weekday and local time. Cards also show season/episode number, episode title and
upcoming, downloading, awaiting download, unmonitored, or the exact time Sonarr added
the episode file.
Only shows the household actually follows appear. Sonarr's calendar is read with
`unmonitored=true`, because the calendar page and the aired banners want the whole
picture, but an unmonitored show is never coming and so is dropped from this row. An
episode arriving with no expanded series record is kept — that is no evidence either way.
This row is informational. An episode listed before it is downloaded is not an Emby
item, so the TV deliberately does not offer Play, Favourite or Watched actions on it.
Sonarr poster and fanart requests are proxied through the gateway; its API key is never
+1
View File
@@ -245,6 +245,7 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
v1.Handle("GET /v1/items/{id}/extras", s.authed(s.handleExtras))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume))
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/store"
)
// extrasResponse is the Extras tab: featurettes, deleted scenes, interviews and the local
// trailer, as Emby's own item JSON so the television's single BaseItem model decodes it.
type extrasResponse struct {
Items []json.RawMessage `json:"items"`
}
// handleExtras joins the two lists Emby keeps an item's supplementary video in.
//
// It has to be a join, because Emby answers the question in two places and neither includes
// the other: `SpecialFeatures` holds the featurettes and deleted scenes, `LocalTrailers`
// holds the trailer, and a detail page asking only one of them shows an Extras tab missing
// the thing most titles that have anything actually have.
//
// Done here rather than on the television for the ordinary reason: in gateway mode the set
// holds no Emby credential. Doing it here also means one request instead of two on the path
// that matters — the tab is probed on every detail page open.
func (s *Server) handleExtras(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "extras:v1:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
cred := credentials(sess)
// Neither list failing is fatal, and only both failing is a failure worth reporting: a
// title can perfectly well have a trailer and no special features, or the reverse, and
// an Extras tab withheld because one of two lookups was unwell is the worse outcome.
var items []json.RawMessage
trailerErr := error(nil)
if result, err := s.emby.LocalTrailers(ctx, cred, itemID); err == nil {
items = append(items, result.Items...)
} else {
trailerErr = err
}
featureErr := error(nil)
if result, err := s.emby.SpecialFeatures(ctx, cred, itemID); err == nil {
items = append(items, result.Items...)
} else {
featureErr = err
}
if trailerErr != nil && featureErr != nil {
s.writeUpstreamError(ctx, w, featureErr, "could not load extras")
return
}
items = dedupeExtras(items)
body, err := json.Marshal(extrasResponse{Items: nonNilRaws(items)})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode extras")
return
}
// Cached for the ordinary item lifetime, empty answers included. Most of a library has
// no extras at all and every detail page asks, so the "no" is the entry worth keeping.
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.loggerFor(ctx).Warn("extras cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// dedupeExtras drops the same file appearing in both lists, in first-seen order.
//
// A trailer filed as a special feature *and* as a local trailer is an ordinary way for a
// library to be laid out, and the television renders these into a keyed grid — which throws
// on a repeated key rather than merely looking wrong. Anything without an id is dropped for
// the same reason.
func dedupeExtras(items []json.RawMessage) []json.RawMessage {
seen := make(map[string]struct{}, len(items))
out := make([]json.RawMessage, 0, len(items))
for _, raw := range items {
var identified struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &identified); err != nil || identified.ID == "" {
continue
}
if _, repeated := seen[identified.ID]; repeated {
continue
}
seen[identified.ID] = struct{}{}
out = append(out, raw)
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
// The two lists Emby answers with can name the same file, and the television renders the
// result into a keyed grid — which throws on a repeated key rather than merely looking
// wrong. First-seen order is kept, so the trailer stays at the front where the join put it.
func TestDedupeExtrasKeepsFirstOfEachID(t *testing.T) {
items := []json.RawMessage{
json.RawMessage(`{"Id":"trailer-1","Name":"Official Trailer","Type":"Trailer"}`),
json.RawMessage(`{"Id":"feature-1","Name":"Four Winters","Type":"Featurette"}`),
json.RawMessage(`{"Id":"trailer-1","Name":"Official Trailer","Type":"Trailer"}`),
json.RawMessage(`{"Id":"feature-2","Name":"Deleted: The Crossing","Type":"DeletedScene"}`),
}
got := dedupeExtras(items)
if len(got) != 3 {
t.Fatalf("expected 3 extras after deduplication, got %d", len(got))
}
ids := extraIDs(t, got)
want := []string{"trailer-1", "feature-1", "feature-2"}
for index, id := range want {
if ids[index] != id {
t.Fatalf("extra %d was %q, want %q (order: %v)", index, ids[index], id, ids)
}
}
}
// An entry the television could not key is dropped rather than passed on. Nothing
// downstream can tell one blank id from another.
func TestDedupeExtrasDropsUnidentifiableEntries(t *testing.T) {
items := []json.RawMessage{
json.RawMessage(`{"Name":"Nameless","Type":"Clip"}`),
json.RawMessage(`{"Id":"","Name":"Blank","Type":"Clip"}`),
json.RawMessage(`not json at all`),
json.RawMessage(`{"Id":"clip-1","Name":"A Clip","Type":"Clip"}`),
}
got := dedupeExtras(items)
if len(got) != 1 {
t.Fatalf("expected only the identified extra to survive, got %d", len(got))
}
if ids := extraIDs(t, got); ids[0] != "clip-1" {
t.Fatalf("survivor was %q, want clip-1", ids[0])
}
}
func TestDedupeExtrasOnNothing(t *testing.T) {
if got := dedupeExtras(nil); len(got) != 0 {
t.Fatalf("expected no extras, got %d", len(got))
}
}
// The Details tab is the only reader of these, and every one of them is absent from Emby's
// response unless it is asked for by name — so a field quietly dropped from the query is a
// row that silently stops appearing rather than anything that fails.
func TestDetailFieldsCarryTheDetailsTabVocabulary(t *testing.T) {
fields := make(map[string]bool)
for _, field := range strings.Split(fieldsDetail, ",") {
fields[field] = true
}
for _, required := range []string{
"Studios", "Taglines", "PremiereDate", "OriginalTitle", "ProductionLocations",
} {
if !fields[required] {
t.Errorf("detail responses must request %q for the Details tab", required)
}
}
}
func extraIDs(t *testing.T, items []json.RawMessage) []string {
t.Helper()
ids := make([]string, 0, len(items))
for _, raw := range items {
var identified struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &identified); err != nil {
t.Fatalf("could not read back an extra: %v", err)
}
ids = append(ids, identified.ID)
}
return ids
}
+6 -1
View File
@@ -32,7 +32,12 @@ const (
// Status is a series' production state ("Continuing"/"Ended"). The television needs it
// to decide whether it is estimating a finish or a catch-up, and the detail call is
// where a field like this belongs — adding it to a home query is a startup cost.
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
//
// Studios, Taglines, PremiereDate, OriginalTitle and ProductionLocations are the Details
// tab's vocabulary and are read nowhere else. They belong here for the same reason: this
// call is made once, after D-pad focus has settled on a card, and every one of them
// would be a per-card cost on a home row.
fieldsDetail = "Overview,Taglines,Genres,MediaStreams,People,Studios,ProductionYear,PremiereDate,OriginalTitle,ProductionLocations,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
+1 -1
View File
@@ -48,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
}
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People or the stored ratings.
key := cache.UserKey(sess.EmbyUserID, "item:v5:"+itemID)
key := cache.UserKey(sess.EmbyUserID, "item:v6:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
+4 -1
View File
@@ -48,7 +48,10 @@ func TestScheduleRowsCarryLifecycle(t *testing.T) {
airs := now.Add(3 * time.Hour)
series := buildSonarrRowForTest(t, sonarr.Episode{
ID: 7, SeriesID: 3, SeasonNumber: 2, EpisodeNumber: 4, AirDateUTC: &airs,
Monitored: true, Series: sonarr.Series{ID: 3, Title: "Severance", Status: "continuing"},
Monitored: true,
Series: sonarr.Series{
ID: 3, Title: "Severance", Status: "continuing", Monitored: true,
},
}, now)
if series.MembyLifecycle != "continuing" || series.MembyLifecycleText != "CONTINUING" {
t.Fatalf("sonarr card lifecycle = %q/%q", series.MembyLifecycle, series.MembyLifecycleText)
+5 -3
View File
@@ -12,9 +12,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
// fieldsRelated adds Studios to the detail set: the explanation layer names the studio a
// viewer keeps returning to, and Emby omits it unless asked.
const fieldsRelated = fieldsDetail + ",Studios"
// fieldsRelated is the detail set. It once added Studios on its own account — the
// explanation layer names the studio a viewer keeps returning to and the Details tab has
// since put Studios in [fieldsDetail] for everybody. Kept as its own name so the
// explanation layer's requirement stays stated rather than depending on another feature.
const fieldsRelated = fieldsDetail
// relatedResponse is what the detail page renders: a strip of short reasons under the
// description, and the carousel beneath the page.
+13 -1
View File
@@ -416,7 +416,7 @@ func buildSonarrRow(
dayStart := localDayStart(now, location)
windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays)
for _, episode := range episodes {
if episode.AirDateUTC == nil {
if episode.AirDateUTC == nil || !scheduleShowFollowed(episode) {
continue
}
localAirTime := episode.AirDateUTC.In(location)
@@ -438,6 +438,18 @@ func buildSonarrRow(
}, nil
}
// scheduleShowFollowed answers whether a show is one the household is actually waiting
// for. Sonarr's calendar is asked with unmonitored=true — the calendar page and the aired
// banners both want the whole picture — but the launcher row answers "what is coming",
// and an unmonitored show is never coming. Absence of a series record is not evidence of
// anything, so an episode carrying no expanded series is kept rather than dropped.
func scheduleShowFollowed(episode sonarr.Episode) bool {
if episode.Series.ID <= 0 {
return true
}
return episode.Series.Monitored
}
func toSonarrScheduleItem(
episode sonarr.Episode,
now time.Time,
+41 -4
View File
@@ -23,8 +23,9 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
Monitored: true,
EpisodeFile: &sonarr.EpisodeFile{DateAdded: &added},
Series: sonarr.Series{
ID: 7,
Title: "Northbound",
ID: 7,
Title: "Northbound",
Monitored: true,
Images: []sonarr.Image{
{CoverType: "poster"},
{CoverType: "fanart"},
@@ -52,6 +53,42 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
}
}
// Sonarr's calendar is asked with unmonitored=true, because the calendar page and the
// aired banners want the whole picture. The launcher row does not: a show nobody follows
// is never coming, so it has no business being announced as airing.
func TestBuildSonarrRowDropsUnmonitoredShows(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
episode := func(id int, series sonarr.Series) sonarr.Episode {
utc := air
return sonarr.Episode{
ID: id, SeriesID: series.ID, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc, Monitored: true, Series: series,
}
}
row, err := buildSonarrRow([]sonarr.Episode{
episode(1, sonarr.Series{ID: 1, Title: "Followed", Monitored: true}),
episode(2, sonarr.Series{ID: 2, Title: "Dropped"}),
// No expanded series record is no evidence either way, so the card stays.
episode(3, sonarr.Series{}),
}, now, location, nil)
if err != nil {
t.Fatal(err)
}
ids := make([]string, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
ids = append(ids, item.ID)
}
if len(ids) != 2 || ids[0] != "sonarr:1:1" || ids[1] != "sonarr:0:3" {
t.Fatalf("unmonitored shows were not dropped: %v", ids)
}
}
func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
@@ -65,7 +102,7 @@ func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
utc := air
return sonarr.Episode{
ID: 1, SeriesID: 1, SeasonNumber: 1, EpisodeNumber: 1, AirDateUTC: &utc,
Series: sonarr.Series{ID: 1, Title: title, Year: year},
Series: sonarr.Series{ID: 1, Title: title, Year: year, Monitored: true},
}
}
cases := map[string]struct {
@@ -169,7 +206,7 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
return sonarr.Episode{
ID: id, SeriesID: id, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc,
Series: sonarr.Series{ID: id, Title: "Show"},
Series: sonarr.Series{ID: id, Title: "Show", Monitored: true},
}
}
row, err := buildSonarrRow([]sonarr.Episode{
+19
View File
@@ -335,6 +335,25 @@ func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID str
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
// SpecialFeatures returns the featurettes, deleted scenes and interviews Emby holds beside
// an item's media file.
//
// Deliberately separate from [Client.LocalTrailers]: Emby keeps trailers out of this list
// entirely, so the Extras tab is the join of the two and neither endpoint can answer for the
// other. Like LocalTrailers it answers with a bare array rather than an Items envelope.
func (c *Client) SpecialFeatures(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) {
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/SpecialFeatures"
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
if err != nil {
return nil, err
}
var items []json.RawMessage
if err := c.do(req, &items); err != nil {
return nil, err
}
return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil
}
func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) {
params := url.Values{}
if fields != "" {