Release v0.2.34
This commit is contained in:
+11
-4
@@ -78,10 +78,17 @@ MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758
|
||||
# import still hears about it. 0 turns the banners off.
|
||||
MEMBY_RADARR_ALERT_WINDOW=3h
|
||||
|
||||
# Optional Bazarr integration, which is what lets a viewer fetch a subtitle from the
|
||||
# player for a title the library has none for. Bazarr writes the file beside the media
|
||||
# file, so Emby serves the result and Memby stores nothing — leaving these unset simply
|
||||
# means the option is never offered. The API key is in Bazarr under Settings > General.
|
||||
# Optional Bazarr integration, one of the two providers a viewer can fetch a missing
|
||||
# subtitle from. Bazarr writes the file beside the media file, so Emby serves the result
|
||||
# and Memby stores nothing — leaving these unset simply means Bazarr is never offered. The
|
||||
# API key is in Bazarr under Settings > General.
|
||||
#
|
||||
# Whether viewers may actually use it is a switch on the admin console's Subtitles page,
|
||||
# not an environment variable: the address is deployment configuration and belongs here,
|
||||
# but turning the provider on and off is an operator's decision that should not need a
|
||||
# redeployment. The second provider, OpenSubtitles, is configured entirely on that page —
|
||||
# it needs only an API key, and the credentials live in the database rather than in this
|
||||
# file.
|
||||
MEMBY_BAZARR_URL=http://10.0.0.2:6767
|
||||
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
|
||||
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
## 0.2.34 — 2026-08-09
|
||||
- Added: Closing credits can move aside and speed up while the next episode is ready.
|
||||
- Added: More colour themes, including seasonal themes for the whole household.
|
||||
- Added: Missing subtitles can now be found through OpenSubtitles as well as Bazarr.
|
||||
- Improved: Genre browsing now shows the right titles and loads more as you scroll.
|
||||
- Improved: The home screen, search, subtitles and settings are clearer and easier to use.
|
||||
|
||||
## 0.2.33 — 2026-08-08
|
||||
- General bug fixes and improvements.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ val projectNoticeText =
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.33"
|
||||
val defaultVersionName = "0.2.34"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -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."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,10 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
s.adminAuth(s.handleAdminRestorePreferences))
|
||||
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
|
||||
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
||||
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
|
||||
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
||||
@@ -65,6 +67,8 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
||||
mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
|
||||
mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
|
||||
mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
|
||||
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
|
||||
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
||||
|
||||
@@ -229,23 +233,24 @@ func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, ne
|
||||
type adminStatus struct {
|
||||
// ServerVersion is what the page's footer reports. An operator reading the live log
|
||||
// needs to know which build wrote it, and the page is the one place that is asked.
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
Runs []store.SyncRun `json:"runs"`
|
||||
SyncEvery string `json:"syncEvery"`
|
||||
ForYou store.ForYouStats `json:"forYou"`
|
||||
ForYouRunning bool `json:"forYouRunning"`
|
||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||
Features featureResponse `json:"features"`
|
||||
RequestUsers []store.KnownUser `json:"requestUsers"`
|
||||
Clients []store.KnownClient `json:"clients"`
|
||||
SonarrReady bool `json:"sonarrReady"`
|
||||
RadarrReady bool `json:"radarrReady"`
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Maintenance store.Maintenance `json:"maintenance"`
|
||||
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
|
||||
Library store.LibraryStats `json:"library"`
|
||||
SyncRunning bool `json:"syncRunning"`
|
||||
Runs []store.SyncRun `json:"runs"`
|
||||
SyncEvery string `json:"syncEvery"`
|
||||
ForYou store.ForYouStats `json:"forYou"`
|
||||
ForYouRunning bool `json:"forYouRunning"`
|
||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||
Subtitles subtitleAdminSettings `json:"subtitles"`
|
||||
Features featureResponse `json:"features"`
|
||||
RequestUsers []store.KnownUser `json:"requestUsers"`
|
||||
Clients []store.KnownClient `json:"clients"`
|
||||
SonarrReady bool `json:"sonarrReady"`
|
||||
RadarrReady bool `json:"radarrReady"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -304,6 +309,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return policy
|
||||
}(),
|
||||
Subtitles: s.subtitleAdminSettings(ctx),
|
||||
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
||||
RequestUsers: requestUsers,
|
||||
Clients: clients,
|
||||
|
||||
@@ -303,6 +303,22 @@ select { padding-right: 8px; }
|
||||
.checks { display: grid; gap: 8px; }
|
||||
.checks.columns { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
|
||||
|
||||
/* A theme, shown as the colours it actually is.
|
||||
This is the one component whose colours are data rather than vocabulary, so the three
|
||||
custom properties below are set from a style attribute on the element — the fragment
|
||||
still declares no *look* of its own, only which palette this row is. Anything else about
|
||||
how a swatch is drawn belongs here. */
|
||||
.swatch {
|
||||
flex: 0 0 auto; width: 46px; height: 30px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line); overflow: hidden; display: flex; align-items: flex-end;
|
||||
background: var(--swatch-surface, var(--surface));
|
||||
}
|
||||
.swatch i {
|
||||
display: block; width: 100%; height: 9px;
|
||||
background: var(--swatch-accent, var(--accent));
|
||||
border-top: 1px solid var(--swatch-hairline, var(--line));
|
||||
}
|
||||
|
||||
.hint { color: var(--muted); font-size: 12.5px; margin: 0; }
|
||||
|
||||
/* ---------- tags, notices ---------- */
|
||||
|
||||
@@ -152,6 +152,7 @@ const Admin = (() => {
|
||||
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
|
||||
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
|
||||
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
|
||||
captions: 'M4 5.5h16v13H4zM7 11h3m2 0h5M7 15h5m3 0h2',
|
||||
};
|
||||
|
||||
const icon = (name) => (icons[name]
|
||||
|
||||
@@ -46,6 +46,27 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Colour schemes</h2>
|
||||
<p class="card-note">Which palettes this person may choose between in Settings →
|
||||
Appearance. Tick everything to leave them unrestricted. Their current choice is
|
||||
an ordinary setting above; withdrawing it here puts them back on Midnight.</p>
|
||||
<p class="card-note">Seasonal themes are not listed. They apply to every television in
|
||||
the house for their dates and nobody can decline one — the only switch is
|
||||
<em>Seasonal themes</em> on the features page.</p>
|
||||
</div>
|
||||
<span id="account-themes-state"></span>
|
||||
</div>
|
||||
<div class="checks columns" id="account-themes"></div>
|
||||
<div class="card-foot">
|
||||
<button class="primary" data-account-action="save-themes">Save colour schemes</button>
|
||||
<button data-account-action="all-themes">Allow all</button>
|
||||
<span class="hint" id="account-themes-message"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title" data-icon="alert" data-icon-tone="bad">Remove Memby access</h2>
|
||||
|
||||
@@ -11,6 +11,13 @@ const base = '/admin/api/accounts/' + encodeURIComponent(userId);
|
||||
// endpoint that would return a slice of the same query.
|
||||
let catalogue = [];
|
||||
|
||||
// The selectable themes, likewise carried by that endpoint rather than written out here.
|
||||
let themeCatalogue = [];
|
||||
|
||||
// The same dirty rule the settings form follows, kept separate so saving one does not
|
||||
// discard an unsaved edit to the other.
|
||||
let themesDirty = false;
|
||||
|
||||
// True while the operator has edited the settings form without saving. The page polls every
|
||||
// thirty seconds and a redraw would take a half-finished change away mid-sentence, so a
|
||||
// dirty form keeps the DOM it already has until it is saved, discarded or reloaded.
|
||||
@@ -115,6 +122,48 @@ function renderSettings(account) {
|
||||
settingControl(definition, values[definition.key])).join('') + '</div></div>').join('');
|
||||
}
|
||||
|
||||
/* ---- colour schemes ------------------------------------------------------ */
|
||||
|
||||
// The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
|
||||
// conversion lives here rather than on the wire because the television is the end that has
|
||||
// to parse thousands of these and the console is the end that parses eight.
|
||||
function cssColour(value) {
|
||||
const hex = String(value || '').replace('#', '');
|
||||
if (hex.length !== 8) return '#' + hex;
|
||||
return '#' + hex.slice(2) + hex.slice(0, 2);
|
||||
}
|
||||
|
||||
function themeRow(theme, allowed) {
|
||||
const palette = theme.palette || {};
|
||||
// Only the palette itself is set inline; see the .swatch note in admin.css.
|
||||
const style = '--swatch-surface:' + cssColour(palette.surface) + ';' +
|
||||
'--swatch-accent:' + cssColour(palette.accent) + ';' +
|
||||
'--swatch-hairline:' + cssColour(palette.hairline);
|
||||
return '<label class="check"><input type="checkbox" data-theme-id="' + fmt.escape(theme.id) +
|
||||
'"' + (allowed ? ' checked' : '') + '>' +
|
||||
'<span class="swatch" style="' + fmt.escape(style) + '"><i></i></span>' +
|
||||
'<span>' + fmt.escape(theme.name) + '<em>' + fmt.escape(theme.description) +
|
||||
'</em></span></label>';
|
||||
}
|
||||
|
||||
function renderThemes(account) {
|
||||
// An empty list from the server means unrestricted, so it draws as every box ticked.
|
||||
// Storing "all" and "never configured" identically is deliberate — they are the same
|
||||
// decision — and this is the one place an operator would notice if it were not.
|
||||
const allowed = account.themes || [];
|
||||
const unrestricted = allowed.length === 0;
|
||||
$('account-themes-state').innerHTML = unrestricted
|
||||
? ui.tag('all schemes', 'idle')
|
||||
: ui.tag(fmt.number(allowed.length) + ' of ' + fmt.number(themeCatalogue.length), 'note');
|
||||
$('account-themes').innerHTML = themeCatalogue.map((theme) =>
|
||||
themeRow(theme, unrestricted || allowed.includes(theme.id))).join('');
|
||||
}
|
||||
|
||||
function collectThemes() {
|
||||
return Array.from($('account-themes').querySelectorAll('input:checked'))
|
||||
.map((input) => input.dataset.themeId);
|
||||
}
|
||||
|
||||
/* ---- the rest of the page ---------------------------------------------- */
|
||||
|
||||
// Every build this set has been seen running, newest first and the current one flagged.
|
||||
@@ -199,6 +248,7 @@ Admin.onRefresh(async () => {
|
||||
$('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings';
|
||||
const payload = await Admin.api('/admin/api/accounts');
|
||||
catalogue = payload.catalogue || catalogue;
|
||||
themeCatalogue = payload.themes || themeCatalogue;
|
||||
const account = (payload.accounts || []).find((entry) => entry.id === userId);
|
||||
if (!account) {
|
||||
$('account-identity').innerHTML =
|
||||
@@ -209,13 +259,21 @@ Admin.onRefresh(async () => {
|
||||
renderDevices(account);
|
||||
renderRecommendations(account);
|
||||
if (!dirty) renderSettings(account);
|
||||
if (!themesDirty) renderThemes(account);
|
||||
});
|
||||
|
||||
/* ---- actions ------------------------------------------------------------ */
|
||||
|
||||
function message(text) { $('account-settings-message').textContent = text || ''; }
|
||||
|
||||
function themeMessage(text) { $('account-themes-message').textContent = text || ''; }
|
||||
|
||||
document.addEventListener('input', (event) => {
|
||||
if ($('account-themes').contains(event.target)) {
|
||||
themesDirty = true;
|
||||
themeMessage('unsaved changes');
|
||||
return;
|
||||
}
|
||||
if (!$('account-settings').contains(event.target)) return;
|
||||
dirty = true;
|
||||
message('unsaved changes');
|
||||
@@ -270,6 +328,28 @@ document.addEventListener('click', (event) => {
|
||||
message('pushed');
|
||||
});
|
||||
}
|
||||
if (action === 'save-themes') {
|
||||
const themes = collectThemes();
|
||||
if (!themes.length &&
|
||||
!confirm('Allow this person no colour schemes? They will be left on Midnight with ' +
|
||||
'nothing to choose between.')) return;
|
||||
themeMessage('saving…');
|
||||
Admin.act(async () => {
|
||||
await Admin.api(base + '/themes', {
|
||||
method: 'PUT', body: JSON.stringify({ themes }),
|
||||
});
|
||||
// Cleared before the refresh so the boxes are redrawn from what was stored, which is
|
||||
// how "every box ticked" comes back as the unrestricted state rather than as a list.
|
||||
themesDirty = false;
|
||||
themeMessage('saved');
|
||||
});
|
||||
}
|
||||
if (action === 'all-themes') {
|
||||
$('account-themes').querySelectorAll('input[data-theme-id]')
|
||||
.forEach((input) => { input.checked = true; });
|
||||
themesDirty = true;
|
||||
themeMessage('unsaved changes');
|
||||
}
|
||||
if (action === 'reload-preferences') {
|
||||
dirty = false;
|
||||
message('');
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<div class="tiles" id="searches-tiles"></div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="search" data-icon-tone="info">What the house looks for</h2>
|
||||
<p class="card-note">Queries the search tab ran, grouped without regard to case and
|
||||
labelled with the most recent spelling. Instant search asks from the second
|
||||
character, so a title typed slowly leaves its prefixes here too.</p>
|
||||
</div>
|
||||
<label class="field narrow"><span>Window</span>
|
||||
<select id="searches-days">
|
||||
<option value="1">24 hours</option>
|
||||
<option value="7" selected>7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Query</th>
|
||||
<th class="num">Searches</th>
|
||||
<th class="num">Viewers</th>
|
||||
<th>Last searched</th>
|
||||
</tr></thead>
|
||||
<tbody id="searches-terms"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2 class="card-title" data-icon="history" data-icon-tone="note">As it happened</h2>
|
||||
<p class="card-note">The log, newest first — the query exactly as it was typed, and who
|
||||
typed it. This is the one to read when somebody says search is not finding something.</p>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>When</th>
|
||||
<th>Viewer</th>
|
||||
<th>Query</th>
|
||||
</tr></thead>
|
||||
<tbody id="searches-recent"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
const { fmt, ui, $ } = Admin;
|
||||
|
||||
Admin.onRefresh(async () => {
|
||||
const payload = await Admin.api('/admin/api/searches?days=' + $('searches-days').value);
|
||||
const terms = payload.terms || [];
|
||||
const recent = payload.recent || [];
|
||||
const totals = payload.totals || {};
|
||||
|
||||
$('searches-tiles').innerHTML = ui.tiles([
|
||||
['searches', fmt.number(totals.searches), { icon: 'search', tone: 'info' }],
|
||||
['distinct queries', fmt.number(totals.queries), { icon: 'list', tone: 'data' }],
|
||||
['viewers searching', fmt.number(totals.viewers), { icon: 'people', tone: 'note' }],
|
||||
// Stated rather than assumed: every figure on this page is bounded by how long the
|
||||
// table keeps a row, and an operator reading a quiet week has no other way to tell a
|
||||
// household that stopped searching from one whose history has aged out.
|
||||
['history kept', payload.retentionDays + ' days', { small: true, icon: 'clock' }],
|
||||
]);
|
||||
|
||||
$('searches-terms').innerHTML = terms.length ? terms.map((term) =>
|
||||
'<tr><td>' + fmt.escape(term.query) + '</td>' +
|
||||
'<td class="num">' + fmt.number(term.searches) + '</td>' +
|
||||
'<td class="num">' + fmt.number(term.viewers) + '</td>' +
|
||||
'<td class="muted">' + fmt.when(term.lastAt) + '</td></tr>').join('')
|
||||
: ui.emptyRow(4, 'Nothing searched in this window.');
|
||||
|
||||
// An unattributed search keeps its row and shows the id: the query is the point, and a
|
||||
// viewer whose sessions have all expired is still one searcher rather than nobody.
|
||||
$('searches-recent').innerHTML = recent.length ? recent.map((event) =>
|
||||
'<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
|
||||
'<td>' + (event.username
|
||||
? fmt.escape(event.username)
|
||||
: ui.tag(event.userId || 'unknown', 'warn')) + '</td>' +
|
||||
'<td>' + fmt.escape(event.query) + '</td></tr>').join('')
|
||||
: ui.emptyRow(3, 'No searches in this window.');
|
||||
});
|
||||
|
||||
Admin.ready(() => $('searches-days').addEventListener('change', Admin.refresh));
|
||||
@@ -0,0 +1,84 @@
|
||||
<div class="tiles" id="subtitle-tiles"></div>
|
||||
|
||||
<div class="grid two">
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="wrench" data-icon-tone="data">Bazarr</h2>
|
||||
<p class="card-note">Bazarr writes the subtitle file beside the media file, so Emby
|
||||
finds it and the track behaves like one that was always there. Its address is
|
||||
deployment configuration; this switch only decides whether viewers may use it.</p>
|
||||
</div>
|
||||
<span id="bazarr-state"></span>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="bazarr-enabled">
|
||||
<span>Offer Bazarr in the player<em>Off leaves every subtitle it has already
|
||||
written in place.</em></span>
|
||||
</label>
|
||||
<p class="hint" id="bazarr-address"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="captions" data-icon-tone="note">OpenSubtitles</h2>
|
||||
<p class="card-note">OpenSubtitles hands back a file rather than writing one, so
|
||||
Memby keeps what it fetches and serves it to the television itself. Titles are
|
||||
matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.</p>
|
||||
</div>
|
||||
<span id="opensubtitles-state"></span>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="opensubtitles-enabled">
|
||||
<span>Offer OpenSubtitles in the player<em>Needs an API key. It cannot be
|
||||
switched on without one.</em></span>
|
||||
</label>
|
||||
<label class="field"><span>API key</span>
|
||||
<em>From your consumer at opensubtitles.com. Leave blank to keep the saved key.</em>
|
||||
<input type="password" id="opensubtitles-key" autocomplete="new-password"
|
||||
placeholder="Paste an API key"></label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="opensubtitles-clear-key"><span>Remove the saved key</span>
|
||||
</label>
|
||||
<div class="fields">
|
||||
<label class="field"><span>Account username</span>
|
||||
<em>Optional, and the difference between a working feature and one that stops
|
||||
after a few files: without an account, downloads come out of the small
|
||||
anonymous allowance.</em>
|
||||
<input type="text" id="opensubtitles-username" autocomplete="off"
|
||||
placeholder="Not signed in"></label>
|
||||
<label class="field"><span>Account password</span>
|
||||
<em>Leave blank to keep the saved one.</em>
|
||||
<input type="password" id="opensubtitles-password" autocomplete="new-password"></label>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="opensubtitles-clear-login"><span>Sign out and forget the account</span>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="row">
|
||||
<button class="primary" id="subtitle-save">Save subtitle settings</button>
|
||||
<button id="subtitle-test">Test the providers</button>
|
||||
<span class="hint" id="subtitle-hint"></span>
|
||||
</div>
|
||||
<div id="subtitle-test-results"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="database" data-icon-tone="data">Subtitles Memby is holding</h2>
|
||||
<p class="card-note">Only files fetched from a provider that cannot write beside the
|
||||
media file are kept here; they are served to televisions as ordinary tracks on every
|
||||
later playback. Emptying this is safe — each one can be fetched again, at the cost of
|
||||
the download allowance that fetched it.</p>
|
||||
</div>
|
||||
<span id="stored-state"></span>
|
||||
</div>
|
||||
<div class="card-foot">
|
||||
<button id="stored-clear">Delete every stored subtitle</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,98 @@
|
||||
const { fmt, ui, $ } = Admin;
|
||||
|
||||
Admin.onStatus((status) => {
|
||||
const subtitles = status.subtitles || {};
|
||||
const stored = subtitles.stored || {};
|
||||
|
||||
$('subtitle-tiles').innerHTML = ui.tiles([
|
||||
['offered on televisions', subtitles.available ? 'yes' : 'no',
|
||||
{ small: true, icon: 'captions', tone: subtitles.available ? 'ok' : undefined }],
|
||||
['providers on',
|
||||
fmt.number((subtitles.bazarrEnabled && subtitles.bazarrConfigured ? 1 : 0) +
|
||||
(subtitles.openSubtitlesEnabled ? 1 : 0)),
|
||||
{ icon: 'list', tone: 'note' }],
|
||||
['subtitles held', fmt.number(stored.count), { icon: 'database', tone: 'data' }],
|
||||
['last fetched', fmt.when(stored.latest), { small: true, icon: 'clock' }],
|
||||
]);
|
||||
|
||||
Admin.check($('bazarr-enabled'), subtitles.bazarrEnabled);
|
||||
$('bazarr-enabled').disabled = !subtitles.bazarrConfigured;
|
||||
$('bazarr-state').innerHTML = !subtitles.bazarrConfigured
|
||||
? ui.tag('not configured', 'idle')
|
||||
: (subtitles.bazarrEnabled ? ui.tag('on', 'ok') : ui.tag('off', 'idle'));
|
||||
// The address is worth printing: it is the one thing on this page an operator cannot
|
||||
// change here, so seeing which Bazarr is meant is how they find out it is the wrong one.
|
||||
$('bazarr-address').textContent = subtitles.bazarrConfigured
|
||||
? 'Configured at ' + subtitles.bazarrUrl
|
||||
: 'Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr.';
|
||||
|
||||
Admin.check($('opensubtitles-enabled'), subtitles.openSubtitlesEnabled);
|
||||
const keyField = $('opensubtitles-key');
|
||||
keyField.placeholder = subtitles.openSubtitlesKeyConfigured
|
||||
? 'Saved key (leave blank to keep)' : 'Paste an API key';
|
||||
Admin.fill($('opensubtitles-username'), subtitles.openSubtitlesUsername || '');
|
||||
$('opensubtitles-state').innerHTML = subtitles.openSubtitlesEnabled
|
||||
? ui.tag(subtitles.openSubtitlesAccount ? 'on · signed in' : 'on · anonymous',
|
||||
subtitles.openSubtitlesAccount ? 'ok' : 'warn')
|
||||
: ui.tag(subtitles.openSubtitlesKeyConfigured ? 'off · key saved' : 'off · no key', 'idle');
|
||||
|
||||
// The feature flag overrides both switches, so a page that stayed silent about it would
|
||||
// be showing two controls that visibly do nothing.
|
||||
$('subtitle-hint').textContent = subtitles.featureEnabled
|
||||
? 'A change applies to the next title opened; no app release is required.'
|
||||
: 'Downloading subtitles is switched off on the Features page, so nothing here is offered.';
|
||||
|
||||
$('stored-state').innerHTML = stored.count
|
||||
? ui.tag(fmt.number(stored.count) + ' files · ' + fmt.bytes(stored.bytes), 'data')
|
||||
: ui.tag('nothing held', 'idle');
|
||||
$('stored-clear').disabled = !stored.count;
|
||||
});
|
||||
|
||||
const save = () => Admin.api('/admin/api/subtitle-settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
bazarrEnabled: $('bazarr-enabled').checked,
|
||||
openSubtitlesEnabled: $('opensubtitles-enabled').checked,
|
||||
openSubtitlesApiKey: $('opensubtitles-key').value.trim(),
|
||||
clearOpenSubtitlesApiKey: $('opensubtitles-clear-key').checked,
|
||||
openSubtitlesUsername: $('opensubtitles-username').value.trim(),
|
||||
openSubtitlesPassword: $('opensubtitles-password').value,
|
||||
clearOpenSubtitlesLogin: $('opensubtitles-clear-login').checked,
|
||||
}),
|
||||
}).then(() => {
|
||||
// The credential fields are emptied on the way out, so a saved page never has a secret
|
||||
// sitting in a form somebody could walk past.
|
||||
$('opensubtitles-key').value = '';
|
||||
$('opensubtitles-password').value = '';
|
||||
$('opensubtitles-clear-key').checked = false;
|
||||
$('opensubtitles-clear-login').checked = false;
|
||||
});
|
||||
|
||||
Admin.ready(() => {
|
||||
$('subtitle-save').addEventListener('click', () => Admin.act(save));
|
||||
|
||||
$('subtitle-test').addEventListener('click', () => {
|
||||
const results = $('subtitle-test-results');
|
||||
results.innerHTML = ui.empty('Asking each provider…');
|
||||
Admin.api('/admin/api/subtitle-test', { method: 'POST' }).then((answer) => {
|
||||
const rows = answer.results || [];
|
||||
results.innerHTML = rows.length
|
||||
? '<div class="list">' + rows.map((row) =>
|
||||
'<div class="list-row"><span class="list-main"><span>' +
|
||||
'<span class="list-title">' + fmt.escape(row.provider) + '</span>' +
|
||||
'<span class="list-meta">' + fmt.escape(row.message) + '</span></span></span>' +
|
||||
'<span class="list-actions">' + ui.tag(row.ok ? 'reachable' : 'not reachable',
|
||||
row.ok ? 'ok' : 'bad') + '</span></div>').join('') + '</div>'
|
||||
: ui.empty('No provider is switched on, so there was nothing to ask.');
|
||||
}).catch((error) => {
|
||||
results.innerHTML = ui.empty(String(error.message || error));
|
||||
});
|
||||
});
|
||||
|
||||
$('stored-clear').addEventListener('click', () => {
|
||||
if (!confirm('Delete every subtitle Memby is holding? Each can be fetched again.')) return;
|
||||
Admin.act(() => Admin.api('/admin/api/subtitle-settings', {
|
||||
method: 'POST', body: JSON.stringify({ action: 'clear-stored' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,11 @@ type adminMembyAccount struct {
|
||||
// the defaults, and saying so is the difference between "chose this" and "has not
|
||||
// chosen anything".
|
||||
Settings adminAccountSettings `json:"settings"`
|
||||
// Themes is the ids this person may choose between, and an empty array means every
|
||||
// selectable theme rather than none — the same permissive reading the store and
|
||||
// themeAllowed take. The console renders that as every box ticked, which is what an
|
||||
// operator who has never touched the page should see.
|
||||
Themes []string `json:"themes"`
|
||||
}
|
||||
|
||||
type adminAccountSettings struct {
|
||||
@@ -87,6 +92,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
settings = map[string]store.UserPreferences{}
|
||||
}
|
||||
|
||||
themes, err := s.store.AllUserThemes(r.Context())
|
||||
if err != nil {
|
||||
// Same trade the settings read above makes: a colour allowlist that would not load
|
||||
// must not cost the operator the device list and the sign-out buttons.
|
||||
s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err)
|
||||
themes = map[string][]string{}
|
||||
}
|
||||
|
||||
result := make([]adminMembyAccount, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
pref := preferences[account.ID]
|
||||
@@ -115,6 +128,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
result = append(result, adminMembyAccount{
|
||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||
Themes: nonNilStrings(themes[account.ID]),
|
||||
Recommendations: adminOnboardingPreferences{
|
||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||
Updated: len(account.RecommendationPreferences) > 2,
|
||||
@@ -131,6 +145,11 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"accounts": result, "catalogue": preferenceCatalogue,
|
||||
"schemaVersion": preferenceSchemaVersion,
|
||||
// The selectable themes, for the same reason the preference catalogue rides along:
|
||||
// a page that hard-coded the swatches would drift from what the gateway will accept
|
||||
// the first time a theme is added, and would do it without saying so. Seasonal ones
|
||||
// are absent because they are not grantable — see themes.go.
|
||||
"themes": selectableThemes(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,11 @@ var adminNav = []adminNavGroup{
|
||||
Intro: "Presentation policy sent with every playback launch.",
|
||||
Icon: "M8 5v14l11-7zM4 5v14",
|
||||
},
|
||||
{
|
||||
ID: "subtitles", Label: "Subtitles", Title: "Subtitles",
|
||||
Intro: "Which providers a viewer may fetch a missing subtitle from.",
|
||||
Icon: "M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",
|
||||
},
|
||||
{
|
||||
ID: "updates", Label: "App updates", Title: "App updates",
|
||||
Intro: "Publish an optional or a required client update.",
|
||||
@@ -147,6 +152,11 @@ var adminNav = []adminNavGroup{
|
||||
Intro: "Impressions, focus, dwell and selections per launcher row.",
|
||||
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
|
||||
},
|
||||
{
|
||||
ID: "searches", Label: "Searches", Title: "Searches",
|
||||
Intro: "What the household has been looking for, and what it searched just now.",
|
||||
Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",
|
||||
},
|
||||
{
|
||||
ID: "logs", Label: "Server logs", Title: "Server logs",
|
||||
Intro: "Structured gateway events as they happen.",
|
||||
|
||||
@@ -167,5 +167,26 @@ func adminPreviewData() map[string]any {
|
||||
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
|
||||
}},
|
||||
"/admin/api/requests": map[string]any{"requests": []any{}},
|
||||
// A prefix among the terms and an unattributed row in the log, because both are
|
||||
// ordinary here and a preview showing neither would not be a preview of this page.
|
||||
"/admin/api/searches": map[string]any{
|
||||
"days": 7, "retentionDays": searchWindowDays,
|
||||
"termLimit": searchTermLimit, "eventLimit": searchEventLimit,
|
||||
"totals": map[string]any{"searches": 214, "queries": 96, "viewers": 2},
|
||||
"terms": []any{
|
||||
map[string]any{"query": "severance", "searches": 18, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
|
||||
map[string]any{"query": "dune", "searches": 11, "viewers": 1, "lastAt": stamp(3 * time.Hour)},
|
||||
map[string]any{"query": "sev", "searches": 9, "viewers": 2, "lastAt": stamp(40 * time.Minute)},
|
||||
map[string]any{"query": "the bear", "searches": 4, "viewers": 1, "lastAt": stamp(2 * 24 * time.Hour)},
|
||||
},
|
||||
"recent": []any{
|
||||
map[string]any{"occurredAt": stamp(40 * time.Minute), "userId": "u-1",
|
||||
"username": "matt", "query": "severance"},
|
||||
map[string]any{"occurredAt": stamp(3 * time.Hour), "userId": "u-2",
|
||||
"username": "sam", "query": "dune"},
|
||||
map[string]any{"occurredAt": stamp(26 * time.Hour), "userId": "u-9",
|
||||
"username": "", "query": "the bear"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's window on search history.
|
||||
//
|
||||
// Two shapes of the same table, because they answer different questions. The summary says
|
||||
// what the household looks for, which is what a library is bought and organised against;
|
||||
// the log says what happened just now, which is what an operator needs the moment somebody
|
||||
// reports that search is not finding something — it shows the query exactly as it was
|
||||
// typed, by whom, and when.
|
||||
const (
|
||||
// searchTermLimit caps the summary. Long enough to show a tail, short enough that the
|
||||
// table is read rather than scrolled.
|
||||
searchTermLimit = 25
|
||||
// searchEventLimit caps the log. It is a window on recent activity, not an export.
|
||||
searchEventLimit = 100
|
||||
// searchWindowDays is the widest window the page offers, and it is the retention
|
||||
// period rather than a round number: RecordSearch prunes to it, so a page offering
|
||||
// more would draw a flat line for the difference.
|
||||
searchWindowDays = int(store.SearchRetention / (24 * time.Hour))
|
||||
)
|
||||
|
||||
type adminSearchesResponse struct {
|
||||
Days int `json:"days"`
|
||||
Retention int `json:"retentionDays"`
|
||||
Totals store.SearchTotals `json:"totals"`
|
||||
Terms []store.SearchTerm `json:"terms"`
|
||||
Recent []store.SearchEvent `json:"recent"`
|
||||
TermLimit int `json:"termLimit"`
|
||||
EventLimit int `json:"eventLimit"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSearches(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
days := queryInt(r, "days", 7, searchWindowDays)
|
||||
since := time.Now().UTC().AddDate(0, 0, -days)
|
||||
|
||||
totals, err := s.store.SearchTotals(ctx, since)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search totals failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
terms, err := s.store.SearchTerms(ctx, since, searchTermLimit)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search terms failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
recent, err := s.store.SearchEvents(ctx, since, searchEventLimit)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("search events failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read search history")
|
||||
return
|
||||
}
|
||||
|
||||
// A name a search cannot be attributed to costs the column and nothing else: the
|
||||
// queries are the page, and an operator reading it after a household member's last
|
||||
// session expired must still see what was searched for.
|
||||
if users, err := s.store.KnownUsers(ctx); err == nil {
|
||||
recent = nameSearchEvents(recent, users)
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("search history names unresolved", "error", err)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, adminSearchesResponse{
|
||||
Days: days,
|
||||
Retention: searchWindowDays,
|
||||
Totals: totals,
|
||||
Terms: terms,
|
||||
Recent: recent,
|
||||
TermLimit: searchTermLimit,
|
||||
EventLimit: searchEventLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// nameSearchEvents fills in who made each search.
|
||||
//
|
||||
// Resolved here rather than joined in SQL because the log is capped and the household is
|
||||
// small: one read of sessions serves a whole page, where a join would repeat the lookup
|
||||
// per row. An id with no session left is returned unnamed rather than dropped — the
|
||||
// console prints the id, which still distinguishes one searcher from another.
|
||||
func nameSearchEvents(events []store.SearchEvent, users []store.KnownUser) []store.SearchEvent {
|
||||
names := make(map[string]string, len(users))
|
||||
for _, user := range users {
|
||||
if user.Username != "" {
|
||||
names[user.ID] = user.Username
|
||||
}
|
||||
}
|
||||
for i := range events {
|
||||
events[i].Username = names[events[i].UserID]
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The console's half of subtitle downloads.
|
||||
//
|
||||
// The operator has two providers to choose between and they need different things said
|
||||
// about them. Bazarr is a service the household already runs, so the console can only turn
|
||||
// it on or off — its address is deployment configuration and stays an environment
|
||||
// variable. OpenSubtitles is an account, so its credentials live here and can be entered,
|
||||
// replaced or removed without a redeployment.
|
||||
//
|
||||
// Nothing on this page ever returns a credential. The console is told whether a key is
|
||||
// saved and whether an account is attached, which is what an operator needs to answer
|
||||
// "why is this not working", and never the values themselves — the stance the MDBList page
|
||||
// already takes.
|
||||
|
||||
// subtitleAdminSettings is the page's whole view of the policy.
|
||||
type subtitleAdminSettings struct {
|
||||
// BazarrConfigured is whether this deployment has a Bazarr at all. It is separate from
|
||||
// BazarrEnabled so the page can say "no address configured" rather than drawing a
|
||||
// switch that would do nothing.
|
||||
BazarrConfigured bool `json:"bazarrConfigured"`
|
||||
BazarrEnabled bool `json:"bazarrEnabled"`
|
||||
BazarrURL string `json:"bazarrUrl,omitempty"`
|
||||
|
||||
OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"`
|
||||
OpenSubtitlesKeyConfigured bool `json:"openSubtitlesKeyConfigured"`
|
||||
// OpenSubtitlesAccount is whether a username and password are saved. It matters more
|
||||
// than it looks: without one, downloads go against the anonymous allowance, which is a
|
||||
// handful of files a day and fails in front of a television rather than in a log.
|
||||
OpenSubtitlesAccount bool `json:"openSubtitlesAccount"`
|
||||
OpenSubtitlesUsername string `json:"openSubtitlesUsername,omitempty"`
|
||||
|
||||
// FeatureEnabled is the `subtitle_download` flag. It is reported here because it
|
||||
// overrides both providers, and an operator who has turned it off on the features page
|
||||
// should not have to guess why these switches do nothing.
|
||||
FeatureEnabled bool `json:"featureEnabled"`
|
||||
// Available is the answer a television gets: the feature is on and at least one
|
||||
// provider can be asked.
|
||||
Available bool `json:"available"`
|
||||
|
||||
Stored store.DownloadedSubtitleStats `json:"stored"`
|
||||
}
|
||||
|
||||
func (s *Server) subtitleAdminSettings(ctx context.Context) subtitleAdminSettings {
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
sources := s.subtitleSources(ctx)
|
||||
settings := subtitleAdminSettings{
|
||||
BazarrConfigured: s.bazarr != nil,
|
||||
BazarrEnabled: policy.BazarrEnabled,
|
||||
BazarrURL: s.cfg.BazarrURL,
|
||||
OpenSubtitlesEnabled: policy.OpenSubtitlesEnabled,
|
||||
OpenSubtitlesKeyConfigured: policy.OpenSubtitlesAPIKey != "",
|
||||
OpenSubtitlesAccount: policy.OpenSubtitlesUsername != "" && policy.OpenSubtitlesPassword != "",
|
||||
OpenSubtitlesUsername: policy.OpenSubtitlesUsername,
|
||||
FeatureEnabled: s.featureEnabled(ctx, featureSubtitleDownload),
|
||||
Available: sources.any(),
|
||||
}
|
||||
if stats, err := s.store.DownloadedSubtitleStats(ctx); err == nil {
|
||||
settings.Stored = stats
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("downloaded subtitle stats failed", "error", err)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
type subtitleSettingsRequest struct {
|
||||
Action string `json:"action"`
|
||||
|
||||
BazarrEnabled bool `json:"bazarrEnabled"`
|
||||
OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"`
|
||||
|
||||
// A blank key keeps whatever is saved, so an operator changing one switch does not
|
||||
// have to paste a credential back in to do it. Clearing is its own flag, because
|
||||
// "leave it alone" and "remove it" cannot both be the empty string.
|
||||
OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"`
|
||||
ClearOpenSubtitlesAPIKey bool `json:"clearOpenSubtitlesApiKey"`
|
||||
OpenSubtitlesUsername string `json:"openSubtitlesUsername"`
|
||||
OpenSubtitlesPassword string `json:"openSubtitlesPassword"`
|
||||
ClearOpenSubtitlesLogin bool `json:"clearOpenSubtitlesLogin"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSubtitleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req subtitleSettingsRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
// Emptying the store is the page's one destructive control, and it is safe in the way
|
||||
// a cache purge is: every file can be fetched again, at the cost of the provider
|
||||
// allowance that fetched it. It is a separate action rather than a checkbox on the
|
||||
// save, so it cannot happen as a side effect of changing a switch.
|
||||
if strings.TrimSpace(req.Action) == "clear-stored" {
|
||||
removed, err := s.store.ClearDownloadedSubtitles(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Error("clearing stored subtitles failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not clear stored subtitles")
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("stored subtitles cleared", "removed", removed)
|
||||
writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx))
|
||||
return
|
||||
}
|
||||
|
||||
current, err := s.store.SubtitlePolicy(ctx)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not read subtitle settings")
|
||||
return
|
||||
}
|
||||
next := store.SubtitlePolicy{
|
||||
BazarrEnabled: req.BazarrEnabled,
|
||||
OpenSubtitlesEnabled: req.OpenSubtitlesEnabled,
|
||||
OpenSubtitlesAPIKey: current.OpenSubtitlesAPIKey,
|
||||
OpenSubtitlesUsername: current.OpenSubtitlesUsername,
|
||||
OpenSubtitlesPassword: current.OpenSubtitlesPassword,
|
||||
}
|
||||
if req.ClearOpenSubtitlesAPIKey {
|
||||
next.OpenSubtitlesAPIKey = ""
|
||||
} else if replacement := strings.TrimSpace(req.OpenSubtitlesAPIKey); replacement != "" {
|
||||
next.OpenSubtitlesAPIKey = replacement
|
||||
}
|
||||
if req.ClearOpenSubtitlesLogin {
|
||||
next.OpenSubtitlesUsername, next.OpenSubtitlesPassword = "", ""
|
||||
} else if username := strings.TrimSpace(req.OpenSubtitlesUsername); username != "" {
|
||||
next.OpenSubtitlesUsername = username
|
||||
// The password only moves when one was typed. Changing a username without
|
||||
// retyping the password is an ordinary edit, and the field is blank on every load.
|
||||
if password := req.OpenSubtitlesPassword; password != "" {
|
||||
next.OpenSubtitlesPassword = password
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.SetSubtitlePolicy(ctx, next); err != nil {
|
||||
s.loggerFor(ctx).Error("subtitle policy write failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save subtitle settings")
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("subtitle providers changed",
|
||||
"bazarr", next.BazarrEnabled,
|
||||
"opensubtitles", next.OpenSubtitlesEnabled,
|
||||
"opensubtitles_account", next.OpenSubtitlesUsername != "",
|
||||
)
|
||||
writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx))
|
||||
}
|
||||
|
||||
// handleAdminSubtitleTest asks each enabled provider whether it is actually reachable.
|
||||
//
|
||||
// It exists because every other symptom of a wrong key looks identical from a television:
|
||||
// the search comes back empty. One button that says "the key is rejected" is the whole
|
||||
// difference between a five-minute fix and an evening of guessing.
|
||||
func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
type probe struct {
|
||||
Provider string `json:"provider"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
results := []probe{}
|
||||
|
||||
if s.bazarr != nil {
|
||||
result := probe{Provider: "Bazarr", OK: true, Message: "Reachable."}
|
||||
if err := s.bazarr.Ping(ctx); err != nil {
|
||||
result.OK, result.Message = false, "Did not answer: "+err.Error()
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
if client := s.openSubtitlesClient(ctx); client != nil {
|
||||
result := probe{Provider: "OpenSubtitles", OK: true}
|
||||
if err := client.Ping(ctx); err != nil {
|
||||
result.OK, result.Message = false, "Did not answer: "+err.Error()
|
||||
} else if client.HasAccount() {
|
||||
result.Message = "Reachable, signed in."
|
||||
} else {
|
||||
// Worth saying rather than reporting a plain success: an anonymous key works
|
||||
// perfectly for searching and runs out after a few downloads, which is the
|
||||
// failure this page exists to make findable.
|
||||
result.Message = "Reachable, but with no account — downloads use the small anonymous allowance."
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/radarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||
@@ -50,9 +51,19 @@ type Server struct {
|
||||
log *slog.Logger
|
||||
events *serverlogging.Buffer
|
||||
sonarrMu sync.Mutex
|
||||
radarrMu sync.Mutex
|
||||
bazarrMu sync.Mutex
|
||||
mdblistMu sync.Mutex
|
||||
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
|
||||
// to My Shows never waits behind a launcher rebuilding the schedule row.
|
||||
sonarrSeriesMu sync.Mutex
|
||||
radarrMu sync.Mutex
|
||||
bazarrMu sync.Mutex
|
||||
// openSubtitles is built from the operator's saved credentials rather than from
|
||||
// configuration, so it is cached against a fingerprint of them and rebuilt when they
|
||||
// change. It is cached at all because the client holds a login token, and logging in
|
||||
// per download would spend a different allowance than the one being conserved.
|
||||
openSubtitlesMu sync.Mutex
|
||||
openSubtitles *opensubtitles.Client
|
||||
openSubtitlesKey string
|
||||
mdblistMu sync.Mutex
|
||||
// mdblistSettingsCache spares every row and keystroke a settings read.
|
||||
mdblistSettingsCache mdblistSettingsCache
|
||||
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
|
||||
@@ -125,6 +136,9 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
||||
// A genre is browsed, not searched: the chip is a filter and this is the route that
|
||||
// treats it as one. Paged, because a household's Drama shelf is not a screenful.
|
||||
v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems))
|
||||
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
||||
@@ -145,6 +159,10 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
||||
// A viewer's settings follow the person, not the television. Both verbs land on one
|
||||
// handler because a write answers with the stored document, not the submitted one.
|
||||
// The palette, fetched only when the revision on the status poll moves. A GET with no
|
||||
// write beside it: what a viewer may change is themeId, and that is an ordinary
|
||||
// setting on the route above — this route only answers with what came of it.
|
||||
v1.Handle("GET /v1/theme", s.authed(s.handleTheme))
|
||||
v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences))
|
||||
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
|
||||
|
||||
@@ -155,10 +173,19 @@ func (s *Server) Routes() http.Handler {
|
||||
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
|
||||
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
|
||||
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
|
||||
v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume))
|
||||
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
|
||||
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
|
||||
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
|
||||
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
|
||||
// Repairing the timing of a subtitle the title already has, which is a different
|
||||
// question from fetching another copy of it — see subtitle_fix.go.
|
||||
v1.Handle("POST /v1/items/{id}/subtitles/fix", s.authed(s.handleSubtitleFix))
|
||||
// The one route that serves a subtitle rather than pointing at Emby's. It exists for
|
||||
// the provider that hands back bytes instead of writing beside the media file; the
|
||||
// token arrives in the query string, the way artwork's does, because a media player
|
||||
// fetching a sidecar sends none of Memby's headers.
|
||||
v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle))
|
||||
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -277,6 +278,74 @@ func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Both routes that write search_history apply one rule, so a query /v1/search records is
|
||||
// exactly one /v1/search/history would have accepted. The length is counted in runes:
|
||||
// bytes would reject a Japanese title at a third of an English one's length.
|
||||
func TestSearchQueryRecordable(t *testing.T) {
|
||||
long := strings.Repeat("a", maxSearchQueryRunes)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
term string
|
||||
want bool
|
||||
}{
|
||||
{"ordinary", "titanic", true},
|
||||
{"at the floor", "up", true},
|
||||
{"one letter", "u", false},
|
||||
{"blank", " ", false},
|
||||
{"padded is measured trimmed", " up ", true},
|
||||
{"at the ceiling", long, true},
|
||||
{"past the ceiling", long + "a", false},
|
||||
{"multibyte counted as runes", strings.Repeat("あ", maxSearchQueryRunes), true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := searchQueryRecordable(tc.term); got != tc.want {
|
||||
t.Fatalf("searchQueryRecordable(%q) = %v, want %v", tc.term, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A search whose viewer no longer has a session keeps its row: the query is what the page
|
||||
// is for, and an id still tells one searcher from another.
|
||||
func TestNameSearchEventsKeepsUnattributedRows(t *testing.T) {
|
||||
events := []store.SearchEvent{
|
||||
{Query: "severance", UserID: "u-1"},
|
||||
{Query: "dune", UserID: "gone"},
|
||||
}
|
||||
users := []store.KnownUser{
|
||||
{ID: "u-1", Username: "matt"},
|
||||
{ID: "u-2", Username: "sam"},
|
||||
}
|
||||
|
||||
got := nameSearchEvents(events, users)
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("event count = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Username != "matt" {
|
||||
t.Fatalf("username = %q, want matt", got[0].Username)
|
||||
}
|
||||
if got[1].Username != "" || got[1].Query != "dune" {
|
||||
t.Fatalf("unattributed event = %+v, want an empty name and its query", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
// The page must not offer a window the table cannot fill: RecordSearch prunes to the
|
||||
// retention period, so a wider one would draw a flat line for the difference.
|
||||
func TestSearchWindowMatchesRetention(t *testing.T) {
|
||||
if searchWindowDays != 30 {
|
||||
t.Fatalf("search window = %d days, want 30 to match store.SearchRetention", searchWindowDays)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording must never be what stops a search being answered. A gateway with no database
|
||||
// reaches this on every keystroke, so the guard comes before anything that could panic on
|
||||
// a half-built server.
|
||||
func TestRecordSearchQueryWithoutStoreIsSilent(t *testing.T) {
|
||||
s := &Server{}
|
||||
s.recordSearchQuery(context.Background(), store.Session{EmbyUserID: "u1"}, "titanic")
|
||||
}
|
||||
|
||||
// The fixed rows must keep their order, ids and kinds: the client maps kinds onto
|
||||
// card shapes and uses ids as Compose keys.
|
||||
func TestBaseRowsShape(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import "strings"
|
||||
|
||||
// Where a title's closing credits begin.
|
||||
//
|
||||
// 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 feature's coverage
|
||||
// actually comes from today.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const markerCreditsStart = "CreditsStart"
|
||||
|
||||
// creditsMinimumPositionFraction is 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.
|
||||
const creditsMinimumPositionFraction = 0.75
|
||||
|
||||
// creditsFromChapters finds where the closing credits begin.
|
||||
//
|
||||
// The rule exists twice — the television's copy is `creditsStartFrom` in `data/Credits.kt` —
|
||||
// and the two are pinned by deliberately parallel tests (`credits_test.go`, `CreditsTest`).
|
||||
// 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.
|
||||
//
|
||||
// Most of this is about refusing to answer, and nothing 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 does not report one. 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 distinguish an opening credit
|
||||
// sequence from a closing one and the position test is the only thing that can.
|
||||
func creditsFromChapters(chapters []embyChapter, runtimeMs int64) (int64, bool) {
|
||||
floor := int64(-1)
|
||||
if runtimeMs > 0 {
|
||||
floor = int64(float64(runtimeMs) * creditsMinimumPositionFraction)
|
||||
}
|
||||
|
||||
// An explicit marker first. The last one wins, where the intro rule takes the first:
|
||||
// two starts mean the markers are untrustworthy, so each rule picks whichever risks
|
||||
// least, and the two features are damaged in opposite directions. An intro skip firing
|
||||
// late throws somebody past the story, so the earlier marker is safer there; the credits
|
||||
// pane firing early runs the last scene past them at double speed, so the later marker is
|
||||
// safer here.
|
||||
marked := int64(-1)
|
||||
for _, chapter := range chapters {
|
||||
if chapter.MarkerType != markerCreditsStart || chapter.StartPositionTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
marked = chapter.StartPositionTicks / ticksPerMillisecond
|
||||
}
|
||||
// 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 marked > 0 && (floor < 0 || marked >= floor) {
|
||||
return marked, true
|
||||
}
|
||||
|
||||
if floor < 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// Then the names. The *earliest* qualifying chapter wins here, which is the opposite of
|
||||
// the marker rule above and 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.
|
||||
named := int64(-1)
|
||||
for _, chapter := range chapters {
|
||||
if !isCreditsChapterName(chapter.Name) || chapter.StartPositionTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
at := chapter.StartPositionTicks / ticksPerMillisecond
|
||||
if at < floor {
|
||||
continue
|
||||
}
|
||||
if named < 0 || at < named {
|
||||
named = at
|
||||
}
|
||||
}
|
||||
if named > 0 {
|
||||
return named, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// isCreditsChapterName recognises a chapter that names a credit roll.
|
||||
//
|
||||
// The exclusions are belt-and-braces beside [creditsMinimumPositionFraction], 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.
|
||||
func isCreditsChapterName(name string) bool {
|
||||
lowered := strings.ToLower(strings.TrimSpace(name))
|
||||
if lowered == "" {
|
||||
return false
|
||||
}
|
||||
for _, opening := range []string{"opening", "main title", "title sequence", "intro"} {
|
||||
if strings.Contains(lowered, opening) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return strings.Contains(lowered, "credit") ||
|
||||
strings.Contains(lowered, "end titles") ||
|
||||
strings.Contains(lowered, "closing")
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
// The credits rule, pinned against the same cases as the television's copy (`CreditsTest`
|
||||
// in app/src/test). 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.
|
||||
|
||||
func named(seconds int64, name string) embyChapter {
|
||||
return embyChapter{
|
||||
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
|
||||
MarkerType: "Chapter",
|
||||
Name: name,
|
||||
}
|
||||
}
|
||||
|
||||
// A two-thousand-second episode, so a percentage of runtime reads as a round number.
|
||||
const testRuntimeMs = 2_000_000
|
||||
|
||||
func TestCreditsFromChapters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
chapters []embyChapter
|
||||
runtimeMs int64
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
// A real episode as the server holds it: chapters, the intro pair, more
|
||||
// chapters, and the credits marker near the end.
|
||||
name: "a real episode",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
chapter(1200, "Chapter"),
|
||||
chapter(1900, "CreditsStart"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// A film, which commonly has the credits marker and no intro at all. This is
|
||||
// the case that would have been lost had the two features shared one flag.
|
||||
name: "credits with no intro",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(1400, "Chapter"),
|
||||
chapter(1850, "CreditsStart"),
|
||||
},
|
||||
want: 1_850_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "no markers at all",
|
||||
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
name: "no chapters at all",
|
||||
chapters: nil,
|
||||
},
|
||||
{
|
||||
// An intro pair is a different feature and must never be read as credits.
|
||||
name: "intro markers are not credits",
|
||||
chapters: []embyChapter{
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// A marker at zero says the whole file is credits, which is not something Emby
|
||||
// means and not something worth shrinking a picture for.
|
||||
name: "a marker at the very beginning",
|
||||
chapters: []embyChapter{chapter(0, "CreditsStart"), chapter(300, "Chapter")},
|
||||
},
|
||||
{
|
||||
// The last marker wins, where the intro rule 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.
|
||||
name: "two starts, the later one wins",
|
||||
chapters: []embyChapter{
|
||||
chapter(1700, "CreditsStart"),
|
||||
chapter(1900, "CreditsStart"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// Order in the array is not trusted to be sorted, so a later marker earlier in
|
||||
// the list still loses to the one further into the film.
|
||||
name: "the later marker wins whatever order they arrive in",
|
||||
chapters: []embyChapter{
|
||||
chapter(1900, "CreditsStart"),
|
||||
chapter(1700, "CreditsStart"),
|
||||
},
|
||||
want: 1_700_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// 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.
|
||||
name: "a chapter named Credits, which is all Emby 4.10 gives",
|
||||
chapters: []embyChapter{
|
||||
chapter(0, "Chapter"),
|
||||
chapter(463, "IntroStart"),
|
||||
chapter(583, "IntroEnd"),
|
||||
named(1800, "Credits"),
|
||||
},
|
||||
want: 1_800_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "a chapter named End Credits",
|
||||
chapters: []embyChapter{named(1920, "End Credits")},
|
||||
want: 1_920_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// 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 creditsMinimumPositionFraction exists.
|
||||
name: "Opening Credits at the start of a film is never the credit roll",
|
||||
chapters: []embyChapter{
|
||||
named(20, "Opening Credits"),
|
||||
chapter(600, "Chapter"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Belfast in full: both chapters present. The opening one must be rejected and the
|
||||
// closing one found, which the position floor does on its own.
|
||||
name: "an opening and a closing credit chapter in one film",
|
||||
chapters: []embyChapter{
|
||||
named(20, "Opening Credits"),
|
||||
chapter(600, "Chapter"),
|
||||
named(1900, "End Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// "The Pitt" carries both, seconds apart, describing one roll. The *earliest*
|
||||
// qualifying chapter wins here — the opposite of the marker rule above — because
|
||||
// the roll begins at the first of them and taking the last would skip part of it.
|
||||
name: "two credits chapters describing one roll take the earlier",
|
||||
chapters: []embyChapter{
|
||||
named(1920, "End Credits"),
|
||||
named(1900, "Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// Anything in the first three quarters is refused however it is worded. A position
|
||||
// test catches wordings nobody thought of; a list of words only catches the listed.
|
||||
name: "a credits-named chapter too early to be the roll",
|
||||
chapters: []embyChapter{named(900, "Credits")},
|
||||
},
|
||||
{
|
||||
// An explicit marker outranks a name, and is honoured even with no runtime to
|
||||
// measure against: it is Emby asserting a position rather than this inferring one.
|
||||
name: "a marker is honoured when the runtime is unknown",
|
||||
chapters: []embyChapter{chapter(1900, "CreditsStart")},
|
||||
runtimeMs: -1,
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// A name is not. Without a runtime there is no way to tell an opening credit
|
||||
// sequence from a closing one, and guessing is what this whole guard refuses.
|
||||
name: "a name is refused when the runtime is unknown",
|
||||
chapters: []embyChapter{named(1900, "End Credits")},
|
||||
runtimeMs: -1,
|
||||
},
|
||||
{
|
||||
// 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.
|
||||
name: "a marker below the floor falls through to a name that qualifies",
|
||||
chapters: []embyChapter{
|
||||
chapter(200, "CreditsStart"),
|
||||
named(1900, "End Credits"),
|
||||
},
|
||||
want: 1_900_000,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
// The intro's own chapters are named "Intro Start"/"Intro End" in this library, and
|
||||
// an episode whose titles run late must never have them read as a credit roll.
|
||||
name: "chapters named for the intro are never credits",
|
||||
chapters: []embyChapter{
|
||||
named(1800, "Intro Start"),
|
||||
named(1900, "Intro End"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 0 means "the ordinary case, use the default"; -1 means "deliberately
|
||||
// unknown", which is a case of its own rather than an absent field.
|
||||
runtime := tc.runtimeMs
|
||||
switch runtime {
|
||||
case 0:
|
||||
runtime = testRuntimeMs
|
||||
case -1:
|
||||
runtime = 0
|
||||
}
|
||||
got, ok := creditsFromChapters(tc.chapters, runtime)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("available = %v, want %v (start %d)", ok, tc.ok, got)
|
||||
}
|
||||
if ok && got != tc.want {
|
||||
t.Fatalf("start = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The two halves of one reading are independent. A film with credits and no intro must not
|
||||
// report an intro starting at zero, which is what a shared flag would have produced.
|
||||
func TestMarkersResponseKeepsTheHalvesApart(t *testing.T) {
|
||||
response := markersResponse(chapterMarkers{creditsStart: 6_840_000, creditsFound: true})
|
||||
if response.Available || response.StartMs != 0 || response.EndMs != 0 {
|
||||
t.Fatalf("intro = %+v, want an absent intro on a title that has none", response)
|
||||
}
|
||||
if !response.CreditsAvailable || response.CreditsStartMs != 6_840_000 {
|
||||
t.Fatalf("credits = %+v, want the marker that was found", response)
|
||||
}
|
||||
|
||||
response = markersResponse(chapterMarkers{
|
||||
intro: introSegment{StartMs: 463_000, EndMs: 583_000}, introFound: true,
|
||||
})
|
||||
if response.CreditsAvailable || response.CreditsStartMs != 0 {
|
||||
t.Fatalf("credits = %+v, want none on a title with only an intro", response)
|
||||
}
|
||||
}
|
||||
|
||||
// An operator turning one feature off must not take the other with it, and must not poison
|
||||
// the cache: masking happens on the way out, so the entry behind it still holds the truth.
|
||||
func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
|
||||
full := introResponse{
|
||||
Available: true, StartMs: 463_000, EndMs: 583_000,
|
||||
CreditsAvailable: true, CreditsStartMs: 2_704_000,
|
||||
}
|
||||
|
||||
withoutIntro := maskMarkers(full, false, true)
|
||||
if withoutIntro.Available || withoutIntro.StartMs != 0 || withoutIntro.EndMs != 0 {
|
||||
t.Fatalf("intro = %+v, want it withheld", withoutIntro)
|
||||
}
|
||||
if !withoutIntro.CreditsAvailable || withoutIntro.CreditsStartMs != 2_704_000 {
|
||||
t.Fatal("turning the skip button off must not cost the credits pane as well")
|
||||
}
|
||||
|
||||
withoutCredits := maskMarkers(full, true, false)
|
||||
if withoutCredits.CreditsAvailable || withoutCredits.CreditsStartMs != 0 {
|
||||
t.Fatalf("credits = %+v, want them withheld", withoutCredits)
|
||||
}
|
||||
if !withoutCredits.Available || withoutCredits.StartMs != 463_000 {
|
||||
t.Fatal("turning the credits pane off must not cost the skip button as well")
|
||||
}
|
||||
}
|
||||
|
||||
// The toggle a television is told to obey has to be one this build knows.
|
||||
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
|
||||
definition, ok := preferenceDefinitionFor("speedUpCredits")
|
||||
if !ok {
|
||||
t.Fatal("speedUpCredits is missing from the preference catalogue")
|
||||
}
|
||||
if definition.Kind != preferenceToggle {
|
||||
t.Fatalf("kind = %v, want a toggle", definition.Kind)
|
||||
}
|
||||
if definition.Default != true {
|
||||
t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
|
||||
"is visible and reversible in a way an automatic seek is not", definition.Default)
|
||||
}
|
||||
|
||||
// An illegal value must come back as the default rather than reaching a player.
|
||||
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
|
||||
if normalised["speedUpCredits"] != true {
|
||||
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ const (
|
||||
featureSubtitleDownload = "subtitle_download"
|
||||
featureTrickplay = "trickplay"
|
||||
featureSkipIntro = "skip_intro"
|
||||
featureEndCredits = "end_credits"
|
||||
featureSeasonalThemes = "seasonal_themes"
|
||||
featureSeasonalDecorations = "seasonal_decorations"
|
||||
)
|
||||
|
||||
type featureDefinition struct {
|
||||
@@ -80,6 +83,40 @@ var featureCatalogue = []featureDefinition{
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
|
||||
},
|
||||
{
|
||||
Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback",
|
||||
Description: "Shrink the picture and run the closing credits at double speed with " +
|
||||
"the next episode beside them, from the credits marker Emby writes. It is read " +
|
||||
"from the same chapter list as the title sequence, so turning this off saves no " +
|
||||
"request unless that is off too.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1",
|
||||
Recovery: "Takes effect the next time playback starts; the credits simply play out full size.",
|
||||
},
|
||||
{
|
||||
// The only switch there is for seasonal themes, and it is deliberately the
|
||||
// operator's rather than the viewer's: a per-person opt-out is a thing somebody
|
||||
// turns off in October and never reconsiders, which is the same as the feature not
|
||||
// existing. Off here means every television falls back to its viewer's own choice
|
||||
// on the next status poll.
|
||||
Key: featureSeasonalThemes, Name: "Seasonal themes", Area: "Presentation",
|
||||
Description: "Put every television into the Halloween, Christmas or Easter palette " +
|
||||
"for its dates. Viewers cannot decline one; turning this off is the only way to " +
|
||||
"stop them.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "themes_v1",
|
||||
Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.",
|
||||
},
|
||||
{
|
||||
// A second switch rather than a consequence of the one above, because the palette
|
||||
// and the animation have quite different costs. Snow drifting over the launcher is
|
||||
// the only thing in the app that animates continuously while somebody is browsing,
|
||||
// and these are weak boxes; an operator who finds it costs frames should be able to
|
||||
// keep December looking like December without it.
|
||||
Key: featureSeasonalDecorations, Name: "Seasonal decorations", Area: "Presentation",
|
||||
Description: "Drift snow, bats or blossom over the launcher while a seasonal theme " +
|
||||
"is on. Turning it off keeps the seasonal colours and stops the animation.",
|
||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1",
|
||||
Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.",
|
||||
},
|
||||
{
|
||||
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
|
||||
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Browsing a genre is a *filter*, not a search.
|
||||
//
|
||||
// The search page's genre chips used to run their label through /v1/search, which is a
|
||||
// text query: "Drama" then matched a film called Drama, anything with the word in its
|
||||
// overview, and — because relevance is a score rather than a rule — a scattering of titles
|
||||
// that are not in the genre at all, while missing most of the ones that are. So this asks
|
||||
// Emby the question actually being asked, with the genre as a filter, and answers a page
|
||||
// at a time.
|
||||
//
|
||||
// It goes to Emby with the viewer's own credentials rather than to the imported catalogue,
|
||||
// for the reason handleSearch does: the household copy may hold titles a library
|
||||
// permission or a parental control hides from this person, so it cannot be the authority
|
||||
// on what they may see.
|
||||
const (
|
||||
// A screenful on a television grid is 4–5 columns of about 3 rows. This is several of
|
||||
// those, so the scroll reaches the next page long before the viewer reaches the end of
|
||||
// this one, and small enough that opening a genre is one quick request rather than a
|
||||
// wait on a library's worth of Comedy.
|
||||
genrePageSize = 48
|
||||
genrePageMax = 100
|
||||
)
|
||||
|
||||
// genrePage is the wire shape. The total is what lets the television stop asking: a page
|
||||
// short of the limit also ends the scroll, but a genre whose last page happens to divide
|
||||
// evenly would otherwise cost one more empty request to discover that.
|
||||
type genrePage struct {
|
||||
Genre string `json:"genre"`
|
||||
Items []json.RawMessage `json:"items"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGenreItems(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
genre := strings.TrimSpace(r.PathValue("genre"))
|
||||
if genre == "" {
|
||||
writeError(w, http.StatusBadRequest, "a genre is required")
|
||||
return
|
||||
}
|
||||
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
||||
offset := queryOffset(r, "offset")
|
||||
|
||||
key := cache.UserKey(sess.EmbyUserID, "genre:"+genre+":"+itoa(offset)+":"+itoa(limit))
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
|
||||
params := rowParams(url.Values{
|
||||
"Genres": {genre},
|
||||
"IncludeItemTypes": {"Movie,Series"},
|
||||
"Recursive": {"true"},
|
||||
"StartIndex": {itoa(offset)},
|
||||
"Limit": {itoa(limit)},
|
||||
// Newest first, because a genre is browsed to find something to watch and the
|
||||
// alphabet is not an answer to that. The second 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": {"PremiereDate,SortName"},
|
||||
"SortOrder": {"Descending"},
|
||||
}, fieldsRow)
|
||||
// rowParams turns this off for the home rows, which never page. Here it is the number
|
||||
// the scroll stops on.
|
||||
params.Set("EnableTotalRecordCount", "true")
|
||||
|
||||
// Episodes are deliberately not among the types. An episode inherits its series'
|
||||
// genres, so including them would fill a page with twenty entries of one comedy and
|
||||
// bury the nineteen other shows behind it.
|
||||
result, err := s.emby.Items(ctx, credentials(sess), params)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(ctx, w, err, "could not browse genre")
|
||||
return
|
||||
}
|
||||
items := nonNil(result.Items)
|
||||
s.decorateItemRatings(ctx, items)
|
||||
|
||||
total := genreTotal(result.TotalRecordCount, offset, len(items), limit)
|
||||
|
||||
// The first page is somebody opening a genre, which is a navigation event worth the
|
||||
// log; the pages after it are one viewer scrolling and would bury it.
|
||||
if offset == 0 {
|
||||
s.loggerFor(ctx).Info("genre browsed", "genre", genre, "results", len(items), "total", total)
|
||||
} else {
|
||||
s.loggerFor(ctx).Debug("genre page", "genre", genre, "offset", offset, "results", len(items))
|
||||
}
|
||||
|
||||
body, err := json.Marshal(genrePage{
|
||||
Genre: genre,
|
||||
Items: items,
|
||||
Offset: offset,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not build genre results")
|
||||
return
|
||||
}
|
||||
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
|
||||
s.loggerFor(ctx).Warn("genre cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// genreTotal is what the television's scroll stops on, and it has to be right in the case
|
||||
// where nobody counted.
|
||||
//
|
||||
// Emby answers TotalRecordCount when it is asked to, and that is the honest number. When
|
||||
// it does not (an older build, or a library it will not count), the page itself is the only
|
||||
// evidence: a *full* page means there may well be more, so the total is nudged one past
|
||||
// what has been delivered and the scroll asks again; a short page is the end of the genre,
|
||||
// so the total is exactly what has been delivered and the scroll stops. Getting that
|
||||
// backwards either strands the viewer half way through a genre or leaves the grid asking
|
||||
// for a page that will never come.
|
||||
func genreTotal(reported, offset, count, limit int) int {
|
||||
if reported > 0 {
|
||||
return reported
|
||||
}
|
||||
total := offset + count
|
||||
if count >= limit && limit > 0 {
|
||||
total++
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// queryOffset is queryInt's other half: an offset of zero is a legal value rather than a
|
||||
// missing one, which is exactly the case queryInt reads as "use the fallback".
|
||||
func queryOffset(r *http.Request, key string) int {
|
||||
raw := r.URL.Query().Get(key)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenreTotalPrefersEmbysOwnCount(t *testing.T) {
|
||||
if got := genreTotal(412, 48, 48, 48); got != 412 {
|
||||
t.Fatalf("genreTotal = %d, want the reported 412", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenreTotalKeepsScrollingWhenNobodyCounted(t *testing.T) {
|
||||
// A full page with no count: there may be more, so the total has to sit past what has
|
||||
// been delivered or the television stops half way through the genre.
|
||||
if got := genreTotal(0, 48, 48, 48); got <= 96 {
|
||||
t.Fatalf("genreTotal = %d, want more than the 96 delivered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenreTotalStopsOnAShortPage(t *testing.T) {
|
||||
// A short page is the end of the genre. Claiming anything beyond it leaves the grid
|
||||
// asking for a page that will never come.
|
||||
if got := genreTotal(0, 48, 11, 48); got != 59 {
|
||||
t.Fatalf("genreTotal = %d, want exactly the 59 delivered", got)
|
||||
}
|
||||
if got := genreTotal(0, 0, 0, 48); got != 0 {
|
||||
t.Fatalf("genreTotal = %d, want 0 for a genre with nothing in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryOffsetTreatsZeroAsAValueRatherThanAMissingOne(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"": 0,
|
||||
"offset=0": 0,
|
||||
"offset=48": 48,
|
||||
"offset=-3": 0,
|
||||
"offset=nonsense": 0,
|
||||
}
|
||||
for query, want := range cases {
|
||||
r := httptest.NewRequest("GET", "/v1/genres/Comedy/items?"+query, nil)
|
||||
if got := queryOffset(r, "offset"); got != want {
|
||||
t.Errorf("queryOffset(%q) = %d, want %d", query, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
limit := queryInt(r, "limit", 40, 100)
|
||||
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
|
||||
|
||||
// Every search the tab performs is recorded here, before the cache is consulted, so a
|
||||
// query answered from Redis counts the same as one that reached Emby. The client also
|
||||
// posts to /v1/search/history and an older APK is the only thing that records at all —
|
||||
// recordSearchQuery's dedupe window is what stops the two writing the same query twice.
|
||||
s.recordSearchQuery(ctx, sess, term)
|
||||
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
@@ -560,6 +566,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
const (
|
||||
// minSearchQueryRunes matches the client's own floor: one letter matches half a
|
||||
// library, so the search tab does not ask below two and neither route records below it.
|
||||
minSearchQueryRunes = 2
|
||||
// maxSearchQueryRunes bounds what is written to search_history. The query arrives in a
|
||||
// URL on one of the two routes, so the table's row size must not be the client's to
|
||||
// choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the
|
||||
// length of one in English.
|
||||
maxSearchQueryRunes = 200
|
||||
)
|
||||
|
||||
// searchQueryRecordable is the one rule both routes apply, so a query the search handler
|
||||
// records is exactly one the history endpoint would have accepted.
|
||||
func searchQueryRecordable(term string) bool {
|
||||
n := len([]rune(strings.TrimSpace(term)))
|
||||
return n >= minSearchQueryRunes && n <= maxSearchQueryRunes
|
||||
}
|
||||
|
||||
// recordSearchQuery writes a query the search tab performed, and never makes the viewer
|
||||
// wait for it.
|
||||
//
|
||||
// Detached from the request context deliberately: instant search cancels the in-flight
|
||||
// request on every keystroke (the client's collectLatest), so a write hung off r.Context()
|
||||
// would be abandoned for precisely the searches somebody typed fastest — and the record is
|
||||
// worth having whether or not they waited for the results.
|
||||
func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) {
|
||||
if s.store == nil || !searchQueryRecordable(term) {
|
||||
return
|
||||
}
|
||||
term = strings.TrimSpace(term)
|
||||
log := s.loggerFor(ctx)
|
||||
detached := context.WithoutCancel(ctx)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(detached, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil {
|
||||
// Telemetry, not the answer: a search whose record failed still returns
|
||||
// results, and this is DEBUG for the same reason the search line itself is.
|
||||
log.Debug("search not recorded", "query", term, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type searchHistoryRequest struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
@@ -602,7 +651,7 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(req.Query)
|
||||
if len([]rune(query)) < 2 || len([]rune(query)) > 200 {
|
||||
if !searchQueryRecordable(query) {
|
||||
writeError(w, http.StatusBadRequest, "search query length is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
+110
-30
@@ -53,10 +53,29 @@ type introSegment struct {
|
||||
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
|
||||
// that has none, and the client defaults to false so a gateway that predates this — or has
|
||||
// the feature turned off — can never conjure a button.
|
||||
//
|
||||
// The closing credits ride the same response for one reason: they are in the same chapter
|
||||
// list, so answering both costs the one Emby request this handler was always going to make.
|
||||
// A second route for `CreditsStart` would have doubled the cost of a feature whose entire
|
||||
// claim is that it is free.
|
||||
type introResponse struct {
|
||||
Available bool `json:"available"`
|
||||
StartMs int64 `json:"startMs,omitempty"`
|
||||
EndMs int64 `json:"endMs,omitempty"`
|
||||
// CreditsAvailable and CreditsStartMs describe the closing credits. Separate from
|
||||
// Available on purpose: an episode routinely has one and not the other, and folding
|
||||
// them into a single flag would cost the credits pane every title Emby has detected no
|
||||
// intro for — which is most films.
|
||||
CreditsAvailable bool `json:"creditsAvailable"`
|
||||
CreditsStartMs int64 `json:"creditsStartMs,omitempty"`
|
||||
}
|
||||
|
||||
// chapterMarkers is everything one reading of an item's chapter list came to.
|
||||
type chapterMarkers struct {
|
||||
intro introSegment
|
||||
introFound bool
|
||||
creditsStart int64
|
||||
creditsFound bool
|
||||
}
|
||||
|
||||
// embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here.
|
||||
@@ -128,77 +147,138 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.skipIntroEnabled(ctx) {
|
||||
// Two features read this one list, and the request is only worth making if the operator
|
||||
// has left at least one of them on.
|
||||
intro, credits := s.skipIntroEnabled(ctx), s.endCreditsEnabled(ctx)
|
||||
if !intro && !credits {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
|
||||
segment, ok, err := s.introFor(ctx, sess, itemID)
|
||||
markers, err := s.markersFor(ctx, sess, itemID)
|
||||
if err != nil {
|
||||
// Trouble is answered with "no intro" rather than an error. The button is an
|
||||
// optional convenience on a film that is already playing, and a failure the viewer
|
||||
// Trouble is answered with "nothing found" rather than an error. Both features are
|
||||
// optional conveniences on a film that is already playing, and a failure the viewer
|
||||
// cannot act on is not worth a red line in the log for every episode watched.
|
||||
s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err)
|
||||
s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if !markers.introFound && !markers.creditsFound {
|
||||
writeJSON(w, http.StatusOK, introResponse{})
|
||||
return
|
||||
}
|
||||
// The same answer for everyone in the house, and it only changes when the media does.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
writeJSON(w, http.StatusOK, introResponse{
|
||||
Available: true,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, maskMarkers(markersResponse(markers), intro, credits))
|
||||
}
|
||||
|
||||
// markersResponse is the wire shape of a reading, and the one place the two halves are put
|
||||
// together — so a title with credits and no intro cannot accidentally report an intro
|
||||
// starting at zero.
|
||||
func markersResponse(markers chapterMarkers) introResponse {
|
||||
response := introResponse{}
|
||||
if markers.introFound {
|
||||
response.Available = true
|
||||
response.StartMs = markers.intro.StartMs
|
||||
response.EndMs = markers.intro.EndMs
|
||||
}
|
||||
if markers.creditsFound {
|
||||
response.CreditsAvailable = true
|
||||
response.CreditsStartMs = markers.creditsStart
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// maskMarkers withholds the half of a reading whose feature the operator has turned off.
|
||||
//
|
||||
// It happens on the way out rather than on the way in, which is what lets the cache hold
|
||||
// the unmasked truth: a feature switched back on takes effect on the next playback, instead
|
||||
// of serving a day of deliberate silence from an entry written while it was off.
|
||||
func maskMarkers(response introResponse, intro, credits bool) introResponse {
|
||||
if !intro {
|
||||
response.Available, response.StartMs, response.EndMs = false, 0, 0
|
||||
}
|
||||
if !credits {
|
||||
response.CreditsAvailable, response.CreditsStartMs = false, 0
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
|
||||
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
|
||||
}
|
||||
|
||||
func introCacheKey(itemID string) string { return "intro:v1:" + itemID }
|
||||
func (s *Server) endCreditsEnabled(ctx context.Context) bool {
|
||||
return s.emby != nil && s.featureEnabled(ctx, featureEndCredits)
|
||||
}
|
||||
|
||||
// introFor reads an item's chapter markers, remembering what they came to.
|
||||
// introCacheKey is v2 because the cached shape grew the credits marker. An entry written by
|
||||
// the previous build holds no `creditsAvailable`, and decoding it would report "no credits"
|
||||
// for a day on every title the house had already played — so the key moves rather than the
|
||||
// old entries being trusted.
|
||||
func introCacheKey(itemID string) string { return "intro:v2:" + itemID }
|
||||
|
||||
// markersFor reads an item's chapter markers, remembering what they came to.
|
||||
//
|
||||
// "No intro" is cached as well as an intro. It is the common case — a film, a special, an
|
||||
// episode Emby has not analysed yet — and without it every playback in the house would be
|
||||
// a fresh request to Emby for the same no.
|
||||
func (s *Server) introFor(
|
||||
// "Nothing found" is cached as well as a finding. It is the common case — a film, a special,
|
||||
// an episode Emby has not analysed yet — and without it every playback in the house would
|
||||
// be a fresh request to Emby for the same no.
|
||||
//
|
||||
// One reading answers for both features. The intro and the credits are the same field of
|
||||
// the same response, so splitting them into two lookups would have made the second one cost
|
||||
// a round trip it has no need to spend.
|
||||
func (s *Server) markersFor(
|
||||
ctx context.Context, sess store.Session, itemID string,
|
||||
) (introSegment, bool, error) {
|
||||
) (chapterMarkers, error) {
|
||||
key := introCacheKey(itemID)
|
||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||
var cached introResponse
|
||||
if json.Unmarshal(raw, &cached) == nil {
|
||||
return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil
|
||||
return chapterMarkers{
|
||||
intro: introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs},
|
||||
introFound: cached.Available,
|
||||
creditsStart: cached.CreditsStartMs,
|
||||
creditsFound: cached.CreditsAvailable,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
|
||||
if err != nil {
|
||||
return introSegment{}, false, err
|
||||
return chapterMarkers{}, err
|
||||
}
|
||||
// RunTimeTicks rides along because the credits rule needs it: a chapter merely *named*
|
||||
// "Credits" cannot be told from "Opening Credits" without knowing how far into the file it
|
||||
// sits. It is a default field on this response, so asking for it costs nothing.
|
||||
var parsed struct {
|
||||
Chapters []embyChapter `json:"Chapters"`
|
||||
Chapters []embyChapter `json:"Chapters"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return introSegment{}, false, err
|
||||
return chapterMarkers{}, err
|
||||
}
|
||||
|
||||
segment, ok := introFromChapters(parsed.Chapters)
|
||||
segment, introFound := introFromChapters(parsed.Chapters)
|
||||
creditsStart, creditsFound := creditsFromChapters(
|
||||
parsed.Chapters, parsed.RunTimeTicks/ticksPerMillisecond,
|
||||
)
|
||||
markers := chapterMarkers{
|
||||
intro: segment,
|
||||
introFound: introFound,
|
||||
creditsStart: creditsStart,
|
||||
creditsFound: creditsFound,
|
||||
}
|
||||
|
||||
// The shorter "not analysed yet" life applies unless *something* was found. A title
|
||||
// with credits but no intro has been analysed, and re-asking hourly for the intro Emby
|
||||
// has already decided it has none of would be a request per playback for a settled no.
|
||||
ttl := introMissingTTL
|
||||
if ok {
|
||||
if introFound || creditsFound {
|
||||
ttl = introTTL
|
||||
}
|
||||
if encoded, err := json.Marshal(introResponse{
|
||||
Available: ok,
|
||||
StartMs: segment.StartMs,
|
||||
EndMs: segment.EndMs,
|
||||
}); err == nil {
|
||||
if encoded, err := json.Marshal(markersResponse(markers)); err == nil {
|
||||
_ = s.cache.Set(ctx, key, encoded, ttl)
|
||||
}
|
||||
return segment, ok, nil
|
||||
return markers, nil
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
|
||||
return
|
||||
}
|
||||
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
series, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
|
||||
s.writeSeasonFinaleResponse(ctx, key, empty, w)
|
||||
@@ -277,6 +277,26 @@ func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
itemID := r.PathValue("id")
|
||||
if itemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching")
|
||||
return
|
||||
}
|
||||
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
|
||||
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
|
||||
}
|
||||
if s.forYou != nil {
|
||||
s.forYou.MarkDirty(r.Context(), sess)
|
||||
}
|
||||
writeRaw(w, http.StatusOK, userData)
|
||||
}
|
||||
|
||||
// setFlag applies a user-data mutation and drops this user's cached views, so the next
|
||||
// home request reflects it rather than serving the row it just contradicted.
|
||||
func (s *Server) setFlag(
|
||||
|
||||
@@ -133,11 +133,11 @@ func componentFor(path string) string {
|
||||
return "auth"
|
||||
case path == "/v1/home", path == "/v1/features":
|
||||
return "home"
|
||||
case path == "/v1/preferences":
|
||||
case path == "/v1/preferences", path == "/v1/theme":
|
||||
return "settings"
|
||||
case path == "/v1/screensaver", path == "/v1/preroll":
|
||||
return "screensaver"
|
||||
case strings.HasPrefix(path, "/v1/search"):
|
||||
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"):
|
||||
return "search"
|
||||
case strings.HasPrefix(path, "/v1/requests"):
|
||||
return "requests"
|
||||
|
||||
@@ -42,6 +42,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
|
||||
"/v1/auth/login": "auth",
|
||||
"/v1/auth/devices/tv-1": "devices",
|
||||
"/v1/search": "search",
|
||||
"/v1/genres/Comedy/items": "search",
|
||||
"/v1/items/42": "details",
|
||||
"/v1/items/42/related": "details",
|
||||
"/v1/items/42/playback": "playback",
|
||||
|
||||
@@ -132,5 +132,13 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses
|
||||
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||
// delivery channel for an operator pushing someone's settings.
|
||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
||||
// The theme, as an id and a revision rather than the palette itself — the
|
||||
// preferencesRevision precedent, for the same reason. The set refetches /v1/theme
|
||||
// only when one of these moves, which is what makes a season arriving at midnight
|
||||
// cost one request per television instead of a palette on every ten-second poll.
|
||||
// It rides the poll rather than the sign-in because that is the whole point: a
|
||||
// season has to reach a set that is already switched on, without anybody doing
|
||||
// anything.
|
||||
"theme": themeStatus(s.themeFor(r.Context(), sess)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
|
||||
}
|
||||
sonarrSeries := []sonarr.Series{}
|
||||
if s.sonarr != nil {
|
||||
if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil {
|
||||
if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil {
|
||||
sonarrSeries = value
|
||||
} else {
|
||||
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
|
||||
@@ -193,7 +193,7 @@ func (s *Server) syncReturnNotifications(
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
all, err := s.sonarr.Series(r.Context())
|
||||
all, err := s.sonarrSeriesCatalogue(r.Context())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ type playbackResponse struct {
|
||||
// already holds this response, and one boolean on a request that is made once per
|
||||
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
|
||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
||||
// Whether at least one subtitle on this title can be checked against another readable
|
||||
// text track. Like the download flag, this rides on the playback response because only
|
||||
// the subtitle drop-up needs it, and defaults to false for older gateways on the client.
|
||||
SubtitleFixAvailable bool `json:"subtitleFixAvailable"`
|
||||
// Whether it is worth asking this gateway for seek previews. Only the answer rides
|
||||
// here; the manifest itself does not, because reading it costs a round trip to Emby
|
||||
// and this response is the one thing standing between a Play press and a decoder
|
||||
@@ -49,6 +53,11 @@ type playbackResponse struct {
|
||||
// trip to Emby, and nothing about a skip button is needed before the first frame. The
|
||||
// television asks for the segment itself once playback has settled.
|
||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
||||
// Whether it is worth asking where the closing credits begin. Same reasoning again, and
|
||||
// deliberately a second boolean rather than a reuse of SkipIntroAvailable: the two are
|
||||
// separate features with separate switches, and a house that has turned the skip button
|
||||
// off has not asked to lose the credits pane with it.
|
||||
EndCreditsAvailable bool `json:"endCreditsAvailable"`
|
||||
}
|
||||
|
||||
type playableSubtitle struct {
|
||||
@@ -205,8 +214,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
||||
PlaySessionID: playSessionID,
|
||||
PlayMethod: playMethod,
|
||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||
SubtitleFixAvailable: s.subtitleFixAvailable(subtitles),
|
||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,6 +443,11 @@ func (s *Server) playbackSubtitles(
|
||||
Codec: stream.Codec,
|
||||
})
|
||||
}
|
||||
// Anything the gateway fetched itself joins the list here, so a subtitle downloaded
|
||||
// from a provider that cannot write beside the media file is an ordinary track on
|
||||
// every later playback — not something that exists only in the response to the
|
||||
// download that produced it.
|
||||
out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID))
|
||||
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||||
if delivery != "" {
|
||||
delivery = s.emby.DeliveryURL(cred, delivery)
|
||||
@@ -742,7 +758,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
|
||||
if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
|
||||
return ""
|
||||
}
|
||||
sonarrSeries, err := s.sonarr.Series(ctx)
|
||||
sonarrSeries, err := s.sonarrSeriesCatalogue(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
|
||||
return ""
|
||||
|
||||
@@ -112,6 +112,21 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
Description: "Use each title's logo artwork in place of plain text.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
// The one thing about themes a viewer decides. The options are the selectable
|
||||
// catalogue in themes.go rather than a list written out here, so a theme added
|
||||
// there cannot become a value this rejects.
|
||||
//
|
||||
// Note what this key does *not* control: whether a season is in force. That is
|
||||
// resolved server-side on top of this choice (resolveTheme), so a viewer's stored
|
||||
// selection survives underneath Halloween rather than being overwritten by it.
|
||||
// Note also that the per-user allowlist is not expressed here — this vocabulary is
|
||||
// the same for everybody, and an operator's restriction is applied at resolution.
|
||||
Key: "themeId", Name: "Colour scheme", Area: "Presentation",
|
||||
Description: "Which palette this viewer's televisions paint themselves.",
|
||||
Kind: preferenceChoice, Default: defaultThemeID,
|
||||
Options: themeOptions(),
|
||||
},
|
||||
{
|
||||
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
|
||||
Description: "Tone of the short line shown after signing in.",
|
||||
@@ -143,6 +158,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
||||
option(skipIntroOff, "Do nothing"),
|
||||
},
|
||||
},
|
||||
{
|
||||
// Default on: the whole feature is that it happens without being asked for, and it
|
||||
// is visible, reversible and over in a minute — a viewer who dislikes it turns it
|
||||
// off having seen exactly what it does. That is a different trade from
|
||||
// skipIntroMode's, which defaults to the button rather than the automatic seek
|
||||
// because a jump nobody can see coming is not recoverable by watching it.
|
||||
Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
|
||||
Description: "When an episode reaches its closing credits, shrink them to one side " +
|
||||
"at double speed and show what is on next beside them.",
|
||||
Kind: preferenceToggle, Default: true,
|
||||
},
|
||||
{
|
||||
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
||||
|
||||
@@ -18,8 +18,67 @@ import (
|
||||
// deployment until the previous daily cache expires.
|
||||
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
|
||||
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
||||
const sonarrSeriesCacheKey = "sonarr:series:v1"
|
||||
const sonarrScheduleDays = 5
|
||||
|
||||
// sonarrSeriesCatalogue is Sonarr's whole series list, cached the way the calendar is.
|
||||
//
|
||||
// Every caller here wants the same thing — the lifecycle, monitored flag and next airing
|
||||
// for one or two shows — and each was paying `/api/v3/series` in full to get it. On a
|
||||
// household with a few hundred followed shows that is a large response Sonarr assembles
|
||||
// from its own database, and it sat in front of things a viewer is waiting on: adding a
|
||||
// show to My Shows, which fetches it *after* the write, and the season-finale lookup on a
|
||||
// detail page. This is where the second or two came from.
|
||||
//
|
||||
// It is shared rather than per user — Sonarr's catalogue belongs to the household, not to
|
||||
// whoever asked — and it takes MEMBY_SONARR_TTL, the same five minutes the calendar rows
|
||||
// take. Short enough that following a show in Sonarr shows up on the next visit, long
|
||||
// enough that a viewer working through My Shows pays for it once.
|
||||
//
|
||||
// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency,
|
||||
// never the answer.
|
||||
func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) {
|
||||
if s.sonarr == nil {
|
||||
return nil, fmt.Errorf("sonarr: not configured")
|
||||
}
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// The same kind of shared lock the calendar takes, for the same reason: several
|
||||
// televisions opening together must not each stampede Sonarr on the one miss. Its own
|
||||
// mutex rather than sonarrMu, so an add to My Shows never waits behind a launcher
|
||||
// rebuilding the schedule row.
|
||||
s.sonarrSeriesMu.Lock()
|
||||
defer s.sonarrSeriesMu.Unlock()
|
||||
if series := s.cachedSonarrSeries(ctx); series != nil {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
series, err := s.sonarr.Series(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body, marshalErr := json.Marshal(series); marshalErr == nil {
|
||||
if cacheErr := s.cache.Set(ctx, sonarrSeriesCacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
|
||||
s.loggerFor(ctx).Warn("sonarr series cache write failed", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
func (s *Server) cachedSonarrSeries(ctx context.Context) []sonarr.Series {
|
||||
raw, err := s.cache.Get(ctx, sonarrSeriesCacheKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var series []sonarr.Series
|
||||
if json.Unmarshal(raw, &series) != nil {
|
||||
return nil
|
||||
}
|
||||
return series
|
||||
}
|
||||
|
||||
type prerollScheduleResponse struct {
|
||||
Today []prerollScheduleEntry `json:"today"`
|
||||
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
||||
|
||||
@@ -5,31 +5,35 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Fetching a subtitle a title does not have.
|
||||
//
|
||||
// The whole feature rests on one property of Bazarr: it writes the subtitle file beside
|
||||
// the media file. So the gateway never stores a subtitle, never serves one, and never
|
||||
// learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and
|
||||
// the track then arrives down the same PlaybackInfo path as an embedded one. That is why
|
||||
// `playableSubtitle` needed no new shape and the player's existing selection rule works on
|
||||
// a downloaded track with no special case.
|
||||
// Two providers answer this now and they are not the same shape. **Bazarr** writes the
|
||||
// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the
|
||||
// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded
|
||||
// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back
|
||||
// bytes, and the gateway has no reach into the media directory, so a file fetched there is
|
||||
// stored by the gateway and served back as a sidecar. `subtitle_providers.go` holds that
|
||||
// difference and everything here is written against the one vocabulary.
|
||||
//
|
||||
// The hard part is not the download, it is the identity. Bazarr keys everything on the
|
||||
// *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows
|
||||
// nothing about either, so an Emby item has to be matched onto one by title, year and —
|
||||
// for an episode — season and episode number. That matching is pure and unit-tested
|
||||
// (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a
|
||||
// mismatch downloads a subtitle for the wrong film and writes it next to this one.
|
||||
// The hard part is not the download, it is the identity, and the two providers make
|
||||
// opposite trades on it. Bazarr keys everything on the *arr's id (`radarrid` for a film,
|
||||
// Sonarr's `episodeid` for an episode) and Emby knows nothing about either, so an item has
|
||||
// to be matched onto one by title, year and — for an episode — season and episode number.
|
||||
// That matching is pure and unit-tested (`bazarrMovieFor`, `bazarrEpisodeFor`), because it
|
||||
// is where a wrong answer is worst: a mismatch downloads a subtitle for the wrong film and
|
||||
// writes it next to this one. OpenSubtitles keys on an imdb or tmdb id, which Memby
|
||||
// already holds — the library import asks Emby for `ProviderIds` so external ratings can
|
||||
// be looked up — so there is no guessing at all on that path.
|
||||
|
||||
const (
|
||||
bazarrMoviesCacheKey = "bazarr:movies"
|
||||
@@ -53,6 +57,12 @@ const (
|
||||
// expire underneath them, which on a set being operated by remote control is the more
|
||||
// likely failure of the two.
|
||||
type subtitleCandidate struct {
|
||||
// Source is which backend produced this row, and it is what the download call
|
||||
// dispatches on. It round-trips through the television with the token, because the two
|
||||
// providers' tokens are opaque in different ways and handing one to the other is a
|
||||
// mistake nothing downstream could detect. Empty means Bazarr: an app built before
|
||||
// there was a second provider sends no source, and its rows all came from one place.
|
||||
Source string `json:"source,omitempty"`
|
||||
Token string `json:"token"`
|
||||
Language string `json:"language"`
|
||||
LanguageLabel string `json:"languageLabel"`
|
||||
@@ -61,6 +71,17 @@ type subtitleCandidate struct {
|
||||
Forced bool `json:"forced"`
|
||||
HearingImpaired bool `json:"hearingImpaired"`
|
||||
OriginalFormat bool `json:"originalFormat"`
|
||||
// MachineOnly marks a translation nobody wrote. It is on the wire rather than folded
|
||||
// into the label alone because it is the one property that changes whether a viewer
|
||||
// wants the row at all, and the ranking sinks it below everything a person wrote.
|
||||
MachineOnly bool `json:"machineOnly,omitempty"`
|
||||
// Release is the file's own release string, carried for the log rather than the screen
|
||||
// — "Interstellar.2014.1080p.BluRay" means nothing across a lounge.
|
||||
Release string `json:"-"`
|
||||
// format is the extension the provider's file carries. Lower case and unexported: it
|
||||
// is the gateway's own bookkeeping for a file it is about to store, and there is
|
||||
// nothing for a television to do with it.
|
||||
format string
|
||||
// Label is what the drop-up prints. It is composed here rather than on the TV so an
|
||||
// older app renders a new wording correctly, the same reason alert labels are the
|
||||
// gateway's.
|
||||
@@ -96,11 +117,11 @@ type subtitleDownloadResponse struct {
|
||||
}
|
||||
|
||||
// subtitleDownloadAvailable is the one thing the television needs to know: whether to
|
||||
// offer the option at all. Both halves matter — an operator can turn the feature off on a
|
||||
// deployment that has Bazarr, and a deployment without Bazarr must never show a row that
|
||||
// cannot do anything.
|
||||
// offer the option at all. It is true when the feature is on and at least one provider is
|
||||
// both configured and switched on — a deployment with neither must never draw a row that
|
||||
// leads to a request nothing can answer.
|
||||
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
|
||||
return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload)
|
||||
return s.subtitleSources(ctx).any()
|
||||
}
|
||||
|
||||
func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
@@ -110,12 +131,13 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
|
||||
target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||
@@ -125,33 +147,32 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
||||
return
|
||||
}
|
||||
|
||||
found, err := s.searchBazarr(ctx, target)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle search failed",
|
||||
"item", itemID, "title", target.Title, "error", err,
|
||||
)
|
||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||
Results: []subtitleCandidate{},
|
||||
Message: "The subtitle service did not answer.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
language := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||
if language == "" {
|
||||
_, language = s.subtitlePreferenceFor(ctx, sess)
|
||||
}
|
||||
results := rankSubtitleCandidates(found, language)
|
||||
found, failures := s.providerSubtitles(ctx, sources, target, language)
|
||||
for _, failure := range failures {
|
||||
s.loggerFor(ctx).Warn("subtitle search failed",
|
||||
"item", itemID, "title", target.Title, "error", failure,
|
||||
)
|
||||
}
|
||||
results := rankMergedCandidates(found, language)
|
||||
s.loggerFor(ctx).Info("subtitle search",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(language),
|
||||
"bazarr", sources.Bazarr && target.hasBazarr,
|
||||
"opensubtitles", sources.OpenSubtitles && target.hasQuery,
|
||||
"found", len(found),
|
||||
"offered", len(results),
|
||||
)
|
||||
response := subtitleSearchResponse{Results: results}
|
||||
if len(results) == 0 {
|
||||
response.Message = "No subtitles were found for this release."
|
||||
// Which of the two empty answers this is matters to somebody standing in front of
|
||||
// the set: providers that found nothing is a different thing from providers that
|
||||
// did not answer, and an exhausted allowance is a third.
|
||||
response.Message = subtitleFailureMessage(failures)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -163,7 +184,8 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "item id is required")
|
||||
return
|
||||
}
|
||||
if !s.subtitleDownloadAvailable(ctx) {
|
||||
sources := s.subtitleSources(ctx)
|
||||
if !sources.any() {
|
||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||
return
|
||||
}
|
||||
@@ -173,54 +195,49 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(request.Candidate.Token) == "" {
|
||||
candidate := request.Candidate
|
||||
candidate.Language = normalizeSubtitleLanguage(candidate.Language)
|
||||
if strings.TrimSpace(candidate.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, "a subtitle is required")
|
||||
return
|
||||
}
|
||||
|
||||
cred := credentials(sess)
|
||||
target, err := s.resolveBazarrTarget(ctx, cred, itemID)
|
||||
target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||
writeError(w, http.StatusNotFound, "Memby could not work out which title this is")
|
||||
return
|
||||
}
|
||||
|
||||
subtitle := bazarr.Subtitle{
|
||||
Language: request.Candidate.Language,
|
||||
Provider: request.Candidate.Provider,
|
||||
Token: request.Candidate.Token,
|
||||
Forced: request.Candidate.Forced,
|
||||
HearingImpaired: request.Candidate.HearingImpaired,
|
||||
OriginalFormat: request.Candidate.OriginalFormat,
|
||||
}
|
||||
if target.EpisodeID > 0 {
|
||||
err = s.bazarr.DownloadEpisode(ctx, target.SeriesID, target.EpisodeID, subtitle)
|
||||
} else {
|
||||
err = s.bazarr.DownloadMovie(ctx, target.RadarrID, subtitle)
|
||||
}
|
||||
fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle download failed",
|
||||
"title", target.Title, "item", itemID,
|
||||
"provider", clientLogValue(subtitle.Provider), "error", err,
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider), "error", err,
|
||||
)
|
||||
writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded")
|
||||
writeError(w, http.StatusBadGateway, subtitleDownloadFailureMessage(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Bazarr has written the file; Emby does not know it exists. Refreshing is what makes
|
||||
// the track appear, and it is best-effort: if it fails the file is still on disk and
|
||||
// the next ordinary scan picks it up, so the viewer is told to try again rather than
|
||||
// told the download failed when it did not.
|
||||
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
|
||||
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
||||
"item", itemID, "error", refreshErr,
|
||||
)
|
||||
}
|
||||
select {
|
||||
case <-time.After(embyRefreshSettleDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
// Bazarr has written a file Emby does not know exists, and refreshing is what makes
|
||||
// the track appear. It is best-effort: if it fails the file is still on disk and the
|
||||
// next ordinary scan picks it up, so the viewer is told to try again rather than told
|
||||
// the download failed when it did not. A subtitle the gateway serves itself needs none
|
||||
// of this, and waiting anyway would spend a couple of seconds of somebody's film on
|
||||
// nothing.
|
||||
if fetched.RefreshEmby {
|
||||
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
|
||||
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
||||
"item", itemID, "error", refreshErr,
|
||||
)
|
||||
}
|
||||
select {
|
||||
case <-time.After(embyRefreshSettleDelay):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
|
||||
@@ -230,24 +247,43 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
||||
if negotiatedURL != "" {
|
||||
streamURL = negotiatedURL
|
||||
}
|
||||
// A file the gateway stored names itself, so the player can be pointed at exactly the
|
||||
// track that was just fetched. Only Bazarr's path has to guess, and it says so by
|
||||
// answering empty.
|
||||
selected := fetched.StoredID
|
||||
if selected == "" {
|
||||
selected = newestSubtitleID(subtitles, candidate)
|
||||
}
|
||||
|
||||
s.loggerFor(ctx).Info("subtitle downloaded",
|
||||
"title", target.Title,
|
||||
"item", itemID,
|
||||
"language", clientLogValue(subtitle.Language),
|
||||
"provider", clientLogValue(subtitle.Provider),
|
||||
"language", clientLogValue(candidate.Language),
|
||||
"source", clientLogValue(candidate.Source),
|
||||
"provider", clientLogValue(candidate.Provider),
|
||||
"release", clientLogValue(candidate.Release),
|
||||
"subtitles", len(subtitles),
|
||||
)
|
||||
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
|
||||
Message: downloadedSubtitleMessage(request.Candidate),
|
||||
Message: downloadedSubtitleMessage(candidate),
|
||||
Subtitles: subtitles,
|
||||
SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate),
|
||||
SelectedSubtitleID: selected,
|
||||
MediaSourceID: mediaSourceID,
|
||||
PlaySessionID: playSessionID,
|
||||
URL: streamURL,
|
||||
})
|
||||
}
|
||||
|
||||
// subtitleDownloadFailureMessage is the sentence a television prints when a fetch fails.
|
||||
// The allowance running out keeps its own wording for the reason the search's does:
|
||||
// pressing the button again will not fix it, and nothing else on the set can say so.
|
||||
func subtitleDownloadFailureMessage(err error) string {
|
||||
if _, ok := err.(*opensubtitles.QuotaError); ok {
|
||||
return "today's subtitle downloads have been used up"
|
||||
}
|
||||
return "the subtitle could not be downloaded"
|
||||
}
|
||||
|
||||
// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
|
||||
// RadarrID and EpisodeID is set.
|
||||
type bazarrTarget struct {
|
||||
@@ -419,83 +455,6 @@ func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Epi
|
||||
return nil
|
||||
}
|
||||
|
||||
// rankSubtitleCandidates orders what the viewer sees and caps the list.
|
||||
//
|
||||
// The viewer's language comes first, because it is the only thing they asked for; within
|
||||
// that, Bazarr's own score decides, because it is the only number on the row that means
|
||||
// anything on a television. Forced and hearing-impaired tracks sort below plain ones in
|
||||
// the same language for the reason the selection rule already gives — somebody who chose
|
||||
// Italian wants the dialogue, not the signs.
|
||||
func rankSubtitleCandidates(found []bazarr.Subtitle, language string) []subtitleCandidate {
|
||||
preferred := normalizeSubtitleLanguage(language)
|
||||
if preferred == subtitleLanguageAuto {
|
||||
preferred = ""
|
||||
}
|
||||
ordered := make([]bazarr.Subtitle, len(found))
|
||||
copy(ordered, found)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left, right := ordered[i], ordered[j]
|
||||
leftPreferred := preferred != "" && normalizeSubtitleLanguage(left.Language) == preferred
|
||||
rightPreferred := preferred != "" && normalizeSubtitleLanguage(right.Language) == preferred
|
||||
if leftPreferred != rightPreferred {
|
||||
return leftPreferred
|
||||
}
|
||||
if leftVariant, rightVariant := subtitleVariantRank(left), subtitleVariantRank(right); leftVariant != rightVariant {
|
||||
return leftVariant < rightVariant
|
||||
}
|
||||
return left.Score > right.Score
|
||||
})
|
||||
if len(ordered) > maxSubtitleResults {
|
||||
ordered = ordered[:maxSubtitleResults]
|
||||
}
|
||||
results := make([]subtitleCandidate, 0, len(ordered))
|
||||
for _, subtitle := range ordered {
|
||||
if strings.TrimSpace(subtitle.Token) == "" {
|
||||
continue
|
||||
}
|
||||
results = append(results, subtitleCandidate{
|
||||
Token: subtitle.Token,
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: subtitle.Provider,
|
||||
Score: subtitle.Score,
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
OriginalFormat: subtitle.OriginalFormat,
|
||||
Label: subtitleCandidateLabel(subtitle),
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func subtitleVariantRank(subtitle bazarr.Subtitle) int {
|
||||
switch {
|
||||
case subtitle.Forced:
|
||||
return 2
|
||||
case subtitle.HearingImpaired:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// subtitleCandidateLabel is what one row says: the language, what kind of track it is, and
|
||||
// how well Bazarr thinks it matches. The provider is deliberately absent — a viewer has no
|
||||
// way to prefer one and the name would only crowd the row.
|
||||
func subtitleCandidateLabel(subtitle bazarr.Subtitle) string {
|
||||
label := subtitleLanguageLabel(subtitle.Language)
|
||||
switch {
|
||||
case subtitle.Forced:
|
||||
label += " · Forced"
|
||||
case subtitle.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
if subtitle.Score > 0 {
|
||||
label += fmt.Sprintf(" · %d%% match", clampPercent(subtitle.Score))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func clampPercent(value int) int {
|
||||
if value < 0 {
|
||||
return 0
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) {
|
||||
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
|
||||
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
|
||||
}
|
||||
results := rankSubtitleCandidates(found, "it")
|
||||
results := rankMergedCandidates(bazarrCandidates(found), "it")
|
||||
if len(results) != 4 {
|
||||
t.Fatalf("results = %+v", results)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) {
|
||||
{Language: "eng", Score: 92, Token: "high"},
|
||||
}
|
||||
for _, language := range []string{"", "auto"} {
|
||||
results := rankSubtitleCandidates(found, language)
|
||||
results := rankMergedCandidates(bazarrCandidates(found), language)
|
||||
if len(results) != 2 || results[0].Token != "high" {
|
||||
t.Fatalf("language %q gave %+v", language, results)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
||||
for i := 0; i < maxSubtitleResults+5; i++ {
|
||||
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
|
||||
}
|
||||
results := rankSubtitleCandidates(found, "en")
|
||||
results := rankMergedCandidates(bazarrCandidates(found), "en")
|
||||
if len(results) > maxSubtitleResults {
|
||||
t.Fatalf("len = %d", len(results))
|
||||
}
|
||||
@@ -138,21 +138,24 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
|
||||
func TestMergedCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
|
||||
label := func(subtitle bazarr.Subtitle) string {
|
||||
return mergedCandidateLabel(bazarrCandidates([]bazarr.Subtitle{subtitle})[0])
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
subtitle bazarr.Subtitle
|
||||
want string
|
||||
}{
|
||||
{bazarr.Subtitle{Language: "ita", Score: 97}, "Italian · 97% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80}, "English · Forced · 80% match"},
|
||||
{bazarr.Subtitle{Language: "eng", HearingImpaired: true}, "English · Hearing impaired"},
|
||||
{bazarr.Subtitle{Language: "ita", Score: 97, Token: "t"}, "Italian · 97% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80, Token: "t"}, "English · Forced · 80% match"},
|
||||
{bazarr.Subtitle{Language: "eng", HearingImpaired: true, Token: "t"}, "English · Hearing impaired"},
|
||||
// A language Memby has no entry for must still name itself rather than go blank.
|
||||
{bazarr.Subtitle{Language: "mi"}, "MI"},
|
||||
{bazarr.Subtitle{}, "Unknown"},
|
||||
{bazarr.Subtitle{Language: "mi", Token: "t"}, "MI"},
|
||||
{bazarr.Subtitle{Token: "t"}, "Unknown"},
|
||||
// Bazarr has been seen to return scores above 100; a "112% match" reads as a bug.
|
||||
{bazarr.Subtitle{Language: "eng", Score: 112}, "English · 100% match"},
|
||||
{bazarr.Subtitle{Language: "eng", Score: 112, Token: "t"}, "English · 100% match"},
|
||||
} {
|
||||
if got := subtitleCandidateLabel(testCase.subtitle); got != testCase.want {
|
||||
if got := label(testCase.subtitle); got != testCase.want {
|
||||
t.Errorf("label = %q, want %q", got, testCase.want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/subsync"
|
||||
)
|
||||
|
||||
// Fixing a subtitle's timing.
|
||||
//
|
||||
// The whole of the work is in internal/subsync, which is pure. What lives here is the part
|
||||
// that cannot be: choosing which track to measure against, reading both of them out of
|
||||
// wherever they happen to live, and storing the result as an ordinary sidecar so it is a
|
||||
// track on every later playback rather than something that exists only in this response.
|
||||
//
|
||||
// It is deliberately a separate route from the download one. A viewer whose subtitle is
|
||||
// out of sync already *has* the file they want, and sending them through a provider search
|
||||
// to get another copy of it — which may be equally out — is the wrong answer to what they
|
||||
// asked.
|
||||
|
||||
// subtitleFixSuffix marks the repaired copy. It is part of the stored id, so fixing the
|
||||
// same track twice replaces the earlier attempt instead of growing a third row that a
|
||||
// viewer has to tell apart from the other two by guessing.
|
||||
const subtitleFixSuffix = "fixed"
|
||||
|
||||
type subtitleFixRequest struct {
|
||||
SubtitleID string `json:"subtitleId"`
|
||||
}
|
||||
|
||||
type subtitleFixResponse struct {
|
||||
// SubtitleID is the new track to select, absent when nothing needed changing.
|
||||
SubtitleID string `json:"subtitleId,omitempty"`
|
||||
Message string `json:"message"`
|
||||
// Changed is false when the track was already in sync. It is on the wire rather than
|
||||
// inferred from an empty id because "already right" is a success a viewer should be
|
||||
// told about plainly, not a silent no-op that reads as the button having failed.
|
||||
Changed bool `json:"changed"`
|
||||
// OffsetMs and Reference are what was done and what it was judged against. A viewer
|
||||
// deciding whether to trust the result needs both.
|
||||
OffsetMs int64 `json:"offsetMs"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
}
|
||||
|
||||
// subtitleFixAvailable answers the smaller question the playback response needs: whether
|
||||
// opening the timing action can lead anywhere for this title. The handler remains
|
||||
// authoritative because a track can disappear or fail to parse between playback and the
|
||||
// press, but not advertising an impossible action avoids a guaranteed refusal in the menu.
|
||||
func (s *Server) subtitleFixAvailable(tracks []playableSubtitle) bool {
|
||||
if s.store == nil {
|
||||
return false
|
||||
}
|
||||
for _, target := range tracks {
|
||||
if len(referenceCandidates(target, tracks)) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleSubtitleFix(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
ctx := r.Context()
|
||||
itemID := r.PathValue("id")
|
||||
if s.store == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "this server cannot fix subtitle timing")
|
||||
return
|
||||
}
|
||||
|
||||
var req subtitleFixRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid subtitle fix request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.SubtitleID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "no subtitle was named")
|
||||
return
|
||||
}
|
||||
|
||||
cred := credentials(sess)
|
||||
tracks, mediaSourceID, _, _, _ := s.playbackSubtitles(
|
||||
ctx, cred, itemID, 0, nil, "", false, sessionPlaybackCapabilities(sess),
|
||||
)
|
||||
|
||||
target, ok := trackByID(tracks, req.SubtitleID)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "that subtitle is no longer on this title")
|
||||
return
|
||||
}
|
||||
|
||||
log := s.loggerFor(ctx)
|
||||
fixed, err := s.fixSubtitleTiming(ctx, cred, itemID, mediaSourceID, target, tracks)
|
||||
if err != nil {
|
||||
var refusal *subsync.ErrNoAlignment
|
||||
if errors.As(err, &refusal) {
|
||||
// A refusal is the ordinary answer, not a fault: most of subsync's job is
|
||||
// declining to guess. It is reported as a 200 carrying the reason, because
|
||||
// the viewer needs the sentence and an error status would have the client
|
||||
// print its own generic one over the top of it.
|
||||
log.Info("subtitle timing not fixed", "item_id", itemID,
|
||||
"subtitle", req.SubtitleID, "reason", refusal.Reason,
|
||||
"score", refusal.Score, "margin", refusal.Margin)
|
||||
writeJSON(w, http.StatusOK, subtitleFixResponse{Message: refusal.Reason})
|
||||
return
|
||||
}
|
||||
log.Warn("subtitle fix failed", "item_id", itemID, "subtitle", req.SubtitleID, "error", err)
|
||||
writeError(w, http.StatusBadGateway, subtitleFixFailureMessage(err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("subtitle timing fixed", "item_id", itemID, "subtitle", req.SubtitleID,
|
||||
"reference", fixed.Reference, "correction", fixed.Correction,
|
||||
"score", fixed.Score, "changed", fixed.Changed)
|
||||
writeJSON(w, http.StatusOK, fixed.response())
|
||||
}
|
||||
|
||||
type subtitleFixOutcome struct {
|
||||
StoredID string
|
||||
Reference string
|
||||
Correction string
|
||||
OffsetMs int64
|
||||
Score float64
|
||||
Changed bool
|
||||
}
|
||||
|
||||
func (o subtitleFixOutcome) response() subtitleFixResponse {
|
||||
if !o.Changed {
|
||||
return subtitleFixResponse{
|
||||
Message: "This subtitle is already in time with " + o.Reference + ".",
|
||||
Reference: o.Reference,
|
||||
}
|
||||
}
|
||||
return subtitleFixResponse{
|
||||
SubtitleID: o.StoredID,
|
||||
Message: fmt.Sprintf("Timing corrected by %s against %s. The fixed copy is in the list.",
|
||||
o.Correction, o.Reference),
|
||||
Changed: true,
|
||||
OffsetMs: o.OffsetMs,
|
||||
Reference: o.Reference,
|
||||
}
|
||||
}
|
||||
|
||||
// fixSubtitleTiming reads both tracks, aligns them, and stores the result.
|
||||
func (s *Server) fixSubtitleTiming(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
target playableSubtitle, tracks []playableSubtitle,
|
||||
) (subtitleFixOutcome, error) {
|
||||
brokenRaw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, target)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("read the subtitle being fixed: %w", err)
|
||||
}
|
||||
broken, err := subsync.Parse(brokenRaw)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("parse the subtitle being fixed: %w", err)
|
||||
}
|
||||
|
||||
reference, referenceTrack, err := s.referenceTrack(ctx, cred, itemID, mediaSourceID, target, tracks)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, err
|
||||
}
|
||||
|
||||
opts := subsync.DefaultOptions()
|
||||
result, err := subsync.Align(broken, reference, opts)
|
||||
if err != nil {
|
||||
return subtitleFixOutcome{}, err
|
||||
}
|
||||
|
||||
outcome := subtitleFixOutcome{
|
||||
Reference: subtitleTrackName(referenceTrack),
|
||||
Correction: result.String(),
|
||||
OffsetMs: result.Offset.Milliseconds(),
|
||||
Score: result.Score,
|
||||
Changed: result.Correction(),
|
||||
}
|
||||
if !outcome.Changed {
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
stored := store.DownloadedSubtitle{
|
||||
ID: fixedSubtitleID(itemID, target),
|
||||
ItemID: itemID,
|
||||
Language: target.Language,
|
||||
Label: fixedSubtitleLabel(target),
|
||||
Forced: target.IsForced,
|
||||
HearingImpaired: target.IsHearingImpaired,
|
||||
Format: "srt",
|
||||
Provider: store.SubtitleProviderMemby,
|
||||
Content: subsync.FormatSRT(subsync.Shift(broken, result.Offset, result.Scale)),
|
||||
}
|
||||
if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
|
||||
return subtitleFixOutcome{}, fmt.Errorf("store the fixed subtitle: %w", err)
|
||||
}
|
||||
outcome.StoredID = stored.ID
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// referenceTrack picks and reads the yardstick.
|
||||
//
|
||||
// Reading is the expensive half — an embedded track is an Emby request each — so the
|
||||
// candidates are read lazily in the order subsync.Reference would rank them, and the first
|
||||
// one that parses wins. A track that will not parse is simply not a reference; failing the
|
||||
// whole fix because the third-choice yardstick is malformed would be perverse.
|
||||
func (s *Server) referenceTrack(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
target playableSubtitle, tracks []playableSubtitle,
|
||||
) ([]subsync.Cue, playableSubtitle, error) {
|
||||
candidates := referenceCandidates(target, tracks)
|
||||
if len(candidates) == 0 {
|
||||
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
|
||||
Reason: "there is no other subtitle on this title to check the timing against",
|
||||
}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
raw, err := s.subtitleContent(ctx, cred, itemID, mediaSourceID, candidate)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Debug("reference subtitle unreadable",
|
||||
"item_id", itemID, "subtitle", candidate.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
cues, err := subsync.Parse(raw)
|
||||
if err != nil || len(cues) < subsync.DefaultOptions().MinCues {
|
||||
continue
|
||||
}
|
||||
return cues, candidate, nil
|
||||
}
|
||||
return nil, playableSubtitle{}, &subsync.ErrNoAlignment{
|
||||
Reason: "none of the other subtitles on this title could be read as a timing reference",
|
||||
}
|
||||
}
|
||||
|
||||
// referenceCandidates orders the tracks worth measuring against, best first.
|
||||
//
|
||||
// The rules are about what makes a usable yardstick rather than a good subtitle. A forced
|
||||
// track carries only what is foreign to the film's own audio, so it is mostly silence and
|
||||
// would agree with almost any shift — it is excluded rather than ranked last. A track the
|
||||
// gateway already fixed is preferred, since it has been checked against something. After
|
||||
// that, a track in the same language is the closest match in line breaks and therefore in
|
||||
// timing, and an embedded track outranks a downloaded one because it shipped with this
|
||||
// copy of the film.
|
||||
func referenceCandidates(target playableSubtitle, tracks []playableSubtitle) []playableSubtitle {
|
||||
out := make([]playableSubtitle, 0, len(tracks))
|
||||
for _, track := range tracks {
|
||||
if track.ID == target.ID || track.IsForced {
|
||||
continue
|
||||
}
|
||||
if !isStoredSubtitleID(track.ID) && track.URL == "" {
|
||||
// An embedded track Emby will not deliver as text: it is burned in or needs a
|
||||
// transcode, and there is nothing to read.
|
||||
continue
|
||||
}
|
||||
out = append(out, track)
|
||||
}
|
||||
rank := func(track playableSubtitle) int {
|
||||
switch {
|
||||
case strings.HasSuffix(track.ID, ":"+subtitleFixSuffix):
|
||||
return 0
|
||||
case target.Language != "" && strings.EqualFold(track.Language, target.Language):
|
||||
return 1
|
||||
case !isStoredSubtitleID(track.ID):
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
// A stable sort, so tracks of equal rank keep the order Emby listed them in — which
|
||||
// puts the default track first, and the default is usually the one that is right.
|
||||
sort.SliceStable(out, func(i, j int) bool { return rank(out[i]) < rank(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// subtitleContent reads a track from wherever it lives: the gateway's own table for one it
|
||||
// fetched, Emby for one that came with the film.
|
||||
func (s *Server) subtitleContent(
|
||||
ctx context.Context, cred emby.Credentials, itemID, mediaSourceID string,
|
||||
track playableSubtitle,
|
||||
) ([]byte, error) {
|
||||
if isStoredSubtitleID(track.ID) {
|
||||
stored, err := s.store.DownloadedSubtitle(ctx, track.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stored.Content, nil
|
||||
}
|
||||
index, err := strconv.Atoi(track.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subtitle id %q is not an Emby stream index", track.ID)
|
||||
}
|
||||
return s.emby.SubtitleBytes(ctx, cred, itemID, mediaSourceID, index)
|
||||
}
|
||||
|
||||
func trackByID(tracks []playableSubtitle, id string) (playableSubtitle, bool) {
|
||||
for _, track := range tracks {
|
||||
if track.ID == id {
|
||||
return track, true
|
||||
}
|
||||
}
|
||||
return playableSubtitle{}, false
|
||||
}
|
||||
|
||||
// fixedSubtitleID keeps the repaired copy in the gateway's own namespace and distinct from
|
||||
// a downloaded file for the same language, so fixing a subtitle never overwrites the one it
|
||||
// was made from — a correction can be wrong, and the original has to still be there.
|
||||
func fixedSubtitleID(itemID string, target playableSubtitle) string {
|
||||
language := target.Language
|
||||
if language == "" {
|
||||
language = "und"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"gw", itemID, language, subtitleFixSuffix, sanitiseIDPart(target.ID),
|
||||
}, ":")
|
||||
}
|
||||
|
||||
// sanitiseIDPart keeps a source track's id usable inside another id. A stored track's own
|
||||
// id already contains colons, and nesting them would make the parts ambiguous.
|
||||
func sanitiseIDPart(id string) string {
|
||||
return strings.ReplaceAll(strings.TrimPrefix(id, storedSubtitleIDPrefix), ":", "-")
|
||||
}
|
||||
|
||||
func fixedSubtitleLabel(target playableSubtitle) string {
|
||||
label := strings.TrimSpace(target.Label)
|
||||
if label == "" {
|
||||
label = subtitleLanguageLabel(target.Language)
|
||||
}
|
||||
// Named rather than silently substituted: this track sits in the menu beside the one
|
||||
// it was made from, and the two are otherwise indistinguishable.
|
||||
return label + " (timing fixed)"
|
||||
}
|
||||
|
||||
func subtitleTrackName(track playableSubtitle) string {
|
||||
if label := strings.TrimSpace(track.Label); label != "" {
|
||||
return label
|
||||
}
|
||||
if language := subtitleLanguageLabel(track.Language); language != "" {
|
||||
return language
|
||||
}
|
||||
return "another subtitle"
|
||||
}
|
||||
|
||||
// subtitleFixFailureMessage is the whole diagnosis. A television has no log and no support
|
||||
// channel, so the sentence has to say what happened and whether pressing again would help.
|
||||
func subtitleFixFailureMessage(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, subsync.ErrNoCues):
|
||||
return "That subtitle could not be read, so its timing cannot be fixed."
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
|
||||
return "Fixing the timing took too long. Try again."
|
||||
default:
|
||||
return "The timing could not be fixed just now."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
func TestSubtitleFixAvailableNeedsAUsableReference(t *testing.T) {
|
||||
server := &Server{store: &store.Store{}}
|
||||
english := playableSubtitle{ID: "1", Language: "eng", URL: "https://emby/subtitle/1.srt"}
|
||||
italian := playableSubtitle{ID: "2", Language: "ita", URL: "https://emby/subtitle/2.srt"}
|
||||
|
||||
if server.subtitleFixAvailable([]playableSubtitle{english}) {
|
||||
t.Fatal("one subtitle cannot be checked against itself")
|
||||
}
|
||||
if !server.subtitleFixAvailable([]playableSubtitle{english, italian}) {
|
||||
t.Fatal("two readable text tracks should make timing repair available")
|
||||
}
|
||||
if (&Server{}).subtitleFixAvailable([]playableSubtitle{english, italian}) {
|
||||
t.Fatal("a gateway with no store cannot keep the repaired copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleFixHandlerRejectsAnUnnamedTrack(t *testing.T) {
|
||||
server := &Server{store: &store.Store{}}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/v1/items/42/subtitles/fix",
|
||||
strings.NewReader(`{"subtitleId":" "}`),
|
||||
)
|
||||
request.SetPathValue("id", "42")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleSubtitleFix(recorder, request, store.Session{})
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", recorder.Code)
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "no subtitle was named") {
|
||||
t.Fatalf("body = %q", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// Which backends may be asked for a subtitle, and how a candidate finds its way home.
|
||||
//
|
||||
// There are two providers now and they are not the same shape. Bazarr writes the file
|
||||
// beside the media file, so the gateway asks and forgets; OpenSubtitles hands back bytes,
|
||||
// which the gateway has to keep and serve itself. Everything above this file is written
|
||||
// against one vocabulary — search, download, a candidate carrying its source — and the
|
||||
// difference lives here and in `downloaded_subtitles`.
|
||||
//
|
||||
// The operator's switches are `store.SubtitlePolicy`, not environment variables, because
|
||||
// the two answer different questions and a household changes its mind about them. A
|
||||
// provider is offered only when it is configured *and* switched on *and* the
|
||||
// `subtitle_download` feature is on: an unconfigured deployment must never draw a row that
|
||||
// leads to a request nothing can answer.
|
||||
|
||||
// subtitleSources is which providers this request may use. Nothing downstream branches on
|
||||
// a client, a viewer or a title — a source is on for the household or it is not.
|
||||
type subtitleSources struct {
|
||||
Bazarr bool
|
||||
OpenSubtitles bool
|
||||
}
|
||||
|
||||
func (s subtitleSources) any() bool { return s.Bazarr || s.OpenSubtitles }
|
||||
|
||||
// subtitlePolicy reads the operator's document, falling back to the defaults rather than
|
||||
// to nothing: a store that will not answer must cost the console its switches, not a
|
||||
// household its subtitles.
|
||||
func (s *Server) subtitlePolicy(ctx context.Context) store.SubtitlePolicy {
|
||||
if s.store == nil {
|
||||
return store.DefaultSubtitlePolicy()
|
||||
}
|
||||
policy, err := s.store.SubtitlePolicy(ctx)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("subtitle policy unavailable; using defaults", "error", err)
|
||||
return store.DefaultSubtitlePolicy()
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Server) subtitleSources(ctx context.Context) subtitleSources {
|
||||
if !s.featureEnabled(ctx, featureSubtitleDownload) {
|
||||
return subtitleSources{}
|
||||
}
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
return subtitleSources{
|
||||
// Bazarr needs an address, which is deployment configuration and stays an
|
||||
// environment variable — it is a service the household runs, not a credential
|
||||
// somebody pastes into a console.
|
||||
Bazarr: s.bazarr != nil && policy.BazarrEnabled,
|
||||
// OpenSubtitles needs only a key, which the console holds, so it can be turned on
|
||||
// without a redeployment. The store already refuses to record it as on with no key.
|
||||
OpenSubtitles: policy.OpenSubtitlesEnabled && policy.OpenSubtitlesAPIKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
// openSubtitlesClient returns a client for the credentials currently saved, rebuilding it
|
||||
// when they change.
|
||||
//
|
||||
// It is cached rather than constructed per request for one reason that matters: the client
|
||||
// holds a login token, and logging in per download would spend a different allowance than
|
||||
// the one being conserved. The fingerprint is a hash so a credential never reaches a log
|
||||
// line or a comparison in a debugger.
|
||||
func (s *Server) openSubtitlesClient(ctx context.Context) *opensubtitles.Client {
|
||||
policy := s.subtitlePolicy(ctx)
|
||||
if !policy.OpenSubtitlesEnabled || policy.OpenSubtitlesAPIKey == "" {
|
||||
return nil
|
||||
}
|
||||
fingerprint := credentialFingerprint(
|
||||
policy.OpenSubtitlesAPIKey, policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
|
||||
)
|
||||
s.openSubtitlesMu.Lock()
|
||||
defer s.openSubtitlesMu.Unlock()
|
||||
if s.openSubtitles != nil && s.openSubtitlesKey == fingerprint {
|
||||
return s.openSubtitles
|
||||
}
|
||||
s.openSubtitles = opensubtitles.New(
|
||||
policy.OpenSubtitlesAPIKey, openSubtitlesUserAgent(),
|
||||
policy.OpenSubtitlesUsername, policy.OpenSubtitlesPassword,
|
||||
s.cfg.BazarrTimeout,
|
||||
)
|
||||
s.openSubtitlesKey = fingerprint
|
||||
return s.openSubtitles
|
||||
}
|
||||
|
||||
// openSubtitlesUserAgent names Memby to the provider. It carries the gateway's own version
|
||||
// rather than a television's: the request is the gateway's, and the API asks a consumer to
|
||||
// identify the build so it can be told when one misbehaves.
|
||||
func openSubtitlesUserAgent() string {
|
||||
return "Memby/" + buildinfo.Version()
|
||||
}
|
||||
|
||||
func credentialFingerprint(values ...string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join(values, "\x00")))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// subtitleTarget is one Emby item resolved onto everything either provider needs. It is
|
||||
// resolved once per search or download, because both providers want the same three facts
|
||||
// about a title and reading them twice would double the Emby traffic of a feature that
|
||||
// runs while somebody's film is paused.
|
||||
type subtitleTarget struct {
|
||||
Title string
|
||||
// Bazarr's ids. Exactly one of RadarrID and EpisodeID is set when Bazarr can be used.
|
||||
bazarr bazarrTarget
|
||||
hasBazarr bool
|
||||
// OpenSubtitles' identity, which is an external id rather than a title. Empty when
|
||||
// Emby knows no provider id for the item or its series.
|
||||
query opensubtitles.Query
|
||||
hasQuery bool
|
||||
}
|
||||
|
||||
// providerSubtitles searches every enabled provider at once and merges the answers.
|
||||
//
|
||||
// Concurrently, because a manual search is a live provider query measured in seconds and
|
||||
// running two in turn would double a wait somebody is standing in front of. A provider
|
||||
// that fails is dropped rather than failing the search: one working provider is a better
|
||||
// answer than an error, and the caller says so when both are empty.
|
||||
func (s *Server) providerSubtitles(
|
||||
ctx context.Context, sources subtitleSources, target subtitleTarget, language string,
|
||||
) ([]subtitleCandidate, []error) {
|
||||
type outcome struct {
|
||||
candidates []subtitleCandidate
|
||||
err error
|
||||
}
|
||||
results := make(chan outcome, 2)
|
||||
requested := 0
|
||||
|
||||
if sources.Bazarr && target.hasBazarr {
|
||||
requested++
|
||||
go func() {
|
||||
found, err := s.searchBazarr(ctx, target.bazarr)
|
||||
results <- outcome{candidates: bazarrCandidates(found), err: err}
|
||||
}()
|
||||
}
|
||||
if sources.OpenSubtitles && target.hasQuery {
|
||||
requested++
|
||||
client := s.openSubtitlesClient(ctx)
|
||||
go func() {
|
||||
if client == nil {
|
||||
results <- outcome{}
|
||||
return
|
||||
}
|
||||
query := target.query
|
||||
query.Languages = openSubtitlesLanguages(language)
|
||||
found, err := client.Search(ctx, query)
|
||||
results <- outcome{candidates: openSubtitlesCandidates(found), err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
var candidates []subtitleCandidate
|
||||
var failures []error
|
||||
for range requested {
|
||||
result := <-results
|
||||
if result.err != nil {
|
||||
failures = append(failures, result.err)
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, result.candidates...)
|
||||
}
|
||||
return candidates, failures
|
||||
}
|
||||
|
||||
// openSubtitlesLanguages is what a search asks for.
|
||||
//
|
||||
// English rides along with whatever the viewer chose, deliberately. A household that has
|
||||
// never set a language gets the auto value, and a search restricted to nothing comes back
|
||||
// with every language on earth ordered by somebody else's idea of relevance — where a
|
||||
// search that names one or two produces a list a person can read on a television.
|
||||
func openSubtitlesLanguages(language string) []string {
|
||||
normalized := normalizeSubtitleLanguage(language)
|
||||
if normalized == "" || normalized == subtitleLanguageAuto {
|
||||
return []string{"en"}
|
||||
}
|
||||
if normalized == "en" {
|
||||
return []string{"en"}
|
||||
}
|
||||
return []string{normalized, "en"}
|
||||
}
|
||||
|
||||
func bazarrCandidates(found []bazarr.Subtitle) []subtitleCandidate {
|
||||
out := make([]subtitleCandidate, 0, len(found))
|
||||
for _, subtitle := range found {
|
||||
if strings.TrimSpace(subtitle.Token) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, subtitleCandidate{
|
||||
Source: store.SubtitleProviderBazarr,
|
||||
Token: subtitle.Token,
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: subtitle.Provider,
|
||||
Score: clampPercent(subtitle.Score),
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
OriginalFormat: subtitle.OriginalFormat,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func openSubtitlesCandidates(found []opensubtitles.Subtitle) []subtitleCandidate {
|
||||
out := make([]subtitleCandidate, 0, len(found))
|
||||
for _, subtitle := range found {
|
||||
if subtitle.FileID <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, subtitleCandidate{
|
||||
Source: store.SubtitleProviderOpenSubtitles,
|
||||
// The token is the file id as a string, so one field carries both providers'
|
||||
// opaque handles and nothing above this file has to know the difference.
|
||||
Token: strconv.Itoa(subtitle.FileID),
|
||||
Language: normalizeSubtitleLanguage(subtitle.Language),
|
||||
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
|
||||
Provider: "OpenSubtitles",
|
||||
Score: openSubtitlesScore(subtitle),
|
||||
Forced: subtitle.Forced,
|
||||
HearingImpaired: subtitle.HearingImpaired,
|
||||
MachineOnly: subtitle.MachineOnly,
|
||||
Release: subtitle.Release,
|
||||
format: subtitle.Format,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// openSubtitlesScore turns the provider's two numbers into the one Bazarr already gives,
|
||||
// so a merged list can be ordered by a single figure that means roughly the same thing on
|
||||
// every row: how much confidence there is in this file.
|
||||
//
|
||||
// The rating is the meaningful half and it is what a viewer would look at; the download
|
||||
// count only breaks ties, because it measures age as much as quality — a subtitle uploaded
|
||||
// last week for a film from 1994 cannot out-download one that has been there for years.
|
||||
// A file nobody has rated is not a bad file, so it lands mid-scale rather than at the
|
||||
// bottom, the same judgement `heroUnratedScore` makes.
|
||||
func openSubtitlesScore(subtitle opensubtitles.Subtitle) int {
|
||||
score := 55
|
||||
if subtitle.Rating > 0 {
|
||||
score = int(subtitle.Rating * 10)
|
||||
}
|
||||
if subtitle.FromTrusted {
|
||||
score += 5
|
||||
}
|
||||
if subtitle.Downloads >= 1000 {
|
||||
score += 3
|
||||
}
|
||||
// A machine translation is a real answer and sometimes the only one, so it is offered
|
||||
// — below everything a person wrote, and saying so on its own row.
|
||||
if subtitle.MachineOnly {
|
||||
score -= 25
|
||||
}
|
||||
return clampPercent(score)
|
||||
}
|
||||
|
||||
// resolveSubtitleTarget reads the item once and works out what each provider needs.
|
||||
//
|
||||
// Failure is per provider rather than for the whole request: a film Bazarr has never heard
|
||||
// of may still have an imdb id, and a title with no provider id at all may still be in
|
||||
// Bazarr's list. Only both failing is a failure.
|
||||
func (s *Server) resolveSubtitleTarget(
|
||||
ctx context.Context, cred emby.Credentials, itemID string, sources subtitleSources,
|
||||
) (subtitleTarget, error) {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID,
|
||||
"ProductionYear,SeriesName,ParentIndexNumber,IndexNumber,ProviderIds")
|
||||
if err != nil {
|
||||
return subtitleTarget{}, err
|
||||
}
|
||||
var item struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
SeriesID string `json:"SeriesId"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
ProductionYear int `json:"ProductionYear"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &item); err != nil {
|
||||
return subtitleTarget{}, fmt.Errorf("unreadable item from emby: %w", err)
|
||||
}
|
||||
|
||||
target := subtitleTarget{Title: strings.TrimSpace(item.Name)}
|
||||
episode := strings.EqualFold(item.Type, "Episode")
|
||||
if !episode && !strings.EqualFold(item.Type, "Movie") {
|
||||
return subtitleTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type)
|
||||
}
|
||||
|
||||
if sources.Bazarr {
|
||||
if resolved, err := s.resolveBazarrTarget(ctx, cred, itemID); err == nil {
|
||||
target.bazarr, target.hasBazarr = resolved, true
|
||||
target.Title = resolved.Title
|
||||
} else {
|
||||
s.loggerFor(ctx).Debug("no bazarr target", "item", itemID, "error", err)
|
||||
}
|
||||
}
|
||||
if sources.OpenSubtitles {
|
||||
query := opensubtitles.Query{
|
||||
IMDBID: providerID(item.ProviderIDs, "imdb"),
|
||||
TMDBID: providerID(item.ProviderIDs, "tmdb"),
|
||||
Query: strings.TrimSpace(item.Name),
|
||||
Type: "movie",
|
||||
}
|
||||
if episode {
|
||||
query.Type = "episode"
|
||||
query.Season = item.ParentIndexNumber
|
||||
query.Episode = item.IndexNumber
|
||||
query.Query = strings.TrimSpace(item.SeriesName)
|
||||
// A show carries an id far more often than each of its episodes does, and the
|
||||
// API takes the series' id with a season and episode number, so the parent is
|
||||
// read whenever there is one to read.
|
||||
if item.SeriesID != "" {
|
||||
parents := s.itemProviderIDs(ctx, cred, item.SeriesID)
|
||||
query.ParentIMDBID = providerID(parents, "imdb")
|
||||
query.ParentTMDBID = providerID(parents, "tmdb")
|
||||
}
|
||||
if target.Title == "" || item.SeriesName != "" {
|
||||
target.Title = fmt.Sprintf("%s S%02dE%02d",
|
||||
item.SeriesName, item.ParentIndexNumber, item.IndexNumber)
|
||||
}
|
||||
}
|
||||
// searchParams answers nil for a query with no identity, which is the same rule
|
||||
// stated in one place; asking it here is what keeps this honest.
|
||||
target.query, target.hasQuery = query, opensubtitles.CanSearch(query)
|
||||
}
|
||||
|
||||
if !target.hasBazarr && !target.hasQuery {
|
||||
return subtitleTarget{}, fmt.Errorf("no subtitle provider can identify %q", item.Name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// itemProviderIDs reads one item's external ids. A failure is not fatal — it costs the
|
||||
// episode its parent's identity and the search falls back to the title.
|
||||
func (s *Server) itemProviderIDs(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
) map[string]string {
|
||||
raw, err := s.emby.Item(ctx, cred, itemID, "ProviderIds")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var parsed struct {
|
||||
ProviderIDs map[string]string `json:"ProviderIds"`
|
||||
}
|
||||
if json.Unmarshal(raw, &parsed) != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed.ProviderIDs
|
||||
}
|
||||
|
||||
// fetchSubtitle carries out one candidate's download and reports what the viewer should be
|
||||
// told. Which provider it goes to is the candidate's own `Source`; an unknown one is
|
||||
// refused rather than guessed at, since guessing means handing one provider's opaque token
|
||||
// to another.
|
||||
func (s *Server) fetchSubtitle(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
target subtitleTarget, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
switch candidate.Source {
|
||||
case store.SubtitleProviderOpenSubtitles:
|
||||
return s.fetchFromOpenSubtitles(ctx, itemID, candidate)
|
||||
case store.SubtitleProviderBazarr, "":
|
||||
return s.fetchFromBazarr(ctx, cred, itemID, target, candidate)
|
||||
default:
|
||||
return fetchedSubtitle{}, fmt.Errorf("unknown subtitle source %q", candidate.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchedSubtitle is what a download produced. StoredID is set only by the provider that
|
||||
// hands back bytes — it names the row the gateway now serves — and is what lets the
|
||||
// response point the player at the new track rather than at whatever Emby happened to
|
||||
// return in the same language.
|
||||
type fetchedSubtitle struct {
|
||||
StoredID string
|
||||
// RefreshEmby is true for a provider that wrote a file Emby has not noticed. Bazarr
|
||||
// needs it; a subtitle the gateway serves itself does not, and refreshing anyway would
|
||||
// spend a couple of seconds of somebody's film waiting for nothing.
|
||||
RefreshEmby bool
|
||||
}
|
||||
|
||||
func (s *Server) fetchFromBazarr(
|
||||
ctx context.Context, cred emby.Credentials, itemID string,
|
||||
target subtitleTarget, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
if s.bazarr == nil || !target.hasBazarr {
|
||||
return fetchedSubtitle{}, fmt.Errorf("bazarr cannot fetch for this title")
|
||||
}
|
||||
subtitle := bazarr.Subtitle{
|
||||
Language: candidate.Language,
|
||||
Provider: candidate.Provider,
|
||||
Token: candidate.Token,
|
||||
Forced: candidate.Forced,
|
||||
HearingImpaired: candidate.HearingImpaired,
|
||||
OriginalFormat: candidate.OriginalFormat,
|
||||
}
|
||||
var err error
|
||||
if target.bazarr.EpisodeID > 0 {
|
||||
err = s.bazarr.DownloadEpisode(ctx, target.bazarr.SeriesID, target.bazarr.EpisodeID, subtitle)
|
||||
} else {
|
||||
err = s.bazarr.DownloadMovie(ctx, target.bazarr.RadarrID, subtitle)
|
||||
}
|
||||
if err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
_ = cred // the refresh the caller makes needs it; the download does not.
|
||||
return fetchedSubtitle{RefreshEmby: true}, nil
|
||||
}
|
||||
|
||||
// fetchFromOpenSubtitles is the half of this feature that is not Bazarr-shaped: the
|
||||
// provider returns a file, the gateway keeps it, and it is served back as a sidecar. The
|
||||
// bytes are stored before anything is reported as successful, because a download that
|
||||
// spent the household's allowance and then lost the file is the worst outcome available.
|
||||
func (s *Server) fetchFromOpenSubtitles(
|
||||
ctx context.Context, itemID string, candidate subtitleCandidate,
|
||||
) (fetchedSubtitle, error) {
|
||||
client := s.openSubtitlesClient(ctx)
|
||||
if client == nil {
|
||||
return fetchedSubtitle{}, fmt.Errorf("opensubtitles is not configured")
|
||||
}
|
||||
fileID, err := strconv.Atoi(strings.TrimSpace(candidate.Token))
|
||||
if err != nil || fileID <= 0 {
|
||||
return fetchedSubtitle{}, fmt.Errorf("unusable opensubtitles file id")
|
||||
}
|
||||
name, content, err := client.Download(ctx, fileID)
|
||||
if err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
format := candidate.format
|
||||
if format == "" {
|
||||
format = subtitleFormatFromName(name)
|
||||
}
|
||||
stored := store.DownloadedSubtitle{
|
||||
ID: storedSubtitleID(itemID, candidate),
|
||||
ItemID: itemID,
|
||||
Language: candidate.Language,
|
||||
Label: storedSubtitleLabel(candidate),
|
||||
Forced: candidate.Forced,
|
||||
HearingImpaired: candidate.HearingImpaired,
|
||||
Format: format,
|
||||
Provider: store.SubtitleProviderOpenSubtitles,
|
||||
Content: content,
|
||||
}
|
||||
if err := s.store.PutDownloadedSubtitle(ctx, stored); err != nil {
|
||||
return fetchedSubtitle{}, err
|
||||
}
|
||||
return fetchedSubtitle{StoredID: stored.ID}, nil
|
||||
}
|
||||
|
||||
// storedSubtitleID names one file. It is derived from what was asked for rather than being
|
||||
// random, so fetching the same language for the same title twice replaces the file instead
|
||||
// of growing a second track a viewer has to tell apart by guessing.
|
||||
func storedSubtitleID(itemID string, candidate subtitleCandidate) string {
|
||||
variant := "plain"
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
variant = "forced"
|
||||
case candidate.HearingImpaired:
|
||||
variant = "sdh"
|
||||
}
|
||||
language := candidate.Language
|
||||
if language == "" {
|
||||
language = "und"
|
||||
}
|
||||
return strings.Join([]string{"gw", itemID, language, variant}, ":")
|
||||
}
|
||||
|
||||
// storedSubtitleIDPrefix is what marks a track as one the gateway serves rather than one
|
||||
// Emby knows about. The player matches on the id it was handed, so the two namespaces must
|
||||
// not be able to collide: Emby's are stream indices, which are plain numbers.
|
||||
const storedSubtitleIDPrefix = "gw:"
|
||||
|
||||
func isStoredSubtitleID(id string) bool {
|
||||
return strings.HasPrefix(id, storedSubtitleIDPrefix)
|
||||
}
|
||||
|
||||
func storedSubtitleLabel(candidate subtitleCandidate) string {
|
||||
label := candidate.LanguageLabel
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = subtitleLanguageLabel(candidate.Language)
|
||||
}
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
label += " · Forced"
|
||||
case candidate.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
// Named for where it came from, because a viewer looking at a track list should be
|
||||
// able to see which one arrived a minute ago and which was always in the file.
|
||||
return label + " · Downloaded"
|
||||
}
|
||||
|
||||
func subtitleFormatFromName(name string) string {
|
||||
if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 {
|
||||
switch extension := strings.ToLower(name[index+1:]); extension {
|
||||
case "srt", "vtt", "ass", "ssa":
|
||||
return extension
|
||||
}
|
||||
}
|
||||
return "srt"
|
||||
}
|
||||
|
||||
func storedSubtitleMIME(format string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "vtt":
|
||||
return "text/vtt"
|
||||
case "ass", "ssa":
|
||||
return "text/x-ssa"
|
||||
default:
|
||||
return "application/x-subrip"
|
||||
}
|
||||
}
|
||||
|
||||
// storedSubtitlesFor turns what the gateway holds for an item into playable tracks.
|
||||
//
|
||||
// The URL is a path rather than an absolute address on purpose: the gateway does not
|
||||
// reliably know its own externally reachable name, and the television does — it is talking
|
||||
// to it. The client resolves a relative subtitle URL against the gateway it is signed into
|
||||
// and appends its own token, exactly as it already does for artwork.
|
||||
func (s *Server) storedSubtitlesFor(ctx context.Context, itemID string) []playableSubtitle {
|
||||
if s.store == nil {
|
||||
return nil
|
||||
}
|
||||
held, err := s.store.DownloadedSubtitlesFor(ctx, itemID)
|
||||
if err != nil {
|
||||
s.loggerFor(ctx).Warn("stored subtitles unavailable", "item", itemID, "error", err)
|
||||
return nil
|
||||
}
|
||||
out := make([]playableSubtitle, 0, len(held))
|
||||
for _, subtitle := range held {
|
||||
out = append(out, playableSubtitle{
|
||||
ID: subtitle.ID,
|
||||
URL: storedSubtitlePath(subtitle),
|
||||
MimeType: storedSubtitleMIME(subtitle.Format),
|
||||
Language: subtitle.Language,
|
||||
Label: subtitle.Label,
|
||||
IsForced: subtitle.Forced,
|
||||
IsHearingImpaired: subtitle.HearingImpaired,
|
||||
DeliveryMethod: "External",
|
||||
Codec: subtitle.Format,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storedSubtitlePath is the route the file is served from. The extension is on the end
|
||||
// because media3 sniffs one when a MIME type is missing or wrong, and a subtitle served
|
||||
// from a path with no extension is the kind of thing that works on one decoder.
|
||||
func storedSubtitlePath(subtitle store.DownloadedSubtitle) string {
|
||||
format := strings.ToLower(strings.TrimSpace(subtitle.Format))
|
||||
if format == "" {
|
||||
format = "srt"
|
||||
}
|
||||
return "/v1/subtitles/" + url.PathEscape(subtitle.ID) + "." + format
|
||||
}
|
||||
|
||||
// handleStoredSubtitle serves one file the gateway fetched.
|
||||
//
|
||||
// It is authenticated like everything else under /v1 — the token arrives in the query
|
||||
// string, the way artwork's does, because a media player fetching a sidecar sends no
|
||||
// headers of Memby's. The response is immutable: an id names one fetch, and a re-fetch
|
||||
// writes a new body under the same id only when a viewer deliberately downloads the same
|
||||
// language again, so a long cache is right and a revalidation per playback is not.
|
||||
func (s *Server) handleStoredSubtitle(w http.ResponseWriter, r *http.Request, _ store.Session) {
|
||||
name := r.PathValue("file")
|
||||
id := name
|
||||
if index := strings.LastIndex(name, "."); index > 0 {
|
||||
id = name[:index]
|
||||
}
|
||||
unescaped, err := url.PathUnescape(id)
|
||||
if err != nil || !isStoredSubtitleID(unescaped) {
|
||||
writeError(w, http.StatusNotFound, "unknown subtitle")
|
||||
return
|
||||
}
|
||||
subtitle, err := s.store.DownloadedSubtitle(r.Context(), unescaped)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "unknown subtitle")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", storedSubtitleMIME(subtitle.Format))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(subtitle.Content)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(subtitle.Content)
|
||||
}
|
||||
|
||||
// mergeSubtitleTracks puts the gateway's own tracks beside Emby's, dropping any it already
|
||||
// covers.
|
||||
//
|
||||
// The overlap is real and it is the reason this is not an append: a subtitle fetched
|
||||
// through Bazarr becomes an Emby track, and a household that has Bazarr on may still have
|
||||
// fetched the same language here first. Two identically labelled rows in a drop-up is the
|
||||
// kind of thing that makes a viewer distrust the whole menu, so where both exist Emby's
|
||||
// wins — it is the one in the file, and it survives this gateway being replaced.
|
||||
func mergeSubtitleTracks(embyTracks, stored []playableSubtitle) []playableSubtitle {
|
||||
if len(stored) == 0 {
|
||||
return embyTracks
|
||||
}
|
||||
covered := map[string]bool{}
|
||||
for _, track := range embyTracks {
|
||||
covered[subtitleVariantKey(track)] = true
|
||||
}
|
||||
out := embyTracks
|
||||
for _, track := range stored {
|
||||
if covered[subtitleVariantKey(track)] {
|
||||
continue
|
||||
}
|
||||
out = append(out, track)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func subtitleVariantKey(track playableSubtitle) string {
|
||||
return fmt.Sprintf("%s|%t|%t",
|
||||
normalizeSubtitleLanguage(track.Language), track.IsForced, track.IsHearingImpaired)
|
||||
}
|
||||
|
||||
// rankMergedCandidates orders what the viewer sees across both providers and caps the list.
|
||||
//
|
||||
// The viewer's language comes first, because it is the only thing they asked for. Within a
|
||||
// language a plain track beats a forced or hearing-impaired one, for the reason the
|
||||
// selection rule already gives — somebody who chose Italian wants the dialogue, not the
|
||||
// signs — and a machine translation sinks below everything a person wrote. Only then does
|
||||
// the score decide, so a provider cannot buy its way to the top of somebody's list with a
|
||||
// confident number about the wrong language.
|
||||
func rankMergedCandidates(found []subtitleCandidate, language string) []subtitleCandidate {
|
||||
preferred := normalizeSubtitleLanguage(language)
|
||||
if preferred == subtitleLanguageAuto {
|
||||
preferred = ""
|
||||
}
|
||||
ordered := make([]subtitleCandidate, len(found))
|
||||
copy(ordered, found)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left, right := ordered[i], ordered[j]
|
||||
leftPreferred := preferred != "" && left.Language == preferred
|
||||
rightPreferred := preferred != "" && right.Language == preferred
|
||||
if leftPreferred != rightPreferred {
|
||||
return leftPreferred
|
||||
}
|
||||
if left.MachineOnly != right.MachineOnly {
|
||||
return right.MachineOnly
|
||||
}
|
||||
if leftRank, rightRank := candidateVariantRank(left), candidateVariantRank(right); leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return left.Score > right.Score
|
||||
})
|
||||
if len(ordered) > maxSubtitleResults {
|
||||
ordered = ordered[:maxSubtitleResults]
|
||||
}
|
||||
for i := range ordered {
|
||||
ordered[i].Label = mergedCandidateLabel(ordered[i])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func candidateVariantRank(candidate subtitleCandidate) int {
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
return 2
|
||||
case candidate.HearingImpaired:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// mergedCandidateLabel is what one row says. It is composed here rather than on the
|
||||
// television so an older app renders a new wording correctly, the same reason alert labels
|
||||
// are the gateway's.
|
||||
//
|
||||
// The provider is named now, where the single-provider version deliberately did not: with
|
||||
// two backends configured the same language appears twice and "which of these is which" is
|
||||
// a question the row has to answer. A machine translation says so, because it is the one
|
||||
// property of a subtitle that changes whether somebody wants it at all.
|
||||
func mergedCandidateLabel(candidate subtitleCandidate) string {
|
||||
label := candidate.LanguageLabel
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = subtitleLanguageLabel(candidate.Language)
|
||||
}
|
||||
switch {
|
||||
case candidate.Forced:
|
||||
label += " · Forced"
|
||||
case candidate.HearingImpaired:
|
||||
label += " · Hearing impaired"
|
||||
}
|
||||
if candidate.MachineOnly {
|
||||
label += " · Machine translated"
|
||||
}
|
||||
if candidate.Score > 0 {
|
||||
label += fmt.Sprintf(" · %d%% match", clampPercent(candidate.Score))
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
// subtitleFailureMessage turns what went wrong into the sentence printed over an empty
|
||||
// list. A television has no log and no support channel, so this is the whole diagnosis —
|
||||
// and the quota case is separated out because it is the only one where pressing the button
|
||||
// again is definitely not the answer.
|
||||
func subtitleFailureMessage(failures []error) string {
|
||||
for _, err := range failures {
|
||||
if _, ok := err.(*opensubtitles.QuotaError); ok {
|
||||
return "Today's subtitle downloads have been used up."
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return "The subtitle service did not answer."
|
||||
}
|
||||
return "No subtitles were found for this release."
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// A row has to carry the backend it came from, because the two tokens are opaque in
|
||||
// different ways and handing one to the other is a mistake nothing downstream could catch.
|
||||
func TestCandidatesCarryTheirSource(t *testing.T) {
|
||||
fromBazarr := bazarrCandidates([]bazarr.Subtitle{{Language: "eng", Token: "opaque"}})
|
||||
if len(fromBazarr) != 1 || fromBazarr[0].Source != store.SubtitleProviderBazarr {
|
||||
t.Fatalf("bazarr candidate = %+v", fromBazarr)
|
||||
}
|
||||
fromOpen := openSubtitlesCandidates([]opensubtitles.Subtitle{{FileID: 42, Language: "en"}})
|
||||
if len(fromOpen) != 1 || fromOpen[0].Source != store.SubtitleProviderOpenSubtitles {
|
||||
t.Fatalf("opensubtitles candidate = %+v", fromOpen)
|
||||
}
|
||||
if fromOpen[0].Token != "42" {
|
||||
t.Fatalf("token = %q, want the file id", fromOpen[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine translation is a real answer and sometimes the only one, so it is offered —
|
||||
// below everything a person wrote, whatever confidence the provider claims for it.
|
||||
func TestRankingSinksMachineTranslationsBelowHumanOnes(t *testing.T) {
|
||||
found := []subtitleCandidate{
|
||||
{Source: "opensubtitles", Token: "robot", Language: "en", Score: 99, MachineOnly: true},
|
||||
{Source: "bazarr", Token: "human", Language: "en", Score: 40},
|
||||
}
|
||||
results := rankMergedCandidates(found, "en")
|
||||
if results[0].Token != "human" {
|
||||
t.Fatalf("order = %q, %q", results[0].Token, results[1].Token)
|
||||
}
|
||||
if want := "English · Machine translated · 99% match"; results[1].Label != want {
|
||||
t.Fatalf("label = %q, want %q", results[1].Label, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer's language still outranks everything, across providers as it did within one.
|
||||
func TestRankingKeepsTheChosenLanguageFirstAcrossProviders(t *testing.T) {
|
||||
found := []subtitleCandidate{
|
||||
{Source: "bazarr", Token: "en", Language: "en", Score: 99},
|
||||
{Source: "opensubtitles", Token: "it", Language: "it", Score: 20},
|
||||
}
|
||||
if got := rankMergedCandidates(found, "it"); got[0].Token != "it" {
|
||||
t.Fatalf("first row = %q, want the Italian one", got[0].Token)
|
||||
}
|
||||
}
|
||||
|
||||
// A file nobody has rated is not a bad file, so it lands mid-scale rather than at the
|
||||
// bottom — the judgement heroUnratedScore already makes about an unrated title.
|
||||
func TestOpenSubtitlesScoreIsMidScaleWhenUnrated(t *testing.T) {
|
||||
unrated := openSubtitlesScore(opensubtitles.Subtitle{})
|
||||
if unrated < 40 || unrated > 70 {
|
||||
t.Fatalf("unrated score = %d, want mid-scale", unrated)
|
||||
}
|
||||
if rated := openSubtitlesScore(opensubtitles.Subtitle{Rating: 9.4}); rated <= unrated {
|
||||
t.Fatalf("a well-rated file scored %d, below an unrated %d", rated, unrated)
|
||||
}
|
||||
if machine := openSubtitlesScore(opensubtitles.Subtitle{Rating: 9.4, MachineOnly: true}); machine >= 94 {
|
||||
t.Fatalf("a machine translation kept its full score (%d)", machine)
|
||||
}
|
||||
}
|
||||
|
||||
// Two identically labelled rows in one drop-up is what makes a viewer distrust the whole
|
||||
// menu, so where both exist Emby's track wins: it is the one in the file.
|
||||
func TestMergeSubtitleTracksDropsWhatEmbyAlreadyHas(t *testing.T) {
|
||||
emby := []playableSubtitle{{ID: "3", Language: "eng", DeliveryMethod: "External"}}
|
||||
stored := []playableSubtitle{
|
||||
{ID: "gw:1:en:plain", Language: "en", DeliveryMethod: "External"},
|
||||
{ID: "gw:1:it:plain", Language: "it", DeliveryMethod: "External"},
|
||||
}
|
||||
merged := mergeSubtitleTracks(emby, stored)
|
||||
if len(merged) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
if merged[1].ID != "gw:1:it:plain" {
|
||||
t.Fatalf("kept %q, want only the Italian one to survive", merged[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// A forced track is not the same track as a plain one in the same language, so it must not
|
||||
// be deduplicated away — that is exactly the subtitle somebody downloaded it for.
|
||||
func TestMergeSubtitleTracksKeepsADifferentVariant(t *testing.T) {
|
||||
emby := []playableSubtitle{{ID: "3", Language: "eng"}}
|
||||
stored := []playableSubtitle{{ID: "gw:1:en:forced", Language: "en", IsForced: true}}
|
||||
if merged := mergeSubtitleTracks(emby, stored); len(merged) != 2 {
|
||||
t.Fatalf("merged = %+v", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// The id is derived from what was asked for, so fetching the same language twice replaces
|
||||
// the file rather than growing a second track a viewer has to tell apart by guessing.
|
||||
func TestStoredSubtitleIDIsStablePerVariant(t *testing.T) {
|
||||
plain := storedSubtitleID("42", subtitleCandidate{Language: "it"})
|
||||
again := storedSubtitleID("42", subtitleCandidate{Language: "it", Provider: "elsewhere"})
|
||||
if plain != again {
|
||||
t.Fatalf("%q != %q for the same title and language", plain, again)
|
||||
}
|
||||
forced := storedSubtitleID("42", subtitleCandidate{Language: "it", Forced: true})
|
||||
if forced == plain {
|
||||
t.Fatal("a forced track took the plain track's id")
|
||||
}
|
||||
if !isStoredSubtitleID(plain) {
|
||||
t.Fatalf("%q is not recognised as the gateway's own", plain)
|
||||
}
|
||||
// Emby's ids are stream indices, which are plain numbers. The two namespaces must not
|
||||
// be able to collide, because the player matches a track on the id it was handed.
|
||||
if isStoredSubtitleID("3") {
|
||||
t.Fatal("an Emby stream index was read as a gateway subtitle")
|
||||
}
|
||||
}
|
||||
|
||||
// A search restricted to nothing returns every language on earth, which is unreadable on a
|
||||
// television; English rides along because a household that never set a language gets one.
|
||||
func TestOpenSubtitlesLanguagesAlwaysNameSomething(t *testing.T) {
|
||||
for _, language := range []string{"", "auto"} {
|
||||
if got := openSubtitlesLanguages(language); len(got) != 1 || got[0] != "en" {
|
||||
t.Fatalf("languages for %q = %v", language, got)
|
||||
}
|
||||
}
|
||||
if got := openSubtitlesLanguages("it"); len(got) != 2 || got[0] != "it" {
|
||||
t.Fatalf("languages for Italian = %v, want Italian first", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The allowance running out is the only failure where pressing the button again is
|
||||
// definitely not the answer, so it must not be flattened into "did not answer".
|
||||
func TestSubtitleFailureMessageSeparatesTheQuotaCase(t *testing.T) {
|
||||
if got := subtitleFailureMessage(nil); got != "No subtitles were found for this release." {
|
||||
t.Fatalf("no failures gave %q", got)
|
||||
}
|
||||
quota := subtitleFailureMessage([]error{&opensubtitles.QuotaError{}})
|
||||
other := subtitleFailureMessage([]error{&opensubtitles.APIError{StatusCode: 500}})
|
||||
if quota == other {
|
||||
t.Fatalf("an exhausted quota reads the same as any other failure: %q", quota)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
)
|
||||
|
||||
// The colour a television paints itself, decided here rather than there.
|
||||
//
|
||||
// The whole feature is server-owned for the same reason the row composition and the
|
||||
// subtitle choice are: a palette that shipped in the APK could only change with a release,
|
||||
// and these sets are sideloaded one at a time. Deciding it here means an operator can hand
|
||||
// a household a new scheme, restrict what a particular viewer may choose, and — the part
|
||||
// that has to happen without anybody doing anything — put the whole house into a seasonal
|
||||
// theme on the right morning and take it away again afterwards.
|
||||
//
|
||||
// Two kinds of theme, and the difference is the point:
|
||||
//
|
||||
// - A **selectable** theme is the viewer's own choice, held as the ordinary synced
|
||||
// preference `themeId` and picked in Settings → Appearance from whatever the operator
|
||||
// has allowed them.
|
||||
// - A **seasonal** theme is not a choice at all. It is in force for its dates and nothing
|
||||
// on the television can decline it — there is no "off" in the picker, because a switch
|
||||
// for it is exactly what somebody would leave switched off in October and never think
|
||||
// about again. The one control that exists is the operator's, as a feature flag, and it
|
||||
// is all-or-nothing for the whole house.
|
||||
//
|
||||
// Both are palettes and nothing else. A theme changes colour; it never changes what a row
|
||||
// contains, where a control sits, or whether a feature exists — so a theme this build has
|
||||
// never heard of is at worst the wrong shade, never a launcher that will not draw.
|
||||
const themeSchemaVersion = 1
|
||||
|
||||
// themePalette is the whole vocabulary a theme may set, and it is deliberately the exact
|
||||
// token list in the television's ui/theme/DesignTokens.kt. A palette carrying a colour the
|
||||
// TV has no slot for would be a promise the client cannot keep; a palette missing one is a
|
||||
// theme that half-applies, which reads as a bug rather than as a design.
|
||||
//
|
||||
// Colours are "#RRGGBB" or "#AARRGGBB" — the alpha-first order Android writes, because that
|
||||
// is the one end that has to parse them.
|
||||
type themePalette struct {
|
||||
Surface string `json:"surface"`
|
||||
SurfaceRaised string `json:"surfaceRaised"`
|
||||
Accent string `json:"accent"`
|
||||
OnSurface string `json:"onSurface"`
|
||||
MutedText string `json:"mutedText"`
|
||||
QuietText string `json:"quietText"`
|
||||
Hairline string `json:"hairline"`
|
||||
RatingsSurface string `json:"ratingsSurface"`
|
||||
}
|
||||
|
||||
// The decorations a theme may ask a television to draw over its launcher. A slug rather
|
||||
// than a description of the animation: the drawing lives on the TV, in Compose, and the
|
||||
// gateway has no business describing shapes to it. A client that does not recognise one
|
||||
// draws nothing, which is why this can gain a decoration before the fleet has the build
|
||||
// that knows it — the MembyHeroLabel precedent.
|
||||
const (
|
||||
decorationSnow = "snow"
|
||||
decorationBats = "bats"
|
||||
decorationBlossom = "blossom"
|
||||
)
|
||||
|
||||
type themeDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Description is what the picker prints under the name. One short line: the viewer is
|
||||
// reading it from across a room and the swatch is doing most of the work.
|
||||
Description string `json:"description"`
|
||||
// Seasonal themes are never offered in the picker and never stored as anybody's choice.
|
||||
Seasonal bool `json:"seasonal"`
|
||||
Palette themePalette `json:"palette"`
|
||||
// Decoration is what drifts over the launcher while this theme is on. Only seasonal
|
||||
// themes carry one: a scheme somebody chose to look at every day of the year must not
|
||||
// have things falling across it, and a viewer who wanted that would have no way to stop
|
||||
// it. It is empty on every selectable theme by construction rather than by a check at
|
||||
// the point of use.
|
||||
Decoration string `json:"decoration,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
themeMidnight = "midnight"
|
||||
themeGraphite = "graphite"
|
||||
themeMidnightB = "indigo"
|
||||
themeEmber = "ember"
|
||||
themeForest = "forest"
|
||||
themePlum = "plum"
|
||||
|
||||
themeHalloween = "halloween"
|
||||
themeChristmas = "christmas"
|
||||
themeEaster = "easter"
|
||||
)
|
||||
|
||||
// defaultThemeID is what a viewer who has never chosen gets, and what an illegal choice
|
||||
// falls back to. It is the palette the app shipped with before themes existed, so nothing
|
||||
// changes appearance on the day this lands.
|
||||
const defaultThemeID = themeMidnight
|
||||
|
||||
// themeCatalogue is the only place a theme is declared: the picker, the admin console's
|
||||
// allowlist editor and the set of legal `themeId` values all read it.
|
||||
//
|
||||
// The neutrals move with the accent rather than staying fixed. A single accent swapped into
|
||||
// one grey shell reads as a stray coloured button rather than as a theme, and on a panel
|
||||
// this dark a hairline that does not carry a hint of the accent disappears entirely.
|
||||
var themeCatalogue = []themeDefinition{
|
||||
{
|
||||
ID: themeMidnight, Name: "Midnight", Description: "The Memby original — near-black and Emby green.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF090B0D", SurfaceRaised: "#FF101418", Accent: "#FF52B54B",
|
||||
OnSurface: "#FFE2E5E8", MutedText: "#FFD0D6DB", QuietText: "#FFAEB7BF",
|
||||
Hairline: "#28FFFFFF", RatingsSurface: "#FF20252A",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeGraphite, Name: "Graphite", Description: "Warm grey and amber, easier on a bright room.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0D0C0A", SurfaceRaised: "#FF181614", Accent: "#FFE0A33C",
|
||||
OnSurface: "#FFE9E5DE", MutedText: "#FFD8D2C8", QuietText: "#FFB6AEA1",
|
||||
Hairline: "#28FFF3DC", RatingsSurface: "#FF262320",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeMidnightB, Name: "Indigo", Description: "Deep blue with a cool electric accent.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF07090F", SurfaceRaised: "#FF111726", Accent: "#FF5C8DFF",
|
||||
OnSurface: "#FFE1E6F0", MutedText: "#FFCBD4E4", QuietText: "#FFA5B0C6",
|
||||
Hairline: "#28C7D8FF", RatingsSurface: "#FF1D2435",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeEmber, Name: "Ember", Description: "Charcoal and a low red, for watching in the dark.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0C0808", SurfaceRaised: "#FF181111", Accent: "#FFE05B4A",
|
||||
OnSurface: "#FFEDE3E1", MutedText: "#FFDACECB", QuietText: "#FFB8A7A3",
|
||||
Hairline: "#28FFD5CE", RatingsSurface: "#FF261B1A",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeForest, Name: "Forest", Description: "Muted green on a near-black that leans warm.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF080B09", SurfaceRaised: "#FF111713", Accent: "#FF7FC08A",
|
||||
OnSurface: "#FFE3E8E3", MutedText: "#FFCFD8CF", QuietText: "#FFA9B5AA",
|
||||
Hairline: "#28D2F0D6", RatingsSurface: "#FF1E2620",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themePlum, Name: "Plum", Description: "Aubergine and soft violet.",
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0B080D", SurfaceRaised: "#FF171020", Accent: "#FFB37FE0",
|
||||
OnSurface: "#FFE7E2EC", MutedText: "#FFD5CCDD", QuietText: "#FFB0A4BC",
|
||||
Hairline: "#28E4D2FF", RatingsSurface: "#FF241B2D",
|
||||
},
|
||||
},
|
||||
|
||||
// --- Seasonal. Never offered, never stored, never declined. ------------------------
|
||||
{
|
||||
ID: themeHalloween, Name: "Halloween", Seasonal: true,
|
||||
Description: "Pumpkin orange on black, for the last week of October.",
|
||||
Decoration: decorationBats,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F",
|
||||
OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C",
|
||||
Hairline: "#28FFB870", RatingsSurface: "#FF26190E",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeChristmas, Name: "Christmas", Seasonal: true,
|
||||
Description: "Pine and holly red, through December.",
|
||||
Decoration: decorationSnow,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF060A07", SurfaceRaised: "#FF0E1710", Accent: "#FFE0403F",
|
||||
OnSurface: "#FFEAF0E9", MutedText: "#FFD6E0D5", QuietText: "#FFAEBCAE",
|
||||
Hairline: "#28CFE8CF", RatingsSurface: "#FF19261B",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: themeEaster, Name: "Easter", Seasonal: true,
|
||||
Description: "Pale spring colours over the Easter weekend.",
|
||||
Decoration: decorationBlossom,
|
||||
Palette: themePalette{
|
||||
Surface: "#FF0A0910", SurfaceRaised: "#FF15131F", Accent: "#FF9BD3F0",
|
||||
OnSurface: "#FFEDE9F2", MutedText: "#FFDCD6E4", QuietText: "#FFB6AEC4",
|
||||
Hairline: "#28D8E9F7", RatingsSurface: "#FF211E2E",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func themeDefinitionFor(id string) (themeDefinition, bool) {
|
||||
for _, theme := range themeCatalogue {
|
||||
if theme.ID == id {
|
||||
return theme, true
|
||||
}
|
||||
}
|
||||
return themeDefinition{}, false
|
||||
}
|
||||
|
||||
// selectableThemes is the catalogue a viewer could ever be offered, before the operator's
|
||||
// per-user allowlist narrows it. Seasonal themes are absent by construction rather than
|
||||
// filtered at the point of use, so there is no code path that can offer one as a choice.
|
||||
func selectableThemes() []themeDefinition {
|
||||
themes := make([]themeDefinition, 0, len(themeCatalogue))
|
||||
for _, theme := range themeCatalogue {
|
||||
if !theme.Seasonal {
|
||||
themes = append(themes, theme)
|
||||
}
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
func selectableThemeIDs() []string {
|
||||
ids := make([]string, 0, len(themeCatalogue))
|
||||
for _, theme := range selectableThemes() {
|
||||
ids = append(ids, theme.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// themeOptions renders the selectable catalogue as preference options, so the `themeId`
|
||||
// entry in preferenceCatalogue cannot drift from the themes that actually exist.
|
||||
//
|
||||
// It lists every selectable theme rather than only the ones a given viewer may pick,
|
||||
// because normalizePreferences is pure and per-viewer policy is not a vocabulary question.
|
||||
// The allowlist is applied at resolution instead — see resolveTheme.
|
||||
func themeOptions() []preferenceOption {
|
||||
options := make([]preferenceOption, 0, len(themeCatalogue))
|
||||
for _, theme := range selectableThemes() {
|
||||
options = append(options, option(theme.ID, theme.Name))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// --- The seasons ------------------------------------------------------------------------
|
||||
|
||||
// themeSeason is one window in the calendar and the theme it puts the house into.
|
||||
type themeSeason struct {
|
||||
theme string
|
||||
// contains answers for a local date. A function rather than a pair of dates because
|
||||
// Easter is not on one.
|
||||
contains func(year int, month time.Month, day int) bool
|
||||
}
|
||||
|
||||
// seasons are checked in order and the first match wins, which only matters if two windows
|
||||
// ever overlap. They do not today, and the ordering is what stops a future one silently
|
||||
// producing two answers.
|
||||
var seasons = []themeSeason{
|
||||
{
|
||||
// The last week of October and All Saints' Day. It starts a week out rather than on
|
||||
// the day: a theme nobody sees until the evening of the 31st is one nobody sees.
|
||||
theme: themeHalloween,
|
||||
contains: func(_ int, month time.Month, day int) bool {
|
||||
return (month == time.October && day >= 25) || (month == time.November && day == 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
// December up to and including Boxing Day. It stops before New Year deliberately —
|
||||
// the tree is down, and a red-and-green launcher on the 30th reads as a server
|
||||
// nobody is maintaining.
|
||||
theme: themeChristmas,
|
||||
contains: func(_ int, month time.Month, day int) bool {
|
||||
return month == time.December && day <= 26
|
||||
},
|
||||
},
|
||||
{
|
||||
// Good Friday to Easter Monday, computed rather than listed: Easter moves, and a
|
||||
// hard-coded table is a feature with an expiry date on it.
|
||||
theme: themeEaster,
|
||||
contains: func(year int, month time.Month, day int) bool {
|
||||
sunday := easterSunday(year)
|
||||
date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
|
||||
return !date.Before(sunday.AddDate(0, 0, -2)) && !date.After(sunday.AddDate(0, 0, 1))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// easterSunday is the anonymous Gregorian computus. It is arithmetic with no calendar
|
||||
// library behind it and no table to go stale, which is the only reason Easter is affordable
|
||||
// as a season at all.
|
||||
func easterSunday(year int) time.Time {
|
||||
a := year % 19
|
||||
b := year / 100
|
||||
c := year % 100
|
||||
d := b / 4
|
||||
e := b % 4
|
||||
f := (b + 8) / 25
|
||||
g := (b - f + 1) / 3
|
||||
h := (19*a + b - d - g + 15) % 30
|
||||
i := c / 4
|
||||
k := c % 4
|
||||
l := (32 + 2*e + 2*i - h - k) % 7
|
||||
m := (a + 11*h + 22*l) / 451
|
||||
month := (h + l - 7*m + 114) / 31
|
||||
day := ((h + l - 7*m + 114) % 31) + 1
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// seasonalThemeFor is the theme in force on a given day, or "" for most of the year.
|
||||
//
|
||||
// Pure, and takes the time rather than reading the clock, so every window can be tested at
|
||||
// both of its edges without waiting for October. The date is read in whatever location the
|
||||
// caller hands it in: the gateway runs on the household's own machine, and "Christmas" means
|
||||
// the calendar on the wall in that house, not a UTC instant.
|
||||
func seasonalThemeFor(now time.Time) string {
|
||||
year, month, day := now.Date()
|
||||
for _, season := range seasons {
|
||||
if season.contains(year, month, day) {
|
||||
return season.theme
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Resolution -------------------------------------------------------------------------
|
||||
|
||||
// resolvedTheme is the answer a television is given: one palette, and enough about where it
|
||||
// came from for the picker to explain itself.
|
||||
type resolvedTheme struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Palette themePalette `json:"palette"`
|
||||
// Seasonal says this palette was not chosen by anybody.
|
||||
Seasonal bool `json:"seasonal"`
|
||||
// Locked is what the picker obeys: while it is true the viewer's own choice is still
|
||||
// stored and still shown, but it cannot be changed and is not what is on screen. It is
|
||||
// a separate field from Seasonal rather than the same one, because a future reason to
|
||||
// lock a theme (an operator pinning one, say) must not have to claim to be a season.
|
||||
Locked bool `json:"locked"`
|
||||
// Chosen is the viewer's own selection, still theirs underneath a season. Without it
|
||||
// the picker would have nothing to show as selected for the fortnight a season is up,
|
||||
// and would look as though the choice had been forgotten.
|
||||
Chosen string `json:"chosen"`
|
||||
// Decoration is what the launcher draws over itself: "snow", "bats", "blossom", or
|
||||
// empty for the whole rest of the year. Empty is also what an operator who has turned
|
||||
// the decorations off gets, which is why it is resolved here rather than read off the
|
||||
// theme by the television — a set holding a cached Christmas palette must not keep
|
||||
// snowing after the switch has been thrown.
|
||||
Decoration string `json:"decoration,omitempty"`
|
||||
// Reason is the sentence the picker prints while it is locked. The gateway's wording,
|
||||
// the MembyHeroLabel precedent, so a season invented later reads correctly on today's
|
||||
// build rather than as a blank space where an explanation should be.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
// Revision changes whenever the bytes of this answer would change. The status poll
|
||||
// carries it and the television refetches only when it moves — which is what makes a
|
||||
// season arriving overnight cost one request rather than a palette on every poll.
|
||||
//
|
||||
// A string, not a number: it is a 64-bit hash, and JSON numbers are float64 in both
|
||||
// the admin console and anything else that reads this. It is only ever compared for
|
||||
// equality, so its being opaque costs nothing.
|
||||
Revision string `json:"revision"`
|
||||
}
|
||||
|
||||
// resolveTheme is the whole rule, and it is pure.
|
||||
//
|
||||
// Order matters and is the feature: a season outranks the viewer, the viewer outranks the
|
||||
// default, and the operator's allowlist is applied to the viewer's choice but never to a
|
||||
// season. That last part is what "cannot be removed or controlled by the user" means in
|
||||
// code — there is no argument to this function that a television could send which suppresses
|
||||
// a season. The only switch is seasonalEnabled, and that is the operator's feature flag.
|
||||
func resolveTheme(
|
||||
chosen string,
|
||||
allowed []string,
|
||||
seasonalEnabled bool,
|
||||
decorationsEnabled bool,
|
||||
now time.Time,
|
||||
) resolvedTheme {
|
||||
// The viewer's own choice first, so it is reported even while a season covers it.
|
||||
pick, ok := themeDefinitionFor(chosen)
|
||||
if !ok || pick.Seasonal || !themeAllowed(pick.ID, allowed) {
|
||||
pick, _ = themeDefinitionFor(defaultThemeID)
|
||||
}
|
||||
|
||||
applied, seasonal, reason := pick, false, ""
|
||||
if seasonalEnabled {
|
||||
if id := seasonalThemeFor(now); id != "" {
|
||||
if season, found := themeDefinitionFor(id); found {
|
||||
applied, seasonal = season, true
|
||||
reason = season.Name + " is on for everyone until it is over."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decorations are a second switch, not a consequence of the first. A household on a
|
||||
// weak box may well want the December palette and nothing moving over it, and a
|
||||
// decoration is by far the more expensive half — it is the only thing in the app that
|
||||
// animates continuously while somebody is browsing.
|
||||
decoration := ""
|
||||
if seasonal && decorationsEnabled {
|
||||
decoration = applied.Decoration
|
||||
}
|
||||
|
||||
resolved := resolvedTheme{
|
||||
ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
|
||||
Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
|
||||
Decoration: decoration,
|
||||
}
|
||||
resolved.Revision = themeRevision(resolved)
|
||||
return resolved
|
||||
}
|
||||
|
||||
// themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row
|
||||
// has ever been written for the great majority of households, and reading that as "this
|
||||
// person may have no themes" would empty every picker in the house the day this ships.
|
||||
func themeAllowed(id string, allowed []string) bool {
|
||||
if len(allowed) == 0 {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(allowed, id)
|
||||
}
|
||||
|
||||
// themeRevision is a hash of the answer rather than a counter in a table, because there is
|
||||
// no write to attach a counter to: this changes when the calendar turns over or when a
|
||||
// deployment edits the catalogue, and neither of those is a row anybody updates.
|
||||
func themeRevision(resolved resolvedTheme) string {
|
||||
digest := fnv.New64a()
|
||||
palette := resolved.Palette
|
||||
for _, part := range []string{
|
||||
strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen,
|
||||
strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason,
|
||||
resolved.Decoration,
|
||||
palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface,
|
||||
palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface,
|
||||
} {
|
||||
_, _ = digest.Write([]byte(part))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
}
|
||||
return strconv.FormatUint(digest.Sum64(), 10)
|
||||
}
|
||||
|
||||
// --- Serving ------------------------------------------------------------------------------
|
||||
|
||||
// themeFor resolves this viewer's theme from the three things it depends on: their stored
|
||||
// choice, the operator's allowlist for them, and the clock.
|
||||
//
|
||||
// Every read failure degrades to the default rather than to an error. A launcher that will
|
||||
// not open because a colour could not be looked up would be an absurd trade, and the
|
||||
// palette it falls back to is the one the app shipped with.
|
||||
func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme {
|
||||
chosen, _ := preferenceDefault("themeId").(string)
|
||||
allowed := []string(nil)
|
||||
if s.store != nil && sess.EmbyUserID != "" {
|
||||
if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil {
|
||||
if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok {
|
||||
chosen = value
|
||||
}
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("theme preference unavailable", "error", err)
|
||||
}
|
||||
if list, err := s.store.UserThemes(ctx, sess.EmbyUserID); err == nil {
|
||||
allowed = list
|
||||
} else {
|
||||
s.loggerFor(ctx).Warn("theme allowlist unavailable", "error", err)
|
||||
}
|
||||
}
|
||||
return resolveTheme(
|
||||
chosen, allowed,
|
||||
s.featureEnabled(ctx, featureSeasonalThemes),
|
||||
s.featureEnabled(ctx, featureSeasonalDecorations),
|
||||
s.now(),
|
||||
)
|
||||
}
|
||||
|
||||
// now is the gateway's own clock, in its own location. Seasons are calendar dates in the
|
||||
// house the server sits in; see seasonalThemeFor.
|
||||
func (s *Server) now() time.Time { return time.Now() }
|
||||
|
||||
// themeStatus is the summary /v1/status carries: enough for a television to know whether
|
||||
// what it is painted with is still right, and nothing more.
|
||||
func themeStatus(resolved resolvedTheme) map[string]any {
|
||||
return map[string]any{
|
||||
"id": resolved.ID,
|
||||
"revision": resolved.Revision,
|
||||
"seasonal": resolved.Seasonal,
|
||||
"locked": resolved.Locked,
|
||||
}
|
||||
}
|
||||
|
||||
type themeResponse struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Theme resolvedTheme `json:"theme"`
|
||||
Available []themeDefinition `json:"available"`
|
||||
}
|
||||
|
||||
// handleTheme is the full document, fetched only when the revision on the status poll moves.
|
||||
//
|
||||
// It carries the *available* list as well as the applied palette, so the television's picker
|
||||
// is drawn from the server's answer for this particular viewer rather than from a catalogue
|
||||
// compiled into the APK. That is what makes the per-user allowlist real: a theme an operator
|
||||
// has withheld is not a greyed-out row on the TV, it is a row that was never sent.
|
||||
func (s *Server) handleTheme(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
resolved := s.themeFor(r.Context(), sess)
|
||||
allowed := []string(nil)
|
||||
if s.store != nil && sess.EmbyUserID != "" {
|
||||
if list, err := s.store.UserThemes(r.Context(), sess.EmbyUserID); err == nil {
|
||||
allowed = list
|
||||
}
|
||||
}
|
||||
available := []themeDefinition{}
|
||||
for _, theme := range selectableThemes() {
|
||||
if themeAllowed(theme.ID, allowed) {
|
||||
available = append(available, theme)
|
||||
}
|
||||
}
|
||||
// A viewer whose allowlist has been emptied down to nothing legal still gets the
|
||||
// default, or Settings → Appearance is a page with no rows on it and no way back to one.
|
||||
if len(available) == 0 {
|
||||
if fallback, ok := themeDefinitionFor(defaultThemeID); ok {
|
||||
available = append(available, fallback)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, themeResponse{
|
||||
SchemaVersion: themeSchemaVersion, Theme: resolved, Available: available,
|
||||
})
|
||||
}
|
||||
|
||||
// --- The operator's allowlist ---------------------------------------------------------------
|
||||
|
||||
// normalizeThemeAllowlist is what stands between a hand-edited admin request and a viewer
|
||||
// with a picker full of themes that do not exist. Unknown and seasonal ids are dropped —
|
||||
// a season is not a thing that can be granted or withheld per person — and the result is
|
||||
// ordered by the catalogue so two operators saving the same set store the same row.
|
||||
//
|
||||
// A list that selects everything selectable is stored as nothing at all, which keeps the
|
||||
// permissive default meaning one thing: "the operator has not restricted this person".
|
||||
func normalizeThemeAllowlist(ids []string) []string {
|
||||
kept := []string{}
|
||||
for _, theme := range selectableThemes() {
|
||||
if slices.Contains(ids, theme.ID) {
|
||||
kept = append(kept, theme.ID)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(selectableThemes()) {
|
||||
return []string{}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
type adminThemesRequest struct {
|
||||
Themes []string `json:"themes"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUserThemes(w http.ResponseWriter, r *http.Request) {
|
||||
userID := strings.TrimSpace(r.PathValue("userID"))
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusBadRequest, "user is required")
|
||||
return
|
||||
}
|
||||
var req adminThemesRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||
return
|
||||
}
|
||||
allowed := normalizeThemeAllowlist(req.Themes)
|
||||
if err := s.store.SetUserThemes(r.Context(), userID, allowed); err != nil {
|
||||
s.loggerFor(r.Context()).Error("theme allowlist write failed", "user", userID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not save those themes")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("themes allowed for viewer",
|
||||
"user", userID, "themes", themeListLabel(allowed))
|
||||
writeJSON(w, http.StatusOK, map[string]any{"themes": allowed})
|
||||
}
|
||||
|
||||
// themeListLabel is for the log line, where "all" says more than an empty array does.
|
||||
func themeListLabel(allowed []string) string {
|
||||
if len(allowed) == 0 {
|
||||
return "all"
|
||||
}
|
||||
sorted := append([]string{}, allowed...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ",")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user