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:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -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("You’re 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("You’re 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user