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
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.26"
val defaultVersionName = "0.2.27"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -106,6 +107,20 @@ data class Playable(
* turned the feature off, so the player never offers a row that cannot do anything.
*/
val subtitleDownloadAvailable: Boolean = false,
/**
* Whether it is worth asking the backend for seek previews. On the direct path the
* television reads Emby's preview file itself, so this is always true there; in
* gateway mode it is the gateway saying whether it will answer, which keeps an older
* or a deliberately-configured-off container from being asked once per playback.
*/
val trickplayAvailable: Boolean = false,
/**
* Whether it is worth asking the backend where this title's opening titles are. True
* on the direct path, where the television reads Emby's chapter markers itself; in
* gateway mode it is the gateway saying whether it will answer, which keeps an older
* or a deliberately-configured-off container from being asked once per playback.
*/
val skipIntroAvailable: Boolean = false,
)
/**
@@ -189,6 +204,19 @@ class EmbyRepository(private val settings: SettingsStore) {
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
/**
* Preview layouts, per title, for as long as the process lives. A title's thumbnails
* only change when its media does, and the entry is what saves a round trip on every
* press of Left or Right.
*/
private val trickplayCache =
LinkedHashMap<String, CachedTrickplay>(TRICKPLAY_CACHE_SIZE, 0.75f, true)
/**
* Where each episode's opening titles are, for as long as the process lives. Markers
* only change when the media is re-analysed, and an evening of one show asks for this
* once per episode — including the ones auto-advance rolls into.
*/
private val introCache = LinkedHashMap<String, CachedIntro>(INTRO_CACHE_SIZE, 0.75f, true)
private val seriesEpisodesMutex = Mutex()
private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
@@ -1195,6 +1223,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
)
}
@@ -1219,6 +1249,8 @@ class EmbyRepository(private val settings: SettingsStore) {
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
@@ -1251,6 +1283,8 @@ class EmbyRepository(private val settings: SettingsStore) {
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
val discovery = directPlayback(
@@ -1272,6 +1306,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
)
}
@@ -1329,6 +1365,8 @@ class EmbyRepository(private val settings: SettingsStore) {
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
if (item.isSeries) {
@@ -1352,6 +1390,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
overview = episode.overview,
episodeCode = episodeCode(episode),
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
@@ -1370,6 +1410,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
overview = item.overview,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
@@ -1461,6 +1503,148 @@ class EmbyRepository(private val settings: SettingsStore) {
}.getOrNull()
}
/**
* Where this title's opening titles sit, or null when it has none.
*
* Dual-path, and the two halves read the same thing from the same place: Emby's chapter
* markers. What differs is who does the reading — in gateway mode the television holds
* no Emby credential, so the gateway looks them up and hands back the segment; on the
* direct path the item is fetched here with `Fields=Chapters` and
* [introSegmentFrom] applies the rule.
*
* An episode with no markers is the ordinary case, not a failure, and so is a server
* that will not answer: both come back as null and the player simply never offers the
* button. Nothing about this is on the path of a Play press.
*/
suspend fun introSegment(itemId: String): IntroSegment? {
if (itemId.isBlank()) return null
introCache[itemId]?.let { return it.value }
val resolved = runCatching {
if (ServerConfig.isGateway) gatewayIntro(itemId) else directIntro(itemId)
}.getOrNull()
// A null is cached too. Most of a library has no intro markers — every film, every
// special, every episode Emby has not analysed yet — and without this the same no
// would be fetched again on each playback and each auto-advance.
introCache[itemId] = CachedIntro(resolved)
while (introCache.size > INTRO_CACHE_SIZE) {
introCache.entries.iterator().run {
next()
remove()
}
}
return resolved
}
private suspend fun gatewayIntro(itemId: String): IntroSegment? {
val intro = requireGateway().intro(itemId)
if (!intro.available || intro.endMs <= intro.startMs) return null
return IntroSegment(startMs = intro.startMs, endMs = intro.endMs)
}
private suspend fun directIntro(itemId: String): IntroSegment? {
val userId = snapshot.userId ?: return null
// Chapters and nothing else. This is the one query in the app that asks for them,
// and adding them to a shared Fields list would put a couple of dozen entries per
// item into every row response to render nothing.
return introSegmentFrom(requireApi().getItem(userId, itemId, "Chapters").chapters)
}
/**
* How this title's seek previews are laid out, or null when it has none.
*
* Dual-path like everything else, but the two halves differ in *where the reading
* happens* rather than in what is read. In gateway mode the television holds no Emby
* credential, so the gateway reads the file and serves a frame at a time; on the
* direct path there is nobody to ask, so the index is read here — one ranged request
* off the front of Emby's file, which is where the format puts it.
*
* A title with no previews is the ordinary case, not a failure, and so is a server
* that will not answer: both come back as null and the seek indicator keeps the
* wordless form it has always had.
*/
suspend fun trickplay(itemId: String): Trickplay? {
if (itemId.isBlank()) return null
trickplayCache[itemId]?.let { return it.value }
val resolved = runCatching {
if (ServerConfig.isGateway) gatewayTrickplay(itemId) else directTrickplay(itemId)
}.getOrNull()
// A null is cached too. A title whose previews have not been generated is common
// in a library still catching up, and without this every press of Right on one
// would be a fresh round trip for the same no.
trickplayCache[itemId] = CachedTrickplay(resolved)
while (trickplayCache.size > TRICKPLAY_CACHE_SIZE) {
trickplayCache.entries.iterator().run {
next()
remove()
}
}
return resolved
}
/**
* One thumbnail's JPEG bytes, or null if it could not be fetched.
*
* Decoding is left to the caller: this is asked for from a player that already owns a
* bitmap cache, and handing back bytes keeps the decode on the caller's terms.
*/
suspend fun trickplayFrame(track: Trickplay, index: Int): ByteArray? {
if (index < 0 || index >= track.count) return null
val bif = track.bif
if (bif != null) {
val url = track.bifUrl ?: return null
val range = bif.frame(index) ?: return null
return TrickplayClient.fetch(url, range)
}
val gateway = ServerConfig.gatewayUrl ?: return null
val token = snapshot.token
if (token.isNullOrBlank()) return null
// The ".jpg" carries no meaning to the gateway. It is there for anything
// downstream that reads a URL rather than a content type.
val url = "${gateway.trimEnd('/')}/v1/items/${track.itemId}/trickplay/$index.jpg" +
"?t=${encode(token)}"
return TrickplayClient.fetch(url)
}
private suspend fun gatewayTrickplay(itemId: String): Trickplay? {
val manifest = requireGateway().trickplay(itemId)
if (!manifest.available || manifest.count <= 0 || manifest.intervalMs <= 0L) return null
return Trickplay(
itemId = itemId,
intervalMs = manifest.intervalMs,
count = manifest.count,
width = manifest.width,
height = manifest.height,
)
}
private suspend fun directTrickplay(itemId: String): Trickplay? {
val base = activeServerUrl ?: return null
val token = snapshot.token
if (token.isNullOrBlank()) return null
val url = "${base.trimEnd('/')}/Videos/$itemId/index.bif" +
"?Width=$TRICKPLAY_WIDTH&api_key=${encode(token)}"
val head = TrickplayClient.fetch(url, 0L until TRICKPLAY_INDEX_WINDOW) ?: return null
val index = when (val parsed = parseBifIndex(head)) {
is BifParse.Parsed -> parsed.index
is BifParse.NotBif -> return null
// A title long enough that its index runs past the window. The count is known
// now, so the second read is exact.
is BifParse.NeedMore -> {
val full = TrickplayClient.fetch(url, 0L until parsed.bytes.toLong()) ?: return null
(parseBifIndex(full) as? BifParse.Parsed)?.index ?: return null
}
}
if (index.count <= 0) return null
return Trickplay(
itemId = itemId,
intervalMs = index.intervalMs,
count = index.count,
bif = index,
bifUrl = url,
)
}
/**
* Ask the gateway for subtitles this title does not have.
*
@@ -1911,6 +2095,15 @@ private data class CachedRelated(
val expiresAtMs: Long,
)
/**
* A wrapper rather than the value itself, because a null [value] is a real answer — this
* title has no previews — and a map cannot tell that from an absent entry.
*/
private data class CachedTrickplay(val value: Trickplay?)
/** Null is a real answer here — most titles have no intro markers — so it is cached too. */
private data class CachedIntro(val value: IntroSegment?)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
@@ -1920,6 +2113,30 @@ internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
// continuePlayLookback.
private const val CONTINUE_PLAY_LOOKBACK = 120
/**
* A viewing session touches a handful of titles — what is playing, and what it advances
* to. Twelve is generous for that and each entry is a few kilobytes of byte offsets.
*/
private const val TRICKPLAY_CACHE_SIZE = 12
// A season's worth, so working through one show in an evening never asks twice.
private const val INTRO_CACHE_SIZE = 24
/**
* The thumbnail width to ask Emby for on the direct path. It is not a free parameter:
* Emby generates previews at the widths its own settings name and answers any other with a
* well-formed file containing no frames. 320 is what Emby writes by default, and it must
* match the gateway's own `trickplayWidth` or the two paths would show different previews.
*/
private const val TRICKPLAY_WIDTH = 320
/**
* How much of the front of a BIF to read while looking for its index. This covers a title
* of about twenty-two hours at ten seconds a frame, so in practice one request settles it;
* anything longer costs a second, exact read rather than being refused.
*/
private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L
private const val SERIES_EPISODE_CACHE_SIZE = 6
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
/**
* Where an episode's opening titles sit, in milliseconds from the start of the file.
*
* Emby finds these itself and writes them into the item's chapter list as two markers,
* `IntroStart` and `IntroEnd`, interleaved with the ordinary chapters in playback order.
* So there is nothing to detect on this end and nothing to store: the answer is already in
* the library, and reading it is one field on a request that was going to be made anyway.
*/
data class IntroSegment(val startMs: Long, val endMs: Long) {
/**
* Whether the playhead is inside the titles, with [lead] of the end held back.
*
* The lead is what stops the button appearing for the last half-second of a sequence,
* where pressing it would be indistinguishable from doing nothing — and, in automatic
* mode, what stops a seek to a point the film has already reached.
*/
fun contains(positionMs: Long, lead: Long = 0L): Boolean =
positionMs >= startMs && positionMs < endMs - lead
}
/**
* The shortest span worth calling an intro. Emby occasionally writes a pair a couple of
* seconds apart on a title whose opening it half-recognised, and a button that skips two
* seconds is worse than no button: somebody presses it, the picture does not visibly move,
* and the feature reads as broken.
*/
private const val INTRO_MINIMUM_MS = 5_000L
/**
* The longest. A pair minutes apart is a mis-detection — a recap, a cold open, or two
* unrelated markers read as a range — and honouring it would throw a viewer past the start
* of the story.
*/
private const val INTRO_MAXIMUM_MS = 5 * 60 * 1_000L
private const val MARKER_INTRO_START = "IntroStart"
private const val MARKER_INTRO_END = "IntroEnd"
/**
* Finds the title sequence in an item's chapter list.
*
* The rule exists twice — the gateway's copy is `introFromChapters` in
* `server/internal/api/intro.go` — and the two are pinned by deliberately parallel tests
* (`IntroTest`, `intro_test.go`). With no gateway there is nobody to ask, and a skip must
* not land somewhere different depending on whether the container is up.
*
* Most of this is about refusing to answer. A pair that is out of order, too short, too
* long, or missing half of itself produces null, and null is a perfectly good answer: the
* player simply never offers the button. A wrong skip costs somebody the opening of a
* scene, which is far worse than not being offered one.
*/
fun introSegmentFrom(chapters: List<EmbyChapter>): IntroSegment? {
var startMs = -1L
for (chapter in chapters) {
when (chapter.markerType) {
MARKER_INTRO_START ->
// The first start wins, and a second is ignored rather than replacing it.
// Two starts mean the markers are already untrustworthy; taking the later
// one would pick the larger, more damaging skip of the two.
if (startMs < 0L && chapter.startPositionTicks >= 0L) {
startMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
}
MARKER_INTRO_END -> {
// An end before any start is a stray marker, not the close of a segment.
if (startMs < 0L) continue
val endMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
val length = endMs - startMs
if (length < INTRO_MINIMUM_MS || length > INTRO_MAXIMUM_MS) return null
return IntroSegment(startMs = startMs, endMs = endMs)
}
}
}
return null
}
private const val TICKS_PER_MILLISECOND = 10_000L
@@ -317,6 +317,9 @@ data class Settings(
// How far one press of Left or Right moves the film. Per-profile and synced for the
// same reason subtitles are: it is a habit, not a property of the room.
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
// What to do when an episode reaches its opening titles: offer a button, skip without
// asking, or nothing. Per-profile and synced for the same reason the two above are.
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -417,6 +420,7 @@ data class EmbyProfile(
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
)
class SettingsStore(private val context: Context) {
@@ -454,6 +458,7 @@ class SettingsStore(private val context: Context) {
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -605,11 +610,21 @@ class SettingsStore(private val context: Context) {
}
}
/** Normalised on the way in, so a mode this build has no vocabulary for never reaches
* the player as an instruction. */
suspend fun setSkipIntroMode(mode: String) {
val normalized = normalizeSkipIntroMode(mode)
context.dataStore.edit { preferences ->
preferences[Keys.SKIP_INTRO_MODE] = normalized
updateActiveProfile(preferences) { it.copy(skipIntroMode = normalized) }
}
}
/**
* Adopts the server's document for the signed-in viewer, in one write.
*
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
* edit, and this touches seventeen keys plus the profiles blob — doing it through the
* edit, and this touches eighteen keys plus the profiles blob — doing it through the
* individual setters would be seventeen rewrites for one sync, on a TV that has just
* started up.
*
@@ -638,6 +653,7 @@ class SettingsStore(private val context: Context) {
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
store[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
@@ -659,6 +675,7 @@ class SettingsStore(private val context: Context) {
subtitleLanguage = preferences.subtitleLanguage,
seekIntervalSeconds =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
forYouMinutes = preferences.forYouMinutes,
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
@@ -1015,6 +1032,7 @@ class SettingsStore(private val context: Context) {
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = previous?.seekIntervalSeconds
?: DEFAULT_SEEK_INTERVAL_SECONDS,
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -1154,6 +1172,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
preferences[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -1195,6 +1214,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
)
}
@@ -1228,6 +1248,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
@@ -0,0 +1,33 @@
package com.ponzischeme89.memby.data
/**
* What happens when an episode reaches its opening titles.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it — the store, the settings row and the player — and the
* gateway's own catalogue (`skipIntroMode` in `internal/api/preferences.go`) holds the
* matching list. Keep the two in step: a value this build does not recognise is normalised
* to the default rather than honoured, so an operator pushing a mode a television has
* never heard of costs that set a button it already understood, never an unexplained jump
* through somebody's episode.
*
* [SKIP_INTRO_PROMPT] is the default deliberately. Skipping automatically is a jump in a
* film nobody asked for, and it must be chosen rather than arrived at.
*/
/** Offer a button. Nothing moves until somebody presses it. */
const val SKIP_INTRO_PROMPT = "prompt"
/** Jump past the titles as soon as playback reaches them, once per episode. */
const val SKIP_INTRO_AUTO = "auto"
/** Leave the titles alone. */
const val SKIP_INTRO_OFF = "off"
/** The modes a viewer may choose between, in the order the settings row offers them. */
val SKIP_INTRO_MODES: List<String> = listOf(SKIP_INTRO_PROMPT, SKIP_INTRO_AUTO, SKIP_INTRO_OFF)
const val DEFAULT_SKIP_INTRO_MODE: String = SKIP_INTRO_PROMPT
fun normalizeSkipIntroMode(mode: String?): String =
mode?.trim()?.lowercase()?.takeIf { it in SKIP_INTRO_MODES } ?: DEFAULT_SKIP_INTRO_MODE
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.data
/**
* The seek preview thumbnails for one title, and — on the direct path — where each one's
* bytes live inside Emby's file.
*
* Emby stores them as a BIF: a header, an index of (timestamp, offset) pairs, then one
* JPEG per entry laid end to end, at one frame every ten seconds. The index sitting at the
* *front* of the file is what makes previews affordable on a television. A two-hour film's
* BIF is five megabytes and nobody can download that to show one thumbnail, but reading
* the first few kilobytes gives every frame's byte range, so a preview costs one ranged
* request of about seven kilobytes.
*
* The parsing exists twice on purpose — here and in the gateway's `internal/trickplay` —
* and is pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`). With no
* gateway there is nobody to ask, and a preview must not depend on whether the container
* is up. In gateway mode the television has no Emby credential to range-read a file with,
* so the server does the reading and serves a frame at a time; [bif] is null there.
*/
data class Trickplay(
val itemId: String,
val intervalMs: Long,
val count: Int,
val width: Int = 0,
val height: Int = 0,
internal val bif: BifIndex? = null,
internal val bifUrl: String? = null,
) {
/**
* Which thumbnail covers a moment in the title.
*
* It clamps rather than refusing. This is read while somebody is still moving a seek
* target about, and a position a second past the last frame should show the last
* frame — a preview that blanks at the end of a film reads as broken.
*/
fun frameAt(positionMs: Long): Int {
if (count <= 0 || intervalMs <= 0L) return 0
if (positionMs <= 0L) return 0
val frame = positionMs / intervalMs
return if (frame >= count) count - 1 else frame.toInt()
}
/**
* How wide to draw the preview for a given height. Falls back to 16:9 until the frames'
* real shape is known, so the plate is laid out at about the right size on the first
* press rather than growing a thumbnail-shaped hole when one arrives.
*/
fun widthFor(heightPx: Int): Int =
if (width > 0 && height > 0) heightPx * width / height else heightPx * 16 / 9
}
/** Where each frame's bytes are, read off the front of a BIF. */
data class BifIndex(
val count: Int,
val intervalMs: Long,
/** [count] + 1 values, the last being the end of the final frame. */
val offsets: List<Long>,
) {
/** The half-open byte range of one thumbnail, ready for a Range header. */
fun frame(index: Int): LongRange? {
if (index < 0 || index >= count || offsets.size <= count) return null
val start = offsets[index]
val end = offsets[index + 1]
return if (end > start) start until end else null
}
}
/**
* What reading the front of a BIF came to.
*
* [NeedMore] is the case that earns this its own type: a caller reads a fixed window off
* the front of the file, and a long title legitimately has an index that runs past it. That
* must be answerable — read this much and try again — rather than looking like a bad file.
*/
sealed interface BifParse {
data class Parsed(val index: BifIndex) : BifParse
data class NeedMore(val bytes: Int) : BifParse
data object NotBif : BifParse
}
/** The fixed preamble: magic, version, frame count, timestamp multiplier. */
const val BIF_HEADER_SIZE = 64
private const val BIF_ENTRY_SIZE = 8
private const val BIF_DEFAULT_MULTIPLIER = 1_000L
/**
* The file's first eight bytes. The leading 0x89 and the CR/LF pair are the trick PNG uses:
* a file mangled by a text-mode transfer stops matching.
*/
private val BIF_MAGIC = byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a)
/** How many bytes of the file hold the header and the whole index. */
fun bifIndexLength(count: Int): Int = BIF_HEADER_SIZE + (count + 1) * BIF_ENTRY_SIZE
/**
* Reads the header and index out of the front of a BIF. [bytes] may be longer than the
* index; the rest is ignored.
*
* A count of zero is not a failure. It is what Emby serves for a title whose thumbnails
* have not been generated — a perfectly well-formed 72-byte file — and it must read as
* "this title has no previews" or every such title looks like a broken one.
*/
fun parseBifIndex(bytes: ByteArray): BifParse {
if (bytes.size < BIF_HEADER_SIZE) return BifParse.NeedMore(BIF_HEADER_SIZE)
for (i in BIF_MAGIC.indices) {
if (bytes[i] != BIF_MAGIC[i]) return BifParse.NotBif
}
val count = readLittleEndian(bytes, 12).toInt()
if (count < 0) return BifParse.NotBif
val multiplier = readLittleEndian(bytes, 16).takeIf { it > 0L } ?: BIF_DEFAULT_MULTIPLIER
if (count == 0) {
return BifParse.Parsed(BifIndex(count = 0, intervalMs = multiplier, offsets = emptyList()))
}
val length = bifIndexLength(count)
if (bytes.size < length) return BifParse.NeedMore(length)
val offsets = ArrayList<Long>(count + 1)
var firstTimestamp = 0L
var secondTimestamp = 0L
for (entry in 0..count) {
val at = BIF_HEADER_SIZE + entry * BIF_ENTRY_SIZE
val timestamp = readLittleEndian(bytes, at)
offsets.add(readLittleEndian(bytes, at + 4))
when (entry) {
0 -> firstTimestamp = timestamp
1 -> secondTimestamp = timestamp
}
}
// A frame that starts inside the index, or before the one ahead of it, means the file
// is not laid out the way the format says. Reading a byte range from it would decode
// whatever happened to be there.
if (offsets[0] < length.toLong()) return BifParse.NotBif
for (entry in 1..count) {
if (offsets[entry] < offsets[entry - 1]) return BifParse.NotBif
}
// Emby writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the interval
// is ten seconds, and taking the multiplier for it would be right only by accident.
val interval = if (count >= 2 && secondTimestamp > firstTimestamp) {
(secondTimestamp - firstTimestamp) * multiplier
} else {
multiplier
}
return BifParse.Parsed(
BifIndex(
count = count,
intervalMs = if (interval > 0L) interval else BIF_DEFAULT_MULTIPLIER,
offsets = offsets,
),
)
}
private fun readLittleEndian(bytes: ByteArray, at: Int): Long =
(bytes[at].toLong() and 0xFF) or
((bytes[at + 1].toLong() and 0xFF) shl 8) or
((bytes[at + 2].toLong() and 0xFF) shl 16) or
((bytes[at + 3].toLong() and 0xFF) shl 24)
@@ -43,6 +43,8 @@ data class UserPreferences(
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
/** How far Left and Right move the film, in seconds. One of [SEEK_INTERVAL_SECONDS]. */
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
@@ -73,6 +75,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
@@ -117,6 +120,11 @@ fun decodeUserPreferences(
seekIntervalSeconds = normalizeSeekIntervalSeconds(
json.int("seekIntervalSeconds", fallback.seekIntervalSeconds),
),
// Normalised for the same reason the interval above is: a mode this build has no
// vocabulary for must cost the viewer the default button, never an unexplained jump.
skipIntroMode = normalizeSkipIntroMode(
json.string("skipIntroMode", fallback.skipIntroMode),
),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
@@ -138,6 +146,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("skipIntroMode", skipIntroMode)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
@@ -308,6 +308,18 @@ data class EmbyPerson(
val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true)
}
/**
* One entry of Emby's chapter list. Only two of its fields matter here: intro markers are
* written as ordinary chapters carrying a [markerType], in playback order beside the real
* ones, which is why finding the titles costs no request of its own.
*/
@Serializable
data class EmbyChapter(
@SerialName("StartPositionTicks") val startPositionTicks: Long = 0L,
@SerialName("MarkerType") val markerType: String = "",
@SerialName("Name") val name: String = "",
)
@Serializable
data class BaseItem(
@SerialName("Id") val id: String,
@@ -348,6 +360,12 @@ data class BaseItem(
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null,
/**
* Chapter markers, which is where Emby records an episode's opening titles. Only the
* direct path's intro lookup asks for them — every other query would be paying for a
* couple of dozen entries per item to render nothing.
*/
@SerialName("Chapters") val chapters: List<EmbyChapter> = emptyList(),
// Server-authored schedule metadata. These fields are absent on normal Emby items.
@SerialName("MembySource") val membySource: String? = null,
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
@@ -386,6 +404,13 @@ data class BaseItem(
// an item the gateway has never looked up carries none and the dedicated ratings
// request still fills it in. Defaulted, so a cached home payload decodes unchanged.
@SerialName("MembyRatings") val membyRatings: List<MediaRating> = emptyList(),
// Why this card is leading the launcher, decided by the gateway from evidence the
// television does not have — Radarr's digital release date, Sonarr's premieres and
// the stored review scores. The wording is the server's for the usual reason: a kind
// of hero card added tomorrow reads correctly on a build that predates it. Both are
// absent on the direct path, where the client picks the hero itself.
@SerialName("MembyHeroLabel") val membyHeroLabel: String? = null,
@SerialName("MembyHeroReason") val membyHeroReason: String? = null,
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
@@ -377,6 +377,44 @@ data class GatewayPlayback(
// that asks and it already holds this. Absent on an older gateway, and the default
// must stay false: a missing field must never conjure a row that cannot do anything.
val subtitleDownloadAvailable: Boolean = false,
// Whether it is worth asking this gateway for seek previews at all. Only the answer
// rides here — the layout itself is its own request, off the critical path of
// starting playback. Absent on an older gateway, and the default must stay false: a
// missing field must never conjure a request the backend would 404.
val trickplayAvailable: Boolean = false,
// Whether it is worth asking this gateway where the title sequence is. Same shape and
// same reasoning as the previews above: the segment itself is its own request, and a
// missing field must never conjure one this backend would not answer.
val skipIntroAvailable: Boolean = false,
)
/**
* Where an episode's opening titles sit, as the gateway found them in Emby's markers.
*
* [available] is explicit rather than implied by a zero pair: an intro can legitimately
* begin at the very start of the file, and that must stay distinguishable from an episode
* that has no markers at all.
*/
@Serializable
data class GatewayIntro(
val available: Boolean = false,
val startMs: Long = 0L,
val endMs: Long = 0L,
)
/**
* How a title's seek previews are laid out, as the gateway describes them.
*
* Frame URLs are not listed. There are hundreds of them and they are formed by a rule the
* client already knows, so a list would be most of the response.
*/
@Serializable
data class GatewayTrickplay(
val available: Boolean = false,
val intervalMs: Long = 0L,
val count: Int = 0,
val width: Int = 0,
val height: Int = 0,
)
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@@ -186,6 +186,31 @@ interface GatewayApi {
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/**
* Where this episode's opening titles are, if Emby has marked them.
*
* Its own request for the same reason [trickplay] is: reading the markers costs the
* gateway a round trip to Emby, and nothing about a skip button is needed before the
* first frame — the earliest intro in a typical library starts a couple of minutes in.
*/
@GET("v1/items/{id}/intro")
suspend fun intro(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayIntro
/**
* How this title's seek previews are laid out, if it has any.
*
* Deliberately its own request rather than a field on [playback]: reading the layout
* costs the gateway a round trip to Emby, and the playback response is the one thing
* standing between a Play press and a decoder starting. This is asked for once the
* first frame is up.
*/
@GET("v1/items/{id}/trickplay")
suspend fun trickplay(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayTrickplay
/**
* Ask the subtitle service for tracks this title does not have. This is a live query
* against subtitle providers, so it is slow by nature — seconds, not milliseconds.
@@ -77,6 +77,12 @@ internal val MEMBY_CAPABILITIES = listOf(
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
// Declares that this build can draw seek preview thumbnails, so the admin console
// reports the feature honestly against an older app that would never ask for them.
"trickplay_v1",
// Declares that this build can offer to skip an episode's opening titles, so the admin
// console reports the feature honestly against an older app that would never ask.
"skip_intro_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -0,0 +1,74 @@
package com.ponzischeme89.memby.data.remote
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* Reads seek preview thumbnails, which are the one thing in the app fetched as raw bytes
* rather than through Retrofit or Coil.
*
* Retrofit is the wrong shape because the direct path wants a *byte range* of a file, and
* Coil is the wrong home because these are small, transient and asked for in bursts: a
* viewer holding Right walks through dozens of them, and letting that churn through the
* artwork cache would evict the backdrops and posters the launcher is about to want again.
* The player keeps its own small cache instead.
*/
internal object TrickplayClient {
/**
* A preview is worth having only while somebody is still pressing. Eight seconds is
* already far past the point where the thumbnail would have answered the question, and
* the seek it is describing has committed and moved on.
*/
private const val TIMEOUT_SECONDS = 8L
/** A frame is a few kilobytes and an index a few more. Nothing here is megabytes. */
private const val MAX_BYTES = 4L * 1024L * 1024L
private const val CHUNK_BYTES = 16 * 1024
private val http by lazy {
HttpStack.base.newBuilder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Fetches [url], optionally only the bytes in [range].
*
* Returns null on any failure. Nothing above this can do anything useful with the
* reason: a preview that does not arrive leaves the seek indicator in the wordless form
* it has always had, which is a complete answer rather than a degraded one.
*
* A server that ignores the range answers 200 with the whole file. Emby does honour it
* on the BIF route while advertising `Accept-Ranges: none`, so the request is made on
* the strength of what comes back — but the read is capped either way, because being
* wrong about that must not turn a seek into a five-megabyte download.
*/
suspend fun fetch(url: String, range: LongRange? = null): ByteArray? =
withContext(Dispatchers.IO) {
val builder = Request.Builder().url(url)
range?.let { builder.header("Range", "bytes=${it.first}-${it.last}") }
val limit = (range?.let { it.last - it.first + 1 } ?: MAX_BYTES)
.coerceIn(0L, MAX_BYTES)
runCatching {
http.newCall(builder.build()).execute().use { response ->
val stream = response.body?.byteStream()
if (!response.isSuccessful || stream == null) return@use null
val collected = ByteArrayOutputStream()
val chunk = ByteArray(CHUNK_BYTES)
while (collected.size() < limit) {
val wanted = minOf(chunk.size.toLong(), limit - collected.size()).toInt()
val read = stream.read(chunk, 0, wanted)
if (read <= 0) break
collected.write(chunk, 0, read)
}
collected.toByteArray().takeIf { it.isNotEmpty() }
}
}.getOrNull()
}
}
@@ -96,6 +96,7 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
@@ -118,6 +119,7 @@ import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
@@ -221,6 +223,7 @@ fun TvNavigationRail(
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
alertCount: Int = 0,
) {
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
@@ -335,6 +338,13 @@ fun TvNavigationRail(
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
onFocused = {},
onClick = { onDestinationSelected(destination) },
// My Alerts is one level in, behind the user picker. Without a mark out
// here nothing on the launcher would ever say there was news waiting.
badge = if (destination == BrowseDestination.PROFILES) {
alertBadgeLabel(alertCount)
} else {
null
},
)
Spacer(Modifier.height(4.dp))
}
@@ -359,10 +369,12 @@ fun UserSwitcherOverlay(
onManageProfiles: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
alertCount: Int = 0,
onOpenAlerts: () -> Unit = {},
) {
val profileIds = profiles.map(EmbyProfile::id)
val focusRequesters = remember(profileIds) {
List(profiles.size + 1) { FocusRequester() }
List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
val focusScope = rememberCoroutineScope()
@@ -408,6 +420,7 @@ fun UserSwitcherOverlay(
currentIndex = focusedIndex,
profileCount = profiles.size,
direction = direction,
actionCount = UserSwitcherActionCount,
)
focusedIndex = next
if (next < profiles.size) {
@@ -475,19 +488,38 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
// My Alerts lives here rather than on the launcher: these alerts belong to a
// person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "Manage users",
label = "My Alerts",
icon = Icons.Default.Notifications,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size
},
onClick = onOpenAlerts,
)
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size + 1
},
onClick = onManageProfiles,
)
}
}
}
/** My Alerts, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2
@Composable
private fun UserSwitcherProfileItem(
profile: EmbyProfile,
@@ -554,7 +586,9 @@ private fun UserSwitcherProfileItem(
@Composable
private fun UserSwitcherAction(
label: String,
icon: ImageVector,
modifier: Modifier = Modifier,
badge: String? = null,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@@ -566,12 +600,14 @@ private fun UserSwitcherAction(
.clip(RoundedCornerShape(MembyChipCorner))
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.semantics {
contentDescription = badge?.let { "$label, $it waiting" } ?: label
}
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Settings,
icon,
contentDescription = null,
tint = if (focused) Color.White else QuietText,
modifier = Modifier.size(17.dp),
@@ -583,9 +619,24 @@ private fun UserSwitcherAction(
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
if (badge != null) {
Spacer(Modifier.weight(1f))
Text(
badge,
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(AlertBadgeRed, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
/** The one red a waiting-alert badge is drawn in, on the rail and in the user menu alike. */
private val AlertBadgeRed = Color(0xFFE04747)
@Composable
fun ExpandableNavigationItem(
destination: BrowseDestination,
@@ -594,6 +645,7 @@ fun ExpandableNavigationItem(
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: String? = null,
) {
var focused by remember { mutableStateOf(false) }
// Not `by`: both colours are read in the draw phase / at the point of use, so the
@@ -627,7 +679,10 @@ fun ExpandableNavigationItem(
.clip(RoundedCornerShape(8.dp))
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.semantics {
contentDescription = badge?.let { "${destination.label}, $it waiting" }
?: destination.label
}
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -643,6 +698,20 @@ fun ExpandableNavigationItem(
.background(EmbyGreen, RoundedCornerShape(2.dp)),
)
}
// Over the icon rather than after the label: the rail spends most of its life
// collapsed to 54dp, where there is no label to sit beside.
if (badge != null) {
Text(
badge,
color = Color.White,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.background(AlertBadgeRed, CircleShape)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
if (expanded) {
Text(
@@ -51,6 +51,7 @@ import com.ponzischeme89.memby.data.floorMod
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 com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -101,22 +102,65 @@ internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Bo
hasMovies && listAtTop
/**
* A hero card and the reason it is there.
* A hero card, the caption it wears and — when the gateway composed it the one line
* saying why it is there.
*
* The label used to be the card's *position* — `listOf("POPULAR", "NEW RELEASE",
* "TRENDING")[index]` — while the selection below interleaves two sources and then falls
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
* one POPULAR. A caption that can be wrong is worse than no caption.
*/
internal data class HomeHeroPick(val item: BaseItem, val label: String)
internal data class HomeHeroPick(
val item: BaseItem,
val label: String,
val reason: String? = null,
)
private const val LABEL_NEW = "NEW RELEASE"
private const val LABEL_POPULAR = "POPULAR"
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
/** The `kind` of the server-composed hero row. It is consumed here, never drawn as a row. */
internal const val SERVER_HERO_ROW_KIND = "hero"
/**
* The hero the gateway composed, if it sent one.
*
* This is the preferred path and it is deliberately a *verbatim* read: the server has
* Radarr's digital release dates, Sonarr's premieres and the stored review scores, none of
* which reach the television, so second-guessing its order here would only ever be able to
* throw that evidence away. No day rotation either — the underlying facts already change
* daily, and rotating a merit ranking is precisely how the best-reviewed release of the
* week ends up in the fourth slot.
*
* Each card carries its own caption, so a kind of hero the server invents tomorrow reads
* correctly on this build. A card that somehow arrives without one falls back to the
* neutral library caption rather than to a claim nobody made.
*/
internal fun serverHeroPicks(rows: List<HomeRow>): List<HomeHeroPick> =
rows.asSequence()
.filter { it.kind == SERVER_HERO_ROW_KIND }
.flatMap { it.items.asSequence() }
.filter { it.membyPlayable }
.distinctBy(BaseItem::id)
.take(4)
.map { item ->
HomeHeroPick(
item = item,
label = item.membyHeroLabel?.takeIf(String::isNotBlank) ?: LABEL_LIBRARY,
reason = item.membyHeroReason?.takeIf(String::isNotBlank),
)
}
.toList()
/**
* Picks a deliberate mix of fresh and popular movies while preserving server ranking.
*
* This is the **direct path's** rule, and the fallback whenever the gateway sends no hero
* row — a container that is down, or one older than the feature. It ranks on the only
* evidence a television has, which is which shelf a title was drawn from; where the
* gateway is answering, [serverHeroPicks] wins because it can see rather more than that.
*
* [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
* each candidate list, so a household that leaves the launcher on the same four films for
* a fortnight instead sees a different set every morning. It is a rotation rather than a
@@ -129,7 +173,10 @@ private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
internal fun selectHomeHeroMovies(
rows: List<HomeBrowseRow>,
day: Long = 0L,
serverRows: List<HomeRow> = emptyList(),
): List<HomeHeroPick> {
serverHeroPicks(serverRows).takeIf(List<HomeHeroPick>::isNotEmpty)?.let { return it }
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
@@ -243,10 +290,20 @@ internal fun HomeMovieHero(
}
}
/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */
/**
* A wash over the artwork, keyed to why the card is on the shelf rather than to its slot.
*
* The gateway's captions are matched here as strings rather than as an enum for the same
* reason their wording lives on the server: a caption this build has never heard of gets
* the neutral wash and reads correctly, where a `when` over a sealed type would have to be
* taught every new one in an app release.
*/
private fun labelTint(label: String): Color = when (label) {
LABEL_NEW -> Color(0x667253B7)
LABEL_POPULAR -> Color(0x66499BD5)
// A premiere is a different kind of news from a film, and reads as one.
"SERIES PREMIERE", "NEW SEASON" -> Color(0x664F9E7A)
"HIGHLY RATED" -> Color(0x66B8873F)
else -> Color(0x66C67A42)
}
@@ -262,7 +319,9 @@ private fun FeaturedMovieCard(
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Featured movie, ${item.name}",
// Not "Featured movie": a series premiere can lead, and a screen reader announcing
// one as a movie is worse than one announcing it by the caption it is wearing.
contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
@@ -327,12 +386,40 @@ private fun FeaturedMovieCard(
)
Spacer(Modifier.height(9.dp))
HeroFactLine(item)
// The reason takes the synopsis's place rather than adding a line to
// it. It is why this card is leading — "Well reviewed, released
// yesterday" — which is more use here than the first two lines of a
// plot the detail page prints in full, and one line where the synopsis
// is two, so preferring it can only make the card shorter. There is
// still no eyebrow above the title: that was the line that pushed a
// wrapped title into the button.
//
// It sits *above* the ratings strip, and that order is load-bearing.
// This column is the one that gives way when a title wraps onto two
// lines, and whatever is last in it is what gets cut — with the reason
// below the strip, the one line explaining why this card is leading
// the launcher was silently dropped on exactly the long-titled films
// most likely to be leading it. The scores are also on the detail page
// this card opens; the reason is not anywhere else.
val reason = pick.reason?.takeIf(String::isNotBlank)
if (reason != null) {
Spacer(Modifier.height(7.dp))
Text(
reason,
color = MembyAccent,
fontSize = 13.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
)
if (titleLines == 1) {
if (reason == null && titleLines == 1) {
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
Spacer(Modifier.height(9.dp))
Text(
@@ -125,8 +125,10 @@ import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationsResponse
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
@@ -805,10 +807,7 @@ private fun ProfileChooser(
removingProfileId: String?,
onSelect: (EmbyProfile) -> Unit,
onRemove: (EmbyProfile) -> Unit,
// Null from the launcher on purpose: adding a viewer belongs to setting the TV up, not
// to browsing it. Most households here have one account, and a permanent "+" tile beside
// their own name reads as a thing they are supposed to do.
onAddProfile: (() -> Unit)?,
onAddProfile: () -> Unit,
onClose: (() -> Unit)?,
) {
val firstFocus = remember { FocusRequester() }
@@ -871,20 +870,18 @@ private fun ProfileChooser(
)
}
}
if (onAddProfile != null) {
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = if (orderedProfiles.isEmpty()) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
}
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = if (orderedProfiles.isEmpty()) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
}
if (onClose != null) {
Spacer(Modifier.height(30.dp))
@@ -1334,6 +1331,10 @@ private fun HomeScreen(
var showSettings by remember { mutableStateOf(false) }
var showProfiles by remember { mutableStateOf(false) }
// Reached only from the manage-users page, which is itself two presses in behind the
// user picker — so the "+" is where somebody looking to add a viewer is already
// standing, and it is nowhere near the launcher.
var addingProfile by remember { mutableStateOf(false) }
var userSwitcherVisible by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var removingProfileId by remember { mutableStateOf<String?>(null) }
@@ -1420,6 +1421,7 @@ private fun HomeScreen(
showSettings = false
showProfiles = false
userSwitcherVisible = false
showNotifications = false
detailsItem = null
detailsAiringNotice = null
quickMenuItem = null
@@ -1513,9 +1515,12 @@ private fun HomeScreen(
// Keyed on the day as well as the rows, so the feature changes when the date does and
// not merely when the launcher happens to be rebuilt.
val heroDay = rememberHomeHeroDay()
val homeHeroMovies = remember(rows, selectedDestination, heroDay) {
// The server rows are passed in *unfiltered*, beside the browse rows the launcher
// draws: serverHomeRows drops the hero row, because it is consumed here rather than
// rendered as a shelf, so this is the only thing that can still see it.
val homeHeroMovies = remember(rows, homeContent.rows, selectedDestination, heroDay) {
if (selectedDestination == BrowseDestination.HOME) {
selectHomeHeroMovies(rows, heroDay)
selectHomeHeroMovies(rows, heroDay, homeContent.rows)
} else {
emptyList()
}
@@ -1626,6 +1631,7 @@ private fun HomeScreen(
}
}
},
alertCount = notificationState.notifications.size,
)
androidx.compose.foundation.layout.BoxWithConstraints(
modifier = Modifier
@@ -1967,19 +1973,6 @@ private fun HomeScreen(
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
)
if (selectedDestination == BrowseDestination.HOME && !showNotifications) {
NotificationBell(
unreadCount = notificationState.notifications.count { it.unread },
onClick = {
showNotifications = true
scope.launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
modifier = Modifier.align(Alignment.TopEnd).padding(top = 20.dp, end = 24.dp),
)
}
if (userSwitcherVisible) {
BackHandler {
userSwitcherVisible = false
@@ -2012,6 +2005,16 @@ private fun HomeScreen(
navigationExpanded = false
showProfiles = true
},
alertCount = notificationState.notifications.size,
onOpenAlerts = {
userSwitcherVisible = false
navigationExpanded = false
showNotifications = true
scope.launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
onDismiss = {
userSwitcherVisible = false
scope.launch {
@@ -2094,11 +2097,19 @@ private fun HomeScreen(
removingProfileId = null
}
},
// Settings → Accounts is where a second viewer is added.
onAddProfile = null,
onAddProfile = { addingProfile = true },
onClose = { showProfiles = false },
)
}
// Over the manage-users page rather than instead of it: cancelling comes straight
// back to the list the "+" was pressed from. A successful sign-in makes the new
// viewer active, which re-keys HomeScreen and takes both flags with it.
if (addingProfile) {
SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
}
detailsItem?.let { selected ->
BackHandler {
val previous = detailsTrail.lastOrNull()
@@ -2184,8 +2195,17 @@ private fun HomeScreen(
)
}
if (showNotifications) {
BackHandler { showNotifications = false }
NotificationsOverlay(
// Reached from the user picker, so leaving it goes back to the rail rather than
// to whatever card happened to hold focus on the launcher behind it.
val closeAlerts: () -> Unit = {
showNotifications = false
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { navigationFocusRequester.requestFocus() }
}
}
BackHandler(onBack = closeAlerts)
MyAlertsPage(
notifications = notificationState.notifications,
preferences = notificationState.preferences,
onToggleEnabled = {
@@ -2217,7 +2237,7 @@ private fun HomeScreen(
}
}
},
onDismissNotification = { notification ->
onDismiss = { notification ->
scope.launch {
runCatching { repo.dismissNotification(notification.id) }.onSuccess {
notificationState = notificationState.copy(
@@ -2228,7 +2248,20 @@ private fun HomeScreen(
}
}
},
onClose = { showNotifications = false },
// The gateway has no bulk route, so this is the same call per alert. The
// list is emptied optimistically: the page is judged on emptying itself,
// and a row that lingered while its request was in flight would be pressed
// a second time.
onDismissAll = {
val pending = notificationState.notifications.map(UserNotification::id)
notificationState = notificationState.copy(notifications = emptyList())
scope.launch {
pending.forEach { id -> runCatching { repo.dismissNotification(id) } }
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
onClose = closeAlerts,
)
}
quickMenuItem?.let { selected ->
@@ -2669,6 +2702,10 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
"continue", "nextup" -> "continue" in enabledSections
"favorites" -> "favorites" in enabledSections
"latest" -> "latest" in enabledSections
// The hero row is the four featured cards. It is drawn above the shelves
// by HomeMovieHero, so letting it through here would print the same four
// titles a second time as an unnamed row of posters directly beneath it.
SERVER_HERO_ROW_KIND -> false
else -> true
}
}
@@ -11,25 +11,14 @@ 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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
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
@@ -39,7 +28,6 @@ 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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -48,14 +36,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -295,147 +279,6 @@ private fun StatusLine(label: String, value: String) {
}
}
@Composable
internal fun NotificationBell(
unreadCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Card(
onClick = onClick,
modifier = modifier
.size(52.dp)
.onFocusChanged { focused = it.hasFocus }
.graphicsLayer {
scaleX = if (focused) 1.08f else 1f
scaleY = if (focused) 1.08f else 1f
},
) {
Box(
Modifier.fillMaxSize().background(if (focused) Color.White else Color(0xCC20262B)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Notifications,
contentDescription = "Notifications",
tint = if (focused) Color.Black else Color.White,
modifier = Modifier.size(25.dp),
)
if (unreadCount > 0) {
Text(
unreadCount.coerceAtMost(99).toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Color(0xFFE04747), CircleShape)
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
}
}
@Composable
internal fun NotificationsOverlay(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismissNotification: (UserNotification) -> Unit,
onClose: () -> Unit,
) {
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(
"Show-return alerts are saved for this user on every Memby TV.",
color = Color(0xFFAEB7BF),
fontSize = 15.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onToggleEnabled) {
Icon(
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
}
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
Text(
if (preferences.showReturnAlerts) {
"Return alerts on"
} else {
"Return alerts off"
},
)
}
Button(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("Close")
}
}
}
if (notifications.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Youre all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(notifications, key = UserNotification::id) { notification ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
RoundedCornerShape(12.dp),
)
.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
Modifier.size(10.dp).background(
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
CircleShape,
),
)
Column(Modifier.weight(1f)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
)
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
}
if (notification.unread) {
Button(onClick = { onRead(notification) }) { Text("Mark read") }
}
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
}
}
}
}
}
}
}
internal fun formatMyShowDate(value: String?): String {
if (value.isNullOrBlank()) return "Not announced"
return runCatching {
@@ -3,8 +3,9 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned Manage users action is always the final
* index. Keeping this arithmetic outside Compose makes remote navigation deterministic.
* Profiles occupy [0, profileCount); the pinned actions My Alerts, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
@@ -15,11 +16,13 @@ internal fun userSwitcherNextIndex(
currentIndex: Int,
profileCount: Int,
direction: UserSwitcherDirection,
actionCount: Int = 1,
): Int {
val manageIndex = profileCount.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, manageIndex)
val lastIndex = (profileCount.coerceAtLeast(0) + actionCount.coerceAtLeast(1) - 1)
.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, lastIndex)
return when (direction) {
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui.alerts
/**
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once.
*
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and
* not unread ones: an alert that has been read but not dismissed is still sitting there, and
* a badge that cleared itself the moment somebody glanced at the page would be a badge that
* never agreed with the list underneath it.
*/
/** Above this the badge stops counting and says so, or the pill grows wider than its row. */
internal const val AlertBadgeMax = 9
internal fun alertBadgeLabel(count: Int): String? = when {
count <= 0 -> null
count > AlertBadgeMax -> "$AlertBadgeMax+"
else -> count.toString()
}
/**
* The line under the page heading. "New" is the unread half worth naming, since it is the
* only thing distinguishing two otherwise identical rows but it is never claimed on its
* own, because a page saying "2 new" above three alerts reads as having lost one.
*/
internal fun alertsSummary(total: Int, unread: Int): String = when {
total <= 0 -> "Nothing waiting for you"
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts"
else -> {
val alerts = if (total == 1) "1 alert" else "$total alerts"
"$alerts · $unread new"
}
}
@@ -0,0 +1,313 @@
package com.ponzischeme89.memby.ui.alerts
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.Column
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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.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.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent
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.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import kotlinx.coroutines.delay
/**
* My Alerts a full page rather than the panel it used to be, reached from the user menu in
* the user picker.
*
* It moved there because these alerts belong to a *person*, not to a television: they follow
* whoever is signed in, so the place that already answers "who is watching" is where somebody
* looks for their own news. The bell it replaced sat in the corner of the launcher, was only
* drawn on Home, and cost a focus target on every set in the house whether or not there was
* anything behind it.
*
* It reads like Settings on purpose black canvas, flat rows on a shared 16dp inset with
* hairlines between them, and the row under focus the only lit surface on the page.
*
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
* and a second confirmation press on every alert is what made the old panel not worth
* opening. And **focus marks read** a row can only be read by being looked at, so nothing
* has to be pressed to clear the "new" flag on it.
*
* Stateless by design: the caller owns the list and the requests, so this can be previewed
* and screenshotted with no server.
*/
@Composable
fun MyAlertsPage(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismiss: (UserNotification) -> Unit,
onDismissAll: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val listFocusRequester = remember { FocusRequester() }
val actionsFocusRequester = remember { FocusRequester() }
val hasAlerts = notifications.isNotEmpty()
LaunchedEffect(hasAlerts) {
// One frame for the list to place its first row; an empty page has nothing below
// the actions to land on, so the chips take the remote instead.
delay(16)
runCatching {
if (hasAlerts) listFocusRequester.requestFocus() else actionsFocusRequester.requestFocus()
}
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp)
.padding(top = 40.dp, bottom = 28.dp),
) {
AlertsHeader(
total = notifications.size,
unread = notifications.count(UserNotification::unread),
)
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
)
MembyChoiceChip(
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
selected = preferences.enabled && preferences.showReturnAlerts,
onClick = { if (preferences.enabled) onToggleShowReturns() },
)
Spacer(Modifier.width(1.dp))
if (hasAlerts) {
MembyChoiceChip(
label = "Dismiss all",
selected = false,
onClick = onDismissAll,
)
}
Spacer(Modifier.weight(1f))
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
}
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (!hasAlerts) {
AlertsEmptyState(enabled = preferences.enabled)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp),
) {
items(notifications, key = UserNotification::id) { notification ->
AlertRow(
notification = notification,
modifier = if (notification.id == notifications.first().id) {
Modifier.focusRequester(listFocusRequester)
} else {
Modifier
},
onFocused = { if (notification.unread) onRead(notification) },
onClick = { onDismiss(notification) },
)
if (notification.id != notifications.last().id) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.background(MembyHairline),
)
}
}
}
}
}
}
}
@Composable
private fun AlertsHeader(total: Int, unread: Int) {
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
}
}
@Composable
private fun AlertsEmptyState(enabled: Boolean) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Alerts about the shows you follow will show up here."
} else {
"Alerts are switched off, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
)
}
}
@Composable
private fun AlertRow(
notification: UserNotification,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(shape)
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = shape,
)
.clickable(onClick = onClick)
.semantics {
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
),
contentAlignment = Alignment.Center,
) {
Icon(
alertIcon(notification.kind),
contentDescription = null,
tint = if (notification.unread) MembyAccent else MembyQuietText,
modifier = Modifier.size(19.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (notification.unread) {
Text(
"NEW",
color = MembyAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent.copy(alpha = 0.14f))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
Text(
notification.message,
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1)
}
}
// The hint is the whole reason a single press is allowed to dismiss: it is stated on
// the row about to go, and only on the row under focus.
Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) {
if (focused) {
Text(
"OK to dismiss",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive
}
@@ -20,6 +20,7 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
@@ -50,6 +51,7 @@ import androidx.media3.ui.SubtitleView
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import coil.imageLoader
import coil.load
@@ -57,14 +59,18 @@ import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest
import com.ponzischeme89.memby.data.PlaybackSession
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SUBTITLE_LANGUAGE_AUTO
import com.ponzischeme89.memby.data.SubtitleCandidate
import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -236,6 +242,26 @@ class PlayerActivity : ComponentActivity() {
private var nextUpDismissed = false
private var advancing = false
// Skipping the opening titles. [skipIntroSegment] is where Emby says they are, fetched
// once playback has settled; the rest is what this episode's viewer has done about it.
private var skipIntroAvailable = false
private var skipIntroView: View? = null
private var skipIntroButton: View? = null
private var skipIntroLabel: TextView? = null
private var skipIntroCountdown: SkipIntroCountdownView? = null
private var skipIntroSegment: IntroSegment? = null
private var skipIntroJob: Job? = null
private var skipIntroLookupJob: Job? = null
/**
* This episode's titles have been dealt with skipped by hand, or skipped for the
* viewer in automatic mode. Deliberately never reset while the same episode is
* playing, unlike [skipIntroDismissed]: in automatic mode a viewer who rewinds to the
* start of an episode has to be able to sit through the opening they just went back
* for, and re-arming would drag them forward again the moment they got there.
*/
private var skipIntroTaken = false
private var skipIntroDismissed = false
// The ten-minute lower third. Shown once per item — a viewer who has been told is
// told; re-announcing it every time they seek would be nagging, not informing.
private var timeRemainingCue: View? = null
@@ -255,6 +281,20 @@ class PlayerActivity : ComponentActivity() {
private var seekCommitJob: Job? = null
private var seekHideJob: Job? = null
/**
* The frame the skip will land on, drawn beside the words in the same chip. It is a
* decoration on an indicator that already works without it, so nothing here is on the
* path of a press: see [TrickplayPreview].
*/
private var trickplayAvailable = false
private val trickplayPreview by lazy {
TrickplayPreview(
scope = lifecycleScope,
loadTrack = { ServiceLocator.repository.trickplay(it) },
loadFrame = { track, frame -> ServiceLocator.repository.trickplayFrame(track, frame) },
)
}
/**
* A seek is landing, so the buffering it causes belongs to the skip and not to the
* film. While this is set the loading overlay is withheld: the viewer asked to move
@@ -484,6 +524,7 @@ class PlayerActivity : ComponentActivity() {
setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner()
setUpSkipIntro()
setUpTimeRemainingCue()
setUpSeasonFinaleCue()
setUpPlaybackError()
@@ -588,6 +629,8 @@ class PlayerActivity : ComponentActivity() {
subtitlePreference = playable.subtitlesEnabled
serverSubtitleId = playable.selectedSubtitleId
subtitleDownloadAvailable = playable.subtitleDownloadAvailable
trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable
subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
@@ -812,6 +855,16 @@ class PlayerActivity : ComponentActivity() {
// episode to ask about — but the request used to go out during onCreate, competing
// for the connection pool with the stream nobody has seen a frame of yet.
loadCast()
// Same reasoning as the cast, and the same moment: the seek previews are wanted by
// the first press of Left or Right, which cannot come before there is a picture, so
// reading their layout during onCreate would only have competed with the decoder
// for the connection pool.
itemId?.let { trickplayPreview.prepare(it, trickplayAvailable) }
// And the same moment again, for the same reason: the opening titles are minutes
// away and the markers that describe them are worth nothing until there is a
// picture to skip forward in.
loadIntroSegment()
startSkipIntroWatch()
reportStarted(playback.currentPosition)
startProgressReporting()
startPlaybackStartCueWatch()
@@ -1629,8 +1682,10 @@ class PlayerActivity : ComponentActivity() {
seekGlyph = it.findViewById(R.id.player_seek_glyph)
seekAmountView = it.findViewById(R.id.player_seek_amount)
seekPositionView = it.findViewById(R.id.player_seek_position)
trickplayPreview.bind(it.findViewById(R.id.player_seek_preview))
} ?: return
val rewinding = preview.offsetMs < 0L
trickplayPreview.show(preview.targetMs, forward = !rewinding)
seekGlyph?.setImageResource(
if (rewinding) R.drawable.ic_player_rewind else R.drawable.ic_player_forward,
)
@@ -1660,6 +1715,9 @@ class PlayerActivity : ComponentActivity() {
.withEndAction {
indicator.visibility = View.GONE
indicator.alpha = 1f
// The chip is coming back for the next press, and it must come back
// wordless rather than carrying the frame from the last one.
trickplayPreview.hide()
}
.start()
}
@@ -1680,6 +1738,277 @@ class PlayerActivity : ComponentActivity() {
visibility = View.GONE
alpha = 1f
}
// This runs when the item underneath changes, so the outgoing episode's thumbnails
// must go with it — the next press is about a different programme.
trickplayPreview.reset()
}
// --- Skipping the opening titles ------------------------------------------------
private fun setUpSkipIntro() {
skipIntroView = findViewById(R.id.player_skip_intro)
skipIntroLabel = findViewById(R.id.player_skip_intro_label)
skipIntroCountdown = findViewById(R.id.player_skip_intro_countdown)
skipIntroButton = findViewById<View>(R.id.player_skip_intro_button)?.also { button ->
button.setOnClickListener { performIntroSkip(automatic = false) }
}
}
/**
* Asks where this episode's titles are, in the background.
*
* Deliberately not on the path of the Play press. Reading the markers costs a round
* trip to the gateway, which reads them from Emby, or to Emby directly and nothing
* about a skip button is wanted before the first frame: the earliest intro in a typical
* library starts a couple of minutes in. So this runs beside the cast lookup and the
* preview manifest, once there is a picture.
*
* An episode with no markers, a film, and a server that will not answer are all the
* same answer null and every one of them simply means no button.
*/
private fun loadIntroSegment() {
skipIntroLookupJob?.cancel()
skipIntroSegment = null
if (!skipIntroAvailable) return
val id = itemId?.takeIf(String::isNotBlank) ?: return
skipIntroLookupJob = lifecycleScope.launch {
val segment = runCatching { ServiceLocator.repository.introSegment(id) }.getOrNull()
// Auto-advance may have moved on to the next episode while this was in flight,
// and its titles are somewhere else entirely.
if (itemId == id) skipIntroSegment = segment
}
}
/**
* Watches the playhead rather than a timer of its own, the way the next-up countdown
* does. Pausing inside the titles leaves the offer up, and seeking out of them takes it
* down both of which fall out of reading the position rather than counting seconds.
*/
private fun startSkipIntroWatch() {
skipIntroJob?.cancel()
skipIntroJob = lifecycleScope.launch {
while (isActive) {
delay(SKIP_INTRO_TICK_MS)
updateSkipIntroFromPlayhead()
}
}
}
private fun updateSkipIntroFromPlayhead() {
if (advancing || returningHomeAfterCompletion) return
val playback = player ?: return
val segment = skipIntroSegment ?: return
val mode = normalizeSkipIntroMode(ServiceLocator.settings.current?.skipIntroMode)
if (mode == SKIP_INTRO_OFF) {
hideSkipIntro()
return
}
val positionMs = playback.currentPosition
// The tail is what stops the offer appearing for the last moment of a sequence,
// where pressing it would be indistinguishable from doing nothing.
if (!segment.contains(positionMs, lead = SKIP_INTRO_TAIL_MS)) {
hideSkipIntro()
// Rewinding to before the titles offers them again: somebody who went back
// there did it on purpose, and the button they dismissed a minute ago is the
// one they now want. Only a dismissal is re-armed — see [skipIntroTaken].
if (positionMs < segment.startMs) skipIntroDismissed = false
return
}
when {
skipIntroTaken || skipIntroDismissed -> Unit
mode == SKIP_INTRO_AUTO -> performIntroSkip(automatic = true)
skipIntroCanShow() -> {
showSkipIntro()
// Every tick, not only on the first: the ring is the offer's own clock and
// showSkipIntro does nothing once the button is already up.
updateSkipIntroCountdown(segment, positionMs)
}
// Something else owns the screen — the transport, an overlay, the pre-roll.
// The offer waits rather than being spent behind whatever is in front of it.
else -> hideSkipIntro()
}
}
/**
* Advances the ring on the button.
*
* It counts down the *offer*, not the title sequence: from where the button appears to
* where it goes, which is [SKIP_INTRO_TAIL_MS] short of the end of the intro. The two
* are seconds apart and only one of them can be drawn honestly a ring measuring the
* whole sequence would stop with a sliver left and vanish mid-sweep, which reads as the
* countdown having broken rather than as the offer having lapsed. What it says is
* therefore exactly what it looks like it says: how long is left to press this.
*/
private fun updateSkipIntroCountdown(segment: IntroSegment, positionMs: Long) {
val countdown = skipIntroCountdown ?: return
val offerEndMs = segment.endMs - SKIP_INTRO_TAIL_MS
countdown.setRemaining(
remainingMs = offerEndMs - positionMs,
totalMs = offerEndMs - segment.startMs,
)
}
/**
* True when there is nothing else the viewer is looking at.
*
* The button takes focus, so it must never appear over something that already has it:
* the transport row, the subtitle drop-up, the cast panel and the next-up banner all
* own the remote while they are up, and stealing focus out from under any of them is
* how a press lands somewhere nobody aimed it.
*/
private fun skipIntroCanShow(): Boolean =
playbackStarted &&
!prerollActive &&
playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
private fun showSkipIntro() {
val view = skipIntroView ?: return
if (view.isVisible) return
dressSkipIntro(asNotice = false)
view.alpha = 0f
view.visibility = View.VISIBLE
view.animate()
.alpha(1f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
// Focus is the only way a remote can say "press this". It is taken as the button
// appears and handed back to the video the moment it goes.
skipIntroButton?.requestFocus()
}
private fun hideSkipIntro(returnFocus: Boolean = true) {
val view = skipIntroView ?: return
if (!view.isVisible) return
val hadFocus = skipIntroButton?.isFocused == true
view.animate()
.alpha(0f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.withEndAction {
view.visibility = View.GONE
view.alpha = 1f
}
.start()
// Only if this was holding it. Taking focus back off whatever the viewer has since
// opened would be worse than leaving it where they put it.
if (returnFocus && hadFocus) playerView?.requestFocus()
}
/**
* Jumps to the end of the titles.
*
* The seek goes through the same door a press of Right does claiming [seekBuffering]
* before asking for it so the couple of seconds it takes to decode a frame at the new
* position is treated as a skip landing rather than as a film that has stopped, and the
* loading overlay stays down.
*/
private fun performIntroSkip(automatic: Boolean) {
val playback = player ?: return
val segment = skipIntroSegment ?: return
if (skipIntroTaken) return
skipIntroTaken = true
val wasPlaying = playback.playWhenReady
// Claimed before the seek is asked for, or the state change lands first and puts
// the loading overlay up anyway.
seekBuffering = true
seekLoadingFallbackJob?.cancel()
seekLoadingFallbackJob = lifecycleScope.launch {
delay(SEEK_LOADING_GRACE_MS)
if (seekBuffering) showPlaybackLoading()
}
playback.seekTo(segment.endMs)
if (wasPlaying) playback.play()
if (automatic) {
// An automatic skip is a jump nobody pressed a button for, so it says what it
// did. Otherwise the picture changes for no visible reason, which reads as the
// stream glitching rather than as a setting working.
announceAutomaticIntroSkip()
} else {
hideSkipIntro()
}
Log.i(
PLAYBACK_LOG_TAG,
"event=skip_intro item=${itemId.orEmpty()} automatic=$automatic " +
"fromMs=${segment.startMs} toMs=${segment.endMs}",
)
}
/**
* Dresses the one view as either the offer or the notice.
*
* They are the same view in the same corner, and they must not look the same. The offer
* is the green pill every other action in this app wears and takes focus; the notice is
* the quiet near-black plate the timing cues use and takes nothing a notice that looks
* pressable is a viewer pressing it to find out what it does.
*/
private fun dressSkipIntro(asNotice: Boolean) {
val button = skipIntroButton ?: return
skipIntroLabel?.apply {
setText(if (asNotice) R.string.player_skip_intro_skipped else R.string.player_skip_intro)
if (asNotice) {
setTextColor(Color.WHITE)
} else {
// A state list, not a colour: the focused pill goes white, so its label has
// to go dark with it.
ContextCompat.getColorStateList(this@PlayerActivity, R.color.next_up_button_text)
?.let(::setTextColor)
}
}
button.setBackgroundResource(
if (asNotice) R.drawable.time_remaining_cue_background
else R.drawable.next_up_primary_button,
)
// The ring counts down an offer, and a notice is not one — there is nothing left to
// press and nothing left to run out. It goes rather than freezing at zero, which
// would read as a countdown that had stalled.
skipIntroCountdown?.isVisible = !asNotice
button.isFocusable = !asNotice
}
/** Says an automatic skip happened, in the same corner the button would have been. */
private fun announceAutomaticIntroSkip() {
val view = skipIntroView ?: return
if (!skipIntroCanShow()) return
dressSkipIntro(asNotice = true)
view.alpha = 0f
view.visibility = View.VISIBLE
view.animate()
.alpha(1f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
lifecycleScope.launch {
delay(SKIP_INTRO_NOTICE_MS)
// Never focused: nothing about a notice is meant to be pressed, and taking the
// remote to say "done" would be worse than saying nothing.
hideSkipIntro(returnFocus = false)
}
}
/** Called when the offer is refused, and when the item underneath changes. */
private fun dismissSkipIntro() {
skipIntroDismissed = true
hideSkipIntro()
}
private fun resetSkipIntro() {
skipIntroJob?.cancel()
skipIntroJob = null
skipIntroLookupJob?.cancel()
skipIntroLookupJob = null
skipIntroSegment = null
skipIntroTaken = false
skipIntroDismissed = false
skipIntroView?.apply {
animate().cancel()
visibility = View.GONE
alpha = 1f
}
}
// --- Next up ------------------------------------------------------------------
@@ -1926,6 +2255,9 @@ class PlayerActivity : ComponentActivity() {
resetTimeRemainingCue()
// A skip aimed at the outgoing episode must not land in the incoming one.
resetSeekControls()
// Nor may the outgoing episode's title-sequence markers: every show times its
// opening differently, and the next episode's are a fresh lookup.
resetSkipIntro()
nextEpisode = null
nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime()
@@ -2005,6 +2337,9 @@ class PlayerActivity : ComponentActivity() {
collapseSubtitleDownloads()
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
nextUpBanner?.isVisible == true -> dismissNextUp()
// Back refuses the offer rather than leaving the film: one press per
// level, the same contract the banner above and the drop-up have.
skipIntroView?.isVisible == true -> dismissSkipIntro()
playerView?.isControllerFullyVisible == true -> playerView?.hideController()
else -> finish()
}
@@ -2054,6 +2389,10 @@ class PlayerActivity : ComponentActivity() {
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The skip button holds focus while it is up, and the centre key is how a
// remote presses the thing it is focused on. Pausing instead would leave the
// one button on screen unpressable.
skipIntroView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3032,6 +3371,26 @@ class PlayerActivity : ComponentActivity() {
private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360
/**
* How often the playhead is compared against the title-sequence markers, and so how
* often the ring on the button advances. The same rate the next-up countdown reads
* at, and for the same reason: four times a second is what makes a counter look like
* it is running rather than stepping. It costs a position read and, on most ticks,
* nothing else [SkipIntroCountdownView] refuses to redraw for movement smaller
* than a degree, which over a two-minute opening is most of them.
*/
private const val SKIP_INTRO_TICK_MS = 250L
/**
* How much of the end of the sequence the offer is held back for. A button that
* skips the last second of an opening moves the picture imperceptibly, which reads
* as a broken button rather than as a short skip.
*/
private const val SKIP_INTRO_TAIL_MS = 2_000L
/** How long "Intro skipped" stays up after an automatic skip. */
private const val SKIP_INTRO_NOTICE_MS = 2_400L
private const val SKIP_INTRO_ANIMATION_MS = 200L
private const val CONTROLLER_TIMEOUT_MS = 6_000
private const val NO_TRACE = -1
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
@@ -0,0 +1,144 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.R
import kotlin.math.ceil
import kotlin.math.min
/**
* The ring on the skip-intro button: how long is left to press it, drawn as a draining arc
* with the figure inside.
*
* Like [PrerollCountdownView] it runs no animator of its own. PlayerActivity advances it
* from the playhead, which is what keeps it honest pausing during the titles holds the
* ring where it is, and seeking moves it to wherever the film now is, neither of which a
* timer counting wall-clock seconds could do.
*
* It takes its colours from its own drawable state rather than from a setter. The button
* around it is a state-list pill green with white text, white with dark text once
* focused so the ring has to change with it or it disappears into the fill the moment
* somebody's remote reaches it. `duplicateParentState` in the layout is what feeds that
* state down; without it this draws focused colours never.
*/
class SkipIntroCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create("sans-serif", Typeface.BOLD)
}
private var figure = ""
private var progress = 1f
/**
* How much of the offer is left, and how long it ran for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for two minutes at a stretch, and a two-minute ring moves by a
* fraction of a degree per tick invalidating on every one of them would be a couple
* of hundred pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
contentDescription = context.getString(R.string.player_skip_intro_countdown, nextFigure)
invalidate()
}
override fun drawableStateChanged() {
super.drawableStateChanged()
// The focused pill is white, so everything on it has to go dark — the same
// inversion `next_up_button_text` makes for the label beside this.
val focused = isFocused || drawableState.contains(android.R.attr.state_focused)
val ink = if (focused) FOCUSED_INK else Color.WHITE
// The unspent part of the ring has to stay visible without competing with the arc.
// Dark ink on the focused white pill needs more of itself than white does on green,
// where the fill is already doing half the separating.
trackPaint.color = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
)
progressPaint.color = ink
figurePaint.color = ink
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
private companion object {
val FOCUSED_INK = Color.rgb(11, 14, 17)
}
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -0,0 +1,176 @@
package com.ponzischeme89.memby.ui.player
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.data.Trickplay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The frame a skip will land on, drawn into the seek indicator.
*
* The idea and the shape of it are borrowed from
* [Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin television client under
* the same GPL-2.0 licence. What differs is the format underneath: Jellyfin serves tile
* sheets and Emby serves BIF files, so where Wholphin crops a sub-image out of a grid,
* this asks for one frame's bytes (see [com.ponzischeme89.memby.data.Trickplay]).
*
* Everything here is arranged around one property: a preview must never be the reason a
* press feels slow. Nothing blocks, every failure is silent, and the thumbnail is composed
* *beside* the words rather than in place of them the chip has always said where the skip
* lands, and it still says it on a title with no previews, on a server that will not answer,
* and in the moment before the first frame arrives.
*/
class TrickplayPreview(
private val scope: CoroutineScope,
/** The layout for a title, or null when it has none. Called once per item. */
private val loadTrack: suspend (String) -> Trickplay?,
/** One thumbnail's JPEG bytes, or null. */
private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
) {
private var view: ImageView? = null
private var itemId: String? = null
private var track: Trickplay? = null
private var trackJob: Job? = null
private var frameJob: Job? = null
private var shownFrame = NO_FRAME
/**
* The bytes of frames already fetched, rather than the bitmaps.
*
* A JPEG this size is a few kilobytes and its decoded form is a few hundred, so
* holding pixels would be most of a megabyte for a handful of thumbnails on a device
* that has better uses for it. Decoding one takes a millisecond or two, and it happens
* off the main thread anyway.
*/
private val frames = object : LinkedHashMap<Int, ByteArray>(FRAME_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, ByteArray>) =
size > FRAME_CACHE_SIZE
}
/** Attaches the view once the seek indicator has been inflated. */
fun bind(preview: ImageView) {
view = preview
applyAspect()
}
/**
* Begins on a title. [available] is the backend saying whether it will answer at all,
* so an older or deliberately-configured-off gateway is never asked once per playback.
*
* The layout is fetched now rather than on the first press, because the first press is
* exactly when it must already be there but it is fetched in the background and
* nothing waits on it.
*/
fun prepare(itemId: String, available: Boolean) {
if (this.itemId == itemId) return
reset()
this.itemId = itemId
if (!available || itemId.isBlank()) return
trackJob = scope.launch {
val resolved = loadTrack(itemId)
if (this@TrickplayPreview.itemId == itemId) track = resolved
}
}
/**
* Draws the frame covering [positionMs], if there is one.
*
* [forward] is the direction the viewer is travelling, which is used only to warm the
* frame they are most likely to ask for next. Presses come in bursts at a fixed step,
* so the one after this is a good guess and a wrong guess costs a few kilobytes.
*/
fun show(positionMs: Long, forward: Boolean) {
val current = track ?: return
val preview = view ?: return
val frame = current.frameAt(positionMs)
if (frame == shownFrame && preview.isVisible) return
// Cancelling the previous load is the load-bearing part. Presses arrive faster than
// a fetch completes, and without this a slow response for a frame the viewer has
// already skipped past would land on screen after the one they are waiting for —
// the same reason the search pipeline uses collectLatest.
frameJob?.cancel()
frameJob = scope.launch {
val bytes = frames[frame]
?: loadFrame(current, frame)?.also { frames[frame] = it }
?: return@launch
val bitmap = decode(bytes) ?: return@launch
shownFrame = frame
applyAspect()
preview.setImageBitmap(bitmap)
preview.visibility = View.VISIBLE
warm(current, frame + if (forward) 1 else -1)
}
}
/** Takes the preview down without forgetting anything. */
fun hide() {
frameJob?.cancel()
frameJob = null
shownFrame = NO_FRAME
view?.let {
it.visibility = View.GONE
it.setImageDrawable(null)
}
}
/**
* Forgets the title entirely. The player advances between episodes inside the running
* player, so this is what stops one episode's thumbnails being shown over the next.
*/
fun reset() {
trackJob?.cancel()
trackJob = null
track = null
itemId = null
frames.clear()
hide()
}
private suspend fun decode(bytes: ByteArray): Bitmap? = withContext(Dispatchers.Default) {
runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
private fun warm(current: Trickplay, frame: Int) {
if (frame < 0 || frame >= current.count || frames.containsKey(frame)) return
scope.launch {
val bytes = loadFrame(current, frame) ?: return@launch
if (track === current) frames[frame] = bytes
}
}
/**
* Gives the preview the shape the frames actually are.
*
* The layout carries a 16:9 box so the chip is about the right size on the very first
* press, before anything is known; a title whose thumbnails are a wider crop would
* otherwise be letterboxed inside it for the life of the playback.
*/
private fun applyAspect() {
val preview = view ?: return
val current = track ?: return
val params = preview.layoutParams ?: return
val width = current.widthFor(params.height)
if (params.height <= 0 || params.width == width) return
params.width = width
preview.layoutParams = params
}
private companion object {
const val NO_FRAME = -1
/**
* A burst of presses walks through frames in one direction and a viewer often walks
* back over the same ones. Forty entries is a couple of hundred kilobytes of JPEG
* and about seven minutes of a title at ten seconds a frame.
*/
const val FRAME_CACHE_SIZE = 40
}
}
@@ -86,7 +86,11 @@ import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
@@ -121,6 +125,13 @@ private val SeekIntervalOptions = SEEK_INTERVAL_SECONDS.map {
ChoiceOption(it.toString(), "$it seconds")
}
// The wording is what a viewer chooses between, not the mode names the wire carries.
private val SkipIntroOptions = listOf(
ChoiceOption(SKIP_INTRO_PROMPT, "Ask me"),
ChoiceOption(SKIP_INTRO_AUTO, "Skip it"),
ChoiceOption(SKIP_INTRO_OFF, "Leave it"),
)
private val ArtworkOptions = listOf(
ChoiceOption("automatic", "Automatic"),
ChoiceOption("poster", "Posters"),
@@ -174,6 +185,7 @@ internal data class SettingsPanelState(
val autoPlayNext: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
@@ -202,6 +214,7 @@ internal data class SettingsPanelActions(
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
@@ -244,6 +257,7 @@ fun SettingsSheet(
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
@@ -303,12 +317,14 @@ fun SettingsSheet(
settings.autoPlayNextEpisode,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
@@ -332,6 +348,7 @@ fun SettingsSheet(
autoPlayNext = autoPlayNext,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
@@ -370,6 +387,10 @@ fun SettingsSheet(
seekInterval = it
scope.launch { store.setSeekIntervalSeconds(it) }
},
onSkipIntroModeChanged = {
skipIntroMode = it
scope.launch { store.setSkipIntroMode(it) }
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -637,6 +658,14 @@ internal fun SettingsPanelContent(
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Opening titles",
description = "What to do when an episode reaches its intro.",
options = SkipIntroOptions,
selected = state.skipIntroMode,
onSelected = actions.onSkipIntroModeChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Skip with left and right",
description = "How far one press moves what you are watching.",
@@ -27,6 +27,11 @@
appears once matters more than one that appears on every press. -->
<include layout="@layout/player_seek_indicator" />
<!-- The offer to skip the opening titles. Declared here so the banners and overlays
below cover it: it lives in the bottom-end corner and belongs to the first two
minutes of an episode, where none of them has anything to say yet. -->
<include layout="@layout/player_skip_intro" />
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
if the two ever coincide: what comes next matters more than how long is left. -->
<include layout="@layout/player_time_remaining" />
@@ -17,44 +17,71 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/time_remaining_cue_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="22dp"
android:paddingBottom="12dp">
<!-- The frame the skip will land on, from Emby's preview thumbnails. It is gone
rather than blank when a title has none, or before the first one arrives: the
chip below it is a complete answer on its own, and a thumbnail-shaped hole
over somebody's film says only that something is missing.
The height is what settles the size, and it is a compromise with the source:
Emby generates these 320px wide, so 120dp is about a third larger than native
on a 1080p set — big enough to read a scene across a room, and not so big that
the upscaling is what the viewer notices. The width here is only the 16:9
placeholder the chip is laid out at before the frames' real shape is known. -->
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:id="@+id/player_seek_preview"
android:layout_width="214dp"
android:layout_height="120dp"
android:layout_marginBottom="12dp"
android:background="#FF000000"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
android:visibility="gone" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:orientation="vertical">
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/player_seek_amount"
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
android:layout_marginStart="14dp"
android:orientation="vertical">
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
<TextView
android:id="@+id/player_seek_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The offer to jump past an episode's opening titles.
It sits over somebody's film, so it is one control and nothing else: no panel, no
scrim, no explanation. The bottom-end corner is where every television client puts
this, which is most of why it needs no label saying what it is — and it clears the
transport row's own 48dp inset and 72dp strip, so raising the controls does not put
the two on top of each other.
The whole view is gone rather than invisible while it has nothing to offer, because a
button-shaped hole in the corner of a film says only that something is missing. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_skip_intro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="48dp"
android:layout_marginBottom="112dp"
android:visibility="gone">
<!-- The row is what is focusable, not the pieces in it: a remote has one idea of
"this", and two focusable children would be two stops on the way past a single
button. Focusable at all, unlike the timing cues and the alert bar beside it,
because this one is meant to be pressed and focus is the only way a remote can say
so. PlayerActivity gives it focus as it appears and hands focus back to the video
when it goes. -->
<LinearLayout
android:id="@+id/player_skip_intro_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="11dp"
android:paddingEnd="24dp"
android:paddingBottom="11dp">
<!-- How long is left to press it. duplicateParentState is load-bearing: the pill
inverts to white on focus, and without the parent's state reaching this the
ring would keep drawing white on white. -->
<com.ponzischeme89.memby.ui.player.SkipIntroCountdownView
android:id="@+id/player_skip_intro_countdown"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="12dp"
android:duplicateParentState="true" />
<TextView
android:id="@+id/player_skip_intro_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:text="@string/player_skip_intro"
android:textColor="@color/next_up_button_text"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Skip intro" />
</LinearLayout>
</FrameLayout>
+6
View File
@@ -59,6 +59,12 @@
</plurals>
<string name="player_seek_forward">Forward %1$s</string>
<string name="player_seek_back">Back %1$s</string>
<!-- "Intro" rather than "the opening titles": it is the word every other television
client uses for this button, and a button is read at a glance or not at all. -->
<string name="player_skip_intro">Skip intro</string>
<string name="player_skip_intro_skipped">Intro skipped</string>
<!-- Spoken for the ring, which is a shape and says nothing on its own. -->
<string name="player_skip_intro_countdown">%1$s left</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
@@ -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
}
}