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.
|
# import still hears about it. 0 turns the banners off.
|
||||||
MEMBY_RADARR_ALERT_WINDOW=3h
|
MEMBY_RADARR_ALERT_WINDOW=3h
|
||||||
|
|
||||||
# Optional Bazarr integration, which is what lets a viewer fetch a subtitle from the
|
# Optional Bazarr integration, one of the two providers a viewer can fetch a missing
|
||||||
# player for a title the library has none for. Bazarr writes the file beside the media
|
# subtitle from. Bazarr writes the file beside the media file, so Emby serves the result
|
||||||
# file, so Emby serves the result and Memby stores nothing — leaving these unset simply
|
# and Memby stores nothing — leaving these unset simply means Bazarr is never offered. The
|
||||||
# means the option is never offered. The API key is in Bazarr under Settings > General.
|
# 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_URL=http://10.0.0.2:6767
|
||||||
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
|
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
|
||||||
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an
|
# 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
|
## 0.2.33 — 2026-08-08
|
||||||
- General bug fixes and improvements.
|
- 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
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.33"
|
val defaultVersionName = "0.2.34"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.ponzischeme89.memby.data.EmbyRepository
|
|||||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||||
import com.ponzischeme89.memby.data.PreferencesSync
|
import com.ponzischeme89.memby.data.PreferencesSync
|
||||||
import com.ponzischeme89.memby.data.SettingsStore
|
import com.ponzischeme89.memby.data.SettingsStore
|
||||||
|
import com.ponzischeme89.memby.data.ThemeSync
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
|
* Tiny manual dependency container. Initialised once from [MembyApp] so that the
|
||||||
@@ -31,6 +32,14 @@ object ServiceLocator {
|
|||||||
lateinit var preferencesSync: PreferencesSync
|
lateinit var preferencesSync: PreferencesSync
|
||||||
private set
|
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) {
|
fun init(context: Context) {
|
||||||
if (::repository.isInitialized) return
|
if (::repository.isInitialized) return
|
||||||
settings = SettingsStore(context.applicationContext)
|
settings = SettingsStore(context.applicationContext)
|
||||||
@@ -40,5 +49,10 @@ object ServiceLocator {
|
|||||||
// app already asks the gateway a question every ten seconds, and settings do not
|
// app already asks the gateway a question every ten seconds, and settings do not
|
||||||
// deserve a second connection.
|
// deserve a second connection.
|
||||||
preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision)
|
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.GatewayServiceStatus
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate
|
||||||
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
|
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.GatewayFeatures
|
||||||
import com.ponzischeme89.memby.data.model.HomeRow
|
import com.ponzischeme89.memby.data.model.HomeRow
|
||||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
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.
|
* turned the feature off, so the player never offers a row that cannot do anything.
|
||||||
*/
|
*/
|
||||||
val subtitleDownloadAvailable: Boolean = false,
|
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
|
* 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
|
* 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.
|
* or a deliberately-configured-off container from being asked once per playback.
|
||||||
*/
|
*/
|
||||||
val skipIntroAvailable: Boolean = false,
|
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,
|
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
|
* Everything needed to resolve a stream, plus everything the player needs to dress its
|
||||||
* loading screen while that resolution is still in flight.
|
* 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
|
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
|
* Read synchronously off the snapshot, like [rotationIntervalMillis], because the
|
||||||
* composables that decide between logo artwork and a text title do so while building a
|
* 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 seriesEpisodesMutex = Mutex()
|
||||||
private val seriesEpisodesCache =
|
private val seriesEpisodesCache =
|
||||||
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||||
|
private val seriesEpisodesInFlight = mutableMapOf<String, Deferred<List<BaseItem>?>>()
|
||||||
private val relatedMutex = Mutex()
|
private val relatedMutex = Mutex()
|
||||||
private val relatedCache =
|
private val relatedCache =
|
||||||
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
||||||
private val relatedInFlight = mutableMapOf<String, Deferred<RelatedContent?>>()
|
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)
|
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. */
|
/** Records a successful gateway search without affecting the direct Emby path. */
|
||||||
suspend fun recordSearch(term: String) {
|
suspend fun recordSearch(term: String) {
|
||||||
if (ServerConfig.isGateway && term.trim().length >= 2) {
|
if (ServerConfig.isGateway && term.trim().length >= 2) {
|
||||||
@@ -758,6 +855,18 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
|
|
||||||
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
|
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. */
|
/** This viewer's server-held settings. */
|
||||||
suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
|
suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
|
||||||
requireGateway().preferences()
|
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
|
* 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.
|
* 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> {
|
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
|
||||||
if (seriesId.isBlank()) return emptyList()
|
if (seriesId.isBlank()) return emptyList()
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
seriesEpisodesMutex.withLock {
|
val inFlight = seriesEpisodesMutex.withLock {
|
||||||
seriesEpisodesCache[seriesId]
|
seriesEpisodesCache[seriesId]
|
||||||
?.takeIf { it.expiresAtMs > now }
|
?.takeIf { it.expiresAtMs > now }
|
||||||
?.episodes
|
?.episodes
|
||||||
?.let { return it }
|
?.let { return it }
|
||||||
seriesEpisodesCache.remove(seriesId)
|
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) {
|
val loaded = if (ServerConfig.isGateway) {
|
||||||
requireGateway().seriesEpisodes(seriesId).items
|
requireGateway().seriesEpisodes(seriesId).items
|
||||||
} else {
|
} else {
|
||||||
@@ -1048,16 +1185,72 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
return result.played
|
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? {
|
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) {
|
if (ServerConfig.isGateway) {
|
||||||
// The gateway answers 404 when an item has no trailer, which is a normal
|
// The gateway answers 404 when an item has no trailer, which is a normal
|
||||||
// outcome here rather than an error worth surfacing.
|
// 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 }
|
.getOrElse { if (it is HttpException && it.code() == 404) null else throw it }
|
||||||
|
return CachedTrailer(trailer)
|
||||||
}
|
}
|
||||||
val userId = snapshot.userId ?: error("Not connected")
|
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,
|
playMethod = discovery.playMethod,
|
||||||
trickplayAvailable = true,
|
trickplayAvailable = true,
|
||||||
skipIntroAvailable = 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
|
// 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.
|
// up to one progress interval behind and would visibly jump the viewer back.
|
||||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||||
subtitles = playback.subtitles,
|
subtitles = resolveSubtitleUrls(playback.subtitles),
|
||||||
subtitlesEnabled = playback.subtitlesEnabled,
|
subtitlesEnabled = playback.subtitlesEnabled,
|
||||||
selectedSubtitleId = playback.selectedSubtitleId,
|
selectedSubtitleId = playback.selectedSubtitleId,
|
||||||
mediaSourceId = playback.mediaSourceId,
|
mediaSourceId = playback.mediaSourceId,
|
||||||
playSessionId = playback.playSessionId,
|
playSessionId = playback.playSessionId,
|
||||||
playMethod = playback.playMethod,
|
playMethod = playback.playMethod,
|
||||||
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
||||||
|
subtitleFixAvailable = playback.subtitleFixAvailable,
|
||||||
trickplayAvailable = playback.trickplayAvailable,
|
trickplayAvailable = playback.trickplayAvailable,
|
||||||
skipIntroAvailable = playback.skipIntroAvailable,
|
skipIntroAvailable = playback.skipIntroAvailable,
|
||||||
|
endCreditsAvailable = playback.endCreditsAvailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,7 +1469,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
title = playback.title.ifBlank { title },
|
title = playback.title.ifBlank { title },
|
||||||
url = playback.url,
|
url = playback.url,
|
||||||
resumePositionMs = positionMs.coerceAtLeast(0L),
|
resumePositionMs = positionMs.coerceAtLeast(0L),
|
||||||
subtitles = playback.subtitles,
|
subtitles = resolveSubtitleUrls(playback.subtitles),
|
||||||
subtitlesEnabled = playback.subtitlesEnabled,
|
subtitlesEnabled = playback.subtitlesEnabled,
|
||||||
selectedSubtitleId = playback.selectedSubtitleId,
|
selectedSubtitleId = playback.selectedSubtitleId,
|
||||||
mediaSourceId = playback.mediaSourceId,
|
mediaSourceId = playback.mediaSourceId,
|
||||||
@@ -1283,8 +1479,10 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
episodeCode = playback.episodeCode.ifBlank { null },
|
episodeCode = playback.episodeCode.ifBlank { null },
|
||||||
runtimeMs = playback.runtimeMs,
|
runtimeMs = playback.runtimeMs,
|
||||||
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
||||||
|
subtitleFixAvailable = playback.subtitleFixAvailable,
|
||||||
trickplayAvailable = playback.trickplayAvailable,
|
trickplayAvailable = playback.trickplayAvailable,
|
||||||
skipIntroAvailable = playback.skipIntroAvailable,
|
skipIntroAvailable = playback.skipIntroAvailable,
|
||||||
|
endCreditsAvailable = playback.endCreditsAvailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val discovery = directPlayback(
|
val discovery = directPlayback(
|
||||||
@@ -1308,6 +1506,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
playMethod = discovery.playMethod,
|
playMethod = discovery.playMethod,
|
||||||
trickplayAvailable = true,
|
trickplayAvailable = true,
|
||||||
skipIntroAvailable = true,
|
skipIntroAvailable = true,
|
||||||
|
endCreditsAvailable = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1353,7 +1552,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
url = playback.url,
|
url = playback.url,
|
||||||
resumePositionMs = playback.resumePositionMs,
|
resumePositionMs = playback.resumePositionMs,
|
||||||
logoUrl = item.logoUrl,
|
logoUrl = item.logoUrl,
|
||||||
subtitles = playback.subtitles,
|
subtitles = resolveSubtitleUrls(playback.subtitles),
|
||||||
subtitlesEnabled = playback.subtitlesEnabled,
|
subtitlesEnabled = playback.subtitlesEnabled,
|
||||||
selectedSubtitleId = playback.selectedSubtitleId,
|
selectedSubtitleId = playback.selectedSubtitleId,
|
||||||
mediaSourceId = playback.mediaSourceId,
|
mediaSourceId = playback.mediaSourceId,
|
||||||
@@ -1365,8 +1564,10 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
prerollEnabled = playback.prerollEnabled,
|
prerollEnabled = playback.prerollEnabled,
|
||||||
prerollDurationMs = playback.prerollDurationMs,
|
prerollDurationMs = playback.prerollDurationMs,
|
||||||
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
|
||||||
|
subtitleFixAvailable = playback.subtitleFixAvailable,
|
||||||
trickplayAvailable = playback.trickplayAvailable,
|
trickplayAvailable = playback.trickplayAvailable,
|
||||||
skipIntroAvailable = playback.skipIntroAvailable,
|
skipIntroAvailable = playback.skipIntroAvailable,
|
||||||
|
endCreditsAvailable = playback.endCreditsAvailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (item.isSeries) {
|
if (item.isSeries) {
|
||||||
@@ -1392,6 +1593,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
playMethod = discovery.playMethod,
|
playMethod = discovery.playMethod,
|
||||||
trickplayAvailable = true,
|
trickplayAvailable = true,
|
||||||
skipIntroAvailable = true,
|
skipIntroAvailable = true,
|
||||||
|
endCreditsAvailable = true,
|
||||||
overview = episode.overview,
|
overview = episode.overview,
|
||||||
episodeCode = episodeCode(episode),
|
episodeCode = episodeCode(episode),
|
||||||
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||||
@@ -1412,6 +1614,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
playMethod = discovery.playMethod,
|
playMethod = discovery.playMethod,
|
||||||
trickplayAvailable = true,
|
trickplayAvailable = true,
|
||||||
skipIntroAvailable = true,
|
skipIntroAvailable = true,
|
||||||
|
endCreditsAvailable = true,
|
||||||
overview = item.overview,
|
overview = item.overview,
|
||||||
episodeCode = item.episodeCode,
|
episodeCode = item.episodeCode,
|
||||||
runtimeMs = item.runtimeMs,
|
runtimeMs = item.runtimeMs,
|
||||||
@@ -1431,7 +1634,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
|
|
||||||
private suspend fun clearSeriesEpisodeCache() {
|
private suspend fun clearSeriesEpisodeCache() {
|
||||||
seriesEpisodesMutex.withLock {
|
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()
|
seriesEpisodesCache.clear()
|
||||||
|
seriesEpisodesInFlight.values.forEach { it.cancel() }
|
||||||
|
seriesEpisodesInFlight.clear()
|
||||||
}
|
}
|
||||||
relatedMutex.withLock {
|
relatedMutex.withLock {
|
||||||
// Reasons are personal: another profile must never inherit this one's, and a
|
// 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.values.forEach { it.cancel() }
|
||||||
relatedInFlight.clear()
|
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) {
|
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
|
* 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.
|
* button. Nothing about this is on the path of a Play press.
|
||||||
*/
|
*/
|
||||||
suspend fun introSegment(itemId: String): IntroSegment? {
|
suspend fun introSegment(itemId: String): IntroSegment? = chapterMarkers(itemId).intro
|
||||||
if (itemId.isBlank()) return null
|
|
||||||
|
/**
|
||||||
|
* 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 }
|
introCache[itemId]?.let { return it.value }
|
||||||
val resolved = runCatching {
|
val resolved = runCatching {
|
||||||
if (ServerConfig.isGateway) gatewayIntro(itemId) else directIntro(itemId)
|
if (ServerConfig.isGateway) gatewayMarkers(itemId) else directMarkers(itemId)
|
||||||
}.getOrNull()
|
}.getOrNull() ?: ChapterMarkers()
|
||||||
// A null is cached too. Most of a library has no intro markers — every film, every
|
// 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
|
// 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.
|
// would be fetched again on each playback and each auto-advance.
|
||||||
introCache[itemId] = CachedIntro(resolved)
|
introCache[itemId] = CachedIntro(resolved)
|
||||||
@@ -1535,18 +1771,33 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun gatewayIntro(itemId: String): IntroSegment? {
|
private suspend fun gatewayMarkers(itemId: String): ChapterMarkers {
|
||||||
val intro = requireGateway().intro(itemId)
|
val markers = requireGateway().intro(itemId)
|
||||||
if (!intro.available || intro.endMs <= intro.startMs) return null
|
return ChapterMarkers(
|
||||||
return IntroSegment(startMs = intro.startMs, endMs = intro.endMs)
|
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? {
|
private suspend fun directMarkers(itemId: String): ChapterMarkers {
|
||||||
val userId = snapshot.userId ?: return null
|
val userId = snapshot.userId ?: return ChapterMarkers()
|
||||||
// Chapters and nothing else. This is the one query in the app that asks for them,
|
// 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
|
// and adding them to a shared Fields list would put a couple of dozen entries per
|
||||||
// item into every row response to render nothing.
|
// 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(
|
SubtitleDownload(
|
||||||
message = response.message,
|
message = response.message,
|
||||||
subtitles = response.subtitles,
|
subtitles = resolveSubtitleUrls(response.subtitles),
|
||||||
selectedSubtitleId = response.selectedSubtitleId,
|
selectedSubtitleId = response.selectedSubtitleId,
|
||||||
mediaSourceId = response.mediaSourceId,
|
mediaSourceId = response.mediaSourceId,
|
||||||
playSessionId = response.playSessionId,
|
playSessionId = response.playSessionId,
|
||||||
@@ -1694,10 +1945,34 @@ class EmbyRepository(private val settings: SettingsStore) {
|
|||||||
}.getOrNull()
|
}.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 {
|
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
|
||||||
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
|
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
|
||||||
return nextEpisodeOf(
|
return nextEpisodeOf(
|
||||||
response.item, response.url, response.resumePositionMs, response.subtitles,
|
response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles),
|
||||||
response.subtitlesEnabled, response.selectedSubtitleId,
|
response.subtitlesEnabled, response.selectedSubtitleId,
|
||||||
response.mediaSourceId, response.playSessionId, response.playMethod,
|
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 =
|
fun hasBackdrop(item: BaseItem): Boolean =
|
||||||
item.backdropImageTags.isNotEmpty() ||
|
item.backdropImageTags.isNotEmpty() ||
|
||||||
(item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty())
|
(item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty())
|
||||||
@@ -2095,14 +2401,31 @@ private data class CachedRelated(
|
|||||||
val expiresAtMs: Long,
|
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
|
* 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.
|
* title has no previews — and a map cannot tell that from an absent entry.
|
||||||
*/
|
*/
|
||||||
private data class CachedTrickplay(val value: Trickplay?)
|
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_SIZE = 16
|
||||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
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 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 SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||||
private const val RELATED_CACHE_SIZE = 12
|
private const val RELATED_CACHE_SIZE = 12
|
||||||
private const val RELATED_LIMIT = 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,
|
// 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.
|
// 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
|
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.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.ponzischeme89.memby.data.model.GatewayAlert
|
import com.ponzischeme89.memby.data.model.GatewayAlert
|
||||||
|
import com.ponzischeme89.memby.data.model.GatewayThemeStatus
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -88,8 +89,19 @@ class MaintenanceMonitor(
|
|||||||
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
|
||||||
|
|
||||||
private val _preferencesRevision = MutableStateFlow(0L)
|
private val _preferencesRevision = MutableStateFlow(0L)
|
||||||
|
private val _theme = MutableStateFlow(GatewayThemeStatus())
|
||||||
private val _installPermissionPrompt = MutableStateFlow(false)
|
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
|
* 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]
|
* 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.
|
// clearing it here would fight that loop for the same flow.
|
||||||
if (ServerConfig.isGateway) _embyOutage.value = null
|
if (ServerConfig.isGateway) _embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_theme.value = GatewayThemeStatus()
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
@@ -186,6 +199,7 @@ class MaintenanceMonitor(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
_preferencesRevision.value = status.preferencesRevision
|
_preferencesRevision.value = status.preferencesRevision
|
||||||
|
_theme.value = status.theme
|
||||||
_installPermissionPrompt.value =
|
_installPermissionPrompt.value =
|
||||||
status.features[INSTALL_PERMISSION_FEATURE] == true
|
status.features[INSTALL_PERMISSION_FEATURE] == true
|
||||||
// Emby's state is reported even during maintenance: an
|
// Emby's state is reported even during maintenance: an
|
||||||
@@ -207,6 +221,7 @@ class MaintenanceMonitor(
|
|||||||
_compatibility.value = null
|
_compatibility.value = null
|
||||||
_embyOutage.value = null
|
_embyOutage.value = null
|
||||||
_preferencesRevision.value = 0
|
_preferencesRevision.value = 0
|
||||||
|
_theme.value = GatewayThemeStatus()
|
||||||
_installPermissionPrompt.value = false
|
_installPermissionPrompt.value = false
|
||||||
dismissAlert()
|
dismissAlert()
|
||||||
return@collectLatest
|
return@collectLatest
|
||||||
|
|||||||
@@ -320,6 +320,9 @@ data class Settings(
|
|||||||
// What to do when an episode reaches its opening titles: offer a button, skip without
|
// 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.
|
// asking, or nothing. Per-profile and synced for the same reason the two above are.
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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.
|
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
|
||||||
val ringColorHex: String = DEFAULT_RING_COLOR,
|
val ringColorHex: String = DEFAULT_RING_COLOR,
|
||||||
val lastBackdropUrl: String? = null,
|
val lastBackdropUrl: String? = null,
|
||||||
@@ -344,6 +347,26 @@ data class Settings(
|
|||||||
val homeHiddenRows: String = "",
|
val homeHiddenRows: String = "",
|
||||||
/** Tone used for the short welcome line after sign-in and during startup. */
|
/** Tone used for the short welcome line after sign-in and during startup. */
|
||||||
val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
|
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
|
* 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,
|
* 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_CARD_DENSITY = "standard"
|
||||||
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
|
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
|
||||||
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
|
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
|
||||||
|
/** Matches `defaultThemeID` in the gateway's theme catalogue. */
|
||||||
|
const val DEFAULT_THEME_ID = "midnight"
|
||||||
val EMPTY = Settings()
|
val EMPTY = Settings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +446,16 @@ data class EmbyProfile(
|
|||||||
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
|
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
|
||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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) {
|
class SettingsStore(private val context: Context) {
|
||||||
@@ -459,6 +494,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
|
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
|
||||||
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
|
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
|
||||||
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
|
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
|
||||||
|
val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits")
|
||||||
val RING_COLOR = stringPreferencesKey("ring_color")
|
val RING_COLOR = stringPreferencesKey("ring_color")
|
||||||
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
||||||
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
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_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
|
||||||
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
|
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
|
||||||
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
|
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 PROFILES = stringPreferencesKey("profiles")
|
||||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_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.
|
* 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
|
* 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
|
* edit, and this touches nineteen keys plus the profiles blob — doing it through the
|
||||||
* individual setters would be seventeen rewrites for one sync, on a TV that has just
|
* individual setters would be eighteen rewrites for one sync, on a TV that has just
|
||||||
* started up.
|
* started up.
|
||||||
*
|
*
|
||||||
* The revision is stored in the same edit as the values it describes. If they could
|
* 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.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
|
||||||
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
|
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
|
||||||
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
|
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
|
||||||
|
store[Keys.THEME_ID] = preferences.themeId
|
||||||
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
|
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
|
||||||
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
|
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
|
||||||
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
|
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
|
||||||
@@ -654,6 +745,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
store[Keys.SEEK_INTERVAL_SECONDS] =
|
store[Keys.SEEK_INTERVAL_SECONDS] =
|
||||||
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
|
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
|
||||||
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
|
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
|
||||||
|
store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits
|
||||||
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
|
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
|
||||||
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
|
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
|
||||||
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
|
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
|
||||||
@@ -669,6 +761,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
hideWatchedMovies = preferences.hideWatchedMovies,
|
hideWatchedMovies = preferences.hideWatchedMovies,
|
||||||
showTitleLogo = preferences.showTitleLogo,
|
showTitleLogo = preferences.showTitleLogo,
|
||||||
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
|
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
|
||||||
|
themeId = preferences.themeId,
|
||||||
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
|
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
|
||||||
showTenMinuteReminder = preferences.showTenMinuteReminder,
|
showTenMinuteReminder = preferences.showTenMinuteReminder,
|
||||||
subtitlesEnabled = preferences.subtitlesEnabled,
|
subtitlesEnabled = preferences.subtitlesEnabled,
|
||||||
@@ -676,6 +769,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
seekIntervalSeconds =
|
seekIntervalSeconds =
|
||||||
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
|
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
|
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
|
||||||
|
speedUpCredits = preferences.speedUpCredits,
|
||||||
forYouMinutes = preferences.forYouMinutes,
|
forYouMinutes = preferences.forYouMinutes,
|
||||||
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
|
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
|
||||||
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
|
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
|
||||||
@@ -1033,6 +1127,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
seekIntervalSeconds = previous?.seekIntervalSeconds
|
seekIntervalSeconds = previous?.seekIntervalSeconds
|
||||||
?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
|
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
|
||||||
|
speedUpCredits = previous?.speedUpCredits ?: true,
|
||||||
)
|
)
|
||||||
profiles.removeAll { it.id == id }
|
profiles.removeAll { it.id == id }
|
||||||
profiles.add(profile)
|
profiles.add(profile)
|
||||||
@@ -1156,6 +1251,13 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
|
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
|
||||||
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
|
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
|
||||||
preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
|
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_SECTIONS] = profile.homeSections
|
||||||
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
|
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
|
||||||
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
||||||
@@ -1173,6 +1275,7 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences[Keys.SEEK_INTERVAL_SECONDS] =
|
preferences[Keys.SEEK_INTERVAL_SECONDS] =
|
||||||
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
|
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
|
||||||
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
|
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
|
||||||
|
preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits
|
||||||
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
|
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
|
||||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
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,
|
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
),
|
),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
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,
|
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
),
|
),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
||||||
|
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
|
||||||
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
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(),
|
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
|
||||||
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
||||||
?: Settings.DEFAULT_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(),
|
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||||
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
|
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
|
||||||
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
|
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 hideWatchedMovies: Boolean = false,
|
||||||
val showTitleLogo: Boolean = true,
|
val showTitleLogo: Boolean = true,
|
||||||
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
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 autoPlayNextEpisode: Boolean = true,
|
||||||
val showTenMinuteReminder: Boolean = true,
|
val showTenMinuteReminder: Boolean = true,
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +57,8 @@ data class UserPreferences(
|
|||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
|
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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 forYouMinutes: Int = 0,
|
||||||
val homeRowOrder: List<String> = emptyList(),
|
val homeRowOrder: List<String> = emptyList(),
|
||||||
val homePinnedRows: List<String> = emptyList(),
|
val homePinnedRows: List<String> = emptyList(),
|
||||||
@@ -70,12 +84,14 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
|
|||||||
hideWatchedMovies = hideWatchedMovies,
|
hideWatchedMovies = hideWatchedMovies,
|
||||||
showTitleLogo = showTitleLogo,
|
showTitleLogo = showTitleLogo,
|
||||||
welcomeQuoteStyle = welcomeQuoteStyle,
|
welcomeQuoteStyle = welcomeQuoteStyle,
|
||||||
|
themeId = themeId,
|
||||||
autoPlayNextEpisode = autoPlayNextEpisode,
|
autoPlayNextEpisode = autoPlayNextEpisode,
|
||||||
showTenMinuteReminder = showTenMinuteReminder,
|
showTenMinuteReminder = showTenMinuteReminder,
|
||||||
subtitlesEnabled = subtitlesEnabled,
|
subtitlesEnabled = subtitlesEnabled,
|
||||||
subtitleLanguage = subtitleLanguage,
|
subtitleLanguage = subtitleLanguage,
|
||||||
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
|
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
|
||||||
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
|
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
|
||||||
|
speedUpCredits = speedUpCredits,
|
||||||
forYouMinutes = forYouMinutes,
|
forYouMinutes = forYouMinutes,
|
||||||
homeRowOrder = homeRowOrder.decodeLineList(),
|
homeRowOrder = homeRowOrder.decodeLineList(),
|
||||||
homePinnedRows = homePinnedRows.decodeLineList(),
|
homePinnedRows = homePinnedRows.decodeLineList(),
|
||||||
@@ -110,6 +126,7 @@ fun decodeUserPreferences(
|
|||||||
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
|
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
|
||||||
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
|
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
|
||||||
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
|
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
|
||||||
|
themeId = json.string("themeId", fallback.themeId),
|
||||||
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
|
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
|
||||||
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
|
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
|
||||||
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
|
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
|
||||||
@@ -125,6 +142,7 @@ fun decodeUserPreferences(
|
|||||||
skipIntroMode = normalizeSkipIntroMode(
|
skipIntroMode = normalizeSkipIntroMode(
|
||||||
json.string("skipIntroMode", fallback.skipIntroMode),
|
json.string("skipIntroMode", fallback.skipIntroMode),
|
||||||
),
|
),
|
||||||
|
speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits),
|
||||||
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
|
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
|
||||||
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
|
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
|
||||||
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
|
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
|
||||||
@@ -141,12 +159,14 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
|
|||||||
put("hideWatchedMovies", hideWatchedMovies)
|
put("hideWatchedMovies", hideWatchedMovies)
|
||||||
put("showTitleLogo", showTitleLogo)
|
put("showTitleLogo", showTitleLogo)
|
||||||
put("welcomeQuoteStyle", welcomeQuoteStyle)
|
put("welcomeQuoteStyle", welcomeQuoteStyle)
|
||||||
|
put("themeId", themeId)
|
||||||
put("autoPlayNextEpisode", autoPlayNextEpisode)
|
put("autoPlayNextEpisode", autoPlayNextEpisode)
|
||||||
put("showTenMinuteReminder", showTenMinuteReminder)
|
put("showTenMinuteReminder", showTenMinuteReminder)
|
||||||
put("subtitlesEnabled", subtitlesEnabled)
|
put("subtitlesEnabled", subtitlesEnabled)
|
||||||
put("subtitleLanguage", subtitleLanguage)
|
put("subtitleLanguage", subtitleLanguage)
|
||||||
put("seekIntervalSeconds", seekIntervalSeconds)
|
put("seekIntervalSeconds", seekIntervalSeconds)
|
||||||
put("skipIntroMode", skipIntroMode)
|
put("skipIntroMode", skipIntroMode)
|
||||||
|
put("speedUpCredits", speedUpCredits)
|
||||||
put("forYouMinutes", forYouMinutes)
|
put("forYouMinutes", forYouMinutes)
|
||||||
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
|
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
|
||||||
putJsonArray("homePinnedRows") { homePinnedRows.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.
|
* on the poll the app is already making.
|
||||||
*/
|
*/
|
||||||
val preferencesRevision: Long = 0,
|
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()),
|
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. */
|
/** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewayPreferencesRequest(
|
data class GatewayPreferencesRequest(
|
||||||
@@ -287,6 +387,25 @@ data class GatewayItems(
|
|||||||
val items: List<BaseItem> = emptyList(),
|
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.
|
* 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
|
// 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.
|
// must stay false: a missing field must never conjure a row that cannot do anything.
|
||||||
val subtitleDownloadAvailable: Boolean = false,
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// missing field must never conjure one this backend would not answer.
|
||||||
val skipIntroAvailable: Boolean = false,
|
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
|
* [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
|
* begin at the very start of the file, and that must stay distinguishable from an episode
|
||||||
* that has no markers at all.
|
* 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
|
@Serializable
|
||||||
data class GatewayIntro(
|
data class GatewayIntro(
|
||||||
val available: Boolean = false,
|
val available: Boolean = false,
|
||||||
val startMs: Long = 0L,
|
val startMs: Long = 0L,
|
||||||
val endMs: 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. */
|
/** One subtitle a viewer can choose to download, as the gateway offers it. */
|
||||||
@Serializable
|
@Serializable
|
||||||
data class GatewaySubtitleCandidate(
|
data class GatewaySubtitleCandidate(
|
||||||
// Bazarr's opaque provider handle. It round-trips untouched — nothing on this side
|
// Which backend produced this row. It rides back with the token on the download call,
|
||||||
// parses it, and reconstructing it from the other fields would break the download.
|
// 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 token: String = "",
|
||||||
val language: String = "",
|
val language: String = "",
|
||||||
val languageLabel: String = "",
|
val languageLabel: String = "",
|
||||||
@@ -430,6 +571,10 @@ data class GatewaySubtitleCandidate(
|
|||||||
val forced: Boolean = false,
|
val forced: Boolean = false,
|
||||||
val hearingImpaired: Boolean = false,
|
val hearingImpaired: Boolean = false,
|
||||||
val originalFormat: 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
|
// 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.
|
// still renders it correctly, the same reason alert labels are the gateway's.
|
||||||
val label: String = "",
|
val label: String = "",
|
||||||
@@ -447,6 +592,19 @@ data class GatewaySubtitleSearch(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate)
|
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
|
* 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
|
* 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("userId") userId: String,
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): UserItemData
|
): 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")
|
@GET("v1/search")
|
||||||
suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems
|
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")
|
@POST("v1/search/history")
|
||||||
suspend fun recordSearch(@Body body: Map<String, String>)
|
suspend fun recordSearch(@Body body: Map<String, String>)
|
||||||
|
|
||||||
@@ -141,6 +152,13 @@ interface GatewayApi {
|
|||||||
@GET("v1/features")
|
@GET("v1/features")
|
||||||
suspend fun features(): GatewayFeatures
|
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. */
|
/** This viewer's settings as the server holds them, for whichever TV they sit at. */
|
||||||
@GET("v1/preferences")
|
@GET("v1/preferences")
|
||||||
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
|
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
|
||||||
@@ -229,6 +247,13 @@ interface GatewayApi {
|
|||||||
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
|
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
|
||||||
): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload
|
): 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. */
|
/** 404 when nothing follows this item: a movie, or a series finale. */
|
||||||
@GET("v1/items/{id}/next")
|
@GET("v1/items/{id}/next")
|
||||||
suspend fun nextEpisode(
|
suspend fun nextEpisode(
|
||||||
@@ -245,6 +270,9 @@ interface GatewayApi {
|
|||||||
@POST("v1/items/{id}/played")
|
@POST("v1/items/{id}/played")
|
||||||
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
|
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}")
|
@POST("v1/playback/{phase}")
|
||||||
suspend fun report(
|
suspend fun report(
|
||||||
@Path("phase") phase: String,
|
@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.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
|
// 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.
|
// here than on the launcher, which is visible the moment a page opens from a row.
|
||||||
internal val DetailBackground = MembySurface
|
//
|
||||||
internal val DetailAccent = MembyAccent
|
// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette
|
||||||
internal val DetailText = MembyOnSurface
|
// comes from the server, and an alias that captured a value would pin this whole page to
|
||||||
internal val DetailMutedText = MembyMutedText
|
// whichever theme was loaded when the class first initialised.
|
||||||
internal val DetailQuietText = MembyQuietText
|
internal val DetailBackground: Color get() = MembySurface
|
||||||
internal val DetailHairline = MembyHairline
|
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
|
internal val DetailSideGutter = 58.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,10 +207,10 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
|
|||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xF2080A0C),
|
0f to DetailBackground.copy(alpha = 0.95f),
|
||||||
0.42f to Color(0xCC080A0C),
|
0.42f to DetailBackground.copy(alpha = 0.80f),
|
||||||
0.72f to Color(0x38080A0C),
|
0.72f to DetailBackground.copy(alpha = 0.22f),
|
||||||
1f to Color(0x10080A0C),
|
1f to DetailBackground.copy(alpha = 0.06f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -213,8 +218,8 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
|
|||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0f to Color(0x18000000),
|
0f to Color(0x18000000),
|
||||||
0.48f to Color(0x26080A0C),
|
0.48f to DetailBackground.copy(alpha = 0.15f),
|
||||||
0.78f to Color(0xE6080A0C),
|
0.78f to DetailBackground.copy(alpha = 0.90f),
|
||||||
1f to DetailBackground,
|
1f to DetailBackground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -456,7 +461,7 @@ internal fun DetailPageScaffold(
|
|||||||
.padding(bottom = 24.dp)
|
.padding(bottom = 24.dp)
|
||||||
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
|
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
|
||||||
.clip(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))
|
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
|
||||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
.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 }
|
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||||
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
|
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
|
||||||
.clip(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)
|
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
|
||||||
.semantics { contentDescription = action.description }
|
.semantics { contentDescription = action.description }
|
||||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
||||||
@@ -954,7 +959,7 @@ private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modi
|
|||||||
Column(
|
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),
|
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)
|
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))
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.Settings
|
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.MediaRating
|
import com.ponzischeme89.memby.data.model.MediaRating
|
||||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||||
@@ -92,7 +91,7 @@ fun EpisodeDetailsOverlay(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val repository = ServiceLocator.repository
|
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 episodes by remember(item.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
|
||||||
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
|
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
|
||||||
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
|
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.LiveTv
|
||||||
import androidx.compose.material.icons.filled.Movie
|
import androidx.compose.material.icons.filled.Movie
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.Person
|
||||||
import androidx.compose.material.icons.filled.PushPin
|
import androidx.compose.material.icons.filled.PushPin
|
||||||
import androidx.compose.material.icons.filled.PlayCircleFilled
|
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.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
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 com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Job
|
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
|
// 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,
|
// (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.
|
// so a green or a grey that differs by a shade differs in front of the viewer.
|
||||||
private val EmbyGreen = MembyAccent
|
// Getters rather than values: the tokens are snapshot state now that the palette is the
|
||||||
private val RailSurface = Color(0xF20C0F12)
|
// server's answer, and capturing one here would pin the launcher to the theme that happened
|
||||||
private val MutedText = MembyMutedText
|
// to be loaded when this class initialised.
|
||||||
private val QuietText = MembyQuietText
|
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 TvRailCollapsedWidth = 54.dp
|
||||||
internal val TvRailExpandedWidth = 184.dp
|
internal val TvRailExpandedWidth = 184.dp
|
||||||
internal val TvRailContentShift = 112.dp
|
internal val TvRailContentShift = 112.dp
|
||||||
@@ -405,7 +415,7 @@ fun UserSwitcherOverlay(
|
|||||||
.heightIn(max = 400.dp)
|
.heightIn(max = 400.dp)
|
||||||
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||||
.background(Color(0xFF090B0D))
|
.background(MembySurface)
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
|
||||||
@@ -749,7 +759,7 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(modifier.background(Color(0xFF090B0D))) {
|
Box(modifier.background(MembySurface)) {
|
||||||
if (request != null) {
|
if (request != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = request,
|
model = request,
|
||||||
@@ -761,18 +771,18 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
|||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xFF090B0D),
|
0f to MembySurface,
|
||||||
0.58f to Color(0xE3090B0D),
|
0.58f to MembySurface.copy(alpha = 0.89f),
|
||||||
1f to Color(0xA6090B0D),
|
1f to MembySurface.copy(alpha = 0.65f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0f to Color(0x73090B0D),
|
0f to MembySurface.copy(alpha = 0.45f),
|
||||||
0.66f to Color(0xD6090B0D),
|
0.66f to MembySurface.copy(alpha = 0.84f),
|
||||||
1f to Color(0xFF090B0D),
|
1f to MembySurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -852,6 +862,7 @@ fun MediaQuickActionsOverlay(
|
|||||||
onOpenDetails: (BaseItem) -> Unit,
|
onOpenDetails: (BaseItem) -> Unit,
|
||||||
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
||||||
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
||||||
|
onRemoveFromContinueWatching: (() -> Unit)? = null,
|
||||||
rowTitle: String? = null,
|
rowTitle: String? = null,
|
||||||
rowPinned: Boolean = false,
|
rowPinned: Boolean = false,
|
||||||
onToggleRowPinned: (() -> Unit)? = null,
|
onToggleRowPinned: (() -> Unit)? = null,
|
||||||
@@ -863,7 +874,9 @@ fun MediaQuickActionsOverlay(
|
|||||||
onToggleRowPinned != null &&
|
onToggleRowPinned != null &&
|
||||||
onHideRow != null &&
|
onHideRow != null &&
|
||||||
onMoveRow != 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() } }
|
val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } }
|
||||||
var focusedIndex by remember(item.id) { mutableStateOf(0) }
|
var focusedIndex by remember(item.id) { mutableStateOf(0) }
|
||||||
// The menu can appear while OK is still physically held. Until that opening press
|
// The menu can appear while OK is still physically held. Until that opening press
|
||||||
@@ -886,7 +899,7 @@ fun MediaQuickActionsOverlay(
|
|||||||
.width(352.dp)
|
.width(352.dp)
|
||||||
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
.shadow(12.dp, RoundedCornerShape(MembyPanelCorner))
|
||||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||||
.background(Color(0xFF090B0D))
|
.background(MembySurface)
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
.border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner))
|
||||||
.onPreviewKeyEvent { event ->
|
.onPreviewKeyEvent { event ->
|
||||||
val native = event.nativeKeyEvent
|
val native = event.nativeKeyEvent
|
||||||
@@ -975,6 +988,17 @@ fun MediaQuickActionsOverlay(
|
|||||||
onClose()
|
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) {
|
if (hasRowActions) {
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -995,32 +1019,32 @@ fun MediaQuickActionsOverlay(
|
|||||||
label = if (rowPinned) "Unpin row" else "Pin row to top",
|
label = if (rowPinned) "Unpin row" else "Pin row to top",
|
||||||
icon = Icons.Default.PushPin,
|
icon = Icons.Default.PushPin,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequesters[3])
|
.focusRequester(focusRequesters[rowActionStartIndex])
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = 3 },
|
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex },
|
||||||
onClick = onToggleRowPinned!!,
|
onClick = onToggleRowPinned!!,
|
||||||
)
|
)
|
||||||
QuickActionMenuItem(
|
QuickActionMenuItem(
|
||||||
label = "Move row up",
|
label = "Move row up",
|
||||||
icon = Icons.Default.ArrowUpward,
|
icon = Icons.Default.ArrowUpward,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequesters[4])
|
.focusRequester(focusRequesters[rowActionStartIndex + 1])
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = 4 },
|
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 },
|
||||||
onClick = { onMoveRow!!(-1) },
|
onClick = { onMoveRow!!(-1) },
|
||||||
)
|
)
|
||||||
QuickActionMenuItem(
|
QuickActionMenuItem(
|
||||||
label = "Move row down",
|
label = "Move row down",
|
||||||
icon = Icons.Default.ArrowDownward,
|
icon = Icons.Default.ArrowDownward,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequesters[5])
|
.focusRequester(focusRequesters[rowActionStartIndex + 2])
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = 5 },
|
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 },
|
||||||
onClick = { onMoveRow!!(1) },
|
onClick = { onMoveRow!!(1) },
|
||||||
)
|
)
|
||||||
QuickActionMenuItem(
|
QuickActionMenuItem(
|
||||||
label = "Hide this row",
|
label = "Hide this row",
|
||||||
icon = Icons.Default.VisibilityOff,
|
icon = Icons.Default.VisibilityOff,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.focusRequester(focusRequesters[6])
|
.focusRequester(focusRequesters[rowActionStartIndex + 3])
|
||||||
.onFocusChanged { if (it.isFocused) focusedIndex = 6 },
|
.onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 },
|
||||||
onClick = onHideRow!!,
|
onClick = onHideRow!!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1339,6 +1363,7 @@ internal fun MediaRow(
|
|||||||
availableWidth: Dp,
|
availableWidth: Dp,
|
||||||
navigationFocusRequester: FocusRequester,
|
navigationFocusRequester: FocusRequester,
|
||||||
contentEntryFocusRequester: FocusRequester?,
|
contentEntryFocusRequester: FocusRequester?,
|
||||||
|
heroEntryFocusRequester: FocusRequester? = null,
|
||||||
returnFocusItemId: String?,
|
returnFocusItemId: String?,
|
||||||
returnFocusRequester: FocusRequester,
|
returnFocusRequester: FocusRequester,
|
||||||
verticalFocusRequest: RowFocusRequest?,
|
verticalFocusRequest: RowFocusRequest?,
|
||||||
@@ -1473,6 +1498,12 @@ internal fun MediaRow(
|
|||||||
if (contentEntryFocusRequester != null) {
|
if (contentEntryFocusRequester != null) {
|
||||||
cardModifier = cardModifier.focusRequester(contentEntryFocusRequester)
|
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) {
|
if (item.id == returnFocusItemId) {
|
||||||
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
cardModifier = cardModifier.focusRequester(returnFocusRequester)
|
||||||
@@ -1943,7 +1974,7 @@ private fun MediaCard(
|
|||||||
shape = RoundedCornerShape(MembyCardCorner),
|
shape = RoundedCornerShape(MembyCardCorner),
|
||||||
)
|
)
|
||||||
.clip(RoundedCornerShape(MembyCardCorner))
|
.clip(RoundedCornerShape(MembyCardCorner))
|
||||||
.background(Color(0xFF20252A))
|
.background(MembySurfaceRaised)
|
||||||
.border(
|
.border(
|
||||||
2.dp,
|
2.dp,
|
||||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||||
@@ -2058,12 +2089,10 @@ private fun MediaCard(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(3.dp))
|
Spacer(Modifier.height(3.dp))
|
||||||
}
|
}
|
||||||
ItemRatingsStrip(
|
// No ratings strip under a poster. The scores are on the detail page the card
|
||||||
item = item,
|
// opens and in the metadata panel beside the focused card; a third copy under
|
||||||
load = focused,
|
// every poster in every row cost a line of height on cards that are already
|
||||||
compact = true,
|
// two lines of text tall.
|
||||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2079,7 +2108,7 @@ private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifie
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D),
|
color = if (status == "available") Color(0xFF071008) else MembySurface,
|
||||||
fontSize = 9.sp,
|
fontSize = 9.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
letterSpacing = 0.5.sp,
|
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.
|
// made is green, over is red, not out yet is blue, in cinemas is amber.
|
||||||
val (background, foreground) = when (status) {
|
val (background, foreground) = when (status) {
|
||||||
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
|
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
|
||||||
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
|
"upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface
|
||||||
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
|
"incinemas" -> Color(0xFFFFB454) to MembySurface
|
||||||
"ended" -> Color(0xFFE04747) to Color.White
|
"ended" -> Color(0xFFE04747) to Color.White
|
||||||
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
|
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.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
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 kotlinx.coroutines.delay
|
||||||
import java.util.TimeZone
|
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
|
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 =
|
* The movie feature owns the header until a shelf below it is being browsed.
|
||||||
hasMovies && listAtTop
|
*
|
||||||
|
* 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
|
* 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,
|
contentEntryFocusRequester: FocusRequester? = null,
|
||||||
returnFocusItemId: String? = null,
|
returnFocusItemId: String? = null,
|
||||||
returnFocusRequester: FocusRequester? = null,
|
returnFocusRequester: FocusRequester? = null,
|
||||||
|
downFocusRequester: FocusRequester? = null,
|
||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -250,10 +265,17 @@ internal fun HomeMovieHero(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
val featured = movies.first()
|
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
|
var featuredModifier: Modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.focusProperties { left = navigationFocusRequester }
|
.focusProperties {
|
||||||
|
left = navigationFocusRequester
|
||||||
|
if (downFocusRequester != null) down = downFocusRequester
|
||||||
|
}
|
||||||
if (contentEntryFocusRequester != null) {
|
if (contentEntryFocusRequester != null) {
|
||||||
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
|
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
|
||||||
}
|
}
|
||||||
@@ -272,8 +294,14 @@ internal fun HomeMovieHero(
|
|||||||
modifier = Modifier.width(miniWidth).fillMaxHeight(),
|
modifier = Modifier.width(miniWidth).fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
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()
|
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) {
|
if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
|
||||||
miniModifier = miniModifier.focusRequester(returnFocusRequester)
|
miniModifier = miniModifier.focusRequester(returnFocusRequester)
|
||||||
}
|
}
|
||||||
@@ -324,14 +352,14 @@ private fun FeaturedMovieCard(
|
|||||||
contentDescription = "Featured, ${pick.label}, ${item.name}",
|
contentDescription = "Featured, ${pick.label}, ${item.name}",
|
||||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
|
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
|
||||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xF20A0D10),
|
0f to MembySurface.copy(alpha = 0.95f),
|
||||||
0.48f to Color(0xA80A0D10),
|
0.48f to MembySurface.copy(alpha = 0.66f),
|
||||||
1f to Color(0x160A0D10),
|
1f to MembySurface.copy(alpha = 0.09f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -475,15 +503,15 @@ private fun MiniMovieCard(
|
|||||||
contentDescription = "${pick.label} movie, ${item.name}",
|
contentDescription = "${pick.label} movie, ${item.name}",
|
||||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||||
) { focused ->
|
) { focused ->
|
||||||
Box(Modifier.fillMaxSize().background(Color(0xFF192027))) {
|
Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) {
|
||||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||||
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to Color(0xE80A0D10),
|
0f to MembySurface.copy(alpha = 0.91f),
|
||||||
0.78f to Color(0x850A0D10),
|
0.78f to MembySurface.copy(alpha = 0.52f),
|
||||||
1f to Color(0x300A0D10),
|
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) {
|
fun setFavorite(item: BaseItem, favorite: Boolean) {
|
||||||
updateFavorite(item, favorite)
|
updateFavorite(item, favorite)
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
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) {
|
private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) {
|
||||||
fun BaseItem.updated(): BaseItem =
|
fun BaseItem.updated(): BaseItem =
|
||||||
if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this
|
if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this
|
||||||
@@ -477,6 +541,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
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 const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||||
|
|
||||||
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
@@ -114,7 +112,10 @@ import coil.imageLoader
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
import androidx.compose.material.icons.Icons
|
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.Search
|
||||||
|
import androidx.compose.material.icons.filled.WbSunny
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
import com.ponzischeme89.memby.BuildConfig
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.Settings
|
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.ReleaseNote
|
||||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||||
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
|
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.WhatsNewOverlay
|
||||||
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
|
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.MembyChipCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
import com.ponzischeme89.memby.ui.theme.MembyTheme
|
||||||
import com.ponzischeme89.memby.ui.setup.SignInContent
|
import com.ponzischeme89.memby.ui.setup.SignInContent
|
||||||
import com.ponzischeme89.memby.update.AppInstall
|
|
||||||
import com.ponzischeme89.memby.update.InstallPermission
|
import com.ponzischeme89.memby.update.InstallPermission
|
||||||
import com.ponzischeme89.memby.update.UpdateChecker
|
|
||||||
import com.ponzischeme89.memby.update.ServerUpdateService
|
import com.ponzischeme89.memby.update.ServerUpdateService
|
||||||
import com.ponzischeme89.memby.update.UpdateStatus
|
|
||||||
import androidx.tv.material3.Button
|
import androidx.tv.material3.Button
|
||||||
import androidx.tv.material3.Card
|
import androidx.tv.material3.Card
|
||||||
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.ensureActive
|
import kotlinx.coroutines.ensureActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import java.util.Date
|
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. */
|
/** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */
|
||||||
internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
|
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
|
val loaded = settings
|
||||||
when {
|
when {
|
||||||
!initialUpdateCheckComplete -> MembyLoadingScreen(
|
!initialUpdateCheckComplete -> MembyLoadingScreen(
|
||||||
@@ -399,6 +419,16 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
|||||||
key(loaded.userId, loaded.serverUrl) {
|
key(loaded.userId, loaded.serverUrl) {
|
||||||
HomeScreen(settings = loaded)
|
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
|
// 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
|
// behind this. Composed after HomeScreen so its Back handler and its focus
|
||||||
// request are the ones that win.
|
// request are the ones that win.
|
||||||
@@ -513,7 +543,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) {
|
|||||||
.height(2.dp)
|
.height(2.dp)
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
drawRoundRect(
|
drawRoundRect(
|
||||||
color = Color(0xFF52B54B),
|
color = MembyAccent,
|
||||||
size = Size(size.width * glow, size.height),
|
size = Size(size.width * glow, size.height),
|
||||||
cornerRadius = CornerRadius(size.height / 2f),
|
cornerRadius = CornerRadius(size.height / 2f),
|
||||||
)
|
)
|
||||||
@@ -572,7 +602,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
|
|||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color(0xFF0A0E11),
|
Color(0xFF0A0E11),
|
||||||
Color(0xFF101A17),
|
Color(0xFF101A17),
|
||||||
Color(0xFF090B0D),
|
MembySurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -585,7 +615,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
|
|||||||
.graphicsLayer { alpha = haloAlpha }
|
.graphicsLayer { alpha = haloAlpha }
|
||||||
.background(
|
.background(
|
||||||
Brush.radialGradient(
|
Brush.radialGradient(
|
||||||
colors = listOf(Color(0xFF52B54B), Color.Transparent),
|
colors = listOf(MembyAccent, Color.Transparent),
|
||||||
),
|
),
|
||||||
CircleShape,
|
CircleShape,
|
||||||
),
|
),
|
||||||
@@ -647,7 +677,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) {
|
|||||||
scaleY = logoScale
|
scaleY = logoScale
|
||||||
alpha = haloAlpha
|
alpha = haloAlpha
|
||||||
}
|
}
|
||||||
.background(Color(0xFF52B54B), CircleShape),
|
.background(MembyAccent, CircleShape),
|
||||||
)
|
)
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(R.drawable.emby_logo),
|
painter = painterResource(R.drawable.emby_logo),
|
||||||
@@ -821,7 +851,7 @@ private fun ProfileChooser(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF090B0D)),
|
.background(MembySurface),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1107,7 +1137,7 @@ private fun OnboardingPeopleRow(
|
|||||||
} else {
|
} else {
|
||||||
Text(person.name.take(1).uppercase(), color = Color(0xFFD5E8D7), fontSize = 46.sp, fontWeight = FontWeight.Light)
|
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)
|
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))
|
.background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D))
|
||||||
.border(
|
.border(
|
||||||
width = if (current) 4.dp else if (focused) 3.dp else 1.dp,
|
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,
|
shape = CircleShape,
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -1351,6 +1381,7 @@ private fun HomeScreen(
|
|||||||
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
|
var detailsAiringNotice by remember { mutableStateOf<AiringNotice?>(null) }
|
||||||
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
|
var quickMenuItem by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
var quickMenuRowId by remember { mutableStateOf<String?>(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 myShows by remember(settings.userId) { mutableStateOf<List<MyShow>>(emptyList()) }
|
||||||
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
|
var selectedMyShow by remember { mutableStateOf<MyShow?>(null) }
|
||||||
var removingMyShow by remember { mutableStateOf(false) }
|
var removingMyShow by remember { mutableStateOf(false) }
|
||||||
@@ -1390,7 +1421,15 @@ private fun HomeScreen(
|
|||||||
val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination)
|
val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination)
|
||||||
val contentFocusRequester = remember { FocusRequester() }
|
val contentFocusRequester = remember { FocusRequester() }
|
||||||
val cardReturnFocusRequester = 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) }
|
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(
|
val playbackLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.StartActivityForResult(),
|
contract = ActivityResultContracts.StartActivityForResult(),
|
||||||
) {
|
) {
|
||||||
@@ -1479,6 +1518,9 @@ private fun HomeScreen(
|
|||||||
subtitlesEnabled = playable.subtitlesEnabled,
|
subtitlesEnabled = playable.subtitlesEnabled,
|
||||||
selectedSubtitleId = playable.selectedSubtitleId,
|
selectedSubtitleId = playable.selectedSubtitleId,
|
||||||
subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
|
subtitleDownloadAvailable = playable.subtitleDownloadAvailable,
|
||||||
|
trickplayAvailable = playable.trickplayAvailable,
|
||||||
|
skipIntroAvailable = playable.skipIntroAvailable,
|
||||||
|
endCreditsAvailable = playable.endCreditsAvailable,
|
||||||
mediaSourceId = playable.mediaSourceId,
|
mediaSourceId = playable.mediaSourceId,
|
||||||
playSessionId = playable.playSessionId,
|
playSessionId = playable.playSessionId,
|
||||||
playMethod = playable.playMethod,
|
playMethod = playable.playMethod,
|
||||||
@@ -1525,7 +1567,14 @@ private fun HomeScreen(
|
|||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val showHomeGreeting = selectedDestination == BrowseDestination.HOME &&
|
||||||
|
shouldShowHomeGreeting(
|
||||||
|
hasHero = homeHeroMovies.isNotEmpty(),
|
||||||
|
focusedRowId = focusedHomeRowId,
|
||||||
|
rowIds = rows.map(HomeBrowseRow::id),
|
||||||
|
)
|
||||||
LaunchedEffect(selectedDestination) {
|
LaunchedEffect(selectedDestination) {
|
||||||
|
focusedHomeRowId = null
|
||||||
if (selectedDestination == BrowseDestination.FAVORITES) {
|
if (selectedDestination == BrowseDestination.FAVORITES) {
|
||||||
recentSearches = repo.getRecentSearches()
|
recentSearches = repo.getRecentSearches()
|
||||||
}
|
}
|
||||||
@@ -1582,7 +1631,7 @@ private fun HomeScreen(
|
|||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "navigation-content-shift",
|
label = "navigation-content-shift",
|
||||||
)
|
)
|
||||||
Box(Modifier.fillMaxSize().background(Color(0xFF090B0D))) {
|
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||||
Row(Modifier.fillMaxSize()) {
|
Row(Modifier.fillMaxSize()) {
|
||||||
TvNavigationRail(
|
TvNavigationRail(
|
||||||
selected = selectedDestination,
|
selected = selectedDestination,
|
||||||
@@ -1614,15 +1663,26 @@ private fun HomeScreen(
|
|||||||
showSettings = false
|
showSettings = false
|
||||||
restoreRailAfterSettings = false
|
restoreRailAfterSettings = false
|
||||||
selectedDestination = destination
|
selectedDestination = destination
|
||||||
destinationFocus[destination]?.let { (rowId, itemId) ->
|
val savedFocus = destinationFocus[destination]
|
||||||
|
savedFocus?.let { (rowId, itemId) ->
|
||||||
returnRowId = rowId
|
returnRowId = rowId
|
||||||
returnItemId = itemId
|
returnItemId = itemId
|
||||||
}
|
}
|
||||||
|
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 {
|
scope.launch {
|
||||||
// Let the destination compose and attach its entry target
|
// Let the destination compose and attach its entry target
|
||||||
// before transferring focus out of the rail.
|
// before transferring focus out of the rail.
|
||||||
kotlinx.coroutines.delay(16L)
|
kotlinx.coroutines.delay(16L)
|
||||||
if (destinationFocus.containsKey(destination)) {
|
if (savedFocus != null) {
|
||||||
runCatching { cardReturnFocusRequester.requestFocus() }
|
runCatching { cardReturnFocusRequester.requestFocus() }
|
||||||
} else {
|
} else {
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
@@ -1630,6 +1690,7 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
alertCount = notificationState.notifications.size,
|
alertCount = notificationState.notifications.size,
|
||||||
)
|
)
|
||||||
@@ -1711,9 +1772,14 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty()
|
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(
|
val showHomeHero = shouldShowHomeMovieHero(
|
||||||
hasMovies = hasHomeHero,
|
hasMovies = hasHomeHero,
|
||||||
listAtTop = homeListAtTop,
|
listAtTop = homeListAtTop,
|
||||||
|
rowFocused = rowFocusedBelowHero,
|
||||||
)
|
)
|
||||||
val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero)
|
val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero)
|
||||||
val contentWidth = maxWidth
|
val contentWidth = maxWidth
|
||||||
@@ -1730,6 +1796,40 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
|
var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) }
|
||||||
var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) }
|
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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
if (showHomeHero) {
|
if (showHomeHero) {
|
||||||
HomeMovieHero(
|
HomeMovieHero(
|
||||||
@@ -1738,8 +1838,15 @@ private fun HomeScreen(
|
|||||||
contentEntryFocusRequester = contentFocusRequester,
|
contentEntryFocusRequester = contentFocusRequester,
|
||||||
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
|
returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID },
|
||||||
returnFocusRequester = cardReturnFocusRequester,
|
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 ->
|
onItemFocused = { item ->
|
||||||
navigationExpanded = false
|
navigationExpanded = false
|
||||||
|
rowFocusedBelowHero = false
|
||||||
|
focusedHomeRowId = null
|
||||||
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
|
destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id
|
||||||
returnRowId = HOME_HERO_ROW_ID
|
returnRowId = HOME_HERO_ROW_ID
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
@@ -1851,7 +1958,10 @@ private fun HomeScreen(
|
|||||||
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
contentEntryFocusRequester = contentFocusRequester.takeIf {
|
||||||
!hasHomeHero &&
|
!hasHomeHero &&
|
||||||
(selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) &&
|
(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 },
|
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
|
||||||
returnFocusRequester = cardReturnFocusRequester,
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
@@ -1869,7 +1979,25 @@ private fun HomeScreen(
|
|||||||
itemCounts = rowItemCounts,
|
itemCounts = rowItemCounts,
|
||||||
currentIndex = sourceRowIndex,
|
currentIndex = sourceRowIndex,
|
||||||
direction = direction,
|
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 destinationRow = rows[destinationRowIndex]
|
||||||
val destinationItemIndex = rowEntryItemIndex(
|
val destinationItemIndex = rowEntryItemIndex(
|
||||||
sourceIndex = sourceItemIndex,
|
sourceIndex = sourceItemIndex,
|
||||||
@@ -1884,11 +2012,6 @@ private fun HomeScreen(
|
|||||||
itemIndex = destinationItemIndex,
|
itemIndex = destinationItemIndex,
|
||||||
requestId = rowFocusRequestId,
|
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
|
rowFocusMoving = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
// Compose the destination row before its MediaRow tries
|
// Compose the destination row before its MediaRow tries
|
||||||
@@ -1904,6 +2027,10 @@ private fun HomeScreen(
|
|||||||
onItemFocused = { item ->
|
onItemFocused = { item ->
|
||||||
val itemIndex = row.items.indexOfFirst { it.id == item.id }
|
val itemIndex = row.items.indexOfFirst { it.id == item.id }
|
||||||
if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex
|
if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex
|
||||||
|
rowFocusedBelowHero = true
|
||||||
|
if (selectedDestination == BrowseDestination.HOME) {
|
||||||
|
focusedHomeRowId = row.id
|
||||||
|
}
|
||||||
destinationFocus[selectedDestination] = row.id to item.id
|
destinationFocus[selectedDestination] = row.id to item.id
|
||||||
returnRowId = row.id
|
returnRowId = row.id
|
||||||
returnItemId = item.id
|
returnItemId = item.id
|
||||||
@@ -1969,6 +2096,8 @@ private fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
HomeClock(
|
HomeClock(
|
||||||
|
showGreeting = showHomeGreeting,
|
||||||
|
username = settings.username,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 24.dp, bottom = 18.dp),
|
.padding(end = 24.dp, bottom = 18.dp),
|
||||||
@@ -2149,20 +2278,33 @@ private fun HomeScreen(
|
|||||||
onToggleFavorite = homeViewModel::setFavorite,
|
onToggleFavorite = homeViewModel::setFavorite,
|
||||||
isMyShow = myShows.any { it.itemId == selected.id },
|
isMyShow = myShows.any { it.itemId == selected.id },
|
||||||
onToggleMyShow = { item, saved ->
|
onToggleMyShow = { item, saved ->
|
||||||
scope.launch {
|
// 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) {
|
if (saved) {
|
||||||
runCatching { repo.saveMyShow(item) }.onSuccess {
|
|
||||||
myShows = it
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
"${item.name} added to My Shows",
|
"${item.name} added to My Shows",
|
||||||
Toast.LENGTH_SHORT,
|
Toast.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
if (saved) {
|
||||||
|
runCatching { repo.saveMyShow(item) }
|
||||||
|
.onSuccess { myShows = it }
|
||||||
|
.onFailure { myShows = previous }
|
||||||
} else {
|
} else {
|
||||||
runCatching { repo.removeMyShow(item.id) }.onSuccess {
|
runCatching { repo.removeMyShow(item.id) }
|
||||||
myShows = myShows.filterNot { it.itemId == item.id }
|
.onFailure { myShows = previous }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2286,6 +2428,30 @@ private fun HomeScreen(
|
|||||||
},
|
},
|
||||||
onSetFavorite = homeViewModel::setFavorite,
|
onSetFavorite = homeViewModel::setFavorite,
|
||||||
onSetPlayed = homeViewModel::setPlayed,
|
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,
|
rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title,
|
||||||
rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(),
|
rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(),
|
||||||
onToggleRowPinned = quickMenuRowId?.let { rowId ->
|
onToggleRowPinned = quickMenuRowId?.let { rowId ->
|
||||||
@@ -2484,7 +2650,7 @@ private fun RecentSearchesRow(
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(
|
.background(
|
||||||
if (focused) Color(0xFF52B54B) else Color(0xFF1A2129),
|
if (focused) MembyAccent else Color(0xFF1A2129),
|
||||||
)
|
)
|
||||||
.padding(horizontal = 17.dp, vertical = 10.dp),
|
.padding(horizontal = 17.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
@@ -2495,7 +2661,11 @@ private fun RecentSearchesRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HomeClock(modifier: Modifier = Modifier) {
|
private fun HomeClock(
|
||||||
|
showGreeting: Boolean,
|
||||||
|
username: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
|
val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) }
|
||||||
var currentTime by remember { mutableStateOf(Date()) }
|
var currentTime by remember { mutableStateOf(Date()) }
|
||||||
@@ -2510,16 +2680,58 @@ private fun HomeClock(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
text = timeFormatter.format(currentTime),
|
text = timeFormatter.format(currentTime),
|
||||||
color = Color.White.copy(alpha = 0.86f),
|
color = Color.White.copy(alpha = 0.86f),
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
modifier = modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(Color(0xB30A0D10))
|
.background(Color(0xB30A0D10))
|
||||||
.padding(horizontal = 12.dp, vertical = 7.dp),
|
.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -2661,6 +2873,7 @@ private fun FocusedQuickActionsOverlay(
|
|||||||
onOpenDetails: (BaseItem) -> Unit,
|
onOpenDetails: (BaseItem) -> Unit,
|
||||||
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
onSetFavorite: (BaseItem, Boolean) -> Unit,
|
||||||
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
onSetPlayed: (BaseItem, Boolean) -> Unit,
|
||||||
|
onRemoveFromContinueWatching: (() -> Unit)?,
|
||||||
rowTitle: String?,
|
rowTitle: String?,
|
||||||
rowPinned: Boolean,
|
rowPinned: Boolean,
|
||||||
onToggleRowPinned: (() -> Unit)?,
|
onToggleRowPinned: (() -> Unit)?,
|
||||||
@@ -2674,6 +2887,7 @@ private fun FocusedQuickActionsOverlay(
|
|||||||
onOpenDetails = onOpenDetails,
|
onOpenDetails = onOpenDetails,
|
||||||
onSetFavorite = onSetFavorite,
|
onSetFavorite = onSetFavorite,
|
||||||
onSetPlayed = onSetPlayed,
|
onSetPlayed = onSetPlayed,
|
||||||
|
onRemoveFromContinueWatching = onRemoveFromContinueWatching,
|
||||||
rowTitle = rowTitle,
|
rowTitle = rowTitle,
|
||||||
rowPinned = rowPinned,
|
rowPinned = rowPinned,
|
||||||
onToggleRowPinned = onToggleRowPinned,
|
onToggleRowPinned = onToggleRowPinned,
|
||||||
@@ -3058,7 +3272,7 @@ private fun ForYouNudgeBanner(
|
|||||||
Modifier
|
Modifier
|
||||||
.size(10.dp)
|
.size(10.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color(0xFF52B54B)),
|
.background(MembyAccent),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(16.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(3.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
|
@Composable
|
||||||
private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) {
|
private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) {
|
||||||
val repo = ServiceLocator.repository
|
val repo = ServiceLocator.repository
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import kotlinx.coroutines.delay
|
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 MaintenanceTitle = Color(0xFFF2F5F7)
|
||||||
private val MaintenanceBody = Color(0xFFAEB7BF)
|
private val MaintenanceBody = Color(0xFFAEB7BF)
|
||||||
private val MaintenanceFaint = Color(0xFFA2ADB5)
|
private val MaintenanceFaint = Color(0xFFA2ADB5)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import androidx.compose.ui.focus.FocusRequester
|
|||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.RelatedContent
|
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.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.MediaRating
|
import com.ponzischeme89.memby.data.model.MediaRating
|
||||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||||
@@ -53,7 +52,8 @@ fun MediaDetailsOverlay(
|
|||||||
onOpenItem: (BaseItem) -> Unit = {},
|
onOpenItem: (BaseItem) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
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 related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
||||||
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
||||||
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
|
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.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MyShowsStrip(
|
internal fun MyShowsStrip(
|
||||||
@@ -147,7 +148,7 @@ private fun MyShowCard(
|
|||||||
.aspectRatio(2f / 3f)
|
.aspectRatio(2f / 3f)
|
||||||
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
|
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
|
||||||
.clip(RoundedCornerShape(9.dp))
|
.clip(RoundedCornerShape(9.dp))
|
||||||
.background(Color(0xFF20252A))
|
.background(MembySurfaceRaised)
|
||||||
.border(
|
.border(
|
||||||
2.dp,
|
2.dp,
|
||||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||||
@@ -252,7 +253,7 @@ internal fun MyShowDetailsOverlay(
|
|||||||
.width(170.dp)
|
.width(170.dp)
|
||||||
.aspectRatio(2f / 3f)
|
.aspectRatio(2f / 3f)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(Color(0xFF20252A))
|
.background(MembySurfaceRaised)
|
||||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
|
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
|
||||||
)
|
)
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.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.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.RelatedContent
|
import com.ponzischeme89.memby.data.RelatedContent
|
||||||
import com.ponzischeme89.memby.data.ServerConfig
|
import com.ponzischeme89.memby.data.ServerConfig
|
||||||
import com.ponzischeme89.memby.data.Settings
|
|
||||||
import com.ponzischeme89.memby.data.estimateSeriesPace
|
import com.ponzischeme89.memby.data.estimateSeriesPace
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.MediaRating
|
import com.ponzischeme89.memby.data.model.MediaRating
|
||||||
@@ -109,7 +108,7 @@ fun SeriesDetailsOverlay(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val repository = ServiceLocator.repository
|
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 episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
||||||
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
||||||
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
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.MaintenanceMonitor
|
||||||
import com.ponzischeme89.memby.data.ServiceAlert
|
import com.ponzischeme89.memby.data.ServiceAlert
|
||||||
import kotlin.math.ceil
|
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 AlertTitle = Color(0xFFF2F5F7)
|
||||||
private val AlertBody = Color(0xFFC3CBD2)
|
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.InstallPermissionRequiredException
|
||||||
import com.ponzischeme89.memby.update.UpdateChecker
|
import com.ponzischeme89.memby.update.UpdateChecker
|
||||||
import kotlinx.coroutines.launch
|
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 UpdateTitle = Color(0xFFF2F5F7)
|
||||||
private val UpdateBody = Color(0xFFAEB7BF)
|
private val UpdateBody = Color(0xFFAEB7BF)
|
||||||
private val UpdateFaint = Color(0xFFA2ADB5)
|
private val UpdateFaint = Color(0xFFA2ADB5)
|
||||||
@@ -175,7 +177,7 @@ fun UpdateScreen(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
||||||
.background(Color(0xFF0B0E11)),
|
.background(MembySurface),
|
||||||
) {
|
) {
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
Canvas(Modifier.fillMaxSize()) {
|
||||||
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
|
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.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import androidx.media3.ui.TimeBar
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
@@ -60,6 +61,7 @@ import com.ponzischeme89.memby.R
|
|||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
||||||
import com.ponzischeme89.memby.data.IntroSegment
|
import com.ponzischeme89.memby.data.IntroSegment
|
||||||
|
import com.ponzischeme89.memby.data.creditsWorthShowing
|
||||||
import com.ponzischeme89.memby.data.NextEpisode
|
import com.ponzischeme89.memby.data.NextEpisode
|
||||||
import com.ponzischeme89.memby.data.Playable
|
import com.ponzischeme89.memby.data.Playable
|
||||||
import com.ponzischeme89.memby.data.PlayableSubtitle
|
import com.ponzischeme89.memby.data.PlayableSubtitle
|
||||||
@@ -92,6 +94,7 @@ import kotlinx.serialization.encodeToString
|
|||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.math.ceil
|
import kotlin.math.ceil
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,6 +214,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
* screen's Back has.
|
* screen's Back has.
|
||||||
*/
|
*/
|
||||||
private var subtitleDownloadExpanded = false
|
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 subtitleOverlay: View? = null
|
||||||
private var castOverlay: View? = null
|
private var castOverlay: View? = null
|
||||||
private var castJob: Job? = null
|
private var castJob: Job? = null
|
||||||
@@ -262,6 +267,32 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private var skipIntroTaken = false
|
private var skipIntroTaken = false
|
||||||
private var skipIntroDismissed = 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
|
// 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.
|
// told; re-announcing it every time they seek would be nagging, not informing.
|
||||||
private var timeRemainingCue: View? = null
|
private var timeRemainingCue: View? = null
|
||||||
@@ -287,6 +318,19 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
* path of a press: see [TrickplayPreview].
|
* path of a press: see [TrickplayPreview].
|
||||||
*/
|
*/
|
||||||
private var trickplayAvailable = false
|
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 {
|
private val trickplayPreview by lazy {
|
||||||
TrickplayPreview(
|
TrickplayPreview(
|
||||||
scope = lifecycleScope,
|
scope = lifecycleScope,
|
||||||
@@ -364,6 +408,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// Default false, not true: a missing extra must never conjure a section whose
|
// Default false, not true: a missing extra must never conjure a section whose
|
||||||
// only row leads to a request the backend cannot answer.
|
// only row leads to a request the backend cannot answer.
|
||||||
subtitleDownloadAvailable = intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false)
|
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}")
|
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
||||||
|
|
||||||
setContentView(R.layout.activity_player)
|
setContentView(R.layout.activity_player)
|
||||||
@@ -383,6 +434,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
view.findViewById<View>(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener {
|
view.findViewById<View>(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener {
|
||||||
showTrackMenu()
|
showTrackMenu()
|
||||||
}
|
}
|
||||||
|
bindScrubPreview(view)
|
||||||
applyPictureMode()
|
applyPictureMode()
|
||||||
loadingView = findViewById(R.id.playback_loading)
|
loadingView = findViewById(R.id.playback_loading)
|
||||||
loadingTitleView = findViewById(R.id.playback_loading_title)
|
loadingTitleView = findViewById(R.id.playback_loading_title)
|
||||||
@@ -431,12 +483,20 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
recordPlaybackState(playbackState)
|
recordPlaybackState(playbackState)
|
||||||
when (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()
|
if (!prerollActive && !seekBuffering) showPlaybackLoading()
|
||||||
|
}
|
||||||
Player.STATE_READY -> {
|
Player.STATE_READY -> {
|
||||||
trace.mark(PlaybackTrace.READY)
|
trace.mark(PlaybackTrace.READY)
|
||||||
endSeekBuffering()
|
endSeekBuffering()
|
||||||
hidePlaybackError()
|
hidePlaybackError()
|
||||||
|
pendingSubtitleConfirmation?.let { label ->
|
||||||
|
pendingSubtitleConfirmation = null
|
||||||
|
showSubtitleConfirmation(label)
|
||||||
|
}
|
||||||
if (prerollActive) bindPrerollNow(playback.duration)
|
if (prerollActive) bindPrerollNow(playback.duration)
|
||||||
if (!prerollActive && renderedFirstFrame) {
|
if (!prerollActive && renderedFirstFrame) {
|
||||||
hidePlaybackLoading()
|
hidePlaybackLoading()
|
||||||
@@ -525,6 +585,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
setUpCastOverlay()
|
setUpCastOverlay()
|
||||||
setUpNextUpBanner()
|
setUpNextUpBanner()
|
||||||
setUpSkipIntro()
|
setUpSkipIntro()
|
||||||
|
setUpEndCredits()
|
||||||
setUpTimeRemainingCue()
|
setUpTimeRemainingCue()
|
||||||
setUpSeasonFinaleCue()
|
setUpSeasonFinaleCue()
|
||||||
setUpPlaybackError()
|
setUpPlaybackError()
|
||||||
@@ -631,6 +692,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
subtitleDownloadAvailable = playable.subtitleDownloadAvailable
|
subtitleDownloadAvailable = playable.subtitleDownloadAvailable
|
||||||
trickplayAvailable = playable.trickplayAvailable
|
trickplayAvailable = playable.trickplayAvailable
|
||||||
skipIntroAvailable = playable.skipIntroAvailable
|
skipIntroAvailable = playable.skipIntroAvailable
|
||||||
|
endCreditsAvailable = playable.endCreditsAvailable
|
||||||
subtitleAutoSelectionAttempted = false
|
subtitleAutoSelectionAttempted = false
|
||||||
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
|
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
|
||||||
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
|
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
|
// away and the markers that describe them are worth nothing until there is a
|
||||||
// picture to skip forward in.
|
// picture to skip forward in.
|
||||||
loadIntroSegment()
|
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()
|
startSkipIntroWatch()
|
||||||
reportStarted(playback.currentPosition)
|
reportStarted(playback.currentPosition)
|
||||||
startProgressReporting()
|
startProgressReporting()
|
||||||
@@ -1602,10 +1667,84 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
castOverlay?.isVisible != true &&
|
castOverlay?.isVisible != true &&
|
||||||
subtitleOverlay?.isVisible != true &&
|
subtitleOverlay?.isVisible != true &&
|
||||||
nextUpBanner?.isVisible != true &&
|
nextUpBanner?.isVisible != true &&
|
||||||
|
creditsView?.isVisible != true &&
|
||||||
loadingView?.isVisible != true &&
|
loadingView?.isVisible != true &&
|
||||||
errorView?.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 =
|
private fun seekIntervalMs(): Long =
|
||||||
normalizeSeekIntervalSeconds(
|
normalizeSeekIntervalSeconds(
|
||||||
ServiceLocator.settings.current?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
ServiceLocator.settings.current?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
@@ -1863,6 +2002,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
castOverlay?.isVisible != true &&
|
castOverlay?.isVisible != true &&
|
||||||
subtitleOverlay?.isVisible != true &&
|
subtitleOverlay?.isVisible != true &&
|
||||||
nextUpBanner?.isVisible != true &&
|
nextUpBanner?.isVisible != true &&
|
||||||
|
creditsView?.isVisible != true &&
|
||||||
loadingView?.isVisible != true &&
|
loadingView?.isVisible != true &&
|
||||||
errorView?.isVisible != true
|
errorView?.isVisible != true
|
||||||
|
|
||||||
@@ -2078,7 +2218,27 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val duration = playback.duration
|
val duration = playback.duration
|
||||||
if (duration == C.TIME_UNSET || duration <= 0L) return
|
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)
|
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 {
|
when {
|
||||||
remainingMs > NEXT_UP_LEAD_MS -> {
|
remainingMs > NEXT_UP_LEAD_MS -> {
|
||||||
hideNextUp()
|
hideNextUp()
|
||||||
@@ -2171,6 +2331,257 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
?.start()
|
?.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() {
|
private fun handlePlaybackEnded() {
|
||||||
when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) {
|
when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) {
|
||||||
PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext)
|
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
|
// Nor may the outgoing episode's title-sequence markers: every show times its
|
||||||
// opening differently, and the next episode's are a fresh lookup.
|
// opening differently, and the next episode's are a fresh lookup.
|
||||||
resetSkipIntro()
|
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
|
nextEpisode = null
|
||||||
nextUpDismissed = false
|
nextUpDismissed = false
|
||||||
requestStartedAtMs = SystemClock.elapsedRealtime()
|
requestStartedAtMs = SystemClock.elapsedRealtime()
|
||||||
@@ -2337,6 +2752,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
collapseSubtitleDownloads()
|
collapseSubtitleDownloads()
|
||||||
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
|
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
|
||||||
nextUpBanner?.isVisible == true -> dismissNextUp()
|
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
|
// Back refuses the offer rather than leaving the film: one press per
|
||||||
// level, the same contract the banner above and the drop-up have.
|
// level, the same contract the banner above and the drop-up have.
|
||||||
skipIntroView?.isVisible == true -> dismissSkipIntro()
|
skipIntroView?.isVisible == true -> dismissSkipIntro()
|
||||||
@@ -2389,6 +2807,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
castOverlay?.isVisible != true &&
|
castOverlay?.isVisible != true &&
|
||||||
subtitleOverlay?.isVisible != true &&
|
subtitleOverlay?.isVisible != true &&
|
||||||
nextUpBanner?.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
|
// 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
|
// remote presses the thing it is focused on. Pausing instead would leave the
|
||||||
// one button on screen unpressable.
|
// one button on screen unpressable.
|
||||||
@@ -2653,6 +3074,10 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId
|
isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId
|
||||||
}.let { if (it >= 0) it else 0 }
|
}.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 preferences = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE)
|
||||||
val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key)
|
val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key)
|
||||||
bindSubtitleMenu(
|
bindSubtitleMenu(
|
||||||
@@ -2667,9 +3092,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
downloads = SubtitleDownloadState(
|
downloads = SubtitleDownloadState(
|
||||||
available = subtitleDownloadAvailable,
|
available = subtitleDownloadAvailable,
|
||||||
status = subtitleDownloadStatus,
|
status = subtitleDownloadStatus,
|
||||||
entries = subtitleDownloadEntries(),
|
entries = subtitleDownloadEntries(noSubtitles),
|
||||||
expanded = subtitleDownloadExpanded,
|
expanded = subtitleDownloadExpanded,
|
||||||
),
|
),
|
||||||
|
tracksState = SubtitleTracksState(
|
||||||
|
empty = noSubtitles,
|
||||||
|
downloadable = subtitleDownloadAvailable,
|
||||||
|
),
|
||||||
onSize = { index ->
|
onSize = { index ->
|
||||||
preferences.edit().putString(SUBTITLE_SIZE_KEY, SubtitleSize.entries[index].key).apply()
|
preferences.edit().putString(SUBTITLE_SIZE_KEY, SubtitleSize.entries[index].key).apply()
|
||||||
applySubtitleAppearance()
|
applySubtitleAppearance()
|
||||||
@@ -2695,6 +3124,11 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
focusTrack != null -> tracksContainer.getChildAt(focusTrack)
|
focusTrack != null -> tracksContainer.getChildAt(focusTrack)
|
||||||
focusSize != null -> sizesContainer.getChildAt(focusSize)
|
focusSize != null -> sizesContainer.getChildAt(focusSize)
|
||||||
focusDownload != null -> downloadsContainer?.getChildAt(focusDownload)
|
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)
|
else -> tracksContainer.getChildAt(selectedTrack)
|
||||||
}
|
}
|
||||||
// A redraw can legitimately ask for a row that is gone or disabled — the search row
|
// 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
|
* 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.
|
* 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(
|
add(
|
||||||
SubtitleMenuEntry(
|
SubtitleMenuEntry(
|
||||||
label = getString(
|
label = getString(
|
||||||
if (subtitleCandidates.isEmpty()) R.string.player_subtitle_search
|
when {
|
||||||
else R.string.player_subtitle_search_again,
|
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,
|
selected = false,
|
||||||
// Unfocusable while a query is in flight, which is what stops a second
|
// Unfocusable while a query is in flight, which is what stops a second
|
||||||
// press starting a second search.
|
// press starting a second search.
|
||||||
enabled = !subtitleRequestInFlight,
|
enabled = !subtitleRequestInFlight,
|
||||||
|
prominent = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
subtitleCandidates.forEach { candidate ->
|
subtitleCandidates.forEach { candidate ->
|
||||||
@@ -2825,6 +3265,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// nothing ever turns it on.
|
// nothing ever turns it on.
|
||||||
subtitleAutoSelectionAttempted = false
|
subtitleAutoSelectionAttempted = false
|
||||||
rememberSubtitleChoice(enabled = true, language = candidate.language)
|
rememberSubtitleChoice(enabled = true, language = candidate.language)
|
||||||
|
pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language }
|
||||||
playback.setMediaItem(mediaItem(result.url, result.subtitles), position)
|
playback.setMediaItem(mediaItem(result.url, result.subtitles), position)
|
||||||
playback.playWhenReady = true
|
playback.playWhenReady = true
|
||||||
playback.prepare()
|
playback.prepare()
|
||||||
@@ -2832,7 +3273,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
subtitleCandidates = emptyList()
|
subtitleCandidates = emptyList()
|
||||||
subtitleDownloadStatus = result.message
|
subtitleDownloadStatus = result.message
|
||||||
subtitleDownloadExpanded = false
|
subtitleDownloadExpanded = false
|
||||||
hideSubtitleOverlay()
|
dismissSubtitleOverlayAfterSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2857,7 +3298,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
if (subtitleOverlay?.isVisible == true) showSubtitleOverlay(focusDownload = focusDownload)
|
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)
|
@OptIn(UnstableApi::class)
|
||||||
private fun selectSubtitleTrack(choice: TrackChoice, index: Int) {
|
private fun selectSubtitleTrack(choice: TrackChoice, index: Int) {
|
||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
@@ -2881,7 +3322,12 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
encodedSubtitleId = null
|
encodedSubtitleId = null
|
||||||
subtitleAutoSelectionAttempted = true
|
subtitleAutoSelectionAttempted = true
|
||||||
reportProgress(playback.currentPosition, !playback.isPlaying, "SubtitleTrackChange")
|
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() {
|
private fun hideSubtitleOverlay() {
|
||||||
@@ -2889,6 +3335,24 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
playerView?.showController()
|
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 {
|
private fun subtitleDisplayLabel(subtitle: PlayableSubtitle): String {
|
||||||
val name = subtitle.label?.takeIf(String::isNotBlank)
|
val name = subtitle.label?.takeIf(String::isNotBlank)
|
||||||
?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage }
|
?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage }
|
||||||
@@ -2906,7 +3370,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val id = itemId?.takeIf(String::isNotBlank) ?: return
|
val id = itemId?.takeIf(String::isNotBlank) ?: return
|
||||||
val index = subtitle.id.toIntOrNull() ?: return
|
val index = subtitle.id.toIntOrNull() ?: return
|
||||||
val position = playback.currentPosition.coerceAtLeast(0L)
|
val position = playback.currentPosition.coerceAtLeast(0L)
|
||||||
hideSubtitleOverlay()
|
dismissSubtitleOverlayAfterSelection()
|
||||||
reportProgress(position, !playback.isPlaying, "SubtitleTrackChange")
|
reportProgress(position, !playback.isPlaying, "SubtitleTrackChange")
|
||||||
showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…")
|
showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…")
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -2926,6 +3390,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
subtitlePreference = true
|
subtitlePreference = true
|
||||||
rememberSubtitleChoice(enabled = true, language = subtitle.language)
|
rememberSubtitleChoice(enabled = true, language = subtitle.language)
|
||||||
subtitleAutoSelectionAttempted = true
|
subtitleAutoSelectionAttempted = true
|
||||||
|
pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle)
|
||||||
playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position)
|
playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position)
|
||||||
playback.playWhenReady = true
|
playback.playWhenReady = true
|
||||||
playback.prepare()
|
playback.prepare()
|
||||||
@@ -3062,6 +3527,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
castJob?.cancel()
|
castJob?.cancel()
|
||||||
subtitleSearchJob?.cancel()
|
subtitleSearchJob?.cancel()
|
||||||
nextUpJob?.cancel()
|
nextUpJob?.cancel()
|
||||||
|
creditsSpeedJob?.cancel()
|
||||||
|
creditsView?.animate()?.cancel()
|
||||||
retryJob?.cancel()
|
retryJob?.cancel()
|
||||||
stablePlaybackJob?.cancel()
|
stablePlaybackJob?.cancel()
|
||||||
playbackIdentityHideJob?.cancel()
|
playbackIdentityHideJob?.cancel()
|
||||||
@@ -3185,6 +3652,17 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val EXTRA_SUBTITLES_ENABLED = "extra_subtitles_enabled"
|
private const val EXTRA_SUBTITLES_ENABLED = "extra_subtitles_enabled"
|
||||||
private const val EXTRA_SELECTED_SUBTITLE_ID = "extra_selected_subtitle_id"
|
private const val EXTRA_SELECTED_SUBTITLE_ID = "extra_selected_subtitle_id"
|
||||||
private const val EXTRA_SUBTITLE_DOWNLOAD = "extra_subtitle_download"
|
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_MEDIA_SOURCE_ID = "extra_media_source_id"
|
||||||
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id"
|
||||||
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
private const val EXTRA_PLAY_METHOD = "extra_play_method"
|
||||||
@@ -3247,6 +3725,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
subtitlesEnabled: Boolean = true,
|
subtitlesEnabled: Boolean = true,
|
||||||
selectedSubtitleId: String = "",
|
selectedSubtitleId: String = "",
|
||||||
subtitleDownloadAvailable: Boolean = false,
|
subtitleDownloadAvailable: Boolean = false,
|
||||||
|
trickplayAvailable: Boolean = false,
|
||||||
|
skipIntroAvailable: Boolean = false,
|
||||||
|
endCreditsAvailable: Boolean = false,
|
||||||
mediaSourceId: String = "",
|
mediaSourceId: String = "",
|
||||||
playSessionId: String = "",
|
playSessionId: String = "",
|
||||||
playMethod: String = "DirectPlay",
|
playMethod: String = "DirectPlay",
|
||||||
@@ -3268,6 +3749,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
putExtra(EXTRA_SUBTITLES_ENABLED, subtitlesEnabled)
|
putExtra(EXTRA_SUBTITLES_ENABLED, subtitlesEnabled)
|
||||||
putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
|
putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId)
|
||||||
putExtra(EXTRA_SUBTITLE_DOWNLOAD, subtitleDownloadAvailable)
|
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_MEDIA_SOURCE_ID, mediaSourceId)
|
||||||
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
|
putExtra(EXTRA_PLAY_SESSION_ID, playSessionId)
|
||||||
putExtra(EXTRA_PLAY_METHOD, playMethod)
|
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_VIDEO_SHIFT_Y = 0.15f
|
||||||
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
|
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
|
||||||
private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360
|
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
|
* 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
|
* 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 label: String,
|
||||||
val selected: Boolean,
|
val selected: Boolean,
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
|
val prominent: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,24 @@ data class SubtitleDownloadState(
|
|||||||
val expanded: Boolean = false,
|
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.
|
* Fills the drop-up's containers from plain lists.
|
||||||
*
|
*
|
||||||
@@ -60,6 +79,7 @@ fun bindSubtitleMenu(
|
|||||||
tracks: List<SubtitleMenuEntry>,
|
tracks: List<SubtitleMenuEntry>,
|
||||||
sizes: List<SubtitleMenuEntry>,
|
sizes: List<SubtitleMenuEntry>,
|
||||||
downloads: SubtitleDownloadState = SubtitleDownloadState(),
|
downloads: SubtitleDownloadState = SubtitleDownloadState(),
|
||||||
|
tracksState: SubtitleTracksState = SubtitleTracksState(),
|
||||||
onTrack: (Int) -> Unit = {},
|
onTrack: (Int) -> Unit = {},
|
||||||
onSize: (Int) -> Unit = {},
|
onSize: (Int) -> Unit = {},
|
||||||
onDownload: (Int) -> Unit = {},
|
onDownload: (Int) -> Unit = {},
|
||||||
@@ -69,6 +89,13 @@ fun bindSubtitleMenu(
|
|||||||
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
|
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
|
||||||
trackContainer.removeAllViews()
|
trackContainer.removeAllViews()
|
||||||
sizeContainer.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 ->
|
tracks.forEachIndexed { index, entry ->
|
||||||
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
|
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
|
||||||
}
|
}
|
||||||
@@ -122,8 +149,15 @@ private fun subtitleMenuOption(
|
|||||||
).apply {
|
).apply {
|
||||||
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
|
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
|
||||||
}
|
}
|
||||||
background = context.getDrawable(R.drawable.player_overlay_option_background)
|
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))
|
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
|
||||||
|
}
|
||||||
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
|
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
|
||||||
setPadding(dp(14), 0, dp(14), 0)
|
setPadding(dp(14), 0, dp(14), 0)
|
||||||
text = entry.label
|
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
|
* *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,
|
* 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.
|
* 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(
|
class TrickplayPreview(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -34,7 +40,8 @@ class TrickplayPreview(
|
|||||||
/** One thumbnail's JPEG bytes, or null. */
|
/** One thumbnail's JPEG bytes, or null. */
|
||||||
private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
|
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 itemId: String? = null
|
||||||
private var track: Trickplay? = null
|
private var track: Trickplay? = null
|
||||||
private var trackJob: Job? = null
|
private var trackJob: Job? = null
|
||||||
@@ -54,12 +61,18 @@ class TrickplayPreview(
|
|||||||
size > FRAME_CACHE_SIZE
|
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) {
|
fun bind(preview: ImageView) {
|
||||||
view = preview
|
if (views.none { it === preview }) views += preview
|
||||||
applyAspect()
|
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,
|
* 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.
|
* 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
|
* [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,
|
* 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.
|
* 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 current = track ?: return
|
||||||
val preview = view ?: return
|
val preview = into ?: return
|
||||||
val frame = current.frameAt(positionMs)
|
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
|
// 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
|
// a fetch completes, and without this a slow response for a frame the viewer has
|
||||||
@@ -103,9 +119,11 @@ class TrickplayPreview(
|
|||||||
?: return@launch
|
?: return@launch
|
||||||
val bitmap = decode(bytes) ?: return@launch
|
val bitmap = decode(bytes) ?: return@launch
|
||||||
shownFrame = frame
|
shownFrame = frame
|
||||||
applyAspect()
|
shownIn = preview
|
||||||
|
applyAspect(preview)
|
||||||
preview.setImageBitmap(bitmap)
|
preview.setImageBitmap(bitmap)
|
||||||
preview.visibility = View.VISIBLE
|
preview.visibility = View.VISIBLE
|
||||||
|
views.forEach { if (it !== preview) clear(it) }
|
||||||
warm(current, frame + if (forward) 1 else -1)
|
warm(current, frame + if (forward) 1 else -1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,10 +133,13 @@ class TrickplayPreview(
|
|||||||
frameJob?.cancel()
|
frameJob?.cancel()
|
||||||
frameJob = null
|
frameJob = null
|
||||||
shownFrame = NO_FRAME
|
shownFrame = NO_FRAME
|
||||||
view?.let {
|
shownIn = null
|
||||||
it.visibility = View.GONE
|
views.forEach(::clear)
|
||||||
it.setImageDrawable(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* 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.
|
* otherwise be letterboxed inside it for the life of the playback.
|
||||||
*/
|
*/
|
||||||
private fun applyAspect() {
|
private fun applyAspect(preview: ImageView) {
|
||||||
val preview = view ?: return
|
|
||||||
val current = track ?: return
|
val current = track ?: return
|
||||||
val params = preview.layoutParams ?: return
|
val params = preview.layoutParams ?: return
|
||||||
val width = current.widthFor(params.height)
|
val width = current.widthFor(params.height)
|
||||||
|
|||||||
@@ -621,10 +621,7 @@ private fun Slideshow(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
SettingsSheet(
|
SettingsSheet(onClose = { settingsOpen = false })
|
||||||
onClose = { settingsOpen = false },
|
|
||||||
onInstallerLaunched = onExit,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
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.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
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.lazy.itemsIndexed as rowItemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.automirrored.filled.Backspace
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.Icon
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil.imageLoader
|
import coil.imageLoader
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
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.data.model.GatewayRequestCandidate
|
||||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
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 KeyIdle = Color(0xFF1A2129)
|
||||||
private val KeyFocused = Color(0xFF52B54B)
|
private val KeyFocused: Color get() = MembyAccent
|
||||||
private val KeyLabel = Color(0xFFE8EDF1)
|
private val KeyLabel = Color(0xFFE8EDF1)
|
||||||
private val KeyLabelFocused = Color(0xFF06240A)
|
private val KeyLabelFocused = Color(0xFF06240A)
|
||||||
private val Heading = Color(0xFFF2F5F7)
|
private val Heading = Color(0xFFF2F5F7)
|
||||||
private val Muted = Color(0xFFB7C0C8)
|
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
|
* 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. */
|
/** Posters prefetched as soon as results land, so the first visible row is never blank. */
|
||||||
private const val PREFETCHED_POSTERS = 8
|
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.
|
* 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() }
|
val keyboardReturn = remember { FocusRequester() }
|
||||||
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
var lastKeyIndex by remember { mutableIntStateOf(0) }
|
||||||
var focusInResults by remember { mutableStateOf(false) }
|
var focusInResults by remember { mutableStateOf(false) }
|
||||||
|
var restoreGenreChipFocus by remember { mutableStateOf(false) }
|
||||||
val hasResultsTarget = when {
|
val hasResultsTarget = when {
|
||||||
state.errorMessage != null && state.results.isEmpty() -> true
|
state.errorMessage != null && state.results.isEmpty() -> true
|
||||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||||
@@ -178,8 +195,42 @@ fun SearchScreen(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
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 {
|
BackHandler {
|
||||||
when {
|
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
|
// Results → keyboard → clear → leave. Each press does one obvious thing, and
|
||||||
// none of them can loop back to the previous state.
|
// none of them can loop back to the previous state.
|
||||||
focusInResults -> runCatching { keyboardReturn.requestFocus() }
|
focusInResults -> runCatching { keyboardReturn.requestFocus() }
|
||||||
@@ -246,6 +297,9 @@ fun SearchScreen(
|
|||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
onRequest = viewModel::request,
|
onRequest = viewModel::request,
|
||||||
onSuggestionSelected = viewModel::onQueryChanged,
|
onSuggestionSelected = viewModel::onQueryChanged,
|
||||||
|
onGenreSelected = viewModel::onGenreSelected,
|
||||||
|
onBackFromGenre = closeGenre,
|
||||||
|
onLoadMore = viewModel::loadMore,
|
||||||
modifier = Modifier.fillMaxHeight(),
|
modifier = Modifier.fillMaxHeight(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -598,6 +652,9 @@ private fun ResultsPane(
|
|||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||||
onSuggestionSelected: (String) -> Unit,
|
onSuggestionSelected: (String) -> Unit,
|
||||||
|
onGenreSelected: (String) -> Unit,
|
||||||
|
onBackFromGenre: () -> Unit,
|
||||||
|
onLoadMore: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
@@ -615,7 +672,11 @@ private fun ResultsPane(
|
|||||||
val items = if (showingDiscovery) discoveryItems else state.results
|
val items = if (showingDiscovery) discoveryItems else state.results
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
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 genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
|
||||||
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
|
||||||
if (showingDiscovery && genres.isNotEmpty()) {
|
if (showingDiscovery && genres.isNotEmpty()) {
|
||||||
@@ -624,7 +685,10 @@ private fun ResultsPane(
|
|||||||
genres = genres,
|
genres = genres,
|
||||||
resultsEntry = resultsEntry,
|
resultsEntry = resultsEntry,
|
||||||
keyboardReturn = keyboardReturn,
|
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()) {
|
if (showingDiscovery && recent.isNotEmpty()) {
|
||||||
@@ -672,6 +736,12 @@ private fun ResultsPane(
|
|||||||
returnFocusRequester = returnFocusRequester,
|
returnFocusRequester = returnFocusRequester,
|
||||||
onItemFocused = onItemFocused,
|
onItemFocused = onItemFocused,
|
||||||
onItemSelected = onItemSelected,
|
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
|
@Composable
|
||||||
private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
|
private fun ResultsHeading(
|
||||||
|
state: SearchUiState,
|
||||||
|
showingDiscovery: Boolean,
|
||||||
|
onBackFromGenre: () -> Unit,
|
||||||
|
) {
|
||||||
val title = when {
|
val title = when {
|
||||||
showingDiscovery -> "Browse your library"
|
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"
|
state.isEmptyResult -> "Search results — no matches"
|
||||||
else -> "Search results for “${state.query.trim()}”"
|
else -> "Search results for “${state.query.trim()}”"
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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(
|
Text(
|
||||||
title,
|
title,
|
||||||
color = Heading,
|
color = Heading,
|
||||||
@@ -695,7 +795,10 @@ private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
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))
|
Spacer(Modifier.width(10.dp))
|
||||||
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
Text("${state.results.size}", color = Muted, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
@@ -713,11 +816,29 @@ private fun ResultsGrid(
|
|||||||
returnFocusRequester: FocusRequester,
|
returnFocusRequester: FocusRequester,
|
||||||
onItemFocused: (BaseItem) -> Unit,
|
onItemFocused: (BaseItem) -> Unit,
|
||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
|
paging: Boolean = false,
|
||||||
|
loadingMore: Boolean = false,
|
||||||
|
onLoadMore: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val gridState = rememberLazyGridState()
|
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
|
// 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.
|
// ids rather than the list, so an unchanged result set never re-fetches.
|
||||||
val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
|
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(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color(0xFF080B0E))
|
.background(MembySurface)
|
||||||
.padding(28.dp),
|
.padding(28.dp),
|
||||||
) {
|
) {
|
||||||
RequestOptions(
|
RequestOptions(
|
||||||
@@ -1133,6 +1269,10 @@ private fun SuggestionChips(
|
|||||||
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
|
||||||
val message = when {
|
val message = when {
|
||||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
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.isLoading -> "Searching…"
|
||||||
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
|
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."
|
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.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.ponzischeme89.memby.data.EmbyRepository
|
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.friendlyEmbyError
|
||||||
|
import com.ponzischeme89.memby.data.hasMoreGenreItems
|
||||||
import com.ponzischeme89.memby.data.model.BaseItem
|
import com.ponzischeme89.memby.data.model.BaseItem
|
||||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -31,6 +34,20 @@ data class SearchSuggestion(val label: String, val kind: Kind) {
|
|||||||
data class SearchUiState(
|
data class SearchUiState(
|
||||||
val query: String = "",
|
val query: String = "",
|
||||||
val results: List<BaseItem> = emptyList(),
|
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 suggestions: List<SearchSuggestion> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
/** 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
|
val isEmptyResult: Boolean
|
||||||
get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty()
|
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
|
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 val recentQueries = ArrayDeque<String>()
|
||||||
private var genreSuggestions: List<String> = emptyList()
|
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 {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
queryFlow
|
queryFlow
|
||||||
@@ -105,10 +130,17 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
|
|
||||||
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||||
fun onQueryChanged(query: String) {
|
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.
|
// The visible field updates immediately; only the *search* is debounced.
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
query = query,
|
query = query,
|
||||||
|
genre = null,
|
||||||
|
isLoadingMore = false,
|
||||||
|
canLoadMore = false,
|
||||||
requestCandidates = emptyList(),
|
requestCandidates = emptyList(),
|
||||||
requestLookupLoading = false,
|
requestLookupLoading = false,
|
||||||
requestMessage = null,
|
requestMessage = null,
|
||||||
@@ -125,20 +157,78 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
|||||||
fun clearQuery() {
|
fun clearQuery() {
|
||||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||||
// debounced empty-query transition is pending.
|
// debounced empty-query transition is pending.
|
||||||
|
genrePageJob?.cancel()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
query = "", results = emptyList(), isLoading = false, hasSearched = false,
|
query = "", results = emptyList(), isLoading = false, hasSearched = false,
|
||||||
errorMessage = null, requestCandidates = emptyList(),
|
errorMessage = null, requestCandidates = emptyList(),
|
||||||
requestLookupLoading = false, requestMessage = null,
|
requestLookupLoading = false, requestMessage = null,
|
||||||
requestMessageIsError = false,
|
requestMessageIsError = false, genre = null,
|
||||||
|
isLoadingMore = false, canLoadMore = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
queryFlow.value = ""
|
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. */
|
/** Retry after an error, without disturbing the query or the keyboard. */
|
||||||
fun retry() {
|
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
|
if (!shouldSearch(term)) return
|
||||||
cache.remove(term)
|
cache.remove(term)
|
||||||
viewModelScope.launch { runSearch(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) {
|
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)) {
|
if (!shouldSearch(term)) {
|
||||||
// Back to the discovery state, but the previous results are dropped rather
|
// Back to the discovery state, but the previous results are dropped rather
|
||||||
// than left behind a shorter query they no longer match.
|
// 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
|
package com.ponzischeme89.memby.ui.settings
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.tween
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
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.verticalScroll
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
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.Devices
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.TagFaces
|
import androidx.compose.material.icons.filled.Storage
|
||||||
import androidx.compose.material.icons.filled.SystemUpdate
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
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.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
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.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Icon
|
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.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import com.ponzischeme89.memby.R
|
import com.ponzischeme89.memby.R
|
||||||
import com.ponzischeme89.memby.BuildConfig
|
|
||||||
import com.ponzischeme89.memby.ServiceLocator
|
import com.ponzischeme89.memby.ServiceLocator
|
||||||
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
|
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.DEFAULT_SKIP_INTRO_MODE
|
||||||
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
|
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
|
||||||
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
|
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
|
||||||
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
|
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
|
||||||
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
|
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
|
||||||
import com.ponzischeme89.memby.data.Settings
|
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.data.model.GatewayDevice
|
||||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||||
import com.ponzischeme89.memby.ui.TvPreview
|
import com.ponzischeme89.memby.ui.TvPreview
|
||||||
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
|
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
|
||||||
import com.ponzischeme89.memby.update.AppInstall
|
|
||||||
import com.ponzischeme89.memby.update.UpdateChecker
|
import com.ponzischeme89.memby.update.UpdateChecker
|
||||||
import com.ponzischeme89.memby.update.UpdateStatus
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
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(
|
private val RingOptions = listOf(
|
||||||
ChoiceOption("FFFFFF", "White", Color.White),
|
ChoiceOption("FFFFFF", "White", Color.White),
|
||||||
@@ -151,10 +157,14 @@ internal enum class SettingsPage(
|
|||||||
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
|
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
|
||||||
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
|
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
|
||||||
HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
|
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),
|
// No Updates page. The manual check needs a Gitea address, a repository and a token,
|
||||||
UPDATES("Updates", "Keep this TV up to date", Icons.Default.SystemUpdate),
|
// 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),
|
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.
|
// 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
|
// 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
|
// make this screen feel like Memby, and they read better against black than they did
|
||||||
// against a card.
|
// 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 Canvas = Color(0xFF000000)
|
||||||
private val Panel = Color(0xFF040506)
|
private val Panel = Color(0xFF040506)
|
||||||
private val RowFocused = Color(0xFF1B2228)
|
private val RowFocused = Color(0xFF1B2228)
|
||||||
@@ -186,6 +199,7 @@ internal data class SettingsPanelState(
|
|||||||
val showTenMinuteReminder: Boolean = true,
|
val showTenMinuteReminder: Boolean = true,
|
||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
||||||
|
val speedUpCredits: Boolean = true,
|
||||||
val ringColor: String = "52B54B",
|
val ringColor: String = "52B54B",
|
||||||
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
|
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
|
||||||
val cardDensity: String = "standard",
|
val cardDensity: String = "standard",
|
||||||
@@ -195,10 +209,25 @@ internal data class SettingsPanelState(
|
|||||||
val showRatingsStrip: Boolean = true,
|
val showRatingsStrip: Boolean = true,
|
||||||
val hideWatchedMovies: Boolean = false,
|
val hideWatchedMovies: Boolean = false,
|
||||||
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
|
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 selectedPage: SettingsPage = SettingsPage.APPEARANCE,
|
||||||
val checking: Boolean = false,
|
|
||||||
val updateStatus: UpdateStatus? = null,
|
|
||||||
val installMessage: String? = null,
|
|
||||||
val installedVersion: String = "",
|
val installedVersion: String = "",
|
||||||
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
|
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
|
||||||
val devices: List<GatewayDevice> = emptyList(),
|
val devices: List<GatewayDevice> = emptyList(),
|
||||||
@@ -206,6 +235,16 @@ internal data class SettingsPanelState(
|
|||||||
val devicesError: String? = null,
|
val devicesError: String? = null,
|
||||||
val removingDeviceId: String? = null,
|
val removingDeviceId: String? = null,
|
||||||
val pendingRemovalDeviceId: 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(
|
internal data class SettingsPanelActions(
|
||||||
@@ -215,6 +254,7 @@ internal data class SettingsPanelActions(
|
|||||||
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
|
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
|
||||||
val onSeekIntervalChanged: (Int) -> Unit = {},
|
val onSeekIntervalChanged: (Int) -> Unit = {},
|
||||||
val onSkipIntroModeChanged: (String) -> Unit = {},
|
val onSkipIntroModeChanged: (String) -> Unit = {},
|
||||||
|
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
|
||||||
val onRingColorChanged: (String) -> Unit = {},
|
val onRingColorChanged: (String) -> Unit = {},
|
||||||
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
|
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
|
||||||
val onCardDensityChanged: (String) -> Unit = {},
|
val onCardDensityChanged: (String) -> Unit = {},
|
||||||
@@ -224,14 +264,13 @@ internal data class SettingsPanelActions(
|
|||||||
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
|
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
|
||||||
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
|
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
|
||||||
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
|
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
|
||||||
|
val onThemeChanged: (String) -> Unit = {},
|
||||||
val onPageSelected: (SettingsPage) -> Unit = {},
|
val onPageSelected: (SettingsPage) -> Unit = {},
|
||||||
val onCheckForUpdates: () -> Unit = {},
|
|
||||||
val onInstallUpdate: (UpdateStatus.Available) -> Unit = {},
|
|
||||||
val onRefreshDevices: () -> Unit = {},
|
val onRefreshDevices: () -> Unit = {},
|
||||||
val onRenameDevice: (GatewayDevice) -> Unit = {},
|
val onRenameDevice: (GatewayDevice) -> Unit = {},
|
||||||
val onRemoveDevice: (GatewayDevice) -> Unit = {},
|
val onRemoveDevice: (GatewayDevice) -> Unit = {},
|
||||||
val onCancelDeviceRemoval: () -> Unit = {},
|
val onCancelDeviceRemoval: () -> Unit = {},
|
||||||
val onOpenSourceCode: () -> Unit = {},
|
val onClearImageCache: () -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -244,20 +283,27 @@ fun SettingsSheet(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
overlay: Boolean = false,
|
overlay: Boolean = false,
|
||||||
onInstallerLaunched: (() -> Unit)? = null,
|
|
||||||
navigationFocusRequester: FocusRequester? = null,
|
navigationFocusRequester: FocusRequester? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val store = ServiceLocator.settings
|
val store = ServiceLocator.settings
|
||||||
val scope = rememberCoroutineScope()
|
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 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 showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
|
||||||
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
|
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
|
||||||
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
|
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
|
||||||
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
|
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
|
||||||
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
|
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
|
||||||
|
var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) }
|
||||||
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
|
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
|
||||||
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
|
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
|
||||||
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
|
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
|
||||||
@@ -266,12 +312,12 @@ fun SettingsSheet(
|
|||||||
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
|
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
|
||||||
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
|
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
|
||||||
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
|
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 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 devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
|
||||||
var devicesLoading by remember { mutableStateOf(false) }
|
var devicesLoading by remember { mutableStateOf(false) }
|
||||||
var devicesError by remember { mutableStateOf<String?>(null) }
|
var devicesError by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -279,6 +325,9 @@ fun SettingsSheet(
|
|||||||
var pendingRemovalDeviceId by remember { mutableStateOf<String?>(null) }
|
var pendingRemovalDeviceId by remember { mutableStateOf<String?>(null) }
|
||||||
var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
|
var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
|
||||||
var deviceJob by remember { mutableStateOf<Job?>(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() {
|
suspend fun refreshDevices() {
|
||||||
devicesLoading = true
|
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(
|
LaunchedEffect(
|
||||||
settings.showTitleLogo,
|
settings.showTitleLogo,
|
||||||
settings.ringColorHex,
|
settings.ringColorHex,
|
||||||
@@ -318,6 +377,7 @@ fun SettingsSheet(
|
|||||||
settings.showTenMinuteReminder,
|
settings.showTenMinuteReminder,
|
||||||
settings.seekIntervalSeconds,
|
settings.seekIntervalSeconds,
|
||||||
settings.skipIntroMode,
|
settings.skipIntroMode,
|
||||||
|
settings.speedUpCredits,
|
||||||
settings.welcomeQuoteStyle,
|
settings.welcomeQuoteStyle,
|
||||||
) {
|
) {
|
||||||
showLogo = settings.showTitleLogo
|
showLogo = settings.showTitleLogo
|
||||||
@@ -325,6 +385,7 @@ fun SettingsSheet(
|
|||||||
showTenMinuteReminder = settings.showTenMinuteReminder
|
showTenMinuteReminder = settings.showTenMinuteReminder
|
||||||
seekInterval = settings.seekIntervalSeconds
|
seekInterval = settings.seekIntervalSeconds
|
||||||
skipIntroMode = settings.skipIntroMode
|
skipIntroMode = settings.skipIntroMode
|
||||||
|
speedUpCredits = settings.speedUpCredits
|
||||||
ringColor = settings.ringColorHex
|
ringColor = settings.ringColorHex
|
||||||
homeSections = settings.homeSections.split(',').toSet()
|
homeSections = settings.homeSections.split(',').toSet()
|
||||||
cardDensity = settings.homeCardDensity
|
cardDensity = settings.homeCardDensity
|
||||||
@@ -349,6 +410,7 @@ fun SettingsSheet(
|
|||||||
showTenMinuteReminder = showTenMinuteReminder,
|
showTenMinuteReminder = showTenMinuteReminder,
|
||||||
seekIntervalSeconds = seekInterval,
|
seekIntervalSeconds = seekInterval,
|
||||||
skipIntroMode = skipIntroMode,
|
skipIntroMode = skipIntroMode,
|
||||||
|
speedUpCredits = speedUpCredits,
|
||||||
ringColor = ringColor,
|
ringColor = ringColor,
|
||||||
homeSections = homeSections,
|
homeSections = homeSections,
|
||||||
cardDensity = cardDensity,
|
cardDensity = cardDensity,
|
||||||
@@ -358,16 +420,24 @@ fun SettingsSheet(
|
|||||||
showRatingsStrip = showRatingsStrip,
|
showRatingsStrip = showRatingsStrip,
|
||||||
hideWatchedMovies = hideWatchedMovies,
|
hideWatchedMovies = hideWatchedMovies,
|
||||||
welcomeQuoteStyle = welcomeQuoteStyle,
|
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,
|
selectedPage = selectedPage,
|
||||||
checking = checking,
|
|
||||||
updateStatus = status,
|
|
||||||
installMessage = installMessage,
|
|
||||||
installedVersion = checker.installedVersion,
|
installedVersion = checker.installedVersion,
|
||||||
devices = devices,
|
devices = devices,
|
||||||
devicesLoading = devicesLoading,
|
devicesLoading = devicesLoading,
|
||||||
devicesError = devicesError,
|
devicesError = devicesError,
|
||||||
removingDeviceId = removingDeviceId,
|
removingDeviceId = removingDeviceId,
|
||||||
pendingRemovalDeviceId = pendingRemovalDeviceId,
|
pendingRemovalDeviceId = pendingRemovalDeviceId,
|
||||||
|
imageCacheSize = imageCacheSize,
|
||||||
|
imageCacheClearing = imageCacheClearing,
|
||||||
|
imageCacheCleared = imageCacheCleared,
|
||||||
)
|
)
|
||||||
val actions = SettingsPanelActions(
|
val actions = SettingsPanelActions(
|
||||||
onClose = onClose,
|
onClose = onClose,
|
||||||
@@ -391,6 +461,10 @@ fun SettingsSheet(
|
|||||||
skipIntroMode = it
|
skipIntroMode = it
|
||||||
scope.launch { store.setSkipIntroMode(it) }
|
scope.launch { store.setSkipIntroMode(it) }
|
||||||
},
|
},
|
||||||
|
onSpeedUpCreditsChanged = {
|
||||||
|
speedUpCredits = it
|
||||||
|
scope.launch { store.setSpeedUpCredits(it) }
|
||||||
|
},
|
||||||
onRingColorChanged = {
|
onRingColorChanged = {
|
||||||
ringColor = it
|
ringColor = it
|
||||||
scope.launch { store.setRingColor(it) }
|
scope.launch { store.setRingColor(it) }
|
||||||
@@ -405,7 +479,12 @@ fun SettingsSheet(
|
|||||||
},
|
},
|
||||||
onArtworkStyleChanged = {
|
onArtworkStyleChanged = {
|
||||||
artworkStyle = it
|
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 = {
|
onRestoreHiddenRows = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -428,41 +507,18 @@ fun SettingsSheet(
|
|||||||
hideWatchedMovies = it
|
hideWatchedMovies = it
|
||||||
scope.launch { store.setHideWatchedMovies(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 = {
|
onWelcomeQuoteStyleChanged = {
|
||||||
welcomeQuoteStyle = it
|
welcomeQuoteStyle = it
|
||||||
scope.launch { store.setWelcomeQuoteStyle(it) }
|
scope.launch { store.setWelcomeQuoteStyle(it) }
|
||||||
},
|
},
|
||||||
onPageSelected = { selectedPage = 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 = {
|
onRefreshDevices = {
|
||||||
deviceJob?.cancel()
|
deviceJob?.cancel()
|
||||||
deviceJob = scope.launch { refreshDevices() }
|
deviceJob = scope.launch { refreshDevices() }
|
||||||
@@ -496,13 +552,22 @@ fun SettingsSheet(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCancelDeviceRemoval = { pendingRemovalDeviceId = null },
|
onCancelDeviceRemoval = { pendingRemovalDeviceId = null },
|
||||||
onOpenSourceCode = {
|
onClearImageCache = clearCache@{
|
||||||
runCatching {
|
if (imageCacheClearing) return@clearCache
|
||||||
context.startActivity(
|
imageCacheClearing = true
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.SOURCE_CODE_URL)).apply {
|
imageCacheCleared = false
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
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,
|
navigationFocusRequester: FocusRequester? = null,
|
||||||
) {
|
) {
|
||||||
val contentScrollState = rememberScrollState()
|
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) {
|
LaunchedEffect(state.selectedPage) {
|
||||||
contentScrollState.scrollTo(0)
|
contentScrollState.scrollTo(0)
|
||||||
@@ -604,6 +676,7 @@ internal fun SettingsPanelContent(
|
|||||||
onClose = actions.onClose,
|
onClose = actions.onClose,
|
||||||
firstFocusRequester = firstFocusRequester,
|
firstFocusRequester = firstFocusRequester,
|
||||||
navigationFocusRequester = navigationFocusRequester,
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
contentFocusRequester = contentFocusRequester,
|
||||||
compact = overlay,
|
compact = overlay,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(if (overlay) 190.dp else 224.dp)
|
.width(if (overlay) 190.dp else 224.dp)
|
||||||
@@ -613,6 +686,11 @@ internal fun SettingsPanelContent(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxHeight()
|
.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)
|
.verticalScroll(contentScrollState)
|
||||||
.padding(
|
.padding(
|
||||||
start = if (overlay) 26.dp else 42.dp,
|
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),
|
verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp),
|
||||||
) {
|
) {
|
||||||
SettingsHeader(
|
SettingsHeader(page = state.selectedPage)
|
||||||
page = state.selectedPage,
|
|
||||||
version = state.installedVersion,
|
|
||||||
)
|
|
||||||
when (state.selectedPage) {
|
when (state.selectedPage) {
|
||||||
SettingsPage.APPEARANCE -> SettingsGroup {
|
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(
|
SettingsToggleRow(
|
||||||
title = "Show logos",
|
title = "Show logos",
|
||||||
description = "Use a film or show's own logo instead of plain text.",
|
description = "Use a film or show's own logo instead of plain text.",
|
||||||
@@ -642,6 +731,26 @@ internal fun SettingsPanelContent(
|
|||||||
selected = state.ringColor,
|
selected = state.ringColor,
|
||||||
onSelected = actions.onRingColorChanged,
|
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 {
|
SettingsPage.PLAYBACK -> SettingsGroup {
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
@@ -666,6 +775,14 @@ internal fun SettingsPanelContent(
|
|||||||
onSelected = actions.onSkipIntroModeChanged,
|
onSelected = actions.onSkipIntroModeChanged,
|
||||||
)
|
)
|
||||||
SettingDivider()
|
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(
|
SettingsChoiceRow(
|
||||||
title = "Skip with left and right",
|
title = "Skip with left and right",
|
||||||
description = "How far one press moves what you are watching.",
|
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 {
|
SettingsPage.DEVICES -> SettingsGroup {
|
||||||
when {
|
when {
|
||||||
state.devicesLoading && state.devices.isEmpty() ->
|
state.devicesLoading && state.devices.isEmpty() ->
|
||||||
@@ -828,6 +883,36 @@ internal fun SettingsPanelContent(
|
|||||||
onClick = actions.onRefreshDevices,
|
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 {
|
SettingsPage.ABOUT -> SettingsGroup {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
@@ -853,13 +938,6 @@ internal fun SettingsPanelContent(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
SettingDivider()
|
|
||||||
SettingsActionRow(
|
|
||||||
title = "Source code",
|
|
||||||
description = BuildConfig.SOURCE_CODE_URL,
|
|
||||||
badge = "OPEN",
|
|
||||||
onClick = actions.onOpenSourceCode,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.selectedPage == SettingsPage.ABOUT) {
|
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
|
@Composable
|
||||||
private fun SettingsSecondaryRail(
|
private fun SettingsSecondaryRail(
|
||||||
selected: SettingsPage,
|
selected: SettingsPage,
|
||||||
@@ -1038,11 +1130,22 @@ private fun SettingsSecondaryRail(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
firstFocusRequester: FocusRequester?,
|
firstFocusRequester: FocusRequester?,
|
||||||
navigationFocusRequester: FocusRequester?,
|
navigationFocusRequester: FocusRequester?,
|
||||||
|
contentFocusRequester: FocusRequester,
|
||||||
compact: Boolean,
|
compact: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val pages = SettingsPage.entries.filter { it.showInRail }
|
val pages = SettingsPage.entries.filter { it.showInRail }
|
||||||
val selectedRailPage = selected
|
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
|
// 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
|
// 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
|
// 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.
|
// settings surface and activate home content.
|
||||||
val railFocusRequesters = remember(firstFocusRequester) {
|
val railFocusRequesters = remember(firstFocusRequester) {
|
||||||
buildList {
|
buildList {
|
||||||
add(FocusRequester()) // Exit
|
add(FocusRequester()) // Back
|
||||||
pages.forEach { page ->
|
pages.forEach { page ->
|
||||||
add(
|
add(
|
||||||
if (page == selectedRailPage && firstFocusRequester != null) {
|
if (page == selectedRailPage && firstFocusRequester != null) {
|
||||||
@@ -1089,16 +1192,17 @@ private fun SettingsSecondaryRail(
|
|||||||
letterSpacing = 1.4.sp,
|
letterSpacing = 1.4.sp,
|
||||||
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp),
|
modifier = Modifier.padding(start = 12.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
SettingsExitRailItem(
|
SettingsBackRailItem(
|
||||||
compact = compact,
|
compact = compact,
|
||||||
onClick = onClose,
|
onClick = onClose,
|
||||||
focusRequester = railFocusRequesters[0],
|
focusRequester = railFocusRequesters[0],
|
||||||
downFocusRequester = railFocusRequesters[1],
|
downFocusRequester = railFocusRequesters[1],
|
||||||
leftFocusRequester = navigationFocusRequester,
|
leftFocusRequester = navigationFocusRequester,
|
||||||
|
rightFocusRequester = contentFocusRequester,
|
||||||
)
|
)
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
val active = page == selectedRailPage
|
val active = page == focusedPage
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1132,13 +1236,37 @@ private fun SettingsSecondaryRail(
|
|||||||
// screen stays composed behind this panel and a covered card can
|
// screen stays composed behind this panel and a covered card can
|
||||||
// otherwise win the focus search.
|
// otherwise win the focus search.
|
||||||
left = navigationFocusRequester ?: FocusRequester.Cancel
|
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 {
|
.onFocusChanged {
|
||||||
focused = it.isFocused
|
focused = it.isFocused
|
||||||
if (it.isFocused && page != selected) onSelected(page)
|
if (it.isFocused) focusedPage = page
|
||||||
}
|
}
|
||||||
.testTag("settings-rail-${page.name.lowercase()}")
|
.testTag("settings-rail-${page.name.lowercase()}")
|
||||||
.clickable { onSelected(page) }
|
.clickable {
|
||||||
|
focusedPage = page
|
||||||
|
onSelected(page)
|
||||||
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 12.dp),
|
.padding(horizontal = 12.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
@@ -1174,12 +1302,13 @@ private fun SettingsSecondaryRail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsExitRailItem(
|
private fun SettingsBackRailItem(
|
||||||
compact: Boolean,
|
compact: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
downFocusRequester: FocusRequester,
|
downFocusRequester: FocusRequester,
|
||||||
leftFocusRequester: FocusRequester?,
|
leftFocusRequester: FocusRequester?,
|
||||||
|
rightFocusRequester: FocusRequester,
|
||||||
) {
|
) {
|
||||||
var focused by remember { mutableStateOf(false) }
|
var focused by remember { mutableStateOf(false) }
|
||||||
Row(
|
Row(
|
||||||
@@ -1197,6 +1326,7 @@ private fun SettingsExitRailItem(
|
|||||||
up = FocusRequester.Cancel
|
up = FocusRequester.Cancel
|
||||||
down = downFocusRequester
|
down = downFocusRequester
|
||||||
left = leftFocusRequester ?: FocusRequester.Cancel
|
left = leftFocusRequester ?: FocusRequester.Cancel
|
||||||
|
right = rightFocusRequester
|
||||||
}
|
}
|
||||||
.onFocusChanged { focused = it.isFocused }
|
.onFocusChanged { focused = it.isFocused }
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
@@ -1205,13 +1335,13 @@ private fun SettingsExitRailItem(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Close,
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (focused) Canvas else TextSecondary,
|
tint = if (focused) Canvas else TextSecondary,
|
||||||
modifier = Modifier.size(19.dp),
|
modifier = Modifier.size(19.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Exit",
|
"Back",
|
||||||
color = if (focused) Canvas else TextPrimary,
|
color = if (focused) Canvas else TextPrimary,
|
||||||
fontSize = if (compact) 13.sp else 14.sp,
|
fontSize = if (compact) 13.sp else 14.sp,
|
||||||
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium,
|
||||||
@@ -1221,7 +1351,7 @@ private fun SettingsExitRailItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsHeader(page: SettingsPage, version: String) {
|
private fun SettingsHeader(page: SettingsPage) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
// Indented to the rows' own text rather than to the panel edge. With the section
|
// 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
|
// 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
|
// 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.
|
// 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)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
|
||||||
Text(
|
Text(
|
||||||
@@ -1242,8 +1376,6 @@ private fun SettingsHeader(page: SettingsPage, version: String) {
|
|||||||
fontSize = 13.sp,
|
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
|
@Composable
|
||||||
private fun SettingsChoiceRow(
|
private fun SettingsChoiceRow(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -1512,7 +1708,7 @@ private fun VersionHistorySection(
|
|||||||
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
|
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
|
||||||
mutableStateOf(releases.firstOrNull()?.version)
|
mutableStateOf(releases.firstOrNull()?.version)
|
||||||
}
|
}
|
||||||
SettingsGroup(label = "What changed in each release") {
|
SettingsGroup(label = "Changelog") {
|
||||||
if (releases.isEmpty()) {
|
if (releases.isEmpty()) {
|
||||||
SettingsNotice("No release history shipped with this build.", positive = false)
|
SettingsNotice("No release history shipped with this build.", positive = false)
|
||||||
return@SettingsGroup
|
return@SettingsGroup
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.ponzischeme89.memby.ui.theme
|
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.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
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
|
* 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
|
* 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.
|
* 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. */
|
/** 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]. */
|
/** 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. */
|
/** The single accent. Emby's green on the default theme; a theme may make it anything. */
|
||||||
val MembyAccent = Color(0xFF52B54B)
|
val MembyAccent: Color get() = activePalette.accent
|
||||||
|
|
||||||
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
|
/** 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 and tertiary copy, raised for TV viewing distance: quiet still reads as
|
||||||
* secondary without falling into low-contrast grey-on-black.
|
* secondary without falling into low-contrast grey-on-black.
|
||||||
*/
|
*/
|
||||||
val MembyMutedText = Color(0xFFD0D6DB)
|
val MembyMutedText: Color get() = activePalette.mutedText
|
||||||
val MembyQuietText = Color(0xFFAEB7BF)
|
val MembyQuietText: Color get() = activePalette.quietText
|
||||||
|
|
||||||
/** Hairline rules and unfocused borders. */
|
/** Hairline rules and unfocused borders. */
|
||||||
val MembyHairline = Color(0x28FFFFFF)
|
val MembyHairline: Color get() = activePalette.hairline
|
||||||
|
|
||||||
/** A quiet neutral capsule behind third-party ratings. */
|
/** A quiet neutral capsule behind third-party ratings. */
|
||||||
val MembyRatingsSurface = Color(0xFF20252A)
|
val MembyRatingsSurface: Color get() = activePalette.ratingsSurface
|
||||||
|
|
||||||
// --- Shape ---------------------------------------------------------------------------
|
// --- Shape ---------------------------------------------------------------------------
|
||||||
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
|
// 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. */
|
/** Chips, badges and small controls. */
|
||||||
val MembyChipCorner = 8.dp
|
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
|
// 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
|
// a component that falls back to a theme colour lands on the surface it is sitting on
|
||||||
// rather than one shade beside it.
|
// 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,
|
primary = MembyAccent,
|
||||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||||
surface = MembySurfaceRaised,
|
surface = MembySurfaceRaised,
|
||||||
@@ -36,7 +41,7 @@ fun MembyTheme(content: @Composable () -> Unit) {
|
|||||||
letterSpacing = 0.006.em,
|
letterSpacing = 0.006.em,
|
||||||
lineHeight = 1.22.em,
|
lineHeight = 1.22.em,
|
||||||
)
|
)
|
||||||
MaterialTheme(colorScheme = EmbyColors) {
|
MaterialTheme(colorScheme = embyColors()) {
|
||||||
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
|
CompositionLocalProvider(LocalTextStyle provides appTextStyle) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,22 +54,7 @@ internal fun whatsNewDecision(
|
|||||||
return WhatsNewDecision.Show(release)
|
return WhatsNewDecision.Show(release)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One changelog bullet, split into its optional leading label and the sentence itself. */
|
// A bullet's leading "Fixed:" / "Added:" used to be split off into an accent pill. Both
|
||||||
internal data class ChangeLine(val tag: String?, val text: String)
|
// 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.
|
||||||
* 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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.UpdateButton
|
||||||
import com.ponzischeme89.memby.ui.settings.ReleaseNote
|
import com.ponzischeme89.memby.ui.settings.ReleaseNote
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
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.MembyMutedText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
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 =
|
internal fun maxChangesFor(availableHeightDp: Int): Int =
|
||||||
((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES)
|
((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)
|
private val Scrim = Color(0xE60A0C0F)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +164,7 @@ internal fun WhatsNewOverlay(
|
|||||||
modifier = Modifier.widthIn(max = 620.dp),
|
modifier = Modifier.widthIn(max = 620.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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) {
|
if (release.changes.size > shown) {
|
||||||
@@ -205,25 +200,20 @@ 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
|
@Composable
|
||||||
private fun ChangeRow(line: ChangeLine) {
|
private fun ChangeRow(change: String) {
|
||||||
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
@@ -231,8 +221,6 @@ private fun ChangeRow(line: ChangeLine) {
|
|||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MembyAccent.copy(alpha = 0.7f)),
|
.background(MembyAccent.copy(alpha = 0.7f)),
|
||||||
)
|
)
|
||||||
}
|
Text(change, color = MembyMutedText, fontSize = 16.sp)
|
||||||
}
|
|
||||||
Text(line.text, 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. -->
|
nothing to say about what comes next. -->
|
||||||
<include layout="@layout/player_next_up_banner" />
|
<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
|
<!-- App-owned subtitle controls. ExoPlayer supplies tracks and rendering only; this
|
||||||
overlay deliberately stays in Memby's TV design language. -->
|
overlay deliberately stays in Memby's TV design language. -->
|
||||||
<include layout="@layout/player_subtitle_overlay" />
|
<include layout="@layout/player_subtitle_overlay" />
|
||||||
|
|||||||
@@ -261,4 +261,25 @@
|
|||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</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>
|
</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:textSize="11sp"
|
||||||
android:textStyle="bold" />
|
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
|
<ScrollView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -32,10 +32,21 @@
|
|||||||
<string name="player_subtitle_download">GET SUBTITLES</string>
|
<string name="player_subtitle_download">GET SUBTITLES</string>
|
||||||
<string name="player_subtitle_search">Search for subtitles…</string>
|
<string name="player_subtitle_search">Search for subtitles…</string>
|
||||||
<string name="player_subtitle_search_again">Search again</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_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_search_empty">No subtitles were found for this release.</string>
|
||||||
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</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_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_cast_open">Cast</string>
|
||||||
<string name="player_back_to_close">BACK · CLOSE</string>
|
<string name="player_back_to_close">BACK · CLOSE</string>
|
||||||
<string name="player_ends_at">Ends at %1$s</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_play_now">Play now</string>
|
||||||
<string name="next_up_dismiss">Dismiss</string>
|
<string name="next_up_dismiss">Dismiss</string>
|
||||||
<string name="next_up_starting_in">Starting in %1$ds</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="next_up_starting_now">Starting now…</string>
|
||||||
<string name="app_name">Memby</string>
|
<string name="app_name">Memby</string>
|
||||||
<string name="screensaver_name">Memby Screensaver</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)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class SettingsStoreProfileRemovalTest {
|
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
|
@Test
|
||||||
fun `ten minute reminder preference is persisted`() = runBlocking {
|
fun `ten minute reminder preference is persisted`() = runBlocking {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
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))
|
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
|
@Test
|
||||||
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
|
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
|
||||||
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
|
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")),
|
tracks = listOf(entry("Off", selected = true), entry("Deutsch")),
|
||||||
downloads = SubtitleDownloadState(
|
downloads = SubtitleDownloadState(
|
||||||
available = true,
|
available = true,
|
||||||
entries = listOf(entry("Search for subtitles…")),
|
entries = listOf(entry("Search for subtitles…", prominent = true)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,9 @@ class SubtitleMenuScreenshotTest {
|
|||||||
available = true,
|
available = true,
|
||||||
status = "Looking for subtitles. This can take a moment.",
|
status = "Looking for subtitles. This can take a moment.",
|
||||||
expanded = true,
|
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,
|
available = true,
|
||||||
expanded = true,
|
expanded = true,
|
||||||
entries = listOf(
|
entries = listOf(
|
||||||
entry("Search again"),
|
entry("Search again", prominent = true),
|
||||||
entry("English · 98% match"),
|
entry("English · 98% match"),
|
||||||
entry("English · Hearing impaired · 96% match"),
|
entry("English · Hearing impaired · 96% match"),
|
||||||
entry("English · Forced · 91% match"),
|
entry("English · Forced · 91% match"),
|
||||||
@@ -158,12 +160,47 @@ class SubtitleMenuScreenshotTest {
|
|||||||
available = true,
|
available = true,
|
||||||
status = "No subtitles were found for this release.",
|
status = "No subtitles were found for this release.",
|
||||||
expanded = true,
|
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(
|
private fun capture(
|
||||||
name: String,
|
name: String,
|
||||||
@@ -172,6 +209,7 @@ class SubtitleMenuScreenshotTest {
|
|||||||
focusedSize: Int? = null,
|
focusedSize: Int? = null,
|
||||||
focusedDownload: Int? = null,
|
focusedDownload: Int? = null,
|
||||||
downloads: SubtitleDownloadState = SubtitleDownloadState(),
|
downloads: SubtitleDownloadState = SubtitleDownloadState(),
|
||||||
|
tracksState: SubtitleTracksState = SubtitleTracksState(),
|
||||||
) {
|
) {
|
||||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
val root = FrameLayout(activity)
|
val root = FrameLayout(activity)
|
||||||
@@ -200,6 +238,7 @@ class SubtitleMenuScreenshotTest {
|
|||||||
tracks = tracks,
|
tracks = tracks,
|
||||||
sizes = listOf(entry("Small"), entry("Medium", selected = true), entry("Large")),
|
sizes = listOf(entry("Small"), entry("Medium", selected = true), entry("Large")),
|
||||||
downloads = downloads,
|
downloads = downloads,
|
||||||
|
tracksState = tracksState,
|
||||||
)
|
)
|
||||||
activity.setContentView(root)
|
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.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.ponzischeme89.memby.data.ImageCacheSize
|
||||||
import com.ponzischeme89.memby.ui.PreviewSurface
|
import com.ponzischeme89.memby.ui.PreviewSurface
|
||||||
import com.ponzischeme89.memby.update.UpdateStatus
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -68,12 +69,17 @@ class SettingsSheetScreenshotTest {
|
|||||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-home.png")
|
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
|
@Test
|
||||||
fun `welcome tone options`() {
|
fun `welcome tone options`() {
|
||||||
compose.setContent {
|
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
|
@Test
|
||||||
@@ -85,11 +91,11 @@ class SettingsSheetScreenshotTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `updates page`() {
|
fun `stored artwork page`() {
|
||||||
compose.setContent {
|
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
|
@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
|
@Test
|
||||||
fun `about lists the release history and opens one release`() {
|
fun `about lists the release history and opens one release`() {
|
||||||
compose.setContent { InteractiveSettingsFixture() }
|
compose.setContent { InteractiveSettingsFixture() }
|
||||||
@@ -128,21 +177,82 @@ class SettingsSheetScreenshotTest {
|
|||||||
compose.onRoot().captureRoboImage("build/screenshots/settings/settings-about.png")
|
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
|
@Composable
|
||||||
private fun InteractiveSettingsFixture() {
|
private fun InteractiveSettingsFixture(onToggled: (String) -> Unit = {}) {
|
||||||
var selectedPage by remember { mutableStateOf(SettingsPage.APPEARANCE) }
|
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() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(170)
|
delay(170)
|
||||||
firstFocus.requestFocus()
|
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 {
|
PreviewSurface {
|
||||||
SettingsPanelContent(
|
SettingsPanelContent(
|
||||||
state = SettingsPanelState(
|
state = SettingsPanelState(
|
||||||
|
showLogo = showLogo,
|
||||||
|
autoPlayNext = autoPlayNext,
|
||||||
|
showTenMinuteReminder = tenMinutes,
|
||||||
|
speedUpCredits = speedUpCredits,
|
||||||
|
hideWatchedMovies = hideWatched,
|
||||||
|
showCardMetadata = cardMetadata,
|
||||||
|
showRatingsStrip = ratings,
|
||||||
|
homeSections = homeSections,
|
||||||
selectedPage = selectedPage,
|
selectedPage = selectedPage,
|
||||||
installedVersion = "0.1.60",
|
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,
|
overlay = false,
|
||||||
firstFocusRequester = firstFocus,
|
firstFocusRequester = firstFocus,
|
||||||
)
|
)
|
||||||
@@ -152,7 +262,7 @@ class SettingsSheetScreenshotTest {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SettingsPreviewFixture(
|
private fun SettingsPreviewFixture(
|
||||||
overlay: Boolean,
|
overlay: Boolean,
|
||||||
selectedPage: SettingsPage = if (overlay) SettingsPage.UPDATES else SettingsPage.APPEARANCE,
|
selectedPage: SettingsPage = if (overlay) SettingsPage.DEVICES else SettingsPage.APPEARANCE,
|
||||||
) {
|
) {
|
||||||
val firstFocus = remember { FocusRequester() }
|
val firstFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
LaunchedEffect(Unit) { firstFocus.requestFocus() }
|
||||||
@@ -168,16 +278,13 @@ class SettingsSheetScreenshotTest {
|
|||||||
showCardMetadata = false,
|
showCardMetadata = false,
|
||||||
welcomeQuoteStyle = "homicidal",
|
welcomeQuoteStyle = "homicidal",
|
||||||
selectedPage = selectedPage,
|
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",
|
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(),
|
actions = SettingsPanelActions(),
|
||||||
overlay = overlay,
|
overlay = overlay,
|
||||||
|
|||||||
@@ -62,12 +62,6 @@ class WhatsNewTest {
|
|||||||
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
|
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
|
@Test
|
||||||
fun `the bullet count is cut to what the screen can hold`() {
|
fun `the bullet count is cut to what the screen can hold`() {
|
||||||
// A 720p television, which is the small case this exists for.
|
// 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.
|
// Never zero: a panel with a heading and nothing under it says nothing at all.
|
||||||
assertEquals(1, maxChangesFor(200))
|
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))
|
s.adminAuth(s.handleAdminRestorePreferences))
|
||||||
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
|
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}/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/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
||||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
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/events", s.adminAuth(s.handleAdminEvents))
|
||||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||||
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
|
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/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||||
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
|
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/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/features", s.adminAuth(s.handleAdminFeaturePolicy))
|
||||||
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
|
||||||
|
|
||||||
@@ -241,6 +245,7 @@ type adminStatus struct {
|
|||||||
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
|
||||||
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
|
||||||
MDBList mdblistAdminSettings `json:"mdblist"`
|
MDBList mdblistAdminSettings `json:"mdblist"`
|
||||||
|
Subtitles subtitleAdminSettings `json:"subtitles"`
|
||||||
Features featureResponse `json:"features"`
|
Features featureResponse `json:"features"`
|
||||||
RequestUsers []store.KnownUser `json:"requestUsers"`
|
RequestUsers []store.KnownUser `json:"requestUsers"`
|
||||||
Clients []store.KnownClient `json:"clients"`
|
Clients []store.KnownClient `json:"clients"`
|
||||||
@@ -304,6 +309,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return policy
|
return policy
|
||||||
}(),
|
}(),
|
||||||
|
Subtitles: s.subtitleAdminSettings(ctx),
|
||||||
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
|
||||||
RequestUsers: requestUsers,
|
RequestUsers: requestUsers,
|
||||||
Clients: clients,
|
Clients: clients,
|
||||||
|
|||||||
@@ -303,6 +303,22 @@ select { padding-right: 8px; }
|
|||||||
.checks { display: grid; gap: 8px; }
|
.checks { display: grid; gap: 8px; }
|
||||||
.checks.columns { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
|
.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; }
|
.hint { color: var(--muted); font-size: 12.5px; margin: 0; }
|
||||||
|
|
||||||
/* ---------- tags, notices ---------- */
|
/* ---------- 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',
|
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',
|
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',
|
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]
|
const icon = (name) => (icons[name]
|
||||||
|
|||||||
@@ -46,6 +46,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2 class="card-title" data-icon="alert" data-icon-tone="bad">Remove Memby access</h2>
|
<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.
|
// endpoint that would return a slice of the same query.
|
||||||
let catalogue = [];
|
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
|
// 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
|
// 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.
|
// 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('');
|
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 ---------------------------------------------- */
|
/* ---- the rest of the page ---------------------------------------------- */
|
||||||
|
|
||||||
// Every build this set has been seen running, newest first and the current one flagged.
|
// 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';
|
$('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings';
|
||||||
const payload = await Admin.api('/admin/api/accounts');
|
const payload = await Admin.api('/admin/api/accounts');
|
||||||
catalogue = payload.catalogue || catalogue;
|
catalogue = payload.catalogue || catalogue;
|
||||||
|
themeCatalogue = payload.themes || themeCatalogue;
|
||||||
const account = (payload.accounts || []).find((entry) => entry.id === userId);
|
const account = (payload.accounts || []).find((entry) => entry.id === userId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
$('account-identity').innerHTML =
|
$('account-identity').innerHTML =
|
||||||
@@ -209,13 +259,21 @@ Admin.onRefresh(async () => {
|
|||||||
renderDevices(account);
|
renderDevices(account);
|
||||||
renderRecommendations(account);
|
renderRecommendations(account);
|
||||||
if (!dirty) renderSettings(account);
|
if (!dirty) renderSettings(account);
|
||||||
|
if (!themesDirty) renderThemes(account);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ---- actions ------------------------------------------------------------ */
|
/* ---- actions ------------------------------------------------------------ */
|
||||||
|
|
||||||
function message(text) { $('account-settings-message').textContent = text || ''; }
|
function message(text) { $('account-settings-message').textContent = text || ''; }
|
||||||
|
|
||||||
|
function themeMessage(text) { $('account-themes-message').textContent = text || ''; }
|
||||||
|
|
||||||
document.addEventListener('input', (event) => {
|
document.addEventListener('input', (event) => {
|
||||||
|
if ($('account-themes').contains(event.target)) {
|
||||||
|
themesDirty = true;
|
||||||
|
themeMessage('unsaved changes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!$('account-settings').contains(event.target)) return;
|
if (!$('account-settings').contains(event.target)) return;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
message('unsaved changes');
|
message('unsaved changes');
|
||||||
@@ -270,6 +328,28 @@ document.addEventListener('click', (event) => {
|
|||||||
message('pushed');
|
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') {
|
if (action === 'reload-preferences') {
|
||||||
dirty = false;
|
dirty = false;
|
||||||
message('');
|
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
|
// the defaults, and saying so is the difference between "chose this" and "has not
|
||||||
// chosen anything".
|
// chosen anything".
|
||||||
Settings adminAccountSettings `json:"settings"`
|
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 {
|
type adminAccountSettings struct {
|
||||||
@@ -87,6 +92,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
settings = map[string]store.UserPreferences{}
|
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))
|
result := make([]adminMembyAccount, 0, len(accounts))
|
||||||
for _, account := range accounts {
|
for _, account := range accounts {
|
||||||
pref := preferences[account.ID]
|
pref := preferences[account.ID]
|
||||||
@@ -115,6 +128,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
|
|||||||
result = append(result, adminMembyAccount{
|
result = append(result, adminMembyAccount{
|
||||||
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
|
||||||
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
|
||||||
|
Themes: nonNilStrings(themes[account.ID]),
|
||||||
Recommendations: adminOnboardingPreferences{
|
Recommendations: adminOnboardingPreferences{
|
||||||
Completed: pref.Completed, Prompted: pref.Prompted,
|
Completed: pref.Completed, Prompted: pref.Prompted,
|
||||||
Updated: len(account.RecommendationPreferences) > 2,
|
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{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"accounts": result, "catalogue": preferenceCatalogue,
|
"accounts": result, "catalogue": preferenceCatalogue,
|
||||||
"schemaVersion": preferenceSchemaVersion,
|
"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.",
|
Intro: "Presentation policy sent with every playback launch.",
|
||||||
Icon: "M8 5v14l11-7zM4 5v14",
|
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",
|
ID: "updates", Label: "App updates", Title: "App updates",
|
||||||
Intro: "Publish an optional or a required client update.",
|
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.",
|
Intro: "Impressions, focus, dwell and selections per launcher row.",
|
||||||
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
|
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",
|
ID: "logs", Label: "Server logs", Title: "Server logs",
|
||||||
Intro: "Structured gateway events as they happen.",
|
Intro: "Structured gateway events as they happen.",
|
||||||
|
|||||||
@@ -167,5 +167,26 @@ func adminPreviewData() map[string]any {
|
|||||||
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
|
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
|
||||||
}},
|
}},
|
||||||
"/admin/api/requests": map[string]any{"requests": []any{}},
|
"/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"
|
"github.com/ponzischeme89/memby/server/internal/foryou"
|
||||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||||
"github.com/ponzischeme89/memby/server/internal/mdblist"
|
"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/radarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
"github.com/ponzischeme89/memby/server/internal/sonarr"
|
||||||
@@ -50,8 +51,18 @@ type Server struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
events *serverlogging.Buffer
|
events *serverlogging.Buffer
|
||||||
sonarrMu sync.Mutex
|
sonarrMu 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
|
radarrMu sync.Mutex
|
||||||
bazarrMu 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
|
mdblistMu sync.Mutex
|
||||||
// mdblistSettingsCache spares every row and keystroke a settings read.
|
// mdblistSettingsCache spares every row and keystroke a settings read.
|
||||||
mdblistSettingsCache mdblistSettingsCache
|
mdblistSettingsCache mdblistSettingsCache
|
||||||
@@ -125,6 +136,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||||||
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||||||
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
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("GET /v1/search/history", s.authed(s.handleRecentSearches))
|
||||||
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
|
||||||
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
|
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))
|
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
|
||||||
// A viewer's settings follow the person, not the television. Both verbs land on one
|
// 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.
|
// 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("GET /v1/preferences", s.authed(s.handlePreferences))
|
||||||
v1.Handle("PUT /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("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}/favorite", s.authed(s.handleFavorite))
|
||||||
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
|
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}/playback", s.authed(s.handlePlayback))
|
||||||
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
|
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("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
|
||||||
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
|
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}/trailer", s.authed(s.handleTrailer))
|
||||||
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
|
||||||
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"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
|
// The fixed rows must keep their order, ids and kinds: the client maps kinds onto
|
||||||
// card shapes and uses ids as Compose keys.
|
// card shapes and uses ids as Compose keys.
|
||||||
func TestBaseRowsShape(t *testing.T) {
|
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"
|
featureSubtitleDownload = "subtitle_download"
|
||||||
featureTrickplay = "trickplay"
|
featureTrickplay = "trickplay"
|
||||||
featureSkipIntro = "skip_intro"
|
featureSkipIntro = "skip_intro"
|
||||||
|
featureEndCredits = "end_credits"
|
||||||
|
featureSeasonalThemes = "seasonal_themes"
|
||||||
|
featureSeasonalDecorations = "seasonal_decorations"
|
||||||
)
|
)
|
||||||
|
|
||||||
type featureDefinition struct {
|
type featureDefinition struct {
|
||||||
@@ -80,6 +83,40 @@ var featureCatalogue = []featureDefinition{
|
|||||||
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
|
||||||
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
|
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",
|
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 " +
|
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)
|
limit := queryInt(r, "limit", 40, 100)
|
||||||
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
|
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 {
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
writeRaw(w, http.StatusOK, raw)
|
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)
|
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 {
|
type searchHistoryRequest struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
}
|
}
|
||||||
@@ -602,7 +651,7 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
query := strings.TrimSpace(req.Query)
|
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")
|
writeError(w, http.StatusBadRequest, "search query length is invalid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-29
@@ -53,10 +53,29 @@ type introSegment struct {
|
|||||||
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
|
// 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
|
// 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 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 {
|
type introResponse struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
StartMs int64 `json:"startMs,omitempty"`
|
StartMs int64 `json:"startMs,omitempty"`
|
||||||
EndMs int64 `json:"endMs,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.
|
// 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")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
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{})
|
writeJSON(w, http.StatusOK, introResponse{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
segment, ok, err := s.introFor(ctx, sess, itemID)
|
markers, err := s.markersFor(ctx, sess, itemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Trouble is answered with "no intro" rather than an error. The button is an
|
// Trouble is answered with "nothing found" rather than an error. Both features are
|
||||||
// optional convenience on a film that is already playing, and a failure the viewer
|
// 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.
|
// 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{})
|
writeJSON(w, http.StatusOK, introResponse{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !markers.introFound && !markers.creditsFound {
|
||||||
writeJSON(w, http.StatusOK, introResponse{})
|
writeJSON(w, http.StatusOK, introResponse{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The same answer for everyone in the house, and it only changes when the media does.
|
// 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")
|
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||||
writeJSON(w, http.StatusOK, introResponse{
|
writeJSON(w, http.StatusOK, maskMarkers(markersResponse(markers), intro, credits))
|
||||||
Available: true,
|
}
|
||||||
StartMs: segment.StartMs,
|
|
||||||
EndMs: segment.EndMs,
|
// 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 {
|
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
|
||||||
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
|
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
|
// "Nothing found" is cached as well as a finding. It is the common case — a film, a special,
|
||||||
// episode Emby has not analysed yet — and without it every playback in the house would be
|
// an episode Emby has not analysed yet — and without it every playback in the house would
|
||||||
// a fresh request to Emby for the same no.
|
// be a fresh request to Emby for the same no.
|
||||||
func (s *Server) introFor(
|
//
|
||||||
|
// 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,
|
ctx context.Context, sess store.Session, itemID string,
|
||||||
) (introSegment, bool, error) {
|
) (chapterMarkers, error) {
|
||||||
key := introCacheKey(itemID)
|
key := introCacheKey(itemID)
|
||||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
var cached introResponse
|
var cached introResponse
|
||||||
if json.Unmarshal(raw, &cached) == nil {
|
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")
|
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
|
||||||
if err != nil {
|
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 {
|
var parsed struct {
|
||||||
Chapters []embyChapter `json:"Chapters"`
|
Chapters []embyChapter `json:"Chapters"`
|
||||||
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
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
|
ttl := introMissingTTL
|
||||||
if ok {
|
if introFound || creditsFound {
|
||||||
ttl = introTTL
|
ttl = introTTL
|
||||||
}
|
}
|
||||||
if encoded, err := json.Marshal(introResponse{
|
if encoded, err := json.Marshal(markersResponse(markers)); err == nil {
|
||||||
Available: ok,
|
|
||||||
StartMs: segment.StartMs,
|
|
||||||
EndMs: segment.EndMs,
|
|
||||||
}); err == nil {
|
|
||||||
_ = s.cache.Set(ctx, key, encoded, ttl)
|
_ = 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
series, err := s.sonarr.Series(ctx)
|
series, err := s.sonarrSeriesCatalogue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
|
s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
|
||||||
s.writeSeasonFinaleResponse(ctx, key, empty, w)
|
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
|
// 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.
|
// home request reflects it rather than serving the row it just contradicted.
|
||||||
func (s *Server) setFlag(
|
func (s *Server) setFlag(
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ func componentFor(path string) string {
|
|||||||
return "auth"
|
return "auth"
|
||||||
case path == "/v1/home", path == "/v1/features":
|
case path == "/v1/home", path == "/v1/features":
|
||||||
return "home"
|
return "home"
|
||||||
case path == "/v1/preferences":
|
case path == "/v1/preferences", path == "/v1/theme":
|
||||||
return "settings"
|
return "settings"
|
||||||
case path == "/v1/screensaver", path == "/v1/preroll":
|
case path == "/v1/screensaver", path == "/v1/preroll":
|
||||||
return "screensaver"
|
return "screensaver"
|
||||||
case strings.HasPrefix(path, "/v1/search"):
|
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"):
|
||||||
return "search"
|
return "search"
|
||||||
case strings.HasPrefix(path, "/v1/requests"):
|
case strings.HasPrefix(path, "/v1/requests"):
|
||||||
return "requests"
|
return "requests"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
|
|||||||
"/v1/auth/login": "auth",
|
"/v1/auth/login": "auth",
|
||||||
"/v1/auth/devices/tv-1": "devices",
|
"/v1/auth/devices/tv-1": "devices",
|
||||||
"/v1/search": "search",
|
"/v1/search": "search",
|
||||||
|
"/v1/genres/Comedy/items": "search",
|
||||||
"/v1/items/42": "details",
|
"/v1/items/42": "details",
|
||||||
"/v1/items/42/related": "details",
|
"/v1/items/42/related": "details",
|
||||||
"/v1/items/42/playback": "playback",
|
"/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
|
// fetches /v1/preferences when they differ. That is what turns this poll into the
|
||||||
// delivery channel for an operator pushing someone's settings.
|
// delivery channel for an operator pushing someone's settings.
|
||||||
"preferencesRevision": s.preferenceRevisionFor(r, sess),
|
"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{}
|
sonarrSeries := []sonarr.Series{}
|
||||||
if s.sonarr != nil {
|
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
|
sonarrSeries = value
|
||||||
} else {
|
} else {
|
||||||
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
|
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
|
||||||
@@ -193,7 +193,7 @@ func (s *Server) syncReturnNotifications(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
all, err := s.sonarr.Series(r.Context())
|
all, err := s.sonarrSeriesCatalogue(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ type playbackResponse struct {
|
|||||||
// already holds this response, and one boolean on a request that is made once per
|
// 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.
|
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
|
||||||
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// television asks for the segment itself once playback has settled.
|
||||||
SkipIntroAvailable bool `json:"skipIntroAvailable"`
|
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 {
|
type playableSubtitle struct {
|
||||||
@@ -205,8 +214,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
|
|||||||
PlaySessionID: playSessionID,
|
PlaySessionID: playSessionID,
|
||||||
PlayMethod: playMethod,
|
PlayMethod: playMethod,
|
||||||
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
|
||||||
|
SubtitleFixAvailable: s.subtitleFixAvailable(subtitles),
|
||||||
TrickplayAvailable: s.trickplayEnabled(ctx),
|
TrickplayAvailable: s.trickplayEnabled(ctx),
|
||||||
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
SkipIntroAvailable: s.skipIntroEnabled(ctx),
|
||||||
|
EndCreditsAvailable: s.endCreditsEnabled(ctx),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,6 +443,11 @@ func (s *Server) playbackSubtitles(
|
|||||||
Codec: stream.Codec,
|
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)
|
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
|
||||||
if delivery != "" {
|
if delivery != "" {
|
||||||
delivery = s.emby.DeliveryURL(cred, 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) == "" {
|
if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
sonarrSeries, err := s.sonarr.Series(ctx)
|
sonarrSeries, err := s.sonarrSeriesCatalogue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
|
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ var preferenceCatalogue = []preferenceDefinition{
|
|||||||
Description: "Use each title's logo artwork in place of plain text.",
|
Description: "Use each title's logo artwork in place of plain text.",
|
||||||
Kind: preferenceToggle, Default: true,
|
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",
|
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
|
||||||
Description: "Tone of the short line shown after signing in.",
|
Description: "Tone of the short line shown after signing in.",
|
||||||
@@ -143,6 +158,17 @@ var preferenceCatalogue = []preferenceDefinition{
|
|||||||
option(skipIntroOff, "Do nothing"),
|
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",
|
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
Description: "Turn a subtitle track on automatically when the title has one.",
|
||||||
|
|||||||
@@ -18,8 +18,67 @@ import (
|
|||||||
// deployment until the previous daily cache expires.
|
// deployment until the previous daily cache expires.
|
||||||
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
|
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
|
||||||
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
|
||||||
|
const sonarrSeriesCacheKey = "sonarr:series:v1"
|
||||||
const sonarrScheduleDays = 5
|
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 {
|
type prerollScheduleResponse struct {
|
||||||
Today []prerollScheduleEntry `json:"today"`
|
Today []prerollScheduleEntry `json:"today"`
|
||||||
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
ThisWeek []prerollScheduleEntry `json:"thisWeek"`
|
||||||
|
|||||||
@@ -5,31 +5,35 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
"github.com/ponzischeme89/memby/server/internal/bazarr"
|
||||||
"github.com/ponzischeme89/memby/server/internal/emby"
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetching a subtitle a title does not have.
|
// Fetching a subtitle a title does not have.
|
||||||
//
|
//
|
||||||
// The whole feature rests on one property of Bazarr: it writes the subtitle file beside
|
// Two providers answer this now and they are not the same shape. **Bazarr** writes the
|
||||||
// the media file. So the gateway never stores a subtitle, never serves one, and never
|
// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the
|
||||||
// learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and
|
// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded
|
||||||
// the track then arrives down the same PlaybackInfo path as an embedded one. That is why
|
// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back
|
||||||
// `playableSubtitle` needed no new shape and the player's existing selection rule works on
|
// bytes, and the gateway has no reach into the media directory, so a file fetched there is
|
||||||
// a downloaded track with no special case.
|
// 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
|
// The hard part is not the download, it is the identity, and the two providers make
|
||||||
// *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows
|
// opposite trades on it. Bazarr keys everything on the *arr's id (`radarrid` for a film,
|
||||||
// nothing about either, so an Emby item has to be matched onto one by title, year and —
|
// Sonarr's `episodeid` for an episode) and Emby knows nothing about either, so an item has
|
||||||
// for an episode — season and episode number. That matching is pure and unit-tested
|
// to be matched onto one by title, year and — for an episode — season and episode number.
|
||||||
// (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a
|
// That matching is pure and unit-tested (`bazarrMovieFor`, `bazarrEpisodeFor`), because it
|
||||||
// mismatch downloads a subtitle for the wrong film and writes it next to this one.
|
// 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 (
|
const (
|
||||||
bazarrMoviesCacheKey = "bazarr:movies"
|
bazarrMoviesCacheKey = "bazarr:movies"
|
||||||
@@ -53,6 +57,12 @@ const (
|
|||||||
// expire underneath them, which on a set being operated by remote control is the more
|
// expire underneath them, which on a set being operated by remote control is the more
|
||||||
// likely failure of the two.
|
// likely failure of the two.
|
||||||
type subtitleCandidate struct {
|
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"`
|
Token string `json:"token"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
LanguageLabel string `json:"languageLabel"`
|
LanguageLabel string `json:"languageLabel"`
|
||||||
@@ -61,6 +71,17 @@ type subtitleCandidate struct {
|
|||||||
Forced bool `json:"forced"`
|
Forced bool `json:"forced"`
|
||||||
HearingImpaired bool `json:"hearingImpaired"`
|
HearingImpaired bool `json:"hearingImpaired"`
|
||||||
OriginalFormat bool `json:"originalFormat"`
|
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
|
// 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
|
// older app renders a new wording correctly, the same reason alert labels are the
|
||||||
// gateway's.
|
// gateway's.
|
||||||
@@ -96,11 +117,11 @@ type subtitleDownloadResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// subtitleDownloadAvailable is the one thing the television needs to know: whether to
|
// 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
|
// offer the option at all. It is true when the feature is on and at least one provider is
|
||||||
// deployment that has Bazarr, and a deployment without Bazarr must never show a row that
|
// both configured and switched on — a deployment with neither must never draw a row that
|
||||||
// cannot do anything.
|
// leads to a request nothing can answer.
|
||||||
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
|
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) {
|
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")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.subtitleDownloadAvailable(ctx) {
|
sources := s.subtitleSources(ctx)
|
||||||
|
if !sources.any() {
|
||||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID)
|
target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
||||||
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
writeJSON(w, http.StatusOK, subtitleSearchResponse{
|
||||||
@@ -125,33 +147,32 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se
|
|||||||
return
|
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"))
|
language := strings.TrimSpace(r.URL.Query().Get("language"))
|
||||||
if language == "" {
|
if language == "" {
|
||||||
_, language = s.subtitlePreferenceFor(ctx, sess)
|
_, 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",
|
s.loggerFor(ctx).Info("subtitle search",
|
||||||
"title", target.Title,
|
"title", target.Title,
|
||||||
"item", itemID,
|
"item", itemID,
|
||||||
"language", clientLogValue(language),
|
"language", clientLogValue(language),
|
||||||
|
"bazarr", sources.Bazarr && target.hasBazarr,
|
||||||
|
"opensubtitles", sources.OpenSubtitles && target.hasQuery,
|
||||||
"found", len(found),
|
"found", len(found),
|
||||||
"offered", len(results),
|
"offered", len(results),
|
||||||
)
|
)
|
||||||
response := subtitleSearchResponse{Results: results}
|
response := subtitleSearchResponse{Results: results}
|
||||||
if len(results) == 0 {
|
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)
|
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")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.subtitleDownloadAvailable(ctx) {
|
sources := s.subtitleSources(ctx)
|
||||||
|
if !sources.any() {
|
||||||
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,45 +195,39 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
|||||||
writeError(w, http.StatusBadRequest, "malformed request body")
|
writeError(w, http.StatusBadRequest, "malformed request body")
|
||||||
return
|
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")
|
writeError(w, http.StatusBadRequest, "a subtitle is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cred := credentials(sess)
|
cred := credentials(sess)
|
||||||
target, err := s.resolveBazarrTarget(ctx, cred, itemID)
|
target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
|
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")
|
writeError(w, http.StatusNotFound, "Memby could not work out which title this is")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitle := bazarr.Subtitle{
|
fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate)
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.loggerFor(ctx).Warn("subtitle download failed",
|
s.loggerFor(ctx).Warn("subtitle download failed",
|
||||||
"title", target.Title, "item", itemID,
|
"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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bazarr has written the file; Emby does not know it exists. Refreshing is what makes
|
// Bazarr has written a file Emby does not know exists, and refreshing is what makes
|
||||||
// the track appear, and it is best-effort: if it fails the file is still on disk and
|
// the track appear. It is best-effort: if it fails the file is still on disk and the
|
||||||
// the next ordinary scan picks it up, so the viewer is told to try again rather than
|
// next ordinary scan picks it up, so the viewer is told to try again rather than told
|
||||||
// told the download failed when it did not.
|
// 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 {
|
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
|
||||||
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
|
||||||
"item", itemID, "error", refreshErr,
|
"item", itemID, "error", refreshErr,
|
||||||
@@ -222,6 +238,7 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
|
subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
|
||||||
ctx, cred, itemID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess),
|
ctx, cred, itemID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess),
|
||||||
@@ -230,24 +247,43 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request,
|
|||||||
if negotiatedURL != "" {
|
if negotiatedURL != "" {
|
||||||
streamURL = 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",
|
s.loggerFor(ctx).Info("subtitle downloaded",
|
||||||
"title", target.Title,
|
"title", target.Title,
|
||||||
"item", itemID,
|
"item", itemID,
|
||||||
"language", clientLogValue(subtitle.Language),
|
"language", clientLogValue(candidate.Language),
|
||||||
"provider", clientLogValue(subtitle.Provider),
|
"source", clientLogValue(candidate.Source),
|
||||||
|
"provider", clientLogValue(candidate.Provider),
|
||||||
|
"release", clientLogValue(candidate.Release),
|
||||||
"subtitles", len(subtitles),
|
"subtitles", len(subtitles),
|
||||||
)
|
)
|
||||||
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
|
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
|
||||||
Message: downloadedSubtitleMessage(request.Candidate),
|
Message: downloadedSubtitleMessage(candidate),
|
||||||
Subtitles: subtitles,
|
Subtitles: subtitles,
|
||||||
SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate),
|
SelectedSubtitleID: selected,
|
||||||
MediaSourceID: mediaSourceID,
|
MediaSourceID: mediaSourceID,
|
||||||
PlaySessionID: playSessionID,
|
PlaySessionID: playSessionID,
|
||||||
URL: streamURL,
|
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
|
// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
|
||||||
// RadarrID and EpisodeID is set.
|
// RadarrID and EpisodeID is set.
|
||||||
type bazarrTarget struct {
|
type bazarrTarget struct {
|
||||||
@@ -419,83 +455,6 @@ func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Epi
|
|||||||
return nil
|
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 {
|
func clampPercent(value int) int {
|
||||||
if value < 0 {
|
if value < 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) {
|
|||||||
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
|
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
|
||||||
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
|
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
|
||||||
}
|
}
|
||||||
results := rankSubtitleCandidates(found, "it")
|
results := rankMergedCandidates(bazarrCandidates(found), "it")
|
||||||
if len(results) != 4 {
|
if len(results) != 4 {
|
||||||
t.Fatalf("results = %+v", results)
|
t.Fatalf("results = %+v", results)
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) {
|
|||||||
{Language: "eng", Score: 92, Token: "high"},
|
{Language: "eng", Score: 92, Token: "high"},
|
||||||
}
|
}
|
||||||
for _, language := range []string{"", "auto"} {
|
for _, language := range []string{"", "auto"} {
|
||||||
results := rankSubtitleCandidates(found, language)
|
results := rankMergedCandidates(bazarrCandidates(found), language)
|
||||||
if len(results) != 2 || results[0].Token != "high" {
|
if len(results) != 2 || results[0].Token != "high" {
|
||||||
t.Fatalf("language %q gave %+v", language, results)
|
t.Fatalf("language %q gave %+v", language, results)
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
|
|||||||
for i := 0; i < maxSubtitleResults+5; i++ {
|
for i := 0; i < maxSubtitleResults+5; i++ {
|
||||||
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
|
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
|
||||||
}
|
}
|
||||||
results := rankSubtitleCandidates(found, "en")
|
results := rankMergedCandidates(bazarrCandidates(found), "en")
|
||||||
if len(results) > maxSubtitleResults {
|
if len(results) > maxSubtitleResults {
|
||||||
t.Fatalf("len = %d", len(results))
|
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 {
|
for _, testCase := range []struct {
|
||||||
subtitle bazarr.Subtitle
|
subtitle bazarr.Subtitle
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{bazarr.Subtitle{Language: "ita", Score: 97}, "Italian · 97% match"},
|
{bazarr.Subtitle{Language: "ita", Score: 97, Token: "t"}, "Italian · 97% match"},
|
||||||
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80}, "English · Forced · 80% match"},
|
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80, Token: "t"}, "English · Forced · 80% match"},
|
||||||
{bazarr.Subtitle{Language: "eng", HearingImpaired: true}, "English · Hearing impaired"},
|
{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.
|
// A language Memby has no entry for must still name itself rather than go blank.
|
||||||
{bazarr.Subtitle{Language: "mi"}, "MI"},
|
{bazarr.Subtitle{Language: "mi", Token: "t"}, "MI"},
|
||||||
{bazarr.Subtitle{}, "Unknown"},
|
{bazarr.Subtitle{Token: "t"}, "Unknown"},
|
||||||
// Bazarr has been seen to return scores above 100; a "112% match" reads as a bug.
|
// 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)
|
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