Release v0.2.34
This commit is contained in:
@@ -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 90–98%
|
||||
* 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"
|
||||
|
||||
@@ -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 90–98% of runtime. Two of them carried "Opening Credits" at 0–1%, 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)
|
||||
|
||||
|
||||
+125
-18
@@ -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."))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user