App v0.2.27 and gateway 0.1.23

Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
@@ -10,6 +10,8 @@ import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayPreferences
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayIntro
import com.ponzischeme89.memby.data.model.GatewayTrickplay
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
@@ -63,6 +65,49 @@ class GatewayPayloadTest {
assertTrue(bare.membyRatings.isEmpty())
}
/**
* The hero row's caption and reason. Both are the gateway's wording, so this only has
* to carry them through — and both must be absent-safe, because the direct path picks
* the hero itself and every home cache written before the feature has neither.
*/
@Test
fun `decodes the hero row's caption and reason and defaults them when absent`() {
val payload = """
{
"rows":[{
"id":"hero",
"title":"Featured",
"kind":"hero",
"items":[{
"Id":"emby-9",
"Name":"The Return",
"Type":"Series",
"MembyHeroLabel":"SERIES PREMIERE",
"MembyHeroReason":"A new series premiered yesterday"
}]
}],
"continueWatching":[],
"nextUp":[],
"favorites":[],
"latestMovies":[],
"partial":false
}
""".trimIndent()
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
assertEquals("hero", row.kind)
val item = row.items.single()
assertEquals("SERIES PREMIERE", item.membyHeroLabel)
assertEquals("A new series premiered yesterday", item.membyHeroReason)
// A hero card is playable by construction; the field is defaulted so a payload
// that omits it is not mistaken for a schedule card the viewer cannot press.
assertTrue(item.membyPlayable)
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
assertEquals(null, bare.membyHeroLabel)
assertEquals(null, bare.membyHeroReason)
}
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
@@ -424,6 +469,57 @@ class GatewayPayloadTest {
assertEquals(false, playback.prerollEnabled)
assertEquals(4_000L, playback.prerollDurationMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
// Absent on a gateway that predates seek previews, and the default must stay false:
// a missing field must never conjure a request the backend would answer with 404.
assertEquals(false, playback.trickplayAvailable)
// And the same for the skip button, for the same reason.
assertEquals(false, playback.skipIntroAvailable)
}
@Test
fun `decodes an intro segment`() {
val intro = json.decodeFromString<GatewayIntro>(
"""{"available":true,"startMs":463000,"endMs":583000}""",
)
assertTrue(intro.available)
assertEquals(463_000L, intro.startMs)
assertEquals(583_000L, intro.endMs)
}
@Test
fun `a title with no intro markers decodes as unavailable`() {
// An episode Emby has not analysed, a film, and a feature the operator has turned
// off are all the same empty object — and all three mean no button.
val intro = json.decodeFromString<GatewayIntro>("{}")
assertEquals(false, intro.available)
assertEquals(0L, intro.startMs)
assertEquals(0L, intro.endMs)
}
@Test
fun `decodes a seek preview layout`() {
val trickplay = json.decodeFromString<GatewayTrickplay>(
"""{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}""",
)
assertTrue(trickplay.available)
assertEquals(10_000L, trickplay.intervalMs)
assertEquals(817, trickplay.count)
assertEquals(320, trickplay.width)
assertEquals(172, trickplay.height)
}
@Test
fun `a title with no seek previews decodes as unavailable`() {
// The gateway answers a title with no thumbnails, a feature the operator has turned
// off and an Emby that would not say with the same empty object. All three mean the
// same thing to a television, and none of them is an error it should render.
val trickplay = json.decodeFromString<GatewayTrickplay>("""{"available":false}""")
assertEquals(false, trickplay.available)
assertEquals(0, trickplay.count)
}
@Test
@@ -0,0 +1,156 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The intro rule, pinned against the same cases as the gateway's copy (`intro_test.go`).
*
* The two implementations exist separately because with no gateway there is nobody to ask,
* and a skip must not land somewhere different depending on whether the container is up —
* so when one of these changes, the other has to change with it.
*
* The cases are taken from what Emby actually writes for FROM: intro markers arrive as
* ordinary chapters carrying a marker type, in playback order beside the real ones.
*/
class IntroTest {
private fun chapter(seconds: Long, marker: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * 10_000L,
markerType = marker,
name = marker,
)
@Test
fun `finds the segment in a real episode`() {
val segment = introSegmentFrom(
listOf(
chapter(0, "Chapter"),
chapter(300, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(600, "Chapter"),
),
)
assertEquals(IntroSegment(startMs = 463_000L, endMs = 583_000L), segment)
}
@Test
fun `an intro can begin at the very start of the file`() {
val segment = introSegmentFrom(
listOf(chapter(0, "IntroStart"), chapter(95, "IntroEnd")),
)
// Which is exactly why availability is never inferred from a zero start.
assertEquals(IntroSegment(startMs = 0L, endMs = 95_000L), segment)
}
@Test
fun `an episode with no markers has no segment`() {
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(300, "Chapter"))))
assertNull(introSegmentFrom(emptyList()))
}
@Test
fun `half a pair is not a segment`() {
// There is no honest end to skip to, and guessing one is how a viewer lands in the
// middle of a scene.
assertNull(introSegmentFrom(listOf(chapter(112, "IntroStart"), chapter(300, "Chapter"))))
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(246, "IntroEnd"))))
}
@Test
fun `a pair in the wrong order is refused`() {
assertNull(introSegmentFrom(listOf(chapter(300, "IntroStart"), chapter(120, "IntroEnd"))))
}
@Test
fun `a segment too short to notice is refused`() {
// A button that moves the picture imperceptibly reads as a broken button, so there
// is deliberately no button at all.
assertNull(introSegmentFrom(listOf(chapter(100, "IntroStart"), chapter(103, "IntroEnd"))))
}
@Test
fun `a segment too long to be an intro is refused`() {
// Far more likely two unrelated markers read as a range than a ten-minute title
// sequence, and honouring it would throw the viewer past the story.
assertNull(introSegmentFrom(listOf(chapter(60, "IntroStart"), chapter(660, "IntroEnd"))))
}
@Test
fun `the first start wins`() {
// Two starts mean the markers are already untrustworthy, and taking the later one
// would pick the larger, more damaging skip of the two.
val segment = introSegmentFrom(
listOf(
chapter(100, "IntroStart"),
chapter(160, "IntroStart"),
chapter(220, "IntroEnd"),
),
)
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
}
@Test
fun `a later pair is ignored once one has been found`() {
val segment = introSegmentFrom(
listOf(
chapter(100, "IntroStart"),
chapter(220, "IntroEnd"),
chapter(1_800, "IntroStart"),
chapter(1_900, "IntroEnd"),
),
)
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
}
@Test
fun `credit markers are not intros`() {
assertNull(
introSegmentFrom(
listOf(chapter(2_800, "CreditsStart"), chapter(2_900, "CreditsEnd")),
),
)
}
@Test
fun `the window holds the playhead only while the titles are running`() {
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
assertFalse(segment.contains(59_999L))
assertTrue(segment.contains(60_000L))
assertTrue(segment.contains(179_999L))
assertFalse(segment.contains(180_000L))
}
@Test
fun `the tail keeps the offer off the last moment of the sequence`() {
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
// Offering a two-second skip is offering nothing; the button has to disappear
// before it becomes indistinguishable from doing nothing.
assertTrue(segment.contains(177_000L, lead = 2_000L))
assertFalse(segment.contains(178_500L, lead = 2_000L))
}
@Test
fun `an unknown mode falls back to the button rather than to a silent skip`() {
// The gateway's catalogue may grow a mode before every set in the house has the
// release that understands it. Costing that set its button is recoverable; jumping
// through somebody's episode on a value this build cannot read is not.
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode("immediately"))
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(null))
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(""))
assertEquals(SKIP_INTRO_AUTO, normalizeSkipIntroMode("auto"))
// Case and whitespace are the operator's, not the viewer's problem.
assertEquals(SKIP_INTRO_OFF, normalizeSkipIntroMode(" OFF "))
}
}
@@ -0,0 +1,151 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The direct path's copy of the gateway's BIF reader. It exists so the two agree — the
* matching Go tests are in `server/internal/trickplay/bif_test.go`, and the cases here are
* deliberately the same ones. With no gateway there is nobody to ask, and a viewer must
* not get different seek previews depending on whether the container is up.
*/
class TrickplayTest {
/**
* Assembles a file in the shape Emby writes one, so the tests exercise the arithmetic
* the real parser will meet rather than a convenient fiction.
*/
private fun buildBif(
count: Int,
multiplier: Long = 10_000L,
frameSizes: List<Int> = emptyList(),
): ByteArray {
val length = bifIndexLength(count)
val file = ByteArray(length)
byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a).copyInto(file)
writeLittleEndian(file, 12, count.toLong())
writeLittleEndian(file, 16, multiplier)
var offset = length
val body = ArrayList<Byte>()
for (entry in 0 until count) {
val at = BIF_HEADER_SIZE + entry * 8
writeLittleEndian(file, at, entry.toLong())
writeLittleEndian(file, at + 4, offset.toLong())
val size = frameSizes.getOrElse(entry) { 100 }
offset += size
repeat(size) { body.add(0) }
}
val terminator = BIF_HEADER_SIZE + count * 8
writeLittleEndian(file, terminator, 0xFFFFFFFFL)
writeLittleEndian(file, terminator + 4, offset.toLong())
return file + body.toByteArray()
}
private fun writeLittleEndian(bytes: ByteArray, at: Int, value: Long) {
for (i in 0..3) bytes[at + i] = ((value shr (8 * i)) and 0xFF).toByte()
}
private fun parsed(bytes: ByteArray): BifIndex =
(parseBifIndex(bytes) as BifParse.Parsed).index
@Test
fun `reads the layout Emby writes`() {
// Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
// interval is ten seconds, and reading the multiplier as the interval would be
// right only by accident. The multiplication is the part worth pinning.
val index = parsed(buildBif(count = 3, frameSizes = listOf(500, 600, 700)))
assertEquals(3, index.count)
assertEquals(10_000L, index.intervalMs)
val frame = index.frame(1)!!
assertEquals((bifIndexLength(3) + 500).toLong(), frame.first)
assertEquals(600L, frame.last - frame.first + 1)
}
@Test
fun `a title with no thumbnails is an answer, not a failure`() {
// This is what Emby serves for a title it has not generated previews for: a
// well-formed 72-byte header with a count of zero. It must read as "this title has
// none", never as a broken file, or every such title looks like a fault.
val index = parsed(buildBif(count = 0))
assertEquals(0, index.count)
assertEquals(null, index.frame(0))
}
@Test
fun `refuses what is not a BIF`() {
// Emby's error pages come back on this route with a 200, so "not a BIF" is a real
// answer the reader has to give rather than a theoretical one.
assertEquals(
BifParse.NotBif,
parseBifIndex(("<html><body>Item not found</body></html>" + " ".repeat(64)).toByteArray()),
)
val mangled = buildBif(count = 2)
mangled[3] = 'X'.code.toByte()
assertEquals(BifParse.NotBif, parseBifIndex(mangled))
}
@Test
fun `refuses frames that point back into the index`() {
// An offset inside the index would have the player decode a slice of the index
// itself as a JPEG. Refuse the file rather than draw nonsense over somebody's film.
val file = buildBif(count = 2)
writeLittleEndian(file, BIF_HEADER_SIZE + 4, 8L)
assertEquals(BifParse.NotBif, parseBifIndex(file))
}
@Test
fun `asks for more rather than failing on a short read`() {
// The index is read as a fixed window off the front of the file, so a long title
// legitimately arrives cut short. That must be answerable — read this much and try
// again — rather than looking like a bad file.
val file = buildBif(count = 40)
val short = parseBifIndex(file.copyOfRange(0, BIF_HEADER_SIZE + 16))
assertTrue(short is BifParse.NeedMore)
assertEquals(bifIndexLength(40), (short as BifParse.NeedMore).bytes)
val header = parseBifIndex(file.copyOfRange(0, 20))
assertEquals(BifParse.NeedMore(BIF_HEADER_SIZE), header)
}
@Test
fun `frameAt clamps rather than refusing`() {
// The preview is drawn while somebody is still moving a seek target about, so a
// position past the last frame must show the last frame. Nothing is worse here than
// the thumbnail blanking at exactly the end of a film.
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
assertEquals(0, track.frameAt(-5_000L))
assertEquals(0, track.frameAt(0L))
assertEquals(0, track.frameAt(9_999L))
assertEquals(1, track.frameAt(10_000L))
assertEquals(2, track.frameAt(25_000L))
assertEquals(2, track.frameAt(9_000_000L))
}
@Test
fun `refuses a frame index out of range`() {
val index = parsed(buildBif(count = 2))
for (n in listOf(-1, 2, 99)) {
assertEquals("frame $n was served", null, index.frame(n))
}
}
@Test
fun `falls back to a sensible shape until the frames are measured`() {
// The gateway measures the frames and says so; the direct path does not, and the
// chip still has to be laid out at about the right size on the very first press.
val unmeasured = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
assertEquals(213, unmeasured.widthFor(120))
val measured = unmeasured.copy(width = 320, height = 172)
assertEquals(120 * 320 / 172, measured.widthFor(120))
}
}
@@ -29,6 +29,7 @@ class UserPreferencesTest {
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
skipIntroMode = SKIP_INTRO_AUTO,
forYouMinutes = 60,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
@@ -86,6 +87,27 @@ class UserPreferencesTest {
)
}
/**
* The same rule for the intro mode, and the stake is higher: an unreadable value that
* fell through to "auto" would jump through somebody's episode on the strength of a
* string this build cannot parse. It falls back to the button instead.
*/
@Test
fun `an unknown intro mode falls back to the button`() {
assertEquals(
DEFAULT_SKIP_INTRO_MODE,
decodeUserPreferences(json("""{"skipIntroMode":"immediately"}""")).skipIntroMode,
)
assertEquals(
SKIP_INTRO_AUTO,
decodeUserPreferences(json("""{"skipIntroMode":"auto"}""")).skipIntroMode,
)
assertEquals(
DEFAULT_SKIP_INTRO_MODE,
Settings(skipIntroMode = "sometimes").toUserPreferences().skipIntroMode,
)
}
/** An empty row selection would be a launcher with nothing on it. */
@Test
fun `an empty section list falls back rather than emptying the launcher`() {
@@ -79,6 +79,67 @@ class HomeMovieHeroScreenshotTest {
)
}
/**
* The hero the gateway composes. Two things are only checkable by looking: the reason
* takes the synopsis's place rather than adding a line above the Play chip, and a
* series premiere leading the launcher has to read as deliberate rather than as a TV
* show that wandered into the movie hero.
*/
@Test
fun `home hero composed by the gateway`() {
capture(
"df_home-movie-hero-server",
listOf(
HomeHeroPick(
series(
"The Quiet Coast",
2026,
"A harbour town's constable is the only one who noticed the tide change.",
8.6,
),
"SERIES PREMIERE",
"A new series premiered yesterday",
),
HomeHeroPick(
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
"NEW RELEASE",
"Well reviewed, released on Monday",
),
HomeHeroPick(
series("Harbour Lights", 2024, "The fourth season opens on an empty pier.", 8.1),
"NEW SEASON",
"A new season started today",
),
HomeHeroPick(
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
"HIGHLY RATED",
"One of the best-reviewed titles in your library",
),
),
)
}
/** The reason must not be what breaks the layout the wrapping-title case pins. */
@Test
fun `home hero with a wrapping title and a reason`() {
capture(
"df_home-movie-hero-long-title-reason",
listOf(
HomeHeroPick(
movie(
"The Longest Northbound Winter",
2026,
"A cartographer chasing a river that no longer exists finds the last " +
"village on the map still waiting for him.",
8.4,
),
"NEW RELEASE",
"Well reviewed, released yesterday",
),
) + movies.drop(1),
)
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
@@ -178,6 +239,9 @@ class HomeMovieHeroScreenshotTest {
),
)
private fun series(name: String, year: Int, overview: String, rating: Double) =
movie(name, year, overview, rating).copy(type = "Series")
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
id = name.lowercase().replace(' ', '-'),
name = name,
@@ -4,6 +4,7 @@ import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -66,6 +67,117 @@ class HomeMovieHeroTest {
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
// --- The gateway's hero ------------------------------------------------------
/**
* The server can see Radarr's digital release dates, Sonarr's premieres and the
* review scores; the television can see which shelf a title came off. So where the
* gateway has composed a hero, its order wins outright — reordering it here could
* only ever throw that evidence away.
*/
@Test
fun `the server's hero row wins over the local rule`() {
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "local-1", "local-2"))
val serverRows = listOf(
heroRow(
heroItem("server-1", "SERIES PREMIERE", "A new series premiered yesterday"),
heroItem("server-2", "NEW RELEASE", "Well reviewed, released on Monday"),
),
)
val picks = selectHomeHeroMovies(browseRows, day = 5, serverRows = serverRows)
assertEquals(listOf("server-1", "server-2"), picks.map { it.item.id })
assertEquals(listOf("SERIES PREMIERE", "NEW RELEASE"), picks.map { it.label })
assertEquals("A new series premiered yesterday", picks.first().reason)
}
/**
* No day rotation on the server's hero. The facts behind it already change daily, and
* rotating a merit ranking is exactly how the best-reviewed release of the week ends
* up in the fourth slot.
*/
@Test
fun `the server's hero keeps its ranking on every day`() {
val serverRows = listOf(
heroRow(
heroItem("first", "NEW RELEASE", null),
heroItem("second", "NEW RELEASE", null),
heroItem("third", "HIGHLY RATED", null),
),
)
(0L..7L).forEach { day ->
assertEquals(
"day $day",
listOf("first", "second", "third"),
selectHomeHeroMovies(emptyList(), day, serverRows).map { it.item.id },
)
}
}
/** The direct path has no gateway to ask, and an older one sends no hero row. */
@Test
fun `the local rule is the fallback when no hero row arrives`() {
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "new-1", "new-2"))
assertEquals(
selectHomeHeroMovies(browseRows, day = 0),
selectHomeHeroMovies(browseRows, day = 0, serverRows = listOf(heroRow())),
)
assertEquals(
listOf("new-1", "new-2"),
selectHomeHeroMovies(browseRows, day = 0, serverRows = emptyList())
.map { it.item.id },
)
}
/** A card that cannot be pressed is news for the schedule row, not a hero. */
@Test
fun `the server's hero drops anything that is not playable`() {
val serverRows = listOf(
heroRow(
heroItem("upcoming", "NEW RELEASE", null).copy(membyPlayable = false),
heroItem("playable", "NEW RELEASE", null),
),
)
assertEquals(
listOf("playable"),
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.item.id },
)
}
/**
* A caption is the gateway's wording, so this build must survive one it has never
* heard of — and a card that somehow arrives with none falls back to the neutral
* caption rather than claiming something nobody said.
*/
@Test
fun `an unfamiliar or missing caption still draws a card`() {
val serverRows = listOf(
heroRow(
heroItem("future", "STAFF PICK OF THE WEEK", null),
heroItem("bare", null, null),
),
)
assertEquals(
listOf("STAFF PICK OF THE WEEK", "FROM YOUR LIBRARY"),
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.label },
)
}
/** Four cards is what the hero draws, however many the row carries. */
@Test
fun `the server's hero is capped at four cards`() {
val serverRows = listOf(
heroRow(*(1..8).map { heroItem("item-$it", "NEW RELEASE", null) }.toTypedArray()),
)
assertEquals(4, selectHomeHeroMovies(emptyList(), 0, serverRows).size)
}
// --- Daily variants ----------------------------------------------------------
@Test
@@ -180,6 +292,21 @@ class HomeMovieHeroTest {
const val DAY_MS = 24L * HOUR_MS
}
private fun heroItem(id: String, label: String?, reason: String?) = BaseItem(
id = id,
name = id,
type = "Movie",
membyHeroLabel = label,
membyHeroReason = reason,
)
private fun heroRow(vararg items: BaseItem) = HomeRow(
id = "hero",
title = "Featured",
kind = SERVER_HERO_ROW_KIND,
items = items.toList(),
)
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
@@ -361,6 +361,27 @@ class ServerHomeRowsTest {
)
}
/**
* The hero row is the four featured cards above the shelves. HomeMovieHero consumes
* it, so letting it through here would print the same four titles a second time as an
* unnamed row of posters directly beneath the hero they are already in.
*/
@Test
fun `the hero row is consumed by the hero, never drawn as a shelf`() {
val rows = serverHomeRows(
HomeUiState(
rows = listOf(
row("hero", SERVER_HERO_ROW_KIND, "featured"),
row("latest-movies", "latest", "d"),
),
loading = emptySet(),
),
Settings(),
)
assertEquals(listOf("latest-movies"), rows.map { it.id })
}
@Test
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(
@@ -37,4 +37,22 @@ class UserSwitcherNavigationTest {
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
}
@Test
fun `both pinned actions are reachable below the profiles`() {
val profileCount = 3
// …the last profile, then My Alerts, then Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2))
}
@Test
fun `pinned actions stay reachable with a single profile`() {
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2))
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui.alerts
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AlertsFormatTest {
@Test
fun `no alerts wears no badge`() {
assertNull(alertBadgeLabel(0))
assertNull(alertBadgeLabel(-1))
}
@Test
fun `a small count is drawn as itself`() {
assertEquals("1", alertBadgeLabel(1))
assertEquals("9", alertBadgeLabel(9))
}
@Test
fun `a large count stops counting rather than widening the pill`() {
assertEquals("9+", alertBadgeLabel(10))
assertEquals("9+", alertBadgeLabel(240))
}
@Test
fun `the summary counts alerts, and names the new ones only when there are some`() {
assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
assertEquals("1 alert", alertsSummary(total = 1, unread = 0))
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
}
}
@@ -0,0 +1,169 @@
package com.ponzischeme89.memby.ui.alerts
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
import com.ponzischeme89.memby.ui.theme.MembyTheme
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 My Alerts to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
* at without deploying to a TV.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*AlertsPageScreenshotTest"
* ```
*
* The empty case is the one worth keeping. This is a page whose whole job is emptying
* itself, so what it looks like with nothing on it is the state a viewer reaches most often
* and the only one a unit test cannot describe.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class AlertsPageScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `alerts waiting`() {
capture("my-alerts-populated", sampleAlerts)
}
/** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */
@Test
fun `everything already read`() {
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") })
}
@Test
fun `nothing waiting`() {
capture("my-alerts-empty", emptyList())
}
/** Alerts switched off has its own empty wording — and both toggles read as off. */
@Test
fun `alerts switched off`() {
capture(
"my-alerts-disabled",
emptyList(),
NotificationPreferences(enabled = false, showReturnAlerts = false),
)
}
/** A long title and a two-line message: the row's own wrapping case. */
@Test
fun `long wording`() {
capture(
"my-alerts-long-wording",
listOf(
UserNotification(
id = 1,
kind = "series_return",
title = "A Very Long Programme Title That Will Not Fit On One Line",
message = "Season 4 of this show returns on Thursday, and the first two " +
"episodes will be in Emby that morning if the download lands.",
eventAt = "2026-08-13T08:30:00Z",
),
),
)
}
/**
* The way in: the user menu in the user picker, with the badge that replaced the bell
* on the launcher. Captured here rather than beside the profile switcher's own tests
* because the badge and the page are one feature — if they disagree about what counts,
* this is the pair that shows it.
*/
@Test
fun `user menu carrying the badge`() {
capturePicker("my-alerts-user-menu", alertCount = 3)
}
@Test
fun `user menu with nothing waiting`() {
capturePicker("my-alerts-user-menu-quiet", alertCount = 0)
}
/** Past the cap the badge stops counting rather than widening the row. */
@Test
fun `user menu with a great many alerts`() {
capturePicker("my-alerts-user-menu-many", alertCount = 42)
}
private fun capturePicker(name: String, alertCount: Int) {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
alertCount = alertCount,
onOpenAlerts = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
}
private fun capture(
name: String,
notifications: List<UserNotification>,
preferences: NotificationPreferences = NotificationPreferences(),
) {
compose.setContent {
MembyTheme {
MyAlertsPage(
notifications = notifications,
preferences = preferences,
onToggleEnabled = {},
onToggleShowReturns = {},
onRead = {},
onDismiss = {},
onDismissAll = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
}
private val sampleAlerts = listOf(
UserNotification(
id = 1,
kind = "series_return",
title = "Northbound returns on Thursday",
message = "Season 3 starts on 13 August. Memby will add it as soon as it lands.",
eventAt = "2026-08-13T20:30:00Z",
),
UserNotification(
id = 2,
kind = "series_return",
title = "The Long Dark is back",
message = "Season 2 started yesterday and the first episode is in Emby now.",
eventAt = "2026-08-06T20:00:00Z",
),
UserNotification(
id = 3,
kind = "library",
title = "24 titles added",
message = "Memby has finished refreshing your library.",
readAt = "2026-08-05T11:00:00Z",
),
)
}
@@ -0,0 +1,128 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.data.Trickplay
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The seek chip with and without a preview frame, captured from the real player XML at TV
* resolution → `build/screenshots/seek-indicator/`.
*
* The case worth having is the *empty* one. A preview is a decoration on an indicator that
* has always worked without it — a title with no thumbnails, a gateway that will not answer
* and the moment before the first frame arrives all have to leave the chip looking exactly
* as it did before this existed, and that is a property no assertion can check.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SeekIndicatorScreenshotTest {
@Test
fun `skipping forward with a preview frame`() {
capture(
name = "seek-forward-with-preview",
amount = "Forward 1 min 30 secs",
position = "1:12:40 / 2:04:00",
forward = true,
withPreview = true,
)
}
@Test
fun `skipping back with a preview frame`() {
capture(
name = "seek-back-with-preview",
amount = "Back 30 seconds",
position = "0:41:10 / 2:04:00",
forward = false,
withPreview = true,
)
}
@Test
fun `a title with no previews keeps the chip it always had`() {
capture(
name = "seek-forward-no-preview",
amount = "Forward 30 seconds",
position = "1:12:40 / 2:04:00",
forward = true,
withPreview = false,
)
}
private fun capture(
name: String,
amount: String,
position: String,
forward: Boolean,
withPreview: Boolean,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(Color.rgb(42, 57, 70), Color.rgb(16, 25, 33), Color.rgb(4, 8, 12)),
)
}
val chip = LayoutInflater.from(activity).inflate(R.layout.player_seek_indicator, root, false)
chip.visibility = View.VISIBLE
chip.findViewById<ImageView>(R.id.player_seek_glyph).setImageResource(
if (forward) R.drawable.ic_player_forward else R.drawable.ic_player_rewind,
)
chip.findViewById<TextView>(R.id.player_seek_amount).text = amount
chip.findViewById<TextView>(R.id.player_seek_position).text = position
if (withPreview) {
val preview = chip.findViewById<ImageView>(R.id.player_seek_preview)
// Sized the way the real one is: from the frames' own shape rather than the
// 16:9 placeholder the layout carries, which is what stops a 320x172 thumbnail
// being letterboxed inside its own box.
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 700, width = 320, height = 172)
preview.layoutParams = preview.layoutParams.apply {
width = track.widthFor(height)
}
preview.setImageBitmap(thumbnail())
preview.visibility = View.VISIBLE
}
root.addView(chip)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/seek-indicator/$name.png")
}
/** Stands in for a frame of a film: the point is the chip around it, not the picture. */
private fun thumbnail(): Bitmap {
val bitmap = Bitmap.createBitmap(320, 172, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawPaint(
Paint().apply {
shader = LinearGradient(
0f, 0f, 320f, 172f,
intArrayOf(Color.rgb(96, 84, 62), Color.rgb(38, 44, 58), Color.rgb(10, 12, 18)),
null,
Shader.TileMode.CLAMP,
)
},
)
return bitmap
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The figure inside the countdown ring.
*
* Pure, and worth pinning separately from the view that draws it: the ring is about 30dp
* across, so the difference between "118" and "1:58" is the difference between a figure
* that fits and one that prints over its own arc.
*/
class SkipIntroCountdownTest {
@Test
fun `seconds under a minute, minutes and seconds over it`() {
assertEquals("59", formatRemaining(59_000L))
assertEquals("1:00", formatRemaining(60_000L))
assertEquals("1:58", formatRemaining(118_000L))
assertEquals("2:13", formatRemaining(133_000L))
}
@Test
fun `a part second still counts as a second until it is gone`() {
// Rounded up, so the ring never shows a figure the viewer has already spent. "0"
// belongs to the moment the offer lapses and to nothing before it.
assertEquals("7", formatRemaining(6_400L))
assertEquals("1", formatRemaining(1L))
assertEquals("0", formatRemaining(0L))
}
@Test
fun `a position past the end never draws a negative figure`() {
// The playhead legitimately overshoots between ticks, and a ring reading "-1"
// would be the last thing a viewer saw of this feature.
assertEquals("0", formatRemaining(-500L))
assertEquals("0", formatRemaining(-90_000L))
}
}
@@ -0,0 +1,173 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The skip-intro offer over a stand-in for a film, captured from the real player XML at TV
* resolution → `build/screenshots/skip-intro/`.
*
* The background is fake on purpose and its only job is to be *bright*: this button sits on
* somebody's episode with no scrim and no panel behind it, so the thing worth looking at is
* whether it still reads against a lit scene. A capture over black would prove nothing —
* everything reads against black.
*
* Both focus states are captured because the button is focusable and takes focus as it
* appears, and the pill inverts to white when it does — which the ring inside it has to
* follow, or it draws white on white. That inversion is the case a unit test cannot see.
* The countdown is captured at both ends of an opening: a two-minute figure, which is the
* ordinary case for a title sequence and the one that has to fit inside the ring, and a
* few seconds left, where the arc is nearly gone.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SkipIntroScreenshotTest {
@Test
fun `the offer as a viewer sees it`() {
capture(
name = "skip-intro-focused",
label = R.string.player_skip_intro,
focused = true,
remainingMs = 118_000L,
)
}
@Test
fun `the offer the moment it lands`() {
capture(
name = "skip-intro-idle",
label = R.string.player_skip_intro,
focused = false,
remainingMs = 118_000L,
)
}
@Test
fun `the last seconds of the offer`() {
// The arc is nearly spent and the figure has dropped to a single digit. It reaches
// zero exactly as the button goes, which is the whole reason the ring counts the
// offer rather than the title sequence.
capture(
name = "skip-intro-running-out",
label = R.string.player_skip_intro,
focused = true,
remainingMs = 7_000L,
)
}
@Test
fun `an automatic skip says what it did`() {
// The same view in the same corner, wearing the timing cues' quiet plate instead of
// the green pill, with no ring and never focused. This is the case worth looking at:
// a notice that still reads as a button is a viewer pressing it to find out what it
// does, and a ring on it would be a countdown to nothing.
capture(
name = "skip-intro-automatic-notice",
label = R.string.player_skip_intro_skipped,
focused = false,
remainingMs = 0L,
asNotice = true,
)
}
private fun capture(
name: String,
label: Int,
focused: Boolean,
remainingMs: Long,
asNotice: Boolean = false,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { background = fakeScene() }
val offer = LayoutInflater.from(activity)
.inflate(R.layout.player_skip_intro, root, false)
offer.visibility = View.VISIBLE
offer.findViewById<TextView>(R.id.player_skip_intro_label).apply {
setText(label)
if (asNotice) setTextColor(Color.WHITE)
}
offer.findViewById<SkipIntroCountdownView>(R.id.player_skip_intro_countdown).apply {
isVisible = !asNotice
// A whole FROM opening, so the arc and the figure are at the size they run at.
setRemaining(remainingMs, OFFER_LENGTH_MS)
}
// The same two lines PlayerActivity's dressSkipIntro applies, so the capture is of
// the real notice rather than of a button with different words in it.
offer.findViewById<View>(R.id.player_skip_intro_button).apply {
if (asNotice) setBackgroundResource(R.drawable.time_remaining_cue_background)
isFocusable = !asNotice
isFocusableInTouchMode = !asNotice
if (focused) requestFocus()
}
root.addView(offer)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/skip-intro/$name.png")
}
/**
* Stands in for a frame of an episode: a lit corner where the button sits, so the
* capture answers "is this still legible over picture" rather than over a flat colour.
*/
private fun fakeScene(): Drawable = object : GradientDrawable(
Orientation.TL_BR,
intArrayOf(Color.rgb(28, 46, 66), Color.rgb(74, 62, 44), Color.rgb(150, 132, 96)),
) {
override fun draw(canvas: Canvas) {
super.draw(canvas)
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
// A bright pool of light behind the bottom-end corner — the hardest case for a
// button with no scrim under it.
canvas.drawPaint(
Paint().apply {
shader = RadialGradient(
width * 0.78f, height * 0.74f, width * 0.42f,
intArrayOf(Color.argb(210, 255, 238, 205), Color.TRANSPARENT),
null,
Shader.TileMode.CLAMP,
)
},
)
// And a soft horizon, so the frame reads as a scene rather than as a swatch.
canvas.drawRect(
0f, height * 0.62f, width, height,
Paint().apply {
shader = LinearGradient(
0f, height * 0.62f, 0f, height,
Color.argb(120, 12, 16, 22), Color.argb(220, 6, 8, 12),
Shader.TileMode.CLAMP,
)
},
)
}
}
private companion object {
/** An episode of FROM: a two-minute opening, less the tail the offer stops short of. */
const val OFFER_LENGTH_MS = 118_000L
}
}