Release v0.2.34

This commit is contained in:
ponzischeme89
2026-08-09 08:25:50 +12:00
parent b1128bcce2
commit fdd9e6cab2
116 changed files with 11418 additions and 879 deletions
@@ -6,6 +6,7 @@ import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
import com.ponzischeme89.memby.data.ThemeSync
/**
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
@@ -31,6 +32,14 @@ object ServiceLocator {
lateinit var preferencesSync: PreferencesSync
private set
/**
* Held for the same reason [preferencesSync] is, and read as well as held: the settings
* picker asks it which schemes this viewer may choose and whether a season has taken the
* choice away for the moment.
*/
lateinit var themeSync: ThemeSync
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
@@ -40,5 +49,10 @@ object ServiceLocator {
// app already asks the gateway a question every ten seconds, and settings do not
// deserve a second connection.
preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision)
// Likewise rides the status poll. It is constructed here rather than by a screen
// because the palette has to be applied before the first frame of the launcher, and
// because the surfaces that obey it — the launcher, the player's Compose islands,
// the screensaver's DreamService — are separate roots with no common owner but this.
themeSync = ThemeSync(repository, settings, maintenance.theme)
}
}
@@ -0,0 +1,148 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
/**
* Where a title's closing credits begin, and whether they are worth doing anything about.
*
* Two sources, in order of trust, because Emby gives one and the media gives the other:
*
* - `CreditsStart`, a marker Emby's own detector writes. It is in Emby's `MarkerType`
* enumeration and is what this feature was originally built on — but **Emby 4.10 does not
* write it**. A survey of a 20,000-item library found `Chapter`, `IntroStart` and `IntroEnd`
* and nothing else, so the enum value existing is not the detector populating it. It is
* still read first, so the day a version does write it this needs no change.
* - A chapter *named* like credits. Plenty of media carries "Credits" or "End Credits" as
* ordinary chapter metadata, and in that same library 216 items had one, clustered at 9098%
* of runtime and consistent within a show. That is where the coverage comes from today.
*
* Both come out of the same `Fields=Chapters` response [introSegmentFrom] already reads, so
* this costs no request the intro was not already making.
*
* There is deliberately no end marker in either source. Credits run to the end of the file by
* definition, so nothing writes one and this must not invent one.
*/
private const val MARKER_CREDITS_START = "CreditsStart"
/**
* How far into a file a credit roll has to begin.
*
* **This is the load-bearing guard, and it exists because of one observed case.** Chapter names
* are not a vocabulary anybody agreed on, and real media carries "Opening Credits" — Belfast at
* 1% of runtime, Game of Thrones at 0%. A name match without a position test therefore starts
* the credits pane in the *first minute* of a film and runs its opening at double speed, which
* is the worst thing this feature could possibly do.
*
* Three quarters is deliberately far below the evidence rather than near it: every genuine
* credit roll in that survey began at 90% or later, so this leaves fifteen points of headroom
* for a long roll while rejecting the whole first half of a file outright.
*/
private const val CREDITS_MINIMUM_POSITION_FRACTION = 0.75
/** Words that mark an *opening* sequence, never a closing one. */
private val OPENING_CHAPTER_WORDS =
listOf("opening", "main title", "title sequence", "intro")
/** Words that name a credit roll. */
private val CREDITS_CHAPTER_WORDS = listOf("credit", "end titles", "closing")
/**
* The least amount of credits worth shrinking the picture for.
*
* A marker twenty seconds from the end is not a credit roll to sit beside something else;
* it is the last card of one, and the transition would be most of what was left. This is
* the guard the gateway deliberately does not apply — it knows where the marker is but not
* how long the file runs, and the duration is exact here.
*/
const val CREDITS_MINIMUM_TAIL_MS = 45_000L
/**
* Finds where the closing credits begin in an item's chapter list.
*
* The rule exists twice — the gateway's copy is `creditsFromChapters` in
* `server/internal/api/credits.go` — and the two are pinned by deliberately parallel tests
* (`CreditsTest`, `credits_test.go`). With no gateway there is nobody to ask, and the
* picture must not start shrinking at a different moment depending on whether the container
* is up.
*
* Like the intro rule, most of this is about refusing to answer, and null is a perfectly
* good answer: the player never shrinks anything and the credits play out full size, which
* is what every other client does anyway.
*
* [runtimeMs] may be zero when Emby reports no runtime. An explicit marker is still honoured
* then — it is Emby asserting a position rather than this inferring one — but a *named* chapter
* is refused outright, because the name alone cannot tell an opening credit sequence from a
* closing one and [CREDITS_MINIMUM_POSITION_FRACTION] is the only thing that can.
*/
fun creditsStartFrom(chapters: List<EmbyChapter>, runtimeMs: Long): Long? {
val floorMs =
if (runtimeMs > 0L) (runtimeMs * CREDITS_MINIMUM_POSITION_FRACTION).toLong() else -1L
// An explicit marker first. **The last one wins, where [introSegmentFrom] takes the
// first.** That inversion is deliberate: two starts mean the markers are already
// untrustworthy, so each rule picks whichever risks least, and the two features are damaged
// in opposite directions. An intro skip that fires late throws somebody past the start of
// the story, so the earlier marker is safer there; the credits pane firing early runs the
// last scene of an episode past somebody at double speed, so the later marker is safer
// here. Neither is a preference for a position in the list.
var markedMs = -1L
for (chapter in chapters) {
if (chapter.markerType != MARKER_CREDITS_START) continue
// A marker at or before zero says the whole file is credits, which is not something
// Emby means and not something worth acting on.
if (chapter.startPositionTicks <= 0L) continue
markedMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
}
// A marker below the floor is a mis-detection whoever wrote it, so it falls through to the
// names rather than being honoured — but with no runtime to measure against, an explicit
// assertion gets the benefit of the doubt.
if (markedMs > 0L && (floorMs < 0L || markedMs >= floorMs)) return markedMs
if (floorMs < 0L) return null
// Then the names. The *earliest* qualifying chapter wins here, the opposite of the marker
// rule above, and that is not an inconsistency: several credits-named chapters are ordinary
// rather than suspicious — "The Pitt" carries both "Credits" and "End Credits" — and they
// describe one roll, which begins at the first of them.
var namedMs = -1L
for (chapter in chapters) {
if (!isCreditsChapterName(chapter.name) || chapter.startPositionTicks <= 0L) continue
val at = chapter.startPositionTicks / TICKS_PER_MILLISECOND
if (at < floorMs) continue
if (namedMs < 0L || at < namedMs) namedMs = at
}
return namedMs.takeIf { it > 0L }
}
/**
* Recognises a chapter that names a credit roll.
*
* The exclusions are belt-and-braces beside [CREDITS_MINIMUM_POSITION_FRACTION], which is what
* actually stops an opening sequence being read as a closing one — a position test catches
* wordings nobody thought of, where a list of them only catches the ones on the list. They are
* here so the trap is stated where the next reader will look for it.
*/
private fun isCreditsChapterName(name: String): Boolean {
val lowered = name.trim().lowercase()
if (lowered.isEmpty()) return false
if (OPENING_CHAPTER_WORDS.any { it in lowered }) return false
return CREDITS_CHAPTER_WORDS.any { it in lowered }
}
/**
* Whether a credits marker is worth acting on in a file of this length.
*
* Separate from finding the marker, and deliberately on this end of the wire: the gateway
* knows where `CreditsStart` sits but not how long the file runs, while the player has the
* exact duration the decoder reported. Both guards refuse rather than guess — a marker past
* the end of the file, or one so near it that the transition would outlast the credits, is
* a detection to ignore rather than a picture to shrink.
*/
fun creditsWorthShowing(startMs: Long?, durationMs: Long): Boolean {
if (startMs == null || startMs <= 0L || durationMs <= 0L) return false
if (startMs >= durationMs) return false
return durationMs - startMs >= CREDITS_MINIMUM_TAIL_MS
}
private const val TICKS_PER_MILLISECOND = 10_000L
@@ -13,6 +13,7 @@ import com.ponzischeme89.memby.data.model.GatewayRowEvents
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -107,6 +108,8 @@ data class Playable(
* turned the feature off, so the player never offers a row that cannot do anything.
*/
val subtitleDownloadAvailable: Boolean = false,
/** Whether this title has a second readable track against which timing can be checked. */
val subtitleFixAvailable: 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
@@ -121,6 +124,14 @@ data class Playable(
* or a deliberately-configured-off container from being asked once per playback.
*/
val skipIntroAvailable: Boolean = false,
/**
* Whether it is worth asking the backend where this title's closing credits begin. True
* on the direct path for the same reason the above is, and in gateway mode it is the
* gateway's own switch. Its own field rather than a reuse of [skipIntroAvailable]: the
* two are separate features, and an operator turning the skip button off has not asked
* to lose the credits pane with it.
*/
val endCreditsAvailable: Boolean = false,
)
/**
@@ -150,6 +161,15 @@ data class SubtitleDownload(
val url: String,
)
/** The gateway's answer after checking one existing subtitle against another. */
data class SubtitleFix(
val subtitleId: String,
val message: String,
val changed: Boolean,
val offsetMs: Long,
val reference: String,
)
/**
* Everything needed to resolve a stream, plus everything the player needs to dress its
* loading screen while that resolution is still in flight.
@@ -193,6 +213,16 @@ class EmbyRepository(private val settings: SettingsStore) {
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
/**
* What the settings are *right now*, for a composable that has to draw before a flow
* can emit. A detail page opened with [Settings.EMPTY] as its initial value draws its
* first frame under default preferences and then recomposes the whole page — and
* restarts the effects keyed on those preferences — one frame later, at precisely the
* moment the page is trying to appear. Reading the snapshot instead makes the first
* frame the right one. Same reasoning as [showTitleLogo].
*/
val currentSettings: Settings get() = snapshot
/**
* Read synchronously off the snapshot, like [rotationIntervalMillis], because the
* composables that decide between logo artwork and a text title do so while building a
@@ -220,10 +250,21 @@ class EmbyRepository(private val settings: SettingsStore) {
private val seriesEpisodesMutex = Mutex()
private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
private val relatedMutex = Mutex()
private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
private val relatedInFlight = mutableMapOf<String, Deferred<RelatedContent?>>()
private val trailerMutex = Mutex()
/**
* Whether a title has a local trailer, for as long as the process lives. A null [value]
* is the answer for most of a library, which is exactly why the wrapper exists: without
* it an absent entry and "no trailer" are the same thing, and every detail page would
* ask the gateway a question it has already answered 404 to.
*/
private val trailerCache =
LinkedHashMap<String, CachedTrailer>(TRAILER_CACHE_SIZE, 0.75f, true)
private val trailerInFlight = mutableMapOf<String, Deferred<CachedTrailer?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -640,6 +681,62 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/**
* One page of a genre, dual-path like the search beside it.
*
* This is a *filter*, not a query. Running a genre's name through search matched a film
* called Drama, anything with the word in its overview, and — relevance being a score
* rather than a rule — a scattering of titles not in the genre at all, while missing
* most of the ones that were. Both paths therefore ask their backend for the genre.
*
* Episodes are excluded on purpose: an episode inherits its series' genres, so a page
* would otherwise fill with twenty entries of one comedy and bury the rest.
*
* The gateway path degrades to a keyword search rather than throwing, so a television
* on this build talking to a gateway that predates the route still shows a viewer
* *something* when they press a genre — which is exactly what it did before.
*/
suspend fun browseGenre(genre: String, offset: Int = 0, limit: Int = GENRE_PAGE_SIZE): GenrePage {
val trimmed = genre.trim()
if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0)
if (ServerConfig.isGateway) {
runCatching { requireGateway().genreItems(trimmed, offset, limit) }
.onSuccess { page ->
return GenrePage(page.items, offset, page.total)
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
// Only the first page falls back. A gateway that answered page one and
// failed on page two is having trouble, not missing the route, and a
// search's results pasted onto the end of a genre would be nonsense.
if (offset > 0) throw error
val items = search(trimmed, limit)
return GenrePage(items, offset, items.size)
}
}
val items = getHomeItems(
params = mapOf(
"Genres" to trimmed,
"IncludeItemTypes" to "Movie,Series",
"Recursive" to "true",
"StartIndex" to offset.toString(),
"Limit" to limit.toString(),
// The second sort key is what makes paging safe: with only a date, two
// titles sharing one could swap places between requests and the scroll
// would repeat one card and never show the other.
"SortBy" to "PremiereDate,SortName",
"SortOrder" to "Descending",
),
fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
imageTypes = "Backdrop,Primary,Logo",
includeUserData = true,
)
// Emby's count is turned off for these list calls, so the page itself is the only
// evidence: a full page means there may be more, a short one is the end.
val total = offset + items.size + if (items.size >= limit) 1 else 0
return GenrePage(items, offset, total)
}
/** Records a successful gateway search without affecting the direct Emby path. */
suspend fun recordSearch(term: String) {
if (ServerConfig.isGateway && term.trim().length >= 2) {
@@ -758,6 +855,18 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
/**
* This viewer's colour scheme, and the ones the operator lets them choose between.
*
* Gateway only, and deliberately so rather than falling through to a direct-path
* equivalent: with no gateway there is nobody who decides themes, and inventing a
* client-side rule would mean a household's palette changing depending on whether the
* container happens to be up. The direct path keeps the default palette, which is what
* the app looked like before this existed.
*/
suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument =
requireGateway().theme()
/** This viewer's server-held settings. */
suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
requireGateway().preferences()
@@ -948,18 +1057,46 @@ class EmbyRepository(private val settings: SettingsStore) {
/**
* All episodes for one show in a single request. Season switching is then a local
* list filter, keeping the detail screen immediate after its first load.
*
* **Single-flighted on the repository's own scope**, for the reason [getRelated] is:
* the caller is usually [HomeViewModel.focusItem] warming the page while a card is
* still focused, and that job is cancelled the moment the D-pad moves on. A request
* cancelled at the socket is one the gateway logs as a failure and one nobody keeps
* the answer of — so the shared request outlives the caller that started it, and the
* detail page opened a moment later awaits it rather than asking for the same
* thousand episodes again.
*/
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
if (seriesId.isBlank()) return emptyList()
val now = System.currentTimeMillis()
seriesEpisodesMutex.withLock {
val inFlight = seriesEpisodesMutex.withLock {
seriesEpisodesCache[seriesId]
?.takeIf { it.expiresAtMs > now }
?.episodes
?.let { return it }
seriesEpisodesCache.remove(seriesId)
seriesEpisodesInFlight[seriesId] ?: newSeriesEpisodesRequest(seriesId)
}
// A failure is the caller's to report, the way it was when this awaited the request
// directly: the detail page distinguishes "no episodes" from "could not load them".
return inFlight.await() ?: error("Could not load episodes for $seriesId")
}
private fun newSeriesEpisodesRequest(seriesId: String): Deferred<List<BaseItem>?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
runCatching { loadSeriesEpisodes(seriesId) }.getOrNull()
} finally {
seriesEpisodesMutex.withLock { seriesEpisodesInFlight.remove(seriesId) }
}
}
seriesEpisodesInFlight[seriesId] = request
request.start()
return request
}
private suspend fun loadSeriesEpisodes(seriesId: String): List<BaseItem> {
val now = System.currentTimeMillis()
val loaded = if (ServerConfig.isGateway) {
requireGateway().seriesEpisodes(seriesId).items
} else {
@@ -1048,16 +1185,72 @@ class EmbyRepository(private val settings: SettingsStore) {
return result.played
}
/** Returns Emby's first local trailer for an item, when one is available. */
/** Removes a title from Continue Watching without changing its watched state. */
suspend fun removeFromContinueWatching(itemId: String) {
if (ServerConfig.isGateway) {
requireGateway().hideFromResume(itemId)
return
}
val userId = snapshot.userId ?: error("Not connected")
requireApi().hideFromResume(userId, itemId)
}
/**
* Emby's first local trailer for an item, when one is available.
*
* Cached, **including the negative answer**, and single-flighted on the repository's
* scope like [getRelated]. Most of a library has no local trailer, so before this every
* detail page opened with a request the gateway answered 404 to — repeated on walking
* Back and reopening, and again for every step of the "More like this" trail. Whether a
* title has a trailer only changes when its media does, so the entry lives as long as
* the process.
*/
suspend fun getLocalTrailer(itemId: String): BaseItem? {
if (itemId.isBlank()) return null
val inFlight = trailerMutex.withLock {
trailerCache[itemId]?.let { return it.value }
trailerInFlight[itemId] ?: newTrailerRequest(itemId)
}
// A failure is not cached — one bad minute must not leave a title trailerless for
// the rest of the session — and it is not surfaced either: a missing trailer button
// is the same outcome the 404 already produces.
return inFlight.await()?.value
}
private fun newTrailerRequest(itemId: String): Deferred<CachedTrailer?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { loadLocalTrailer(itemId) }.getOrNull()
?: return@async null
trailerMutex.withLock {
trailerCache[itemId] = loaded
while (trailerCache.size > TRAILER_CACHE_SIZE) {
trailerCache.entries.iterator().run {
next()
remove()
}
}
loaded
}
} finally {
trailerMutex.withLock { trailerInFlight.remove(itemId) }
}
}
trailerInFlight[itemId] = request
request.start()
return request
}
private suspend fun loadLocalTrailer(itemId: String): CachedTrailer {
if (ServerConfig.isGateway) {
// The gateway answers 404 when an item has no trailer, which is a normal
// outcome here rather than an error worth surfacing.
return runCatching { requireGateway().trailer(itemId) }
val trailer = runCatching { requireGateway().trailer(itemId) }
.getOrElse { if (it is HttpException && it.code() == 404) null else throw it }
return CachedTrailer(trailer)
}
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getLocalTrailers(userId, itemId).items.firstOrNull()
return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull())
}
/**
@@ -1225,6 +1418,7 @@ class EmbyRepository(private val settings: SettingsStore) {
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
endCreditsAvailable = true,
)
}
@@ -1242,15 +1436,17 @@ class EmbyRepository(private val settings: SettingsStore) {
// Recovery follows the local playhead. The server's persisted position may be
// up to one progress interval behind and would visibly jump the viewer back.
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitles = resolveSubtitleUrls(playback.subtitles),
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
subtitleFixAvailable = playback.subtitleFixAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
)
}
@@ -1273,7 +1469,7 @@ class EmbyRepository(private val settings: SettingsStore) {
title = playback.title.ifBlank { title },
url = playback.url,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitles = resolveSubtitleUrls(playback.subtitles),
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
@@ -1283,8 +1479,10 @@ class EmbyRepository(private val settings: SettingsStore) {
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
subtitleFixAvailable = playback.subtitleFixAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
)
}
val discovery = directPlayback(
@@ -1308,6 +1506,7 @@ class EmbyRepository(private val settings: SettingsStore) {
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
endCreditsAvailable = true,
)
}
@@ -1353,7 +1552,7 @@ class EmbyRepository(private val settings: SettingsStore) {
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = item.logoUrl,
subtitles = playback.subtitles,
subtitles = resolveSubtitleUrls(playback.subtitles),
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
@@ -1365,8 +1564,10 @@ class EmbyRepository(private val settings: SettingsStore) {
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
subtitleFixAvailable = playback.subtitleFixAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
endCreditsAvailable = playback.endCreditsAvailable,
)
}
if (item.isSeries) {
@@ -1392,6 +1593,7 @@ class EmbyRepository(private val settings: SettingsStore) {
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
endCreditsAvailable = true,
overview = episode.overview,
episodeCode = episodeCode(episode),
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
@@ -1412,6 +1614,7 @@ class EmbyRepository(private val settings: SettingsStore) {
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
endCreditsAvailable = true,
overview = item.overview,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
@@ -1431,7 +1634,12 @@ class EmbyRepository(private val settings: SettingsStore) {
private suspend fun clearSeriesEpisodeCache() {
seriesEpisodesMutex.withLock {
// Episodes carry this viewer's watched and resume state, so the same reasoning
// as below applies: another profile must not inherit them, and a request
// already in the air was made with the outgoing profile's session.
seriesEpisodesCache.clear()
seriesEpisodesInFlight.values.forEach { it.cancel() }
seriesEpisodesInFlight.clear()
}
relatedMutex.withLock {
// Reasons are personal: another profile must never inherit this one's, and a
@@ -1440,6 +1648,14 @@ class EmbyRepository(private val settings: SettingsStore) {
relatedInFlight.values.forEach { it.cancel() }
relatedInFlight.clear()
}
trailerMutex.withLock {
// Not personal — whether a title has a trailer is a property of the library —
// but the request in flight carries the outgoing session, and the next profile
// may be signed into a different server entirely.
trailerCache.clear()
trailerInFlight.values.forEach { it.cancel() }
trailerInFlight.clear()
}
}
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
@@ -1516,13 +1732,33 @@ class EmbyRepository(private val settings: SettingsStore) {
* 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
suspend fun introSegment(itemId: String): IntroSegment? = chapterMarkers(itemId).intro
/**
* Where this title's closing credits begin, or null when it has none.
*
* Reads the same cached lookup [introSegment] does, which is the whole reason the credits
* pane costs nothing: the markers are two entries of one chapter list, so the second
* feature to want them finds them already in hand. It answers only where the marker is —
* whether it is worth acting on depends on the file's duration, which only the player
* knows, and is [creditsWorthShowing]'s question.
*/
suspend fun creditsStartMs(itemId: String): Long? = chapterMarkers(itemId).creditsStartMs
/**
* One reading of an item's chapter markers, remembered.
*
* Both features read this rather than fetching their own copy. On the direct path that
* matters most: two lookups would be two `Fields=Chapters` requests to Emby for one
* response, made at the moment the decoder wants the connection pool.
*/
private suspend fun chapterMarkers(itemId: String): ChapterMarkers {
if (itemId.isBlank()) return ChapterMarkers()
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
if (ServerConfig.isGateway) gatewayMarkers(itemId) else directMarkers(itemId)
}.getOrNull() ?: ChapterMarkers()
// An empty reading is cached too. Most of a library has no intro markers — 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)
@@ -1535,18 +1771,33 @@ class EmbyRepository(private val settings: SettingsStore) {
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 gatewayMarkers(itemId: String): ChapterMarkers {
val markers = requireGateway().intro(itemId)
return ChapterMarkers(
intro = if (markers.available && markers.endMs > markers.startMs) {
IntroSegment(startMs = markers.startMs, endMs = markers.endMs)
} else {
null
},
creditsStartMs = markers.creditsStartMs.takeIf { markers.creditsAvailable && it > 0L },
)
}
private suspend fun directIntro(itemId: String): IntroSegment? {
val userId = snapshot.userId ?: return null
private suspend fun directMarkers(itemId: String): ChapterMarkers {
val userId = snapshot.userId ?: return ChapterMarkers()
// 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)
val item = requireApi().getItem(userId, itemId, "Chapters")
return ChapterMarkers(
intro = introSegmentFrom(item.chapters),
// The runtime is what tells a chapter named "Credits" from one named "Opening
// Credits". It is a default field on this response, so it costs nothing to use.
creditsStartMs = creditsStartFrom(
item.chapters,
item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
),
)
}
/**
@@ -1685,7 +1936,7 @@ class EmbyRepository(private val settings: SettingsStore) {
)
SubtitleDownload(
message = response.message,
subtitles = response.subtitles,
subtitles = resolveSubtitleUrls(response.subtitles),
selectedSubtitleId = response.selectedSubtitleId,
mediaSourceId = response.mediaSourceId,
playSessionId = response.playSessionId,
@@ -1694,10 +1945,34 @@ class EmbyRepository(private val settings: SettingsStore) {
}.getOrNull()
}
/**
* Check an existing subtitle's timing and keep the corrected copy when it moved.
*
* Gateway-only: the direct path has nowhere durable to store the repaired sidecar.
* Null is a transport failure; a safe refusal to guess is a successful response whose
* [SubtitleFix.message] explains why nothing changed.
*/
suspend fun fixSubtitle(itemId: String, subtitleId: String): SubtitleFix? {
if (itemId.isBlank() || subtitleId.isBlank() || !ServerConfig.isGateway) return null
return runCatching {
val response = requireGateway().fixSubtitle(
itemId,
GatewaySubtitleFixRequest(subtitleId),
)
SubtitleFix(
subtitleId = response.subtitleId,
message = response.message,
changed = response.changed,
offsetMs = response.offsetMs,
reference = response.reference,
)
}.getOrNull()
}
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
return nextEpisodeOf(
response.item, response.url, response.resumePositionMs, response.subtitles,
response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
response.subtitlesEnabled, response.selectedSubtitleId,
response.mediaSourceId, response.playSessionId, response.playMethod,
)
@@ -1942,6 +2217,37 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
/**
* Resolves subtitle URLs the gateway handed back as paths rather than addresses.
*
* Almost every subtitle is Emby's and arrives as a complete URL with its own
* credential on it. The exception is one the gateway fetched itself — from a provider
* that hands back a file instead of writing it beside the media — which the gateway
* serves from its own route. It cannot write that as an absolute address because it
* does not reliably know its externally reachable name; the television does, because
* it is the thing talking to it. So the server sends a path and this puts the base and
* the token on it, exactly as [imageUrl] does for artwork, and for the same reason:
* media3 fetches a sidecar as a plain URL with none of Memby's headers attached.
*
* A URL that is already absolute is left alone, so this is safe to run over every
* playback response rather than only the one that produced a download.
*/
private fun resolveSubtitleUrls(subtitles: List<PlayableSubtitle>): List<PlayableSubtitle> {
if (subtitles.none { it.url.startsWith("/") }) return subtitles
val gateway = ServerConfig.gatewayUrl?.trimEnd('/')
val token = snapshot.token
return subtitles.map { subtitle ->
if (!subtitle.url.startsWith("/")) return@map subtitle
// Dropping the track is the right failure: a sidecar URL with no credential on
// it fetches a 401, and media3 reports that as a broken stream rather than as a
// missing subtitle.
if (gateway.isNullOrBlank() || token.isNullOrBlank()) {
return@map subtitle.copy(url = "")
}
subtitle.copy(url = gateway + subtitle.url + "?t=" + encode(token))
}
}
fun hasBackdrop(item: BaseItem): Boolean =
item.backdropImageTags.isNotEmpty() ||
(item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty())
@@ -2095,14 +2401,31 @@ private data class CachedRelated(
val expiresAtMs: Long,
)
/**
* A wrapper rather than the value itself, for the reason [CachedTrickplay] is one: a null
* [value] is a real answer — this title has no local trailer, which is most of a library —
* and a map cannot tell that from an absent entry.
*/
private data class CachedTrailer(val value: BaseItem?)
/**
* 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?)
/**
* What one reading of a title's chapter markers came to. Both halves are nullable and both
* nulls are real answers — most of a library has no intro, and a title can easily have
* credits and no intro or the other way round.
*/
data class ChapterMarkers(
val intro: IntroSegment? = null,
val creditsStartMs: Long? = null,
)
/** An empty reading is a real answer — most titles have no markers — so it is cached too. */
private data class CachedIntro(val value: ChapterMarkers)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
@@ -2137,11 +2460,23 @@ private const val TRICKPLAY_WIDTH = 320
*/
private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L
private const val SERIES_EPISODE_CACHE_SIZE = 6
/**
* Raised from six when focus began warming this: a viewer walking a shelf of shows now
* fills it as they go, and at six the show they finally press could have been evicted by
* the ones they passed on the way to it — which is the entire case the warm exists for.
*/
private const val SERIES_EPISODE_CACHE_SIZE = 10
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
private const val RELATED_LIMIT = 12
/**
* Whether a title has a trailer is one boolean and an occasional item, so this can afford
* to be generous: an evening of browsing touches far more titles than it plays, and the
* point of the cache is that walking back into a page never asks again.
*/
private const val TRAILER_CACHE_SIZE = 64
// Long enough that walking back and forth between a row and a detail page never re-asks,
// short enough that a newly watched title drops out of "more like this" the same evening.
private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* Browsing a genre: what came back, where it started, and how much there is.
*
* Kept apart from the search results it shares a pane with, because the two answer
* different questions. A search is one response and it is either everything or nothing;
* a genre is a shelf somebody scrolls, so what matters about a page is where it sits in
* the whole and whether there is another one behind it.
*/
data class GenrePage(
val items: List<BaseItem>,
val offset: Int,
/**
* How many titles the genre holds. Zero from a backend that would not count, which
* [hasMoreGenreItems] reads as "this is all there is" rather than as an invitation to
* keep asking — a grid that asks forever is worse than one that stops early, since the
* viewer can always search.
*/
val total: Int,
)
/** A page this size, in items. Several television screenfuls, so the scroll stays ahead. */
const val GENRE_PAGE_SIZE = 48
/**
* Whether the grid should ask for another page.
*
* Two things end a scroll and both have to, because either one alone leaves a real case
* broken. Reaching the total is the ordinary end. A page that came back *short* of what was
* asked for is the other: a backend that did not count says nothing useful with its total,
* and without this the grid would go on asking for pages of a genre that ran out.
*/
fun hasMoreGenreItems(loaded: Int, total: Int, lastPageSize: Int, pageSize: Int): Boolean {
if (loaded == 0) return false
if (lastPageSize < pageSize) return false
return loaded < total
}
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.data
import android.content.Context
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What Memby is holding of the library's artwork on this television.
*
* Two caches, deliberately reported apart: the disk half is the 128MB of posters and
* backdrops under `cacheDir/media_artwork` and survives a restart, while the memory half is
* decoded bitmaps and is gone the moment Android reclaims the process. Adding them into one
* figure would tell a viewer that emptying the cache frees more storage than it does.
*/
data class ImageCacheSize(
val diskBytes: Long,
val memoryBytes: Long,
) {
val totalBytes: Long get() = diskBytes + memoryBytes
companion object {
val EMPTY = ImageCacheSize(0L, 0L)
}
}
/**
* A size a viewer reads on a television across the room, so it is one number and a unit —
* never a byte count with six digits in it.
*
* Units are binary (a kilobyte is 1024 bytes), because that is what Coil's own budget is
* measured in and a figure that disagreed with the cache's stated maximum would look wrong.
* Below a megabyte nothing is worth a decimal point; above it one place is enough to show
* the number moving.
*/
fun formatCacheSize(bytes: Long): String {
if (bytes <= 0L) return "0 MB"
val kb = 1024.0
val mb = kb * 1024.0
val gb = mb * 1024.0
return when {
bytes < kb -> "$bytes B"
bytes < mb -> "${Math.round(bytes / kb)} KB"
bytes < gb -> "${roundToOneDecimal(bytes / mb)} MB"
else -> "${roundToOneDecimal(bytes / gb)} GB"
}
}
private fun roundToOneDecimal(value: Double): String {
val tenths = Math.round(value * 10.0)
val whole = tenths / 10
val remainder = tenths % 10
return if (remainder == 0L) whole.toString() else "$whole.$remainder"
}
/**
* Measuring and emptying Coil's caches.
*
* Both sides are off the main thread: reading the disk cache's size walks its journal, and
* clearing it deletes up to 128MB of files — either one on the main thread is a settings
* screen that stops answering the remote.
*/
@OptIn(ExperimentalCoilApi::class)
object ImageCacheMaintenance {
suspend fun measure(context: Context): ImageCacheSize = withContext(Dispatchers.IO) {
val loader = context.applicationContext.imageLoader
ImageCacheSize(
diskBytes = runCatching { loader.diskCache?.size ?: 0L }.getOrDefault(0L),
memoryBytes = runCatching { loader.memoryCache?.size?.toLong() ?: 0L }.getOrDefault(0L),
)
}
/**
* Empties both halves and answers with what is left, which on a working set is nothing.
* Memory goes first: a bitmap still held there would be re-written to disk by the very
* next card that asked for it, and the figure reported back would not be zero.
*/
suspend fun clear(context: Context): ImageCacheSize = withContext(Dispatchers.IO) {
val loader = context.applicationContext.imageLoader
runCatching { loader.memoryCache?.clear() }
runCatching { loader.diskCache?.clear() }
measure(context)
}
}
@@ -4,6 +4,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayAlert
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -88,8 +89,19 @@ class MaintenanceMonitor(
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _theme = MutableStateFlow(GatewayThemeStatus())
private val _installPermissionPrompt = MutableStateFlow(false)
/**
* Which colour scheme this viewer's televisions should be painted, as an id and a
* revision. [ThemeSync] fetches the palette only when the revision moves.
*
* It rides this poll rather than the sign-in because that is the whole feature: a
* seasonal theme has to reach a set that is already switched on, at the midnight it
* begins, with nobody doing anything.
*/
val theme: StateFlow<GatewayThemeStatus> = _theme.asStateFlow()
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
@@ -159,6 +171,7 @@ class MaintenanceMonitor(
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
@@ -186,6 +199,7 @@ class MaintenanceMonitor(
null
}
_preferencesRevision.value = status.preferencesRevision
_theme.value = status.theme
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
// Emby's state is reported even during maintenance: an
@@ -207,6 +221,7 @@ class MaintenanceMonitor(
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_theme.value = GatewayThemeStatus()
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
@@ -320,6 +320,9 @@ data class Settings(
// 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,
// Shrink the closing credits to one side at double speed with what is on next beside
// them. Per-profile and synced for the same reason the three above are.
val speedUpCredits: Boolean = true,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -344,6 +347,26 @@ data class Settings(
val homeHiddenRows: String = "",
/** Tone used for the short welcome line after sign-in and during startup. */
val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
/**
* The colour scheme this viewer chose, as a server theme id. Profile-specific and
* synced like the rest, so it follows the person to every set they sign into.
*
* It is only ever the *choice*. What is actually on screen is resolved by the gateway
* on top of it — a season outranks it, and an operator's allowlist can withdraw it —
* and that answer arrives as [themePaletteJson] below rather than as a change here.
* Keeping them apart is what lets a viewer's own scheme survive underneath Christmas
* and come back on the 27th without anybody re-choosing it.
*/
val themeId: String = DEFAULT_THEME_ID,
/**
* The palette the gateway last handed this television, verbatim, and the revision it
* came at. Device state rather than a synced preference: it is a *cache* of a server
* answer, not a decision, and its whole job is to be on disk before the first request
* of a cold start returns so the launcher does not paint itself in the default colours
* and then flick into the viewer's.
*/
val themePaletteJson: String? = null,
val themeRevision: String = "",
/**
* User ids known to have finished recommendation onboarding on this TV. Cold start
* consults this instead of waiting on the gateway: onboarding is a one-time,
@@ -386,6 +409,8 @@ data class Settings(
const val DEFAULT_HOME_CARD_DENSITY = "standard"
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
/** Matches `defaultThemeID` in the gateway's theme catalogue. */
const val DEFAULT_THEME_ID = "midnight"
val EMPTY = Settings()
}
}
@@ -421,6 +446,16 @@ data class EmbyProfile(
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val speedUpCredits: Boolean = true,
/**
* The colour scheme this person chose. Per profile like the rest — two people sharing a
* television have separate documents on the server and separate schemes on it.
*
* The resolved *palette* is deliberately not here: it is device state, because it is a
* cache of a server answer rather than a choice, and stuffing eight colours into the
* profiles blob would put them into the file that is rewritten on every settings edit.
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
)
class SettingsStore(private val context: Context) {
@@ -459,6 +494,7 @@ class SettingsStore(private val context: Context) {
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -474,6 +510,9 @@ class SettingsStore(private val context: Context) {
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
val THEME_ID = stringPreferencesKey("theme_id")
val THEME_PALETTE = stringPreferencesKey("theme_palette")
val THEME_REVISION = stringPreferencesKey("theme_revision")
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
@@ -620,12 +659,63 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setSpeedUpCredits(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.SPEED_UP_CREDITS] = enabled
updateActiveProfile(preferences) { it.copy(speedUpCredits = enabled) }
}
}
/**
* The viewer picking a colour scheme. Not validated here on purpose: the legal ids are
* the gateway's catalogue and this build has no copy of it, so the only honest check is
* the one the server makes when the choice is pushed. An id it rejects comes back as the
* default on the next pull, which is the same correction every other setting gets.
*
* Nothing repaints from this write. What is on screen is the *resolved* palette, and
* that arrives from `ThemeSync` a moment later — which is also what makes a choice the
* operator has withdrawn, or one covered by a season, visibly not take effect rather
* than take effect and then be undone.
*/
suspend fun setThemeId(themeId: String) {
val trimmed = themeId.trim().ifEmpty { return }
context.dataStore.edit { preferences ->
preferences[Keys.THEME_ID] = trimmed
updateActiveProfile(preferences) { it.copy(themeId = trimmed) }
}
}
/**
* Caches the palette the gateway resolved, so the next cold start paints in the right
* colours before any request returns.
*
* Written in one edit with the revision it came at, the same invariant
* [applyRemotePreferences] keeps and for the same reason: the revision is the only thing
* that decides whether to fetch, and a set holding a revision without the palette it
* describes would never ask again.
*
* The unchanged case is skipped rather than written, because this runs off a poll: a
* store rewritten every ten seconds to say nothing new is exactly the cost the revision
* exists to avoid.
*/
suspend fun setThemePalette(paletteJson: String, revision: String) {
if (latestSettings?.themePaletteJson == paletteJson &&
latestSettings?.themeRevision == revision
) {
return
}
context.dataStore.edit { preferences ->
preferences[Keys.THEME_PALETTE] = paletteJson
preferences[Keys.THEME_REVISION] = revision
}
}
/**
* 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 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
* edit, and this touches nineteen keys plus the profiles blob — doing it through the
* individual setters would be eighteen rewrites for one sync, on a TV that has just
* started up.
*
* The revision is stored in the same edit as the values it describes. If they could
@@ -647,6 +737,7 @@ class SettingsStore(private val context: Context) {
store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.THEME_ID] = preferences.themeId
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
@@ -654,6 +745,7 @@ class SettingsStore(private val context: Context) {
store[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits
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")
@@ -669,6 +761,7 @@ class SettingsStore(private val context: Context) {
hideWatchedMovies = preferences.hideWatchedMovies,
showTitleLogo = preferences.showTitleLogo,
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
themeId = preferences.themeId,
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled,
@@ -676,6 +769,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
speedUpCredits = preferences.speedUpCredits,
forYouMinutes = preferences.forYouMinutes,
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
@@ -1033,6 +1127,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds = previous?.seekIntervalSeconds
?: DEFAULT_SEEK_INTERVAL_SECONDS,
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
speedUpCredits = previous?.speedUpCredits ?: true,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -1156,6 +1251,13 @@ class SettingsStore(private val context: Context) {
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
preferences[Keys.THEME_ID] = profile.themeId
// The cached palette belongs to whoever was signed in a moment ago, and this is a
// different person with a different scheme (and possibly a different allowlist).
// Dropping it puts this set on the default until ThemeSync answers, which is a
// second of the app's own colours rather than a minute of somebody else's.
preferences.remove(Keys.THEME_PALETTE)
preferences.remove(Keys.THEME_REVISION)
preferences[Keys.HOME_SECTIONS] = profile.homeSections
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
@@ -1173,6 +1275,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -1215,6 +1318,8 @@ class SettingsStore(private val context: Context) {
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
)
}
@@ -1249,6 +1354,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
@@ -1266,6 +1372,9 @@ class SettingsStore(private val context: Context) {
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
themePaletteJson = preferences[Keys.THEME_PALETTE],
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
@@ -0,0 +1,184 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.ponzischeme89.memby.data.model.GatewayTheme
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
/**
* Keeps this television painted the colour the gateway says it should be.
*
* Three things move a theme and all three arrive the same way — as a revision on the status
* poll that no longer matches what this set holds:
*
* - **The viewer picks a scheme.** The choice is an ordinary synced setting, pushed by
* [PreferencesSync]; the *palette* comes back through here a moment later.
* - **The operator changes what they may choose.** A scheme withdrawn resolves to the
* default, and the set repaints without anybody signing in again.
* - **A season begins or ends.** This is the one that could not work any other way. Nobody
* writes anything at midnight on the 1st of December — the answer simply becomes
* different — which is why the revision is a hash of the resolved theme rather than a
* counter in a table, and why the poll is where it rides.
*
* The cached palette is applied first, before any request. A television that has been signed
* in before therefore starts in its own colours rather than opening in the default and
* flicking over a second later, which is the same promise `HomeCache` makes about the rows.
*
* Everything here fails silently. A colour is not worth an error message on a television,
* and the state a failure leaves — the palette already on screen, the revision not advanced
* — is a correct one to sit in until the next poll.
*/
class ThemeSync(
private val repository: EmbyRepository,
private val settings: SettingsStore,
private val remoteTheme: StateFlow<GatewayThemeStatus>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
private val json = Json { ignoreUnknownKeys = true }
private val _theme = MutableStateFlow<GatewayTheme?>(null)
/**
* The resolved theme as the server last described it: which scheme is on, whether it is
* a season, and the sentence saying so. Null until this set has been told once — which
* is what the settings picker renders as "not available" rather than as a locked state
* it has no evidence for.
*/
val theme: StateFlow<GatewayTheme?> = _theme.asStateFlow()
private val _available = MutableStateFlow<List<GatewayTheme>>(emptyList())
/**
* The schemes this viewer may choose between. Empty on the direct path and before the
* first fetch, and the picker draws nothing rather than a list compiled into the APK:
* the per-user allowlist is only real if a withheld theme is one the television was
* never sent.
*/
val available: StateFlow<List<GatewayTheme>> = _available.asStateFlow()
/** One fetch at a time; two racing would decide the stored revision twice. */
private val mutex = Mutex()
/**
* The revision whose palette is on screen. Held here as well as on disk because the
* write is skipped when nothing changed, so disk alone cannot say whether this process
* has already acted on a revision.
*/
private var appliedRevision: String? = null
init {
scope.launch { run() }
}
private suspend fun run() {
// Paint from the cache immediately, outside the lifecycle gate below: this has to
// happen while the launcher is composing its first frame, not when the process
// happens to reach the foreground.
scope.launch {
repository.settingsFlow
.distinctUntilChanged { old, new -> old.themePaletteJson == new.themePaletteJson }
.collect { session -> applyCached(session.themePaletteJson) }
}
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(repository.settingsFlow, remoteTheme) { session, status ->
SyncTrigger(session, status)
}
.distinctUntilChanged()
// Plain collect rather than collectLatest: a fetch cancelled halfway could
// leave the stored revision describing a palette that was never written.
.collect(::reconcile)
}
}
/** The only parts of the session and the poll a theme fetch depends on. */
private data class SyncTrigger(
val signedIn: Boolean,
val heldRevision: String,
val serverRevision: String,
) {
constructor(session: Settings, status: GatewayThemeStatus) : this(
signedIn = session.isSignedIn,
heldRevision = session.themeRevision,
serverRevision = status.revision,
)
}
private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway) return
if (!trigger.signedIn) {
// Back to the app's own colours. A sign-out that left the last viewer's scheme
// on the setup screen would be showing somebody's choice to whoever is about to
// replace them.
_theme.value = null
_available.value = emptyList()
appliedRevision = null
applyMembyPalette(MembyPalette())
return
}
// A server that predates themes sends nothing, and there is nothing to fetch. The
// palette already on screen — cached or default — is the right thing to keep.
if (trigger.serverRevision.isEmpty()) return
if (trigger.serverRevision == appliedRevision &&
trigger.serverRevision == trigger.heldRevision
) {
return
}
mutex.withLock { fetch(trigger.serverRevision) }
}
private suspend fun fetch(expectedRevision: String) {
if (appliedRevision == expectedRevision) return
val document = runCatching { repository.theme() }.getOrNull() ?: return
val resolved = document.theme
_theme.value = resolved
_available.value = document.available
val palette = resolved.palette.toMembyPalette()
applyMembyPalette(palette)
// The revision recorded is the one the *response* carried, not the one the poll
// advertised. They differ if a season turned over between the two, and storing the
// poll's would leave this set believing it holds a palette it never received.
appliedRevision = resolved.revision.ifEmpty { expectedRevision }
runCatching {
settings.setThemePalette(
json.encodeToString(com.ponzischeme89.memby.data.model.GatewayPalette.serializer(), resolved.palette),
appliedRevision.orEmpty(),
)
}.onFailure {
// The palette is on screen and simply not cached: this set repaints correctly
// now and pays one extra fetch on its next cold start. Clearing the applied
// revision would instead make it refetch on every poll.
}
}
/**
* Paints from what was stored at the end of the last session, before anything is asked
* of the network. A blank or unreadable cache leaves the default palette standing.
*/
private fun applyCached(paletteJson: String?) {
if (paletteJson.isNullOrBlank()) return
val palette = runCatching {
json.decodeFromString(
com.ponzischeme89.memby.data.model.GatewayPalette.serializer(),
paletteJson,
)
}.getOrNull() ?: return
applyMembyPalette(palette.toMembyPalette())
}
}
@@ -0,0 +1,60 @@
package com.ponzischeme89.memby.data
import androidx.compose.ui.graphics.Color
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.ui.theme.MembyPalette
/**
* Turning the gateway's answer into colours, and nothing else.
*
* There is deliberately **no client-side theme rule** here to match — no seasonal
* calculation, no allowlist, no fallback catalogue. That is the opposite of the choice made
* for subtitles, intros and Continue Watching, where the rule exists twice because with no
* gateway there is nobody to ask. The difference is what "nobody to ask" costs: for those,
* the direct path would behave *differently*, which is a bug. Here it behaves as it always
* did — the default palette, the one the app shipped with. A television painting itself
* Halloween orange on the strength of its own clock, while the household's server has the
* feature switched off, would be the feature failing rather than degrading.
*/
/**
* Parses `#AARRGGBB` (or `#RRGGBB`) as the gateway writes it.
*
* Alpha-first is Android's own order and the reason it is what goes on the wire: this end
* parses one of these for every colour of every theme change, and the admin console — which
* parses eight, once — is where the reordering for CSS happens instead.
*
* Returns null rather than a colour for anything it cannot read, so the caller can keep the
* app's own token for that slot. A theme drawn one colour wrong is a blemish; a screen drawn
* transparent because a hex string had a typo in it is a television nobody can use.
*/
internal fun parseThemeColor(value: String?): Color? {
val hex = value?.trim()?.removePrefix("#") ?: return null
if (hex.length != 6 && hex.length != 8) return null
if (!hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return null
val argb = hex.toLongOrNull(16) ?: return null
// Six digits are opaque. Emby's own artwork colours are written that way and it is the
// form somebody hand-editing a palette would reach for.
return Color(if (hex.length == 6) argb or 0xFF000000L else argb)
}
/**
* The palette to paint with, given what the server sent.
*
* [fallback] is the palette currently in force rather than the class defaults, the same
* distinction [decodeUserPreferences] draws: a response missing a colour must leave that one
* alone, not silently reset it. That is what lets the gateway grow a token before every
* television in the house has the release that knows about it — and what makes a partial
* palette a partial change rather than a half-black screen.
*/
fun GatewayPalette.toMembyPalette(fallback: MembyPalette = MembyPalette()): MembyPalette =
MembyPalette(
surface = parseThemeColor(surface) ?: fallback.surface,
surfaceRaised = parseThemeColor(surfaceRaised) ?: fallback.surfaceRaised,
accent = parseThemeColor(accent) ?: fallback.accent,
onSurface = parseThemeColor(onSurface) ?: fallback.onSurface,
mutedText = parseThemeColor(mutedText) ?: fallback.mutedText,
quietText = parseThemeColor(quietText) ?: fallback.quietText,
hairline = parseThemeColor(hairline) ?: fallback.hairline,
ratingsSurface = parseThemeColor(ratingsSurface) ?: fallback.ratingsSurface,
)
@@ -31,6 +31,18 @@ data class UserPreferences(
val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
/**
* The colour scheme this viewer chose, as a server theme id.
*
* Deliberately not validated on this side, unlike [seekIntervalSeconds] and
* [skipIntroMode]. Those normalise because a value this build cannot read would reach
* the player as a behaviour — an unknown skip length, an unexplained jump. A theme id is
* only ever handed back to the gateway, which is the thing that decides what it means,
* and the palette that arrives is never derived from it here. So the safe treatment is
* to carry an unrecognised id through untouched, which is also what lets the server grow
* a theme before every television in the house has any idea it exists.
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
/**
@@ -45,6 +57,8 @@ data class UserPreferences(
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,
/** Shrink the closing credits to one side at double speed with what is on next beside. */
val speedUpCredits: Boolean = true,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
@@ -70,12 +84,14 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = themeId,
autoPlayNextEpisode = autoPlayNextEpisode,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
speedUpCredits = speedUpCredits,
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
@@ -110,6 +126,7 @@ fun decodeUserPreferences(
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
themeId = json.string("themeId", fallback.themeId),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
@@ -125,6 +142,7 @@ fun decodeUserPreferences(
skipIntroMode = normalizeSkipIntroMode(
json.string("skipIntroMode", fallback.skipIntroMode),
),
speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
@@ -141,12 +159,14 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("hideWatchedMovies", hideWatchedMovies)
put("showTitleLogo", showTitleLogo)
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("themeId", themeId)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("skipIntroMode", skipIntroMode)
put("speedUpCredits", speedUpCredits)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
@@ -136,6 +136,31 @@ data class GatewayServiceStatus(
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
/**
* The colour scheme this viewer's televisions should be painted, as an id and a
* revision rather than the palette itself — the [preferencesRevision] precedent, for
* the same reason: this poll runs every ten seconds on every open set, and a palette
* riding it would be eight colours repeated six times a minute to say nothing new.
*
* A server that predates this sends none, which decodes to a revision of "" and so
* never triggers a fetch: an app that cannot be told its theme keeps the one it shipped
* with, which is the palette everything looked like before themes existed.
*/
val theme: GatewayThemeStatus = GatewayThemeStatus(),
)
/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */
@Serializable
data class GatewayThemeStatus(
val id: String = "",
/**
* Opaque, and compared only for equality. It moves when the palette would look
* different — which includes the morning a season begins, an event no revision counter
* in a table could produce because nobody wrote anything.
*/
val revision: String = "",
val seasonal: Boolean = false,
val locked: Boolean = false,
)
/**
@@ -180,6 +205,81 @@ data class GatewayPreferences(
kotlinx.serialization.json.JsonObject(emptyMap()),
)
/**
* The colour scheme in force and the ones this viewer may choose between.
*
* Both halves come from the server and neither is compiled into the app. The catalogue is
* per viewer, not per app: a theme the operator has withheld from somebody is not a greyed
* row on their television, it is a row that was never sent — which is what makes the
* per-user allowlist real rather than advisory.
*/
@Serializable
data class GatewayThemeDocument(
val schemaVersion: Int = 0,
val theme: GatewayTheme = GatewayTheme(),
val available: List<GatewayTheme> = emptyList(),
)
/**
* One theme. [palette] is the whole of what it changes — a theme never moves a control or
* alters what a row contains, so the worst a scheme this build has never heard of can do is
* look wrong.
*/
@Serializable
data class GatewayTheme(
val id: String = "",
val name: String = "",
val description: String = "",
/**
* True for Halloween, Christmas and Easter. Never offered as a choice and never stored
* as one; it is simply in force for its dates.
*/
val seasonal: Boolean = false,
/**
* While true the picker shows the viewer's own choice but will not let them change it,
* and [reason] is the sentence explaining why. Distinct from [seasonal] so a future
* reason to pin a theme does not have to claim to be a season.
*/
val locked: Boolean = false,
/** The viewer's own selection, still theirs underneath a season. */
val chosen: String = "",
/** The server's wording for the lock, so a season invented later still reads correctly. */
val reason: String = "",
/**
* What drifts over the launcher while this theme is on: "snow", "bats", "blossom", or
* empty for the whole rest of the year and for every scheme somebody chose themselves.
*
* A slug rather than anything describing the animation — the drawing is the television's,
* in `ui/seasonal/`. A build that does not recognise one draws nothing, so the gateway may
* invent a decoration before the fleet has the release that knows it. Empty is also what
* an operator who has turned decorations off gets, which is why the client must obey this
* field rather than deriving the animation from the theme id.
*/
val decoration: String = "",
val revision: String = "",
val palette: GatewayPalette = GatewayPalette(),
)
/**
* The eight colours a theme sets, as `#AARRGGBB`. Alpha first because Android is the end
* that has to parse thousands of these; the admin console reorders for CSS at its own end.
*
* Every field defaults to blank rather than to a colour: a missing one falls back to the
* app's own token at the point of conversion, which is a theme drawn slightly wrong rather
* than a screen drawn transparent.
*/
@Serializable
data class GatewayPalette(
val surface: String = "",
val surfaceRaised: String = "",
val accent: String = "",
val onSurface: String = "",
val mutedText: String = "",
val quietText: String = "",
val hairline: String = "",
val ratingsSurface: String = "",
)
/** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */
@Serializable
data class GatewayPreferencesRequest(
@@ -287,6 +387,25 @@ data class GatewayItems(
val items: List<BaseItem> = emptyList(),
)
/**
* One page of `GET /v1/genres/{genre}/items` — browsing a genre rather than searching for
* its name.
*
* [total] is what ends the scroll. A page shorter than [limit] ends it too, but a genre
* whose last page happens to divide evenly would otherwise cost one more empty request to
* discover that, and that request lands exactly as somebody reaches the bottom of the grid.
* It defaults to zero rather than to something optimistic: a gateway that answered without
* it must leave the television believing it has everything, not asking forever.
*/
@Serializable
data class GatewayGenrePage(
val genre: String = "",
val items: List<BaseItem> = emptyList(),
val offset: Int = 0,
val limit: Int = 0,
val total: Int = 0,
)
/**
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
*
@@ -377,6 +496,9 @@ 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 this title has another readable text track against which subtitle timing can
// be checked. False for an older gateway, so a missing field never creates a dead row.
val subtitleFixAvailable: 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
@@ -386,20 +508,33 @@ data class GatewayPlayback(
// 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,
// Whether it is worth asking this gateway where the closing credits begin. Same shape
// and same reasoning again, and deliberately its own field rather than a reuse of
// [skipIntroAvailable]: they are separate features with separate switches, and a house
// that turned the skip button off has not asked to lose the credits pane with it.
val endCreditsAvailable: Boolean = false,
)
/**
* Where an episode's opening titles sit, as the gateway found them in Emby's markers.
* Where an episode's opening titles sit, and where its closing credits begin, 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.
*
* Both answers share one response because they are in the same chapter list, so reading them
* together costs the one Emby request the gateway was always going to make. [creditsAvailable]
* is separate from [available] because an episode routinely has one and not the other — every
* film Emby has found credits but no intro in would otherwise be lost.
*/
@Serializable
data class GatewayIntro(
val available: Boolean = false,
val startMs: Long = 0L,
val endMs: Long = 0L,
val creditsAvailable: Boolean = false,
val creditsStartMs: Long = 0L,
)
/**
@@ -420,8 +555,14 @@ data class GatewayTrickplay(
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@Serializable
data class GatewaySubtitleCandidate(
// Bazarr's opaque provider handle. It round-trips untouched — nothing on this side
// parses it, and reconstructing it from the other fields would break the download.
// Which backend produced this row. It rides back with the token on the download call,
// because the gateway dispatches on it — the providers' tokens are opaque in different
// ways and handing one to the other is a mistake nothing could detect. Never derived
// here: an empty value is a gateway that predates the second provider, and the server
// reads that as Bazarr.
val source: String = "",
// The provider's opaque handle. It round-trips untouched — nothing on this side parses
// it, and reconstructing it from the other fields would break the download.
val token: String = "",
val language: String = "",
val languageLabel: String = "",
@@ -430,6 +571,10 @@ data class GatewaySubtitleCandidate(
val forced: Boolean = false,
val hearingImpaired: Boolean = false,
val originalFormat: Boolean = false,
// Whether nobody wrote this translation. It is on the wire rather than only in the
// label because it is the one property that changes whether a viewer wants the row at
// all, and the gateway's ranking sinks it below everything a person wrote.
val machineOnly: Boolean = false,
// What the row prints. Composed by the gateway so an app that predates a new wording
// still renders it correctly, the same reason alert labels are the gateway's.
val label: String = "",
@@ -447,6 +592,19 @@ data class GatewaySubtitleSearch(
@Serializable
data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate)
@Serializable
data class GatewaySubtitleFixRequest(val subtitleId: String)
/** The result of checking one subtitle's timing against another track on the title. */
@Serializable
data class GatewaySubtitleFix(
val subtitleId: String = "",
val message: String = "",
val changed: Boolean = false,
val offsetMs: Long = 0L,
val reference: String = "",
)
/**
* The result of a download: the item's tracks re-read from Emby after it was told to look
* again, so the player can swap its media item and turn the new track on without a second
@@ -112,4 +112,12 @@ interface EmbyApi {
@Path("userId") userId: String,
@Path("itemId") itemId: String,
): UserItemData
/** Hides an item from Emby's resume/next-up feeds without changing watched state. */
@POST("Users/{userId}/Items/{itemId}/HideFromResume")
suspend fun hideFromResume(
@Path("userId") userId: String,
@Path("itemId") itemId: String,
@Query("Hide") hide: Boolean = true,
): UserItemData
}
@@ -73,6 +73,17 @@ interface GatewayApi {
@GET("v1/search")
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
/**
* One page of a genre. A filter, not a query: the genre is the path rather than a term,
* so the gateway can ask Emby the question actually being asked.
*/
@GET("v1/genres/{genre}/items")
suspend fun genreItems(
@Path("genre") genre: String,
@Query("offset") offset: Int,
@Query("limit") limit: Int,
): com.ponzischeme89.memby.data.model.GatewayGenrePage
@POST("v1/search/history")
suspend fun recordSearch(@Body body: Map<String, String>)
@@ -141,6 +152,13 @@ interface GatewayApi {
@GET("v1/features")
suspend fun features(): GatewayFeatures
/**
* The colour scheme in force and the ones this viewer may pick between. Fetched only
* when the revision on the status poll moves — see `ThemeSync`.
*/
@GET("v1/theme")
suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument
/** This viewer's settings as the server holds them, for whichever TV they sit at. */
@GET("v1/preferences")
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
@@ -229,6 +247,13 @@ interface GatewayApi {
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload
/** Check one existing subtitle against another and store a corrected copy when needed. */
@POST("v1/items/{id}/subtitles/fix")
suspend fun fixSubtitle(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleFix
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@@ -245,6 +270,9 @@ interface GatewayApi {
@POST("v1/items/{id}/played")
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
@POST("v1/items/{id}/hide-from-resume")
suspend fun hideFromResume(@Path("id") itemId: String): UserItemData
@POST("v1/playback/{phase}")
suspend fun report(
@Path("phase") phase: String,
@@ -94,17 +94,22 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import kotlinx.coroutines.launch
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
// here than on the launcher, which is visible the moment a page opens from a row.
internal val DetailBackground = MembySurface
internal val DetailAccent = MembyAccent
internal val DetailText = MembyOnSurface
internal val DetailMutedText = MembyMutedText
internal val DetailQuietText = MembyQuietText
internal val DetailHairline = MembyHairline
//
// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette
// comes from the server, and an alias that captured a value would pin this whole page to
// whichever theme was loaded when the class first initialised.
internal val DetailBackground: Color get() = MembySurface
internal val DetailAccent: Color get() = MembyAccent
internal val DetailText: Color get() = MembyOnSurface
internal val DetailMutedText: Color get() = MembyMutedText
internal val DetailQuietText: Color get() = MembyQuietText
internal val DetailHairline: Color get() = MembyHairline
internal val DetailSideGutter = 58.dp
/**
@@ -202,10 +207,10 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xF2080A0C),
0.42f to Color(0xCC080A0C),
0.72f to Color(0x38080A0C),
1f to Color(0x10080A0C),
0f to DetailBackground.copy(alpha = 0.95f),
0.42f to DetailBackground.copy(alpha = 0.80f),
0.72f to DetailBackground.copy(alpha = 0.22f),
1f to DetailBackground.copy(alpha = 0.06f),
),
),
)
@@ -213,8 +218,8 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x18000000),
0.48f to Color(0x26080A0C),
0.78f to Color(0xE6080A0C),
0.48f to DetailBackground.copy(alpha = 0.15f),
0.78f to DetailBackground.copy(alpha = 0.90f),
1f to DetailBackground,
),
),
@@ -456,7 +461,7 @@ internal fun DetailPageScaffold(
.padding(bottom = 24.dp)
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xEE20252A))
.background(MembySurfaceRaised.copy(alpha = 0.93f))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 20.dp, vertical = 10.dp),
)
@@ -710,7 +715,7 @@ private fun DetailCircularAction(
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
.clip(CircleShape)
.background(if (action.active) DetailAccent else Color(0xB3262B30))
.background(if (action.active) DetailAccent else MembySurfaceRaised.copy(alpha = 0.70f))
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
.semantics { contentDescription = action.description }
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
@@ -954,7 +959,7 @@ private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modi
Column(
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
) {
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(MembySurfaceRaised).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
}
Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
@@ -50,7 +50,6 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailZone
@@ -92,7 +91,7 @@ fun EpisodeDetailsOverlay(
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings)
var episodes by remember(item.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
@@ -97,6 +97,7 @@ 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.PlaylistRemove
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
@@ -130,6 +131,8 @@ import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.Job
@@ -139,10 +142,17 @@ import kotlinx.coroutines.launch
// The launcher's names for the shared tokens. Both surfaces read from one palette now
// (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side,
// so a green or a grey that differs by a shade differs in front of the viewer.
private val EmbyGreen = MembyAccent
private val RailSurface = Color(0xF20C0F12)
private val MutedText = MembyMutedText
private val QuietText = MembyQuietText
// Getters rather than values: the tokens are snapshot state now that the palette is the
// server's answer, and capturing one here would pin the launcher to the theme that happened
// to be loaded when this class initialised.
private val EmbyGreen: Color get() = MembyAccent
// The rail sits over the launcher rather than beside it, so it is the surface at the
// alpha the scrim wants rather than a near-black of its own. A constant here was one of
// the reasons a theme change left most of the screen looking untouched.
private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
private val MutedText: Color get() = MembyMutedText
private val QuietText: Color get() = MembyQuietText
internal val TvRailCollapsedWidth = 54.dp
internal val TvRailExpandedWidth = 184.dp
internal val TvRailContentShift = 112.dp
@@ -405,7 +415,7 @@ fun UserSwitcherOverlay(
.heightIn(max = 400.dp)
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xFF090B0D))
.background(MembySurface)
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
@@ -749,7 +759,7 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
.build()
}
}
Box(modifier.background(Color(0xFF090B0D))) {
Box(modifier.background(MembySurface)) {
if (request != null) {
AsyncImage(
model = request,
@@ -761,18 +771,18 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xFF090B0D),
0.58f to Color(0xE3090B0D),
1f to Color(0xA6090B0D),
0f to MembySurface,
0.58f to MembySurface.copy(alpha = 0.89f),
1f to MembySurface.copy(alpha = 0.65f),
),
),
)
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
0f to Color(0x73090B0D),
0.66f to Color(0xD6090B0D),
1f to Color(0xFF090B0D),
0f to MembySurface.copy(alpha = 0.45f),
0.66f to MembySurface.copy(alpha = 0.84f),
1f to MembySurface,
),
),
)
@@ -852,6 +862,7 @@ fun MediaQuickActionsOverlay(
onOpenDetails: (BaseItem) -> Unit,
onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)? = null,
rowTitle: String? = null,
rowPinned: Boolean = false,
onToggleRowPinned: (() -> Unit)? = null,
@@ -863,7 +874,9 @@ fun MediaQuickActionsOverlay(
onToggleRowPinned != null &&
onHideRow != null &&
onMoveRow != null
val actionCount = if (hasRowActions) 8 else 4
val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0
val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) +
(if (hasRowActions) 4 else 0)
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
var focusedIndex by remember(item.id) { mutableStateOf(0) }
// The menu can appear while OK is still physically held. Until that opening press
@@ -886,7 +899,7 @@ fun MediaQuickActionsOverlay(
.width(352.dp)
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
.clip(RoundedCornerShape(MembyPanelCorner))
.background(Color(0xFF090B0D))
.background(MembySurface)
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
.onPreviewKeyEvent { event ->
val native = event.nativeKeyEvent
@@ -975,6 +988,17 @@ fun MediaQuickActionsOverlay(
onClose()
},
)
if (onRemoveFromContinueWatching != null) {
Spacer(Modifier.height(2.dp))
QuickActionMenuItem(
label = "Remove from Continue Watching",
icon = Icons.Default.PlaylistRemove,
modifier = Modifier
.focusRequester(focusRequesters[3])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
onClick = onRemoveFromContinueWatching,
)
}
if (hasRowActions) {
Spacer(Modifier.height(6.dp))
Box(
@@ -995,32 +1019,32 @@ fun MediaQuickActionsOverlay(
label = if (rowPinned) "Unpin row" else "Pin row to top",
icon = Icons.Default.PushPin,
modifier = Modifier
.focusRequester(focusRequesters[3])
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
.focusRequester(focusRequesters[rowActionStartIndex])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex },
onClick = onToggleRowPinned!!,
)
QuickActionMenuItem(
label = "Move row up",
icon = Icons.Default.ArrowUpward,
modifier = Modifier
.focusRequester(focusRequesters[4])
.onFocusChanged { if (it.isFocused) focusedIndex = 4 },
.focusRequester(focusRequesters[rowActionStartIndex + 1])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 },
onClick = { onMoveRow!!(-1) },
)
QuickActionMenuItem(
label = "Move row down",
icon = Icons.Default.ArrowDownward,
modifier = Modifier
.focusRequester(focusRequesters[5])
.onFocusChanged { if (it.isFocused) focusedIndex = 5 },
.focusRequester(focusRequesters[rowActionStartIndex + 2])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 },
onClick = { onMoveRow!!(1) },
)
QuickActionMenuItem(
label = "Hide this row",
icon = Icons.Default.VisibilityOff,
modifier = Modifier
.focusRequester(focusRequesters[6])
.onFocusChanged { if (it.isFocused) focusedIndex = 6 },
.focusRequester(focusRequesters[rowActionStartIndex + 3])
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 },
onClick = onHideRow!!,
)
}
@@ -1339,6 +1363,7 @@ internal fun MediaRow(
availableWidth: Dp,
navigationFocusRequester: FocusRequester,
contentEntryFocusRequester: FocusRequester?,
heroEntryFocusRequester: FocusRequester? = null,
returnFocusItemId: String?,
returnFocusRequester: FocusRequester,
verticalFocusRequest: RowFocusRequest?,
@@ -1473,6 +1498,12 @@ internal fun MediaRow(
if (contentEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
}
// Where Down out of the home hero lands. It is a second
// requester rather than the entry one because while the hero is
// up, that one belongs to the hero.
if (heroEntryFocusRequester != null) {
cardModifier = cardModifier.focusRequester(heroEntryFocusRequester)
}
}
if (item.id == returnFocusItemId) {
cardModifier = cardModifier.focusRequester(returnFocusRequester)
@@ -1943,7 +1974,7 @@ private fun MediaCard(
shape = RoundedCornerShape(MembyCardCorner),
)
.clip(RoundedCornerShape(MembyCardCorner))
.background(Color(0xFF20252A))
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
@@ -2058,12 +2089,10 @@ private fun MediaCard(
} else {
Spacer(Modifier.height(3.dp))
}
ItemRatingsStrip(
item = item,
load = focused,
compact = true,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
// No ratings strip under a poster. The scores are on the detail page the card
// opens and in the metadata panel beside the focused card; a third copy under
// every poster in every row cost a line of height on cards that are already
// two lines of text tall.
}
}
}
@@ -2079,7 +2108,7 @@ private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifie
}
Text(
text = label,
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
color = if (status == "available") Color(0xFF071008) else MembySurface,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
@@ -2119,8 +2148,8 @@ internal fun LifecycleBadge(status: String, label: String, modifier: Modifier =
// made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
"upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
"incinemas" -> Color(0xFFFFB454) to MembySurface
"ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
}
@@ -0,0 +1,25 @@
package com.ponzischeme89.memby.ui
internal enum class HomeGreetingPeriod(val words: String) {
MORNING("Good morning"),
AFTERNOON("Good afternoon"),
EVENING("Good evening"),
}
internal fun homeGreetingPeriod(hourOfDay: Int): HomeGreetingPeriod = when (hourOfDay) {
in 5..11 -> HomeGreetingPeriod.MORNING
in 12..16 -> HomeGreetingPeriod.AFTERNOON
else -> HomeGreetingPeriod.EVENING
}
/** The greeting lives between leaving the hero and leaving Continue Watching. */
internal fun shouldShowHomeGreeting(
hasHero: Boolean,
focusedRowId: String?,
rowIds: List<String>,
): Boolean {
if (!hasHero || focusedRowId == null) return false
val focusedIndex = rowIds.indexOf(focusedRowId)
val continueIndex = rowIds.indexOf("continue")
return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex
}
@@ -58,6 +58,8 @@ import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import kotlinx.coroutines.delay
import java.util.TimeZone
@@ -97,9 +99,21 @@ private fun currentLocalDay(): Long = System.currentTimeMillis().let { now ->
private const val MIN_DAY_TICK_MS = 1_000L
/** The movie feature owns the header whenever the Home shelves are back at their top. */
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Boolean =
hasMovies && listAtTop
/**
* The movie feature owns the header until a shelf below it is being browsed.
*
* Scroll position alone was not enough. Continue Watching is the first row, so moving down
* into it scrolls nothing the hero stayed while the viewer walked along a row whose cards
* had nowhere to describe themselves, which is the one thing the metadata panel exists for.
* [rowFocused] is what stands the hero down, and pressing Up out of the first row is what
* brings it back; the scroll test remains because a viewer who is somewhere down the
* launcher should not have the hero returned to them by a row losing focus.
*/
internal fun shouldShowHomeMovieHero(
hasMovies: Boolean,
listAtTop: Boolean,
rowFocused: Boolean = false,
): Boolean = hasMovies && listAtTop && !rowFocused
/**
* A hero card, the caption it wears and when the gateway composed it the one line
@@ -236,6 +250,7 @@ internal fun HomeMovieHero(
contentEntryFocusRequester: FocusRequester? = null,
returnFocusItemId: String? = null,
returnFocusRequester: FocusRequester? = null,
downFocusRequester: FocusRequester? = null,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
@@ -250,10 +265,17 @@ internal fun HomeMovieHero(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val featured = movies.first()
// Down is stated rather than left to Compose's spatial search. The hero cards
// are far wider than the cards below them, so the nearest-centre rule reaches
// past the first card of the shelf — Continue Watching would open on the second
// title when the whole point of that row is the first.
var featuredModifier: Modifier = Modifier
.weight(1f)
.fillMaxHeight()
.focusProperties { left = navigationFocusRequester }
.focusProperties {
left = navigationFocusRequester
if (downFocusRequester != null) down = downFocusRequester
}
if (contentEntryFocusRequester != null) {
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
}
@@ -272,8 +294,14 @@ internal fun HomeMovieHero(
modifier = Modifier.width(miniWidth).fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
movies.drop(1).take(3).forEach { pick ->
val minis = movies.drop(1).take(3)
minis.forEachIndexed { index, pick ->
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
// Only the bottom mini leaves the hero by Down; the ones above it are
// still walking their own column.
if (downFocusRequester != null && index == minis.lastIndex) {
miniModifier = miniModifier.focusProperties { down = downFocusRequester }
}
if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
miniModifier = miniModifier.focusRequester(returnFocusRequester)
}
@@ -324,14 +352,14 @@ private fun FeaturedMovieCard(
contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xF20A0D10),
0.48f to Color(0xA80A0D10),
1f to Color(0x160A0D10),
0f to MembySurface.copy(alpha = 0.95f),
0.48f to MembySurface.copy(alpha = 0.66f),
1f to MembySurface.copy(alpha = 0.09f),
),
),
)
@@ -475,15 +503,15 @@ private fun MiniMovieCard(
contentDescription = "${pick.label} movie, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF192027))) {
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
Box(
Modifier.fillMaxSize().background(
Brush.horizontalGradient(
0f to Color(0xE80A0D10),
0.78f to Color(0x850A0D10),
1f to Color(0x300A0D10),
0f to MembySurface.copy(alpha = 0.91f),
0.78f to MembySurface.copy(alpha = 0.52f),
1f to MembySurface.copy(alpha = 0.19f),
),
),
)
@@ -338,10 +338,49 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
}
launch { warmDetailPage(item) }
}
}
}
/**
* The two requests a detail page still opened cold, warmed while the card is focused.
*
* Everything else the page needs is already in hand by the time it opens the item
* record, its "why you might enjoy it" and its playable URL are all warmed above but
* the episode list and the trailer were not, so a series page opened with an empty
* Episodes pane, no progress, no next episode and no estimated finish, and every page
* opened with its trailer button missing until the network answered. Continue Watching
* is the case that matters most: every card on the launcher's busiest row is an
* episode, and all of them want the same show's list.
*
* **It waits longer than the metadata warm does**, and that is the whole cost control.
* An episode list is the largest request the client makes a long-running show is a
* thousand records so warming one per card as somebody scans across a shelf would
* spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves,
* so a viewer travelling along a row never reaches it; one who has stopped on a card,
* which is what precedes a press, does. Both requests are single-flighted and cached on
* the repository, so the press that follows finds the answer rather than a second copy
* of the request.
*/
private suspend fun warmDetailPage(item: BaseItem) {
if (item.isSchedule) return
delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS)
coroutineScope {
// A series is keyed on itself, an episode on the show it belongs to — which is
// exactly what its own detail page will ask for.
val seriesId = when {
item.isSeries -> item.id
item.isEpisode -> item.seriesId
else -> null
}
if (!seriesId.isNullOrBlank()) {
launch { runCatching { repository.getSeriesEpisodes(seriesId) } }
}
launch { runCatching { repository.getLocalTrailer(item.id) } }
}
}
fun setFavorite(item: BaseItem, favorite: Boolean) {
updateFavorite(item, favorite)
viewModelScope.launch(Dispatchers.IO) {
@@ -389,6 +428,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
fun removeFromContinueWatching(item: BaseItem) {
val previous = _state.value
_state.update { state ->
state.copy(
continueWatching = state.continueWatching.filterNot { it.id == item.id },
rows = state.rows.map { row ->
if (row.kind == "continue" || row.kind == "nextup") {
row.copy(items = row.items.filterNot { it.id == item.id })
} else {
row
}
},
)
}
_focusedItem.update { focused -> focused?.takeUnless { it.id == item.id } }
viewModelScope.launch(Dispatchers.IO) {
runCatching { repository.removeFromContinueWatching(item.id) }
.onSuccess { persistCurrentHome() }
.onFailure {
_state.value = previous
_focusedItem.value = item
}
}
}
private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) {
fun BaseItem.updated(): BaseItem =
if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this
@@ -477,6 +541,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
companion object {
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
/**
* How long focus must rest on a card before its detail page is warmed, measured
* from the press that focused it. Deliberately well past
* [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides
* what the panel beside the row says, so it should follow the D-pad closely, while
* this one can be a thousand episode records and should only follow a viewer who
* has stopped. See [warmDetailPage].
*/
private const val DETAIL_PREFETCH_DELAY_MS = 450L
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
@@ -35,10 +35,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -114,7 +112,10 @@ import coil.imageLoader
import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.NightsStay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.WbSunny
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
@@ -139,24 +140,43 @@ import com.ponzischeme89.memby.ui.settings.MembyReleaseHistory
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.settings.SettingsSheet
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewOverlay
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.ui.theme.MembyTheme
import com.ponzischeme89.memby.ui.setup.SignInContent
import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.InstallPermission
import com.ponzischeme89.memby.update.UpdateChecker
import com.ponzischeme89.memby.update.ServerUpdateService
import com.ponzischeme89.memby.update.UpdateStatus
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.Date
import java.util.Calendar
/**
* The followed show a press stands for, as much of it as the card already knows.
*
* The button flips against this rather than against the gateway's answer, which is the
* whole list decorated with Sonarr's lifecycle for every entry on it. Everything Sonarr
* would fill in is left at its default "Not found", "Unknown" because that is honestly
* what this television knows for the moment, and the real row replaces it as soon as the
* save returns. The same promise `scheduleSeriesStub` makes about a detail page.
*/
private fun myShowStub(item: BaseItem): com.ponzischeme89.memby.data.model.MyShow =
com.ponzischeme89.memby.data.model.MyShow(
itemId = item.id,
title = item.name,
year = item.productionYear,
imageTag = item.imageTags["Primary"].orEmpty(),
)
/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */
internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
@@ -345,7 +365,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
}
}
Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) {
Box(Modifier.fillMaxSize().background(MembySurface)) {
val loaded = settings
when {
!initialUpdateCheckComplete -> MembyLoadingScreen(
@@ -399,6 +419,16 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
key(loaded.userId, loaded.serverUrl) {
HomeScreen(settings = loaded)
}
// Snow, bats or blossom for the few days a year a season is on, over the
// launcher and nowhere else. Not over playback — a film is the one thing
// nothing may drift across — and not over the settings sheet, which is a
// page of small text. It is collected here rather than inside HomeScreen so
// an arriving theme cannot invalidate the rows: this is a sibling node, and
// the only thing that recomposes when December starts.
SeasonalDecorations(
decoration = ServiceLocator.themeSync.theme
.collectAsState().value?.decoration.orEmpty(),
)
// Over the launcher, not instead of it: the cached rows are already drawn
// behind this. Composed after HomeScreen so its Back handler and its focus
// request are the ones that win.
@@ -513,7 +543,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
.height(2.dp)
.drawBehind {
drawRoundRect(
color = Color(0xFF52B54B),
color = MembyAccent,
size = Size(size.width * glow, size.height),
cornerRadius = CornerRadius(size.height / 2f),
)
@@ -572,7 +602,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
colors = listOf(
Color(0xFF0A0E11),
Color(0xFF101A17),
Color(0xFF090B0D),
MembySurface,
),
),
),
@@ -585,7 +615,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
.graphicsLayer { alpha = haloAlpha }
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF52B54B), Color.Transparent),
colors = listOf(MembyAccent, Color.Transparent),
),
CircleShape,
),
@@ -647,7 +677,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
scaleY = logoScale
alpha = haloAlpha
}
.background(Color(0xFF52B54B), CircleShape),
.background(MembyAccent, CircleShape),
)
Image(
painter = painterResource(R.drawable.emby_logo),
@@ -821,7 +851,7 @@ private fun ProfileChooser(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF090B0D)),
.background(MembySurface),
) {
Column(
modifier = Modifier
@@ -1107,7 +1137,7 @@ private fun OnboardingPeopleRow(
} else {
Text(person.name.take(1).uppercase(), color = Color(0xFFD5E8D7), fontSize = 46.sp, fontWeight = FontWeight.Light)
}
if (selected) Text("", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(Color(0xFF52B54B), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
if (selected) Text("", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp))
}
Text(person.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
}
@@ -1270,7 +1300,7 @@ private fun ProfileTile(
.background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D))
.border(
width = if (current) 4.dp else if (focused) 3.dp else 1.dp,
color = if (current) Color(0xFF52B54B) else if (focused) Color.White else Color(0xFF5B646C),
color = if (current) MembyAccent else if (focused) Color.White else Color(0xFF5B646C),
shape = CircleShape,
),
contentAlignment = Alignment.Center,
@@ -1351,6 +1381,7 @@ private fun HomeScreen(
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
var quickMenuRowId by remember { mutableStateOf<String?>(null) }
var focusedHomeRowId by remember { mutableStateOf<String?>(null) }
var myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
var removingMyShow by remember { mutableStateOf(false) }
@@ -1390,7 +1421,15 @@ private fun HomeScreen(
val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination)
val contentFocusRequester = remember { FocusRequester() }
val cardReturnFocusRequester = remember { FocusRequester() }
// Down out of the hero, stated rather than left to Compose's spatial search. The
// featured card is wide, so its centre sits nearer the second card of the shelf below
// than the first, and the search obligingly skipped past the thing somebody had just
// been reading about in Continue Watching.
val heroRowEntryFocusRequester = remember { FocusRequester() }
var initialFocusRequested by remember { mutableStateOf(false) }
// Incremented when a rail selection needs to restore a card in the lazy row list.
// The list owns the scroll state, so it also owns the actual restoration below.
var rowListFocusRestoreRequest by remember { mutableStateOf(0) }
val playbackLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) {
@@ -1479,6 +1518,9 @@ private fun HomeScreen(
subtitlesEnabled = playable.subtitlesEnabled,
selectedSubtitleId = playable.selectedSubtitleId,
subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
trickplayAvailable = playable.trickplayAvailable,
skipIntroAvailable = playable.skipIntroAvailable,
endCreditsAvailable = playable.endCreditsAvailable,
mediaSourceId = playable.mediaSourceId,
playSessionId = playable.playSessionId,
playMethod = playable.playMethod,
@@ -1525,7 +1567,14 @@ private fun HomeScreen(
emptyList()
}
}
val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
shouldShowHomeGreeting(
hasHero = homeHeroMovies.isNotEmpty(),
focusedRowId = focusedHomeRowId,
rowIds = rows.map(HomeBrowseRow::id),
)
LaunchedEffect(selectedDestination) {
focusedHomeRowId = null
if (selectedDestination == BrowseDestination.FAVORITES) {
recentSearches = repo.getRecentSearches()
}
@@ -1582,7 +1631,7 @@ private fun HomeScreen(
animationSpec = tween(150),
label = "navigation-content-shift",
)
Box(Modifier.fillMaxSize().background(Color(0xFF090B0D))) {
Box(Modifier.fillMaxSize().background(MembySurface)) {
Row(Modifier.fillMaxSize()) {
TvNavigationRail(
selected = selectedDestination,
@@ -1614,18 +1663,30 @@ private fun HomeScreen(
showSettings = false
restoreRailAfterSettings = false
selectedDestination = destination
destinationFocus[destination]?.let { (rowId, itemId) ->
val savedFocus = destinationFocus[destination]
savedFocus?.let { (rowId, itemId) ->
returnRowId = rowId
returnItemId = itemId
}
scope.launch {
// Let the destination compose and attach its entry target
// before transferring focus out of the rail.
kotlinx.coroutines.delay(16L)
if (destinationFocus.containsKey(destination)) {
runCatching { cardReturnFocusRequester.requestFocus() }
} else {
runCatching { contentFocusRequester.requestFocus() }
if (
savedFocus != null &&
savedFocus.first != HOME_HERO_ROW_ID &&
savedFocus.first != SEARCH_ROW_ID
) {
// A low shelf may have fallen out of LazyColumn composition
// while the rail owned focus. Let the list scroll it back in
// before asking its card for focus.
rowListFocusRestoreRequest += 1
} else {
scope.launch {
// Let the destination compose and attach its entry target
// before transferring focus out of the rail.
kotlinx.coroutines.delay(16L)
if (savedFocus != null) {
runCatching { cardReturnFocusRequester.requestFocus() }
} else {
runCatching { contentFocusRequester.requestFocus() }
}
}
}
}
@@ -1711,9 +1772,14 @@ private fun HomeScreen(
}
}
val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
// Set by a card in a shelf taking focus and cleared by the hero taking it
// back. Keyed on the destination so arriving at Home never inherits where
// focus happened to be on another one.
var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) }
val showHomeHero = shouldShowHomeMovieHero(
hasMovies = hasHomeHero,
listAtTop = homeListAtTop,
rowFocused = rowFocusedBelowHero,
)
val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero)
val contentWidth = maxWidth
@@ -1730,6 +1796,40 @@ private fun HomeScreen(
}
var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) }
val firstPopulatedRowId = remember(rows) {
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
}
val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
if (
selectedDestination == BrowseDestination.FAVORITES &&
recentSearches.isNotEmpty()
) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0
LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) {
if (rowListFocusRestoreRequest == 0) return@LaunchedEffect
val rowId = returnRowId ?: run {
rowListFocusRestoreRequest = 0
return@LaunchedEffect
}
val rowIndex = rows.indexOfFirst { it.id == rowId }
if (rowIndex < 0) {
rowListFocusRestoreRequest = 0
kotlinx.coroutines.delay(16L)
runCatching { contentFocusRequester.requestFocus() }
return@LaunchedEffect
}
// FocusRequester cannot focus a lazy child that is not composed. The
// airing shelf is commonly just beyond the retained viewport, which
// made focus fall back to its still-attached neighbour above.
verticalState.scrollToItem(leadingItemCount + rowIndex)
repeat(3) {
kotlinx.coroutines.delay(16L)
if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) {
rowListFocusRestoreRequest = 0
return@LaunchedEffect
}
}
rowListFocusRestoreRequest = 0
}
Column(Modifier.fillMaxSize()) {
if (showHomeHero) {
HomeMovieHero(
@@ -1738,8 +1838,15 @@ private fun HomeScreen(
contentEntryFocusRequester = contentFocusRequester,
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
returnFocusRequester = cardReturnFocusRequester,
// Only when there is a card down there to attach it to: a
// requester naming nothing throws the moment Down is pressed.
downFocusRequester = heroRowEntryFocusRequester.takeIf {
firstPopulatedRowId != null
},
onItemFocused = { item ->
navigationExpanded = false
rowFocusedBelowHero = false
focusedHomeRowId = null
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
returnRowId = HOME_HERO_ROW_ID
returnItemId = item.id
@@ -1851,7 +1958,10 @@ private fun HomeScreen(
contentEntryFocusRequester = contentFocusRequester.takeIf {
!hasHomeHero &&
(selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) &&
row.id == rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
row.id == firstPopulatedRowId
},
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
hasHomeHero && row.id == firstPopulatedRowId
},
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
returnFocusRequester = cardReturnFocusRequester,
@@ -1869,7 +1979,25 @@ private fun HomeScreen(
itemCounts = rowItemCounts,
currentIndex = sourceRowIndex,
direction = direction,
) ?: return@moveVertical false
) ?: run {
// Up out of the topmost shelf is the way back to the
// hero, and it has to be handled here: the hero is
// not composed while a row holds focus, so there is
// nothing above for Compose's own focus search to
// find and the press would otherwise be dead.
if (direction == RowFocusDirection.UP && hasHomeHero) {
rowFocusedBelowHero = false
scope.launch {
verticalState.animateScrollToItem(0)
// Let the hero compose and attach its entry
// target before focus is handed to it.
kotlinx.coroutines.delay(16L)
runCatching { contentFocusRequester.requestFocus() }
}
return@moveVertical true
}
return@moveVertical false
}
val destinationRow = rows[destinationRowIndex]
val destinationItemIndex = rowEntryItemIndex(
sourceIndex = sourceItemIndex,
@@ -1884,11 +2012,6 @@ private fun HomeScreen(
itemIndex = destinationItemIndex,
requestId = rowFocusRequestId,
)
val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) +
if (
selectedDestination == BrowseDestination.FAVORITES &&
recentSearches.isNotEmpty()
) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0
rowFocusMoving = true
scope.launch {
// Compose the destination row before its MediaRow tries
@@ -1904,6 +2027,10 @@ private fun HomeScreen(
onItemFocused = { item ->
val itemIndex = row.items.indexOfFirst { it.id == item.id }
if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex
rowFocusedBelowHero = true
if (selectedDestination == BrowseDestination.HOME) {
focusedHomeRowId = row.id
}
destinationFocus[selectedDestination] = row.id to item.id
returnRowId = row.id
returnItemId = item.id
@@ -1969,6 +2096,8 @@ private fun HomeScreen(
)
}
HomeClock(
showGreeting = showHomeGreeting,
username = settings.username,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
@@ -2149,20 +2278,33 @@ private fun HomeScreen(
onToggleFavorite = homeViewModel::setFavorite,
isMyShow = myShows.any { it.itemId == selected.id },
onToggleMyShow = { item, saved ->
// Optimistic, the way a favourite already is. Following a show is a
// press with an obvious outcome, and the server's answer to it is the
// *whole* list decorated with Sonarr's lifecycle for every show on it
// — a request the viewer has no reason to sit through watching an
// unchanged button. The row that lands a moment later replaces this
// placeholder; a failure puts the button back where it was.
val previous = myShows
myShows = if (saved) {
myShows.filterNot { it.itemId == item.id } + myShowStub(item)
} else {
myShows.filterNot { it.itemId == item.id }
}
if (saved) {
Toast.makeText(
context,
"${item.name} added to My Shows",
Toast.LENGTH_SHORT,
).show()
}
scope.launch {
if (saved) {
runCatching { repo.saveMyShow(item) }.onSuccess {
myShows = it
Toast.makeText(
context,
"${item.name} added to My Shows",
Toast.LENGTH_SHORT,
).show()
}
runCatching { repo.saveMyShow(item) }
.onSuccess { myShows = it }
.onFailure { myShows = previous }
} else {
runCatching { repo.removeMyShow(item.id) }.onSuccess {
myShows = myShows.filterNot { it.itemId == item.id }
}
runCatching { repo.removeMyShow(item.id) }
.onFailure { myShows = previous }
}
}
},
@@ -2286,6 +2428,30 @@ private fun HomeScreen(
},
onSetFavorite = homeViewModel::setFavorite,
onSetPlayed = homeViewModel::setPlayed,
onRemoveFromContinueWatching = if (
rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE
) {
{
quickMenuItem = null
quickMenuRowId = null
homeViewModel.removeFromContinueWatching(selected)
scope.launch {
kotlinx.coroutines.delay(16L)
val removalFocusRequester = if (
selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
) {
heroRowEntryFocusRequester
} else {
contentFocusRequester
}
if (runCatching { removalFocusRequester.requestFocus() }.isFailure) {
runCatching { navigationFocusRequester.requestFocus() }
}
}
}
} else {
null
},
rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title,
rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(),
onToggleRowPinned = quickMenuRowId?.let { rowId ->
@@ -2484,7 +2650,7 @@ private fun RecentSearchesRow(
maxLines = 1,
modifier = Modifier
.background(
if (focused) Color(0xFF52B54B) else Color(0xFF1A2129),
if (focused) MembyAccent else Color(0xFF1A2129),
)
.padding(horizontal = 17.dp, vertical = 10.dp),
)
@@ -2495,7 +2661,11 @@ private fun RecentSearchesRow(
}
@Composable
private fun HomeClock(modifier: Modifier = Modifier) {
private fun HomeClock(
showGreeting: Boolean,
username: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
var currentTime by remember { mutableStateOf(Date()) }
@@ -2510,16 +2680,58 @@ private fun HomeClock(modifier: Modifier = Modifier) {
}
}
Text(
text = timeFormatter.format(currentTime),
color = Color.White.copy(alpha = 0.86f),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.padding(horizontal = 12.dp, vertical = 7.dp),
val period = homeGreetingPeriod(
Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY),
)
val name = friendlyProfileName(username)
Row(
modifier = modifier,
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
androidx.compose.animation.AnimatedVisibility(
visible = showGreeting && name != null,
enter = androidx.compose.animation.fadeIn(tween(180)),
exit = androidx.compose.animation.fadeOut(tween(120)),
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = when (period) {
HomeGreetingPeriod.MORNING -> Icons.Default.LightMode
HomeGreetingPeriod.AFTERNOON -> Icons.Default.WbSunny
HomeGreetingPeriod.EVENING -> Icons.Default.NightsStay
},
contentDescription = null,
tint = MembyAccent,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = "${period.words}, $name",
color = Color.White.copy(alpha = 0.9f),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
}
}
if (showGreeting && name != null) Spacer(Modifier.width(8.dp))
Text(
text = timeFormatter.format(currentTime),
color = Color.White.copy(alpha = 0.86f),
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xB30A0D10))
.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
}
@Composable
@@ -2661,6 +2873,7 @@ private fun FocusedQuickActionsOverlay(
onOpenDetails: (BaseItem) -> Unit,
onSetFavorite: (BaseItem, Boolean) -> Unit,
onSetPlayed: (BaseItem, Boolean) -> Unit,
onRemoveFromContinueWatching: (() -> Unit)?,
rowTitle: String?,
rowPinned: Boolean,
onToggleRowPinned: (() -> Unit)?,
@@ -2674,6 +2887,7 @@ private fun FocusedQuickActionsOverlay(
onOpenDetails = onOpenDetails,
onSetFavorite = onSetFavorite,
onSetPlayed = onSetPlayed,
onRemoveFromContinueWatching = onRemoveFromContinueWatching,
rowTitle = rowTitle,
rowPinned = rowPinned,
onToggleRowPinned = onToggleRowPinned,
@@ -3058,7 +3272,7 @@ private fun ForYouNudgeBanner(
Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(0xFF52B54B)),
.background(MembyAccent),
)
Spacer(Modifier.width(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
@@ -3151,161 +3365,6 @@ private fun HeaderAction(label: String, onClick: () -> Unit) {
)
}
@Composable
private fun SettingsPanel(settings: Settings, onBack: () -> Unit) {
val context = LocalContext.current
val store = ServiceLocator.settings
val scope = rememberCoroutineScope()
val checker = remember { UpdateChecker(context) }
// Hardware Back returns to the home screen instead of leaving the app.
BackHandler(onBack = onBack)
var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) }
var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) }
var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) }
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var checking by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(null) }
// The system installer reports back here, not to downloadAndInstall's caller.
LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } }
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 56.dp, vertical = 48.dp)
.width(760.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Settings", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold)
// --- Screensaver appearance ---
Text("Screensaver", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Button(
onClick = {
showLogo = !showLogo
scope.launch { store.setShowTitleLogo(showLogo) }
},
modifier = Modifier.focusRequester(firstFocus),
) { Text(if (showLogo) "Show title logo: On" else "Show title logo: Off") }
Text(
"When on, shows each title's logo artwork from Emby instead of plain text " +
"(falls back to text when a title has no logo).",
color = Color(0xFF9AA3AC),
fontSize = 14.sp,
)
Text("Spinner colour", color = Color(0xFF9AA3AC), fontSize = 14.sp)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
listOf(
"White" to "FFFFFF",
"Emby green" to "52B54B",
"Netflix red" to "E50914",
).forEach { (label, hex) ->
val selected = ringColor.equals(hex, ignoreCase = true)
Button(
onClick = {
ringColor = hex
scope.launch { store.setRingColor(hex) }
},
) { Text(if (selected) "$label" else label) }
}
}
// --- Updates ---
Text("Updates", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Text(
"Installed version: ${checker.installedVersion}",
color = Color(0xFFB9C0C7),
fontSize = 16.sp,
)
TvTextField(
label = "Gitea URL (e.g. https://gitea.example.com)",
value = baseUrl,
onValueChange = { baseUrl = it; status = null },
keyboardType = KeyboardType.Uri,
)
TvTextField(
label = "Repository (owner/repo)",
value = repoPath,
onValueChange = { repoPath = it; status = null },
)
TvTextField(
label = "Access token",
value = token,
onValueChange = { token = it; status = null },
isPassword = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Button(
onClick = {
if (checking) return@Button
checking = true
status = null
installMessage = null
scope.launch {
store.setUpdateConfig(baseUrl, repoPath, token)
status = checker.check(baseUrl, repoPath, token)
checking = false
}
},
) { Text(if (checking) "Checking…" else "Check for updates") }
Button(onClick = onBack) { Text("Back") }
}
when (val s = status) {
is UpdateStatus.UpToDate -> Text(
"You're on the latest version (${s.version}).",
color = Color(0xFF7BD88F),
fontSize = 16.sp,
)
is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 16.sp)
is UpdateStatus.Available -> {
Text(
"Update available: ${s.version}",
color = Color(0xFF7BD88F),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
if (s.notes.isNotBlank()) {
Text(s.notes, color = Color(0xFFB9C0C7), fontSize = 14.sp)
}
Button(
onClick = {
installMessage = "Downloading update…"
scope.launch {
val result = checker.downloadAndInstall(s.apkUrl, token)
installMessage = result.exceptionOrNull()?.message
?: "Opening the installer…"
}
},
) { Text("Download & install") }
}
null -> {}
}
installMessage?.let { Text(it, color = Color(0xFFB9C0C7), fontSize = 15.sp) }
Text("About", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
Text(
"${stringResource(R.string.app_name)} ${checker.installedVersion} · by " +
stringResource(R.string.developer_name),
color = Color(0xFF9AA3AC),
fontSize = 14.sp,
)
}
}
@Composable
private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) {
val repo = ServiceLocator.repository
@@ -55,8 +55,9 @@ import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import kotlinx.coroutines.delay
import com.ponzischeme89.memby.ui.theme.MembyAccent
private val MaintenanceAccent = Color(0xFF52B54B)
private val MaintenanceAccent: Color get() = MembyAccent
private val MaintenanceTitle = Color(0xFFF2F5F7)
private val MaintenanceBody = Color(0xFFAEB7BF)
private val MaintenanceFaint = Color(0xFFA2ADB5)
@@ -20,7 +20,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailTab
@@ -53,7 +52,8 @@ fun MediaDetailsOverlay(
onOpenItem: (BaseItem) -> Unit = {},
modifier: Modifier = Modifier,
) {
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
val settings by ServiceLocator.repository.settingsFlow
.collectAsState(initial = ServiceLocator.repository.currentSettings)
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
@@ -43,6 +43,7 @@ import com.ponzischeme89.memby.data.model.MyShow
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
@Composable
internal fun MyShowsStrip(
@@ -147,7 +148,7 @@ private fun MyShowCard(
.aspectRatio(2f / 3f)
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
.clip(RoundedCornerShape(9.dp))
.background(Color(0xFF20252A))
.background(MembySurfaceRaised)
.border(
2.dp,
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
@@ -252,7 +253,7 @@ internal fun MyShowDetailsOverlay(
.width(170.dp)
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF20252A))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -62,7 +62,6 @@ import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.RelatedContent
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.estimateSeriesPace
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
@@ -109,7 +108,7 @@ fun SeriesDetailsOverlay(
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings)
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
@@ -52,8 +52,9 @@ import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.ServiceAlert
import kotlin.math.ceil
import com.ponzischeme89.memby.ui.theme.MembyAccent
private val AlertAccent = Color(0xFF52B54B)
private val AlertAccent: Color get() = MembyAccent
private val AlertTitle = Color(0xFFF2F5F7)
private val AlertBody = Color(0xFFC3CBD2)
@@ -65,8 +65,10 @@ import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurface
private val UpdateAccent = Color(0xFF52B54B)
private val UpdateAccent: Color get() = MembyAccent
private val UpdateTitle = Color(0xFFF2F5F7)
private val UpdateBody = Color(0xFFAEB7BF)
private val UpdateFaint = Color(0xFFA2ADB5)
@@ -175,7 +177,7 @@ fun UpdateScreen(
modifier = modifier
.fillMaxSize()
// Opaque, not a scrim: a required update is not a dialog over usable content.
.background(Color(0xFF0B0E11)),
.background(MembySurface),
) {
Canvas(Modifier.fillMaxSize()) {
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
@@ -0,0 +1,125 @@
package com.ponzischeme89.memby.ui.player
/**
* How fast the closing credits run, and what to do when the stream cannot keep up.
*
* Pure and unit-tested, apart from the player, for the usual reason the rest of this package
* splits that way: the arithmetic is the part that can be wrong in a way nobody would notice
* on a television, and the part a screenshot cannot show.
*
* The fallback is the half worth reading. Doubling the speed doubles the bitrate pulled from
* Emby over HTTP, and a remuxed 4K file on a remote server may simply not sustain it so
* the target is a *ceiling that can fall*, never a speed that is set once and defended. It
* steps down on a stall and never climbs back inside the same credit roll: a stream that
* could not hold 2× thirty seconds ago is not one to keep testing over somebody's picture,
* and a speed that oscillated would be worse than either end of it.
*/
/**
* Where the picture goes while the panel has the right half: a little under half size, shifted
* left by a little under a quarter of the width.
*
* The two are a pair and neither is a round number, which is the point. A clean 0.5 and 0.25
* put the picture's left edge at exactly x=0 mathematically the left half, and on a
* television the first thing overscan cuts. Shrinking it slightly buys an inset at that edge
* *and* clearance against the panel's own padding, so the gap between the credits and the
* words about the next episode is deliberate rather than whatever was left over.
*
* They live here rather than privately in `PlayerActivity` so `EndCreditsScreenshotTest` can
* place its stand-in picture at exactly the transform the activity applies; a capture that
* guessed the split would prove nothing about whether the two halves balance.
*/
const val CREDITS_VIDEO_SCALE = 0.45f
const val CREDITS_VIDEO_SHIFT_X = 0.24f
/** Ordinary speed, and the floor the ceiling falls to. */
const val CREDITS_NORMAL_SPEED = 1.0f
/** What the credits aim for. */
const val CREDITS_TARGET_SPEED = 2.0f
/**
* The ceilings, in the order they are given up.
*
* Three rather than a continuous climbdown: each step has to be held long enough to know
* whether it worked, and a stream that stalls at 1.5× is telling us to stop rather than to
* try 1.4×.
*/
val CREDITS_SPEED_STEPS: List<Float> = listOf(2.0f, 1.5f, CREDITS_NORMAL_SPEED)
/**
* How long the picture takes to reach full speed.
*
* Long enough to hear as a ramp rather than a glitch the music speeding up is most of what
* tells a viewer the player did this deliberately and short enough that it is over before
* anybody reaches for the remote.
*/
const val CREDITS_RAMP_MS = 1_500L
/**
* How many quantisation steps make up 1× so the smallest change sent to the player is
* 0.05×.
*
* Every call rebuilds the audio pipeline's resampler, so a ramp asking for 1.8001× after
* 1.8000× is work for nothing. Quantising also makes the ramp reproducible in a test.
*/
private const val CREDITS_SPEED_STEPS_PER_UNIT = 20f
/**
* The speed at [elapsedMs] into a ramp towards [ceiling].
*
* Eased out rather than linear, the same curve `DecelerateInterpolator` gives every other
* transition in this package: most of the change happens immediately, so the effect reads as
* the picture being let go rather than as a slow drift nobody attributes to anything.
*
* A ceiling at or below normal speed is answered with normal speed at every point, including
* zero a ramp to nowhere must not produce a curve.
*/
fun creditsSpeedAt(elapsedMs: Long, ceiling: Float): Float {
if (ceiling <= CREDITS_NORMAL_SPEED) return CREDITS_NORMAL_SPEED
if (elapsedMs <= 0L) return CREDITS_NORMAL_SPEED
if (elapsedMs >= CREDITS_RAMP_MS) return quantiseSpeed(ceiling)
val progress = elapsedMs.toFloat() / CREDITS_RAMP_MS
val eased = 1f - (1f - progress) * (1f - progress)
return quantiseSpeed(CREDITS_NORMAL_SPEED + (ceiling - CREDITS_NORMAL_SPEED) * eased)
}
/**
* The next ceiling down after the stream failed to hold [ceiling].
*
* Returns [CREDITS_NORMAL_SPEED] once there is nowhere left to fall, which is also the
* signal to stop trying: the caller treats reaching the floor as the end of the ramp rather
* than as another step to schedule.
*/
fun creditsCeilingAfterStall(ceiling: Float): Float {
val next = CREDITS_SPEED_STEPS.firstOrNull { it < ceiling - SPEED_EPSILON }
return next ?: CREDITS_NORMAL_SPEED
}
/** Whether a ceiling still has any speeding up left in it. */
fun creditsSpeedIsActive(ceiling: Float): Boolean = ceiling > CREDITS_NORMAL_SPEED + SPEED_EPSILON
/**
* How a speed reads on the chip in the corner of the panel: "2×", "1.5×".
*
* Built out of whole tenths rather than by formatting the float. A quantised 1.5 is not
* exactly 1.5 in binary, so `toString` on it prints "1.5000001", and `String.format` would
* print a comma for the decimal point on a set configured in half of Europe.
*/
fun creditsSpeedLabel(speed: Float): String {
val tenths = Math.round(speed * 10f)
val whole = tenths / 10
val fraction = tenths % 10
return if (fraction == 0) "$whole×" else "$whole.$fraction×"
}
/**
* Rounds to the nearest step. Written as a multiply-round-divide so that the three speeds
* that matter 1.0, 1.5, 2.0 come back exactly, which is what lets a test assert the ramp
* reaches its ceiling.
*/
private fun quantiseSpeed(speed: Float): Float =
Math.round(speed * CREDITS_SPEED_STEPS_PER_UNIT) / CREDITS_SPEED_STEPS_PER_UNIT
/** Floats compared by threshold, since every one of these has been through a division. */
private const val SPEED_EPSILON = 0.001f
@@ -48,6 +48,7 @@ import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.TimeBar
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
@@ -60,6 +61,7 @@ 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.creditsWorthShowing
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
@@ -92,6 +94,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
/**
@@ -211,6 +214,8 @@ class PlayerActivity : ComponentActivity() {
* screen's Back has.
*/
private var subtitleDownloadExpanded = false
/** Shown once a manually selected subtitle survives a media-item reload. */
private var pendingSubtitleConfirmation: String? = null
private var subtitleOverlay: View? = null
private var castOverlay: View? = null
private var castJob: Job? = null
@@ -262,6 +267,32 @@ class PlayerActivity : ComponentActivity() {
private var skipIntroTaken = false
private var skipIntroDismissed = false
// The closing credits, moved aside and sped up. [creditsStartMs] is where Emby says they
// begin, read from the same chapter lookup the intro markers come out of, so this whole
// feature costs no request of its own.
private var endCreditsAvailable = false
private var creditsView: View? = null
private var creditsCountdown: TextView? = null
private var creditsSpeedChip: TextView? = null
private var creditsStartMs: Long? = null
private var creditsActive = false
/**
* The viewer asked to watch the credits, so nothing offers again for this episode.
*
* Never re-armed within an episode, the way [skipIntroTaken] is not: somebody who pressed
* "Watch credits" and then seeked back into the roll wants the roll, and re-offering
* would shrink their picture again the moment they got there.
*/
private var creditsDismissed = false
private var creditsSpeedJob: Job? = null
/**
* The fastest this stream has been allowed to run. It only ever falls see
* [creditsCeilingAfterStall]. Reset per episode, because the next file may be a
* different bitrate entirely and one that stalled says nothing about the next.
*/
private var creditsSpeedCeiling = CREDITS_TARGET_SPEED
private var creditsEnteredAtMs = 0L
// 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
@@ -287,6 +318,19 @@ class PlayerActivity : ComponentActivity() {
* path of a press: see [TrickplayPreview].
*/
private var trickplayAvailable = false
/**
* The same frames, drawn above the time bar while the viewer scrubs it.
*
* Skipping with the transport hidden and scrubbing with it up are the two ways to move
* through a film, and only one of them used to show where it was going which read as
* the previews having stopped working the moment somebody pressed a button to look at
* the controls. [scrubAnchor] is the bar the strip rides above; both are owned by the
* controller layout, so they go away with it and need no hiding of their own.
*/
private var scrubPreview: ImageView? = null
private var scrubAnchor: View? = null
private var scrubPositionMs = 0L
private val trickplayPreview by lazy {
TrickplayPreview(
scope = lifecycleScope,
@@ -364,6 +408,13 @@ class PlayerActivity : ComponentActivity() {
// Default false, not true: a missing extra must never conjure a section whose
// only row leads to a request the backend cannot answer.
subtitleDownloadAvailable = intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false)
// Same rule, and the same default, for the three that decide whether the previews,
// the skip button and the credits pane are offered at all. On the request form of
// the launch these are corrected by adoptPlayable once the server settles; on this
// form the intent is the only thing that will ever say.
trickplayAvailable = intent.getBooleanExtra(EXTRA_TRICKPLAY, false)
skipIntroAvailable = intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
endCreditsAvailable = intent.getBooleanExtra(EXTRA_END_CREDITS, false)
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
setContentView(R.layout.activity_player)
@@ -383,6 +434,7 @@ class PlayerActivity : ComponentActivity() {
view.findViewById<View>(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener {
showTrackMenu()
}
bindScrubPreview(view)
applyPictureMode()
loadingView = findViewById(R.id.playback_loading)
loadingTitleView = findViewById(R.id.playback_loading_title)
@@ -431,12 +483,20 @@ class PlayerActivity : ComponentActivity() {
override fun onPlaybackStateChanged(playbackState: Int) {
recordPlaybackState(playbackState)
when (playbackState) {
Player.STATE_BUFFERING ->
Player.STATE_BUFFERING -> {
// A stall at double speed is the stream failing to keep up
// with it, so the ceiling falls before the overlay goes up.
stepDownCreditsSpeed()
if (!prerollActive && !seekBuffering) showPlaybackLoading()
}
Player.STATE_READY -> {
trace.mark(PlaybackTrace.READY)
endSeekBuffering()
hidePlaybackError()
pendingSubtitleConfirmation?.let { label ->
pendingSubtitleConfirmation = null
showSubtitleConfirmation(label)
}
if (prerollActive) bindPrerollNow(playback.duration)
if (!prerollActive && renderedFirstFrame) {
hidePlaybackLoading()
@@ -525,6 +585,7 @@ class PlayerActivity : ComponentActivity() {
setUpCastOverlay()
setUpNextUpBanner()
setUpSkipIntro()
setUpEndCredits()
setUpTimeRemainingCue()
setUpSeasonFinaleCue()
setUpPlaybackError()
@@ -631,6 +692,7 @@ class PlayerActivity : ComponentActivity() {
subtitleDownloadAvailable = playable.subtitleDownloadAvailable
trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable
endCreditsAvailable = playable.endCreditsAvailable
subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
@@ -864,6 +926,9 @@ class PlayerActivity : ComponentActivity() {
// away and the markers that describe them are worth nothing until there is a
// picture to skip forward in.
loadIntroSegment()
// Free: the repository has cached one reading of the chapter list, and the credits
// marker is the other half of what the line above just fetched.
loadCreditsStart()
startSkipIntroWatch()
reportStarted(playback.currentPosition)
startProgressReporting()
@@ -1602,10 +1667,84 @@ class PlayerActivity : ComponentActivity() {
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
}
/**
* Wires the strip above the time bar to media3's own scrubbing.
*
* The transport being up is what makes this a second implementation rather than a
* branch of [nudgeSeek]: those presses are the time bar's, and it is the one thing that
* knows where a scrub has reached. So the frames follow *it* [TimeBar.OnScrubListener]
* is the same contract media3's own position text is updated from, which is why the
* strip and the clock beneath it can never disagree about where the viewer is.
*
* Both views belong to the controller layout, so a controller that hides takes the
* strip with it and there is no visibility of ours to keep in step.
*/
@OptIn(UnstableApi::class)
private fun bindScrubPreview(view: PlayerView) {
val preview = view.findViewById<ImageView>(R.id.player_scrub_preview) ?: return
val bar = view.findViewById<View>(androidx.media3.ui.R.id.exo_progress) ?: return
scrubPreview = preview
scrubAnchor = bar
trickplayPreview.bind(preview)
(bar as? TimeBar)?.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) {
scrubPositionMs = position
showScrubPreview(position, forward = true)
}
override fun onScrubMove(timeBar: TimeBar, position: Long) {
val forward = position >= scrubPositionMs
scrubPositionMs = position
showScrubPreview(position, forward)
}
override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) {
// The seek media3 is about to make is the viewer's answer; the frame that
// asked the question has nothing left to say. It goes down here rather than
// on a timer, so it can never outlive the scrub that raised it.
trickplayPreview.hide()
}
})
}
/**
* Draws the frame under the scrubber and slides the strip to sit above it.
*
* The horizontal placement is computed rather than fixed, because a thumbnail parked in
* the middle of the screen while the scrubber is at the far end is a picture of some
* other moment. It is clamped to the bar, so the two ends of a film do not push it off
* the edge where overscan would take it and it is centred on the scrubber otherwise.
*/
private fun showScrubPreview(positionMs: Long, forward: Boolean) {
val preview = scrubPreview ?: return
val bar = scrubAnchor ?: return
val parent = preview.parent as? View ?: return
val durationMs = player?.duration ?: return
if (durationMs <= 0L || durationMs == C.TIME_UNSET) return
val track = bar.width - bar.paddingLeft - bar.paddingRight
val width = preview.width.takeIf { it > 0 } ?: preview.layoutParams?.width ?: 0
if (track > 0 && width > 0) {
// Measured in window coordinates rather than from either view's own left,
// because the bar is two levels down inside the controls column and the strip
// hangs off the controller root: only the window is a frame both agree on.
val barAt = IntArray(2).also(bar::getLocationInWindow)
val parentAt = IntArray(2).also(parent::getLocationInWindow)
val left = barAt[0] - parentAt[0] + bar.paddingLeft
val fraction = (positionMs.toFloat() / durationMs).coerceIn(0f, 1f)
val centre = left + track * fraction - width / 2f
preview.translationX = centre.coerceIn(
left.toFloat(),
(left + track - width).toFloat().coerceAtLeast(left.toFloat()),
)
}
trickplayPreview.show(positionMs, forward, into = preview)
}
private fun seekIntervalMs(): Long =
normalizeSeekIntervalSeconds(
ServiceLocator.settings.current?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS,
@@ -1863,6 +2002,7 @@ class PlayerActivity : ComponentActivity() {
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
creditsView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -2078,7 +2218,27 @@ class PlayerActivity : ComponentActivity() {
val duration = playback.duration
if (duration == C.TIME_UNSET || duration <= 0L) return
// Evaluated first, because whether the pane is up decides what the banner may do.
updateEndCreditsFromPlayhead(playback, next)
val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L)
// The pane says what is on next already, and shrinks the same picture by a different
// amount. Raising the banner over it would fight for the transform and print the
// episode twice, so the countdown moves into the pane instead — and it is only worth
// drawing inside the last minute, where it was always the banner's job.
if (creditsActive) {
creditsCountdown?.apply {
if (remainingMs in 1L..NEXT_UP_LEAD_MS) {
text = getString(R.string.next_up_starting_in, ceil(remainingMs / 1_000.0).toInt())
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
if (remainingMs == 0L) playNext(next)
return
}
when {
remainingMs > NEXT_UP_LEAD_MS -> {
hideNextUp()
@@ -2171,6 +2331,257 @@ class PlayerActivity : ComponentActivity() {
?.start()
}
// --- Closing credits ----------------------------------------------------------------
private fun setUpEndCredits() {
val view = findViewById<View>(R.id.player_end_credits)
creditsView = view
creditsCountdown = view.findViewById(R.id.player_end_credits_countdown)
creditsSpeedChip = view.findViewById(R.id.player_end_credits_speed)
view.findViewById<View>(R.id.player_end_credits_play).setOnClickListener {
nextEpisode?.let(::playNext)
}
view.findViewById<View>(R.id.player_end_credits_dismiss).setOnClickListener {
dismissEndCredits()
}
}
/**
* Reads where this title's credits begin, once playback has settled.
*
* Free, and that is the whole reason it is here: the repository caches one reading of the
* chapter list, so this finds the answer [loadIntroSegment] has already fetched rather
* than making a request of its own. Fetched beside it for the same reason nothing about
* a credit roll is wanted before the first frame.
*
* A film, an episode with no marker, and a server that will not answer are all the same
* answer, and every one of them simply means the credits play out full size.
*/
private fun loadCreditsStart() {
creditsStartMs = null
if (!endCreditsAvailable) return
val id = itemId?.takeIf(String::isNotBlank) ?: return
lifecycleScope.launch {
val start = runCatching { ServiceLocator.repository.creditsStartMs(id) }.getOrNull()
// Auto-advance may have moved on while this was in flight, and the next episode's
// credits are somewhere else entirely.
if (itemId == id) creditsStartMs = start
}
}
/**
* Whether the credits pane should be up, evaluated on the next-up tick.
*
* It rides that loop rather than starting one of its own, and reuses its `nextEpisode`
* guard, which happens to be exactly the right gate: the pane's right-hand half *is* what
* is on next, so a film and the last episode of a series both correctly get nothing. That
* also means the pane inherits auto-play's switch, since a next episode is only resolved
* when auto-play is on deliberate, because this is the auto-advance experience and a
* viewer who turned that off has said they want the credits.
*/
private fun updateEndCreditsFromPlayhead(playback: Player, next: NextEpisode) {
if (creditsDismissed) return
if (!(ServiceLocator.settings.current?.speedUpCredits ?: true)) {
if (creditsActive) leaveEndCredits(restoreSpeed = true)
return
}
val duration = playback.duration
if (!creditsWorthShowing(creditsStartMs, duration)) return
val start = creditsStartMs ?: return
if (playback.currentPosition >= start) {
enterEndCredits(next)
} else if (creditsActive) {
// Seeking back out of the roll puts the picture back, the same way the next-up
// banner and the skip button both retreat when the playhead leaves their window.
leaveEndCredits(restoreSpeed = true)
}
}
private fun enterEndCredits(next: NextEpisode) {
val view = creditsView ?: return
if (creditsActive) return
creditsActive = true
creditsEnteredAtMs = SystemClock.elapsedRealtime()
view.findViewById<TextView>(R.id.player_end_credits_title).text =
next.title.ifBlank { next.seriesName }
val meta = listOfNotNull(
next.episodeCode,
next.seriesName.takeIf { it.isNotBlank() && next.title.isNotBlank() },
).joinToString(" · ")
view.findViewById<TextView>(R.id.player_end_credits_meta).apply {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
view.findViewById<ImageView>(R.id.player_end_credits_image).load(next.imageUrl) {
crossfade(true)
}
creditsCountdown?.visibility = View.GONE
updateCreditsSpeedChip(CREDITS_NORMAL_SPEED)
view.alpha = 0f
view.visibility = View.VISIBLE
view.post {
shrinkVideoForCredits()
view.animate()
.alpha(1f)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
playerView?.hideController()
view.findViewById<View>(R.id.player_end_credits_play).requestFocus()
}
startCreditsSpeedRamp()
}
/**
* Puts the picture and the speed back, without recording a decision.
*
* Separate from [dismissEndCredits] because the two mean different things: this is the
* playhead having left the roll, which may happen again, while dismissing is the viewer
* saying not to offer it for this episode at all.
*/
private fun leaveEndCredits(restoreSpeed: Boolean) {
creditsActive = false
creditsSpeedJob?.cancel()
creditsSpeedJob = null
if (restoreSpeed) restoreNormalSpeed()
val view = creditsView ?: return
if (!view.isVisible) return
view.animate()
.alpha(0f)
.setDuration(NEXT_UP_ANIMATION_MS)
.withEndAction {
view.visibility = View.GONE
view.alpha = 1f
}
.start()
restoreVideoAfterCredits()
}
private fun dismissEndCredits() {
creditsDismissed = true
leaveEndCredits(restoreSpeed = true)
playerView?.requestFocus()
}
/**
* Eases the picture up to speed rather than snapping to it.
*
* A short-lived job of its own rather than a branch of the next-up tick: that runs every
* 250 ms, which over a 1.5 s ramp is six audible steps. The rule itself is pure and lives
* in [creditsSpeedAt] this only supplies the clock and the player.
*/
private fun startCreditsSpeedRamp() {
creditsSpeedJob?.cancel()
if (!creditsSpeedIsActive(creditsSpeedCeiling)) return
creditsSpeedJob = lifecycleScope.launch {
while (isActive && creditsActive) {
val elapsed = SystemClock.elapsedRealtime() - creditsEnteredAtMs
val speed = creditsSpeedAt(elapsed, creditsSpeedCeiling)
applyCreditsSpeed(speed)
if (elapsed >= CREDITS_RAMP_MS) break
delay(CREDITS_RAMP_TICK_MS)
}
}
}
private fun applyCreditsSpeed(speed: Float) {
val playback = player ?: return
if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return
playback.setPlaybackSpeed(speed)
updateCreditsSpeedChip(speed)
}
private fun updateCreditsSpeedChip(speed: Float) {
val chip = creditsSpeedChip ?: return
if (!creditsSpeedIsActive(speed)) {
chip.visibility = View.GONE
return
}
chip.text = getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
chip.visibility = View.VISIBLE
}
private fun restoreNormalSpeed() {
creditsSpeedChip?.visibility = View.GONE
val playback = player ?: return
if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) {
return
}
playback.setPlaybackSpeed(CREDITS_NORMAL_SPEED)
}
/**
* Gives up a step of speed because the stream could not hold this one.
*
* Doubling the speed doubles the bitrate pulled from Emby over HTTP, and a high-bitrate
* file on a remote server may simply not sustain it. Credits that stutter read as a
* broken app, so the ceiling falls and because [creditsCeilingAfterStall] only ever
* goes down never climbs back inside this episode. Reaching normal speed leaves the
* pane up: what is on next is still worth showing, and only the speeding up has failed.
*/
private fun stepDownCreditsSpeed() {
if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return
creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling)
creditsSpeedJob?.cancel()
creditsSpeedJob = null
if (!creditsSpeedIsActive(creditsSpeedCeiling)) {
restoreNormalSpeed()
return
}
// Re-ramp from where the picture already is rather than from 1×, so giving up a step
// is a small slowing rather than a stop and a fresh acceleration.
creditsEnteredAtMs = SystemClock.elapsedRealtime() - CREDITS_RAMP_MS
startCreditsSpeedRamp()
}
private fun shrinkVideoForCredits() {
val view = playerView ?: return
view.animate()
.scaleX(CREDITS_VIDEO_SCALE)
.scaleY(CREDITS_VIDEO_SCALE)
.translationX(-view.width * CREDITS_VIDEO_SHIFT_X)
.translationY(0f)
.setDuration(NEXT_UP_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
}
private fun restoreVideoAfterCredits() {
playerView?.animate()
?.scaleX(1f)
?.scaleY(1f)
?.translationX(0f)
?.translationY(0f)
?.setDuration(NEXT_UP_ANIMATION_MS)
?.setInterpolator(DecelerateInterpolator())
?.start()
}
/**
* Everything about the credits, forgotten. Called when the episode underneath changes:
* every title rolls its credits at a different point, the next one's marker is a fresh
* lookup, and a speed left over from the outgoing episode would run the incoming one's
* opening scene at double speed.
*/
private fun resetEndCredits() {
creditsSpeedJob?.cancel()
creditsSpeedJob = null
creditsActive = false
creditsDismissed = false
creditsStartMs = null
creditsSpeedCeiling = CREDITS_TARGET_SPEED
creditsSpeedChip?.visibility = View.GONE
creditsView?.apply {
animate().cancel()
visibility = View.GONE
alpha = 1f
}
restoreVideoAfterCredits()
restoreNormalSpeed()
}
private fun handlePlaybackEnded() {
when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) {
PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext)
@@ -2258,6 +2669,10 @@ class PlayerActivity : ComponentActivity() {
// 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()
// Nor the outgoing episode's credits marker or its speed: every title rolls them at
// a different point, and a speed left behind would run the next episode's opening
// scene at double speed.
resetEndCredits()
nextEpisode = null
nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime()
@@ -2337,6 +2752,9 @@ class PlayerActivity : ComponentActivity() {
collapseSubtitleDownloads()
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
nextUpBanner?.isVisible == true -> dismissNextUp()
// Back asks for the credits back rather than leaving the film: one press
// per level, the same contract every other overlay here has.
creditsView?.isVisible == true -> dismissEndCredits()
// 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()
@@ -2389,6 +2807,9 @@ class PlayerActivity : ComponentActivity() {
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The pane's Play button holds focus while it is up, and the centre key is how a
// remote presses what it is focused on.
creditsView?.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.
@@ -2653,6 +3074,10 @@ class PlayerActivity : ComponentActivity() {
isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId
}.let { if (it >= 0) it else 0 }
// Nothing but "Off" in the list means the title carries no subtitles, which is a
// thing to be told rather than a shorter menu — see SubtitleTracksState.
val noSubtitles = tracks.size <= 1
val preferences = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key)
bindSubtitleMenu(
@@ -2667,9 +3092,13 @@ class PlayerActivity : ComponentActivity() {
downloads = SubtitleDownloadState(
available = subtitleDownloadAvailable,
status = subtitleDownloadStatus,
entries = subtitleDownloadEntries(),
entries = subtitleDownloadEntries(noSubtitles),
expanded = subtitleDownloadExpanded,
),
tracksState = SubtitleTracksState(
empty = noSubtitles,
downloadable = subtitleDownloadAvailable,
),
onSize = { index ->
preferences.edit().putString(SUBTITLE_SIZE_KEY, SubtitleSize.entries[index].key).apply()
applySubtitleAppearance()
@@ -2695,6 +3124,11 @@ class PlayerActivity : ComponentActivity() {
focusTrack != null -> tracksContainer.getChildAt(focusTrack)
focusSize != null -> sizesContainer.getChildAt(focusSize)
focusDownload != null -> downloadsContainer?.getChildAt(focusDownload)
// With no tracks to choose between, the only useful press on this panel is the
// one that goes looking for some. Landing on "Off" instead would put focus on
// the state the viewer is already in, below a rule they would have to guess to
// travel past.
noSubtitles && subtitleDownloadAvailable -> downloadsContainer?.getChildAt(0)
else -> tracksContainer.getChildAt(selectedTrack)
}
// A redraw can legitimately ask for a row that is gone or disabled — the search row
@@ -2710,17 +3144,23 @@ class PlayerActivity : ComponentActivity() {
* Derived rather than stored, so every redraw of the menu produces the same section
* from the same two fields and the list cannot drift from the candidates behind it.
*/
private fun subtitleDownloadEntries(): List<SubtitleMenuEntry> = buildList {
private fun subtitleDownloadEntries(noSubtitles: Boolean = false): List<SubtitleMenuEntry> = buildList {
add(
SubtitleMenuEntry(
label = getString(
if (subtitleCandidates.isEmpty()) R.string.player_subtitle_search
else R.string.player_subtitle_search_again,
when {
subtitleCandidates.isNotEmpty() -> R.string.player_subtitle_search_again
// The row is the whole point of the panel on a title with none, so
// it says what it is for rather than what it does.
noSubtitles -> R.string.player_subtitle_none_search
else -> R.string.player_subtitle_search
},
),
selected = false,
// Unfocusable while a query is in flight, which is what stops a second
// press starting a second search.
enabled = !subtitleRequestInFlight,
prominent = true,
),
)
subtitleCandidates.forEach { candidate ->
@@ -2825,6 +3265,7 @@ class PlayerActivity : ComponentActivity() {
// nothing ever turns it on.
subtitleAutoSelectionAttempted = false
rememberSubtitleChoice(enabled = true, language = candidate.language)
pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language }
playback.setMediaItem(mediaItem(result.url, result.subtitles), position)
playback.playWhenReady = true
playback.prepare()
@@ -2832,7 +3273,7 @@ class PlayerActivity : ComponentActivity() {
subtitleCandidates = emptyList()
subtitleDownloadStatus = result.message
subtitleDownloadExpanded = false
hideSubtitleOverlay()
dismissSubtitleOverlayAfterSelection()
}
}
@@ -2857,7 +3298,7 @@ class PlayerActivity : ComponentActivity() {
if (subtitleOverlay?.isVisible == true) showSubtitleOverlay(focusDownload = focusDownload)
}
/** Applies the track a row stands for, then redraws the menu on that row. */
/** Applies the track a row stands for, then gets the picker and transport out of the way. */
@OptIn(UnstableApi::class)
private fun selectSubtitleTrack(choice: TrackChoice, index: Int) {
val playback = player ?: return
@@ -2881,7 +3322,12 @@ class PlayerActivity : ComponentActivity() {
encodedSubtitleId = null
subtitleAutoSelectionAttempted = true
reportProgress(playback.currentPosition, !playback.isPlaying, "SubtitleTrackChange")
showSubtitleOverlay(focusTrack = index)
dismissSubtitleOverlayAfterSelection()
if (choice.group == null) {
Toast.makeText(this, R.string.player_subtitle_disabled, Toast.LENGTH_SHORT).show()
} else {
showSubtitleConfirmation(choice.label)
}
}
private fun hideSubtitleOverlay() {
@@ -2889,6 +3335,24 @@ class PlayerActivity : ComponentActivity() {
playerView?.showController()
}
/** A completed choice returns directly to the programme, without reopening transport. */
private fun dismissSubtitleOverlayAfterSelection() {
subtitleOverlay?.visibility = View.GONE
playerView?.hideController()
playerView?.requestFocus()
}
private fun showSubtitleConfirmation(label: String) {
Toast.makeText(
this,
getString(
R.string.player_subtitle_loaded,
label.ifBlank { getString(R.string.player_subtitle_generic) },
),
Toast.LENGTH_SHORT,
).show()
}
private fun subtitleDisplayLabel(subtitle: PlayableSubtitle): String {
val name = subtitle.label?.takeIf(String::isNotBlank)
?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage }
@@ -2906,7 +3370,7 @@ class PlayerActivity : ComponentActivity() {
val id = itemId?.takeIf(String::isNotBlank) ?: return
val index = subtitle.id.toIntOrNull() ?: return
val position = playback.currentPosition.coerceAtLeast(0L)
hideSubtitleOverlay()
dismissSubtitleOverlayAfterSelection()
reportProgress(position, !playback.isPlaying, "SubtitleTrackChange")
showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…")
lifecycleScope.launch {
@@ -2926,6 +3390,7 @@ class PlayerActivity : ComponentActivity() {
subtitlePreference = true
rememberSubtitleChoice(enabled = true, language = subtitle.language)
subtitleAutoSelectionAttempted = true
pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle)
playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position)
playback.playWhenReady = true
playback.prepare()
@@ -3062,6 +3527,8 @@ class PlayerActivity : ComponentActivity() {
castJob?.cancel()
subtitleSearchJob?.cancel()
nextUpJob?.cancel()
creditsSpeedJob?.cancel()
creditsView?.animate()?.cancel()
retryJob?.cancel()
stablePlaybackJob?.cancel()
playbackIdentityHideJob?.cancel()
@@ -3185,6 +3652,17 @@ class PlayerActivity : ComponentActivity() {
private const val EXTRA_SUBTITLES_ENABLED = "extra_subtitles_enabled"
private const val EXTRA_SELECTED_SUBTITLE_ID = "extra_selected_subtitle_id"
private const val EXTRA_SUBTITLE_DOWNLOAD = "extra_subtitle_download"
// The three "is it worth asking the gateway" answers. They must travel with the
// stream on this form of the intent, because it is the form that carries an
// already-resolved [Playable] and therefore never reaches [adoptPlayable] — which
// is the only other place they are set. Omitting them left every cold start and
// every warm-prefetch launch with previews, the skip button and the credits pane
// silently switched off, since each defaults to false and the default is what a
// missing extra yields.
private const val EXTRA_TRICKPLAY = "extra_trickplay_available"
private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available"
private const val EXTRA_END_CREDITS = "extra_end_credits_available"
private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id"
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
private const val EXTRA_PLAY_METHOD = "extra_play_method"
@@ -3247,6 +3725,9 @@ class PlayerActivity : ComponentActivity() {
subtitlesEnabled: Boolean = true,
selectedSubtitleId: String = "",
subtitleDownloadAvailable: Boolean = false,
trickplayAvailable: Boolean = false,
skipIntroAvailable: Boolean = false,
endCreditsAvailable: Boolean = false,
mediaSourceId: String = "",
playSessionId: String = "",
playMethod: String = "DirectPlay",
@@ -3268,6 +3749,9 @@ class PlayerActivity : ComponentActivity() {
putExtra(EXTRA_SUBTITLES_ENABLED, subtitlesEnabled)
putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
putExtra(EXTRA_SUBTITLE_DOWNLOAD, subtitleDownloadAvailable)
putExtra(EXTRA_TRICKPLAY, trickplayAvailable)
putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable)
putExtra(EXTRA_END_CREDITS, endCreditsAvailable)
putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId)
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
putExtra(EXTRA_PLAY_METHOD, playMethod)
@@ -3371,6 +3855,16 @@ 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 speed ramp advances. 60 ms over [CREDITS_RAMP_MS] is 25 steps, which
* is heard as an acceleration rather than as a handful of jumps the next-up tick's
* 250 ms would have given six.
*/
private const val CREDITS_RAMP_TICK_MS = 60L
/** Floats compared by threshold, so an unchanged speed is never re-sent. */
private const val CREDITS_SPEED_EPSILON = 0.001f
/**
* 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
@@ -21,6 +21,7 @@ data class SubtitleMenuEntry(
val label: String,
val selected: Boolean,
val enabled: Boolean = true,
val prominent: Boolean = false,
)
/**
@@ -46,6 +47,24 @@ data class SubtitleDownloadState(
val expanded: Boolean = false,
)
/**
* What the track half of the panel says when the title carries no subtitles at all.
*
* It is a state rather than an absence because the two cases read completely differently
* to somebody in front of a television. A list holding nothing but "Off" is indistinguishable
* from a menu that failed to load and the row that can do something about it sits below a
* rule, under a heading the eye has no reason to travel to. Saying it in the track section
* is what connects the two.
*
* [downloadable] is the difference between a sentence that leads somewhere and one that is
* simply the answer: on a backend with no provider there is nothing to offer, and the honest
* thing is to say so once rather than to leave the viewer looking for the option.
*/
data class SubtitleTracksState(
val empty: Boolean = false,
val downloadable: Boolean = false,
)
/**
* Fills the drop-up's containers from plain lists.
*
@@ -60,6 +79,7 @@ fun bindSubtitleMenu(
tracks: List<SubtitleMenuEntry>,
sizes: List<SubtitleMenuEntry>,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
tracksState: SubtitleTracksState = SubtitleTracksState(),
onTrack: (Int) -> Unit = {},
onSize: (Int) -> Unit = {},
onDownload: (Int) -> Unit = {},
@@ -69,6 +89,13 @@ fun bindSubtitleMenu(
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
trackContainer.removeAllViews()
sizeContainer.removeAllViews()
overlay.findViewById<TextView>(R.id.player_subtitle_empty_notice)?.apply {
isVisible = tracksState.empty
setText(
if (tracksState.downloadable) R.string.player_subtitle_none
else R.string.player_subtitle_none_unavailable,
)
}
tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
}
@@ -122,8 +149,15 @@ private fun subtitleMenuOption(
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
background = context.getDrawable(R.drawable.player_overlay_option_background)
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
background = context.getDrawable(
if (entry.prominent) R.drawable.next_up_primary_button
else R.drawable.player_overlay_option_background,
)
if (entry.prominent) {
setTextColor(context.getColorStateList(R.color.player_overlay_primary_option_text))
} else {
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
}
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0)
text = entry.label
@@ -26,6 +26,12 @@ import kotlinx.coroutines.withContext
* *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.
*
* There are two places a frame is drawn, because there are two ways to seek: the centred
* chip a press of Left or Right raises with the transport hidden, and the strip above the
* time bar when the controls are up and the viewer is scrubbing it. They share one instance
* deliberately one layout fetched per title and one cache of frames between them, since a
* viewer who skips and then opens the controls is travelling through the same film.
*/
class TrickplayPreview(
private val scope: CoroutineScope,
@@ -34,7 +40,8 @@ class TrickplayPreview(
/** One thumbnail's JPEG bytes, or null. */
private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
) {
private var view: ImageView? = null
private val views = mutableListOf<ImageView>()
private var shownIn: ImageView? = null
private var itemId: String? = null
private var track: Trickplay? = null
private var trackJob: Job? = null
@@ -54,12 +61,18 @@ class TrickplayPreview(
size > FRAME_CACHE_SIZE
}
/** Attaches the view once the seek indicator has been inflated. */
/**
* Attaches a surface once the view holding it has been inflated. Called once for the
* seek chip and once for the scrubbing strip; either may be absent.
*/
fun bind(preview: ImageView) {
view = preview
applyAspect()
if (views.none { it === preview }) views += preview
applyAspect(preview)
}
/** True when there is a layout to draw from, so a caller can leave its own chrome down. */
fun hasFrames(): Boolean = track != null
/**
* 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.
@@ -85,12 +98,15 @@ class TrickplayPreview(
* [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.
*
* [into] names which of the bound surfaces is asking. The other is taken down rather
* than left holding a frame from the last thing that used it.
*/
fun show(positionMs: Long, forward: Boolean) {
fun show(positionMs: Long, forward: Boolean, into: ImageView? = views.firstOrNull()) {
val current = track ?: return
val preview = view ?: return
val preview = into ?: return
val frame = current.frameAt(positionMs)
if (frame == shownFrame && preview.isVisible) return
if (frame == shownFrame && preview === shownIn && 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
@@ -103,9 +119,11 @@ class TrickplayPreview(
?: return@launch
val bitmap = decode(bytes) ?: return@launch
shownFrame = frame
applyAspect()
shownIn = preview
applyAspect(preview)
preview.setImageBitmap(bitmap)
preview.visibility = View.VISIBLE
views.forEach { if (it !== preview) clear(it) }
warm(current, frame + if (forward) 1 else -1)
}
}
@@ -115,10 +133,13 @@ class TrickplayPreview(
frameJob?.cancel()
frameJob = null
shownFrame = NO_FRAME
view?.let {
it.visibility = View.GONE
it.setImageDrawable(null)
}
shownIn = null
views.forEach(::clear)
}
private fun clear(preview: ImageView) {
preview.visibility = View.GONE
preview.setImageDrawable(null)
}
/**
@@ -153,8 +174,7 @@ class TrickplayPreview(
* 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
private fun applyAspect(preview: ImageView) {
val current = track ?: return
val params = preview.layoutParams ?: return
val width = current.widthFor(params.height)
@@ -621,10 +621,7 @@ private fun Slideshow(
)
if (settingsOpen) {
SettingsSheet(
onClose = { settingsOpen = false },
onInstallerLaunched = onExit,
)
SettingsSheet(onClose = { settingsOpen = false })
}
}
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
@@ -38,6 +39,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Add
@@ -58,6 +60,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -87,6 +90,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import kotlinx.coroutines.flow.distinctUntilChanged
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
@@ -94,15 +98,18 @@ import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembySurface
private val PaneBackground = Color(0xFF0C1014)
private val PaneBackground: Color get() = MembySurfaceRaised
private val KeyIdle = Color(0xFF1A2129)
private val KeyFocused = Color(0xFF52B54B)
private val KeyFocused: Color get() = MembyAccent
private val KeyLabel = Color(0xFFE8EDF1)
private val KeyLabelFocused = Color(0xFF06240A)
private val Heading = Color(0xFFF2F5F7)
private val Muted = Color(0xFFB7C0C8)
private val Accent = Color(0xFF52B54B)
private val Accent: Color get() = MembyAccent
/**
* Six columns of six, then a row of actions. Rectangular on purpose: every key has a
@@ -124,6 +131,15 @@ private const val KEYBOARD_PANE_FRACTION = 0.35f
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
private const val PREFETCHED_POSTERS = 8
/**
* How far from the end of a genre the next page is asked for, in rows.
*
* Two rows rather than one: a D-pad walks a row at a time and the request has to be in
* flight before the viewer arrives at the bottom, or the scroll stops dead and the shelf
* reads as having ended.
*/
private const val LOAD_MORE_ROWS_AHEAD = 2
/**
* Full-screen search: keyboard on the left, results on the right, updating as you type.
*
@@ -169,6 +185,7 @@ fun SearchScreen(
val keyboardReturn = remember { FocusRequester() }
var lastKeyIndex by remember { mutableIntStateOf(0) }
var focusInResults by remember { mutableStateOf(false) }
var restoreGenreChipFocus by remember { mutableStateOf(false) }
val hasResultsTarget = when {
state.errorMessage != null && state.results.isEmpty() -> true
state.isDiscovery -> discoveryItems.isNotEmpty() ||
@@ -178,8 +195,42 @@ fun SearchScreen(
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
// A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with
// it — so the focus it was holding belongs to nothing unless something takes it. The
// grid claims it as soon as there is a card to land on, which is where the viewer is
// already looking; a genre that came back empty or failed hands the remote back to the
// keyboard rather than leaving a television with nothing focused at all.
LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) {
if (state.genre == null) return@LaunchedEffect
when {
state.results.isNotEmpty() || state.errorMessage != null ->
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
!state.isLoading -> runCatching { keyboardReturn.requestFocus() }
// Still loading: the branch above takes it the moment the first page lands.
else -> Unit
}
}
LaunchedEffect(state.genre, restoreGenreChipFocus) {
if (state.genre == null && restoreGenreChipFocus) {
// clearGenre remounts discovery and its genre chips. Wait for the first chip's
// focus node before handing the remote back to where this level was opened.
kotlinx.coroutines.delay(16L)
if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
restoreGenreChipFocus = false
}
}
val closeGenre: () -> Unit = {
restoreGenreChipFocus = true
viewModel.clearGenre()
}
BackHandler {
when {
// A genre is its own level, between discovery and leaving Search. Close that
// level first regardless of which card currently owns focus.
state.genre != null -> closeGenre()
// Results → keyboard → clear → leave. Each press does one obvious thing, and
// none of them can loop back to the previous state.
focusInResults -> runCatching { keyboardReturn.requestFocus() }
@@ -246,6 +297,9 @@ fun SearchScreen(
onRetry = viewModel::retry,
onRequest = viewModel::request,
onSuggestionSelected = viewModel::onQueryChanged,
onGenreSelected = viewModel::onGenreSelected,
onBackFromGenre = closeGenre,
onLoadMore = viewModel::loadMore,
modifier = Modifier.fillMaxHeight(),
)
}
@@ -598,6 +652,9 @@ private fun ResultsPane(
onRetry: () -> Unit,
onRequest: (GatewayRequestCandidate) -> Unit,
onSuggestionSelected: (String) -> Unit,
onGenreSelected: (String) -> Unit,
onBackFromGenre: () -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
@@ -615,7 +672,11 @@ private fun ResultsPane(
val items = if (showingDiscovery) discoveryItems else state.results
Column(Modifier.fillMaxSize()) {
ResultsHeading(state = state, showingDiscovery = showingDiscovery)
ResultsHeading(
state = state,
showingDiscovery = showingDiscovery,
onBackFromGenre = onBackFromGenre,
)
val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
if (showingDiscovery && genres.isNotEmpty()) {
@@ -624,7 +685,10 @@ private fun ResultsPane(
genres = genres,
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onSelected = onSuggestionSelected,
// Not onSuggestionSelected: a genre opens the shelf of titles that are
// in it, where running its name through search matched a film called
// Drama and missed most of the drama.
onSelected = onGenreSelected,
)
}
if (showingDiscovery && recent.isNotEmpty()) {
@@ -672,6 +736,12 @@ private fun ResultsPane(
returnFocusRequester = returnFocusRequester,
onItemFocused = onItemFocused,
onItemSelected = onItemSelected,
// Only a genre pages. A search is one response, and asking the grid to
// watch for the end of a list that has no more behind it is a scroll
// listener running for nothing.
paging = state.genre != null && (state.canLoadMore || state.isLoadingMore),
loadingMore = state.isLoadingMore,
onLoadMore = onLoadMore,
)
}
}
@@ -679,13 +749,43 @@ private fun ResultsPane(
}
@Composable
private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
private fun ResultsHeading(
state: SearchUiState,
showingDiscovery: Boolean,
onBackFromGenre: () -> Unit,
) {
val title = when {
showingDiscovery -> "Browse your library"
// A genre says what it is. It was never searched for, so calling it a search result
// would misdescribe both where the titles came from and how to get out of it.
state.genre != null -> state.genre
state.isEmptyResult -> "Search results — no matches"
else -> "Search results for “${state.query.trim()}"
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (state.genre != null) {
FocusScaleContainer(
onFocused = {},
onClick = onBackFromGenre,
contentDescription = "Back to search",
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)),
) { focused ->
Box(
modifier = Modifier
.fillMaxSize()
.background(if (focused) Color.White else KeyIdle),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = if (focused) KeyLabelFocused else Heading,
modifier = Modifier.size(21.dp),
)
}
}
Spacer(Modifier.width(12.dp))
}
Text(
title,
color = Heading,
@@ -695,7 +795,10 @@ private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (!showingDiscovery && state.results.isNotEmpty()) {
// The count belongs to a search, where it is the whole answer. On a genre it would
// be the number of cards fetched so far, which grows as the viewer scrolls and
// describes the paging rather than the library.
if (state.genre == null && !showingDiscovery && state.results.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
}
@@ -713,11 +816,29 @@ private fun ResultsGrid(
returnFocusRequester: FocusRequester,
onItemFocused: (BaseItem) -> Unit,
onItemSelected: (BaseItem) -> Unit,
paging: Boolean = false,
loadingMore: Boolean = false,
onLoadMore: () -> Unit = {},
) {
val context = LocalContext.current
val density = LocalDensity.current
val gridState = rememberLazyGridState()
// Infinite scroll. Read in a snapshotFlow rather than from the composable body: the
// last visible index changes on every frame of a scroll, and reading it up here would
// recompose the whole grid the entire way down a genre. The next page is asked for a
// row early — the request has to be in flight before the viewer arrives at the end, or
// the scroll stops dead while they wait for it.
if (paging) {
LaunchedEffect(gridState, items.size, columns) {
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
.distinctUntilChanged()
.collect { last ->
if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) onLoadMore()
}
}
}
// Warm the first screenful so the grid does not fill in card by card. Keyed on the
// ids rather than the list, so an unchanged result set never re-fetches.
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
@@ -770,6 +891,21 @@ private fun ResultsGrid(
},
)
}
if (loadingMore) {
// A full-width row rather than a card-shaped placeholder: a skeleton card is
// something a remote tries to focus, and there is nothing there to open.
item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging") {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
LoadingDot()
Spacer(Modifier.width(10.dp))
Text("Loading more", color = Muted, fontSize = 13.sp)
}
}
}
}
}
@@ -990,7 +1126,7 @@ internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF080B0E))
.background(MembySurface)
.padding(28.dp),
) {
RequestOptions(
@@ -1133,6 +1269,10 @@ private fun SuggestionChips(
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
val message = when {
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
// A genre was never searched for, and saying so while a shelf opens is the one
// moment the difference is invisible on screen.
state.genre != null && state.isLoading -> "Loading ${state.genre}"
state.genre != null -> "Nothing in this library is tagged ${state.genre}."
state.isLoading -> "Searching…"
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
@@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE
import com.ponzischeme89.memby.data.friendlyEmbyError
import com.ponzischeme89.memby.data.hasMoreGenreItems
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -31,6 +34,20 @@ data class SearchSuggestion(val label: String, val kind: Kind) {
data class SearchUiState(
val query: String = "",
val results: List<BaseItem> = emptyList(),
/**
* The genre being browsed, or null when this pane is showing a search.
*
* It is a *mode*, not a query: nothing is typed, the keyboard is untouched, and the
* results are a filtered shelf rather than matches for a word. Keeping it beside the
* query rather than pretending to be one is what lets the pane say "Comedy" instead of
* "Search results for “Comedy”", and what lets Back step out of the genre without
* clearing something the viewer never typed.
*/
val genre: String? = null,
/** A further page is on its way. The grid keeps what it has and adds a footer. */
val isLoadingMore: Boolean = false,
/** There is more of this genre to ask for. See [hasMoreGenreItems]. */
val canLoadMore: Boolean = false,
val suggestions: List<SearchSuggestion> = emptyList(),
val isLoading: Boolean = false,
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
@@ -46,9 +63,9 @@ data class SearchUiState(
val isEmptyResult: Boolean
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
/** Nothing typed yet: the pane shows discovery rather than results. */
/** Nothing typed and no genre open: the pane shows discovery rather than results. */
val isDiscovery: Boolean
get() = !shouldSearch(query)
get() = genre == null && !shouldSearch(query)
}
/**
@@ -80,6 +97,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
private val recentQueries = ArrayDeque<String>()
private var genreSuggestions: List<String> = emptyList()
/**
* The page in flight, so a viewer who leaves a genre or scrolls a second page while
* the first is still coming is never overtaken by an answer to a question they have
* moved on from. It is the genre shelf's equivalent of the search pipeline's
* `collectLatest`.
*/
private var genrePageJob: Job? = null
init {
viewModelScope.launch {
queryFlow
@@ -105,10 +130,17 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
fun onQueryChanged(query: String) {
// Typing supersedes a genre. Somebody who reaches for the keyboard while a shelf is
// open is asking for something else, and a pane headed "Comedy" listing matches for
// what they are typing would be a lie about where those results came from.
genrePageJob?.cancel()
// The visible field updates immediately; only the *search* is debounced.
_state.update {
it.copy(
query = query,
genre = null,
isLoadingMore = false,
canLoadMore = false,
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
@@ -125,20 +157,78 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
fun clearQuery() {
// Clear immediately so results from a genre tile cannot remain visible while the
// debounced empty-query transition is pending.
genrePageJob?.cancel()
_state.update {
it.copy(
query = "", results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
requestMessageIsError = false, genre = null,
isLoadingMore = false, canLoadMore = false,
)
}
queryFlow.value = ""
}
/**
* A genre chip was pressed. This is a filter, not a query: nothing is typed and nothing
* is debounced the viewer made one deliberate choice and the shelf opens on it.
*/
fun onGenreSelected(genre: String) {
val name = genre.trim()
if (name.isEmpty()) return
genrePageJob?.cancel()
_state.update {
it.copy(
query = "", genre = name, results = emptyList(), isLoading = true,
hasSearched = false, errorMessage = null, isLoadingMore = false,
canLoadMore = false, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
// The typed query is dropped along with it, and the flow is told so a stale term
// cannot arrive from the debounce and overwrite the shelf that is opening.
queryFlow.value = ""
genrePageJob = viewModelScope.launch { loadGenrePage(name, offset = 0) }
}
/** Back out of a genre, to the discovery pane the chip was pressed on. */
fun clearGenre() {
genrePageJob?.cancel()
_state.update {
it.copy(
genre = null, results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, isLoadingMore = false, canLoadMore = false,
)
}
}
/**
* The grid is near the end of what it holds. Ignored unless there is a genre open, more
* of it to fetch and nothing already in flight the grid asks on every scroll, and it
* is cheaper to refuse here than to make the screen keep track.
*/
fun loadMore() {
val current = state.value
val genre = current.genre ?: return
if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return
_state.update { it.copy(isLoadingMore = true) }
genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) }
}
/** Retry after an error, without disturbing the query or the keyboard. */
fun retry() {
val term = state.value.query.trim()
val current = state.value
current.genre?.let { genre ->
genrePageJob?.cancel()
_state.update { it.copy(isLoading = true, errorMessage = null) }
genrePageJob = viewModelScope.launch {
loadGenrePage(genre, offset = current.results.size)
}
return
}
val term = current.query.trim()
if (!shouldSearch(term)) return
cache.remove(term)
viewModelScope.launch { runSearch(term) }
@@ -199,7 +289,58 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
}
}
/**
* One page of a genre, appended to whatever the shelf already holds.
*
* Appending by [GenrePage.offset] rather than trusting the order of arrival is what
* makes a slow page harmless: only a page that starts where the shelf currently ends is
* taken, so a response for an offset the viewer has already scrolled past or one from
* a genre they have left is dropped rather than pasted into the middle of the grid.
*/
private suspend fun loadGenrePage(genre: String, offset: Int) {
runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) }
.onSuccess { page ->
_state.update { current ->
if (current.genre != genre || current.results.size != page.offset) return@update current
val items = current.results + page.items
current.copy(
results = items,
isLoading = false,
isLoadingMore = false,
hasSearched = true,
errorMessage = null,
canLoadMore = hasMoreGenreItems(
loaded = items.size,
total = page.total,
lastPageSize = page.items.size,
pageSize = GENRE_PAGE_SIZE,
),
)
}
}
.onFailure { error ->
if (error is kotlinx.coroutines.CancellationException) throw error
_state.update { current ->
if (current.genre != genre) return@update current
current.copy(
isLoading = false,
isLoadingMore = false,
hasSearched = true,
// A page that failed part way down a shelf keeps what is already
// there and simply stops: the viewer has plenty on screen, and
// replacing it with an error would take away what was working.
errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null,
canLoadMore = false,
)
}
}
}
private suspend fun runSearch(term: String) {
// A genre shelf is not a query, so the empty-query transition has nothing to say
// about it. Without this, opening a genre — which clears the query — would arrive
// here a moment later and wipe the shelf it had just filled.
if (state.value.genre != null && !shouldSearch(term)) return
if (!shouldSearch(term)) {
// Back to the discovery state, but the previous results are dropped rather
// than left behind a shorter query they no longer match.
@@ -0,0 +1,358 @@
package com.ponzischeme89.memby.ui.seasonal
import android.provider.Settings as AndroidSettings
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/**
* Snow, bats and blossom, drifting over the launcher for the few days a year a seasonal
* theme is on.
*
* A palette on its own is a thin idea of Christmas the colours change and nothing about
* the television says why. This is the half that does. It is also, by a distance, the most
* expensive thing in the app: the only animation that runs continuously while somebody is
* simply browsing, on boxes that struggle with the launcher as it is. Everything below is
* shaped by that.
*
* **Nothing here recomposes.** The repo rule (see CLAUDE.md, "Animations must not
* recompose") is the whole design: one `Canvas`, one animated `State<Float>` that is never
* read in a composable body, and every particle's position derived arithmetically from it
* inside the draw lambda. So a full field of decorations costs *zero* recompositions and one
* draw pass a scope that already redraws whenever the launcher does.
*
* **The field is a pure function of one number**, which is what makes it both cheap and
* testable: [drawSeasonalField] takes a progress in `0..1` and draws exactly one frame, so a
* screenshot test can render the moment without an animation clock. There is no per-particle
* state, no list to allocate and nothing to keep in step a particle is `index` and
* `progress` and nothing else.
*
* **Everything wraps seamlessly.** Each particle's cycle counts are whole numbers, so at the
* instant the driving value rolls 1 0 every position, sway and rotation is exactly where
* it was. Without that the entire field would visibly jump once a minute, which is worse
* than no animation at all.
*/
/** What the gateway asks for. An unknown slug draws nothing; see [drawSeasonalField]. */
object Decorations {
const val SNOW = "snow"
const val BATS = "bats"
const val BLOSSOM = "blossom"
}
/**
* How many of the things are on screen at once.
*
* Deliberately low. This is decoration behind a launcher somebody is trying to read, and the
* failure mode is not "too few" it is a set that drops frames while scrolling a row, which
* nobody would connect to Christmas. Twenty-two is enough to read as weather at 1080p and
* cheap enough to draw in a handful of paths.
*/
private const val ParticleCount = 26
/** One full cycle of the field, in milliseconds. Long, because this is meant to be barely noticed. */
private const val CycleMillis = 48_000
/**
* The decoration layer for the launcher.
*
* Never focusable, never clickable, and drawn with no scrim of its own it sits over
* artwork somebody is choosing from, so anything that dimmed the page to make the snow read
* better would have the priority exactly backwards.
*
* Call it with the gateway's slug. Empty, unknown, or a platform with animations turned off
* all produce nothing at all not an empty Canvas, but no node, so there is nothing in the
* tree for the other 51 weeks of the year.
*/
@Composable
fun SeasonalDecorations(decoration: String, modifier: Modifier = Modifier) {
if (!hasField(decoration)) return
// Somebody who has turned animations off at the platform level has said something about
// every animation on the device, and this is the least important one on it. Honoured
// here rather than exposed as a Memby setting, because a seasonal theme is deliberately
// not the viewer's to decline — but an accessibility choice is not a preference, and
// "no animations" has to mean no animations.
val context = LocalContext.current
val animationsOn = remember(context) {
AndroidSettings.Global.getFloat(
context.contentResolver,
AndroidSettings.Global.ANIMATOR_DURATION_SCALE,
1f,
) > 0f
}
if (!animationsOn) return
val progress = rememberInfiniteTransition(label = "seasonal").animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(CycleMillis, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "drift",
)
// The accent and the body colour are read here, in the composable, on purpose: they are
// palette state, and reading them in composition means a theme change recomposes this
// one node rather than being missed. The *animated* value is the one that must not be
// read here, and is not — `progress` is passed down as State and unwrapped in the draw
// lambda below.
val accent = MembyAccent
val body = MembyOnSurface
Canvas(modifier = modifier.fillMaxSize().testTag(DecorationTestTag)) {
drawSeasonalField(decoration, progress.value, accent, body)
}
}
/** So a screenshot test can find the layer without reaching into the drawing. */
const val DecorationTestTag = "seasonal-decorations"
/** Whether [decoration] is one this build knows how to draw. */
fun hasField(decoration: String): Boolean = when (decoration) {
Decorations.SNOW, Decorations.BATS, Decorations.BLOSSOM -> true
else -> false
}
/**
* One frame of the field, at [progress] through a cycle.
*
* Pure with respect to everything except the canvas: the same progress always draws the same
* picture, which is what the screenshot test relies on and what lets the animation be
* verified by looking at three still images rather than by watching a television.
*/
fun DrawScope.drawSeasonalField(
decoration: String,
progress: Float,
accent: Color,
body: Color,
) {
if (!hasField(decoration)) return
for (index in 0 until ParticleCount) {
drawParticle(decoration, index, progress, accent, body)
}
}
private fun DrawScope.drawParticle(
decoration: String,
index: Int,
progress: Float,
accent: Color,
body: Color,
) {
// Everything about a particle comes out of its index through these two hashes. A stored
// array of particles would be the obvious shape and is the wrong one here: it is state to
// allocate, keep and re-seed on every configuration change, in exchange for randomness
// nobody can tell from this.
val a = noise(index * 2)
val b = noise(index * 2 + 1)
// **Stratified, not random.** The hash alone put visible bands and a bare patch through
// the middle of the screen — twenty-two samples is far too few for a hash to look evenly
// spread, and the eye finds a clump in a snowfield immediately. So each particle owns a
// slice of the axis and the hash only jitters it within that slice, which is what makes
// this read as weather rather than as a handful of dots.
val lane = (index + 0.5f) / ParticleCount
val jitter = (a - 0.5f) / ParticleCount
// Whole numbers, so the field is exactly where it started when progress rolls over.
val speed = 2 + index % 4
val swayCycles = 1 + index % 3
val scale = 0.82f + b * 0.55f
// Faint on purpose, and this is the number that was tuned by looking rather than
// reasoned about. The layer sits *over* the launcher — it has to, since the surface
// beneath it is opaque — so every so often a flake lands on the Play button. At this
// alpha that reads as snow passing in front of the screen; a few points higher and it
// reads as something wrong with the rendering. See `decoration-over-content.png`, which
// exists for exactly this judgement.
val alpha = 0.17f + a * 0.18f
val sway = sin((progress * swayCycles + a) * 2f * PI.toFloat())
when (decoration) {
Decorations.SNOW -> {
val x = (lane + jitter + sway * 0.035f) * size.width
val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
place(x, y, (progress * (1 + index % 2) + a) * 360f) {
drawSnowflake(14f * scale, body.copy(alpha = alpha))
}
}
Decorations.BLOSSOM -> {
val x = (lane + jitter + sway * 0.06f) * size.width
val y = wrap(b * 0.4f + lane + progress * speed) * (size.height + 140f) - 70f
// Blossom tumbles rather than spinning flat, so it turns faster than snow —
// the rotation is what sells it as falling rather than sliding down the screen.
place(x, y, (progress * (2 + index % 3) + b) * 360f) {
// Barely blended toward the body colour. A single petal was drawn at half
// the way and came out a grey seed: these palettes are pastels already, and
// mixing a pastel with a near-white leaves nothing of the colour behind.
drawBlossom(13f * scale, accent.copy(alpha = alpha + 0.08f), body)
}
}
Decorations.BATS -> {
// Bats fly across rather than fall, and alternate direction so the screen does
// not read as everything leaving one side of the room.
val leftward = index % 2 == 0
val travel = wrap(lane + progress * (1 + index % 3))
val x = (if (leftward) travel else 1f - travel) * (size.width + 220f) - 110f
// Banded down the screen rather than hashed, for the same reason the fall is:
// the flock has to look like it is crossing the whole room. The top and bottom
// eighths are left clear so nothing collides with the row titles or the rail.
val band = 0.12f + ((index * 7 % ParticleCount) + b) / ParticleCount * 0.76f
val y = (band + sin((progress * (2 + index % 2) + a) * 2f * PI.toFloat()) * 0.04f) *
size.height
place(x, y, 0f) {
// The wings beat by squeezing the shape horizontally — six flaps a cycle,
// whole-numbered like everything else. A cheaper animation than redrawing a
// wing path, and at this size it is the only cue that reads as alive.
val flap = 0.45f + abs(sin((progress * 6 + a) * 2f * PI.toFloat())) * 0.55f
drawBat(22f * scale, flap, blend(accent, body, 0.12f).copy(alpha = alpha))
}
}
}
}
/** Moves to a point and turns, so each shape can be drawn around its own origin. */
private inline fun DrawScope.place(x: Float, y: Float, degrees: Float, draw: DrawScope.() -> Unit) {
translate(x, y) { rotate(degrees, pivot = Offset.Zero) { draw() } }
}
/** Six spokes and a centre — the shape everybody draws, because at 18px nothing else reads. */
private fun DrawScope.drawSnowflake(radius: Float, color: Color) {
val stroke = (radius * 0.16f).coerceAtLeast(1f)
for (spoke in 0 until 3) {
val angle = spoke * PI.toFloat() / 3f
val dx = cos(angle) * radius
val dy = sin(angle) * radius
drawLine(color, Offset(-dx, -dy), Offset(dx, dy), strokeWidth = stroke)
// The little barbs. Without them a snowflake at this size is a three-line asterisk,
// which reads as a scratch on the panel rather than as snow.
val barb = radius * 0.34f
for (side in listOf(1f, -1f)) {
val tip = Offset(dx * side, dy * side)
val inner = Offset(dx * side * 0.55f, dy * side * 0.55f)
drawLine(
color,
inner,
Offset(
inner.x + cos(angle + side * 1.05f) * barb,
inner.y + sin(angle + side * 1.05f) * barb,
),
strokeWidth = stroke * 0.8f,
)
drawLine(color, inner, tip, strokeWidth = stroke)
}
}
drawCircle(color, radius * 0.18f, Offset.Zero)
}
/**
* A five-petal blossom: five overlapping discs and a centre.
*
* It began as a single petal, which at this size rendered as a grey seed recognisable as
* *something falling* and as nothing else. A whole flower is barely more expensive (five
* circles against a two-curve path) and is read instantly, which for the one decoration
* whose season lasts four days is the difference between the feature landing and not.
*/
private fun DrawScope.drawBlossom(radius: Float, color: Color, body: Color) {
val petal = radius * 0.44f
val reach = radius * 0.56f
for (index in 0 until 5) {
val angle = index * 2f * PI.toFloat() / 5f
drawCircle(color, petal, Offset(cos(angle) * reach, sin(angle) * reach))
}
// The centre is warmer and a touch stronger, which is what stops five discs reading as
// a cluster of bubbles.
drawCircle(
blend(color, body, 0.45f).copy(alpha = (color.alpha * 1.5f).coerceAtMost(1f)),
radius * 0.3f,
Offset.Zero,
)
}
/**
* A bat silhouette, [flap] squeezing the wings between folded and spread.
*
* Drawn as a body plus two scalloped wings rather than as one path, because the scallop is
* the only thing separating a bat from a bird at fourteen pixels.
*/
private fun DrawScope.drawBat(radius: Float, flap: Float, color: Color) {
val span = radius * flap
val path = Path().apply {
moveTo(0f, -radius * 0.18f)
// Right wing: out along the top, back in through two scallops.
cubicTo(span * 0.5f, -radius * 0.75f, span * 0.85f, -radius * 0.5f, span, -radius * 0.1f)
lineTo(span * 0.72f, radius * 0.22f)
lineTo(span * 0.6f, radius * 0.02f)
lineTo(span * 0.34f, radius * 0.3f)
lineTo(span * 0.22f, radius * 0.08f)
lineTo(0f, radius * 0.34f)
// Left wing: the mirror of it.
lineTo(-span * 0.22f, radius * 0.08f)
lineTo(-span * 0.34f, radius * 0.3f)
lineTo(-span * 0.6f, radius * 0.02f)
lineTo(-span * 0.72f, radius * 0.22f)
lineTo(-span, -radius * 0.1f)
cubicTo(-span * 0.85f, -radius * 0.5f, -span * 0.5f, -radius * 0.75f, 0f, -radius * 0.18f)
close()
}
drawPath(path, color)
drawCircle(color, radius * 0.2f, Offset(0f, -radius * 0.12f))
// Two ears. Tiny, and the reason the silhouette is legible at all.
drawPath(
Path().apply {
moveTo(-radius * 0.2f, -radius * 0.22f)
lineTo(-radius * 0.1f, -radius * 0.5f)
lineTo(-radius * 0.02f, -radius * 0.24f)
close()
moveTo(radius * 0.2f, -radius * 0.22f)
lineTo(radius * 0.1f, -radius * 0.5f)
lineTo(radius * 0.02f, -radius * 0.24f)
close()
},
color,
)
}
/**
* A deterministic value in `0..1` for a particle's index.
*
* A hash rather than `Random`, so the field is identical on every television, on every
* launch, and in a screenshot which is what makes a still image of it worth looking at.
*/
private fun noise(seed: Int): Float {
var value = seed * 374_761_393 + 668_265_263
value = (value xor (value shr 13)) * 1_274_126_177
return (abs(value xor (value shr 16)) % 10_000) / 10_000f
}
/** The fractional part, so a particle leaving the bottom re-enters at the top. */
private fun wrap(value: Float): Float = value - kotlin.math.floor(value)
private fun blend(from: Color, to: Color, amount: Float): Color = Color(
red = from.red + (to.red - from.red) * amount,
green = from.green + (to.green - from.green) * amount,
blue = from.blue + (to.blue - from.blue) * amount,
)
@@ -8,8 +8,6 @@
package com.ponzischeme89.memby.ui.settings
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@@ -23,6 +21,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -38,14 +37,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.TagFaces
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material.icons.filled.Storage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -67,6 +65,11 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -74,7 +77,6 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
@@ -83,29 +85,33 @@ import androidx.tv.material3.Text
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.ImageCacheMaintenance
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.data.formatCacheSize
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.parseThemeColor
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.UpdateChecker
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
private data class ChoiceOption(val value: String, val label: String, val color: Color? = null)
internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null)
private val RingOptions = listOf(
ChoiceOption("FFFFFF", "White", Color.White),
@@ -151,10 +157,14 @@ internal enum class SettingsPage(
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
WELCOME("Welcome", "The line you get when you sign in", Icons.Default.TagFaces),
UPDATES("Updates", "Keep this TV up to date", Icons.Default.SystemUpdate),
// No Updates page. The manual check needs a Gitea address, a repository and a token,
// and nothing on this television can enter them — so the button could only ever report
// a failure, on the one screen a viewer goes to when they suspect something is wrong.
// Updates arrive through the gateway's own verdict (ui/UpdateScreen.kt), which carries
// its download URL with it.
DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices),
ABOUT("About", "Version and source code", Icons.Default.Info),
STORAGE("Storage", "Artwork Memby keeps on this TV", Icons.Default.Storage),
ABOUT("About", "Version and release notes", Icons.Default.Info),
}
// Black, and one lit thing at a time.
@@ -169,7 +179,10 @@ internal enum class SettingsPage(
// The controls are deliberately untouched: the green pill toggle and the chip row are what
// make this screen feel like Memby, and they read better against black than they did
// against a card.
private val EmbyGreen = Color(0xFF52B54B)
// The one colour on this page that is not fixed. It is the accent, and the accent is the
// viewer's own choice now — a picker offering an orange scheme with a green selected chip
// would be showing them the wrong answer to the question they are being asked.
private val EmbyGreen: Color get() = MembyAccent
private val Canvas = Color(0xFF000000)
private val Panel = Color(0xFF040506)
private val RowFocused = Color(0xFF1B2228)
@@ -186,6 +199,7 @@ internal data class SettingsPanelState(
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val speedUpCredits: Boolean = true,
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
@@ -195,10 +209,25 @@ internal data class SettingsPanelState(
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
/**
* The viewer's own colour scheme, and the ones the gateway says they may pick between.
*
* [themeOptions] is empty on the direct path, on a gateway that predates themes, and
* before the first fetch in every one of which the row is simply not drawn. Nothing
* here falls back to a catalogue compiled into the app: a scheme the operator has
* withheld has to be one this television was never sent, or the allowlist is decoration.
*/
val themeId: String = Settings.DEFAULT_THEME_ID,
val themeOptions: List<ChoiceOption> = emptyList(),
/**
* A season is on, so the chips are shown but cannot be moved and [themeNotice] says why.
* The viewer's own choice stays selected underneath, because it is still theirs and
* comes back when the season ends.
*/
val themeLocked: Boolean = false,
/** The gateway's wording for the lock, so a season invented later still reads right. */
val themeNotice: String = "",
val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
val checking: Boolean = false,
val updateStatus: UpdateStatus? = null,
val installMessage: String? = null,
val installedVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
@@ -206,6 +235,16 @@ internal data class SettingsPanelState(
val devicesError: String? = null,
val removingDeviceId: String? = null,
val pendingRemovalDeviceId: String? = null,
/**
* How much artwork this TV is holding, or null while it is still being measured.
*
* Null rather than [ImageCacheSize.EMPTY] on purpose: reading the disk cache's size
* walks its journal, so there is a moment before the answer arrives, and a page that
* showed "0 MB" during it would be telling the viewer the opposite of the truth.
*/
val imageCacheSize: ImageCacheSize? = null,
val imageCacheClearing: Boolean = false,
val imageCacheCleared: Boolean = false,
)
internal data class SettingsPanelActions(
@@ -215,6 +254,7 @@ internal data class SettingsPanelActions(
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
@@ -224,14 +264,13 @@ internal data class SettingsPanelActions(
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
val onThemeChanged: (String) -> Unit = {},
val onPageSelected: (SettingsPage) -> Unit = {},
val onCheckForUpdates: () -> Unit = {},
val onInstallUpdate: (UpdateStatus.Available) -> Unit = {},
val onRefreshDevices: () -> Unit = {},
val onRenameDevice: (GatewayDevice) -> Unit = {},
val onRemoveDevice: (GatewayDevice) -> Unit = {},
val onCancelDeviceRemoval: () -> Unit = {},
val onOpenSourceCode: () -> Unit = {},
val onClearImageCache: () -> Unit = {},
)
/**
@@ -244,20 +283,27 @@ fun SettingsSheet(
onClose: () -> Unit,
modifier: Modifier = Modifier,
overlay: Boolean = false,
onInstallerLaunched: (() -> Unit)? = null,
navigationFocusRequester: FocusRequester? = null,
) {
val context = LocalContext.current
val store = ServiceLocator.settings
val scope = rememberCoroutineScope()
// Kept only for the version this TV is running, which About prints. Nothing here checks
// for an update: see the note on SettingsPage.
val checker = remember { UpdateChecker(context) }
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
// Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects
// default chips before DataStore emits; a viewer could press "Automatic" in that gap
// and then watch the stored "Posters" value appear to revert their choice.
val settings by ServiceLocator.repository.settingsFlow.collectAsState(
initial = ServiceLocator.repository.currentSettings,
)
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
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 speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
@@ -266,12 +312,12 @@ fun SettingsSheet(
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
// The resolved theme and the schemes on offer both come from the gateway, through the
// sync that owns them. Collected here rather than in SettingsPanelContent so the content
// stays parameter-driven and screenshot-testable with no server.
val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState()
val availableThemes by ServiceLocator.themeSync.available.collectAsState()
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var checking by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(null) }
// The system installer reports back here, not to downloadAndInstall's caller.
LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) }
var devicesError by remember { mutableStateOf<String?>(null) }
@@ -279,6 +325,9 @@ fun SettingsSheet(
var pendingRemovalDeviceId by remember { mutableStateOf<String?>(null) }
var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
var deviceJob by remember { mutableStateOf<Job?>(null) }
var imageCacheSize by remember { mutableStateOf<ImageCacheSize?>(null) }
var imageCacheClearing by remember { mutableStateOf(false) }
var imageCacheCleared by remember { mutableStateOf(false) }
suspend fun refreshDevices() {
devicesLoading = true
@@ -305,6 +354,16 @@ fun SettingsSheet(
}
}
// Measured when the page is opened rather than when Settings is, because walking the
// disk cache's journal has no business happening on a viewer who came here to turn
// subtitles on. It is re-measured on every arrival, since a browse between two visits
// will have filled it again.
LaunchedEffect(selectedPage) {
if (selectedPage == SettingsPage.STORAGE && !imageCacheClearing) {
imageCacheSize = ImageCacheMaintenance.measure(context)
}
}
LaunchedEffect(
settings.showTitleLogo,
settings.ringColorHex,
@@ -318,6 +377,7 @@ fun SettingsSheet(
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
settings.speedUpCredits,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
@@ -325,6 +385,7 @@ fun SettingsSheet(
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
speedUpCredits = settings.speedUpCredits
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
@@ -349,6 +410,7 @@ fun SettingsSheet(
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
speedUpCredits = speedUpCredits,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
@@ -358,16 +420,24 @@ fun SettingsSheet(
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
welcomeQuoteStyle = welcomeQuoteStyle,
themeId = settings.themeId,
themeOptions = availableThemes.map { theme ->
ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent))
},
// Locked is taken from the server's answer and defaults to false: a set that has not
// been told anything must not present a picker it will not let anybody use.
themeLocked = resolvedTheme?.locked == true,
themeNotice = resolvedTheme?.takeIf { it.locked }?.reason.orEmpty(),
selectedPage = selectedPage,
checking = checking,
updateStatus = status,
installMessage = installMessage,
installedVersion = checker.installedVersion,
devices = devices,
devicesLoading = devicesLoading,
devicesError = devicesError,
removingDeviceId = removingDeviceId,
pendingRemovalDeviceId = pendingRemovalDeviceId,
imageCacheSize = imageCacheSize,
imageCacheClearing = imageCacheClearing,
imageCacheCleared = imageCacheCleared,
)
val actions = SettingsPanelActions(
onClose = onClose,
@@ -391,6 +461,10 @@ fun SettingsSheet(
skipIntroMode = it
scope.launch { store.setSkipIntroMode(it) }
},
onSpeedUpCreditsChanged = {
speedUpCredits = it
scope.launch { store.setSpeedUpCredits(it) }
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -405,7 +479,12 @@ fun SettingsSheet(
},
onArtworkStyleChanged = {
artworkStyle = it
scope.launch { store.setHomeArtworkStyle(it) }
scope.launch {
// Back closes this composable and cancels its scope. Once a selection has
// been accepted on screen, its tiny atomic DataStore write must finish even
// if the viewer leaves Settings immediately afterwards.
withContext(NonCancellable) { store.setHomeArtworkStyle(it) }
}
},
onRestoreHiddenRows = {
scope.launch {
@@ -428,41 +507,18 @@ fun SettingsSheet(
hideWatchedMovies = it
scope.launch { store.setHideWatchedMovies(it) }
},
onThemeChanged = { chosen ->
// No local echo: what is on screen is the palette the gateway resolves, and it
// arrives through ThemeSync a moment later. Painting optimistically here would
// show a viewer a scheme that a season, or an allowlist they do not know about,
// is about to take back off them.
scope.launch { store.setThemeId(chosen) }
},
onWelcomeQuoteStyleChanged = {
welcomeQuoteStyle = it
scope.launch { store.setWelcomeQuoteStyle(it) }
},
onPageSelected = { selectedPage = it },
onCheckForUpdates = {
if (!checking) {
checking = true
status = null
installMessage = null
scope.launch {
status = checker.check(
settings.updateBaseUrl.orEmpty(),
settings.updateRepo.orEmpty(),
settings.updateToken.orEmpty(),
)
checking = false
}
}
},
onInstallUpdate = { available ->
installMessage = "Downloading update…"
scope.launch {
val result = checker.downloadAndInstall(
available.apkUrl,
settings.updateToken.orEmpty(),
)
result.exceptionOrNull()?.let {
installMessage = it.message
} ?: run {
installMessage = "Opening the installer…"
onInstallerLaunched?.invoke()
}
}
},
onRefreshDevices = {
deviceJob?.cancel()
deviceJob = scope.launch { refreshDevices() }
@@ -496,13 +552,22 @@ fun SettingsSheet(
}
},
onCancelDeviceRemoval = { pendingRemovalDeviceId = null },
onOpenSourceCode = {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.SOURCE_CODE_URL)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
onClearImageCache = clearCache@{
if (imageCacheClearing) return@clearCache
imageCacheClearing = true
imageCacheCleared = false
scope.launch {
// NonCancellable because emptying 128MB of files outlives a viewer pressing
// Back, and a half-cleared cache is exactly the state this button exists to
// get out of. The state written afterwards is the composition's, so a screen
// that has gone simply drops it.
val remaining = withContext(NonCancellable) {
runCatching { ImageCacheMaintenance.clear(context) }
.getOrDefault(ImageCacheSize.EMPTY)
}
imageCacheSize = remaining
imageCacheCleared = true
imageCacheClearing = false
}
},
)
@@ -580,6 +645,13 @@ internal fun SettingsPanelContent(
navigationFocusRequester: FocusRequester? = null,
) {
val contentScrollState = rememberScrollState()
// Right out of the rail was the one direction on this screen left to Compose's spatial
// search, and it is the one that has to cross from a fixed column into a pane whose
// first control is somewhere different on every page — a chip row high up on Welcome, a
// full-width row far down on Devices. A search that finds nothing is a press that does
// nothing, which is what "stuck on that page" looks like from the sofa. The pane is a
// focus group, so one requester names "whatever this page starts with".
val contentFocusRequester = remember { FocusRequester() }
LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0)
@@ -604,6 +676,7 @@ internal fun SettingsPanelContent(
onClose = actions.onClose,
firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester,
contentFocusRequester = contentFocusRequester,
compact = overlay,
modifier = Modifier
.width(if (overlay) 190.dp else 224.dp)
@@ -613,6 +686,11 @@ internal fun SettingsPanelContent(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
// The requester has to sit above the focus target it names, and focusGroup
// is a target that cannot hold focus itself — so a request on it enters the
// page's first control, whatever that page turns out to be.
.focusRequester(contentFocusRequester)
.focusGroup()
.verticalScroll(contentScrollState)
.padding(
start = if (overlay) 26.dp else 42.dp,
@@ -622,12 +700,23 @@ internal fun SettingsPanelContent(
),
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
) {
SettingsHeader(
page = state.selectedPage,
version = state.installedVersion,
)
SettingsHeader(page = state.selectedPage)
when (state.selectedPage) {
SettingsPage.APPEARANCE -> SettingsGroup {
// Only drawn when the server has offered something to choose from. On
// the direct path, on an older gateway, and for a viewer the operator
// has left with one scheme, there is no question to ask — and a row of
// one chip that cannot be moved is worse than no row.
if (state.themeOptions.size > 1) {
SettingsColourSchemeRow(
options = state.themeOptions,
selected = state.themeId,
locked = state.themeLocked,
notice = state.themeNotice,
onSelected = actions.onThemeChanged,
)
SettingDivider()
}
SettingsToggleRow(
title = "Show logos",
description = "Use a film or show's own logo instead of plain text.",
@@ -642,6 +731,26 @@ internal fun SettingsPanelContent(
selected = state.ringColor,
onSelected = actions.onRingColorChanged,
)
SettingDivider()
// The welcome line had a rail page to itself for one chip row. A page
// holding a single question is a destination a viewer has to find before
// they can answer it, and the question — what Memby sounds like when it
// opens — is the same one the rest of this page is asking.
SettingsChoiceRow(
title = "Welcome tone",
description = "A different line is picked each time Memby opens.",
options = WelcomeOptions,
selected = state.welcomeQuoteStyle,
onSelected = actions.onWelcomeQuoteStyleChanged,
)
SettingsNotice(
text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) {
WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”"
WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”"
WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”"
},
positive = true,
)
}
SettingsPage.PLAYBACK -> SettingsGroup {
SettingsToggleRow(
@@ -666,6 +775,14 @@ internal fun SettingsPanelContent(
onSelected = actions.onSkipIntroModeChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Closing credits",
description = "Shrink them to one side at double speed and show " +
"what is on next.",
checked = state.speedUpCredits,
onCheckedChange = actions.onSpeedUpCreditsChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Skip with left and right",
description = "How far one press moves what you are watching.",
@@ -740,68 +857,6 @@ internal fun SettingsPanelContent(
)
}
}
SettingsPage.WELCOME -> SettingsGroup {
SettingsChoiceRow(
title = "Tone",
description = "A different line is picked each time Memby opens.",
options = WelcomeOptions,
selected = state.welcomeQuoteStyle,
onSelected = actions.onWelcomeQuoteStyleChanged,
)
SettingsNotice(
text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) {
WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”"
WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”"
WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”"
},
positive = true,
)
}
SettingsPage.UPDATES -> SettingsGroup {
VersionRow("On this TV", state.installedVersion)
SettingDivider()
VersionRow(
"Newest release",
when (val update = state.updateStatus) {
is UpdateStatus.Available -> update.version
is UpdateStatus.UpToDate -> update.version
is UpdateStatus.Error -> "Unavailable"
null -> "Not checked"
},
)
SettingDivider()
SettingsActionRow(
title = if (state.checking) "Checking…" else "Check for updates",
description = "Ask whether a newer build is available.",
badge = if (state.checking) "WORKING" else "CHECK NOW",
onClick = actions.onCheckForUpdates,
)
when (val update = state.updateStatus) {
is UpdateStatus.UpToDate -> SettingsNotice("This TV is up to date.", positive = true)
is UpdateStatus.Error -> SettingsNotice(update.message, positive = false)
is UpdateStatus.Available -> {
SettingsNotice("Version ${update.version} is ready to install.", positive = true)
if (update.notes.isNotBlank()) {
Text(
update.notes,
color = TextSecondary,
fontSize = 13.sp,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
SettingsActionRow(
title = "Install it",
description = "Android will ask you to confirm.",
badge = "INSTALL",
onClick = { actions.onInstallUpdate(update) },
)
}
null -> Unit
}
state.installMessage?.let { SettingsNotice(it, positive = true) }
}
SettingsPage.DEVICES -> SettingsGroup {
when {
state.devicesLoading && state.devices.isEmpty() ->
@@ -828,6 +883,36 @@ internal fun SettingsPanelContent(
onClick = actions.onRefreshDevices,
)
}
SettingsPage.STORAGE -> SettingsGroup {
val cache = state.imageCacheSize
VersionRow(
"Stored artwork",
cache?.let { formatCacheSize(it.diskBytes) } ?: "Measuring…",
)
SettingDivider()
VersionRow(
"Artwork in memory",
cache?.let { formatCacheSize(it.memoryBytes) } ?: "Measuring…",
)
SettingDivider()
SettingsActionRow(
title = if (state.imageCacheClearing) "Clearing…" else "Clear stored artwork",
description = "Frees the space back up and fetches fresh artwork. " +
"Rows will take a moment longer to fill in the first time.",
badge = when {
state.imageCacheClearing -> "WORKING"
cache == null -> "CLEAR"
else -> formatCacheSize(cache.totalBytes).uppercase()
},
onClick = actions.onClearImageCache,
)
// Only the confirmation gets a tinted band. A standing explanation in
// the notice colour would be the loudest thing on a page whose whole
// job is to be quiet until somebody presses the one button on it.
if (state.imageCacheCleared && !state.imageCacheClearing) {
SettingsNotice("Stored artwork cleared.", positive = true)
}
}
SettingsPage.ABOUT -> SettingsGroup {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
@@ -853,13 +938,6 @@ internal fun SettingsPanelContent(
fontWeight = FontWeight.Bold,
)
}
SettingDivider()
SettingsActionRow(
title = "Source code",
description = BuildConfig.SOURCE_CODE_URL,
badge = "OPEN",
onClick = actions.onOpenSourceCode,
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
@@ -1031,6 +1109,20 @@ private fun DeviceRenameDialog(
}
}
/**
* How long a rail item must hold focus before its page is actually drawn.
*
* Focus moving down the rail is what selects a page, which is the right gesture and the
* expensive one: walking from Appearance to About passes four pages, and each of them was
* composed in full Devices going as far as asking the gateway who is signed in under a
* thumb that had already moved on. On a weak set that is what made the rail feel as though
* it had stuck. The highlight still follows the remote with no delay, because that is the
* part a viewer is watching; only the pane waits to see where they stopped. Short enough
* that a deliberate press never feels held up, the same trade as the launcher's own
* FOCUS_METADATA_DEBOUNCE_MS.
*/
private const val SETTINGS_PAGE_SETTLE_MS = 130L
@Composable
private fun SettingsSecondaryRail(
selected: SettingsPage,
@@ -1038,11 +1130,22 @@ private fun SettingsSecondaryRail(
onClose: () -> Unit,
firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?,
contentFocusRequester: FocusRequester,
compact: Boolean,
modifier: Modifier = Modifier,
) {
val pages = SettingsPage.entries.filter { it.showInRail }
val selectedRailPage = selected
val scope = rememberCoroutineScope()
// Which item the remote is on, which is deliberately not the same thing as which page
// is drawn. Everything the rail paints reads from this, so nothing about the highlight
// waits on the settle above.
var focusedPage by remember { mutableStateOf(selected) }
LaunchedEffect(focusedPage, selected) {
if (focusedPage == selected) return@LaunchedEffect
delay(SETTINGS_PAGE_SETTLE_MS)
onSelected(focusedPage)
}
// The home screen remains composed behind this panel. Relying on spatial focus
// search therefore lets a covered media card beat the next rail item when their
// bounds happen to be closer (notably below Playback on a 540p viewport). Give
@@ -1050,7 +1153,7 @@ private fun SettingsSecondaryRail(
// settings surface and activate home content.
val railFocusRequesters = remember(firstFocusRequester) {
buildList {
add(FocusRequester()) // Exit
add(FocusRequester()) // Back
pages.forEach { page ->
add(
if (page == selectedRailPage && firstFocusRequester != null) {
@@ -1089,16 +1192,17 @@ private fun SettingsSecondaryRail(
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp),
)
SettingsExitRailItem(
SettingsBackRailItem(
compact = compact,
onClick = onClose,
focusRequester = railFocusRequesters[0],
downFocusRequester = railFocusRequesters[1],
leftFocusRequester = navigationFocusRequester,
rightFocusRequester = contentFocusRequester,
)
pages.forEachIndexed { index, page ->
var focused by remember { mutableStateOf(false) }
val active = page == selectedRailPage
val active = page == focusedPage
Row(
modifier = Modifier
.fillMaxWidth()
@@ -1132,13 +1236,37 @@ private fun SettingsSecondaryRail(
// screen stays composed behind this panel and a covered card can
// otherwise win the focus search.
left = navigationFocusRequester ?: FocusRequester.Cancel
right = contentFocusRequester
}
.onPreviewKeyEvent { event ->
if (
event.type != KeyEventType.KeyDown ||
event.key != Key.DirectionRight ||
page == selected
) {
return@onPreviewKeyEvent false
}
// Right while a settle is still pending. Letting the focus property
// run would enter the page the viewer has already walked past, and
// lose focus again the moment it is replaced — the same dead end the
// requester exists to close. Draw the page they are actually on
// first, then hand focus to it once it has composed.
onSelected(page)
scope.launch {
delay(32L)
runCatching { contentFocusRequester.requestFocus() }
}
true
}
.onFocusChanged {
focused = it.isFocused
if (it.isFocused && page != selected) onSelected(page)
if (it.isFocused) focusedPage = page
}
.testTag("settings-rail-${page.name.lowercase()}")
.clickable { onSelected(page) }
.clickable {
focusedPage = page
onSelected(page)
}
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -1174,12 +1302,13 @@ private fun SettingsSecondaryRail(
}
@Composable
private fun SettingsExitRailItem(
private fun SettingsBackRailItem(
compact: Boolean,
onClick: () -> Unit,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
leftFocusRequester: FocusRequester?,
rightFocusRequester: FocusRequester,
) {
var focused by remember { mutableStateOf(false) }
Row(
@@ -1197,6 +1326,7 @@ private fun SettingsExitRailItem(
up = FocusRequester.Cancel
down = downFocusRequester
left = leftFocusRequester ?: FocusRequester.Cancel
right = rightFocusRequester
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
@@ -1205,13 +1335,13 @@ private fun SettingsExitRailItem(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(
Icons.Default.Close,
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
tint = if (focused) Canvas else TextSecondary,
modifier = Modifier.size(19.dp),
)
Text(
"Exit",
"Back",
color = if (focused) Canvas else TextPrimary,
fontSize = if (compact) 13.sp else 14.sp,
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
@@ -1221,7 +1351,7 @@ private fun SettingsExitRailItem(
}
@Composable
private fun SettingsHeader(page: SettingsPage, version: String) {
private fun SettingsHeader(page: SettingsPage) {
Row(
verticalAlignment = Alignment.CenterVertically,
// Indented to the rows' own text rather than to the panel edge. With the section
@@ -1234,6 +1364,10 @@ private fun SettingsHeader(page: SettingsPage, version: String) {
// No eyebrow above the title. It said "MEMBY · TV" on every page of a sheet that is
// already inside Memby on a television, so it named the one thing nobody could be in
// doubt about while taking the vertical space the settings themselves want.
//
// No version in the corner either, for the same reason it lost the eyebrow: it was
// on all seven pages to answer a question asked on two of them, where Updates prints
// it as "On this TV" and About prints it beside the app's own name.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(
@@ -1242,8 +1376,6 @@ private fun SettingsHeader(page: SettingsPage, version: String) {
fontSize = 13.sp,
)
}
Spacer(Modifier.weight(1f))
Text("v$version", color = TextQuiet, fontSize = 11.sp, fontWeight = FontWeight.SemiBold)
}
}
@@ -1356,6 +1488,70 @@ private fun StatusToggle(checked: Boolean) {
}
}
/**
* The colour scheme picker.
*
* It is its own row rather than a [SettingsChoiceRow] for one reason: this is the only
* setting on the television that the viewer can be *temporarily overruled* on. While a
* season is in force the chips still show what they chose it is still theirs, and it comes
* back but they do nothing, and the line underneath says so in the server's words rather
* than in wording this build guessed at. A greyed row with no explanation would read as a
* broken setting, which is exactly what somebody would report in December.
*/
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun SettingsColourSchemeRow(
options: List<ChoiceOption>,
selected: String,
locked: Boolean,
notice: String,
onSelected: (String) -> Unit,
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"Colour scheme",
color = TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
"The colours Memby uses, on every TV you sign in to.",
color = TextSecondary,
fontSize = 13.sp,
)
}
// Wraps, unlike every other choice row on this page. Those offer three fixed
// options and will never offer four; this one is a server catalogue that grows
// without an app release, and six chips already reach the right edge of a 960dp
// set — the seventh would be off the screen with nothing to say it was there.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(9.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
options.forEach { option ->
SettingsChoiceChip(
option = option,
selected = selected.equals(option.value, ignoreCase = true),
// Still focusable while locked, so a viewer can read across the row and
// see what is theirs; the press simply does nothing.
onClick = { if (!locked) onSelected(option.value) },
)
}
}
if (locked) {
Text(
notice.ifBlank { "A seasonal scheme is on for everyone at the moment." },
color = TextQuiet,
fontSize = 13.sp,
)
}
}
}
@Composable
private fun SettingsChoiceRow(
title: String,
@@ -1512,7 +1708,7 @@ private fun VersionHistorySection(
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
mutableStateOf(releases.firstOrNull()?.version)
}
SettingsGroup(label = "What changed in each release") {
SettingsGroup(label = "Changelog") {
if (releases.isEmpty()) {
SettingsNotice("No release history shipped with this build.", positive = false)
return@SettingsGroup
@@ -1,5 +1,8 @@
package com.ponzischeme89.memby.ui.theme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@@ -11,36 +14,98 @@ import androidx.compose.ui.unit.dp
* and they had drifted into four near-blacks, four greens, two secondary-text greys and
* eight corner radii. Nothing here is new design it is the values that were already
* winning, named once so a change lands on both screens at the same time.
*
* **The colours are now the server's answer, not constants.** A theme is decided by the
* gateway (see `server/internal/api/themes.go`) and handed down as a [MembyPalette]; these
* names are reads of whichever one is in force. Which is why every one of them is a `get()`
* rather than a value: a token captured once at class-init time would be the palette that
* happened to be loaded when the first screen composed, and would never change again.
*
* The shape and punctuation below are *not* themeable and are still constants. A theme
* changes colour and nothing else a palette that could move a corner radius or a
* separator would be able to make a layout wrong from the server, and the whole safety of
* this feature is that the worst a bad theme can do is look bad.
*/
/**
* Every colour a theme sets, and the complete list of them. It matches `themePalette` on
* the gateway field for field: a palette carrying a colour with no slot here would be a
* promise the app cannot keep, and one missing a slot is a theme that half-applies, which
* reads as a rendering fault rather than as a colour scheme.
*
* The defaults are Midnight the palette the app shipped with before themes existed so a
* television with no gateway, no network or no theme yet looks exactly as it always did.
*/
data class MembyPalette(
val surface: Color = Color(0xFF090B0D),
val surfaceRaised: Color = Color(0xFF101418),
val accent: Color = Color(0xFF52B54B),
val onSurface: Color = Color(0xFFE2E5E8),
val mutedText: Color = Color(0xFFD0D6DB),
val quietText: Color = Color(0xFFAEB7BF),
val hairline: Color = Color(0x28FFFFFF),
val ratingsSurface: Color = Color(0xFF20252A),
)
/**
* The palette in force, as snapshot state.
*
* Process-wide rather than a `CompositionLocal`, and that is deliberate. One television has
* one signed-in viewer and therefore one theme, and the surfaces that have to obey it are
* not all inside one composition: the launcher, the detail overlays, the settings sheet, the
* player's Compose islands and the screensaver's `DreamService` are five separate roots. A
* local would have to be provided at each of them and would be silently missing from the
* next one somebody added.
*
* Being snapshot state is what makes the tokens below work without a `@Composable`
* annotation: a read inside composition or a draw scope is recorded, so assigning here
* repaints exactly the scopes that use the colour that changed.
*/
private var activePalette by mutableStateOf(MembyPalette())
/**
* Repaints the app. Called by `ThemeSync` when the gateway's answer changes at sign-in,
* when the viewer picks a scheme, and at the midnight a season begins or ends.
*
* Setting the same palette twice is free: `mutableStateOf` compares with `equals`, and
* [MembyPalette] is a data class, so a poll that resolves to what is already on screen
* invalidates nothing.
*/
fun applyMembyPalette(palette: MembyPalette) {
activePalette = palette
}
/** The palette in force, for the few places that need it whole rather than one colour. */
val membyPalette: MembyPalette get() = activePalette
/** The near-black every full-screen surface is drawn on. */
val MembySurface = Color(0xFF090B0D)
val MembySurface: Color get() = activePalette.surface
/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */
val MembySurfaceRaised = Color(0xFF101418)
val MembySurfaceRaised: Color get() = activePalette.surfaceRaised
/** Emby's green. The only accent in the app. */
val MembyAccent = Color(0xFF52B54B)
/** The single accent. Emby's green on the default theme; a theme may make it anything. */
val MembyAccent: Color get() = activePalette.accent
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
val MembyOnSurface = Color(0xFFE2E5E8)
val MembyOnSurface: Color get() = activePalette.onSurface
/**
* Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as
* secondary without falling into low-contrast grey-on-black.
*/
val MembyMutedText = Color(0xFFD0D6DB)
val MembyQuietText = Color(0xFFAEB7BF)
val MembyMutedText: Color get() = activePalette.mutedText
val MembyQuietText: Color get() = activePalette.quietText
/** Hairline rules and unfocused borders. */
val MembyHairline = Color(0x28FFFFFF)
val MembyHairline: Color get() = activePalette.hairline
/** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface = Color(0xFF20252A)
val MembyRatingsSurface: Color get() = activePalette.ratingsSurface
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
// than inventing a fourth.
// than inventing a fourth. Not themeable; see the note at the top of this file.
/** Chips, badges and small controls. */
val MembyChipCorner = 8.dp
@@ -12,7 +12,12 @@ import androidx.tv.material3.darkColorScheme
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
// a component that falls back to a theme colour lands on the surface it is sitting on
// rather than one shade beside it.
private val EmbyColors = darkColorScheme(
//
// Built per composition rather than held as a constant, because the palette is the
// gateway's answer and changes under a running app. `darkColorScheme` reads the tokens, so
// this function is what carries a theme change into every component that never names a
// colour of its own.
private fun embyColors() = darkColorScheme(
primary = MembyAccent,
onPrimary = androidx.compose.ui.graphics.Color.White,
surface = MembySurfaceRaised,
@@ -36,7 +41,7 @@ fun MembyTheme(content: @Composable () -> Unit) {
letterSpacing = 0.006.em,
lineHeight = 1.22.em,
)
MaterialTheme(colorScheme = EmbyColors) {
MaterialTheme(colorScheme = embyColors()) {
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
content()
}
@@ -54,22 +54,7 @@ internal fun whatsNewDecision(
return WhatsNewDecision.Show(release)
}
/** One changelog bullet, split into its optional leading label and the sentence itself. */
internal data class ChangeLine(val tag: String?, val text: String)
/**
* The labels the changelog is written with. A closed set on purpose: any other colon in a
* bullet ("Fixed: Settings → About: …") is part of the sentence, and lifting it into a
* chip would break the line in half.
*/
private val ChangeTags = setOf("Added", "Changed", "Fixed", "Removed")
/** Splits `"Added: Subtitles are saved"` into the chip and the copy beside it. */
internal fun changeLine(raw: String): ChangeLine {
val text = raw.trim()
val colon = text.indexOf(':')
if (colon <= 0) return ChangeLine(null, text)
val tag = text.take(colon)
if (tag !in ChangeTags) return ChangeLine(null, text)
return ChangeLine(tag.uppercase(), text.drop(colon + 1).trim())
}
// A bullet's leading "Fixed:" / "Added:" used to be split off into an accent pill. Both
// places a viewer reads release notes — this panel and Settings → About — now render the
// line the changelog was written with, so there is nothing left to parse. See
// WhatsNewOverlay's ChangeRow for why.
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -49,7 +48,6 @@ import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
@@ -79,9 +77,6 @@ private const val CHANGE_ROW_HEIGHT_DP = 52
internal fun maxChangesFor(availableHeightDp: Int): Int =
((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES)
/** The label column, fixed so every sentence starts at the same place down the list. */
private val TagColumnWidth = 88.dp
private val Scrim = Color(0xE60A0C0F)
/**
@@ -169,7 +164,7 @@ internal fun WhatsNewOverlay(
modifier = Modifier.widthIn(max = 620.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
release.changes.take(shown).forEach { ChangeRow(changeLine(it)) }
release.changes.take(shown).forEach { ChangeRow(it) }
}
if (release.changes.size > shown) {
@@ -205,34 +200,27 @@ internal fun WhatsNewOverlay(
}
}
/**
* One bullet, as the sentence the changelog was written with and nothing else.
*
* It used to lift a leading "Fixed:" / "Added:" into an accent pill in a fixed 88dp column.
* Two things were wrong with that. The column was sized for the longest label, so a panel of
* three one-word tags spent a fifth of its width on them and every sentence started an inch
* from the margin; and the pills were the brightest thing on a screen whose job is to be
* read, so the eye went to four repetitions of the word FIXED rather than to what had been.
* The full line is shown instead which is also exactly what Settings About renders, so
* the two places a viewer reads release notes now agree.
*/
@Composable
private fun ChangeRow(line: ChangeLine) {
private fun ChangeRow(change: String) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// Fixed column: the labels are different lengths, and ragged sentence starts read
// as a list that has not been laid out rather than one that has.
Box(modifier = Modifier.width(TagColumnWidth), contentAlignment = Alignment.TopStart) {
if (line.tag != null) {
Text(
line.tag,
color = MembyAccent,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(MembyAccent.copy(alpha = 0.12f))
.padding(horizontal = 9.dp, vertical = 4.dp),
)
} else {
Box(
modifier = Modifier
.padding(top = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(MembyAccent.copy(alpha = 0.7f)),
)
}
}
Text(line.text, color = MembyMutedText, fontSize = 16.sp)
Box(
modifier = Modifier
.padding(top = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(MembyAccent.copy(alpha = 0.7f)),
)
Text(change, color = MembyMutedText, fontSize = 16.sp)
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Dark on both the green resting plate and the white focused plate. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF07090A" />
</selector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What sits behind the scrubbing thumbnail. Black rather than translucent: a frame drawn
over the film it came from reads as a rendering fault, and this is the one preview with
nothing but the picture in it to say otherwise. The hairline is what separates a dark
scene from the dark screen around it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF000000" />
<stroke
android:width="1dp"
android:color="#33FFFFFF" />
<corners android:radius="4dp" />
</shape>
@@ -44,6 +44,11 @@
nothing to say about what comes next. -->
<include layout="@layout/player_next_up_banner" />
<!-- The credits pane, above the banner it stands in for. While it is up the banner is
suppressed: both shrink the same picture and both say what is on next, and two of
them would fight over the transform and print the episode twice. -->
<include layout="@layout/player_end_credits" />
<!-- App-owned subtitle controls. ExoPlayer supplies tracks and rendering only; this
overlay deliberately stays in Memby's TV design language. -->
<include layout="@layout/player_subtitle_overlay" />
@@ -261,4 +261,25 @@
</FrameLayout>
</LinearLayout>
<!-- The frame the scrubber is sitting on, while the transport is up and Left/Right
belong to the time bar rather than to us. Declared last so it draws over the OSD
gradients, and positioned horizontally from code so it rides above the scrubber
instead of naming a place on screen the scrubber may not be.
There is no wording beside it, unlike the centred chip: the transport is already
printing the position it would say, and a second copy of it over somebody's film
is a caption on a caption. The bottom margin clears the button row (72dp), its
padding (28dp) and the bar itself (34dp), with a gap above that. -->
<ImageView
android:id="@+id/player_scrub_preview"
android:layout_width="214dp"
android:layout_height="120dp"
android:layout_gravity="bottom|start"
android:layout_marginBottom="146dp"
android:background="@drawable/player_scrub_preview_background"
android:contentDescription="@null"
android:focusable="false"
android:scaleType="fitCenter"
android:visibility="gone" />
</merge>
@@ -0,0 +1,176 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The closing credits, moved aside.
From the `CreditsStart` marker Emby writes into the chapter list, the picture is scaled
into the left half of the screen and run at double speed while this panel says what is
on next. The transform is the *activity's* — the same scale-and-translate the next-up
banner has always used on the PlayerView, deliberately not the pre-roll's reparenting,
because moving a SurfaceView between parents mid-playback tears the surface down and
flashes black. The pre-roll can afford that; a running credit roll cannot.
So the left half of this layout is empty on purpose: it is a hole for the picture that
is already there to show through.
There is no scrim and no card behind the picture. It sits on somebody's film, and the
only lit surface is the button under focus. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_end_credits"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:visibility="gone">
<!-- Why the picture is running fast. Without it a viewer who looks up mid-roll has a
sped-up image and no stated reason for it, which reads as the stream misbehaving.
It sits under the picture's left half rather than in the panel, because it is a
label on the video and not part of what is on next.
The start margin is aligned with the shrunken picture's own left edge, which
CREDITS_VIDEO_SCALE and CREDITS_VIDEO_SHIFT_X put at ~34dp on a 16:9 set, so it reads
as a caption on the credits rather than as something floating in the black beside
them. The bottom margin is the seek chip's, for the same reason: it is the height
this app already puts a chip at. -->
<TextView
android:id="@+id/player_end_credits_speed"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_marginStart="34dp"
android:layout_marginBottom="104dp"
android:background="@drawable/time_remaining_cue_background"
android:letterSpacing="0.06"
android:paddingStart="14dp"
android:paddingTop="7dp"
android:paddingEnd="14dp"
android:paddingBottom="7dp"
android:textColor="#FF69CD61"
android:textSize="14sp"
android:textStyle="bold"
tools:text="2×" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<!-- The hole the picture occupies. Weighted rather than fixed so the split is half
the screen on every set, whatever its width. -->
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/player_end_credits_panel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="30dp"
android:paddingEnd="72dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/next_up_label"
android:textColor="#FF69CD61"
android:textSize="11sp" />
<ImageView
android:id="@+id/player_end_credits_image"
android:layout_width="match_parent"
android:layout_height="196dp"
android:layout_marginTop="14dp"
android:background="#FF1B2026"
android:contentDescription="@null"
android:scaleType="centerCrop" />
<TextView
android:id="@+id/player_end_credits_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFFFF"
android:textSize="26sp"
android:textStyle="bold"
tools:text="The Crossing" />
<TextView
android:id="@+id/player_end_credits_meta"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#BFFFFFFF"
android:textSize="13sp"
tools:text="S02E05 · Northbound" />
<!-- Gone rather than blank until the last minute. The pane opens minutes before
the file ends, and a countdown that sat there reading "starting in 4:12"
would be the most prominent thing on it for most of its life. -->
<TextView
android:id="@+id/player_end_credits_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:textColor="#FF69CD61"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="Starting in 28s"
tools:visibility="visible" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:orientation="horizontal"
tools:ignore="ButtonStyle">
<Button
android:id="@+id/player_end_credits_play"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/next_up_play_now"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
<!-- Restores the picture and normal speed. Worded as watching the credits
rather than as dismissing a panel: somebody who presses this wants the
credits, and it is the only way back to them. -->
<Button
android:id="@+id/player_end_credits_dismiss"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:background="@drawable/next_up_secondary_button"
android:focusable="true"
android:minWidth="0dp"
android:paddingStart="22dp"
android:paddingTop="9dp"
android:paddingEnd="22dp"
android:paddingBottom="9dp"
android:stateListAnimator="@null"
android:text="@string/end_credits_watch"
android:textAllCaps="false"
android:textColor="@color/next_up_button_text"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -50,6 +50,22 @@
android:textSize="11sp"
android:textStyle="bold" />
<!-- Why the list under this heading has nothing but Off in it. Without the
line, a title carrying no subtitles reads as the menu having failed rather
than as the film not having any, and the row that could fix it is below a
rule the eye has no reason to travel past. -->
<TextView
android:id="@+id/player_subtitle_empty_notice"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:text="@string/player_subtitle_none"
android:textColor="#96FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
+16
View File
@@ -32,10 +32,21 @@
<string name="player_subtitle_download">GET SUBTITLES</string>
<string name="player_subtitle_search">Search for subtitles…</string>
<string name="player_subtitle_search_again">Search again</string>
<!-- What the panel says when the title carries no subtitles at all. It is the reason
the track list has nothing but Off in it, and without it that list reads as the
menu having failed rather than as the film not having any. -->
<string name="player_subtitle_none">This title has no subtitles.</string>
<string name="player_subtitle_none_search">Find subtitles for this title…</string>
<!-- Shown in place of the track list on a backend that cannot fetch one, where there
is nothing to offer and the only honest thing to do is say so. -->
<string name="player_subtitle_none_unavailable">This title has no subtitles, and none can be downloaded.</string>
<string name="player_subtitle_searching">Looking for subtitles. This can take a moment.</string>
<string name="player_subtitle_search_empty">No subtitles were found for this release.</string>
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</string>
<string name="player_subtitle_download_failed">That subtitle could not be downloaded. Try another.</string>
<string name="player_subtitle_loaded">%1$s subtitles loaded</string>
<string name="player_subtitle_disabled">Subtitles off</string>
<string name="player_subtitle_generic">Selected</string>
<string name="player_cast_open">Cast</string>
<string name="player_back_to_close">BACK · CLOSE</string>
<string name="player_ends_at">Ends at %1$s</string>
@@ -69,6 +80,11 @@
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
<string name="next_up_starting_in">Starting in %1$ds</string>
<!-- The way back to the credits at normal size and normal speed. Worded as wanting the
credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. -->
<string name="end_credits_watch">Watch credits</string>
<string name="end_credits_speed">%1$s speed</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
@@ -0,0 +1,275 @@
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 credits rule, pinned against the same cases as the gateway's copy (`credits_test.go`).
* The two exist separately because with no gateway there is nobody to ask, and the picture
* must not start shrinking at a different moment depending on whether the container is up
* so when one of these changes, the other has to change with it.
*
* The cases come from a survey of a real 20,000-item library, and the shape of that survey is
* why the rule looks the way it does. Emby 4.10 wrote **no `CreditsStart` at all** only
* `Chapter`, `IntroStart` and `IntroEnd` while 216 items carried a chapter *named* like
* credits at 9098% of runtime. Two of them carried "Opening Credits" at 01%, which is the
* case the position floor exists for and the one worth never regressing.
*/
class CreditsTest {
private fun chapter(seconds: Long, marker: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * TICKS_PER_MS,
markerType = marker,
name = marker,
)
/**
* An ordinary chapter carrying a name, which is where the coverage actually comes from:
* Emby 4.10 writes no `CreditsStart`, but plenty of media carries "End Credits".
*/
private fun named(seconds: Long, name: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * TICKS_PER_MS,
markerType = "Chapter",
name = name,
)
@Test
fun `finds the marker on a real episode`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(
chapter(0, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(1200, "Chapter"),
chapter(1900, "CreditsStart"),
),
RUNTIME_MS,
),
)
}
/**
* A film commonly has the credits and no intro at all. This is the case that would have
* been lost had the two features shared one availability flag.
*/
@Test
fun `finds credits on a title with no intro`() {
assertEquals(
1_850_000L,
creditsStartFrom(
listOf(chapter(1400, "Chapter"), chapter(1850, "CreditsStart")),
RUNTIME_MS,
),
)
}
@Test
fun `refuses a list with no markers`() {
assertNull(
creditsStartFrom(listOf(chapter(0, "Chapter"), chapter(300, "Chapter")), RUNTIME_MS),
)
assertNull(creditsStartFrom(emptyList(), RUNTIME_MS))
}
/** An intro pair is a different feature and must never be read as credits. */
@Test
fun `refuses intro markers`() {
assertNull(
creditsStartFrom(
listOf(chapter(463, "IntroStart"), chapter(583, "IntroEnd")),
RUNTIME_MS,
),
)
}
/**
* A marker at zero says the whole file is credits, which is not something Emby means and
* not something worth shrinking a picture for.
*/
@Test
fun `refuses a marker at the very beginning`() {
assertNull(
creditsStartFrom(
listOf(chapter(0, "CreditsStart"), chapter(300, "Chapter")),
RUNTIME_MS,
),
)
}
/**
* The last marker wins, where [introSegmentFrom] takes the first. Two starts mean the
* markers are untrustworthy, and the two features are damaged in opposite directions: an
* intro skip that fires late throws somebody past the story, so the earlier marker is
* safer there; the credits pane firing early runs the last scene past somebody at double
* speed, so the later marker is safer here.
*/
@Test
fun `the later of two markers wins`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(chapter(1700, "CreditsStart"), chapter(1900, "CreditsStart")),
RUNTIME_MS,
),
)
// Order in the array is not trusted to be sorted.
assertEquals(
1_700_000L,
creditsStartFrom(
listOf(chapter(1900, "CreditsStart"), chapter(1700, "CreditsStart")),
RUNTIME_MS,
),
)
}
/**
* Squid Game, as the library actually holds it: no marker of any kind, one ordinary
* chapter named "Credits" at 90% of runtime. This is where every bit of this feature's
* coverage comes from today.
*/
@Test
fun `finds a chapter named Credits, which is all Emby 4-10 gives`() {
assertEquals(
1_800_000L,
creditsStartFrom(
listOf(
chapter(0, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
named(1800, "Credits"),
),
RUNTIME_MS,
),
)
assertEquals(
1_920_000L,
creditsStartFrom(listOf(named(1920, "End Credits")), RUNTIME_MS),
)
}
/**
* THE case. Belfast carries "Opening Credits" at 1% of runtime and Game of Thrones at 0%.
* Matching a name without testing the position starts the pane in the first minute of a
* film and runs its opening at double speed the worst thing this feature could do, and
* the reason the position floor exists.
*/
@Test
fun `Opening Credits at the start of a film is never the credit roll`() {
assertNull(
creditsStartFrom(
listOf(named(20, "Opening Credits"), chapter(600, "Chapter")),
RUNTIME_MS,
),
)
// Belfast in full: the opening one is rejected and the closing one found.
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(
named(20, "Opening Credits"),
chapter(600, "Chapter"),
named(1900, "End Credits"),
),
RUNTIME_MS,
),
)
}
/**
* "The Pitt" carries both "Credits" and "End Credits" seconds apart, describing one roll.
* The *earliest* qualifying chapter wins here the opposite of the marker rule because
* the roll begins at the first of them and taking the last would skip part of it.
*/
@Test
fun `two credits chapters describing one roll take the earlier`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(named(1920, "End Credits"), named(1900, "Credits")),
RUNTIME_MS,
),
)
}
/**
* Anything in the first three quarters is refused however it is worded, and the intro's own
* chapters named "Intro Start"/"Intro End" in this library are never a credit roll.
*/
@Test
fun `refuses a credits-named chapter too early to be the roll`() {
assertNull(creditsStartFrom(listOf(named(900, "Credits")), RUNTIME_MS))
assertNull(
creditsStartFrom(
listOf(named(1800, "Intro Start"), named(1900, "Intro End")),
RUNTIME_MS,
),
)
}
/**
* With no runtime an explicit marker is still honoured it is Emby asserting a position
* rather than this inferring one but a name is refused, because nothing can then tell an
* opening credit sequence from a closing one.
*/
@Test
fun `an unknown runtime honours a marker and refuses a name`() {
assertEquals(
1_900_000L,
creditsStartFrom(listOf(chapter(1900, "CreditsStart")), runtimeMs = 0L),
)
assertNull(creditsStartFrom(listOf(named(1900, "End Credits")), runtimeMs = 0L))
}
/**
* A marker below the floor is a mis-detection whoever wrote it, so it gives way to a name
* that does qualify rather than being honoured on authority.
*/
@Test
fun `a marker below the floor falls through to a name that qualifies`() {
assertEquals(
1_900_000L,
creditsStartFrom(
listOf(chapter(200, "CreditsStart"), named(1900, "End Credits")),
RUNTIME_MS,
),
)
}
/**
* The duration guard is a second, separate refusal: the rule above says *where* the roll
* is, and this says whether there is enough of it left to be worth moving the picture for.
*/
@Test
fun `refuses a marker too near the end to be worth a transition`() {
val duration = 2_760_000L
assertTrue(creditsWorthShowing(2_700_000L, duration))
// Twenty seconds of credits is the last card of a roll, not something to sit beside a
// panel — the transition would be most of what was left.
assertFalse(creditsWorthShowing(2_740_000L, duration))
}
@Test
fun `refuses a marker past the end of the file`() {
assertFalse(creditsWorthShowing(3_000_000L, 2_760_000L))
assertFalse(creditsWorthShowing(2_760_000L, 2_760_000L))
}
/** An unknown duration is not a reason to guess. */
@Test
fun `refuses when the duration is unknown or the marker absent`() {
assertFalse(creditsWorthShowing(2_700_000L, 0L))
assertFalse(creditsWorthShowing(null, 2_760_000L))
}
private companion object {
const val TICKS_PER_MS = 10_000L
/** A two-thousand-second episode, so a percentage of runtime reads as a round number. */
const val RUNTIME_MS = 2_000_000L
}
}
@@ -0,0 +1,37 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The rule the infinite scroll stops on. Both halves of it exist because either one alone
* leaves a real case broken: a shelf that stops half way through a genre, or a grid that
* asks forever for pages that will never come.
*/
class GenreBrowseTest {
@Test
fun `keeps paging while the genre has more`() {
assertTrue(hasMoreGenreItems(loaded = 48, total = 412, lastPageSize = 48, pageSize = 48))
}
@Test
fun `stops once everything has been loaded`() {
assertFalse(hasMoreGenreItems(loaded = 412, total = 412, lastPageSize = 44, pageSize = 48))
}
@Test
fun `a short page is the end, whatever the total claims`() {
// A backend that would not count sends a total this side cannot trust. The page
// itself is the evidence, and one short of what was asked for is the last one.
assertFalse(hasMoreGenreItems(loaded = 61, total = 9_999, lastPageSize = 13, pageSize = 48))
}
@Test
fun `an empty genre does not page`() {
assertFalse(hasMoreGenreItems(loaded = 0, total = 0, lastPageSize = 0, pageSize = 48))
// Nor does one whose backend reported nothing at all about how much there is.
assertFalse(hasMoreGenreItems(loaded = 48, total = 0, lastPageSize = 48, pageSize = 48))
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Test
class ImageCacheTest {
@Test
fun `an empty cache reads as zero megabytes rather than zero bytes`() {
// "0 B" on a storage page reads as a measurement that failed. The unit the rest of
// the page is counting in is what says the cache is simply empty.
assertEquals("0 MB", formatCacheSize(0L))
assertEquals("0 MB", formatCacheSize(-1L))
}
@Test
fun `sizes carry one unit and at most one decimal`() {
assertEquals("512 B", formatCacheSize(512L))
assertEquals("2 KB", formatCacheSize(2048L))
assertEquals("1 MB", formatCacheSize(1024L * 1024L))
assertEquals("1.5 MB", formatCacheSize(1024L * 1024L * 3L / 2L))
assertEquals("128 MB", formatCacheSize(128L * 1024L * 1024L))
assertEquals("1.2 GB", formatCacheSize((1.23 * 1024 * 1024 * 1024).toLong()))
}
@Test
fun `a whole number keeps no trailing decimal point`() {
assertEquals("2 MB", formatCacheSize(2L * 1024L * 1024L))
assertEquals("1 GB", formatCacheSize(1024L * 1024L * 1024L))
}
@Test
fun `the total is both halves`() {
val size = ImageCacheSize(diskBytes = 90L * 1024L * 1024L, memoryBytes = 38L * 1024L * 1024L)
assertEquals(128L * 1024L * 1024L, size.totalBytes)
assertEquals("128 MB", formatCacheSize(size.totalBytes))
}
}
@@ -12,6 +12,24 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SettingsStoreProfileRemovalTest {
@Test
fun `automatic artwork style persists in the active profile`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SettingsStore(context)
store.saveSession("https://artwork.example.test", "token", "art-user", "Ari", "server")
store.setHomeArtworkStyle("poster")
store.setHomeArtworkStyle("automatic")
val settings = store.snapshot()
assertEquals("automatic", settings.homeArtworkStyle)
assertEquals(
"automatic",
settings.profiles.single { it.userId == "art-user" }.homeArtworkStyle,
)
store.removeProfile(settings.profiles.single { it.userId == "art-user" }.id)
}
@Test
fun `ten minute reminder preference is persisted`() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
@@ -0,0 +1,66 @@
package com.ponzischeme89.memby.data
import androidx.compose.ui.graphics.Color
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.ui.theme.MembyPalette
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The client's whole share of the theme rule: reading hex, and refusing to.
*
* There is deliberately no parallel to `themes_test.go` here, unlike the subtitle and intro
* tests. Which theme is in force is decided in one place, on the gateway; this end only
* paints what it is handed, and with no gateway it paints the default. A second copy of the
* seasonal calendar would be a television that could disagree with the server about whether
* it is Christmas.
*/
class ThemesTest {
@Test
fun `reads the alpha-first form the gateway writes`() {
assertEquals(Color(0xFF52B54B), parseThemeColor("#FF52B54B"))
assertEquals(Color(0x28FFFFFF), parseThemeColor("#28FFFFFF"))
}
@Test
fun `six digits are opaque`() {
// The form somebody hand-editing a palette reaches for, and the form Emby's own
// artwork colours take. Read as fully opaque rather than fully transparent, which
// is what a naive parse produces and what would draw nothing at all.
assertEquals(Color(0xFF52B54B), parseThemeColor("52B54B"))
assertEquals(Color(0xFF52B54B), parseThemeColor("#52b54b"))
}
@Test
fun `anything unreadable is refused rather than guessed at`() {
// Every one of these has to come back null so the caller keeps the app's own token
// for that slot. A theme one colour wrong is a blemish; a screen drawn transparent
// because of a typo is a television nobody can use.
listOf(null, "", "#", "#FFF", "#GGGGGGGG", "#FF52B54B52", "rgb(1,2,3)", " ")
.forEach { assertNull("expected null for '$it'", parseThemeColor(it)) }
}
@Test
fun `a palette keeps the current colour for anything the server did not send`() {
val current = MembyPalette(accent = Color(0xFFAA0000), quietText = Color(0xFF445566))
// A gateway that has grown a token this build does not send back, or one field with
// a typo in it, must leave the rest of the scheme alone rather than resetting the
// whole palette to the class defaults.
val palette = GatewayPalette(surface = "#FF010203", accent = "not a colour")
.toMembyPalette(fallback = current)
assertEquals(Color(0xFF010203), palette.surface)
assertEquals("an unreadable colour keeps the one in force", current.accent, palette.accent)
assertEquals("an absent colour keeps the one in force", current.quietText, palette.quietText)
}
@Test
fun `an empty palette changes nothing`() {
// What a server predating themes effectively sends. It must be a no-op, not a repaint
// into the defaults over whatever the viewer already had.
val current = MembyPalette(accent = Color(0xFFAA0000))
assertEquals(current, GatewayPalette().toMembyPalette(fallback = current))
}
}
@@ -0,0 +1,29 @@
package com.ponzischeme89.memby.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class HomeGreetingTest {
@Test
fun `time of day selects the expected greeting`() {
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(4))
assertEquals(HomeGreetingPeriod.MORNING, homeGreetingPeriod(5))
assertEquals(HomeGreetingPeriod.MORNING, homeGreetingPeriod(11))
assertEquals(HomeGreetingPeriod.AFTERNOON, homeGreetingPeriod(12))
assertEquals(HomeGreetingPeriod.AFTERNOON, homeGreetingPeriod(16))
assertEquals(HomeGreetingPeriod.EVENING, homeGreetingPeriod(17))
}
@Test
fun `greeting is shown after hero through continue watching`() {
val rows = listOf("featured", "continue", "latest")
assertTrue(shouldShowHomeGreeting(true, "featured", rows))
assertTrue(shouldShowHomeGreeting(true, "continue", rows))
assertFalse(shouldShowHomeGreeting(true, "latest", rows))
assertFalse(shouldShowHomeGreeting(true, null, rows))
assertFalse(shouldShowHomeGreeting(false, "continue", rows))
}
}
@@ -17,6 +17,22 @@ class HomeMovieHeroTest {
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
}
@Test
fun `a card in a shelf stands the hero down without any scrolling`() {
// Continue Watching is the first row, so browsing it moves the list not at all.
assertEquals(
false,
shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = true),
)
// And Up out of that row, which clears the flag, is what brings the hero back.
assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true, rowFocused = false))
// A viewer partway down the launcher still gets no hero for losing row focus.
assertEquals(
false,
shouldShowHomeMovieHero(hasMovies = true, listAtTop = false, rowFocused = false),
)
}
@Test
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
@@ -0,0 +1,422 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.test.core.app.ApplicationProvider
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayPalette
import com.ponzischeme89.memby.data.model.Studio
import com.ponzischeme89.memby.data.parseThemeColor
import com.ponzischeme89.memby.data.toMembyPalette
import com.ponzischeme89.memby.ui.seasonal.Decorations
import com.ponzischeme89.memby.ui.seasonal.drawSeasonalField
import com.ponzischeme89.memby.ui.settings.SettingsPanelActions
import com.ponzischeme89.memby.ui.settings.SettingsPanelContent
import com.ponzischeme89.memby.ui.settings.SettingsPanelState
import com.ponzischeme89.memby.ui.settings.SettingsPage
import com.ponzischeme89.memby.ui.settings.ChoiceOption
import com.ponzischeme89.memby.ui.theme.MembyPalette
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Every colour scheme on a real screen, and the picker that chooses between them, to
* `build/screenshots/themes/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*ThemeScreenshotTest"
* ```
*
* This is the feature that needs looking at more than any other, because the only thing a
* unit test can check about a palette is that its hex parses. What it cannot check is whether
* the pale accent on Easter is legible against its own near-black, whether Halloween's quiet
* text survives on an orange-tinted surface, or whether the hairline under the tab strip
* still exists on Forest. Those are single hex digits in `themes.go` and every one of them is
* a judgement a scheme nobody has looked at is a scheme shipped on faith.
*
* The sweep deliberately renders **one screen** across every theme rather than several
* screens on one theme. A series page is the densest thing in the app for this purpose: it
* puts the surface, the accent (Play), all three text weights and the hairline in one frame,
* so the whole palette can be judged side by side across nine images that differ in nothing
* but colour.
*
* The palettes are duplicated here from the gateway's catalogue as a **fixture**, not as a
* second copy of a rule nothing in the app derives a colour from this list, and if it
* drifts the cost is a picture of a theme that has changed, which is the ordinary risk every
* screenshot fixture carries. Which theme is actually in force is decided in exactly one
* place, on the server, and is pinned there by `themes_test.go`.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class ThemeScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/**
* The palette is process-wide state, which is the whole reason a theme can repaint five
* separate composition roots. It also means a test that left one applied would tint every
* screenshot taken after it in the same JVM, so this reset is load-bearing rather than
* tidiness.
*/
@After
fun untheme() {
applyMembyPalette(MembyPalette())
}
/** Every scheme an operator can hand out, on the same page, in catalogue order. */
@Test
fun `every selectable scheme on a series page`() {
sweep("scheme", Themes.selectable)
}
/**
* The three nobody chooses. Worth its own test rather than folding into the sweep above,
* because these are the only palettes that appear on a television without anybody asking
* for them so "would I be annoyed to find my launcher like this" is a question that has
* to be answered by looking, before a date makes it everybody's problem at once.
*/
@Test
fun `the seasonal schemes`() {
sweep("season", Themes.seasonal)
}
/** The picker as an unrestricted viewer sees it, on the theme they have chosen. */
@Test
fun `the picker`() {
applyMembyPalette(theme("indigo").palette.toMembyPalette())
capture("picker-open") {
appearancePage(selected = "indigo", options = Themes.selectable)
}
}
/**
* The operator has left this person two schemes. The row is drawn from what the server
* sent, so a withheld theme is absent rather than greyed which is the thing to check
* here, along with whether two chips look deliberate or look like something failed to
* load.
*/
@Test
fun `a viewer the operator has restricted`() {
applyMembyPalette(theme("graphite").palette.toMembyPalette())
capture("picker-restricted") {
appearancePage(
selected = "graphite",
options = Themes.selectable.filter { it.id in setOf("midnight", "graphite") },
)
}
}
/**
* December. The page is painted Christmas, the viewer's own chip is still the one
* selected, and the line underneath is the server's explanation.
*
* This is the case worth looking at hardest, because it is the only setting in the app
* that can be overruled and it has one job: to not read as broken. A viewer who presses
* a chip and sees nothing happen, with no sentence saying why, files a bug.
*/
@Test
fun `the picker while a season is on`() {
applyMembyPalette(theme("christmas").palette.toMembyPalette())
capture("picker-locked") {
appearancePage(
selected = "plum",
options = Themes.selectable,
locked = true,
notice = "Christmas is on for everyone until it is over.",
)
}
}
/**
* A gateway that predates themes, or the direct path with no gateway at all: nothing was
* offered, so the row is not drawn and Appearance is the page it always was. Compare with
* `picker-open` the difference must be one missing row and nothing shifted.
*/
@Test
fun `no schemes offered`() {
capture("picker-absent") { appearancePage(selected = "midnight", options = emptyList()) }
}
/**
* The decorations, three frames each, over the palette they belong to.
*
* Three rather than one because the only thing worth checking about an animation in a
* still is that it *is* one that the field is spread rather than clumped at any given
* moment, and that a bat's wings are somewhere different in each. `drawSeasonalField`
* takes the progress, so this needs no animation clock and cannot be flaky: the same
* number always draws the same picture.
*
* `0.0` and `1.0` are both captured for snow, and they must come out **identical**. That
* is the seamless wrap the whole particle scheme is built around, and it is the one
* property nobody could confirm by watching a television for less than a minute.
*/
@Test
fun `snow`() {
frames("christmas", Decorations.SNOW, listOf(0f, 0.37f, 1f))
}
/** One test each, because a compose rule allows one `setContent` per test. */
@Test
fun `bats`() {
frames("halloween", Decorations.BATS, listOf(0f, 0.37f, 0.74f))
}
@Test
fun `blossom`() {
frames("easter", Decorations.BLOSSOM, listOf(0f, 0.37f, 0.74f))
}
/**
* Snow over a page with content on it the only view that answers the question that
* actually matters. Decoration competing with the title, the facts and the Play button is
* worse than no decoration, and no amount of looking at particles on plain black would
* ever show it.
*/
@Test
fun `a decoration over a page somebody is reading`() {
val palette = theme("christmas").palette.toMembyPalette()
applyMembyPalette(palette)
capture("decoration-over-content") {
Box {
seriesPage()
Canvas(Modifier.fillMaxSize()) {
drawSeasonalField(Decorations.SNOW, 0.37f, palette.accent, palette.onSurface)
}
}
}
}
// ---------------------------------------------------------------------------
/**
* One field, composed once, captured at several points through its cycle.
*
* The progress is snapshot state read inside the draw lambda the same arrangement the
* real animation uses, so moving it here invalidates the draw and nothing else. That is
* not incidental to the test: if advancing the frame recomposed anything, this would be
* quietly measuring a different thing from what ships.
*/
private fun frames(themeId: String, decoration: String, progressions: List<Float>) {
val palette = theme(themeId).palette.toMembyPalette()
applyMembyPalette(palette)
val progress = mutableFloatStateOf(progressions.first())
compose.setContent {
Box(Modifier.fillMaxSize().background(palette.surface)) {
Canvas(Modifier.fillMaxSize()) {
drawSeasonalField(decoration, progress.floatValue, palette.accent, palette.onSurface)
}
}
}
progressions.forEach { at ->
progress.floatValue = at
compose.waitForIdle()
compose.onRoot().captureRoboImage(
"build/screenshots/themes/decoration-$decoration-${(at * 100).toInt()}.png",
)
}
}
/**
* One page, composed once, captured under each palette in turn.
*
* Composed once because `setContent` may only be called once per test but it is also
* the more honest picture. Repainting a live composition is exactly what happens on a
* television when a season begins under somebody who is already looking at the screen,
* and it only works because the tokens are snapshot state: nothing here re-composes the
* page or tells it anything changed.
*/
private fun sweep(prefix: String, themes: List<ThemeFixture>) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { seriesPage() }
}
themes.forEach { theme ->
applyMembyPalette(theme.palette.toMembyPalette())
compose.waitForIdle()
compose.onRoot().captureRoboImage("build/screenshots/themes/$prefix-${theme.id}.png")
}
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/themes/$name.png")
}
@Composable
private fun seriesPage() {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
)
}
@Composable
private fun appearancePage(
selected: String,
options: List<ThemeFixture>,
locked: Boolean = false,
notice: String = "",
) {
SettingsPanelContent(
state = SettingsPanelState(
selectedPage = SettingsPage.APPEARANCE,
themeId = selected,
themeOptions = options.map { theme ->
ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent))
},
themeLocked = locked,
themeNotice = notice,
installedVersion = "0.2.28",
),
actions = SettingsPanelActions(),
overlay = false,
)
}
private fun theme(id: String): ThemeFixture =
(Themes.selectable + Themes.seasonal).first { it.id == id }
private val series = BaseItem(
id = "series-1",
name = "Signal Hill",
type = "Series",
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
"down what she hears.",
productionYear = 2022,
officialRating = "TV-MA",
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
status = "Ended",
)
private val episodes = (1..8).map { number ->
BaseItem(
id = "s1e$number",
name = EPISODE_TITLES[number - 1],
type = "Episode",
seriesName = "Signal Hill",
parentIndexNumber = 1,
indexNumber = number,
runTimeTicks = 48L * 600_000_000L,
)
}
/** One theme as the gateway describes it. A fixture; see the note at the top. */
internal data class ThemeFixture(
val id: String,
val name: String,
val palette: GatewayPalette,
)
private object Themes {
val selectable = listOf(
fixture(
"midnight", "Midnight",
"#FF090B0D", "#FF101418", "#FF52B54B", "#FFE2E5E8",
"#FFD0D6DB", "#FFAEB7BF", "#28FFFFFF", "#FF20252A",
),
fixture(
"graphite", "Graphite",
"#FF0D0C0A", "#FF181614", "#FFE0A33C", "#FFE9E5DE",
"#FFD8D2C8", "#FFB6AEA1", "#28FFF3DC", "#FF262320",
),
fixture(
"indigo", "Indigo",
"#FF07090F", "#FF111726", "#FF5C8DFF", "#FFE1E6F0",
"#FFCBD4E4", "#FFA5B0C6", "#28C7D8FF", "#FF1D2435",
),
fixture(
"ember", "Ember",
"#FF0C0808", "#FF181111", "#FFE05B4A", "#FFEDE3E1",
"#FFDACECB", "#FFB8A7A3", "#28FFD5CE", "#FF261B1A",
),
fixture(
"forest", "Forest",
"#FF080B09", "#FF111713", "#FF7FC08A", "#FFE3E8E3",
"#FFCFD8CF", "#FFA9B5AA", "#28D2F0D6", "#FF1E2620",
),
fixture(
"plum", "Plum",
"#FF0B080D", "#FF171020", "#FFB37FE0", "#FFE7E2EC",
"#FFD5CCDD", "#FFB0A4BC", "#28E4D2FF", "#FF241B2D",
),
)
val seasonal = listOf(
fixture(
"halloween", "Halloween",
"#FF0A0704", "#FF17100A", "#FFFF8A1F", "#FFF2E7DA",
"#FFE2D2BE", "#FFBBA48C", "#28FFB870", "#FF26190E",
),
fixture(
"christmas", "Christmas",
"#FF060A07", "#FF0E1710", "#FFE0403F", "#FFEAF0E9",
"#FFD6E0D5", "#FFAEBCAE", "#28CFE8CF", "#FF19261B",
),
fixture(
"easter", "Easter",
"#FF0A0910", "#FF15131F", "#FF9BD3F0", "#FFEDE9F2",
"#FFDCD6E4", "#FFB6AEC4", "#28D8E9F7", "#FF211E2E",
),
)
@Suppress("LongParameterList")
private fun fixture(
id: String,
name: String,
surface: String,
surfaceRaised: String,
accent: String,
onSurface: String,
mutedText: String,
quietText: String,
hairline: String,
ratingsSurface: String,
) = ThemeFixture(
id, name,
GatewayPalette(
surface = surface, surfaceRaised = surfaceRaised, accent = accent,
onSurface = onSurface, mutedText = mutedText, quietText = quietText,
hairline = hairline, ratingsSurface = ratingsSurface,
),
)
}
private companion object {
val EPISODE_TITLES = listOf(
"Carrier Wave", "Dead Air", "The Long Count", "Nightingale",
"Six Weeks Out", "Landfall", "The Shipping Forecast", "Quiet Hours",
)
}
}
@@ -0,0 +1,123 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The credits speed ramp and its climbdown.
*
* The fallback is the half worth pinning. Doubling the speed doubles the bitrate pulled from
* Emby over HTTP, so the ceiling has to be able to fall and it must never climb back inside
* one credit roll, or a marginal stream would oscillate between stuttering and recovering for
* the length of the roll.
*/
class CreditsSpeedTest {
@Test
fun `the ramp starts at normal speed and ends at the ceiling`() {
assertEquals(CREDITS_NORMAL_SPEED, creditsSpeedAt(0L, CREDITS_TARGET_SPEED), TOLERANCE)
assertEquals(
CREDITS_TARGET_SPEED,
creditsSpeedAt(CREDITS_RAMP_MS, CREDITS_TARGET_SPEED),
TOLERANCE,
)
// And stays there rather than overshooting once the ramp is past.
assertEquals(
CREDITS_TARGET_SPEED,
creditsSpeedAt(CREDITS_RAMP_MS * 10, CREDITS_TARGET_SPEED),
TOLERANCE,
)
}
@Test
fun `the ramp only ever moves forwards`() {
var previous = CREDITS_NORMAL_SPEED
var elapsed = 0L
while (elapsed <= CREDITS_RAMP_MS) {
val speed = creditsSpeedAt(elapsed, CREDITS_TARGET_SPEED)
assertTrue(
"speed went backwards at ${elapsed}ms: $previous then $speed",
speed >= previous - TOLERANCE,
)
previous = speed
elapsed += 60L
}
}
/**
* Eased out, the same curve every other transition in this package uses: most of the
* change happens immediately, so the effect reads as the picture being let go rather than
* as a slow drift nobody attributes to anything.
*/
@Test
fun `the ramp is past halfway by the time it is half over`() {
val halfway = creditsSpeedAt(CREDITS_RAMP_MS / 2, CREDITS_TARGET_SPEED)
val midpoint = (CREDITS_NORMAL_SPEED + CREDITS_TARGET_SPEED) / 2f
assertTrue("halfway speed was $halfway, wanted past $midpoint", halfway > midpoint)
}
/** A ramp to nowhere must not produce a curve. */
@Test
fun `a ceiling of normal speed produces normal speed throughout`() {
listOf(0L, 100L, CREDITS_RAMP_MS, CREDITS_RAMP_MS * 4).forEach { elapsed ->
assertEquals(
CREDITS_NORMAL_SPEED,
creditsSpeedAt(elapsed, CREDITS_NORMAL_SPEED),
TOLERANCE,
)
}
}
@Test
fun `the ceiling falls one step at a time and stops at normal speed`() {
val first = creditsCeilingAfterStall(CREDITS_TARGET_SPEED)
assertEquals(1.5f, first, TOLERANCE)
val second = creditsCeilingAfterStall(first)
assertEquals(CREDITS_NORMAL_SPEED, second, TOLERANCE)
// The floor is terminal: there is nowhere below it and nothing left to give up.
assertEquals(CREDITS_NORMAL_SPEED, creditsCeilingAfterStall(second), TOLERANCE)
}
/**
* The one property that must hold for a marginal stream: a stall never leaves the ceiling
* where it was, so a title that cannot hold 2× cannot be asked to hold it again.
*/
@Test
fun `a stall always gives up speed`() {
var ceiling = CREDITS_TARGET_SPEED
repeat(6) {
val next = creditsCeilingAfterStall(ceiling)
assertTrue("ceiling rose from $ceiling to $next", next <= ceiling + TOLERANCE)
ceiling = next
}
assertEquals(CREDITS_NORMAL_SPEED, ceiling, TOLERANCE)
}
@Test
fun `only a ceiling above normal speed counts as active`() {
assertTrue(creditsSpeedIsActive(CREDITS_TARGET_SPEED))
assertTrue(creditsSpeedIsActive(1.5f))
assertFalse(creditsSpeedIsActive(CREDITS_NORMAL_SPEED))
assertFalse(creditsSpeedIsActive(0.5f))
}
/**
* The chip is built from whole tenths rather than by formatting the float: a quantised
* 1.5 is not exactly 1.5 in binary, so `toString` on it prints "1.5000001", and
* `String.format` would print a comma for the point on a set configured in half of Europe.
*/
@Test
fun `the label reads as a person would write it`() {
assertEquals("2×", creditsSpeedLabel(CREDITS_TARGET_SPEED))
assertEquals("1.5×", creditsSpeedLabel(1.5f))
assertEquals("1×", creditsSpeedLabel(CREDITS_NORMAL_SPEED))
// And off the ramp, where the speed is whatever the curve produced.
assertEquals("1.8×", creditsSpeedLabel(1.7996f))
}
private companion object {
const val TOLERANCE = 0.03f
}
}
@@ -0,0 +1,197 @@
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.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 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 credits pane over a stand-in for a running credit roll, captured from the real player
* XML at TV resolution `build/screenshots/end-credits/`.
*
* The point of capturing it is the *split*, which no assertion can check: the panel is
* measured against a picture the activity scales to half width, so the two halves have to
* balance, and the only way to know they do is to look. The scaled picture here is drawn
* rather than played, at exactly the transform `shrinkVideoForCredits` applies.
*
* There is no scrim under this panel, so the frame behind it is deliberately bright: a
* capture over black would prove nothing about legibility the same reason
* `SkipIntroScreenshotTest` uses a bright scene.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EndCreditsScreenshotTest {
/** The ordinary case: minutes of credits left, so no countdown yet. */
@Test
fun `an episode rolling its credits`() {
capture(
name = "end-credits",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
countdown = null,
)
}
/**
* Inside the last minute, where the countdown appears. It lives in the pane rather than
* in the next-up banner, which is suppressed while this is up two of them would fight
* over the same transform and print the episode twice.
*/
@Test
fun `the last minute, counting down`() {
capture(
name = "end-credits-countdown",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
countdown = "Starting in 28s",
)
}
/**
* The stream could not hold 2×, so the ceiling fell. Worth a capture because the chip is
* the only thing on screen that ever says so, and "1.5× speed" has to sit in the same
* plate as "2× speed" without changing the layout around it.
*/
@Test
fun `a stream that could only manage one and a half`() {
capture(
name = "end-credits-reduced-speed",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = 1.5f,
countdown = null,
)
}
/**
* A long title with no episode code the shape a series whose next entry is named but
* unnumbered takes. The meta line is gone rather than blank, so the title has to sit
* correctly against the artwork above it with nothing between them.
*/
@Test
fun `a long title and no episode metadata`() {
capture(
name = "end-credits-long-title",
title = "The Cartographer of the Lower Reaches",
meta = "",
speed = CREDITS_TARGET_SPEED,
countdown = null,
)
}
private fun capture(
name: String,
title: String,
meta: String,
speed: Float,
countdown: String?,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { setBackgroundColor(Color.BLACK) }
// The picture, where the activity's transform puts it. Scaling a view that is not
// laid out yet does nothing, so this stands in for the PlayerView at the finished
// scale and offset rather than trying to animate one.
val picture = ImageView(activity).apply {
setImageBitmap(creditRollFrame())
scaleType = ImageView.ScaleType.FIT_CENTER
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
)
scaleX = CREDITS_VIDEO_SCALE
scaleY = CREDITS_VIDEO_SCALE
// The shift matters as much as the scale: without it the picture stays centred
// and the panel is drawn over it, which is exactly the failure this capture is
// here to catch.
translationX = -SCREEN_WIDTH_PX * CREDITS_VIDEO_SHIFT_X
}
root.addView(picture)
val pane = LayoutInflater.from(activity)
.inflate(R.layout.player_end_credits, root, false)
pane.visibility = View.VISIBLE
pane.findViewById<TextView>(R.id.player_end_credits_title).text = title
pane.findViewById<TextView>(R.id.player_end_credits_meta).apply {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
pane.findViewById<TextView>(R.id.player_end_credits_speed).text =
activity.getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
pane.findViewById<TextView>(R.id.player_end_credits_countdown).apply {
text = countdown.orEmpty()
visibility = if (countdown == null) View.GONE else View.VISIBLE
}
pane.findViewById<ImageView>(R.id.player_end_credits_image)
.setImageBitmap(nextEpisodeArtwork())
// The remote lands on Play, which is what the pane does on opening — so the capture
// shows the focus state a viewer actually sees.
root.addView(pane)
activity.setContentView(root)
pane.findViewById<View>(R.id.player_end_credits_play).requestFocus()
root.captureRoboImage("build/screenshots/end-credits/$name.png")
}
/**
* Stands in for a credit roll. Deliberately bright at the top: there is no scrim between
* the picture and the panel, and the point of the capture is that they stay separable.
*/
private fun creditRollFrame(): Bitmap = gradient(
width = 640,
height = 360,
colours = intArrayOf(
Color.rgb(226, 222, 210),
Color.rgb(120, 118, 112),
Color.rgb(18, 18, 20),
),
)
private fun nextEpisodeArtwork(): Bitmap = gradient(
width = 480,
height = 270,
colours = intArrayOf(
Color.rgb(58, 92, 104),
Color.rgb(30, 44, 58),
Color.rgb(12, 16, 22),
),
)
private fun gradient(width: Int, height: Int, colours: IntArray): Bitmap {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawPaint(
Paint().apply {
shader = LinearGradient(
0f, 0f, width.toFloat(), height.toFloat(),
colours, null, Shader.TileMode.CLAMP,
)
},
)
return bitmap
}
private companion object {
/** The width the `w960dp-…-xhdpi` qualifier gives, in pixels. */
const val SCREEN_WIDTH_PX = 1920f
}
}
@@ -104,7 +104,7 @@ class SubtitleMenuScreenshotTest {
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Search for subtitles…")),
entries = listOf(entry("Search for subtitles…", prominent = true)),
),
)
}
@@ -119,7 +119,9 @@ class SubtitleMenuScreenshotTest {
available = true,
status = "Looking for subtitles. This can take a moment.",
expanded = true,
entries = listOf(SubtitleMenuEntry("Search for subtitles…", false, enabled = false)),
entries = listOf(
SubtitleMenuEntry("Search for subtitles…", false, enabled = false, prominent = true),
),
),
)
}
@@ -137,7 +139,7 @@ class SubtitleMenuScreenshotTest {
available = true,
expanded = true,
entries = listOf(
entry("Search again"),
entry("Search again", prominent = true),
entry("English · 98% match"),
entry("English · Hearing impaired · 96% match"),
entry("English · Forced · 91% match"),
@@ -158,12 +160,47 @@ class SubtitleMenuScreenshotTest {
available = true,
status = "No subtitles were found for this release.",
expanded = true,
entries = listOf(entry("Search for subtitles…")),
entries = listOf(entry("Search for subtitles…", prominent = true)),
),
)
}
private fun entry(label: String, selected: Boolean = false) = SubtitleMenuEntry(label, selected)
/**
* A title with no subtitles at all, which is the case the whole feature exists for and
* the one a capture is worth most on: the track list holds nothing but Off, and whether
* that reads as "this film has none" or as "the menu is broken" is entirely down to the
* notice under the heading and where the focus ring landed.
*/
@Test
fun `title has no subtitles`() {
capture(
name = "subtitles-menu-none-downloadable",
tracks = listOf(entry("Off", selected = true)),
tracksState = SubtitleTracksState(empty = true, downloadable = true),
downloads = SubtitleDownloadState(
available = true,
entries = listOf(entry("Find subtitles for this title…", prominent = true)),
),
focusedDownload = 0,
)
}
/** The same title on a backend with no provider: there is nothing to offer, and the
* panel says so once rather than leaving somebody looking for an option. */
@Test
fun `title has no subtitles and none can be fetched`() {
capture(
name = "subtitles-menu-none-unavailable",
tracks = listOf(entry("Off", selected = true)),
tracksState = SubtitleTracksState(empty = true, downloadable = false),
)
}
private fun entry(
label: String,
selected: Boolean = false,
prominent: Boolean = false,
) = SubtitleMenuEntry(label, selected, prominent = prominent)
private fun capture(
name: String,
@@ -172,6 +209,7 @@ class SubtitleMenuScreenshotTest {
focusedSize: Int? = null,
focusedDownload: Int? = null,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
tracksState: SubtitleTracksState = SubtitleTracksState(),
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity)
@@ -200,6 +238,7 @@ class SubtitleMenuScreenshotTest {
tracks = tracks,
sizes = listOf(entry("Small"), entry("Medium", selected = true), entry("Large")),
downloads = downloads,
tracksState = tracksState,
)
activity.setContentView(root)
@@ -15,9 +15,10 @@ import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.ImageCacheSize
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -68,12 +69,17 @@ class SettingsSheetScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-home.png")
}
/**
* The welcome tone lives on Appearance now, under the colour and logo rows. The capture
* is of the whole page rather than the chip row, because the thing worth looking at is
* whether a fourth question fits on it.
*/
@Test
fun `welcome tone options`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.WELCOME)
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.APPEARANCE)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-welcome.png")
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-appearance.png")
}
@Test
@@ -85,11 +91,11 @@ class SettingsSheetScreenshotTest {
}
@Test
fun `updates page`() {
fun `stored artwork page`() {
compose.setContent {
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.UPDATES)
SettingsPreviewFixture(overlay = false, selectedPage = SettingsPage.STORAGE)
}
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-updates.png")
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-storage.png")
}
@Test
@@ -104,6 +110,49 @@ class SettingsSheetScreenshotTest {
}
}
// The other half of the rail — selection by *focus*, through SETTINGS_PAGE_SETTLE_MS —
// is deliberately not tested here. Robolectric's host view never takes window focus, so
// a semantics requestFocus on a rail item does not reach onFocusChanged and the settle
// never starts; a test written against it would pass or fail for reasons that have
// nothing to do with the rail. It is exercised on a television.
/**
* Every switch on every page turns on. The pages are walked by the rail, because a
* control that cannot be reached is as good as one that does not work.
*/
@Test
fun `every toggle on every page can be turned on`() {
val turnedOn = mutableSetOf<String>()
compose.setContent { InteractiveSettingsFixture(onToggled = { turnedOn += it }) }
compose.waitForIdle()
val togglesByPage = mapOf(
SettingsPage.APPEARANCE to listOf("Show logos"),
SettingsPage.PLAYBACK to listOf(
"Ten minutes left",
"Play the next episode",
"Closing credits",
),
SettingsPage.HOME to listOf(
"Continue watching",
"Favourites",
"Latest movies",
"Hide films you have seen",
"Text under cards",
"Ratings",
),
)
togglesByPage.forEach { (page, titles) ->
compose.onNodeWithTag("settings-rail-${page.name.lowercase()}").performClick()
compose.waitForIdle()
titles.forEach { title ->
compose.onNodeWithText(title).performScrollTo().performClick()
compose.waitForIdle()
}
}
assertEquals(togglesByPage.values.flatten().toSet(), turnedOn)
}
@Test
fun `about lists the release history and opens one release`() {
compose.setContent { InteractiveSettingsFixture() }
@@ -128,21 +177,82 @@ class SettingsSheetScreenshotTest {
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
}
/**
* The panel wired to real state, so a press changes something a test can read. Every
* switch starts off: the assertion worth making is that each one can be turned *on*,
* which a fixture holding the defaults could not tell apart from a press doing nothing.
*/
@Composable
private fun InteractiveSettingsFixture() {
private fun InteractiveSettingsFixture(onToggled: (String) -> Unit = {}) {
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
var showLogo by remember { mutableStateOf(false) }
var autoPlayNext by remember { mutableStateOf(false) }
var tenMinutes by remember { mutableStateOf(false) }
var speedUpCredits by remember { mutableStateOf(false) }
var hideWatched by remember { mutableStateOf(false) }
var cardMetadata by remember { mutableStateOf(false) }
var ratings by remember { mutableStateOf(false) }
var homeSections by remember { mutableStateOf(emptySet<String>()) }
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) {
delay(170)
firstFocus.requestFocus()
}
// Only an on counts. A recorded off would let a row that reported the wrong
// direction pass the test that exists to catch exactly that.
fun record(title: String, enabled: Boolean) {
if (enabled) onToggled(title)
}
PreviewSurface {
SettingsPanelContent(
state = SettingsPanelState(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
showTenMinuteReminder = tenMinutes,
speedUpCredits = speedUpCredits,
hideWatchedMovies = hideWatched,
showCardMetadata = cardMetadata,
showRatingsStrip = ratings,
homeSections = homeSections,
selectedPage = selectedPage,
installedVersion = "0.1.60",
),
actions = SettingsPanelActions(onPageSelected = { selectedPage = it }),
actions = SettingsPanelActions(
onPageSelected = { selectedPage = it },
onShowLogoChanged = { showLogo = it; record("Show logos", it) },
onAutoPlayNextChanged = {
autoPlayNext = it
record("Play the next episode", it)
},
onShowTenMinuteReminderChanged = {
tenMinutes = it
record("Ten minutes left", it)
},
onSpeedUpCreditsChanged = {
speedUpCredits = it
record("Closing credits", it)
},
onHideWatchedMoviesChanged = {
hideWatched = it
record("Hide films you have seen", it)
},
onShowCardMetadataChanged = {
cardMetadata = it
record("Text under cards", it)
},
onShowRatingsStripChanged = { ratings = it; record("Ratings", it) },
onHomeSectionChanged = { key, enabled ->
homeSections = if (enabled) homeSections + key else homeSections - key
record(
when (key) {
"continue" -> "Continue watching"
"favorites" -> "Favourites"
else -> "Latest movies"
},
enabled,
)
},
),
overlay = false,
firstFocusRequester = firstFocus,
)
@@ -152,7 +262,7 @@ class SettingsSheetScreenshotTest {
@Composable
private fun SettingsPreviewFixture(
overlay: Boolean,
selectedPage: SettingsPage = if (overlay) SettingsPage.UPDATES else SettingsPage.APPEARANCE,
selectedPage: SettingsPage = if (overlay) SettingsPage.DEVICES else SettingsPage.APPEARANCE,
) {
val firstFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { firstFocus.requestFocus() }
@@ -168,16 +278,13 @@ class SettingsSheetScreenshotTest {
showCardMetadata = false,
welcomeQuoteStyle = "homicidal",
selectedPage = selectedPage,
updateStatus = if (overlay) {
UpdateStatus.Available(
version = "0.1.61",
apkUrl = "https://example.invalid/memby.apk",
notes = "Faster startup and a better settings rail.",
)
} else {
null
},
installedVersion = "0.1.60",
// A measured cache rather than the null the page opens on: the capture
// worth looking at is the one with figures in it.
imageCacheSize = ImageCacheSize(
diskBytes = 96L * 1024L * 1024L,
memoryBytes = 21L * 1024L * 1024L,
),
),
actions = SettingsPanelActions(),
overlay = overlay,
@@ -62,12 +62,6 @@ class WhatsNewTest {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
}
@Test
fun `known labels become their own chip`() {
assertEquals(ChangeLine("FIXED", "A thing."), changeLine("Fixed: A thing."))
assertEquals(ChangeLine("ADDED", "Another thing."), changeLine("Added: Another thing."))
}
@Test
fun `the bullet count is cut to what the screen can hold`() {
// A 720p television, which is the small case this exists for.
@@ -77,13 +71,4 @@ class WhatsNewTest {
// Never zero: a panel with a heading and nothing under it says nothing at all.
assertEquals(1, maxChangesFor(200))
}
@Test
fun `a sentence that merely contains a colon is left whole`() {
assertEquals(
ChangeLine(null, "Rename a TV in Settings: Devices."),
changeLine("Rename a TV in Settings: Devices."),
)
assertEquals(ChangeLine(null, "No label here."), changeLine("No label here."))
}
}