From fdd9e6cab2e4e47ec16611fe892d6232214bd890 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Sun, 9 Aug 2026 08:25:50 +1200 Subject: [PATCH] Release v0.2.34 --- .env.example | 15 +- CHANGELOG.md | 7 + app/build.gradle.kts | 2 +- .../com/ponzischeme89/memby/ServiceLocator.kt | 14 + .../com/ponzischeme89/memby/data/Credits.kt | 148 ++++ .../memby/data/EmbyRepository.kt | 383 ++++++++- .../ponzischeme89/memby/data/GenreBrowse.kt | 40 + .../ponzischeme89/memby/data/ImageCache.kt | 86 +++ .../memby/data/MaintenanceMonitor.kt | 15 + .../ponzischeme89/memby/data/SettingsStore.kt | 113 ++- .../com/ponzischeme89/memby/data/ThemeSync.kt | 184 +++++ .../com/ponzischeme89/memby/data/Themes.kt | 60 ++ .../memby/data/UserPreferences.kt | 20 + .../memby/data/model/GatewayModels.kt | 164 +++- .../memby/data/remote/EmbyApi.kt | 8 + .../memby/data/remote/GatewayApi.kt | 28 + .../memby/ui/DetailPageComponents.kt | 35 +- .../memby/ui/EpisodeDetailsOverlay.kt | 3 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 93 ++- .../ponzischeme89/memby/ui/HomeGreeting.kt | 25 + .../ponzischeme89/memby/ui/HomeMovieHero.kt | 54 +- .../ponzischeme89/memby/ui/HomeViewModel.kt | 74 ++ .../ponzischeme89/memby/ui/MainActivity.kt | 475 +++++++----- .../memby/ui/MaintenanceScreen.kt | 3 +- .../memby/ui/MediaDetailsOverlay.kt | 4 +- .../memby/ui/MyShowsNotifications.kt | 5 +- .../memby/ui/SeriesDetailsOverlay.kt | 3 +- .../memby/ui/ServiceAlertBanner.kt | 3 +- .../ponzischeme89/memby/ui/UpdateScreen.kt | 6 +- .../memby/ui/player/CreditsSpeed.kt | 125 +++ .../memby/ui/player/PlayerActivity.kt | 512 +++++++++++- .../memby/ui/player/SubtitleMenu.kt | 38 +- .../memby/ui/player/TrickplayPreview.kt | 48 +- .../ui/screensaver/ScreensaverContent.kt | 5 +- .../memby/ui/search/SearchScreen.kt | 156 +++- .../memby/ui/search/SearchViewModel.kt | 149 +++- .../memby/ui/seasonal/SeasonalDecorations.kt | 358 +++++++++ .../memby/ui/settings/SettingsSheet.kt | 502 ++++++++---- .../memby/ui/theme/DesignTokens.kt | 85 +- .../com/ponzischeme89/memby/ui/theme/Theme.kt | 9 +- .../memby/ui/whatsnew/WhatsNew.kt | 23 +- .../memby/ui/whatsnew/WhatsNewOverlay.kt | 54 +- .../player_overlay_primary_option_text.xml | 5 + .../player_scrub_preview_background.xml | 13 + app/src/main/res/layout/activity_player.xml | 5 + .../main/res/layout/memby_player_controls.xml | 21 + .../main/res/layout/player_end_credits.xml | 176 +++++ .../res/layout/player_subtitle_overlay.xml | 16 + app/src/main/res/values/strings.xml | 16 + .../ponzischeme89/memby/data/CreditsTest.kt | 275 +++++++ .../memby/data/GenreBrowseTest.kt | 37 + .../memby/data/ImageCacheTest.kt | 38 + .../data/SettingsStoreProfileRemovalTest.kt | 18 + .../ponzischeme89/memby/data/ThemesTest.kt | 66 ++ .../memby/ui/HomeGreetingTest.kt | 29 + .../memby/ui/HomeMovieHeroTest.kt | 16 + .../memby/ui/ThemeScreenshotTest.kt | 422 ++++++++++ .../memby/ui/player/CreditsSpeedTest.kt | 123 +++ .../ui/player/EndCreditsScreenshotTest.kt | 197 +++++ .../ui/player/SubtitleMenuScreenshotTest.kt | 49 +- .../settings/SettingsSheetScreenshotTest.kt | 143 +++- .../memby/ui/whatsnew/WhatsNewTest.kt | 15 - server/internal/api/admin.go | 40 +- server/internal/api/admin/admin.css | 16 + server/internal/api/admin/core.js | 1 + server/internal/api/admin/pages/account.html | 21 + server/internal/api/admin/pages/account.js | 80 ++ server/internal/api/admin/pages/searches.html | 47 ++ server/internal/api/admin/pages/searches.js | 37 + .../internal/api/admin/pages/subtitles.html | 84 ++ server/internal/api/admin/pages/subtitles.js | 98 +++ server/internal/api/admin_accounts.go | 19 + server/internal/api/admin_console.go | 10 + server/internal/api/admin_preview_test.go | 21 + server/internal/api/admin_searches.go | 100 +++ server/internal/api/admin_subtitles.go | 192 +++++ server/internal/api/api.go | 33 +- server/internal/api/api_test.go | 69 ++ server/internal/api/credits.go | 124 +++ server/internal/api/credits_test.go | 292 +++++++ server/internal/api/features.go | 37 + server/internal/api/genres.go | 153 ++++ server/internal/api/genres_test.go | 47 ++ server/internal/api/home.go | 51 +- server/internal/api/intro.go | 140 +++- server/internal/api/items.go | 22 +- server/internal/api/logcontext.go | 4 +- server/internal/api/logging_test.go | 1 + server/internal/api/maintenance.go | 8 + server/internal/api/my_shows.go | 4 +- server/internal/api/playback.go | 18 +- server/internal/api/preferences.go | 26 + server/internal/api/sonarr.go | 59 ++ server/internal/api/subtitle_download.go | 249 +++--- server/internal/api/subtitle_download_test.go | 25 +- server/internal/api/subtitle_fix.go | 357 +++++++++ server/internal/api/subtitle_fix_test.go | 46 ++ server/internal/api/subtitle_providers.go | 727 ++++++++++++++++++ .../internal/api/subtitle_providers_test.go | 142 ++++ server/internal/api/themes.go | 577 ++++++++++++++ server/internal/api/themes_test.go | 279 +++++++ server/internal/buildinfo/VERSION | 2 +- server/internal/emby/client.go | 52 ++ server/internal/emby/client_resume_test.go | 42 + server/internal/opensubtitles/client.go | 465 +++++++++++ server/internal/opensubtitles/client_test.go | 177 +++++ server/internal/store/schema.sql | 50 ++ server/internal/store/searches.go | 190 +++++ server/internal/store/store.go | 52 -- server/internal/store/subtitles.go | 235 ++++++ server/internal/store/themes.go | 83 ++ server/internal/subsync/signal.go | 74 ++ server/internal/subsync/srt.go | 138 ++++ server/internal/subsync/subsync_test.go | 314 ++++++++ server/internal/subsync/subtitles.go | 345 +++++++++ server/internal/trickplay/bif.go | 8 +- 116 files changed, 11418 insertions(+), 879 deletions(-) create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/Credits.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/ImageCache.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/Themes.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/seasonal/SeasonalDecorations.kt create mode 100644 app/src/main/res/color/player_overlay_primary_option_text.xml create mode 100644 app/src/main/res/drawable/player_scrub_preview_background.xml create mode 100644 app/src/main/res/layout/player_end_credits.xml create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/CreditsTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/GenreBrowseTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/ImageCacheTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/ThemesTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/HomeGreetingTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/ThemeScreenshotTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/player/CreditsSpeedTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/player/EndCreditsScreenshotTest.kt create mode 100644 server/internal/api/admin/pages/searches.html create mode 100644 server/internal/api/admin/pages/searches.js create mode 100644 server/internal/api/admin/pages/subtitles.html create mode 100644 server/internal/api/admin/pages/subtitles.js create mode 100644 server/internal/api/admin_searches.go create mode 100644 server/internal/api/admin_subtitles.go create mode 100644 server/internal/api/credits.go create mode 100644 server/internal/api/credits_test.go create mode 100644 server/internal/api/genres.go create mode 100644 server/internal/api/genres_test.go create mode 100644 server/internal/api/subtitle_fix.go create mode 100644 server/internal/api/subtitle_fix_test.go create mode 100644 server/internal/api/subtitle_providers.go create mode 100644 server/internal/api/subtitle_providers_test.go create mode 100644 server/internal/api/themes.go create mode 100644 server/internal/api/themes_test.go create mode 100644 server/internal/emby/client_resume_test.go create mode 100644 server/internal/opensubtitles/client.go create mode 100644 server/internal/opensubtitles/client_test.go create mode 100644 server/internal/store/searches.go create mode 100644 server/internal/store/subtitles.go create mode 100644 server/internal/store/themes.go create mode 100644 server/internal/subsync/signal.go create mode 100644 server/internal/subsync/srt.go create mode 100644 server/internal/subsync/subsync_test.go create mode 100644 server/internal/subsync/subtitles.go diff --git a/.env.example b/.env.example index 5434edf..b9114b9 100644 --- a/.env.example +++ b/.env.example @@ -78,10 +78,17 @@ MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758 # import still hears about it. 0 turns the banners off. MEMBY_RADARR_ALERT_WINDOW=3h -# Optional Bazarr integration, which is what lets a viewer fetch a subtitle from the -# player for a title the library has none for. Bazarr writes the file beside the media -# file, so Emby serves the result and Memby stores nothing — leaving these unset simply -# means the option is never offered. The API key is in Bazarr under Settings > General. +# Optional Bazarr integration, one of the two providers a viewer can fetch a missing +# subtitle from. Bazarr writes the file beside the media file, so Emby serves the result +# and Memby stores nothing — leaving these unset simply means Bazarr is never offered. The +# API key is in Bazarr under Settings > General. +# +# Whether viewers may actually use it is a switch on the admin console's Subtitles page, +# not an environment variable: the address is deployment configuration and belongs here, +# but turning the provider on and off is an operator's decision that should not need a +# redeployment. The second provider, OpenSubtitles, is configured entirely on that page — +# it needs only an API key, and the credentials live in the database rather than in this +# file. MEMBY_BAZARR_URL=http://10.0.0.2:6767 MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11 # How long Bazarr's movie/series/episode listings are cached. They exist only to turn an diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6bd21..e6910fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.2.34 — 2026-08-09 +- Added: Closing credits can move aside and speed up while the next episode is ready. +- Added: More colour themes, including seasonal themes for the whole household. +- Added: Missing subtitles can now be found through OpenSubtitles as well as Bazarr. +- Improved: Genre browsing now shows the right titles and loads more as you scroll. +- Improved: The home screen, search, subtitles and settings are clearer and easier to use. + ## 0.2.33 — 2026-08-08 - General bug fixes and improvements. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dd91c1f..0c794fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,7 +42,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.33" +val defaultVersionName = "0.2.34" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt index 8c0fcec..e1b08a1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -6,6 +6,7 @@ import com.ponzischeme89.memby.data.EmbyRepository import com.ponzischeme89.memby.data.MaintenanceMonitor import com.ponzischeme89.memby.data.PreferencesSync import com.ponzischeme89.memby.data.SettingsStore +import com.ponzischeme89.memby.data.ThemeSync /** * Tiny manual dependency container. Initialised once from [MembyApp] so that the @@ -31,6 +32,14 @@ object ServiceLocator { lateinit var preferencesSync: PreferencesSync private set + /** + * Held for the same reason [preferencesSync] is, and read as well as held: the settings + * picker asks it which schemes this viewer may choose and whether a season has taken the + * choice away for the moment. + */ + lateinit var themeSync: ThemeSync + private set + fun init(context: Context) { if (::repository.isInitialized) return settings = SettingsStore(context.applicationContext) @@ -40,5 +49,10 @@ object ServiceLocator { // app already asks the gateway a question every ten seconds, and settings do not // deserve a second connection. preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision) + // Likewise rides the status poll. It is constructed here rather than by a screen + // because the palette has to be applied before the first frame of the launcher, and + // because the surfaces that obey it — the launcher, the player's Compose islands, + // the screensaver's DreamService — are separate roots with no common owner but this. + themeSync = ThemeSync(repository, settings, maintenance.theme) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/Credits.kt b/app/src/main/java/com/ponzischeme89/memby/data/Credits.kt new file mode 100644 index 0000000..581ab01 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/Credits.kt @@ -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, 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index e2a05d8..d30d76e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -13,6 +13,7 @@ import com.ponzischeme89.memby.data.model.GatewayRowEvents import com.ponzischeme89.memby.data.model.GatewayServiceStatus import com.ponzischeme89.memby.data.model.GatewaySubtitleCandidate import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest +import com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest import com.ponzischeme89.memby.data.model.GatewayFeatures import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.PlaybackReport @@ -107,6 +108,8 @@ data class Playable( * turned the feature off, so the player never offers a row that cannot do anything. */ val subtitleDownloadAvailable: Boolean = false, + /** Whether this title has a second readable track against which timing can be checked. */ + val subtitleFixAvailable: Boolean = false, /** * Whether it is worth asking the backend for seek previews. On the direct path the * television reads Emby's preview file itself, so this is always true there; in @@ -121,6 +124,14 @@ data class Playable( * or a deliberately-configured-off container from being asked once per playback. */ val skipIntroAvailable: Boolean = false, + /** + * Whether it is worth asking the backend where this title's closing credits begin. True + * on the direct path for the same reason the above is, and in gateway mode it is the + * gateway's own switch. Its own field rather than a reuse of [skipIntroAvailable]: the + * two are separate features, and an operator turning the skip button off has not asked + * to lose the credits pane with it. + */ + val endCreditsAvailable: Boolean = false, ) /** @@ -150,6 +161,15 @@ data class SubtitleDownload( val url: String, ) +/** The gateway's answer after checking one existing subtitle against another. */ +data class SubtitleFix( + val subtitleId: String, + val message: String, + val changed: Boolean, + val offsetMs: Long, + val reference: String, +) + /** * Everything needed to resolve a stream, plus everything the player needs to dress its * loading screen while that resolution is still in flight. @@ -193,6 +213,16 @@ class EmbyRepository(private val settings: SettingsStore) { val settingsFlow: Flow get() = settings.settingsFlow + /** + * What the settings are *right now*, for a composable that has to draw before a flow + * can emit. A detail page opened with [Settings.EMPTY] as its initial value draws its + * first frame under default preferences and then recomposes the whole page — and + * restarts the effects keyed on those preferences — one frame later, at precisely the + * moment the page is trying to appear. Reading the snapshot instead makes the first + * frame the right one. Same reasoning as [showTitleLogo]. + */ + val currentSettings: Settings get() = snapshot + /** * Read synchronously off the snapshot, like [rotationIntervalMillis], because the * composables that decide between logo artwork and a text title do so while building a @@ -220,10 +250,21 @@ class EmbyRepository(private val settings: SettingsStore) { private val seriesEpisodesMutex = Mutex() private val seriesEpisodesCache = LinkedHashMap(SERIES_EPISODE_CACHE_SIZE, 0.75f, true) + private val seriesEpisodesInFlight = mutableMapOf?>>() private val relatedMutex = Mutex() private val relatedCache = LinkedHashMap(RELATED_CACHE_SIZE, 0.75f, true) private val relatedInFlight = mutableMapOf>() + 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(TRAILER_CACHE_SIZE, 0.75f, true) + private val trailerInFlight = mutableMapOf>() fun cachedHome(): HomeCache? = settings.homeCache(snapshot) @@ -640,6 +681,62 @@ class EmbyRepository(private val settings: SettingsStore) { ) } + /** + * One page of a genre, dual-path like the search beside it. + * + * This is a *filter*, not a query. Running a genre's name through search matched a film + * called Drama, anything with the word in its overview, and — relevance being a score + * rather than a rule — a scattering of titles not in the genre at all, while missing + * most of the ones that were. Both paths therefore ask their backend for the genre. + * + * Episodes are excluded on purpose: an episode inherits its series' genres, so a page + * would otherwise fill with twenty entries of one comedy and bury the rest. + * + * The gateway path degrades to a keyword search rather than throwing, so a television + * on this build talking to a gateway that predates the route still shows a viewer + * *something* when they press a genre — which is exactly what it did before. + */ + suspend fun browseGenre(genre: String, offset: Int = 0, limit: Int = GENRE_PAGE_SIZE): GenrePage { + val trimmed = genre.trim() + if (trimmed.isEmpty()) return GenrePage(emptyList(), offset, 0) + if (ServerConfig.isGateway) { + runCatching { requireGateway().genreItems(trimmed, offset, limit) } + .onSuccess { page -> + return GenrePage(page.items, offset, page.total) + } + .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + // Only the first page falls back. A gateway that answered page one and + // failed on page two is having trouble, not missing the route, and a + // search's results pasted onto the end of a genre would be nonsense. + if (offset > 0) throw error + val items = search(trimmed, limit) + return GenrePage(items, offset, items.size) + } + } + val items = getHomeItems( + params = mapOf( + "Genres" to trimmed, + "IncludeItemTypes" to "Movie,Series", + "Recursive" to "true", + "StartIndex" to offset.toString(), + "Limit" to limit.toString(), + // The second sort key is what makes paging safe: with only a date, two + // titles sharing one could swap places between requests and the scroll + // would repeat one card and never show the other. + "SortBy" to "PremiereDate,SortName", + "SortOrder" to "Descending", + ), + fields = "ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + imageTypes = "Backdrop,Primary,Logo", + includeUserData = true, + ) + // Emby's count is turned off for these list calls, so the page itself is the only + // evidence: a full page means there may be more, a short one is the end. + val total = offset + items.size + if (items.size >= limit) 1 else 0 + return GenrePage(items, offset, total) + } + /** Records a successful gateway search without affecting the direct Emby path. */ suspend fun recordSearch(term: String) { if (ServerConfig.isGateway && term.trim().length >= 2) { @@ -758,6 +855,18 @@ class EmbyRepository(private val settings: SettingsStore) { suspend fun serverFeatures(): GatewayFeatures = requireGateway().features() + /** + * This viewer's colour scheme, and the ones the operator lets them choose between. + * + * Gateway only, and deliberately so rather than falling through to a direct-path + * equivalent: with no gateway there is nobody who decides themes, and inventing a + * client-side rule would mean a household's palette changing depending on whether the + * container happens to be up. The direct path keeps the default palette, which is what + * the app looked like before this existed. + */ + suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument = + requireGateway().theme() + /** This viewer's server-held settings. */ suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences = requireGateway().preferences() @@ -948,18 +1057,46 @@ class EmbyRepository(private val settings: SettingsStore) { /** * All episodes for one show in a single request. Season switching is then a local * list filter, keeping the detail screen immediate after its first load. + * + * **Single-flighted on the repository's own scope**, for the reason [getRelated] is: + * the caller is usually [HomeViewModel.focusItem] warming the page while a card is + * still focused, and that job is cancelled the moment the D-pad moves on. A request + * cancelled at the socket is one the gateway logs as a failure and one nobody keeps + * the answer of — so the shared request outlives the caller that started it, and the + * detail page opened a moment later awaits it rather than asking for the same + * thousand episodes again. */ suspend fun getSeriesEpisodes(seriesId: String): List { if (seriesId.isBlank()) return emptyList() val now = System.currentTimeMillis() - seriesEpisodesMutex.withLock { + val inFlight = seriesEpisodesMutex.withLock { seriesEpisodesCache[seriesId] ?.takeIf { it.expiresAtMs > now } ?.episodes ?.let { return it } seriesEpisodesCache.remove(seriesId) + seriesEpisodesInFlight[seriesId] ?: newSeriesEpisodesRequest(seriesId) } + // A failure is the caller's to report, the way it was when this awaited the request + // directly: the detail page distinguishes "no episodes" from "could not load them". + return inFlight.await() ?: error("Could not load episodes for $seriesId") + } + private fun newSeriesEpisodesRequest(seriesId: String): Deferred?> { + 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 { + val now = System.currentTimeMillis() val loaded = if (ServerConfig.isGateway) { requireGateway().seriesEpisodes(seriesId).items } else { @@ -1048,16 +1185,72 @@ class EmbyRepository(private val settings: SettingsStore) { return result.played } - /** Returns Emby's first local trailer for an item, when one is available. */ + /** Removes a title from Continue Watching without changing its watched state. */ + suspend fun removeFromContinueWatching(itemId: String) { + if (ServerConfig.isGateway) { + requireGateway().hideFromResume(itemId) + return + } + val userId = snapshot.userId ?: error("Not connected") + requireApi().hideFromResume(userId, itemId) + } + + /** + * Emby's first local trailer for an item, when one is available. + * + * Cached, **including the negative answer**, and single-flighted on the repository's + * scope like [getRelated]. Most of a library has no local trailer, so before this every + * detail page opened with a request the gateway answered 404 to — repeated on walking + * Back and reopening, and again for every step of the "More like this" trail. Whether a + * title has a trailer only changes when its media does, so the entry lives as long as + * the process. + */ suspend fun getLocalTrailer(itemId: String): BaseItem? { + if (itemId.isBlank()) return null + val inFlight = trailerMutex.withLock { + trailerCache[itemId]?.let { return it.value } + trailerInFlight[itemId] ?: newTrailerRequest(itemId) + } + // A failure is not cached — one bad minute must not leave a title trailerless for + // the rest of the session — and it is not surfaced either: a missing trailer button + // is the same outcome the 404 already produces. + return inFlight.await()?.value + } + + private fun newTrailerRequest(itemId: String): Deferred { + val request = scope.async(start = CoroutineStart.LAZY) { + try { + val loaded = runCatching { loadLocalTrailer(itemId) }.getOrNull() + ?: return@async null + trailerMutex.withLock { + trailerCache[itemId] = loaded + while (trailerCache.size > TRAILER_CACHE_SIZE) { + trailerCache.entries.iterator().run { + next() + remove() + } + } + loaded + } + } finally { + trailerMutex.withLock { trailerInFlight.remove(itemId) } + } + } + trailerInFlight[itemId] = request + request.start() + return request + } + + private suspend fun loadLocalTrailer(itemId: String): CachedTrailer { if (ServerConfig.isGateway) { // The gateway answers 404 when an item has no trailer, which is a normal // outcome here rather than an error worth surfacing. - return runCatching { requireGateway().trailer(itemId) } + val trailer = runCatching { requireGateway().trailer(itemId) } .getOrElse { if (it is HttpException && it.code() == 404) null else throw it } + return CachedTrailer(trailer) } val userId = snapshot.userId ?: error("Not connected") - return requireApi().getLocalTrailers(userId, itemId).items.firstOrNull() + return CachedTrailer(requireApi().getLocalTrailers(userId, itemId).items.firstOrNull()) } /** @@ -1225,6 +1418,7 @@ class EmbyRepository(private val settings: SettingsStore) { playMethod = discovery.playMethod, trickplayAvailable = true, skipIntroAvailable = true, + endCreditsAvailable = true, ) } @@ -1242,15 +1436,17 @@ class EmbyRepository(private val settings: SettingsStore) { // Recovery follows the local playhead. The server's persisted position may be // up to one progress interval behind and would visibly jump the viewer back. resumePositionMs = resumePositionMs.coerceAtLeast(0L), - subtitles = playback.subtitles, + subtitles = resolveSubtitleUrls(playback.subtitles), subtitlesEnabled = playback.subtitlesEnabled, selectedSubtitleId = playback.selectedSubtitleId, mediaSourceId = playback.mediaSourceId, playSessionId = playback.playSessionId, playMethod = playback.playMethod, subtitleDownloadAvailable = playback.subtitleDownloadAvailable, + subtitleFixAvailable = playback.subtitleFixAvailable, trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, + endCreditsAvailable = playback.endCreditsAvailable, ) } @@ -1273,7 +1469,7 @@ class EmbyRepository(private val settings: SettingsStore) { title = playback.title.ifBlank { title }, url = playback.url, resumePositionMs = positionMs.coerceAtLeast(0L), - subtitles = playback.subtitles, + subtitles = resolveSubtitleUrls(playback.subtitles), subtitlesEnabled = playback.subtitlesEnabled, selectedSubtitleId = playback.selectedSubtitleId, mediaSourceId = playback.mediaSourceId, @@ -1283,8 +1479,10 @@ class EmbyRepository(private val settings: SettingsStore) { episodeCode = playback.episodeCode.ifBlank { null }, runtimeMs = playback.runtimeMs, subtitleDownloadAvailable = playback.subtitleDownloadAvailable, + subtitleFixAvailable = playback.subtitleFixAvailable, trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, + endCreditsAvailable = playback.endCreditsAvailable, ) } val discovery = directPlayback( @@ -1308,6 +1506,7 @@ class EmbyRepository(private val settings: SettingsStore) { playMethod = discovery.playMethod, trickplayAvailable = true, skipIntroAvailable = true, + endCreditsAvailable = true, ) } @@ -1353,7 +1552,7 @@ class EmbyRepository(private val settings: SettingsStore) { url = playback.url, resumePositionMs = playback.resumePositionMs, logoUrl = item.logoUrl, - subtitles = playback.subtitles, + subtitles = resolveSubtitleUrls(playback.subtitles), subtitlesEnabled = playback.subtitlesEnabled, selectedSubtitleId = playback.selectedSubtitleId, mediaSourceId = playback.mediaSourceId, @@ -1365,8 +1564,10 @@ class EmbyRepository(private val settings: SettingsStore) { prerollEnabled = playback.prerollEnabled, prerollDurationMs = playback.prerollDurationMs, subtitleDownloadAvailable = playback.subtitleDownloadAvailable, + subtitleFixAvailable = playback.subtitleFixAvailable, trickplayAvailable = playback.trickplayAvailable, skipIntroAvailable = playback.skipIntroAvailable, + endCreditsAvailable = playback.endCreditsAvailable, ) } if (item.isSeries) { @@ -1392,6 +1593,7 @@ class EmbyRepository(private val settings: SettingsStore) { playMethod = discovery.playMethod, trickplayAvailable = true, skipIntroAvailable = true, + endCreditsAvailable = true, overview = episode.overview, episodeCode = episodeCode(episode), runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, @@ -1412,6 +1614,7 @@ class EmbyRepository(private val settings: SettingsStore) { playMethod = discovery.playMethod, trickplayAvailable = true, skipIntroAvailable = true, + endCreditsAvailable = true, overview = item.overview, episodeCode = item.episodeCode, runtimeMs = item.runtimeMs, @@ -1431,7 +1634,12 @@ class EmbyRepository(private val settings: SettingsStore) { private suspend fun clearSeriesEpisodeCache() { seriesEpisodesMutex.withLock { + // Episodes carry this viewer's watched and resume state, so the same reasoning + // as below applies: another profile must not inherit them, and a request + // already in the air was made with the outgoing profile's session. seriesEpisodesCache.clear() + seriesEpisodesInFlight.values.forEach { it.cancel() } + seriesEpisodesInFlight.clear() } relatedMutex.withLock { // Reasons are personal: another profile must never inherit this one's, and a @@ -1440,6 +1648,14 @@ class EmbyRepository(private val settings: SettingsStore) { relatedInFlight.values.forEach { it.cancel() } relatedInFlight.clear() } + trailerMutex.withLock { + // Not personal — whether a title has a trailer is a property of the library — + // but the request in flight carries the outgoing session, and the next profile + // may be signed into a different server entirely. + trailerCache.clear() + trailerInFlight.values.forEach { it.cancel() } + trailerInFlight.clear() + } } suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) { @@ -1516,13 +1732,33 @@ class EmbyRepository(private val settings: SettingsStore) { * that will not answer: both come back as null and the player simply never offers the * button. Nothing about this is on the path of a Play press. */ - suspend fun introSegment(itemId: String): IntroSegment? { - if (itemId.isBlank()) return null + suspend fun introSegment(itemId: String): IntroSegment? = chapterMarkers(itemId).intro + + /** + * Where this title's closing credits begin, or null when it has none. + * + * Reads the same cached lookup [introSegment] does, which is the whole reason the credits + * pane costs nothing: the markers are two entries of one chapter list, so the second + * feature to want them finds them already in hand. It answers only where the marker is — + * whether it is worth acting on depends on the file's duration, which only the player + * knows, and is [creditsWorthShowing]'s question. + */ + suspend fun creditsStartMs(itemId: String): Long? = chapterMarkers(itemId).creditsStartMs + + /** + * One reading of an item's chapter markers, remembered. + * + * Both features read this rather than fetching their own copy. On the direct path that + * matters most: two lookups would be two `Fields=Chapters` requests to Emby for one + * response, made at the moment the decoder wants the connection pool. + */ + private suspend fun chapterMarkers(itemId: String): ChapterMarkers { + if (itemId.isBlank()) return ChapterMarkers() introCache[itemId]?.let { return it.value } val resolved = runCatching { - if (ServerConfig.isGateway) gatewayIntro(itemId) else directIntro(itemId) - }.getOrNull() - // A null is cached too. Most of a library has no intro markers — every film, every + if (ServerConfig.isGateway) gatewayMarkers(itemId) else directMarkers(itemId) + }.getOrNull() ?: ChapterMarkers() + // An empty reading is cached too. Most of a library has no intro markers — every // special, every episode Emby has not analysed yet — and without this the same no // would be fetched again on each playback and each auto-advance. introCache[itemId] = CachedIntro(resolved) @@ -1535,18 +1771,33 @@ class EmbyRepository(private val settings: SettingsStore) { return resolved } - private suspend fun gatewayIntro(itemId: String): IntroSegment? { - val intro = requireGateway().intro(itemId) - if (!intro.available || intro.endMs <= intro.startMs) return null - return IntroSegment(startMs = intro.startMs, endMs = intro.endMs) + private suspend fun gatewayMarkers(itemId: String): ChapterMarkers { + val markers = requireGateway().intro(itemId) + return ChapterMarkers( + intro = if (markers.available && markers.endMs > markers.startMs) { + IntroSegment(startMs = markers.startMs, endMs = markers.endMs) + } else { + null + }, + creditsStartMs = markers.creditsStartMs.takeIf { markers.creditsAvailable && it > 0L }, + ) } - private suspend fun directIntro(itemId: String): IntroSegment? { - val userId = snapshot.userId ?: return null + private suspend fun directMarkers(itemId: String): ChapterMarkers { + val userId = snapshot.userId ?: return ChapterMarkers() // Chapters and nothing else. This is the one query in the app that asks for them, // and adding them to a shared Fields list would put a couple of dozen entries per // item into every row response to render nothing. - return introSegmentFrom(requireApi().getItem(userId, itemId, "Chapters").chapters) + val item = requireApi().getItem(userId, itemId, "Chapters") + return ChapterMarkers( + intro = introSegmentFrom(item.chapters), + // The runtime is what tells a chapter named "Credits" from one named "Opening + // Credits". It is a default field on this response, so it costs nothing to use. + creditsStartMs = creditsStartFrom( + item.chapters, + item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L, + ), + ) } /** @@ -1685,7 +1936,7 @@ class EmbyRepository(private val settings: SettingsStore) { ) SubtitleDownload( message = response.message, - subtitles = response.subtitles, + subtitles = resolveSubtitleUrls(response.subtitles), selectedSubtitleId = response.selectedSubtitleId, mediaSourceId = response.mediaSourceId, playSessionId = response.playSessionId, @@ -1694,10 +1945,34 @@ class EmbyRepository(private val settings: SettingsStore) { }.getOrNull() } + /** + * Check an existing subtitle's timing and keep the corrected copy when it moved. + * + * Gateway-only: the direct path has nowhere durable to store the repaired sidecar. + * Null is a transport failure; a safe refusal to guess is a successful response whose + * [SubtitleFix.message] explains why nothing changed. + */ + suspend fun fixSubtitle(itemId: String, subtitleId: String): SubtitleFix? { + if (itemId.isBlank() || subtitleId.isBlank() || !ServerConfig.isGateway) return null + return runCatching { + val response = requireGateway().fixSubtitle( + itemId, + GatewaySubtitleFixRequest(subtitleId), + ) + SubtitleFix( + subtitleId = response.subtitleId, + message = response.message, + changed = response.changed, + offsetMs = response.offsetMs, + reference = response.reference, + ) + }.getOrNull() + } + private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode { val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty()) return nextEpisodeOf( - response.item, response.url, response.resumePositionMs, response.subtitles, + response.item, response.url, response.resumePositionMs, resolveSubtitleUrls(response.subtitles), response.subtitlesEnabled, response.selectedSubtitleId, response.mediaSourceId, response.playSessionId, response.playMethod, ) @@ -1942,6 +2217,37 @@ class EmbyRepository(private val settings: SettingsStore) { } } + /** + * Resolves subtitle URLs the gateway handed back as paths rather than addresses. + * + * Almost every subtitle is Emby's and arrives as a complete URL with its own + * credential on it. The exception is one the gateway fetched itself — from a provider + * that hands back a file instead of writing it beside the media — which the gateway + * serves from its own route. It cannot write that as an absolute address because it + * does not reliably know its externally reachable name; the television does, because + * it is the thing talking to it. So the server sends a path and this puts the base and + * the token on it, exactly as [imageUrl] does for artwork, and for the same reason: + * media3 fetches a sidecar as a plain URL with none of Memby's headers attached. + * + * A URL that is already absolute is left alone, so this is safe to run over every + * playback response rather than only the one that produced a download. + */ + private fun resolveSubtitleUrls(subtitles: List): List { + if (subtitles.none { it.url.startsWith("/") }) return subtitles + val gateway = ServerConfig.gatewayUrl?.trimEnd('/') + val token = snapshot.token + return subtitles.map { subtitle -> + if (!subtitle.url.startsWith("/")) return@map subtitle + // Dropping the track is the right failure: a sidecar URL with no credential on + // it fetches a 401, and media3 reports that as a broken stream rather than as a + // missing subtitle. + if (gateway.isNullOrBlank() || token.isNullOrBlank()) { + return@map subtitle.copy(url = "") + } + subtitle.copy(url = gateway + subtitle.url + "?t=" + encode(token)) + } + } + fun hasBackdrop(item: BaseItem): Boolean = item.backdropImageTags.isNotEmpty() || (item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty()) @@ -2095,14 +2401,31 @@ private data class CachedRelated( val expiresAtMs: Long, ) +/** + * A wrapper rather than the value itself, for the reason [CachedTrickplay] is one: a null + * [value] is a real answer — this title has no local trailer, which is most of a library — + * and a map cannot tell that from an absent entry. + */ +private data class CachedTrailer(val value: BaseItem?) + /** * A wrapper rather than the value itself, because a null [value] is a real answer — this * title has no previews — and a map cannot tell that from an absent entry. */ private data class CachedTrickplay(val value: Trickplay?) -/** Null is a real answer here — most titles have no intro markers — so it is cached too. */ -private data class CachedIntro(val value: IntroSegment?) +/** + * What one reading of a title's chapter markers came to. Both halves are nullable and both + * nulls are real answers — most of a library has no intro, and a title can easily have + * credits and no intro or the other way round. + */ +data class ChapterMarkers( + val intro: IntroSegment? = null, + val creditsStartMs: Long? = null, +) + +/** An empty reading is a real answer — most titles have no markers — so it is cached too. */ +private data class CachedIntro(val value: ChapterMarkers) private const val PLAYABLE_CACHE_SIZE = 16 private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L @@ -2137,11 +2460,23 @@ private const val TRICKPLAY_WIDTH = 320 */ private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L -private const val SERIES_EPISODE_CACHE_SIZE = 6 +/** + * Raised from six when focus began warming this: a viewer walking a shelf of shows now + * fills it as they go, and at six the show they finally press could have been evicted by + * the ones they passed on the way to it — which is the entire case the warm exists for. + */ +private const val SERIES_EPISODE_CACHE_SIZE = 10 private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L private const val RELATED_CACHE_SIZE = 12 private const val RELATED_LIMIT = 12 +/** + * Whether a title has a trailer is one boolean and an occasional item, so this can afford + * to be generous: an evening of browsing touches far more titles than it plays, and the + * point of the cache is that walking back into a page never asks again. + */ +private const val TRAILER_CACHE_SIZE = 64 + // Long enough that walking back and forth between a row and a detail page never re-asks, // short enough that a newly watched title drops out of "more like this" the same evening. private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L diff --git a/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt b/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt new file mode 100644 index 0000000..09cf2fa --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/GenreBrowse.kt @@ -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, + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ImageCache.kt b/app/src/main/java/com/ponzischeme89/memby/data/ImageCache.kt new file mode 100644 index 0000000..9c21653 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/ImageCache.kt @@ -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) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt index 4c1b231..07e2b33 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/MaintenanceMonitor.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.ponzischeme89.memby.data.model.GatewayAlert +import com.ponzischeme89.memby.data.model.GatewayThemeStatus import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -88,8 +89,19 @@ class MaintenanceMonitor( val embyOutage: StateFlow = _embyOutage.asStateFlow() private val _preferencesRevision = MutableStateFlow(0L) + private val _theme = MutableStateFlow(GatewayThemeStatus()) private val _installPermissionPrompt = MutableStateFlow(false) + /** + * Which colour scheme this viewer's televisions should be painted, as an id and a + * revision. [ThemeSync] fetches the palette only when the revision moves. + * + * It rides this poll rather than the sign-in because that is the whole feature: a + * seasonal theme has to reach a set that is already switched on, at the midnight it + * begins, with nobody doing anything. + */ + val theme: StateFlow = _theme.asStateFlow() + /** * The viewer's server-held settings revision, as of the last successful poll. This is * how an operator's push reaches a television: the number changes, [PreferencesSync] @@ -159,6 +171,7 @@ class MaintenanceMonitor( // clearing it here would fight that loop for the same flow. if (ServerConfig.isGateway) _embyOutage.value = null _preferencesRevision.value = 0 + _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false dismissAlert() return@collectLatest @@ -186,6 +199,7 @@ class MaintenanceMonitor( null } _preferencesRevision.value = status.preferencesRevision + _theme.value = status.theme _installPermissionPrompt.value = status.features[INSTALL_PERMISSION_FEATURE] == true // Emby's state is reported even during maintenance: an @@ -207,6 +221,7 @@ class MaintenanceMonitor( _compatibility.value = null _embyOutage.value = null _preferencesRevision.value = 0 + _theme.value = GatewayThemeStatus() _installPermissionPrompt.value = false dismissAlert() return@collectLatest diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index aaf300e..f5fc076 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -320,6 +320,9 @@ data class Settings( // What to do when an episode reaches its opening titles: offer a button, skip without // asking, or nothing. Per-profile and synced for the same reason the two above are. val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, + // Shrink the closing credits to one side at double speed with what is on next beside + // them. Per-profile and synced for the same reason the three above are. + val speedUpCredits: Boolean = true, // Foreground colour of the slide-progress ring, as an RRGGBB hex string. val ringColorHex: String = DEFAULT_RING_COLOR, val lastBackdropUrl: String? = null, @@ -344,6 +347,26 @@ data class Settings( val homeHiddenRows: String = "", /** Tone used for the short welcome line after sign-in and during startup. */ val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE, + /** + * The colour scheme this viewer chose, as a server theme id. Profile-specific and + * synced like the rest, so it follows the person to every set they sign into. + * + * It is only ever the *choice*. What is actually on screen is resolved by the gateway + * on top of it — a season outranks it, and an operator's allowlist can withdraw it — + * and that answer arrives as [themePaletteJson] below rather than as a change here. + * Keeping them apart is what lets a viewer's own scheme survive underneath Christmas + * and come back on the 27th without anybody re-choosing it. + */ + val themeId: String = DEFAULT_THEME_ID, + /** + * The palette the gateway last handed this television, verbatim, and the revision it + * came at. Device state rather than a synced preference: it is a *cache* of a server + * answer, not a decision, and its whole job is to be on disk before the first request + * of a cold start returns so the launcher does not paint itself in the default colours + * and then flick into the viewer's. + */ + val themePaletteJson: String? = null, + val themeRevision: String = "", /** * User ids known to have finished recommendation onboarding on this TV. Cold start * consults this instead of waiting on the gateway: onboarding is a one-time, @@ -386,6 +409,8 @@ data class Settings( const val DEFAULT_HOME_CARD_DENSITY = "standard" const val DEFAULT_HOME_ARTWORK_STYLE = "automatic" const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral" + /** Matches `defaultThemeID` in the gateway's theme catalogue. */ + const val DEFAULT_THEME_ID = "midnight" val EMPTY = Settings() } } @@ -421,6 +446,16 @@ data class EmbyProfile( val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, + val speedUpCredits: Boolean = true, + /** + * The colour scheme this person chose. Per profile like the rest — two people sharing a + * television have separate documents on the server and separate schemes on it. + * + * The resolved *palette* is deliberately not here: it is device state, because it is a + * cache of a server answer rather than a choice, and stuffing eight colours into the + * profiles blob would put them into the file that is rewritten on every settings edit. + */ + val themeId: String = Settings.DEFAULT_THEME_ID, ) class SettingsStore(private val context: Context) { @@ -459,6 +494,7 @@ class SettingsStore(private val context: Context) { val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language") val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds") val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode") + val SPEED_UP_CREDITS = booleanPreferencesKey("speed_up_credits") val RING_COLOR = stringPreferencesKey("ring_color") val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") val HOME_SECTIONS = stringPreferencesKey("home_sections") @@ -474,6 +510,9 @@ class SettingsStore(private val context: Context) { val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows") val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows") val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style") + val THEME_ID = stringPreferencesKey("theme_id") + val THEME_PALETTE = stringPreferencesKey("theme_palette") + val THEME_REVISION = stringPreferencesKey("theme_revision") val PROFILES = stringPreferencesKey("profiles") val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids") val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids") @@ -620,12 +659,63 @@ class SettingsStore(private val context: Context) { } } + suspend fun setSpeedUpCredits(enabled: Boolean) { + context.dataStore.edit { preferences -> + preferences[Keys.SPEED_UP_CREDITS] = enabled + updateActiveProfile(preferences) { it.copy(speedUpCredits = enabled) } + } + } + + /** + * The viewer picking a colour scheme. Not validated here on purpose: the legal ids are + * the gateway's catalogue and this build has no copy of it, so the only honest check is + * the one the server makes when the choice is pushed. An id it rejects comes back as the + * default on the next pull, which is the same correction every other setting gets. + * + * Nothing repaints from this write. What is on screen is the *resolved* palette, and + * that arrives from `ThemeSync` a moment later — which is also what makes a choice the + * operator has withdrawn, or one covered by a season, visibly not take effect rather + * than take effect and then be undone. + */ + suspend fun setThemeId(themeId: String) { + val trimmed = themeId.trim().ifEmpty { return } + context.dataStore.edit { preferences -> + preferences[Keys.THEME_ID] = trimmed + updateActiveProfile(preferences) { it.copy(themeId = trimmed) } + } + } + + /** + * Caches the palette the gateway resolved, so the next cold start paints in the right + * colours before any request returns. + * + * Written in one edit with the revision it came at, the same invariant + * [applyRemotePreferences] keeps and for the same reason: the revision is the only thing + * that decides whether to fetch, and a set holding a revision without the palette it + * describes would never ask again. + * + * The unchanged case is skipped rather than written, because this runs off a poll: a + * store rewritten every ten seconds to say nothing new is exactly the cost the revision + * exists to avoid. + */ + suspend fun setThemePalette(paletteJson: String, revision: String) { + if (latestSettings?.themePaletteJson == paletteJson && + latestSettings?.themeRevision == revision + ) { + return + } + context.dataStore.edit { preferences -> + preferences[Keys.THEME_PALETTE] = paletteJson + preferences[Keys.THEME_REVISION] = revision + } + } + /** * Adopts the server's document for the signed-in viewer, in one write. * * One write is the point. Preferences DataStore rewrites and fsyncs the whole file per - * edit, and this touches eighteen keys plus the profiles blob — doing it through the - * individual setters would be seventeen rewrites for one sync, on a TV that has just + * edit, and this touches nineteen keys plus the profiles blob — doing it through the + * individual setters would be eighteen rewrites for one sync, on a TV that has just * started up. * * The revision is stored in the same edit as the values it describes. If they could @@ -647,6 +737,7 @@ class SettingsStore(private val context: Context) { store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle + store[Keys.THEME_ID] = preferences.themeId store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled @@ -654,6 +745,7 @@ class SettingsStore(private val context: Context) { store[Keys.SEEK_INTERVAL_SECONDS] = normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds) store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode) + store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n") store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n") @@ -669,6 +761,7 @@ class SettingsStore(private val context: Context) { hideWatchedMovies = preferences.hideWatchedMovies, showTitleLogo = preferences.showTitleLogo, welcomeQuoteStyle = preferences.welcomeQuoteStyle, + themeId = preferences.themeId, autoPlayNextEpisode = preferences.autoPlayNextEpisode, showTenMinuteReminder = preferences.showTenMinuteReminder, subtitlesEnabled = preferences.subtitlesEnabled, @@ -676,6 +769,7 @@ class SettingsStore(private val context: Context) { seekIntervalSeconds = normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds), skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode), + speedUpCredits = preferences.speedUpCredits, forYouMinutes = preferences.forYouMinutes, homeRowOrder = preferences.homeRowOrder.joinToString("\n"), homePinnedRows = preferences.homePinnedRows.joinToString("\n"), @@ -1033,6 +1127,7 @@ class SettingsStore(private val context: Context) { seekIntervalSeconds = previous?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS, skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE, + speedUpCredits = previous?.speedUpCredits ?: true, ) profiles.removeAll { it.id == id } profiles.add(profile) @@ -1156,6 +1251,13 @@ class SettingsStore(private val context: Context) { preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle + preferences[Keys.THEME_ID] = profile.themeId + // The cached palette belongs to whoever was signed in a moment ago, and this is a + // different person with a different scheme (and possibly a different allowlist). + // Dropping it puts this set on the default until ThemeSync answers, which is a + // second of the app's own colours rather than a minute of somebody else's. + preferences.remove(Keys.THEME_PALETTE) + preferences.remove(Keys.THEME_REVISION) preferences[Keys.HOME_SECTIONS] = profile.homeSections preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle @@ -1173,6 +1275,7 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] = normalizeSeekIntervalSeconds(profile.seekIntervalSeconds) preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode) + preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision preferences.remove(Keys.LAST_BACKDROP_URL) } @@ -1215,6 +1318,8 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS, ), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]), + speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, + themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID, ) } @@ -1249,6 +1354,7 @@ class SettingsStore(private val context: Context) { preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS, ), skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]), + speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true, ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR, lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL], homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, @@ -1266,6 +1372,9 @@ class SettingsStore(private val context: Context) { homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(), welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE] ?: Settings.DEFAULT_WELCOME_QUOTE_STYLE, + themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID, + themePaletteJson = preferences[Keys.THEME_PALETTE], + themeRevision = preferences[Keys.THEME_REVISION].orEmpty(), onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(), whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION], preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt new file mode 100644 index 0000000..f6fa0fa --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/ThemeSync.kt @@ -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, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), +) { + private val json = Json { ignoreUnknownKeys = true } + + private val _theme = MutableStateFlow(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 = _theme.asStateFlow() + + private val _available = MutableStateFlow>(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> = _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()) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/Themes.kt b/app/src/main/java/com/ponzischeme89/memby/data/Themes.kt new file mode 100644 index 0000000..d23b5de --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/Themes.kt @@ -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, + ) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index f56c543..02b3217 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -31,6 +31,18 @@ data class UserPreferences( val hideWatchedMovies: Boolean = false, val showTitleLogo: Boolean = true, val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE, + /** + * The colour scheme this viewer chose, as a server theme id. + * + * Deliberately not validated on this side, unlike [seekIntervalSeconds] and + * [skipIntroMode]. Those normalise because a value this build cannot read would reach + * the player as a behaviour — an unknown skip length, an unexplained jump. A theme id is + * only ever handed back to the gateway, which is the thing that decides what it means, + * and the palette that arrives is never derived from it here. So the safe treatment is + * to carry an unrecognised id through untouched, which is also what lets the server grow + * a theme before every television in the house has any idea it exists. + */ + val themeId: String = Settings.DEFAULT_THEME_ID, val autoPlayNextEpisode: Boolean = true, val showTenMinuteReminder: Boolean = true, /** @@ -45,6 +57,8 @@ data class UserPreferences( val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, /** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */ val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, + /** Shrink the closing credits to one side at double speed with what is on next beside. */ + val speedUpCredits: Boolean = true, val forYouMinutes: Int = 0, val homeRowOrder: List = emptyList(), val homePinnedRows: List = emptyList(), @@ -70,12 +84,14 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences( hideWatchedMovies = hideWatchedMovies, showTitleLogo = showTitleLogo, welcomeQuoteStyle = welcomeQuoteStyle, + themeId = themeId, autoPlayNextEpisode = autoPlayNextEpisode, showTenMinuteReminder = showTenMinuteReminder, subtitlesEnabled = subtitlesEnabled, subtitleLanguage = subtitleLanguage, seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds), skipIntroMode = normalizeSkipIntroMode(skipIntroMode), + speedUpCredits = speedUpCredits, forYouMinutes = forYouMinutes, homeRowOrder = homeRowOrder.decodeLineList(), homePinnedRows = homePinnedRows.decodeLineList(), @@ -110,6 +126,7 @@ fun decodeUserPreferences( hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies), showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo), welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle), + themeId = json.string("themeId", fallback.themeId), autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode), showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder), subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled), @@ -125,6 +142,7 @@ fun decodeUserPreferences( skipIntroMode = normalizeSkipIntroMode( json.string("skipIntroMode", fallback.skipIntroMode), ), + speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits), forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes), homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder), homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows), @@ -141,12 +159,14 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject { put("hideWatchedMovies", hideWatchedMovies) put("showTitleLogo", showTitleLogo) put("welcomeQuoteStyle", welcomeQuoteStyle) + put("themeId", themeId) put("autoPlayNextEpisode", autoPlayNextEpisode) put("showTenMinuteReminder", showTenMinuteReminder) put("subtitlesEnabled", subtitlesEnabled) put("subtitleLanguage", subtitleLanguage) put("seekIntervalSeconds", seekIntervalSeconds) put("skipIntroMode", skipIntroMode) + put("speedUpCredits", speedUpCredits) put("forYouMinutes", forYouMinutes) putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } } putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 9e769c4..617a6c6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -136,6 +136,31 @@ data class GatewayServiceStatus( * on the poll the app is already making. */ val preferencesRevision: Long = 0, + /** + * The colour scheme this viewer's televisions should be painted, as an id and a + * revision rather than the palette itself — the [preferencesRevision] precedent, for + * the same reason: this poll runs every ten seconds on every open set, and a palette + * riding it would be eight colours repeated six times a minute to say nothing new. + * + * A server that predates this sends none, which decodes to a revision of "" and so + * never triggers a fetch: an app that cannot be told its theme keeps the one it shipped + * with, which is the palette everything looked like before themes existed. + */ + val theme: GatewayThemeStatus = GatewayThemeStatus(), +) + +/** The summary of a theme that rides the status poll. See [GatewayTheme] for the document. */ +@Serializable +data class GatewayThemeStatus( + val id: String = "", + /** + * Opaque, and compared only for equality. It moves when the palette would look + * different — which includes the morning a season begins, an event no revision counter + * in a table could produce because nobody wrote anything. + */ + val revision: String = "", + val seasonal: Boolean = false, + val locked: Boolean = false, ) /** @@ -180,6 +205,81 @@ data class GatewayPreferences( kotlinx.serialization.json.JsonObject(emptyMap()), ) +/** + * The colour scheme in force and the ones this viewer may choose between. + * + * Both halves come from the server and neither is compiled into the app. The catalogue is + * per viewer, not per app: a theme the operator has withheld from somebody is not a greyed + * row on their television, it is a row that was never sent — which is what makes the + * per-user allowlist real rather than advisory. + */ +@Serializable +data class GatewayThemeDocument( + val schemaVersion: Int = 0, + val theme: GatewayTheme = GatewayTheme(), + val available: List = emptyList(), +) + +/** + * One theme. [palette] is the whole of what it changes — a theme never moves a control or + * alters what a row contains, so the worst a scheme this build has never heard of can do is + * look wrong. + */ +@Serializable +data class GatewayTheme( + val id: String = "", + val name: String = "", + val description: String = "", + /** + * True for Halloween, Christmas and Easter. Never offered as a choice and never stored + * as one; it is simply in force for its dates. + */ + val seasonal: Boolean = false, + /** + * While true the picker shows the viewer's own choice but will not let them change it, + * and [reason] is the sentence explaining why. Distinct from [seasonal] so a future + * reason to pin a theme does not have to claim to be a season. + */ + val locked: Boolean = false, + /** The viewer's own selection, still theirs underneath a season. */ + val chosen: String = "", + /** The server's wording for the lock, so a season invented later still reads correctly. */ + val reason: String = "", + /** + * What drifts over the launcher while this theme is on: "snow", "bats", "blossom", or + * empty for the whole rest of the year and for every scheme somebody chose themselves. + * + * A slug rather than anything describing the animation — the drawing is the television's, + * in `ui/seasonal/`. A build that does not recognise one draws nothing, so the gateway may + * invent a decoration before the fleet has the release that knows it. Empty is also what + * an operator who has turned decorations off gets, which is why the client must obey this + * field rather than deriving the animation from the theme id. + */ + val decoration: String = "", + val revision: String = "", + val palette: GatewayPalette = GatewayPalette(), +) + +/** + * The eight colours a theme sets, as `#AARRGGBB`. Alpha first because Android is the end + * that has to parse thousands of these; the admin console reorders for CSS at its own end. + * + * Every field defaults to blank rather than to a colour: a missing one falls back to the + * app's own token at the point of conversion, which is a theme drawn slightly wrong rather + * than a screen drawn transparent. + */ +@Serializable +data class GatewayPalette( + val surface: String = "", + val surfaceRaised: String = "", + val accent: String = "", + val onSurface: String = "", + val mutedText: String = "", + val quietText: String = "", + val hairline: String = "", + val ratingsSurface: String = "", +) + /** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */ @Serializable data class GatewayPreferencesRequest( @@ -287,6 +387,25 @@ data class GatewayItems( val items: List = 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 = emptyList(), + val offset: Int = 0, + val limit: Int = 0, + val total: Int = 0, +) + /** * Response of `GET /v1/items/{id}/related` — the detail page's two additions. * @@ -377,6 +496,9 @@ data class GatewayPlayback( // that asks and it already holds this. Absent on an older gateway, and the default // must stay false: a missing field must never conjure a row that cannot do anything. val subtitleDownloadAvailable: Boolean = false, + // Whether this title has another readable text track against which subtitle timing can + // be checked. False for an older gateway, so a missing field never creates a dead row. + val subtitleFixAvailable: Boolean = false, // Whether it is worth asking this gateway for seek previews at all. Only the answer // rides here — the layout itself is its own request, off the critical path of // starting playback. Absent on an older gateway, and the default must stay false: a @@ -386,20 +508,33 @@ data class GatewayPlayback( // same reasoning as the previews above: the segment itself is its own request, and a // missing field must never conjure one this backend would not answer. val skipIntroAvailable: Boolean = false, + // Whether it is worth asking this gateway where the closing credits begin. Same shape + // and same reasoning again, and deliberately its own field rather than a reuse of + // [skipIntroAvailable]: they are separate features with separate switches, and a house + // that turned the skip button off has not asked to lose the credits pane with it. + val endCreditsAvailable: Boolean = false, ) /** - * Where an episode's opening titles sit, as the gateway found them in Emby's markers. + * Where an episode's opening titles sit, and where its closing credits begin, as the gateway + * found them in Emby's markers. * * [available] is explicit rather than implied by a zero pair: an intro can legitimately * begin at the very start of the file, and that must stay distinguishable from an episode * that has no markers at all. + * + * Both answers share one response because they are in the same chapter list, so reading them + * together costs the one Emby request the gateway was always going to make. [creditsAvailable] + * is separate from [available] because an episode routinely has one and not the other — every + * film Emby has found credits but no intro in would otherwise be lost. */ @Serializable data class GatewayIntro( val available: Boolean = false, val startMs: Long = 0L, val endMs: Long = 0L, + val creditsAvailable: Boolean = false, + val creditsStartMs: Long = 0L, ) /** @@ -420,8 +555,14 @@ data class GatewayTrickplay( /** One subtitle a viewer can choose to download, as the gateway offers it. */ @Serializable data class GatewaySubtitleCandidate( - // Bazarr's opaque provider handle. It round-trips untouched — nothing on this side - // parses it, and reconstructing it from the other fields would break the download. + // Which backend produced this row. It rides back with the token on the download call, + // because the gateway dispatches on it — the providers' tokens are opaque in different + // ways and handing one to the other is a mistake nothing could detect. Never derived + // here: an empty value is a gateway that predates the second provider, and the server + // reads that as Bazarr. + val source: String = "", + // The provider's opaque handle. It round-trips untouched — nothing on this side parses + // it, and reconstructing it from the other fields would break the download. val token: String = "", val language: String = "", val languageLabel: String = "", @@ -430,6 +571,10 @@ data class GatewaySubtitleCandidate( val forced: Boolean = false, val hearingImpaired: Boolean = false, val originalFormat: Boolean = false, + // Whether nobody wrote this translation. It is on the wire rather than only in the + // label because it is the one property that changes whether a viewer wants the row at + // all, and the gateway's ranking sinks it below everything a person wrote. + val machineOnly: Boolean = false, // What the row prints. Composed by the gateway so an app that predates a new wording // still renders it correctly, the same reason alert labels are the gateway's. val label: String = "", @@ -447,6 +592,19 @@ data class GatewaySubtitleSearch( @Serializable data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate) +@Serializable +data class GatewaySubtitleFixRequest(val subtitleId: String) + +/** The result of checking one subtitle's timing against another track on the title. */ +@Serializable +data class GatewaySubtitleFix( + val subtitleId: String = "", + val message: String = "", + val changed: Boolean = false, + val offsetMs: Long = 0L, + val reference: String = "", +) + /** * The result of a download: the item's tracks re-read from Emby after it was told to look * again, so the player can swap its media item and turn the new track on without a second diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt index 6a15eaa..cef259d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt @@ -112,4 +112,12 @@ interface EmbyApi { @Path("userId") userId: String, @Path("itemId") itemId: String, ): UserItemData + + /** Hides an item from Emby's resume/next-up feeds without changing watched state. */ + @POST("Users/{userId}/Items/{itemId}/HideFromResume") + suspend fun hideFromResume( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + @Query("Hide") hide: Boolean = true, + ): UserItemData } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index ba4fbaa..99d1f16 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -73,6 +73,17 @@ interface GatewayApi { @GET("v1/search") suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems + /** + * One page of a genre. A filter, not a query: the genre is the path rather than a term, + * so the gateway can ask Emby the question actually being asked. + */ + @GET("v1/genres/{genre}/items") + suspend fun genreItems( + @Path("genre") genre: String, + @Query("offset") offset: Int, + @Query("limit") limit: Int, + ): com.ponzischeme89.memby.data.model.GatewayGenrePage + @POST("v1/search/history") suspend fun recordSearch(@Body body: Map) @@ -141,6 +152,13 @@ interface GatewayApi { @GET("v1/features") suspend fun features(): GatewayFeatures + /** + * The colour scheme in force and the ones this viewer may pick between. Fetched only + * when the revision on the status poll moves — see `ThemeSync`. + */ + @GET("v1/theme") + suspend fun theme(): com.ponzischeme89.memby.data.model.GatewayThemeDocument + /** This viewer's settings as the server holds them, for whichever TV they sit at. */ @GET("v1/preferences") suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences @@ -229,6 +247,13 @@ interface GatewayApi { @Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest, ): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload + /** Check one existing subtitle against another and store a corrected copy when needed. */ + @POST("v1/items/{id}/subtitles/fix") + suspend fun fixSubtitle( + @Path("id") itemId: String, + @Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleFixRequest, + ): com.ponzischeme89.memby.data.model.GatewaySubtitleFix + /** 404 when nothing follows this item: a movie, or a series finale. */ @GET("v1/items/{id}/next") suspend fun nextEpisode( @@ -245,6 +270,9 @@ interface GatewayApi { @POST("v1/items/{id}/played") suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData + @POST("v1/items/{id}/hide-from-resume") + suspend fun hideFromResume(@Path("id") itemId: String): UserItemData + @POST("v1/playback/{phase}") suspend fun report( @Path("phase") phase: String, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt index 25aef99..1690cb5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/DetailPageComponents.kt @@ -94,17 +94,22 @@ import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.ValueSeparator import kotlinx.coroutines.launch // The detail page's names for the shared tokens. The neutrals used to be a shade darker // here than on the launcher, which is visible the moment a page opens from a row. -internal val DetailBackground = MembySurface -internal val DetailAccent = MembyAccent -internal val DetailText = MembyOnSurface -internal val DetailMutedText = MembyMutedText -internal val DetailQuietText = MembyQuietText -internal val DetailHairline = MembyHairline +// +// Each is a `get()` and must stay one: the tokens are snapshot state now that the palette +// comes from the server, and an alias that captured a value would pin this whole page to +// whichever theme was loaded when the class first initialised. +internal val DetailBackground: Color get() = MembySurface +internal val DetailAccent: Color get() = MembyAccent +internal val DetailText: Color get() = MembyOnSurface +internal val DetailMutedText: Color get() = MembyMutedText +internal val DetailQuietText: Color get() = MembyQuietText +internal val DetailHairline: Color get() = MembyHairline internal val DetailSideGutter = 58.dp /** @@ -202,10 +207,10 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) { Box( Modifier.fillMaxSize().background( Brush.horizontalGradient( - 0f to Color(0xF2080A0C), - 0.42f to Color(0xCC080A0C), - 0.72f to Color(0x38080A0C), - 1f to Color(0x10080A0C), + 0f to DetailBackground.copy(alpha = 0.95f), + 0.42f to DetailBackground.copy(alpha = 0.80f), + 0.72f to DetailBackground.copy(alpha = 0.22f), + 1f to DetailBackground.copy(alpha = 0.06f), ), ), ) @@ -213,8 +218,8 @@ internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) { Modifier.fillMaxSize().background( Brush.verticalGradient( 0f to Color(0x18000000), - 0.48f to Color(0x26080A0C), - 0.78f to Color(0xE6080A0C), + 0.48f to DetailBackground.copy(alpha = 0.15f), + 0.78f to DetailBackground.copy(alpha = 0.90f), 1f to DetailBackground, ), ), @@ -456,7 +461,7 @@ internal fun DetailPageScaffold( .padding(bottom = 24.dp) .shadow(16.dp, RoundedCornerShape(MembyPanelCorner)) .clip(RoundedCornerShape(MembyPanelCorner)) - .background(Color(0xEE20252A)) + .background(MembySurfaceRaised.copy(alpha = 0.93f)) .border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner)) .padding(horizontal = 20.dp, vertical = 10.dp), ) @@ -710,7 +715,7 @@ private fun DetailCircularAction( .graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f } .shadow(if (focused) 16.dp else 5.dp, CircleShape) .clip(CircleShape) - .background(if (action.active) DetailAccent else Color(0xB3262B30)) + .background(if (action.active) DetailAccent else MembySurfaceRaised.copy(alpha = 0.70f)) .border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape) .semantics { contentDescription = action.description } .onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() } @@ -954,7 +959,7 @@ private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modi Column( modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick), ) { - Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) { + Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(MembySurfaceRaised).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) { if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp)) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt index 2ffe6ab..4f793c7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/EpisodeDetailsOverlay.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MediaRating import com.ponzischeme89.memby.ui.detail.DetailZone @@ -92,7 +91,7 @@ fun EpisodeDetailsOverlay( modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository - val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings) var episodes by remember(item.seriesId) { mutableStateOf?>(null) } var loadFailed by remember(item.seriesId) { mutableStateOf(false) } var ratings by remember(item.id) { mutableStateOf>(emptyList()) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 238f9c6..51fe2a3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -97,6 +97,7 @@ import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.LiveTv import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PlayCircleFilled @@ -130,6 +131,8 @@ import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import com.ponzischeme89.memby.ui.theme.ValueSeparator import java.util.Locale import kotlinx.coroutines.Job @@ -139,10 +142,17 @@ import kotlinx.coroutines.launch // The launcher's names for the shared tokens. Both surfaces read from one palette now // (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side, // so a green or a grey that differs by a shade differs in front of the viewer. -private val EmbyGreen = MembyAccent -private val RailSurface = Color(0xF20C0F12) -private val MutedText = MembyMutedText -private val QuietText = MembyQuietText +// Getters rather than values: the tokens are snapshot state now that the palette is the +// server's answer, and capturing one here would pin the launcher to the theme that happened +// to be loaded when this class initialised. +private val EmbyGreen: Color get() = MembyAccent + +// The rail sits over the launcher rather than beside it, so it is the surface at the +// alpha the scrim wants rather than a near-black of its own. A constant here was one of +// the reasons a theme change left most of the screen looking untouched. +private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f) +private val MutedText: Color get() = MembyMutedText +private val QuietText: Color get() = MembyQuietText internal val TvRailCollapsedWidth = 54.dp internal val TvRailExpandedWidth = 184.dp internal val TvRailContentShift = 112.dp @@ -405,7 +415,7 @@ fun UserSwitcherOverlay( .heightIn(max = 400.dp) .shadow(12.dp, RoundedCornerShape(MembyPanelCorner)) .clip(RoundedCornerShape(MembyPanelCorner)) - .background(Color(0xFF090B0D)) + .background(MembySurface) .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner)) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false @@ -749,7 +759,7 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { .build() } } - Box(modifier.background(Color(0xFF090B0D))) { + Box(modifier.background(MembySurface)) { if (request != null) { AsyncImage( model = request, @@ -761,18 +771,18 @@ fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { Box( Modifier.fillMaxSize().background( Brush.horizontalGradient( - 0f to Color(0xFF090B0D), - 0.58f to Color(0xE3090B0D), - 1f to Color(0xA6090B0D), + 0f to MembySurface, + 0.58f to MembySurface.copy(alpha = 0.89f), + 1f to MembySurface.copy(alpha = 0.65f), ), ), ) Box( Modifier.fillMaxSize().background( Brush.verticalGradient( - 0f to Color(0x73090B0D), - 0.66f to Color(0xD6090B0D), - 1f to Color(0xFF090B0D), + 0f to MembySurface.copy(alpha = 0.45f), + 0.66f to MembySurface.copy(alpha = 0.84f), + 1f to MembySurface, ), ), ) @@ -852,6 +862,7 @@ fun MediaQuickActionsOverlay( onOpenDetails: (BaseItem) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, + onRemoveFromContinueWatching: (() -> Unit)? = null, rowTitle: String? = null, rowPinned: Boolean = false, onToggleRowPinned: (() -> Unit)? = null, @@ -863,7 +874,9 @@ fun MediaQuickActionsOverlay( onToggleRowPinned != null && onHideRow != null && onMoveRow != null - val actionCount = if (hasRowActions) 8 else 4 + val rowActionStartIndex = 3 + if (onRemoveFromContinueWatching != null) 1 else 0 + val actionCount = 4 + (if (onRemoveFromContinueWatching != null) 1 else 0) + + (if (hasRowActions) 4 else 0) val focusRequesters = remember(item.id) { List(actionCount) { FocusRequester() } } var focusedIndex by remember(item.id) { mutableStateOf(0) } // The menu can appear while OK is still physically held. Until that opening press @@ -886,7 +899,7 @@ fun MediaQuickActionsOverlay( .width(352.dp) .shadow(12.dp, RoundedCornerShape(MembyPanelCorner)) .clip(RoundedCornerShape(MembyPanelCorner)) - .background(Color(0xFF090B0D)) + .background(MembySurface) .border(1.dp, Color.White.copy(alpha = 0.07f), RoundedCornerShape(MembyPanelCorner)) .onPreviewKeyEvent { event -> val native = event.nativeKeyEvent @@ -975,6 +988,17 @@ fun MediaQuickActionsOverlay( onClose() }, ) + if (onRemoveFromContinueWatching != null) { + Spacer(Modifier.height(2.dp)) + QuickActionMenuItem( + label = "Remove from Continue Watching", + icon = Icons.Default.PlaylistRemove, + modifier = Modifier + .focusRequester(focusRequesters[3]) + .onFocusChanged { if (it.isFocused) focusedIndex = 3 }, + onClick = onRemoveFromContinueWatching, + ) + } if (hasRowActions) { Spacer(Modifier.height(6.dp)) Box( @@ -995,32 +1019,32 @@ fun MediaQuickActionsOverlay( label = if (rowPinned) "Unpin row" else "Pin row to top", icon = Icons.Default.PushPin, modifier = Modifier - .focusRequester(focusRequesters[3]) - .onFocusChanged { if (it.isFocused) focusedIndex = 3 }, + .focusRequester(focusRequesters[rowActionStartIndex]) + .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex }, onClick = onToggleRowPinned!!, ) QuickActionMenuItem( label = "Move row up", icon = Icons.Default.ArrowUpward, modifier = Modifier - .focusRequester(focusRequesters[4]) - .onFocusChanged { if (it.isFocused) focusedIndex = 4 }, + .focusRequester(focusRequesters[rowActionStartIndex + 1]) + .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 1 }, onClick = { onMoveRow!!(-1) }, ) QuickActionMenuItem( label = "Move row down", icon = Icons.Default.ArrowDownward, modifier = Modifier - .focusRequester(focusRequesters[5]) - .onFocusChanged { if (it.isFocused) focusedIndex = 5 }, + .focusRequester(focusRequesters[rowActionStartIndex + 2]) + .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 2 }, onClick = { onMoveRow!!(1) }, ) QuickActionMenuItem( label = "Hide this row", icon = Icons.Default.VisibilityOff, modifier = Modifier - .focusRequester(focusRequesters[6]) - .onFocusChanged { if (it.isFocused) focusedIndex = 6 }, + .focusRequester(focusRequesters[rowActionStartIndex + 3]) + .onFocusChanged { if (it.isFocused) focusedIndex = rowActionStartIndex + 3 }, onClick = onHideRow!!, ) } @@ -1339,6 +1363,7 @@ internal fun MediaRow( availableWidth: Dp, navigationFocusRequester: FocusRequester, contentEntryFocusRequester: FocusRequester?, + heroEntryFocusRequester: FocusRequester? = null, returnFocusItemId: String?, returnFocusRequester: FocusRequester, verticalFocusRequest: RowFocusRequest?, @@ -1473,6 +1498,12 @@ internal fun MediaRow( if (contentEntryFocusRequester != null) { cardModifier = cardModifier.focusRequester(contentEntryFocusRequester) } + // Where Down out of the home hero lands. It is a second + // requester rather than the entry one because while the hero is + // up, that one belongs to the hero. + if (heroEntryFocusRequester != null) { + cardModifier = cardModifier.focusRequester(heroEntryFocusRequester) + } } if (item.id == returnFocusItemId) { cardModifier = cardModifier.focusRequester(returnFocusRequester) @@ -1943,7 +1974,7 @@ private fun MediaCard( shape = RoundedCornerShape(MembyCardCorner), ) .clip(RoundedCornerShape(MembyCardCorner)) - .background(Color(0xFF20252A)) + .background(MembySurfaceRaised) .border( 2.dp, if (focused) Color.White else Color.White.copy(alpha = 0.07f), @@ -2058,12 +2089,10 @@ private fun MediaCard( } else { Spacer(Modifier.height(3.dp)) } - ItemRatingsStrip( - item = item, - load = focused, - compact = true, - modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), - ) + // No ratings strip under a poster. The scores are on the detail page the card + // opens and in the metadata panel beside the focused card; a third copy under + // every poster in every row cost a line of height on cards that are already + // two lines of text tall. } } } @@ -2079,7 +2108,7 @@ private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifie } Text( text = label, - color = if (status == "available") Color(0xFF071008) else Color(0xFF090B0D), + color = if (status == "available") Color(0xFF071008) else MembySurface, fontSize = 9.sp, fontWeight = FontWeight.Bold, letterSpacing = 0.5.sp, @@ -2119,8 +2148,8 @@ internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = // made is green, over is red, not out yet is blue, in cinemas is amber. val (background, foreground) = when (status) { "continuing", "released" -> EmbyGreen to Color(0xFF071008) - "upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D) - "incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D) + "upcoming", "announced" -> Color(0xFF5DA9FF) to MembySurface + "incinemas" -> Color(0xFFFFB454) to MembySurface "ended" -> Color(0xFFE04747) to Color.White else -> Color(0xFF3A4249) to Color(0xFFE1E5E8) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt new file mode 100644 index 0000000..e11ddbd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeGreeting.kt @@ -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, +): Boolean { + if (!hasHero || focusedRowId == null) return false + val focusedIndex = rowIds.indexOf(focusedRowId) + val continueIndex = rowIds.indexOf("continue") + return focusedIndex >= 0 && continueIndex >= 0 && focusedIndex <= continueIndex +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 09ffea7..4b6b1e5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -58,6 +58,8 @@ import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText +import com.ponzischeme89.memby.ui.theme.MembySurface +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised import kotlinx.coroutines.delay import java.util.TimeZone @@ -97,9 +99,21 @@ private fun currentLocalDay(): Long = System.currentTimeMillis().let { now -> private const val MIN_DAY_TICK_MS = 1_000L -/** The movie feature owns the header whenever the Home shelves are back at their top. */ -internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Boolean = - hasMovies && listAtTop +/** + * The movie feature owns the header until a shelf below it is being browsed. + * + * Scroll position alone was not enough. Continue Watching is the first row, so moving down + * into it scrolls nothing — the hero stayed while the viewer walked along a row whose cards + * had nowhere to describe themselves, which is the one thing the metadata panel exists for. + * [rowFocused] is what stands the hero down, and pressing Up out of the first row is what + * brings it back; the scroll test remains because a viewer who is somewhere down the + * launcher should not have the hero returned to them by a row losing focus. + */ +internal fun shouldShowHomeMovieHero( + hasMovies: Boolean, + listAtTop: Boolean, + rowFocused: Boolean = false, +): Boolean = hasMovies && listAtTop && !rowFocused /** * A hero card, the caption it wears and — when the gateway composed it — the one line @@ -236,6 +250,7 @@ internal fun HomeMovieHero( contentEntryFocusRequester: FocusRequester? = null, returnFocusItemId: String? = null, returnFocusRequester: FocusRequester? = null, + downFocusRequester: FocusRequester? = null, onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, modifier: Modifier = Modifier, @@ -250,10 +265,17 @@ internal fun HomeMovieHero( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { val featured = movies.first() + // Down is stated rather than left to Compose's spatial search. The hero cards + // are far wider than the cards below them, so the nearest-centre rule reaches + // past the first card of the shelf — Continue Watching would open on the second + // title when the whole point of that row is the first. var featuredModifier: Modifier = Modifier .weight(1f) .fillMaxHeight() - .focusProperties { left = navigationFocusRequester } + .focusProperties { + left = navigationFocusRequester + if (downFocusRequester != null) down = downFocusRequester + } if (contentEntryFocusRequester != null) { featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester) } @@ -272,8 +294,14 @@ internal fun HomeMovieHero( modifier = Modifier.width(miniWidth).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - movies.drop(1).take(3).forEach { pick -> + val minis = movies.drop(1).take(3) + minis.forEachIndexed { index, pick -> var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth() + // Only the bottom mini leaves the hero by Down; the ones above it are + // still walking their own column. + if (downFocusRequester != null && index == minis.lastIndex) { + miniModifier = miniModifier.focusProperties { down = downFocusRequester } + } if (pick.item.id == returnFocusItemId && returnFocusRequester != null) { miniModifier = miniModifier.focusRequester(returnFocusRequester) } @@ -324,14 +352,14 @@ private fun FeaturedMovieCard( contentDescription = "Featured, ${pick.label}, ${item.name}", modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), ) { focused -> - Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) { + Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) { HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) Box( Modifier.fillMaxSize().background( Brush.horizontalGradient( - 0f to Color(0xF20A0D10), - 0.48f to Color(0xA80A0D10), - 1f to Color(0x160A0D10), + 0f to MembySurface.copy(alpha = 0.95f), + 0.48f to MembySurface.copy(alpha = 0.66f), + 1f to MembySurface.copy(alpha = 0.09f), ), ), ) @@ -475,15 +503,15 @@ private fun MiniMovieCard( contentDescription = "${pick.label} movie, ${item.name}", modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)), ) { focused -> - Box(Modifier.fillMaxSize().background(Color(0xFF192027))) { + Box(Modifier.fillMaxSize().background(MembySurfaceRaised)) { HeroArtwork(item, previewArtwork, Modifier.fillMaxSize()) Box(Modifier.fillMaxSize().background(labelTint(pick.label))) Box( Modifier.fillMaxSize().background( Brush.horizontalGradient( - 0f to Color(0xE80A0D10), - 0.78f to Color(0x850A0D10), - 1f to Color(0x300A0D10), + 0f to MembySurface.copy(alpha = 0.91f), + 0.78f to MembySurface.copy(alpha = 0.52f), + 1f to MembySurface.copy(alpha = 0.19f), ), ), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index 9857a72..d7b5c72 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -338,10 +338,49 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } } + launch { warmDetailPage(item) } } } } + /** + * The two requests a detail page still opened cold, warmed while the card is focused. + * + * Everything else the page needs is already in hand by the time it opens — the item + * record, its "why you might enjoy it" and its playable URL are all warmed above — but + * the episode list and the trailer were not, so a series page opened with an empty + * Episodes pane, no progress, no next episode and no estimated finish, and every page + * opened with its trailer button missing until the network answered. Continue Watching + * is the case that matters most: every card on the launcher's busiest row is an + * episode, and all of them want the same show's list. + * + * **It waits longer than the metadata warm does**, and that is the whole cost control. + * An episode list is the largest request the client makes — a long-running show is a + * thousand records — so warming one per card as somebody scans across a shelf would + * spend more bandwidth than it saves. This job is cancelled the moment the D-pad moves, + * so a viewer travelling along a row never reaches it; one who has stopped on a card, + * which is what precedes a press, does. Both requests are single-flighted and cached on + * the repository, so the press that follows finds the answer rather than a second copy + * of the request. + */ + private suspend fun warmDetailPage(item: BaseItem) { + if (item.isSchedule) return + delay(DETAIL_PREFETCH_DELAY_MS - FOCUS_METADATA_DEBOUNCE_MS) + coroutineScope { + // A series is keyed on itself, an episode on the show it belongs to — which is + // exactly what its own detail page will ask for. + val seriesId = when { + item.isSeries -> item.id + item.isEpisode -> item.seriesId + else -> null + } + if (!seriesId.isNullOrBlank()) { + launch { runCatching { repository.getSeriesEpisodes(seriesId) } } + } + launch { runCatching { repository.getLocalTrailer(item.id) } } + } + } + fun setFavorite(item: BaseItem, favorite: Boolean) { updateFavorite(item, favorite) viewModelScope.launch(Dispatchers.IO) { @@ -389,6 +428,31 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { } } + fun removeFromContinueWatching(item: BaseItem) { + val previous = _state.value + _state.update { state -> + state.copy( + continueWatching = state.continueWatching.filterNot { it.id == item.id }, + rows = state.rows.map { row -> + if (row.kind == "continue" || row.kind == "nextup") { + row.copy(items = row.items.filterNot { it.id == item.id }) + } else { + row + } + }, + ) + } + _focusedItem.update { focused -> focused?.takeUnless { it.id == item.id } } + viewModelScope.launch(Dispatchers.IO) { + runCatching { repository.removeFromContinueWatching(item.id) } + .onSuccess { persistCurrentHome() } + .onFailure { + _state.value = previous + _focusedItem.value = item + } + } + } + private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) { fun BaseItem.updated(): BaseItem = if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this @@ -477,6 +541,16 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { companion object { private const val FOCUS_METADATA_DEBOUNCE_MS = 140L + + /** + * How long focus must rest on a card before its detail page is warmed, measured + * from the press that focused it. Deliberately well past + * [FOCUS_METADATA_DEBOUNCE_MS]: the metadata warm is a small request that decides + * what the panel beside the row says, so it should follow the D-pad closely, while + * this one can be a thousand episode records and should only follow a viewer who + * has stopped. See [warmDetailPage]. + */ + private const val DETAIL_PREFETCH_DELAY_MS = 450L private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L private fun initialFocusedItem(state: HomeUiState): BaseItem? = diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index d107d2d..3eb8a4c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -35,10 +35,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -114,7 +112,10 @@ import coil.imageLoader import coil.request.ImageRequest import com.ponzischeme89.memby.R import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.NightsStay import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.WbSunny import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings @@ -139,24 +140,43 @@ import com.ponzischeme89.memby.ui.settings.MembyReleaseHistory import com.ponzischeme89.memby.ui.settings.ReleaseNote import com.ponzischeme89.memby.ui.settings.SettingsSheet import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision +import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations import com.ponzischeme89.memby.ui.whatsnew.WhatsNewOverlay import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision +import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.ui.theme.MembyChipCorner +import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembyTheme import com.ponzischeme89.memby.ui.setup.SignInContent -import com.ponzischeme89.memby.update.AppInstall import com.ponzischeme89.memby.update.InstallPermission -import com.ponzischeme89.memby.update.UpdateChecker import com.ponzischeme89.memby.update.ServerUpdateService -import com.ponzischeme89.memby.update.UpdateStatus import androidx.tv.material3.Button import androidx.tv.material3.Card +import androidx.tv.material3.Icon import androidx.tv.material3.Text import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import java.util.Date +import java.util.Calendar + +/** + * The followed show a press stands for, as much of it as the card already knows. + * + * The button flips against this rather than against the gateway's answer, which is the + * whole list decorated with Sonarr's lifecycle for every entry on it. Everything Sonarr + * would fill in is left at its default — "Not found", "Unknown" — because that is honestly + * what this television knows for the moment, and the real row replaces it as soon as the + * save returns. The same promise `scheduleSeriesStub` makes about a detail page. + */ +private fun myShowStub(item: BaseItem): com.ponzischeme89.memby.data.model.MyShow = + com.ponzischeme89.memby.data.model.MyShow( + itemId = item.id, + title = item.name, + year = item.productionYear, + imageTag = item.imageTags["Primary"].orEmpty(), + ) /** Leaves enough of a TV viewport for a complete shelf, including card title metadata. */ internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp = @@ -345,7 +365,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) { } } - Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) { + Box(Modifier.fillMaxSize().background(MembySurface)) { val loaded = settings when { !initialUpdateCheckComplete -> MembyLoadingScreen( @@ -399,6 +419,16 @@ private fun AppRoot(onCloseSettings: () -> Unit) { key(loaded.userId, loaded.serverUrl) { HomeScreen(settings = loaded) } + // Snow, bats or blossom for the few days a year a season is on, over the + // launcher and nowhere else. Not over playback — a film is the one thing + // nothing may drift across — and not over the settings sheet, which is a + // page of small text. It is collected here rather than inside HomeScreen so + // an arriving theme cannot invalidate the rows: this is a sibling node, and + // the only thing that recomposes when December starts. + SeasonalDecorations( + decoration = ServiceLocator.themeSync.theme + .collectAsState().value?.decoration.orEmpty(), + ) // Over the launcher, not instead of it: the cached rows are already drawn // behind this. Composed after HomeScreen so its Back handler and its focus // request are the ones that win. @@ -513,7 +543,7 @@ private fun MembyLoadingScreen(quoteStyle: String? = null) { .height(2.dp) .drawBehind { drawRoundRect( - color = Color(0xFF52B54B), + color = MembyAccent, size = Size(size.width * glow, size.height), cornerRadius = CornerRadius(size.height / 2f), ) @@ -572,7 +602,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) { colors = listOf( Color(0xFF0A0E11), Color(0xFF101A17), - Color(0xFF090B0D), + MembySurface, ), ), ), @@ -585,7 +615,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) { .graphicsLayer { alpha = haloAlpha } .background( Brush.radialGradient( - colors = listOf(Color(0xFF52B54B), Color.Transparent), + colors = listOf(MembyAccent, Color.Transparent), ), CircleShape, ), @@ -647,7 +677,7 @@ internal fun FirstRunScreen(onGetStarted: () -> Unit) { scaleY = logoScale alpha = haloAlpha } - .background(Color(0xFF52B54B), CircleShape), + .background(MembyAccent, CircleShape), ) Image( painter = painterResource(R.drawable.emby_logo), @@ -821,7 +851,7 @@ private fun ProfileChooser( Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF090B0D)), + .background(MembySurface), ) { Column( modifier = Modifier @@ -1107,7 +1137,7 @@ private fun OnboardingPeopleRow( } else { Text(person.name.take(1).uppercase(), color = Color(0xFFD5E8D7), fontSize = 46.sp, fontWeight = FontWeight.Light) } - if (selected) Text("✓", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(Color(0xFF52B54B), CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) + if (selected) Text("✓", color = Color.White, fontSize = 17.sp, fontWeight = FontWeight.Bold, modifier = Modifier.align(Alignment.BottomEnd).background(MembyAccent, CircleShape).padding(horizontal = 9.dp, vertical = 5.dp)) } Text(person.name, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) } @@ -1270,7 +1300,7 @@ private fun ProfileTile( .background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D)) .border( width = if (current) 4.dp else if (focused) 3.dp else 1.dp, - color = if (current) Color(0xFF52B54B) else if (focused) Color.White else Color(0xFF5B646C), + color = if (current) MembyAccent else if (focused) Color.White else Color(0xFF5B646C), shape = CircleShape, ), contentAlignment = Alignment.Center, @@ -1351,6 +1381,7 @@ private fun HomeScreen( var detailsAiringNotice by remember { mutableStateOf(null) } var quickMenuItem by remember { mutableStateOf(null) } var quickMenuRowId by remember { mutableStateOf(null) } + var focusedHomeRowId by remember { mutableStateOf(null) } var myShows by remember(settings.userId) { mutableStateOf>(emptyList()) } var selectedMyShow by remember { mutableStateOf(null) } var removingMyShow by remember { mutableStateOf(false) } @@ -1390,7 +1421,15 @@ private fun HomeScreen( val navigationFocusRequester = navigationFocusRequesters.getValue(selectedDestination) val contentFocusRequester = remember { FocusRequester() } val cardReturnFocusRequester = remember { FocusRequester() } + // Down out of the hero, stated rather than left to Compose's spatial search. The + // featured card is wide, so its centre sits nearer the second card of the shelf below + // than the first, and the search obligingly skipped past the thing somebody had just + // been reading about in Continue Watching. + val heroRowEntryFocusRequester = remember { FocusRequester() } var initialFocusRequested by remember { mutableStateOf(false) } + // Incremented when a rail selection needs to restore a card in the lazy row list. + // The list owns the scroll state, so it also owns the actual restoration below. + var rowListFocusRestoreRequest by remember { mutableStateOf(0) } val playbackLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), ) { @@ -1479,6 +1518,9 @@ private fun HomeScreen( subtitlesEnabled = playable.subtitlesEnabled, selectedSubtitleId = playable.selectedSubtitleId, subtitleDownloadAvailable = playable.subtitleDownloadAvailable, + trickplayAvailable = playable.trickplayAvailable, + skipIntroAvailable = playable.skipIntroAvailable, + endCreditsAvailable = playable.endCreditsAvailable, mediaSourceId = playable.mediaSourceId, playSessionId = playable.playSessionId, playMethod = playable.playMethod, @@ -1525,7 +1567,14 @@ private fun HomeScreen( emptyList() } } + val showHomeGreeting = selectedDestination == BrowseDestination.HOME && + shouldShowHomeGreeting( + hasHero = homeHeroMovies.isNotEmpty(), + focusedRowId = focusedHomeRowId, + rowIds = rows.map(HomeBrowseRow::id), + ) LaunchedEffect(selectedDestination) { + focusedHomeRowId = null if (selectedDestination == BrowseDestination.FAVORITES) { recentSearches = repo.getRecentSearches() } @@ -1582,7 +1631,7 @@ private fun HomeScreen( animationSpec = tween(150), label = "navigation-content-shift", ) - Box(Modifier.fillMaxSize().background(Color(0xFF090B0D))) { + Box(Modifier.fillMaxSize().background(MembySurface)) { Row(Modifier.fillMaxSize()) { TvNavigationRail( selected = selectedDestination, @@ -1614,18 +1663,30 @@ private fun HomeScreen( showSettings = false restoreRailAfterSettings = false selectedDestination = destination - destinationFocus[destination]?.let { (rowId, itemId) -> + val savedFocus = destinationFocus[destination] + savedFocus?.let { (rowId, itemId) -> returnRowId = rowId returnItemId = itemId } - scope.launch { - // Let the destination compose and attach its entry target - // before transferring focus out of the rail. - kotlinx.coroutines.delay(16L) - if (destinationFocus.containsKey(destination)) { - runCatching { cardReturnFocusRequester.requestFocus() } - } else { - runCatching { contentFocusRequester.requestFocus() } + if ( + savedFocus != null && + savedFocus.first != HOME_HERO_ROW_ID && + savedFocus.first != SEARCH_ROW_ID + ) { + // A low shelf may have fallen out of LazyColumn composition + // while the rail owned focus. Let the list scroll it back in + // before asking its card for focus. + rowListFocusRestoreRequest += 1 + } else { + scope.launch { + // Let the destination compose and attach its entry target + // before transferring focus out of the rail. + kotlinx.coroutines.delay(16L) + if (savedFocus != null) { + runCatching { cardReturnFocusRequester.requestFocus() } + } else { + runCatching { contentFocusRequester.requestFocus() } + } } } } @@ -1711,9 +1772,14 @@ private fun HomeScreen( } } val hasHomeHero = selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty() + // Set by a card in a shelf taking focus and cleared by the hero taking it + // back. Keyed on the destination so arriving at Home never inherits where + // focus happened to be on another one. + var rowFocusedBelowHero by remember(selectedDestination) { mutableStateOf(false) } val showHomeHero = shouldShowHomeMovieHero( hasMovies = hasHomeHero, listAtTop = homeListAtTop, + rowFocused = rowFocusedBelowHero, ) val metadataHeight = homeHeaderHeight(maxHeight, showHomeHero) val contentWidth = maxWidth @@ -1730,6 +1796,40 @@ private fun HomeScreen( } var rowFocusMoving by remember(selectedDestination) { mutableStateOf(false) } var rowFocusRequestId by remember(selectedDestination) { mutableStateOf(0) } + val firstPopulatedRowId = remember(rows) { + rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id + } + val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + + if ( + selectedDestination == BrowseDestination.FAVORITES && + recentSearches.isNotEmpty() + ) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0 + LaunchedEffect(rowListFocusRestoreRequest, selectedDestination) { + if (rowListFocusRestoreRequest == 0) return@LaunchedEffect + val rowId = returnRowId ?: run { + rowListFocusRestoreRequest = 0 + return@LaunchedEffect + } + val rowIndex = rows.indexOfFirst { it.id == rowId } + if (rowIndex < 0) { + rowListFocusRestoreRequest = 0 + kotlinx.coroutines.delay(16L) + runCatching { contentFocusRequester.requestFocus() } + return@LaunchedEffect + } + // FocusRequester cannot focus a lazy child that is not composed. The + // airing shelf is commonly just beyond the retained viewport, which + // made focus fall back to its still-attached neighbour above. + verticalState.scrollToItem(leadingItemCount + rowIndex) + repeat(3) { + kotlinx.coroutines.delay(16L) + if (runCatching { cardReturnFocusRequester.requestFocus() }.isSuccess) { + rowListFocusRestoreRequest = 0 + return@LaunchedEffect + } + } + rowListFocusRestoreRequest = 0 + } Column(Modifier.fillMaxSize()) { if (showHomeHero) { HomeMovieHero( @@ -1738,8 +1838,15 @@ private fun HomeScreen( contentEntryFocusRequester = contentFocusRequester, returnFocusItemId = returnItemId.takeIf { returnRowId == HOME_HERO_ROW_ID }, returnFocusRequester = cardReturnFocusRequester, + // Only when there is a card down there to attach it to: a + // requester naming nothing throws the moment Down is pressed. + downFocusRequester = heroRowEntryFocusRequester.takeIf { + firstPopulatedRowId != null + }, onItemFocused = { item -> navigationExpanded = false + rowFocusedBelowHero = false + focusedHomeRowId = null destinationFocus[selectedDestination] = HOME_HERO_ROW_ID to item.id returnRowId = HOME_HERO_ROW_ID returnItemId = item.id @@ -1851,7 +1958,10 @@ private fun HomeScreen( contentEntryFocusRequester = contentFocusRequester.takeIf { !hasHomeHero && (selectedDestination != BrowseDestination.SHOWS || myShows.isEmpty()) && - row.id == rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id + row.id == firstPopulatedRowId + }, + heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf { + hasHomeHero && row.id == firstPopulatedRowId }, returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, returnFocusRequester = cardReturnFocusRequester, @@ -1869,7 +1979,25 @@ private fun HomeScreen( itemCounts = rowItemCounts, currentIndex = sourceRowIndex, direction = direction, - ) ?: return@moveVertical false + ) ?: run { + // Up out of the topmost shelf is the way back to the + // hero, and it has to be handled here: the hero is + // not composed while a row holds focus, so there is + // nothing above for Compose's own focus search to + // find and the press would otherwise be dead. + if (direction == RowFocusDirection.UP && hasHomeHero) { + rowFocusedBelowHero = false + scope.launch { + verticalState.animateScrollToItem(0) + // Let the hero compose and attach its entry + // target before focus is handed to it. + kotlinx.coroutines.delay(16L) + runCatching { contentFocusRequester.requestFocus() } + } + return@moveVertical true + } + return@moveVertical false + } val destinationRow = rows[destinationRowIndex] val destinationItemIndex = rowEntryItemIndex( sourceIndex = sourceItemIndex, @@ -1884,11 +2012,6 @@ private fun HomeScreen( itemIndex = destinationItemIndex, requestId = rowFocusRequestId, ) - val leadingItemCount = (if (selectedDestination == BrowseDestination.SHOWS) 1 else 0) + - if ( - selectedDestination == BrowseDestination.FAVORITES && - recentSearches.isNotEmpty() - ) 1 else if (selectedDestination == BrowseDestination.FOR_YOU) 1 else 0 rowFocusMoving = true scope.launch { // Compose the destination row before its MediaRow tries @@ -1904,6 +2027,10 @@ private fun HomeScreen( onItemFocused = { item -> val itemIndex = row.items.indexOfFirst { it.id == item.id } if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex + rowFocusedBelowHero = true + if (selectedDestination == BrowseDestination.HOME) { + focusedHomeRowId = row.id + } destinationFocus[selectedDestination] = row.id to item.id returnRowId = row.id returnItemId = item.id @@ -1969,6 +2096,8 @@ private fun HomeScreen( ) } HomeClock( + showGreeting = showHomeGreeting, + username = settings.username, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 24.dp, bottom = 18.dp), @@ -2149,20 +2278,33 @@ private fun HomeScreen( onToggleFavorite = homeViewModel::setFavorite, isMyShow = myShows.any { it.itemId == selected.id }, onToggleMyShow = { item, saved -> + // Optimistic, the way a favourite already is. Following a show is a + // press with an obvious outcome, and the server's answer to it is the + // *whole* list decorated with Sonarr's lifecycle for every show on it + // — a request the viewer has no reason to sit through watching an + // unchanged button. The row that lands a moment later replaces this + // placeholder; a failure puts the button back where it was. + val previous = myShows + myShows = if (saved) { + myShows.filterNot { it.itemId == item.id } + myShowStub(item) + } else { + myShows.filterNot { it.itemId == item.id } + } + if (saved) { + Toast.makeText( + context, + "${item.name} added to My Shows", + Toast.LENGTH_SHORT, + ).show() + } scope.launch { if (saved) { - runCatching { repo.saveMyShow(item) }.onSuccess { - myShows = it - Toast.makeText( - context, - "${item.name} added to My Shows", - Toast.LENGTH_SHORT, - ).show() - } + runCatching { repo.saveMyShow(item) } + .onSuccess { myShows = it } + .onFailure { myShows = previous } } else { - runCatching { repo.removeMyShow(item.id) }.onSuccess { - myShows = myShows.filterNot { it.itemId == item.id } - } + runCatching { repo.removeMyShow(item.id) } + .onFailure { myShows = previous } } } }, @@ -2286,6 +2428,30 @@ private fun HomeScreen( }, onSetFavorite = homeViewModel::setFavorite, onSetPlayed = homeViewModel::setPlayed, + onRemoveFromContinueWatching = if ( + rows.firstOrNull { it.id == quickMenuRowId }?.kind == MediaRowKind.CONTINUE + ) { + { + quickMenuItem = null + quickMenuRowId = null + homeViewModel.removeFromContinueWatching(selected) + scope.launch { + kotlinx.coroutines.delay(16L) + val removalFocusRequester = if ( + selectedDestination == BrowseDestination.HOME && homeHeroMovies.isNotEmpty() + ) { + heroRowEntryFocusRequester + } else { + contentFocusRequester + } + if (runCatching { removalFocusRequester.requestFocus() }.isFailure) { + runCatching { navigationFocusRequester.requestFocus() } + } + } + } + } else { + null + }, rowTitle = rows.firstOrNull { it.id == quickMenuRowId }?.title, rowPinned = quickMenuRowId in settings.homePinnedRows.decodeRowIds(), onToggleRowPinned = quickMenuRowId?.let { rowId -> @@ -2484,7 +2650,7 @@ private fun RecentSearchesRow( maxLines = 1, modifier = Modifier .background( - if (focused) Color(0xFF52B54B) else Color(0xFF1A2129), + if (focused) MembyAccent else Color(0xFF1A2129), ) .padding(horizontal = 17.dp, vertical = 10.dp), ) @@ -2495,7 +2661,11 @@ private fun RecentSearchesRow( } @Composable -private fun HomeClock(modifier: Modifier = Modifier) { +private fun HomeClock( + showGreeting: Boolean, + username: String?, + modifier: Modifier = Modifier, +) { val context = LocalContext.current val timeFormatter = remember(context) { DateFormat.getTimeFormat(context) } var currentTime by remember { mutableStateOf(Date()) } @@ -2510,16 +2680,58 @@ private fun HomeClock(modifier: Modifier = Modifier) { } } - Text( - text = timeFormatter.format(currentTime), - color = Color.White.copy(alpha = 0.86f), - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - modifier = modifier - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xB30A0D10)) - .padding(horizontal = 12.dp, vertical = 7.dp), + val period = homeGreetingPeriod( + Calendar.getInstance().apply { time = currentTime }.get(Calendar.HOUR_OF_DAY), ) + val name = friendlyProfileName(username) + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.animation.AnimatedVisibility( + visible = showGreeting && name != null, + enter = androidx.compose.animation.fadeIn(tween(180)), + exit = androidx.compose.animation.fadeOut(tween(120)), + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xB30A0D10)) + .padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = when (period) { + HomeGreetingPeriod.MORNING -> Icons.Default.LightMode + HomeGreetingPeriod.AFTERNOON -> Icons.Default.WbSunny + HomeGreetingPeriod.EVENING -> Icons.Default.NightsStay + }, + contentDescription = null, + tint = MembyAccent, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "${period.words}, $name", + color = Color.White.copy(alpha = 0.9f), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + } + } + if (showGreeting && name != null) Spacer(Modifier.width(8.dp)) + Text( + text = timeFormatter.format(currentTime), + color = Color.White.copy(alpha = 0.86f), + fontSize = 18.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xB30A0D10)) + .padding(horizontal = 12.dp, vertical = 7.dp), + ) + } } @Composable @@ -2661,6 +2873,7 @@ private fun FocusedQuickActionsOverlay( onOpenDetails: (BaseItem) -> Unit, onSetFavorite: (BaseItem, Boolean) -> Unit, onSetPlayed: (BaseItem, Boolean) -> Unit, + onRemoveFromContinueWatching: (() -> Unit)?, rowTitle: String?, rowPinned: Boolean, onToggleRowPinned: (() -> Unit)?, @@ -2674,6 +2887,7 @@ private fun FocusedQuickActionsOverlay( onOpenDetails = onOpenDetails, onSetFavorite = onSetFavorite, onSetPlayed = onSetPlayed, + onRemoveFromContinueWatching = onRemoveFromContinueWatching, rowTitle = rowTitle, rowPinned = rowPinned, onToggleRowPinned = onToggleRowPinned, @@ -3058,7 +3272,7 @@ private fun ForYouNudgeBanner( Modifier .size(10.dp) .clip(CircleShape) - .background(Color(0xFF52B54B)), + .background(MembyAccent), ) Spacer(Modifier.width(16.dp)) Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { @@ -3151,161 +3365,6 @@ private fun HeaderAction(label: String, onClick: () -> Unit) { ) } -@Composable -private fun SettingsPanel(settings: Settings, onBack: () -> Unit) { - val context = LocalContext.current - val store = ServiceLocator.settings - val scope = rememberCoroutineScope() - val checker = remember { UpdateChecker(context) } - - // Hardware Back returns to the home screen instead of leaving the app. - BackHandler(onBack = onBack) - - var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) } - var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) } - var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) } - var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } - var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } - - var checking by remember { mutableStateOf(false) } - var status by remember { mutableStateOf(null) } - var installMessage by remember { mutableStateOf(null) } - // The system installer reports back here, not to downloadAndInstall's caller. - LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } } - - val firstFocus = remember { FocusRequester() } - LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 56.dp, vertical = 48.dp) - .width(760.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Text("Settings", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold) - - // --- Screensaver appearance --- - Text("Screensaver", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) - Button( - onClick = { - showLogo = !showLogo - scope.launch { store.setShowTitleLogo(showLogo) } - }, - modifier = Modifier.focusRequester(firstFocus), - ) { Text(if (showLogo) "Show title logo: On" else "Show title logo: Off") } - Text( - "When on, shows each title's logo artwork from Emby instead of plain text " + - "(falls back to text when a title has no logo).", - color = Color(0xFF9AA3AC), - fontSize = 14.sp, - ) - - Text("Spinner colour", color = Color(0xFF9AA3AC), fontSize = 14.sp) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - listOf( - "White" to "FFFFFF", - "Emby green" to "52B54B", - "Netflix red" to "E50914", - ).forEach { (label, hex) -> - val selected = ringColor.equals(hex, ignoreCase = true) - Button( - onClick = { - ringColor = hex - scope.launch { store.setRingColor(hex) } - }, - ) { Text(if (selected) "● $label" else label) } - } - } - - // --- Updates --- - Text("Updates", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) - Text( - "Installed version: ${checker.installedVersion}", - color = Color(0xFFB9C0C7), - fontSize = 16.sp, - ) - - TvTextField( - label = "Gitea URL (e.g. https://gitea.example.com)", - value = baseUrl, - onValueChange = { baseUrl = it; status = null }, - keyboardType = KeyboardType.Uri, - ) - TvTextField( - label = "Repository (owner/repo)", - value = repoPath, - onValueChange = { repoPath = it; status = null }, - ) - TvTextField( - label = "Access token", - value = token, - onValueChange = { token = it; status = null }, - isPassword = true, - ) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Button( - onClick = { - if (checking) return@Button - checking = true - status = null - installMessage = null - scope.launch { - store.setUpdateConfig(baseUrl, repoPath, token) - status = checker.check(baseUrl, repoPath, token) - checking = false - } - }, - ) { Text(if (checking) "Checking…" else "Check for updates") } - - Button(onClick = onBack) { Text("Back") } - } - - when (val s = status) { - is UpdateStatus.UpToDate -> Text( - "You're on the latest version (${s.version}).", - color = Color(0xFF7BD88F), - fontSize = 16.sp, - ) - is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 16.sp) - is UpdateStatus.Available -> { - Text( - "Update available: ${s.version}", - color = Color(0xFF7BD88F), - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - ) - if (s.notes.isNotBlank()) { - Text(s.notes, color = Color(0xFFB9C0C7), fontSize = 14.sp) - } - Button( - onClick = { - installMessage = "Downloading update…" - scope.launch { - val result = checker.downloadAndInstall(s.apkUrl, token) - installMessage = result.exceptionOrNull()?.message - ?: "Opening the installer…" - } - }, - ) { Text("Download & install") } - } - null -> {} - } - - installMessage?.let { Text(it, color = Color(0xFFB9C0C7), fontSize = 15.sp) } - - Text("About", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) - Text( - "${stringResource(R.string.app_name)} ${checker.installedVersion} · by " + - stringResource(R.string.developer_name), - color = Color(0xFF9AA3AC), - fontSize = 14.sp, - ) - } -} - @Composable private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) { val repo = ServiceLocator.repository diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt index a2dca09..3f029ef 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -55,8 +55,9 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon import androidx.tv.material3.Text import kotlinx.coroutines.delay +import com.ponzischeme89.memby.ui.theme.MembyAccent -private val MaintenanceAccent = Color(0xFF52B54B) +private val MaintenanceAccent: Color get() = MembyAccent private val MaintenanceTitle = Color(0xFFF2F5F7) private val MaintenanceBody = Color(0xFFAEB7BF) private val MaintenanceFaint = Color(0xFFA2ADB5) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt index f4b35af..b507da5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MediaDetailsOverlay.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.RelatedContent -import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MediaRating import com.ponzischeme89.memby.ui.detail.DetailTab @@ -53,7 +52,8 @@ fun MediaDetailsOverlay( onOpenItem: (BaseItem) -> Unit = {}, modifier: Modifier = Modifier, ) { - val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + val settings by ServiceLocator.repository.settingsFlow + .collectAsState(initial = ServiceLocator.repository.currentSettings) var related by remember(item.id) { mutableStateOf(null) } var trailer by remember(item.id) { mutableStateOf(null) } var ratings by remember(item.id) { mutableStateOf>(emptyList()) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index 550455c..528fa87 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -43,6 +43,7 @@ import com.ponzischeme89.memby.data.model.MyShow import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised @Composable internal fun MyShowsStrip( @@ -147,7 +148,7 @@ private fun MyShowCard( .aspectRatio(2f / 3f) .shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp)) .clip(RoundedCornerShape(9.dp)) - .background(Color(0xFF20252A)) + .background(MembySurfaceRaised) .border( 2.dp, if (focused) Color.White else Color.White.copy(alpha = 0.07f), @@ -252,7 +253,7 @@ internal fun MyShowDetailsOverlay( .width(170.dp) .aspectRatio(2f / 3f) .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFF20252A)) + .background(MembySurfaceRaised) .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)), ) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt index f61c774..d6434e0 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/SeriesDetailsOverlay.kt @@ -62,7 +62,6 @@ import coil.compose.AsyncImage import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.RelatedContent import com.ponzischeme89.memby.data.ServerConfig -import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.estimateSeriesPace import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.MediaRating @@ -109,7 +108,7 @@ fun SeriesDetailsOverlay( modifier: Modifier = Modifier, ) { val repository = ServiceLocator.repository - val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + val settings by repository.settingsFlow.collectAsState(initial = repository.currentSettings) var episodes by remember(item.id) { mutableStateOf?>(null) } var loadFailed by remember(item.id) { mutableStateOf(false) } var related by remember(item.id) { mutableStateOf(null) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt index 5cd7f27..896c894 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/ServiceAlertBanner.kt @@ -52,8 +52,9 @@ import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.MaintenanceMonitor import com.ponzischeme89.memby.data.ServiceAlert import kotlin.math.ceil +import com.ponzischeme89.memby.ui.theme.MembyAccent -private val AlertAccent = Color(0xFF52B54B) +private val AlertAccent: Color get() = MembyAccent private val AlertTitle = Color(0xFFF2F5F7) private val AlertBody = Color(0xFFC3CBD2) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt index 4271731..5a460fe 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UpdateScreen.kt @@ -65,8 +65,10 @@ import com.ponzischeme89.memby.update.AppInstall import com.ponzischeme89.memby.update.InstallPermissionRequiredException import com.ponzischeme89.memby.update.UpdateChecker import kotlinx.coroutines.launch +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembySurface -private val UpdateAccent = Color(0xFF52B54B) +private val UpdateAccent: Color get() = MembyAccent private val UpdateTitle = Color(0xFFF2F5F7) private val UpdateBody = Color(0xFFAEB7BF) private val UpdateFaint = Color(0xFFA2ADB5) @@ -175,7 +177,7 @@ fun UpdateScreen( modifier = modifier .fillMaxSize() // Opaque, not a scrim: a required update is not a dialog over usable content. - .background(Color(0xFF0B0E11)), + .background(MembySurface), ) { Canvas(Modifier.fillMaxSize()) { val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt new file mode 100644 index 0000000..dc754f3 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/CreditsSpeed.kt @@ -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 = 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index cce5076..82c5531 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -48,6 +48,7 @@ import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView +import androidx.media3.ui.TimeBar import androidx.lifecycle.lifecycleScope import androidx.lifecycle.Lifecycle import androidx.lifecycle.repeatOnLifecycle @@ -60,6 +61,7 @@ import com.ponzischeme89.memby.R import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.IntroSegment +import com.ponzischeme89.memby.data.creditsWorthShowing import com.ponzischeme89.memby.data.NextEpisode import com.ponzischeme89.memby.data.Playable import com.ponzischeme89.memby.data.PlayableSubtitle @@ -92,6 +94,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.Date import java.util.Locale +import kotlin.math.abs import kotlin.math.ceil /** @@ -211,6 +214,8 @@ class PlayerActivity : ComponentActivity() { * screen's Back has. */ private var subtitleDownloadExpanded = false + /** Shown once a manually selected subtitle survives a media-item reload. */ + private var pendingSubtitleConfirmation: String? = null private var subtitleOverlay: View? = null private var castOverlay: View? = null private var castJob: Job? = null @@ -262,6 +267,32 @@ class PlayerActivity : ComponentActivity() { private var skipIntroTaken = false private var skipIntroDismissed = false + // The closing credits, moved aside and sped up. [creditsStartMs] is where Emby says they + // begin, read from the same chapter lookup the intro markers come out of, so this whole + // feature costs no request of its own. + private var endCreditsAvailable = false + private var creditsView: View? = null + private var creditsCountdown: TextView? = null + private var creditsSpeedChip: TextView? = null + private var creditsStartMs: Long? = null + private var creditsActive = false + /** + * The viewer asked to watch the credits, so nothing offers again for this episode. + * + * Never re-armed within an episode, the way [skipIntroTaken] is not: somebody who pressed + * "Watch credits" and then seeked back into the roll wants the roll, and re-offering + * would shrink their picture again the moment they got there. + */ + private var creditsDismissed = false + private var creditsSpeedJob: Job? = null + /** + * The fastest this stream has been allowed to run. It only ever falls — see + * [creditsCeilingAfterStall]. Reset per episode, because the next file may be a + * different bitrate entirely and one that stalled says nothing about the next. + */ + private var creditsSpeedCeiling = CREDITS_TARGET_SPEED + private var creditsEnteredAtMs = 0L + // The ten-minute lower third. Shown once per item — a viewer who has been told is // told; re-announcing it every time they seek would be nagging, not informing. private var timeRemainingCue: View? = null @@ -287,6 +318,19 @@ class PlayerActivity : ComponentActivity() { * path of a press: see [TrickplayPreview]. */ private var trickplayAvailable = false + + /** + * The same frames, drawn above the time bar while the viewer scrubs it. + * + * Skipping with the transport hidden and scrubbing with it up are the two ways to move + * through a film, and only one of them used to show where it was going — which read as + * the previews having stopped working the moment somebody pressed a button to look at + * the controls. [scrubAnchor] is the bar the strip rides above; both are owned by the + * controller layout, so they go away with it and need no hiding of their own. + */ + private var scrubPreview: ImageView? = null + private var scrubAnchor: View? = null + private var scrubPositionMs = 0L private val trickplayPreview by lazy { TrickplayPreview( scope = lifecycleScope, @@ -364,6 +408,13 @@ class PlayerActivity : ComponentActivity() { // Default false, not true: a missing extra must never conjure a section whose // only row leads to a request the backend cannot answer. subtitleDownloadAvailable = intent.getBooleanExtra(EXTRA_SUBTITLE_DOWNLOAD, false) + // Same rule, and the same default, for the three that decide whether the previews, + // the skip button and the credits pane are offered at all. On the request form of + // the launch these are corrected by adoptPlayable once the server settles; on this + // form the intent is the only thing that will ever say. + trickplayAvailable = intent.getBooleanExtra(EXTRA_TRICKPLAY, false) + skipIntroAvailable = intent.getBooleanExtra(EXTRA_SKIP_INTRO, false) + endCreditsAvailable = intent.getBooleanExtra(EXTRA_END_CREDITS, false) Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}") setContentView(R.layout.activity_player) @@ -383,6 +434,7 @@ class PlayerActivity : ComponentActivity() { view.findViewById(androidx.media3.ui.R.id.exo_settings)?.setOnClickListener { showTrackMenu() } + bindScrubPreview(view) applyPictureMode() loadingView = findViewById(R.id.playback_loading) loadingTitleView = findViewById(R.id.playback_loading_title) @@ -431,12 +483,20 @@ class PlayerActivity : ComponentActivity() { override fun onPlaybackStateChanged(playbackState: Int) { recordPlaybackState(playbackState) when (playbackState) { - Player.STATE_BUFFERING -> + Player.STATE_BUFFERING -> { + // A stall at double speed is the stream failing to keep up + // with it, so the ceiling falls before the overlay goes up. + stepDownCreditsSpeed() if (!prerollActive && !seekBuffering) showPlaybackLoading() + } Player.STATE_READY -> { trace.mark(PlaybackTrace.READY) endSeekBuffering() hidePlaybackError() + pendingSubtitleConfirmation?.let { label -> + pendingSubtitleConfirmation = null + showSubtitleConfirmation(label) + } if (prerollActive) bindPrerollNow(playback.duration) if (!prerollActive && renderedFirstFrame) { hidePlaybackLoading() @@ -525,6 +585,7 @@ class PlayerActivity : ComponentActivity() { setUpCastOverlay() setUpNextUpBanner() setUpSkipIntro() + setUpEndCredits() setUpTimeRemainingCue() setUpSeasonFinaleCue() setUpPlaybackError() @@ -631,6 +692,7 @@ class PlayerActivity : ComponentActivity() { subtitleDownloadAvailable = playable.subtitleDownloadAvailable trickplayAvailable = playable.trickplayAvailable skipIntroAvailable = playable.skipIntroAvailable + endCreditsAvailable = playable.endCreditsAvailable subtitleAutoSelectionAttempted = false initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L) playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it } @@ -864,6 +926,9 @@ class PlayerActivity : ComponentActivity() { // away and the markers that describe them are worth nothing until there is a // picture to skip forward in. loadIntroSegment() + // Free: the repository has cached one reading of the chapter list, and the credits + // marker is the other half of what the line above just fetched. + loadCreditsStart() startSkipIntroWatch() reportStarted(playback.currentPosition) startProgressReporting() @@ -1602,10 +1667,84 @@ class PlayerActivity : ComponentActivity() { castOverlay?.isVisible != true && subtitleOverlay?.isVisible != true && nextUpBanner?.isVisible != true && + creditsView?.isVisible != true && loadingView?.isVisible != true && errorView?.isVisible != true } + /** + * Wires the strip above the time bar to media3's own scrubbing. + * + * The transport being up is what makes this a second implementation rather than a + * branch of [nudgeSeek]: those presses are the time bar's, and it is the one thing that + * knows where a scrub has reached. So the frames follow *it* — [TimeBar.OnScrubListener] + * is the same contract media3's own position text is updated from, which is why the + * strip and the clock beneath it can never disagree about where the viewer is. + * + * Both views belong to the controller layout, so a controller that hides takes the + * strip with it and there is no visibility of ours to keep in step. + */ + @OptIn(UnstableApi::class) + private fun bindScrubPreview(view: PlayerView) { + val preview = view.findViewById(R.id.player_scrub_preview) ?: return + val bar = view.findViewById(androidx.media3.ui.R.id.exo_progress) ?: return + scrubPreview = preview + scrubAnchor = bar + trickplayPreview.bind(preview) + (bar as? TimeBar)?.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) { + scrubPositionMs = position + showScrubPreview(position, forward = true) + } + + override fun onScrubMove(timeBar: TimeBar, position: Long) { + val forward = position >= scrubPositionMs + scrubPositionMs = position + showScrubPreview(position, forward) + } + + override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { + // The seek media3 is about to make is the viewer's answer; the frame that + // asked the question has nothing left to say. It goes down here rather than + // on a timer, so it can never outlive the scrub that raised it. + trickplayPreview.hide() + } + }) + } + + /** + * Draws the frame under the scrubber and slides the strip to sit above it. + * + * The horizontal placement is computed rather than fixed, because a thumbnail parked in + * the middle of the screen while the scrubber is at the far end is a picture of some + * other moment. It is clamped to the bar, so the two ends of a film do not push it off + * the edge — where overscan would take it — and it is centred on the scrubber otherwise. + */ + private fun showScrubPreview(positionMs: Long, forward: Boolean) { + val preview = scrubPreview ?: return + val bar = scrubAnchor ?: return + val parent = preview.parent as? View ?: return + val durationMs = player?.duration ?: return + if (durationMs <= 0L || durationMs == C.TIME_UNSET) return + val track = bar.width - bar.paddingLeft - bar.paddingRight + val width = preview.width.takeIf { it > 0 } ?: preview.layoutParams?.width ?: 0 + if (track > 0 && width > 0) { + // Measured in window coordinates rather than from either view's own left, + // because the bar is two levels down inside the controls column and the strip + // hangs off the controller root: only the window is a frame both agree on. + val barAt = IntArray(2).also(bar::getLocationInWindow) + val parentAt = IntArray(2).also(parent::getLocationInWindow) + val left = barAt[0] - parentAt[0] + bar.paddingLeft + val fraction = (positionMs.toFloat() / durationMs).coerceIn(0f, 1f) + val centre = left + track * fraction - width / 2f + preview.translationX = centre.coerceIn( + left.toFloat(), + (left + track - width).toFloat().coerceAtLeast(left.toFloat()), + ) + } + trickplayPreview.show(positionMs, forward, into = preview) + } + private fun seekIntervalMs(): Long = normalizeSeekIntervalSeconds( ServiceLocator.settings.current?.seekIntervalSeconds ?: DEFAULT_SEEK_INTERVAL_SECONDS, @@ -1863,6 +2002,7 @@ class PlayerActivity : ComponentActivity() { castOverlay?.isVisible != true && subtitleOverlay?.isVisible != true && nextUpBanner?.isVisible != true && + creditsView?.isVisible != true && loadingView?.isVisible != true && errorView?.isVisible != true @@ -2078,7 +2218,27 @@ class PlayerActivity : ComponentActivity() { val duration = playback.duration if (duration == C.TIME_UNSET || duration <= 0L) return + // Evaluated first, because whether the pane is up decides what the banner may do. + updateEndCreditsFromPlayhead(playback, next) + val remainingMs = (duration - playback.currentPosition).coerceAtLeast(0L) + // The pane says what is on next already, and shrinks the same picture by a different + // amount. Raising the banner over it would fight for the transform and print the + // episode twice, so the countdown moves into the pane instead — and it is only worth + // drawing inside the last minute, where it was always the banner's job. + if (creditsActive) { + creditsCountdown?.apply { + if (remainingMs in 1L..NEXT_UP_LEAD_MS) { + text = getString(R.string.next_up_starting_in, ceil(remainingMs / 1_000.0).toInt()) + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + if (remainingMs == 0L) playNext(next) + return + } + when { remainingMs > NEXT_UP_LEAD_MS -> { hideNextUp() @@ -2171,6 +2331,257 @@ class PlayerActivity : ComponentActivity() { ?.start() } + // --- Closing credits ---------------------------------------------------------------- + + private fun setUpEndCredits() { + val view = findViewById(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(R.id.player_end_credits_play).setOnClickListener { + nextEpisode?.let(::playNext) + } + view.findViewById(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(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(R.id.player_end_credits_meta).apply { + text = meta + visibility = if (meta.isBlank()) View.GONE else View.VISIBLE + } + view.findViewById(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(R.id.player_end_credits_play).requestFocus() + } + startCreditsSpeedRamp() + } + + /** + * Puts the picture and the speed back, without recording a decision. + * + * Separate from [dismissEndCredits] because the two mean different things: this is the + * playhead having left the roll, which may happen again, while dismissing is the viewer + * saying not to offer it for this episode at all. + */ + private fun leaveEndCredits(restoreSpeed: Boolean) { + creditsActive = false + creditsSpeedJob?.cancel() + creditsSpeedJob = null + if (restoreSpeed) restoreNormalSpeed() + val view = creditsView ?: return + if (!view.isVisible) return + view.animate() + .alpha(0f) + .setDuration(NEXT_UP_ANIMATION_MS) + .withEndAction { + view.visibility = View.GONE + view.alpha = 1f + } + .start() + restoreVideoAfterCredits() + } + + private fun dismissEndCredits() { + creditsDismissed = true + leaveEndCredits(restoreSpeed = true) + playerView?.requestFocus() + } + + /** + * Eases the picture up to speed rather than snapping to it. + * + * A short-lived job of its own rather than a branch of the next-up tick: that runs every + * 250 ms, which over a 1.5 s ramp is six audible steps. The rule itself is pure and lives + * in [creditsSpeedAt] — this only supplies the clock and the player. + */ + private fun startCreditsSpeedRamp() { + creditsSpeedJob?.cancel() + if (!creditsSpeedIsActive(creditsSpeedCeiling)) return + creditsSpeedJob = lifecycleScope.launch { + while (isActive && creditsActive) { + val elapsed = SystemClock.elapsedRealtime() - creditsEnteredAtMs + val speed = creditsSpeedAt(elapsed, creditsSpeedCeiling) + applyCreditsSpeed(speed) + if (elapsed >= CREDITS_RAMP_MS) break + delay(CREDITS_RAMP_TICK_MS) + } + } + } + + private fun applyCreditsSpeed(speed: Float) { + val playback = player ?: return + if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return + playback.setPlaybackSpeed(speed) + updateCreditsSpeedChip(speed) + } + + private fun updateCreditsSpeedChip(speed: Float) { + val chip = creditsSpeedChip ?: return + if (!creditsSpeedIsActive(speed)) { + chip.visibility = View.GONE + return + } + chip.text = getString(R.string.end_credits_speed, creditsSpeedLabel(speed)) + chip.visibility = View.VISIBLE + } + + private fun restoreNormalSpeed() { + creditsSpeedChip?.visibility = View.GONE + val playback = player ?: return + if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) { + return + } + playback.setPlaybackSpeed(CREDITS_NORMAL_SPEED) + } + + /** + * Gives up a step of speed because the stream could not hold this one. + * + * Doubling the speed doubles the bitrate pulled from Emby over HTTP, and a high-bitrate + * file on a remote server may simply not sustain it. Credits that stutter read as a + * broken app, so the ceiling falls and — because [creditsCeilingAfterStall] only ever + * goes down — never climbs back inside this episode. Reaching normal speed leaves the + * pane up: what is on next is still worth showing, and only the speeding up has failed. + */ + private fun stepDownCreditsSpeed() { + if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return + creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling) + creditsSpeedJob?.cancel() + creditsSpeedJob = null + if (!creditsSpeedIsActive(creditsSpeedCeiling)) { + restoreNormalSpeed() + return + } + // Re-ramp from where the picture already is rather than from 1×, so giving up a step + // is a small slowing rather than a stop and a fresh acceleration. + creditsEnteredAtMs = SystemClock.elapsedRealtime() - CREDITS_RAMP_MS + startCreditsSpeedRamp() + } + + private fun shrinkVideoForCredits() { + val view = playerView ?: return + view.animate() + .scaleX(CREDITS_VIDEO_SCALE) + .scaleY(CREDITS_VIDEO_SCALE) + .translationX(-view.width * CREDITS_VIDEO_SHIFT_X) + .translationY(0f) + .setDuration(NEXT_UP_ANIMATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + } + + private fun restoreVideoAfterCredits() { + playerView?.animate() + ?.scaleX(1f) + ?.scaleY(1f) + ?.translationX(0f) + ?.translationY(0f) + ?.setDuration(NEXT_UP_ANIMATION_MS) + ?.setInterpolator(DecelerateInterpolator()) + ?.start() + } + + /** + * Everything about the credits, forgotten. Called when the episode underneath changes: + * every title rolls its credits at a different point, the next one's marker is a fresh + * lookup, and a speed left over from the outgoing episode would run the incoming one's + * opening scene at double speed. + */ + private fun resetEndCredits() { + creditsSpeedJob?.cancel() + creditsSpeedJob = null + creditsActive = false + creditsDismissed = false + creditsStartMs = null + creditsSpeedCeiling = CREDITS_TARGET_SPEED + creditsSpeedChip?.visibility = View.GONE + creditsView?.apply { + animate().cancel() + visibility = View.GONE + alpha = 1f + } + restoreVideoAfterCredits() + restoreNormalSpeed() + } + private fun handlePlaybackEnded() { when (playbackCompletionAction(nextEpisode != null, nextUpDismissed)) { PlaybackCompletionAction.PLAY_NEXT -> nextEpisode?.let(::playNext) @@ -2258,6 +2669,10 @@ class PlayerActivity : ComponentActivity() { // Nor may the outgoing episode's title-sequence markers: every show times its // opening differently, and the next episode's are a fresh lookup. resetSkipIntro() + // Nor the outgoing episode's credits marker or its speed: every title rolls them at + // a different point, and a speed left behind would run the next episode's opening + // scene at double speed. + resetEndCredits() nextEpisode = null nextUpDismissed = false requestStartedAtMs = SystemClock.elapsedRealtime() @@ -2337,6 +2752,9 @@ class PlayerActivity : ComponentActivity() { collapseSubtitleDownloads() subtitleOverlay?.isVisible == true -> hideSubtitleOverlay() nextUpBanner?.isVisible == true -> dismissNextUp() + // Back asks for the credits back rather than leaving the film: one press + // per level, the same contract every other overlay here has. + creditsView?.isVisible == true -> dismissEndCredits() // Back refuses the offer rather than leaving the film: one press per // level, the same contract the banner above and the drop-up have. skipIntroView?.isVisible == true -> dismissSkipIntro() @@ -2389,6 +2807,9 @@ class PlayerActivity : ComponentActivity() { castOverlay?.isVisible != true && subtitleOverlay?.isVisible != true && nextUpBanner?.isVisible != true && + // The pane's Play button holds focus while it is up, and the centre key is how a + // remote presses what it is focused on. + creditsView?.isVisible != true && // The skip button holds focus while it is up, and the centre key is how a // remote presses the thing it is focused on. Pausing instead would leave the // one button on screen unpressable. @@ -2653,6 +3074,10 @@ class PlayerActivity : ComponentActivity() { isSelected(choice) || choice.encodedSubtitle?.id == encodedSubtitleId }.let { if (it >= 0) it else 0 } + // Nothing but "Off" in the list means the title carries no subtitles, which is a + // thing to be told rather than a shorter menu — see SubtitleTracksState. + val noSubtitles = tracks.size <= 1 + val preferences = getSharedPreferences(PLAYER_PREFERENCES, Context.MODE_PRIVATE) val currentSize = preferences.getString(SUBTITLE_SIZE_KEY, SubtitleSize.MEDIUM.key) bindSubtitleMenu( @@ -2667,9 +3092,13 @@ class PlayerActivity : ComponentActivity() { downloads = SubtitleDownloadState( available = subtitleDownloadAvailable, status = subtitleDownloadStatus, - entries = subtitleDownloadEntries(), + entries = subtitleDownloadEntries(noSubtitles), expanded = subtitleDownloadExpanded, ), + tracksState = SubtitleTracksState( + empty = noSubtitles, + downloadable = subtitleDownloadAvailable, + ), onSize = { index -> preferences.edit().putString(SUBTITLE_SIZE_KEY, SubtitleSize.entries[index].key).apply() applySubtitleAppearance() @@ -2695,6 +3124,11 @@ class PlayerActivity : ComponentActivity() { focusTrack != null -> tracksContainer.getChildAt(focusTrack) focusSize != null -> sizesContainer.getChildAt(focusSize) focusDownload != null -> downloadsContainer?.getChildAt(focusDownload) + // With no tracks to choose between, the only useful press on this panel is the + // one that goes looking for some. Landing on "Off" instead would put focus on + // the state the viewer is already in, below a rule they would have to guess to + // travel past. + noSubtitles && subtitleDownloadAvailable -> downloadsContainer?.getChildAt(0) else -> tracksContainer.getChildAt(selectedTrack) } // A redraw can legitimately ask for a row that is gone or disabled — the search row @@ -2710,17 +3144,23 @@ class PlayerActivity : ComponentActivity() { * Derived rather than stored, so every redraw of the menu produces the same section * from the same two fields and the list cannot drift from the candidates behind it. */ - private fun subtitleDownloadEntries(): List = buildList { + private fun subtitleDownloadEntries(noSubtitles: Boolean = false): List = buildList { add( SubtitleMenuEntry( label = getString( - if (subtitleCandidates.isEmpty()) R.string.player_subtitle_search - else R.string.player_subtitle_search_again, + when { + subtitleCandidates.isNotEmpty() -> R.string.player_subtitle_search_again + // The row is the whole point of the panel on a title with none, so + // it says what it is for rather than what it does. + noSubtitles -> R.string.player_subtitle_none_search + else -> R.string.player_subtitle_search + }, ), selected = false, // Unfocusable while a query is in flight, which is what stops a second // press starting a second search. enabled = !subtitleRequestInFlight, + prominent = true, ), ) subtitleCandidates.forEach { candidate -> @@ -2825,6 +3265,7 @@ class PlayerActivity : ComponentActivity() { // nothing ever turns it on. subtitleAutoSelectionAttempted = false rememberSubtitleChoice(enabled = true, language = candidate.language) + pendingSubtitleConfirmation = candidate.languageLabel.ifBlank { candidate.language } playback.setMediaItem(mediaItem(result.url, result.subtitles), position) playback.playWhenReady = true playback.prepare() @@ -2832,7 +3273,7 @@ class PlayerActivity : ComponentActivity() { subtitleCandidates = emptyList() subtitleDownloadStatus = result.message subtitleDownloadExpanded = false - hideSubtitleOverlay() + dismissSubtitleOverlayAfterSelection() } } @@ -2857,7 +3298,7 @@ class PlayerActivity : ComponentActivity() { if (subtitleOverlay?.isVisible == true) showSubtitleOverlay(focusDownload = focusDownload) } - /** Applies the track a row stands for, then redraws the menu on that row. */ + /** Applies the track a row stands for, then gets the picker and transport out of the way. */ @OptIn(UnstableApi::class) private fun selectSubtitleTrack(choice: TrackChoice, index: Int) { val playback = player ?: return @@ -2881,7 +3322,12 @@ class PlayerActivity : ComponentActivity() { encodedSubtitleId = null subtitleAutoSelectionAttempted = true reportProgress(playback.currentPosition, !playback.isPlaying, "SubtitleTrackChange") - showSubtitleOverlay(focusTrack = index) + dismissSubtitleOverlayAfterSelection() + if (choice.group == null) { + Toast.makeText(this, R.string.player_subtitle_disabled, Toast.LENGTH_SHORT).show() + } else { + showSubtitleConfirmation(choice.label) + } } private fun hideSubtitleOverlay() { @@ -2889,6 +3335,24 @@ class PlayerActivity : ComponentActivity() { playerView?.showController() } + /** A completed choice returns directly to the programme, without reopening transport. */ + private fun dismissSubtitleOverlayAfterSelection() { + subtitleOverlay?.visibility = View.GONE + playerView?.hideController() + playerView?.requestFocus() + } + + private fun showSubtitleConfirmation(label: String) { + Toast.makeText( + this, + getString( + R.string.player_subtitle_loaded, + label.ifBlank { getString(R.string.player_subtitle_generic) }, + ), + Toast.LENGTH_SHORT, + ).show() + } + private fun subtitleDisplayLabel(subtitle: PlayableSubtitle): String { val name = subtitle.label?.takeIf(String::isNotBlank) ?: subtitle.language?.let { Locale.forLanguageTag(it).displayLanguage } @@ -2906,7 +3370,7 @@ class PlayerActivity : ComponentActivity() { val id = itemId?.takeIf(String::isNotBlank) ?: return val index = subtitle.id.toIntOrNull() ?: return val position = playback.currentPosition.coerceAtLeast(0L) - hideSubtitleOverlay() + dismissSubtitleOverlayAfterSelection() reportProgress(position, !playback.isPlaying, "SubtitleTrackChange") showPlaybackLoading(getString(R.string.playback_loading), "Preparing burned-in subtitles…") lifecycleScope.launch { @@ -2926,6 +3390,7 @@ class PlayerActivity : ComponentActivity() { subtitlePreference = true rememberSubtitleChoice(enabled = true, language = subtitle.language) subtitleAutoSelectionAttempted = true + pendingSubtitleConfirmation = subtitleDisplayLabel(subtitle) playback.setMediaItem(mediaItem(selected.url, selected.subtitles), position) playback.playWhenReady = true playback.prepare() @@ -3062,6 +3527,8 @@ class PlayerActivity : ComponentActivity() { castJob?.cancel() subtitleSearchJob?.cancel() nextUpJob?.cancel() + creditsSpeedJob?.cancel() + creditsView?.animate()?.cancel() retryJob?.cancel() stablePlaybackJob?.cancel() playbackIdentityHideJob?.cancel() @@ -3185,6 +3652,17 @@ class PlayerActivity : ComponentActivity() { private const val EXTRA_SUBTITLES_ENABLED = "extra_subtitles_enabled" private const val EXTRA_SELECTED_SUBTITLE_ID = "extra_selected_subtitle_id" private const val EXTRA_SUBTITLE_DOWNLOAD = "extra_subtitle_download" + + // The three "is it worth asking the gateway" answers. They must travel with the + // stream on this form of the intent, because it is the form that carries an + // already-resolved [Playable] and therefore never reaches [adoptPlayable] — which + // is the only other place they are set. Omitting them left every cold start and + // every warm-prefetch launch with previews, the skip button and the credits pane + // silently switched off, since each defaults to false and the default is what a + // missing extra yields. + private const val EXTRA_TRICKPLAY = "extra_trickplay_available" + private const val EXTRA_SKIP_INTRO = "extra_skip_intro_available" + private const val EXTRA_END_CREDITS = "extra_end_credits_available" private const val EXTRA_MEDIA_SOURCE_ID = "extra_media_source_id" private const val EXTRA_PLAY_SESSION_ID = "extra_play_session_id" private const val EXTRA_PLAY_METHOD = "extra_play_method" @@ -3247,6 +3725,9 @@ class PlayerActivity : ComponentActivity() { subtitlesEnabled: Boolean = true, selectedSubtitleId: String = "", subtitleDownloadAvailable: Boolean = false, + trickplayAvailable: Boolean = false, + skipIntroAvailable: Boolean = false, + endCreditsAvailable: Boolean = false, mediaSourceId: String = "", playSessionId: String = "", playMethod: String = "DirectPlay", @@ -3268,6 +3749,9 @@ class PlayerActivity : ComponentActivity() { putExtra(EXTRA_SUBTITLES_ENABLED, subtitlesEnabled) putExtra(EXTRA_SELECTED_SUBTITLE_ID, selectedSubtitleId) putExtra(EXTRA_SUBTITLE_DOWNLOAD, subtitleDownloadAvailable) + putExtra(EXTRA_TRICKPLAY, trickplayAvailable) + putExtra(EXTRA_SKIP_INTRO, skipIntroAvailable) + putExtra(EXTRA_END_CREDITS, endCreditsAvailable) putExtra(EXTRA_MEDIA_SOURCE_ID, mediaSourceId) putExtra(EXTRA_PLAY_SESSION_ID, playSessionId) putExtra(EXTRA_PLAY_METHOD, playMethod) @@ -3371,6 +3855,16 @@ class PlayerActivity : ComponentActivity() { private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640 private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360 + + /** + * How often the speed ramp advances. 60 ms over [CREDITS_RAMP_MS] is 25 steps, which + * is heard as an acceleration rather than as a handful of jumps — the next-up tick's + * 250 ms would have given six. + */ + private const val CREDITS_RAMP_TICK_MS = 60L + + /** Floats compared by threshold, so an unchanged speed is never re-sent. */ + private const val CREDITS_SPEED_EPSILON = 0.001f /** * How often the playhead is compared against the title-sequence markers, and so how * often the ring on the button advances. The same rate the next-up countdown reads diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt index fbcdfb5..cc56fc7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/SubtitleMenu.kt @@ -21,6 +21,7 @@ data class SubtitleMenuEntry( val label: String, val selected: Boolean, val enabled: Boolean = true, + val prominent: Boolean = false, ) /** @@ -46,6 +47,24 @@ data class SubtitleDownloadState( val expanded: Boolean = false, ) +/** + * What the track half of the panel says when the title carries no subtitles at all. + * + * It is a state rather than an absence because the two cases read completely differently + * to somebody in front of a television. A list holding nothing but "Off" is indistinguishable + * from a menu that failed to load — and the row that can do something about it sits below a + * rule, under a heading the eye has no reason to travel to. Saying it in the track section + * is what connects the two. + * + * [downloadable] is the difference between a sentence that leads somewhere and one that is + * simply the answer: on a backend with no provider there is nothing to offer, and the honest + * thing is to say so once rather than to leave the viewer looking for the option. + */ +data class SubtitleTracksState( + val empty: Boolean = false, + val downloadable: Boolean = false, +) + /** * Fills the drop-up's containers from plain lists. * @@ -60,6 +79,7 @@ fun bindSubtitleMenu( tracks: List, sizes: List, downloads: SubtitleDownloadState = SubtitleDownloadState(), + tracksState: SubtitleTracksState = SubtitleTracksState(), onTrack: (Int) -> Unit = {}, onSize: (Int) -> Unit = {}, onDownload: (Int) -> Unit = {}, @@ -69,6 +89,13 @@ fun bindSubtitleMenu( val sizeContainer = overlay.findViewById(R.id.player_subtitle_sizes) trackContainer.removeAllViews() sizeContainer.removeAllViews() + overlay.findViewById(R.id.player_subtitle_empty_notice)?.apply { + isVisible = tracksState.empty + setText( + if (tracksState.downloadable) R.string.player_subtitle_none + else R.string.player_subtitle_none_unavailable, + ) + } tracks.forEachIndexed { index, entry -> trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) }) } @@ -122,8 +149,15 @@ private fun subtitleMenuOption( ).apply { if (chip) marginEnd = dp(6) else bottomMargin = dp(2) } - background = context.getDrawable(R.drawable.player_overlay_option_background) - setTextColor(context.getColorStateList(R.color.player_overlay_option_text)) + background = context.getDrawable( + if (entry.prominent) R.drawable.next_up_primary_button + else R.drawable.player_overlay_option_background, + ) + if (entry.prominent) { + setTextColor(context.getColorStateList(R.color.player_overlay_primary_option_text)) + } else { + setTextColor(context.getColorStateList(R.color.player_overlay_option_text)) + } gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL setPadding(dp(14), 0, dp(14), 0) text = entry.label diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt index d7b6fdc..f2b0856 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt @@ -26,6 +26,12 @@ import kotlinx.coroutines.withContext * *beside* the words rather than in place of them — the chip has always said where the skip * lands, and it still says it on a title with no previews, on a server that will not answer, * and in the moment before the first frame arrives. + * + * There are two places a frame is drawn, because there are two ways to seek: the centred + * chip a press of Left or Right raises with the transport hidden, and the strip above the + * time bar when the controls are up and the viewer is scrubbing it. They share one instance + * deliberately — one layout fetched per title and one cache of frames between them, since a + * viewer who skips and then opens the controls is travelling through the same film. */ class TrickplayPreview( private val scope: CoroutineScope, @@ -34,7 +40,8 @@ class TrickplayPreview( /** One thumbnail's JPEG bytes, or null. */ private val loadFrame: suspend (Trickplay, Int) -> ByteArray?, ) { - private var view: ImageView? = null + private val views = mutableListOf() + private var shownIn: ImageView? = null private var itemId: String? = null private var track: Trickplay? = null private var trackJob: Job? = null @@ -54,12 +61,18 @@ class TrickplayPreview( size > FRAME_CACHE_SIZE } - /** Attaches the view once the seek indicator has been inflated. */ + /** + * Attaches a surface once the view holding it has been inflated. Called once for the + * seek chip and once for the scrubbing strip; either may be absent. + */ fun bind(preview: ImageView) { - view = preview - applyAspect() + if (views.none { it === preview }) views += preview + applyAspect(preview) } + /** True when there is a layout to draw from, so a caller can leave its own chrome down. */ + fun hasFrames(): Boolean = track != null + /** * Begins on a title. [available] is the backend saying whether it will answer at all, * so an older or deliberately-configured-off gateway is never asked once per playback. @@ -85,12 +98,15 @@ class TrickplayPreview( * [forward] is the direction the viewer is travelling, which is used only to warm the * frame they are most likely to ask for next. Presses come in bursts at a fixed step, * so the one after this is a good guess and a wrong guess costs a few kilobytes. + * + * [into] names which of the bound surfaces is asking. The other is taken down rather + * than left holding a frame from the last thing that used it. */ - fun show(positionMs: Long, forward: Boolean) { + fun show(positionMs: Long, forward: Boolean, into: ImageView? = views.firstOrNull()) { val current = track ?: return - val preview = view ?: return + val preview = into ?: return val frame = current.frameAt(positionMs) - if (frame == shownFrame && preview.isVisible) return + if (frame == shownFrame && preview === shownIn && preview.isVisible) return // Cancelling the previous load is the load-bearing part. Presses arrive faster than // a fetch completes, and without this a slow response for a frame the viewer has @@ -103,9 +119,11 @@ class TrickplayPreview( ?: return@launch val bitmap = decode(bytes) ?: return@launch shownFrame = frame - applyAspect() + shownIn = preview + applyAspect(preview) preview.setImageBitmap(bitmap) preview.visibility = View.VISIBLE + views.forEach { if (it !== preview) clear(it) } warm(current, frame + if (forward) 1 else -1) } } @@ -115,10 +133,13 @@ class TrickplayPreview( frameJob?.cancel() frameJob = null shownFrame = NO_FRAME - view?.let { - it.visibility = View.GONE - it.setImageDrawable(null) - } + shownIn = null + views.forEach(::clear) + } + + private fun clear(preview: ImageView) { + preview.visibility = View.GONE + preview.setImageDrawable(null) } /** @@ -153,8 +174,7 @@ class TrickplayPreview( * press, before anything is known; a title whose thumbnails are a wider crop would * otherwise be letterboxed inside it for the life of the playback. */ - private fun applyAspect() { - val preview = view ?: return + private fun applyAspect(preview: ImageView) { val current = track ?: return val params = preview.layoutParams ?: return val width = current.widthFor(params.height) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt index 0e182b2..e656a1d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -621,10 +621,7 @@ private fun Slideshow( ) if (settingsOpen) { - SettingsSheet( - onClose = { settingsOpen = false }, - onInstallerLaunched = onExit, - ) + SettingsSheet(onClose = { settingsOpen = false }) } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt index 4799004..b52b14d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -38,6 +39,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Add @@ -58,6 +60,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -87,6 +90,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.imageLoader +import kotlinx.coroutines.flow.distinctUntilChanged import coil.compose.AsyncImage import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator @@ -94,15 +98,18 @@ import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import com.ponzischeme89.memby.ui.FocusScaleContainer import com.ponzischeme89.memby.ui.PosterGridCard +import com.ponzischeme89.memby.ui.theme.MembyAccent +import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised +import com.ponzischeme89.memby.ui.theme.MembySurface -private val PaneBackground = Color(0xFF0C1014) +private val PaneBackground: Color get() = MembySurfaceRaised private val KeyIdle = Color(0xFF1A2129) -private val KeyFocused = Color(0xFF52B54B) +private val KeyFocused: Color get() = MembyAccent private val KeyLabel = Color(0xFFE8EDF1) private val KeyLabelFocused = Color(0xFF06240A) private val Heading = Color(0xFFF2F5F7) private val Muted = Color(0xFFB7C0C8) -private val Accent = Color(0xFF52B54B) +private val Accent: Color get() = MembyAccent /** * Six columns of six, then a row of actions. Rectangular on purpose: every key has a @@ -124,6 +131,15 @@ private const val KEYBOARD_PANE_FRACTION = 0.35f /** Posters prefetched as soon as results land, so the first visible row is never blank. */ private const val PREFETCHED_POSTERS = 8 +/** + * How far from the end of a genre the next page is asked for, in rows. + * + * Two rows rather than one: a D-pad walks a row at a time and the request has to be in + * flight before the viewer arrives at the bottom, or the scroll stops dead and the shelf + * reads as having ended. + */ +private const val LOAD_MORE_ROWS_AHEAD = 2 + /** * Full-screen search: keyboard on the left, results on the right, updating as you type. * @@ -169,6 +185,7 @@ fun SearchScreen( val keyboardReturn = remember { FocusRequester() } var lastKeyIndex by remember { mutableIntStateOf(0) } var focusInResults by remember { mutableStateOf(false) } + var restoreGenreChipFocus by remember { mutableStateOf(false) } val hasResultsTarget = when { state.errorMessage != null && state.results.isEmpty() -> true state.isDiscovery -> discoveryItems.isNotEmpty() || @@ -178,8 +195,42 @@ fun SearchScreen( LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } } + // A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with + // it — so the focus it was holding belongs to nothing unless something takes it. The + // grid claims it as soon as there is a card to land on, which is where the viewer is + // already looking; a genre that came back empty or failed hands the remote back to the + // keyboard rather than leaving a television with nothing focused at all. + LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) { + if (state.genre == null) return@LaunchedEffect + when { + state.results.isNotEmpty() || state.errorMessage != null -> + if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true + !state.isLoading -> runCatching { keyboardReturn.requestFocus() } + // Still loading: the branch above takes it the moment the first page lands. + else -> Unit + } + } + + LaunchedEffect(state.genre, restoreGenreChipFocus) { + if (state.genre == null && restoreGenreChipFocus) { + // clearGenre remounts discovery and its genre chips. Wait for the first chip's + // focus node before handing the remote back to where this level was opened. + kotlinx.coroutines.delay(16L) + if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true + restoreGenreChipFocus = false + } + } + + val closeGenre: () -> Unit = { + restoreGenreChipFocus = true + viewModel.clearGenre() + } + BackHandler { when { + // A genre is its own level, between discovery and leaving Search. Close that + // level first regardless of which card currently owns focus. + state.genre != null -> closeGenre() // Results → keyboard → clear → leave. Each press does one obvious thing, and // none of them can loop back to the previous state. focusInResults -> runCatching { keyboardReturn.requestFocus() } @@ -246,6 +297,9 @@ fun SearchScreen( onRetry = viewModel::retry, onRequest = viewModel::request, onSuggestionSelected = viewModel::onQueryChanged, + onGenreSelected = viewModel::onGenreSelected, + onBackFromGenre = closeGenre, + onLoadMore = viewModel::loadMore, modifier = Modifier.fillMaxHeight(), ) } @@ -598,6 +652,9 @@ private fun ResultsPane( onRetry: () -> Unit, onRequest: (GatewayRequestCandidate) -> Unit, onSuggestionSelected: (String) -> Unit, + onGenreSelected: (String) -> Unit, + onBackFromGenre: () -> Unit, + onLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { BoxWithConstraints( @@ -615,7 +672,11 @@ private fun ResultsPane( val items = if (showingDiscovery) discoveryItems else state.results Column(Modifier.fillMaxSize()) { - ResultsHeading(state = state, showingDiscovery = showingDiscovery) + ResultsHeading( + state = state, + showingDiscovery = showingDiscovery, + onBackFromGenre = onBackFromGenre, + ) val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE } val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT } if (showingDiscovery && genres.isNotEmpty()) { @@ -624,7 +685,10 @@ private fun ResultsPane( genres = genres, resultsEntry = resultsEntry, keyboardReturn = keyboardReturn, - onSelected = onSuggestionSelected, + // Not onSuggestionSelected: a genre opens the shelf of titles that are + // in it, where running its name through search matched a film called + // Drama and missed most of the drama. + onSelected = onGenreSelected, ) } if (showingDiscovery && recent.isNotEmpty()) { @@ -672,6 +736,12 @@ private fun ResultsPane( returnFocusRequester = returnFocusRequester, onItemFocused = onItemFocused, onItemSelected = onItemSelected, + // Only a genre pages. A search is one response, and asking the grid to + // watch for the end of a list that has no more behind it is a scroll + // listener running for nothing. + paging = state.genre != null && (state.canLoadMore || state.isLoadingMore), + loadingMore = state.isLoadingMore, + onLoadMore = onLoadMore, ) } } @@ -679,13 +749,43 @@ private fun ResultsPane( } @Composable -private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) { +private fun ResultsHeading( + state: SearchUiState, + showingDiscovery: Boolean, + onBackFromGenre: () -> Unit, +) { val title = when { showingDiscovery -> "Browse your library" + // A genre says what it is. It was never searched for, so calling it a search result + // would misdescribe both where the titles came from and how to get out of it. + state.genre != null -> state.genre state.isEmptyResult -> "Search results — no matches" else -> "Search results for “${state.query.trim()}”" } Row(verticalAlignment = Alignment.CenterVertically) { + if (state.genre != null) { + FocusScaleContainer( + onFocused = {}, + onClick = onBackFromGenre, + contentDescription = "Back to search", + modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)), + ) { focused -> + Box( + modifier = Modifier + .fillMaxSize() + .background(if (focused) Color.White else KeyIdle), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = if (focused) KeyLabelFocused else Heading, + modifier = Modifier.size(21.dp), + ) + } + } + Spacer(Modifier.width(12.dp)) + } Text( title, color = Heading, @@ -695,7 +795,10 @@ private fun ResultsHeading(state: SearchUiState, showingDiscovery: Boolean) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - if (!showingDiscovery && state.results.isNotEmpty()) { + // The count belongs to a search, where it is the whole answer. On a genre it would + // be the number of cards fetched so far, which grows as the viewer scrolls and + // describes the paging rather than the library. + if (state.genre == null && !showingDiscovery && state.results.isNotEmpty()) { Spacer(Modifier.width(10.dp)) Text("${state.results.size}", color = Muted, fontSize = 16.sp) } @@ -713,11 +816,29 @@ private fun ResultsGrid( returnFocusRequester: FocusRequester, onItemFocused: (BaseItem) -> Unit, onItemSelected: (BaseItem) -> Unit, + paging: Boolean = false, + loadingMore: Boolean = false, + onLoadMore: () -> Unit = {}, ) { val context = LocalContext.current val density = LocalDensity.current val gridState = rememberLazyGridState() + // Infinite scroll. Read in a snapshotFlow rather than from the composable body: the + // last visible index changes on every frame of a scroll, and reading it up here would + // recompose the whole grid the entire way down a genre. The next page is asked for a + // row early — the request has to be in flight before the viewer arrives at the end, or + // the scroll stops dead while they wait for it. + if (paging) { + LaunchedEffect(gridState, items.size, columns) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .distinctUntilChanged() + .collect { last -> + if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) onLoadMore() + } + } + } + // Warm the first screenful so the grid does not fill in card by card. Keyed on the // ids rather than the list, so an unchanged result set never re-fetches. val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } } @@ -770,6 +891,21 @@ private fun ResultsGrid( }, ) } + if (loadingMore) { + // A full-width row rather than a card-shaped placeholder: a skeleton card is + // something a remote tries to focus, and there is nothing there to open. + item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + LoadingDot() + Spacer(Modifier.width(10.dp)) + Text("Loading more", color = Muted, fontSize = 13.sp) + } + } + } } } @@ -990,7 +1126,7 @@ internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) { Box( Modifier .fillMaxSize() - .background(Color(0xFF080B0E)) + .background(MembySurface) .padding(28.dp), ) { RequestOptions( @@ -1133,6 +1269,10 @@ private fun SuggestionChips( private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) { val message = when { showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off." + // A genre was never searched for, and saying so while a shelf opens is the one + // moment the difference is invisible on screen. + state.genre != null && state.isLoading -> "Loading ${state.genre}…" + state.genre != null -> "Nothing in this library is tagged ${state.genre}." state.isLoading -> "Searching…" state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…" else -> "Nothing in this library matches that. Try fewer letters, or a different spelling." diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt index c8842d2..35ef06e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/search/SearchViewModel.kt @@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.GENRE_PAGE_SIZE import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.hasMoreGenreItems import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.GatewayRequestCandidate import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -31,6 +34,20 @@ data class SearchSuggestion(val label: String, val kind: Kind) { data class SearchUiState( val query: String = "", val results: List = 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 = emptyList(), val isLoading: Boolean = false, /** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */ @@ -46,9 +63,9 @@ data class SearchUiState( val isEmptyResult: Boolean get() = hasSearched && !isLoading && errorMessage == null && results.isEmpty() - /** Nothing typed yet: the pane shows discovery rather than results. */ + /** Nothing typed and no genre open: the pane shows discovery rather than results. */ val isDiscovery: Boolean - get() = !shouldSearch(query) + get() = genre == null && !shouldSearch(query) } /** @@ -80,6 +97,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { private val recentQueries = ArrayDeque() private var genreSuggestions: List = emptyList() + /** + * The page in flight, so a viewer who leaves a genre — or scrolls a second page while + * the first is still coming — is never overtaken by an answer to a question they have + * moved on from. It is the genre shelf's equivalent of the search pipeline's + * `collectLatest`. + */ + private var genrePageJob: Job? = null + init { viewModelScope.launch { queryFlow @@ -105,10 +130,17 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { /** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */ fun onQueryChanged(query: String) { + // Typing supersedes a genre. Somebody who reaches for the keyboard while a shelf is + // open is asking for something else, and a pane headed "Comedy" listing matches for + // what they are typing would be a lie about where those results came from. + genrePageJob?.cancel() // The visible field updates immediately; only the *search* is debounced. _state.update { it.copy( query = query, + genre = null, + isLoadingMore = false, + canLoadMore = false, requestCandidates = emptyList(), requestLookupLoading = false, requestMessage = null, @@ -125,20 +157,78 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { fun clearQuery() { // Clear immediately so results from a genre tile cannot remain visible while the // debounced empty-query transition is pending. + genrePageJob?.cancel() _state.update { it.copy( query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null, requestCandidates = emptyList(), requestLookupLoading = false, requestMessage = null, - requestMessageIsError = false, + requestMessageIsError = false, genre = null, + isLoadingMore = false, canLoadMore = false, ) } queryFlow.value = "" } + /** + * A genre chip was pressed. This is a filter, not a query: nothing is typed and nothing + * is debounced — the viewer made one deliberate choice and the shelf opens on it. + */ + fun onGenreSelected(genre: String) { + val name = genre.trim() + if (name.isEmpty()) return + genrePageJob?.cancel() + _state.update { + it.copy( + query = "", genre = name, results = emptyList(), isLoading = true, + hasSearched = false, errorMessage = null, isLoadingMore = false, + canLoadMore = false, requestCandidates = emptyList(), + requestLookupLoading = false, requestMessage = null, + requestMessageIsError = false, + ) + } + // The typed query is dropped along with it, and the flow is told so a stale term + // cannot arrive from the debounce and overwrite the shelf that is opening. + queryFlow.value = "" + genrePageJob = viewModelScope.launch { loadGenrePage(name, offset = 0) } + } + + /** Back out of a genre, to the discovery pane the chip was pressed on. */ + fun clearGenre() { + genrePageJob?.cancel() + _state.update { + it.copy( + genre = null, results = emptyList(), isLoading = false, hasSearched = false, + errorMessage = null, isLoadingMore = false, canLoadMore = false, + ) + } + } + + /** + * The grid is near the end of what it holds. Ignored unless there is a genre open, more + * of it to fetch and nothing already in flight — the grid asks on every scroll, and it + * is cheaper to refuse here than to make the screen keep track. + */ + fun loadMore() { + val current = state.value + val genre = current.genre ?: return + if (!current.canLoadMore || current.isLoadingMore || current.isLoading) return + _state.update { it.copy(isLoadingMore = true) } + genrePageJob = viewModelScope.launch { loadGenrePage(genre, offset = current.results.size) } + } + /** Retry after an error, without disturbing the query or the keyboard. */ fun retry() { - val term = state.value.query.trim() + val current = state.value + current.genre?.let { genre -> + genrePageJob?.cancel() + _state.update { it.copy(isLoading = true, errorMessage = null) } + genrePageJob = viewModelScope.launch { + loadGenrePage(genre, offset = current.results.size) + } + return + } + val term = current.query.trim() if (!shouldSearch(term)) return cache.remove(term) viewModelScope.launch { runSearch(term) } @@ -199,7 +289,58 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() { } } + /** + * One page of a genre, appended to whatever the shelf already holds. + * + * Appending by [GenrePage.offset] rather than trusting the order of arrival is what + * makes a slow page harmless: only a page that starts where the shelf currently ends is + * taken, so a response for an offset the viewer has already scrolled past — or one from + * a genre they have left — is dropped rather than pasted into the middle of the grid. + */ + private suspend fun loadGenrePage(genre: String, offset: Int) { + runCatching { repository.browseGenre(genre, offset = offset, limit = GENRE_PAGE_SIZE) } + .onSuccess { page -> + _state.update { current -> + if (current.genre != genre || current.results.size != page.offset) return@update current + val items = current.results + page.items + current.copy( + results = items, + isLoading = false, + isLoadingMore = false, + hasSearched = true, + errorMessage = null, + canLoadMore = hasMoreGenreItems( + loaded = items.size, + total = page.total, + lastPageSize = page.items.size, + pageSize = GENRE_PAGE_SIZE, + ), + ) + } + } + .onFailure { error -> + if (error is kotlinx.coroutines.CancellationException) throw error + _state.update { current -> + if (current.genre != genre) return@update current + current.copy( + isLoading = false, + isLoadingMore = false, + hasSearched = true, + // A page that failed part way down a shelf keeps what is already + // there and simply stops: the viewer has plenty on screen, and + // replacing it with an error would take away what was working. + errorMessage = if (current.results.isEmpty()) friendlyEmbyError(error) else null, + canLoadMore = false, + ) + } + } + } + private suspend fun runSearch(term: String) { + // A genre shelf is not a query, so the empty-query transition has nothing to say + // about it. Without this, opening a genre — which clears the query — would arrive + // here a moment later and wipe the shelf it had just filled. + if (state.value.genre != null && !shouldSearch(term)) return if (!shouldSearch(term)) { // Back to the discovery state, but the previous results are dropped rather // than left behind a shorter query they no longer match. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/seasonal/SeasonalDecorations.kt b/app/src/main/java/com/ponzischeme89/memby/ui/seasonal/SeasonalDecorations.kt new file mode 100644 index 0000000..445e3fd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/seasonal/SeasonalDecorations.kt @@ -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` 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, +) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 9f44ea6..7858e53 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -8,8 +8,6 @@ package com.ponzischeme89.memby.ui.settings -import android.content.Intent -import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -23,6 +21,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -38,14 +37,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.TagFaces -import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material.icons.filled.Storage import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -67,6 +65,11 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -74,7 +77,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon @@ -83,29 +85,33 @@ import androidx.tv.material3.Text import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS +import com.ponzischeme89.memby.data.ImageCacheMaintenance +import com.ponzischeme89.memby.data.ImageCacheSize +import com.ponzischeme89.memby.data.formatCacheSize import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO import com.ponzischeme89.memby.data.SKIP_INTRO_OFF import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.parseThemeColor +import com.ponzischeme89.memby.ui.theme.MembyAccent import com.ponzischeme89.memby.data.model.GatewayDevice import com.ponzischeme89.memby.ui.PreviewSurface import com.ponzischeme89.memby.ui.TvPreview import com.ponzischeme89.memby.ui.WelcomeQuoteStyle -import com.ponzischeme89.memby.update.AppInstall import com.ponzischeme89.memby.update.UpdateChecker -import com.ponzischeme89.memby.update.UpdateStatus import kotlinx.coroutines.delay import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -private data class ChoiceOption(val value: String, val label: String, val color: Color? = null) +internal data class ChoiceOption(val value: String, val label: String, val color: Color? = null) private val RingOptions = listOf( ChoiceOption("FFFFFF", "White", Color.White), @@ -151,10 +157,14 @@ internal enum class SettingsPage( APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette), PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow), HOME("Home screen", "What you see when Memby opens", Icons.Default.Home), - WELCOME("Welcome", "The line you get when you sign in", Icons.Default.TagFaces), - UPDATES("Updates", "Keep this TV up to date", Icons.Default.SystemUpdate), + // No Updates page. The manual check needs a Gitea address, a repository and a token, + // and nothing on this television can enter them — so the button could only ever report + // a failure, on the one screen a viewer goes to when they suspect something is wrong. + // Updates arrive through the gateway's own verdict (ui/UpdateScreen.kt), which carries + // its download URL with it. DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices), - ABOUT("About", "Version and source code", Icons.Default.Info), + STORAGE("Storage", "Artwork Memby keeps on this TV", Icons.Default.Storage), + ABOUT("About", "Version and release notes", Icons.Default.Info), } // Black, and one lit thing at a time. @@ -169,7 +179,10 @@ internal enum class SettingsPage( // The controls are deliberately untouched: the green pill toggle and the chip row are what // make this screen feel like Memby, and they read better against black than they did // against a card. -private val EmbyGreen = Color(0xFF52B54B) +// The one colour on this page that is not fixed. It is the accent, and the accent is the +// viewer's own choice now — a picker offering an orange scheme with a green selected chip +// would be showing them the wrong answer to the question they are being asked. +private val EmbyGreen: Color get() = MembyAccent private val Canvas = Color(0xFF000000) private val Panel = Color(0xFF040506) private val RowFocused = Color(0xFF1B2228) @@ -186,6 +199,7 @@ internal data class SettingsPanelState( val showTenMinuteReminder: Boolean = true, val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS, val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE, + val speedUpCredits: Boolean = true, val ringColor: String = "52B54B", val homeSections: Set = setOf("continue", "favorites", "latest"), val cardDensity: String = "standard", @@ -195,10 +209,25 @@ internal data class SettingsPanelState( val showRatingsStrip: Boolean = true, val hideWatchedMovies: Boolean = false, val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value, + /** + * The viewer's own colour scheme, and the ones the gateway says they may pick between. + * + * [themeOptions] is empty on the direct path, on a gateway that predates themes, and + * before the first fetch — in every one of which the row is simply not drawn. Nothing + * here falls back to a catalogue compiled into the app: a scheme the operator has + * withheld has to be one this television was never sent, or the allowlist is decoration. + */ + val themeId: String = Settings.DEFAULT_THEME_ID, + val themeOptions: List = emptyList(), + /** + * A season is on, so the chips are shown but cannot be moved and [themeNotice] says why. + * The viewer's own choice stays selected underneath, because it is still theirs and + * comes back when the season ends. + */ + val themeLocked: Boolean = false, + /** The gateway's wording for the lock, so a season invented later still reads right. */ + val themeNotice: String = "", val selectedPage: SettingsPage = SettingsPage.APPEARANCE, - val checking: Boolean = false, - val updateStatus: UpdateStatus? = null, - val installMessage: String? = null, val installedVersion: String = "", val releaseHistory: List = MembyReleaseHistory, val devices: List = emptyList(), @@ -206,6 +235,16 @@ internal data class SettingsPanelState( val devicesError: String? = null, val removingDeviceId: String? = null, val pendingRemovalDeviceId: String? = null, + /** + * How much artwork this TV is holding, or null while it is still being measured. + * + * Null rather than [ImageCacheSize.EMPTY] on purpose: reading the disk cache's size + * walks its journal, so there is a moment before the answer arrives, and a page that + * showed "0 MB" during it would be telling the viewer the opposite of the truth. + */ + val imageCacheSize: ImageCacheSize? = null, + val imageCacheClearing: Boolean = false, + val imageCacheCleared: Boolean = false, ) internal data class SettingsPanelActions( @@ -215,6 +254,7 @@ internal data class SettingsPanelActions( val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {}, val onSeekIntervalChanged: (Int) -> Unit = {}, val onSkipIntroModeChanged: (String) -> Unit = {}, + val onSpeedUpCreditsChanged: (Boolean) -> Unit = {}, val onRingColorChanged: (String) -> Unit = {}, val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> }, val onCardDensityChanged: (String) -> Unit = {}, @@ -224,14 +264,13 @@ internal data class SettingsPanelActions( val onShowRatingsStripChanged: (Boolean) -> Unit = {}, val onHideWatchedMoviesChanged: (Boolean) -> Unit = {}, val onWelcomeQuoteStyleChanged: (String) -> Unit = {}, + val onThemeChanged: (String) -> Unit = {}, val onPageSelected: (SettingsPage) -> Unit = {}, - val onCheckForUpdates: () -> Unit = {}, - val onInstallUpdate: (UpdateStatus.Available) -> Unit = {}, val onRefreshDevices: () -> Unit = {}, val onRenameDevice: (GatewayDevice) -> Unit = {}, val onRemoveDevice: (GatewayDevice) -> Unit = {}, val onCancelDeviceRemoval: () -> Unit = {}, - val onOpenSourceCode: () -> Unit = {}, + val onClearImageCache: () -> Unit = {}, ) /** @@ -244,20 +283,27 @@ fun SettingsSheet( onClose: () -> Unit, modifier: Modifier = Modifier, overlay: Boolean = false, - onInstallerLaunched: (() -> Unit)? = null, navigationFocusRequester: FocusRequester? = null, ) { val context = LocalContext.current val store = ServiceLocator.settings val scope = rememberCoroutineScope() + // Kept only for the version this TV is running, which About prints. Nothing here checks + // for an update: see the note on SettingsPage. val checker = remember { UpdateChecker(context) } - val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + // Start with the repository's real in-memory snapshot. Settings.EMPTY briefly selects + // default chips before DataStore emits; a viewer could press "Automatic" in that gap + // and then watch the stored "Posters" value appear to revert their choice. + val settings by ServiceLocator.repository.settingsFlow.collectAsState( + initial = ServiceLocator.repository.currentSettings, + ) var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) } var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) } var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) } var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) } + var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) } var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) } var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } @@ -266,12 +312,12 @@ fun SettingsSheet( var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) } var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) } var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) } + // The resolved theme and the schemes on offer both come from the gateway, through the + // sync that owns them. Collected here rather than in SettingsPanelContent so the content + // stays parameter-driven and screenshot-testable with no server. + val resolvedTheme by ServiceLocator.themeSync.theme.collectAsState() + val availableThemes by ServiceLocator.themeSync.available.collectAsState() var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) } - var checking by remember { mutableStateOf(false) } - var status by remember { mutableStateOf(null) } - var installMessage by remember { mutableStateOf(null) } - // The system installer reports back here, not to downloadAndInstall's caller. - LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } } var devices by remember { mutableStateOf>(emptyList()) } var devicesLoading by remember { mutableStateOf(false) } var devicesError by remember { mutableStateOf(null) } @@ -279,6 +325,9 @@ fun SettingsSheet( var pendingRemovalDeviceId by remember { mutableStateOf(null) } var editingDevice by remember { mutableStateOf(null) } var deviceJob by remember { mutableStateOf(null) } + var imageCacheSize by remember { mutableStateOf(null) } + var imageCacheClearing by remember { mutableStateOf(false) } + var imageCacheCleared by remember { mutableStateOf(false) } suspend fun refreshDevices() { devicesLoading = true @@ -305,6 +354,16 @@ fun SettingsSheet( } } + // Measured when the page is opened rather than when Settings is, because walking the + // disk cache's journal has no business happening on a viewer who came here to turn + // subtitles on. It is re-measured on every arrival, since a browse between two visits + // will have filled it again. + LaunchedEffect(selectedPage) { + if (selectedPage == SettingsPage.STORAGE && !imageCacheClearing) { + imageCacheSize = ImageCacheMaintenance.measure(context) + } + } + LaunchedEffect( settings.showTitleLogo, settings.ringColorHex, @@ -318,6 +377,7 @@ fun SettingsSheet( settings.showTenMinuteReminder, settings.seekIntervalSeconds, settings.skipIntroMode, + settings.speedUpCredits, settings.welcomeQuoteStyle, ) { showLogo = settings.showTitleLogo @@ -325,6 +385,7 @@ fun SettingsSheet( showTenMinuteReminder = settings.showTenMinuteReminder seekInterval = settings.seekIntervalSeconds skipIntroMode = settings.skipIntroMode + speedUpCredits = settings.speedUpCredits ringColor = settings.ringColorHex homeSections = settings.homeSections.split(',').toSet() cardDensity = settings.homeCardDensity @@ -349,6 +410,7 @@ fun SettingsSheet( showTenMinuteReminder = showTenMinuteReminder, seekIntervalSeconds = seekInterval, skipIntroMode = skipIntroMode, + speedUpCredits = speedUpCredits, ringColor = ringColor, homeSections = homeSections, cardDensity = cardDensity, @@ -358,16 +420,24 @@ fun SettingsSheet( showRatingsStrip = showRatingsStrip, hideWatchedMovies = hideWatchedMovies, welcomeQuoteStyle = welcomeQuoteStyle, + themeId = settings.themeId, + themeOptions = availableThemes.map { theme -> + ChoiceOption(theme.id, theme.name, parseThemeColor(theme.palette.accent)) + }, + // Locked is taken from the server's answer and defaults to false: a set that has not + // been told anything must not present a picker it will not let anybody use. + themeLocked = resolvedTheme?.locked == true, + themeNotice = resolvedTheme?.takeIf { it.locked }?.reason.orEmpty(), selectedPage = selectedPage, - checking = checking, - updateStatus = status, - installMessage = installMessage, installedVersion = checker.installedVersion, devices = devices, devicesLoading = devicesLoading, devicesError = devicesError, removingDeviceId = removingDeviceId, pendingRemovalDeviceId = pendingRemovalDeviceId, + imageCacheSize = imageCacheSize, + imageCacheClearing = imageCacheClearing, + imageCacheCleared = imageCacheCleared, ) val actions = SettingsPanelActions( onClose = onClose, @@ -391,6 +461,10 @@ fun SettingsSheet( skipIntroMode = it scope.launch { store.setSkipIntroMode(it) } }, + onSpeedUpCreditsChanged = { + speedUpCredits = it + scope.launch { store.setSpeedUpCredits(it) } + }, onRingColorChanged = { ringColor = it scope.launch { store.setRingColor(it) } @@ -405,7 +479,12 @@ fun SettingsSheet( }, onArtworkStyleChanged = { artworkStyle = it - scope.launch { store.setHomeArtworkStyle(it) } + scope.launch { + // Back closes this composable and cancels its scope. Once a selection has + // been accepted on screen, its tiny atomic DataStore write must finish even + // if the viewer leaves Settings immediately afterwards. + withContext(NonCancellable) { store.setHomeArtworkStyle(it) } + } }, onRestoreHiddenRows = { scope.launch { @@ -428,41 +507,18 @@ fun SettingsSheet( hideWatchedMovies = it scope.launch { store.setHideWatchedMovies(it) } }, + onThemeChanged = { chosen -> + // No local echo: what is on screen is the palette the gateway resolves, and it + // arrives through ThemeSync a moment later. Painting optimistically here would + // show a viewer a scheme that a season, or an allowlist they do not know about, + // is about to take back off them. + scope.launch { store.setThemeId(chosen) } + }, onWelcomeQuoteStyleChanged = { welcomeQuoteStyle = it scope.launch { store.setWelcomeQuoteStyle(it) } }, onPageSelected = { selectedPage = it }, - onCheckForUpdates = { - if (!checking) { - checking = true - status = null - installMessage = null - scope.launch { - status = checker.check( - settings.updateBaseUrl.orEmpty(), - settings.updateRepo.orEmpty(), - settings.updateToken.orEmpty(), - ) - checking = false - } - } - }, - onInstallUpdate = { available -> - installMessage = "Downloading update…" - scope.launch { - val result = checker.downloadAndInstall( - available.apkUrl, - settings.updateToken.orEmpty(), - ) - result.exceptionOrNull()?.let { - installMessage = it.message - } ?: run { - installMessage = "Opening the installer…" - onInstallerLaunched?.invoke() - } - } - }, onRefreshDevices = { deviceJob?.cancel() deviceJob = scope.launch { refreshDevices() } @@ -496,13 +552,22 @@ fun SettingsSheet( } }, onCancelDeviceRemoval = { pendingRemovalDeviceId = null }, - onOpenSourceCode = { - runCatching { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.SOURCE_CODE_URL)).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }, - ) + onClearImageCache = clearCache@{ + if (imageCacheClearing) return@clearCache + imageCacheClearing = true + imageCacheCleared = false + scope.launch { + // NonCancellable because emptying 128MB of files outlives a viewer pressing + // Back, and a half-cleared cache is exactly the state this button exists to + // get out of. The state written afterwards is the composition's, so a screen + // that has gone simply drops it. + val remaining = withContext(NonCancellable) { + runCatching { ImageCacheMaintenance.clear(context) } + .getOrDefault(ImageCacheSize.EMPTY) + } + imageCacheSize = remaining + imageCacheCleared = true + imageCacheClearing = false } }, ) @@ -580,6 +645,13 @@ internal fun SettingsPanelContent( navigationFocusRequester: FocusRequester? = null, ) { val contentScrollState = rememberScrollState() + // Right out of the rail was the one direction on this screen left to Compose's spatial + // search, and it is the one that has to cross from a fixed column into a pane whose + // first control is somewhere different on every page — a chip row high up on Welcome, a + // full-width row far down on Devices. A search that finds nothing is a press that does + // nothing, which is what "stuck on that page" looks like from the sofa. The pane is a + // focus group, so one requester names "whatever this page starts with". + val contentFocusRequester = remember { FocusRequester() } LaunchedEffect(state.selectedPage) { contentScrollState.scrollTo(0) @@ -604,6 +676,7 @@ internal fun SettingsPanelContent( onClose = actions.onClose, firstFocusRequester = firstFocusRequester, navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, compact = overlay, modifier = Modifier .width(if (overlay) 190.dp else 224.dp) @@ -613,6 +686,11 @@ internal fun SettingsPanelContent( modifier = Modifier .weight(1f) .fillMaxHeight() + // The requester has to sit above the focus target it names, and focusGroup + // is a target that cannot hold focus itself — so a request on it enters the + // page's first control, whatever that page turns out to be. + .focusRequester(contentFocusRequester) + .focusGroup() .verticalScroll(contentScrollState) .padding( start = if (overlay) 26.dp else 42.dp, @@ -622,12 +700,23 @@ internal fun SettingsPanelContent( ), verticalArrangement = Arrangement.spacedBy(if (overlay) 18.dp else 22.dp), ) { - SettingsHeader( - page = state.selectedPage, - version = state.installedVersion, - ) + SettingsHeader(page = state.selectedPage) when (state.selectedPage) { SettingsPage.APPEARANCE -> SettingsGroup { + // Only drawn when the server has offered something to choose from. On + // the direct path, on an older gateway, and for a viewer the operator + // has left with one scheme, there is no question to ask — and a row of + // one chip that cannot be moved is worse than no row. + if (state.themeOptions.size > 1) { + SettingsColourSchemeRow( + options = state.themeOptions, + selected = state.themeId, + locked = state.themeLocked, + notice = state.themeNotice, + onSelected = actions.onThemeChanged, + ) + SettingDivider() + } SettingsToggleRow( title = "Show logos", description = "Use a film or show's own logo instead of plain text.", @@ -642,6 +731,26 @@ internal fun SettingsPanelContent( selected = state.ringColor, onSelected = actions.onRingColorChanged, ) + SettingDivider() + // The welcome line had a rail page to itself for one chip row. A page + // holding a single question is a destination a viewer has to find before + // they can answer it, and the question — what Memby sounds like when it + // opens — is the same one the rest of this page is asking. + SettingsChoiceRow( + title = "Welcome tone", + description = "A different line is picked each time Memby opens.", + options = WelcomeOptions, + selected = state.welcomeQuoteStyle, + onSelected = actions.onWelcomeQuoteStyleChanged, + ) + SettingsNotice( + text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) { + WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”" + WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”" + WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”" + }, + positive = true, + ) } SettingsPage.PLAYBACK -> SettingsGroup { SettingsToggleRow( @@ -666,6 +775,14 @@ internal fun SettingsPanelContent( onSelected = actions.onSkipIntroModeChanged, ) SettingDivider() + SettingsToggleRow( + title = "Closing credits", + description = "Shrink them to one side at double speed and show " + + "what is on next.", + checked = state.speedUpCredits, + onCheckedChange = actions.onSpeedUpCreditsChanged, + ) + SettingDivider() SettingsChoiceRow( title = "Skip with left and right", description = "How far one press moves what you are watching.", @@ -740,68 +857,6 @@ internal fun SettingsPanelContent( ) } } - SettingsPage.WELCOME -> SettingsGroup { - SettingsChoiceRow( - title = "Tone", - description = "A different line is picked each time Memby opens.", - options = WelcomeOptions, - selected = state.welcomeQuoteStyle, - onSelected = actions.onWelcomeQuoteStyleChanged, - ) - SettingsNotice( - text = when (WelcomeQuoteStyle.from(state.welcomeQuoteStyle)) { - WelcomeQuoteStyle.NEUTRAL -> "“The sofa has been expecting you.”" - WelcomeQuoteStyle.POSITIVE -> "“Tonight has strong main-character energy.”" - WelcomeQuoteStyle.HOMICIDAL -> "“The remote knows what it did.”" - }, - positive = true, - ) - } - SettingsPage.UPDATES -> SettingsGroup { - VersionRow("On this TV", state.installedVersion) - SettingDivider() - VersionRow( - "Newest release", - when (val update = state.updateStatus) { - is UpdateStatus.Available -> update.version - is UpdateStatus.UpToDate -> update.version - is UpdateStatus.Error -> "Unavailable" - null -> "Not checked" - }, - ) - SettingDivider() - SettingsActionRow( - title = if (state.checking) "Checking…" else "Check for updates", - description = "Ask whether a newer build is available.", - badge = if (state.checking) "WORKING" else "CHECK NOW", - onClick = actions.onCheckForUpdates, - ) - when (val update = state.updateStatus) { - is UpdateStatus.UpToDate -> SettingsNotice("This TV is up to date.", positive = true) - is UpdateStatus.Error -> SettingsNotice(update.message, positive = false) - is UpdateStatus.Available -> { - SettingsNotice("Version ${update.version} is ready to install.", positive = true) - if (update.notes.isNotBlank()) { - Text( - update.notes, - color = TextSecondary, - fontSize = 13.sp, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } - SettingsActionRow( - title = "Install it", - description = "Android will ask you to confirm.", - badge = "INSTALL", - onClick = { actions.onInstallUpdate(update) }, - ) - } - null -> Unit - } - state.installMessage?.let { SettingsNotice(it, positive = true) } - } SettingsPage.DEVICES -> SettingsGroup { when { state.devicesLoading && state.devices.isEmpty() -> @@ -828,6 +883,36 @@ internal fun SettingsPanelContent( onClick = actions.onRefreshDevices, ) } + SettingsPage.STORAGE -> SettingsGroup { + val cache = state.imageCacheSize + VersionRow( + "Stored artwork", + cache?.let { formatCacheSize(it.diskBytes) } ?: "Measuring…", + ) + SettingDivider() + VersionRow( + "Artwork in memory", + cache?.let { formatCacheSize(it.memoryBytes) } ?: "Measuring…", + ) + SettingDivider() + SettingsActionRow( + title = if (state.imageCacheClearing) "Clearing…" else "Clear stored artwork", + description = "Frees the space back up and fetches fresh artwork. " + + "Rows will take a moment longer to fill in the first time.", + badge = when { + state.imageCacheClearing -> "WORKING" + cache == null -> "CLEAR" + else -> formatCacheSize(cache.totalBytes).uppercase() + }, + onClick = actions.onClearImageCache, + ) + // Only the confirmation gets a tinted band. A standing explanation in + // the notice colour would be the loudest thing on a page whose whole + // job is to be quiet until somebody presses the one button on it. + if (state.imageCacheCleared && !state.imageCacheClearing) { + SettingsNotice("Stored artwork cleared.", positive = true) + } + } SettingsPage.ABOUT -> SettingsGroup { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), @@ -853,13 +938,6 @@ internal fun SettingsPanelContent( fontWeight = FontWeight.Bold, ) } - SettingDivider() - SettingsActionRow( - title = "Source code", - description = BuildConfig.SOURCE_CODE_URL, - badge = "OPEN", - onClick = actions.onOpenSourceCode, - ) } } if (state.selectedPage == SettingsPage.ABOUT) { @@ -1031,6 +1109,20 @@ private fun DeviceRenameDialog( } } +/** + * How long a rail item must hold focus before its page is actually drawn. + * + * Focus moving down the rail is what selects a page, which is the right gesture and the + * expensive one: walking from Appearance to About passes four pages, and each of them was + * composed in full — Devices going as far as asking the gateway who is signed in — under a + * thumb that had already moved on. On a weak set that is what made the rail feel as though + * it had stuck. The highlight still follows the remote with no delay, because that is the + * part a viewer is watching; only the pane waits to see where they stopped. Short enough + * that a deliberate press never feels held up, the same trade as the launcher's own + * FOCUS_METADATA_DEBOUNCE_MS. + */ +private const val SETTINGS_PAGE_SETTLE_MS = 130L + @Composable private fun SettingsSecondaryRail( selected: SettingsPage, @@ -1038,11 +1130,22 @@ private fun SettingsSecondaryRail( onClose: () -> Unit, firstFocusRequester: FocusRequester?, navigationFocusRequester: FocusRequester?, + contentFocusRequester: FocusRequester, compact: Boolean, modifier: Modifier = Modifier, ) { val pages = SettingsPage.entries.filter { it.showInRail } val selectedRailPage = selected + val scope = rememberCoroutineScope() + // Which item the remote is on, which is deliberately not the same thing as which page + // is drawn. Everything the rail paints reads from this, so nothing about the highlight + // waits on the settle above. + var focusedPage by remember { mutableStateOf(selected) } + LaunchedEffect(focusedPage, selected) { + if (focusedPage == selected) return@LaunchedEffect + delay(SETTINGS_PAGE_SETTLE_MS) + onSelected(focusedPage) + } // The home screen remains composed behind this panel. Relying on spatial focus // search therefore lets a covered media card beat the next rail item when their // bounds happen to be closer (notably below Playback on a 540p viewport). Give @@ -1050,7 +1153,7 @@ private fun SettingsSecondaryRail( // settings surface and activate home content. val railFocusRequesters = remember(firstFocusRequester) { buildList { - add(FocusRequester()) // Exit + add(FocusRequester()) // Back pages.forEach { page -> add( if (page == selectedRailPage && firstFocusRequester != null) { @@ -1089,16 +1192,17 @@ private fun SettingsSecondaryRail( letterSpacing = 1.4.sp, modifier = Modifier.padding(start = 12.dp, bottom = 8.dp), ) - SettingsExitRailItem( + SettingsBackRailItem( compact = compact, onClick = onClose, focusRequester = railFocusRequesters[0], downFocusRequester = railFocusRequesters[1], leftFocusRequester = navigationFocusRequester, + rightFocusRequester = contentFocusRequester, ) pages.forEachIndexed { index, page -> var focused by remember { mutableStateOf(false) } - val active = page == selectedRailPage + val active = page == focusedPage Row( modifier = Modifier .fillMaxWidth() @@ -1132,13 +1236,37 @@ private fun SettingsSecondaryRail( // screen stays composed behind this panel and a covered card can // otherwise win the focus search. left = navigationFocusRequester ?: FocusRequester.Cancel + right = contentFocusRequester + } + .onPreviewKeyEvent { event -> + if ( + event.type != KeyEventType.KeyDown || + event.key != Key.DirectionRight || + page == selected + ) { + return@onPreviewKeyEvent false + } + // Right while a settle is still pending. Letting the focus property + // run would enter the page the viewer has already walked past, and + // lose focus again the moment it is replaced — the same dead end the + // requester exists to close. Draw the page they are actually on + // first, then hand focus to it once it has composed. + onSelected(page) + scope.launch { + delay(32L) + runCatching { contentFocusRequester.requestFocus() } + } + true } .onFocusChanged { focused = it.isFocused - if (it.isFocused && page != selected) onSelected(page) + if (it.isFocused) focusedPage = page } .testTag("settings-rail-${page.name.lowercase()}") - .clickable { onSelected(page) } + .clickable { + focusedPage = page + onSelected(page) + } .padding(horizontal = 12.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -1174,12 +1302,13 @@ private fun SettingsSecondaryRail( } @Composable -private fun SettingsExitRailItem( +private fun SettingsBackRailItem( compact: Boolean, onClick: () -> Unit, focusRequester: FocusRequester, downFocusRequester: FocusRequester, leftFocusRequester: FocusRequester?, + rightFocusRequester: FocusRequester, ) { var focused by remember { mutableStateOf(false) } Row( @@ -1197,6 +1326,7 @@ private fun SettingsExitRailItem( up = FocusRequester.Cancel down = downFocusRequester left = leftFocusRequester ?: FocusRequester.Cancel + right = rightFocusRequester } .onFocusChanged { focused = it.isFocused } .clickable(onClick = onClick) @@ -1205,13 +1335,13 @@ private fun SettingsExitRailItem( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Icon( - Icons.Default.Close, + Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = if (focused) Canvas else TextSecondary, modifier = Modifier.size(19.dp), ) Text( - "Exit", + "Back", color = if (focused) Canvas else TextPrimary, fontSize = if (compact) 13.sp else 14.sp, fontWeight = if (focused) FontWeight.Bold else FontWeight.Medium, @@ -1221,7 +1351,7 @@ private fun SettingsExitRailItem( } @Composable -private fun SettingsHeader(page: SettingsPage, version: String) { +private fun SettingsHeader(page: SettingsPage) { Row( verticalAlignment = Alignment.CenterVertically, // Indented to the rows' own text rather than to the panel edge. With the section @@ -1234,6 +1364,10 @@ private fun SettingsHeader(page: SettingsPage, version: String) { // No eyebrow above the title. It said "MEMBY · TV" on every page of a sheet that is // already inside Memby on a television, so it named the one thing nobody could be in // doubt about while taking the vertical space the settings themselves want. + // + // No version in the corner either, for the same reason it lost the eyebrow: it was + // on all seven pages to answer a question asked on two of them, where Updates prints + // it as "On this TV" and About prints it beside the app's own name. Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold) Text( @@ -1242,8 +1376,6 @@ private fun SettingsHeader(page: SettingsPage, version: String) { fontSize = 13.sp, ) } - Spacer(Modifier.weight(1f)) - Text("v$version", color = TextQuiet, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) } } @@ -1356,6 +1488,70 @@ private fun StatusToggle(checked: Boolean) { } } +/** + * The colour scheme picker. + * + * It is its own row rather than a [SettingsChoiceRow] for one reason: this is the only + * setting on the television that the viewer can be *temporarily overruled* on. While a + * season is in force the chips still show what they chose — it is still theirs, and it comes + * back — but they do nothing, and the line underneath says so in the server's words rather + * than in wording this build guessed at. A greyed row with no explanation would read as a + * broken setting, which is exactly what somebody would report in December. + */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun SettingsColourSchemeRow( + options: List, + selected: String, + locked: Boolean, + notice: String, + onSelected: (String) -> Unit, +) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 13.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + "Colour scheme", + color = TextPrimary, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + Text( + "The colours Memby uses, on every TV you sign in to.", + color = TextSecondary, + fontSize = 13.sp, + ) + } + // Wraps, unlike every other choice row on this page. Those offer three fixed + // options and will never offer four; this one is a server catalogue that grows + // without an app release, and six chips already reach the right edge of a 960dp + // set — the seventh would be off the screen with nothing to say it was there. + FlowRow( + horizontalArrangement = Arrangement.spacedBy(9.dp), + verticalArrangement = Arrangement.spacedBy(9.dp), + ) { + options.forEach { option -> + SettingsChoiceChip( + option = option, + selected = selected.equals(option.value, ignoreCase = true), + // Still focusable while locked, so a viewer can read across the row and + // see what is theirs; the press simply does nothing. + onClick = { if (!locked) onSelected(option.value) }, + ) + } + } + if (locked) { + Text( + notice.ifBlank { "A seasonal scheme is on for everyone at the moment." }, + color = TextQuiet, + fontSize = 13.sp, + ) + } + } +} + @Composable private fun SettingsChoiceRow( title: String, @@ -1512,7 +1708,7 @@ private fun VersionHistorySection( var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) { mutableStateOf(releases.firstOrNull()?.version) } - SettingsGroup(label = "What changed in each release") { + SettingsGroup(label = "Changelog") { if (releases.isEmpty()) { SettingsNotice("No release history shipped with this build.", positive = false) return@SettingsGroup diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt index e0132ec..4cb674a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/DesignTokens.kt @@ -1,5 +1,8 @@ package com.ponzischeme89.memby.ui.theme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -11,36 +14,98 @@ import androidx.compose.ui.unit.dp * and they had drifted into four near-blacks, four greens, two secondary-text greys and * eight corner radii. Nothing here is new design — it is the values that were already * winning, named once so a change lands on both screens at the same time. + * + * **The colours are now the server's answer, not constants.** A theme is decided by the + * gateway (see `server/internal/api/themes.go`) and handed down as a [MembyPalette]; these + * names are reads of whichever one is in force. Which is why every one of them is a `get()` + * rather than a value: a token captured once at class-init time would be the palette that + * happened to be loaded when the first screen composed, and would never change again. + * + * The shape and punctuation below are *not* themeable and are still constants. A theme + * changes colour and nothing else — a palette that could move a corner radius or a + * separator would be able to make a layout wrong from the server, and the whole safety of + * this feature is that the worst a bad theme can do is look bad. */ +/** + * Every colour a theme sets, and the complete list of them. It matches `themePalette` on + * the gateway field for field: a palette carrying a colour with no slot here would be a + * promise the app cannot keep, and one missing a slot is a theme that half-applies, which + * reads as a rendering fault rather than as a colour scheme. + * + * The defaults are Midnight — the palette the app shipped with before themes existed — so a + * television with no gateway, no network or no theme yet looks exactly as it always did. + */ +data class MembyPalette( + val surface: Color = Color(0xFF090B0D), + val surfaceRaised: Color = Color(0xFF101418), + val accent: Color = Color(0xFF52B54B), + val onSurface: Color = Color(0xFFE2E5E8), + val mutedText: Color = Color(0xFFD0D6DB), + val quietText: Color = Color(0xFFAEB7BF), + val hairline: Color = Color(0x28FFFFFF), + val ratingsSurface: Color = Color(0xFF20252A), +) + +/** + * The palette in force, as snapshot state. + * + * Process-wide rather than a `CompositionLocal`, and that is deliberate. One television has + * one signed-in viewer and therefore one theme, and the surfaces that have to obey it are + * not all inside one composition: the launcher, the detail overlays, the settings sheet, the + * player's Compose islands and the screensaver's `DreamService` are five separate roots. A + * local would have to be provided at each of them and would be silently missing from the + * next one somebody added. + * + * Being snapshot state is what makes the tokens below work without a `@Composable` + * annotation: a read inside composition or a draw scope is recorded, so assigning here + * repaints exactly the scopes that use the colour that changed. + */ +private var activePalette by mutableStateOf(MembyPalette()) + +/** + * Repaints the app. Called by `ThemeSync` when the gateway's answer changes — at sign-in, + * when the viewer picks a scheme, and at the midnight a season begins or ends. + * + * Setting the same palette twice is free: `mutableStateOf` compares with `equals`, and + * [MembyPalette] is a data class, so a poll that resolves to what is already on screen + * invalidates nothing. + */ +fun applyMembyPalette(palette: MembyPalette) { + activePalette = palette +} + +/** The palette in force, for the few places that need it whole rather than one colour. */ +val membyPalette: MembyPalette get() = activePalette + /** The near-black every full-screen surface is drawn on. */ -val MembySurface = Color(0xFF090B0D) +val MembySurface: Color get() = activePalette.surface /** One step up, for panels and sheets that need to read as raised off [MembySurface]. */ -val MembySurfaceRaised = Color(0xFF101418) +val MembySurfaceRaised: Color get() = activePalette.surfaceRaised -/** Emby's green. The only accent in the app. */ -val MembyAccent = Color(0xFF52B54B) +/** The single accent. Emby's green on the default theme; a theme may make it anything. */ +val MembyAccent: Color get() = activePalette.accent /** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */ -val MembyOnSurface = Color(0xFFE2E5E8) +val MembyOnSurface: Color get() = activePalette.onSurface /** * Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as * secondary without falling into low-contrast grey-on-black. */ -val MembyMutedText = Color(0xFFD0D6DB) -val MembyQuietText = Color(0xFFAEB7BF) +val MembyMutedText: Color get() = activePalette.mutedText +val MembyQuietText: Color get() = activePalette.quietText /** Hairline rules and unfocused borders. */ -val MembyHairline = Color(0x28FFFFFF) +val MembyHairline: Color get() = activePalette.hairline /** A quiet neutral capsule behind third-party ratings. */ -val MembyRatingsSurface = Color(0xFF20252A) +val MembyRatingsSurface: Color get() = activePalette.ratingsSurface // --- Shape --------------------------------------------------------------------------- // Three steps, largest last. Anything that needs a radius picks the nearest one rather -// than inventing a fourth. +// than inventing a fourth. Not themeable; see the note at the top of this file. /** Chips, badges and small controls. */ val MembyChipCorner = 8.dp diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt index 8d8de2c..8d14140 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt @@ -12,7 +12,12 @@ import androidx.tv.material3.darkColorScheme // The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so // a component that falls back to a theme colour lands on the surface it is sitting on // rather than one shade beside it. -private val EmbyColors = darkColorScheme( +// +// Built per composition rather than held as a constant, because the palette is the +// gateway's answer and changes under a running app. `darkColorScheme` reads the tokens, so +// this function is what carries a theme change into every component that never names a +// colour of its own. +private fun embyColors() = darkColorScheme( primary = MembyAccent, onPrimary = androidx.compose.ui.graphics.Color.White, surface = MembySurfaceRaised, @@ -36,7 +41,7 @@ fun MembyTheme(content: @Composable () -> Unit) { letterSpacing = 0.006.em, lineHeight = 1.22.em, ) - MaterialTheme(colorScheme = EmbyColors) { + MaterialTheme(colorScheme = embyColors()) { CompositionLocalProvider(LocalTextStyle provides appTextStyle) { content() } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt index 6d6258c..940ee09 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNew.kt @@ -54,22 +54,7 @@ internal fun whatsNewDecision( return WhatsNewDecision.Show(release) } -/** One changelog bullet, split into its optional leading label and the sentence itself. */ -internal data class ChangeLine(val tag: String?, val text: String) - -/** - * The labels the changelog is written with. A closed set on purpose: any other colon in a - * bullet ("Fixed: Settings → About: …") is part of the sentence, and lifting it into a - * chip would break the line in half. - */ -private val ChangeTags = setOf("Added", "Changed", "Fixed", "Removed") - -/** Splits `"Added: Subtitles are saved"` into the chip and the copy beside it. */ -internal fun changeLine(raw: String): ChangeLine { - val text = raw.trim() - val colon = text.indexOf(':') - if (colon <= 0) return ChangeLine(null, text) - val tag = text.take(colon) - if (tag !in ChangeTags) return ChangeLine(null, text) - return ChangeLine(tag.uppercase(), text.drop(colon + 1).trim()) -} +// A bullet's leading "Fixed:" / "Added:" used to be split off into an accent pill. Both +// places a viewer reads release notes — this panel and Settings → About — now render the +// line the changelog was written with, so there is nothing left to parse. See +// WhatsNewOverlay's ChangeRow for why. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt index f000847..c51b3b5 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/whatsnew/WhatsNewOverlay.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -49,7 +48,6 @@ import androidx.tv.material3.Text import com.ponzischeme89.memby.ui.UpdateButton import com.ponzischeme89.memby.ui.settings.ReleaseNote import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyChipCorner import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface import com.ponzischeme89.memby.ui.theme.MembyPanelCorner @@ -79,9 +77,6 @@ private const val CHANGE_ROW_HEIGHT_DP = 52 internal fun maxChangesFor(availableHeightDp: Int): Int = ((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES) -/** The label column, fixed so every sentence starts at the same place down the list. */ -private val TagColumnWidth = 88.dp - private val Scrim = Color(0xE60A0C0F) /** @@ -169,7 +164,7 @@ internal fun WhatsNewOverlay( modifier = Modifier.widthIn(max = 620.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - release.changes.take(shown).forEach { ChangeRow(changeLine(it)) } + release.changes.take(shown).forEach { ChangeRow(it) } } if (release.changes.size > shown) { @@ -205,34 +200,27 @@ internal fun WhatsNewOverlay( } } +/** + * One bullet, as the sentence the changelog was written with and nothing else. + * + * It used to lift a leading "Fixed:" / "Added:" into an accent pill in a fixed 88dp column. + * Two things were wrong with that. The column was sized for the longest label, so a panel of + * three one-word tags spent a fifth of its width on them and every sentence started an inch + * from the margin; and the pills were the brightest thing on a screen whose job is to be + * read, so the eye went to four repetitions of the word FIXED rather than to what had been. + * The full line is shown instead — which is also exactly what Settings → About renders, so + * the two places a viewer reads release notes now agree. + */ @Composable -private fun ChangeRow(line: ChangeLine) { +private fun ChangeRow(change: String) { Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - // Fixed column: the labels are different lengths, and ragged sentence starts read - // as a list that has not been laid out rather than one that has. - Box(modifier = Modifier.width(TagColumnWidth), contentAlignment = Alignment.TopStart) { - if (line.tag != null) { - Text( - line.tag, - color = MembyAccent, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 1.sp, - modifier = Modifier - .clip(RoundedCornerShape(MembyChipCorner)) - .background(MembyAccent.copy(alpha = 0.12f)) - .padding(horizontal = 9.dp, vertical = 4.dp), - ) - } else { - Box( - modifier = Modifier - .padding(top = 8.dp) - .size(6.dp) - .clip(CircleShape) - .background(MembyAccent.copy(alpha = 0.7f)), - ) - } - } - Text(line.text, color = MembyMutedText, fontSize = 16.sp) + Box( + modifier = Modifier + .padding(top = 8.dp) + .size(6.dp) + .clip(CircleShape) + .background(MembyAccent.copy(alpha = 0.7f)), + ) + Text(change, color = MembyMutedText, fontSize = 16.sp) } } diff --git a/app/src/main/res/color/player_overlay_primary_option_text.xml b/app/src/main/res/color/player_overlay_primary_option_text.xml new file mode 100644 index 0000000..8395804 --- /dev/null +++ b/app/src/main/res/color/player_overlay_primary_option_text.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/player_scrub_preview_background.xml b/app/src/main/res/drawable/player_scrub_preview_background.xml new file mode 100644 index 0000000..93cab2d --- /dev/null +++ b/app/src/main/res/drawable/player_scrub_preview_background.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 1c612ac..7603284 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -44,6 +44,11 @@ nothing to say about what comes next. --> + + + diff --git a/app/src/main/res/layout/memby_player_controls.xml b/app/src/main/res/layout/memby_player_controls.xml index 358b3ca..5c0e9dc 100644 --- a/app/src/main/res/layout/memby_player_controls.xml +++ b/app/src/main/res/layout/memby_player_controls.xml @@ -261,4 +261,25 @@ + + + diff --git a/app/src/main/res/layout/player_end_credits.xml b/app/src/main/res/layout/player_end_credits.xml new file mode 100644 index 0000000..4726297 --- /dev/null +++ b/app/src/main/res/layout/player_end_credits.xml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Remove Memby access

diff --git a/server/internal/api/admin/pages/account.js b/server/internal/api/admin/pages/account.js index a5b98c9..cc08145 100644 --- a/server/internal/api/admin/pages/account.js +++ b/server/internal/api/admin/pages/account.js @@ -11,6 +11,13 @@ const base = '/admin/api/accounts/' + encodeURIComponent(userId); // endpoint that would return a slice of the same query. let catalogue = []; +// The selectable themes, likewise carried by that endpoint rather than written out here. +let themeCatalogue = []; + +// The same dirty rule the settings form follows, kept separate so saving one does not +// discard an unsaved edit to the other. +let themesDirty = false; + // True while the operator has edited the settings form without saving. The page polls every // thirty seconds and a redraw would take a half-finished change away mid-sentence, so a // dirty form keeps the DOM it already has until it is saved, discarded or reloaded. @@ -115,6 +122,48 @@ function renderSettings(account) { settingControl(definition, values[definition.key])).join('') + '
').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 ''; +} + +function renderThemes(account) { + // An empty list from the server means unrestricted, so it draws as every box ticked. + // Storing "all" and "never configured" identically is deliberate — they are the same + // decision — and this is the one place an operator would notice if it were not. + const allowed = account.themes || []; + const unrestricted = allowed.length === 0; + $('account-themes-state').innerHTML = unrestricted + ? ui.tag('all schemes', 'idle') + : ui.tag(fmt.number(allowed.length) + ' of ' + fmt.number(themeCatalogue.length), 'note'); + $('account-themes').innerHTML = themeCatalogue.map((theme) => + themeRow(theme, unrestricted || allowed.includes(theme.id))).join(''); +} + +function collectThemes() { + return Array.from($('account-themes').querySelectorAll('input:checked')) + .map((input) => input.dataset.themeId); +} + /* ---- the rest of the page ---------------------------------------------- */ // Every build this set has been seen running, newest first and the current one flagged. @@ -199,6 +248,7 @@ Admin.onRefresh(async () => { $('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings'; const payload = await Admin.api('/admin/api/accounts'); catalogue = payload.catalogue || catalogue; + themeCatalogue = payload.themes || themeCatalogue; const account = (payload.accounts || []).find((entry) => entry.id === userId); if (!account) { $('account-identity').innerHTML = @@ -209,13 +259,21 @@ Admin.onRefresh(async () => { renderDevices(account); renderRecommendations(account); if (!dirty) renderSettings(account); + if (!themesDirty) renderThemes(account); }); /* ---- actions ------------------------------------------------------------ */ function message(text) { $('account-settings-message').textContent = text || ''; } +function themeMessage(text) { $('account-themes-message').textContent = text || ''; } + document.addEventListener('input', (event) => { + if ($('account-themes').contains(event.target)) { + themesDirty = true; + themeMessage('unsaved changes'); + return; + } if (!$('account-settings').contains(event.target)) return; dirty = true; message('unsaved changes'); @@ -270,6 +328,28 @@ document.addEventListener('click', (event) => { message('pushed'); }); } + if (action === 'save-themes') { + const themes = collectThemes(); + if (!themes.length && + !confirm('Allow this person no colour schemes? They will be left on Midnight with ' + + 'nothing to choose between.')) return; + themeMessage('saving…'); + Admin.act(async () => { + await Admin.api(base + '/themes', { + method: 'PUT', body: JSON.stringify({ themes }), + }); + // Cleared before the refresh so the boxes are redrawn from what was stored, which is + // how "every box ticked" comes back as the unrestricted state rather than as a list. + themesDirty = false; + themeMessage('saved'); + }); + } + if (action === 'all-themes') { + $('account-themes').querySelectorAll('input[data-theme-id]') + .forEach((input) => { input.checked = true; }); + themesDirty = true; + themeMessage('unsaved changes'); + } if (action === 'reload-preferences') { dirty = false; message(''); diff --git a/server/internal/api/admin/pages/searches.html b/server/internal/api/admin/pages/searches.html new file mode 100644 index 0000000..568dd6d --- /dev/null +++ b/server/internal/api/admin/pages/searches.html @@ -0,0 +1,47 @@ +
+ +
+
+
+

What the house looks for

+

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.

+
+ +
+
+ + + + + + + + +
QuerySearchesViewersLast searched
+
+
+ +
+
+

As it happened

+

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.

+
+
+ + + + + + + +
WhenViewerQuery
+
+
diff --git a/server/internal/api/admin/pages/searches.js b/server/internal/api/admin/pages/searches.js new file mode 100644 index 0000000..63ba29e --- /dev/null +++ b/server/internal/api/admin/pages/searches.js @@ -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) => + '' + fmt.escape(term.query) + '' + + '' + fmt.number(term.searches) + '' + + '' + fmt.number(term.viewers) + '' + + '' + fmt.when(term.lastAt) + '').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) => + '' + fmt.when(event.occurredAt) + '' + + '' + (event.username + ? fmt.escape(event.username) + : ui.tag(event.userId || 'unknown', 'warn')) + '' + + '' + fmt.escape(event.query) + '').join('') + : ui.emptyRow(3, 'No searches in this window.'); +}); + +Admin.ready(() => $('searches-days').addEventListener('change', Admin.refresh)); diff --git a/server/internal/api/admin/pages/subtitles.html b/server/internal/api/admin/pages/subtitles.html new file mode 100644 index 0000000..e0e187b --- /dev/null +++ b/server/internal/api/admin/pages/subtitles.html @@ -0,0 +1,84 @@ +
+ +
+
+
+
+

Bazarr

+

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.

+
+ +
+ +

+
+ +
+
+
+

OpenSubtitles

+

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.

+
+ +
+ + + +
+ + +
+ +
+
+ +
+
+ + + +
+
+
+ +
+
+
+

Subtitles Memby is holding

+

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.

+
+ +
+
+ +
+
diff --git a/server/internal/api/admin/pages/subtitles.js b/server/internal/api/admin/pages/subtitles.js new file mode 100644 index 0000000..82691d9 --- /dev/null +++ b/server/internal/api/admin/pages/subtitles.js @@ -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 + ? '
' + rows.map((row) => + '
' + + '' + fmt.escape(row.provider) + '' + + '' + fmt.escape(row.message) + '' + + '' + ui.tag(row.ok ? 'reachable' : 'not reachable', + row.ok ? 'ok' : 'bad') + '
').join('') + '
' + : 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' }), + })); + }); +}); diff --git a/server/internal/api/admin_accounts.go b/server/internal/api/admin_accounts.go index 018db2e..e8f4cb6 100644 --- a/server/internal/api/admin_accounts.go +++ b/server/internal/api/admin_accounts.go @@ -44,6 +44,11 @@ type adminMembyAccount struct { // the defaults, and saying so is the difference between "chose this" and "has not // chosen anything". Settings adminAccountSettings `json:"settings"` + // Themes is the ids this person may choose between, and an empty array means every + // selectable theme rather than none — the same permissive reading the store and + // themeAllowed take. The console renders that as every box ticked, which is what an + // operator who has never touched the page should see. + Themes []string `json:"themes"` } type adminAccountSettings struct { @@ -87,6 +92,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { settings = map[string]store.UserPreferences{} } + themes, err := s.store.AllUserThemes(r.Context()) + if err != nil { + // Same trade the settings read above makes: a colour allowlist that would not load + // must not cost the operator the device list and the sign-out buttons. + s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err) + themes = map[string][]string{} + } + result := make([]adminMembyAccount, 0, len(accounts)) for _, account := range accounts { pref := preferences[account.ID] @@ -115,6 +128,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { result = append(result, adminMembyAccount{ ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt, LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings, + Themes: nonNilStrings(themes[account.ID]), Recommendations: adminOnboardingPreferences{ Completed: pref.Completed, Prompted: pref.Prompted, Updated: len(account.RecommendationPreferences) > 2, @@ -131,6 +145,11 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "accounts": result, "catalogue": preferenceCatalogue, "schemaVersion": preferenceSchemaVersion, + // The selectable themes, for the same reason the preference catalogue rides along: + // a page that hard-coded the swatches would drift from what the gateway will accept + // the first time a theme is added, and would do it without saying so. Seasonal ones + // are absent because they are not grantable — see themes.go. + "themes": selectableThemes(), }) } diff --git a/server/internal/api/admin_console.go b/server/internal/api/admin_console.go index e48aa96..bc7855e 100644 --- a/server/internal/api/admin_console.go +++ b/server/internal/api/admin_console.go @@ -122,6 +122,11 @@ var adminNav = []adminNavGroup{ Intro: "Presentation policy sent with every playback launch.", Icon: "M8 5v14l11-7zM4 5v14", }, + { + ID: "subtitles", Label: "Subtitles", Title: "Subtitles", + Intro: "Which providers a viewer may fetch a missing subtitle from.", + Icon: "M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5", + }, { ID: "updates", Label: "App updates", Title: "App updates", Intro: "Publish an optional or a required client update.", @@ -147,6 +152,11 @@ var adminNav = []adminNavGroup{ Intro: "Impressions, focus, dwell and selections per launcher row.", Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3", }, + { + ID: "searches", Label: "Searches", Title: "Searches", + Intro: "What the household has been looking for, and what it searched just now.", + Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20", + }, { ID: "logs", Label: "Server logs", Title: "Server logs", Intro: "Structured gateway events as they happen.", diff --git a/server/internal/api/admin_preview_test.go b/server/internal/api/admin_preview_test.go index 846334a..7cb8978 100644 --- a/server/internal/api/admin_preview_test.go +++ b/server/internal/api/admin_preview_test.go @@ -167,5 +167,26 @@ func adminPreviewData() map[string]any { "focuses": 300, "selections": 74, "averageDwellMs": 1800}, }}, "/admin/api/requests": map[string]any{"requests": []any{}}, + // A prefix among the terms and an unattributed row in the log, because both are + // ordinary here and a preview showing neither would not be a preview of this page. + "/admin/api/searches": map[string]any{ + "days": 7, "retentionDays": searchWindowDays, + "termLimit": searchTermLimit, "eventLimit": searchEventLimit, + "totals": map[string]any{"searches": 214, "queries": 96, "viewers": 2}, + "terms": []any{ + map[string]any{"query": "severance", "searches": 18, "viewers": 2, "lastAt": stamp(40 * time.Minute)}, + map[string]any{"query": "dune", "searches": 11, "viewers": 1, "lastAt": stamp(3 * time.Hour)}, + map[string]any{"query": "sev", "searches": 9, "viewers": 2, "lastAt": stamp(40 * time.Minute)}, + map[string]any{"query": "the bear", "searches": 4, "viewers": 1, "lastAt": stamp(2 * 24 * time.Hour)}, + }, + "recent": []any{ + map[string]any{"occurredAt": stamp(40 * time.Minute), "userId": "u-1", + "username": "matt", "query": "severance"}, + map[string]any{"occurredAt": stamp(3 * time.Hour), "userId": "u-2", + "username": "sam", "query": "dune"}, + map[string]any{"occurredAt": stamp(26 * time.Hour), "userId": "u-9", + "username": "", "query": "the bear"}, + }, + }, } } diff --git a/server/internal/api/admin_searches.go b/server/internal/api/admin_searches.go new file mode 100644 index 0000000..b42824c --- /dev/null +++ b/server/internal/api/admin_searches.go @@ -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 +} diff --git a/server/internal/api/admin_subtitles.go b/server/internal/api/admin_subtitles.go new file mode 100644 index 0000000..84d1245 --- /dev/null +++ b/server/internal/api/admin_subtitles.go @@ -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}) +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index d31fafb..05ea767 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -29,6 +29,7 @@ import ( "github.com/ponzischeme89/memby/server/internal/foryou" serverlogging "github.com/ponzischeme89/memby/server/internal/logging" "github.com/ponzischeme89/memby/server/internal/mdblist" + "github.com/ponzischeme89/memby/server/internal/opensubtitles" "github.com/ponzischeme89/memby/server/internal/radarr" "github.com/ponzischeme89/memby/server/internal/recommend" "github.com/ponzischeme89/memby/server/internal/sonarr" @@ -50,9 +51,19 @@ type Server struct { log *slog.Logger events *serverlogging.Buffer sonarrMu sync.Mutex - radarrMu sync.Mutex - bazarrMu sync.Mutex - mdblistMu sync.Mutex + // sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add + // to My Shows never waits behind a launcher rebuilding the schedule row. + sonarrSeriesMu sync.Mutex + radarrMu sync.Mutex + bazarrMu sync.Mutex + // openSubtitles is built from the operator's saved credentials rather than from + // configuration, so it is cached against a fingerprint of them and rebuilt when they + // change. It is cached at all because the client holds a login token, and logging in + // per download would spend a different allowance than the one being conserved. + openSubtitlesMu sync.Mutex + openSubtitles *opensubtitles.Client + openSubtitlesKey string + mdblistMu sync.Mutex // mdblistSettingsCache spares every row and keystroke a settings read. mdblistSettingsCache mdblistSettingsCache // ratingsWarm fills and renews the durable rating cache behind the viewer, so a row @@ -125,6 +136,9 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/home", s.authed(s.handleHome)) v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) v1.Handle("GET /v1/search", s.authed(s.handleSearch)) + // A genre is browsed, not searched: the chip is a filter and this is the route that + // treats it as one. Paged, because a household's Drama shelf is not a screenful. + v1.Handle("GET /v1/genres/{genre}/items", s.authed(s.handleGenreItems)) v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches)) v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory)) v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup)) @@ -145,6 +159,10 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/features", s.authed(s.handleFeatures)) // A viewer's settings follow the person, not the television. Both verbs land on one // handler because a write answers with the stored document, not the submitted one. + // The palette, fetched only when the revision on the status poll moves. A GET with no + // write beside it: what a viewer may change is themeId, and that is an ordinary + // setting on the route above — this route only answers with what came of it. + v1.Handle("GET /v1/theme", s.authed(s.handleTheme)) v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences)) v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences)) @@ -155,10 +173,19 @@ func (s *Server) Routes() http.Handler { v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated)) v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed)) + v1.Handle("POST /v1/items/{id}/hide-from-resume", s.authed(s.handleHideFromResume)) v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback)) v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode)) v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch)) v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload)) + // Repairing the timing of a subtitle the title already has, which is a different + // question from fetching another copy of it — see subtitle_fix.go. + v1.Handle("POST /v1/items/{id}/subtitles/fix", s.authed(s.handleSubtitleFix)) + // The one route that serves a subtitle rather than pointing at Emby's. It exists for + // the provider that hands back bytes instead of writing beside the media file; the + // token arrives in the query string, the way artwork's does, because a media player + // fetching a sidecar sends none of Memby's headers. + v1.Handle("GET /v1/subtitles/{file}", s.authed(s.handleStoredSubtitle)) v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro)) v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay)) diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index d42d7e1..df4e94f 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -277,6 +278,74 @@ func TestSearchHistoryResponseEncodesEmptyQueriesAsArray(t *testing.T) { } } +// Both routes that write search_history apply one rule, so a query /v1/search records is +// exactly one /v1/search/history would have accepted. The length is counted in runes: +// bytes would reject a Japanese title at a third of an English one's length. +func TestSearchQueryRecordable(t *testing.T) { + long := strings.Repeat("a", maxSearchQueryRunes) + for _, tc := range []struct { + name string + term string + want bool + }{ + {"ordinary", "titanic", true}, + {"at the floor", "up", true}, + {"one letter", "u", false}, + {"blank", " ", false}, + {"padded is measured trimmed", " up ", true}, + {"at the ceiling", long, true}, + {"past the ceiling", long + "a", false}, + {"multibyte counted as runes", strings.Repeat("あ", maxSearchQueryRunes), true}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := searchQueryRecordable(tc.term); got != tc.want { + t.Fatalf("searchQueryRecordable(%q) = %v, want %v", tc.term, got, tc.want) + } + }) + } +} + +// A search whose viewer no longer has a session keeps its row: the query is what the page +// is for, and an id still tells one searcher from another. +func TestNameSearchEventsKeepsUnattributedRows(t *testing.T) { + events := []store.SearchEvent{ + {Query: "severance", UserID: "u-1"}, + {Query: "dune", UserID: "gone"}, + } + users := []store.KnownUser{ + {ID: "u-1", Username: "matt"}, + {ID: "u-2", Username: "sam"}, + } + + got := nameSearchEvents(events, users) + + if len(got) != 2 { + t.Fatalf("event count = %d, want 2", len(got)) + } + if got[0].Username != "matt" { + t.Fatalf("username = %q, want matt", got[0].Username) + } + if got[1].Username != "" || got[1].Query != "dune" { + t.Fatalf("unattributed event = %+v, want an empty name and its query", got[1]) + } +} + +// The page must not offer a window the table cannot fill: RecordSearch prunes to the +// retention period, so a wider one would draw a flat line for the difference. +func TestSearchWindowMatchesRetention(t *testing.T) { + if searchWindowDays != 30 { + t.Fatalf("search window = %d days, want 30 to match store.SearchRetention", searchWindowDays) + } +} + +// Recording must never be what stops a search being answered. A gateway with no database +// reaches this on every keystroke, so the guard comes before anything that could panic on +// a half-built server. +func TestRecordSearchQueryWithoutStoreIsSilent(t *testing.T) { + s := &Server{} + s.recordSearchQuery(context.Background(), store.Session{EmbyUserID: "u1"}, "titanic") +} + // The fixed rows must keep their order, ids and kinds: the client maps kinds onto // card shapes and uses ids as Compose keys. func TestBaseRowsShape(t *testing.T) { diff --git a/server/internal/api/credits.go b/server/internal/api/credits.go new file mode 100644 index 0000000..41d4378 --- /dev/null +++ b/server/internal/api/credits.go @@ -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") +} diff --git a/server/internal/api/credits_test.go b/server/internal/api/credits_test.go new file mode 100644 index 0000000..1c97db0 --- /dev/null +++ b/server/internal/api/credits_test.go @@ -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"]) + } +} diff --git a/server/internal/api/features.go b/server/internal/api/features.go index 4e49f0d..1100e9b 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -21,6 +21,9 @@ const ( featureSubtitleDownload = "subtitle_download" featureTrickplay = "trickplay" featureSkipIntro = "skip_intro" + featureEndCredits = "end_credits" + featureSeasonalThemes = "seasonal_themes" + featureSeasonalDecorations = "seasonal_decorations" ) type featureDefinition struct { @@ -80,6 +83,40 @@ var featureCatalogue = []featureDefinition{ DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1", Recovery: "Takes effect the next time playback starts; the button simply stops appearing.", }, + { + Key: featureEndCredits, Name: "Speed through the credits", Area: "Playback", + Description: "Shrink the picture and run the closing credits at double speed with " + + "the next episode beside them, from the credits marker Emby writes. It is read " + + "from the same chapter list as the title sequence, so turning this off saves no " + + "request unless that is off too.", + DefaultEnabled: true, MinimumProtocol: 1, Capability: "end_credits_v1", + Recovery: "Takes effect the next time playback starts; the credits simply play out full size.", + }, + { + // The only switch there is for seasonal themes, and it is deliberately the + // operator's rather than the viewer's: a per-person opt-out is a thing somebody + // turns off in October and never reconsiders, which is the same as the feature not + // existing. Off here means every television falls back to its viewer's own choice + // on the next status poll. + Key: featureSeasonalThemes, Name: "Seasonal themes", Area: "Presentation", + Description: "Put every television into the Halloween, Christmas or Easter palette " + + "for its dates. Viewers cannot decline one; turning this off is the only way to " + + "stop them.", + DefaultEnabled: true, MinimumProtocol: 1, Capability: "themes_v1", + Recovery: "Takes effect on the next status poll, within ten seconds on an open TV.", + }, + { + // A second switch rather than a consequence of the one above, because the palette + // and the animation have quite different costs. Snow drifting over the launcher is + // the only thing in the app that animates continuously while somebody is browsing, + // and these are weak boxes; an operator who finds it costs frames should be able to + // keep December looking like December without it. + Key: featureSeasonalDecorations, Name: "Seasonal decorations", Area: "Presentation", + Description: "Drift snow, bats or blossom over the launcher while a seasonal theme " + + "is on. Turning it off keeps the seasonal colours and stops the animation.", + DefaultEnabled: true, MinimumProtocol: 1, Capability: "seasonal_decorations_v1", + Recovery: "Takes effect on the next status poll; the launcher simply stops drawing them.", + }, { Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup", Description: "Ask a signed-in TV that cannot install its own updates to grant the " + diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go new file mode 100644 index 0000000..220989d --- /dev/null +++ b/server/internal/api/genres.go @@ -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 +} diff --git a/server/internal/api/genres_test.go b/server/internal/api/genres_test.go new file mode 100644 index 0000000..72e1b3f --- /dev/null +++ b/server/internal/api/genres_test.go @@ -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) + } + } +} diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 36ed44f..8c3c694 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -522,6 +522,12 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store limit := queryInt(r, "limit", 40, 100) key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID) + // Every search the tab performs is recorded here, before the cache is consulted, so a + // query answered from Redis counts the same as one that reached Emby. The client also + // posts to /v1/search/history and an older APK is the only thing that records at all — + // recordSearchQuery's dedupe window is what stops the two writing the same query twice. + s.recordSearchQuery(ctx, sess, term) + if raw, err := s.cache.Get(ctx, key); err == nil { w.Header().Set("X-Memby-Cache", "hit") writeRaw(w, http.StatusOK, raw) @@ -560,6 +566,49 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store writeRaw(w, http.StatusOK, body) } +const ( + // minSearchQueryRunes matches the client's own floor: one letter matches half a + // library, so the search tab does not ask below two and neither route records below it. + minSearchQueryRunes = 2 + // maxSearchQueryRunes bounds what is written to search_history. The query arrives in a + // URL on one of the two routes, so the table's row size must not be the client's to + // choose. Runes rather than bytes, or a title in Japanese is rejected at a third of the + // length of one in English. + maxSearchQueryRunes = 200 +) + +// searchQueryRecordable is the one rule both routes apply, so a query the search handler +// records is exactly one the history endpoint would have accepted. +func searchQueryRecordable(term string) bool { + n := len([]rune(strings.TrimSpace(term))) + return n >= minSearchQueryRunes && n <= maxSearchQueryRunes +} + +// recordSearchQuery writes a query the search tab performed, and never makes the viewer +// wait for it. +// +// Detached from the request context deliberately: instant search cancels the in-flight +// request on every keystroke (the client's collectLatest), so a write hung off r.Context() +// would be abandoned for precisely the searches somebody typed fastest — and the record is +// worth having whether or not they waited for the results. +func (s *Server) recordSearchQuery(ctx context.Context, sess store.Session, term string) { + if s.store == nil || !searchQueryRecordable(term) { + return + } + term = strings.TrimSpace(term) + log := s.loggerFor(ctx) + detached := context.WithoutCancel(ctx) + go func() { + ctx, cancel := context.WithTimeout(detached, 5*time.Second) + defer cancel() + if err := s.store.RecordSearch(ctx, sess.EmbyUserID, term); err != nil { + // Telemetry, not the answer: a search whose record failed still returns + // results, and this is DEBUG for the same reason the search line itself is. + log.Debug("search not recorded", "query", term, "error", err) + } + }() +} + type searchHistoryRequest struct { Query string `json:"query"` } @@ -602,7 +651,7 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses return } query := strings.TrimSpace(req.Query) - if len([]rune(query)) < 2 || len([]rune(query)) > 200 { + if !searchQueryRecordable(query) { writeError(w, http.StatusBadRequest, "search query length is invalid") return } diff --git a/server/internal/api/intro.go b/server/internal/api/intro.go index d39884b..ba0e1cb 100644 --- a/server/internal/api/intro.go +++ b/server/internal/api/intro.go @@ -53,10 +53,29 @@ type introSegment struct { // a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title // that has none, and the client defaults to false so a gateway that predates this — or has // the feature turned off — can never conjure a button. +// +// The closing credits ride the same response for one reason: they are in the same chapter +// list, so answering both costs the one Emby request this handler was always going to make. +// A second route for `CreditsStart` would have doubled the cost of a feature whose entire +// claim is that it is free. type introResponse struct { Available bool `json:"available"` StartMs int64 `json:"startMs,omitempty"` EndMs int64 `json:"endMs,omitempty"` + // CreditsAvailable and CreditsStartMs describe the closing credits. Separate from + // Available on purpose: an episode routinely has one and not the other, and folding + // them into a single flag would cost the credits pane every title Emby has detected no + // intro for — which is most films. + CreditsAvailable bool `json:"creditsAvailable"` + CreditsStartMs int64 `json:"creditsStartMs,omitempty"` +} + +// chapterMarkers is everything one reading of an item's chapter list came to. +type chapterMarkers struct { + intro introSegment + introFound bool + creditsStart int64 + creditsFound bool } // embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here. @@ -128,77 +147,138 @@ func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store. writeError(w, http.StatusBadRequest, "item id is required") return } - if !s.skipIntroEnabled(ctx) { + // Two features read this one list, and the request is only worth making if the operator + // has left at least one of them on. + intro, credits := s.skipIntroEnabled(ctx), s.endCreditsEnabled(ctx) + if !intro && !credits { writeJSON(w, http.StatusOK, introResponse{}) return } - segment, ok, err := s.introFor(ctx, sess, itemID) + markers, err := s.markersFor(ctx, sess, itemID) if err != nil { - // Trouble is answered with "no intro" rather than an error. The button is an - // optional convenience on a film that is already playing, and a failure the viewer + // Trouble is answered with "nothing found" rather than an error. Both features are + // optional conveniences on a film that is already playing, and a failure the viewer // cannot act on is not worth a red line in the log for every episode watched. - s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err) + s.loggerFor(ctx).Debug("chapter markers unavailable", "item_id", itemID, "error", err) writeJSON(w, http.StatusOK, introResponse{}) return } - if !ok { + if !markers.introFound && !markers.creditsFound { writeJSON(w, http.StatusOK, introResponse{}) return } // The same answer for everyone in the house, and it only changes when the media does. w.Header().Set("Cache-Control", "private, max-age=3600") - writeJSON(w, http.StatusOK, introResponse{ - Available: true, - StartMs: segment.StartMs, - EndMs: segment.EndMs, - }) + writeJSON(w, http.StatusOK, maskMarkers(markersResponse(markers), intro, credits)) +} + +// markersResponse is the wire shape of a reading, and the one place the two halves are put +// together — so a title with credits and no intro cannot accidentally report an intro +// starting at zero. +func markersResponse(markers chapterMarkers) introResponse { + response := introResponse{} + if markers.introFound { + response.Available = true + response.StartMs = markers.intro.StartMs + response.EndMs = markers.intro.EndMs + } + if markers.creditsFound { + response.CreditsAvailable = true + response.CreditsStartMs = markers.creditsStart + } + return response +} + +// maskMarkers withholds the half of a reading whose feature the operator has turned off. +// +// It happens on the way out rather than on the way in, which is what lets the cache hold +// the unmasked truth: a feature switched back on takes effect on the next playback, instead +// of serving a day of deliberate silence from an entry written while it was off. +func maskMarkers(response introResponse, intro, credits bool) introResponse { + if !intro { + response.Available, response.StartMs, response.EndMs = false, 0, 0 + } + if !credits { + response.CreditsAvailable, response.CreditsStartMs = false, 0 + } + return response } func (s *Server) skipIntroEnabled(ctx context.Context) bool { return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro) } -func introCacheKey(itemID string) string { return "intro:v1:" + itemID } +func (s *Server) endCreditsEnabled(ctx context.Context) bool { + return s.emby != nil && s.featureEnabled(ctx, featureEndCredits) +} -// introFor reads an item's chapter markers, remembering what they came to. +// introCacheKey is v2 because the cached shape grew the credits marker. An entry written by +// the previous build holds no `creditsAvailable`, and decoding it would report "no credits" +// for a day on every title the house had already played — so the key moves rather than the +// old entries being trusted. +func introCacheKey(itemID string) string { return "intro:v2:" + itemID } + +// markersFor reads an item's chapter markers, remembering what they came to. // -// "No intro" is cached as well as an intro. It is the common case — a film, a special, an -// episode Emby has not analysed yet — and without it every playback in the house would be -// a fresh request to Emby for the same no. -func (s *Server) introFor( +// "Nothing found" is cached as well as a finding. It is the common case — a film, a special, +// an episode Emby has not analysed yet — and without it every playback in the house would +// be a fresh request to Emby for the same no. +// +// One reading answers for both features. The intro and the credits are the same field of +// the same response, so splitting them into two lookups would have made the second one cost +// a round trip it has no need to spend. +func (s *Server) markersFor( ctx context.Context, sess store.Session, itemID string, -) (introSegment, bool, error) { +) (chapterMarkers, error) { key := introCacheKey(itemID) if raw, err := s.cache.Get(ctx, key); err == nil { var cached introResponse if json.Unmarshal(raw, &cached) == nil { - return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil + return chapterMarkers{ + intro: introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, + introFound: cached.Available, + creditsStart: cached.CreditsStartMs, + creditsFound: cached.CreditsAvailable, + }, nil } } raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters") if err != nil { - return introSegment{}, false, err + return chapterMarkers{}, err } + // RunTimeTicks rides along because the credits rule needs it: a chapter merely *named* + // "Credits" cannot be told from "Opening Credits" without knowing how far into the file it + // sits. It is a default field on this response, so asking for it costs nothing. var parsed struct { - Chapters []embyChapter `json:"Chapters"` + Chapters []embyChapter `json:"Chapters"` + RunTimeTicks int64 `json:"RunTimeTicks"` } if err := json.Unmarshal(raw, &parsed); err != nil { - return introSegment{}, false, err + return chapterMarkers{}, err } - segment, ok := introFromChapters(parsed.Chapters) + segment, introFound := introFromChapters(parsed.Chapters) + creditsStart, creditsFound := creditsFromChapters( + parsed.Chapters, parsed.RunTimeTicks/ticksPerMillisecond, + ) + markers := chapterMarkers{ + intro: segment, + introFound: introFound, + creditsStart: creditsStart, + creditsFound: creditsFound, + } + + // The shorter "not analysed yet" life applies unless *something* was found. A title + // with credits but no intro has been analysed, and re-asking hourly for the intro Emby + // has already decided it has none of would be a request per playback for a settled no. ttl := introMissingTTL - if ok { + if introFound || creditsFound { ttl = introTTL } - if encoded, err := json.Marshal(introResponse{ - Available: ok, - StartMs: segment.StartMs, - EndMs: segment.EndMs, - }); err == nil { + if encoded, err := json.Marshal(markersResponse(markers)); err == nil { _ = s.cache.Set(ctx, key, encoded, ttl) } - return segment, ok, nil + return markers, nil } diff --git a/server/internal/api/items.go b/server/internal/api/items.go index b519cc9..01a785e 100644 --- a/server/internal/api/items.go +++ b/server/internal/api/items.go @@ -127,7 +127,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess return } - series, err := s.sonarr.Series(ctx) + series, err := s.sonarrSeriesCatalogue(ctx) if err != nil { s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err) s.writeSeasonFinaleResponse(ctx, key, empty, w) @@ -277,6 +277,26 @@ func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store }) } +func (s *Server) handleHideFromResume(w http.ResponseWriter, r *http.Request, sess store.Session) { + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + userData, err := s.emby.HideFromResume(r.Context(), credentials(sess), itemID) + if err != nil { + s.writeUpstreamError(r.Context(), w, err, "could not remove the item from Continue Watching") + return + } + if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { + s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err) + } + if s.forYou != nil { + s.forYou.MarkDirty(r.Context(), sess) + } + writeRaw(w, http.StatusOK, userData) +} + // setFlag applies a user-data mutation and drops this user's cached views, so the next // home request reflects it rather than serving the row it just contradicted. func (s *Server) setFlag( diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index febc036..5568fd5 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -133,11 +133,11 @@ func componentFor(path string) string { return "auth" case path == "/v1/home", path == "/v1/features": return "home" - case path == "/v1/preferences": + case path == "/v1/preferences", path == "/v1/theme": return "settings" case path == "/v1/screensaver", path == "/v1/preroll": return "screensaver" - case strings.HasPrefix(path, "/v1/search"): + case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"): return "search" case strings.HasPrefix(path, "/v1/requests"): return "requests" diff --git a/server/internal/api/logging_test.go b/server/internal/api/logging_test.go index 15f10f4..965253d 100644 --- a/server/internal/api/logging_test.go +++ b/server/internal/api/logging_test.go @@ -42,6 +42,7 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) { "/v1/auth/login": "auth", "/v1/auth/devices/tv-1": "devices", "/v1/search": "search", + "/v1/genres/Comedy/items": "search", "/v1/items/42": "details", "/v1/items/42/related": "details", "/v1/items/42/playback": "playback", diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go index a175fff..b20d981 100644 --- a/server/internal/api/maintenance.go +++ b/server/internal/api/maintenance.go @@ -132,5 +132,13 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, ses // fetches /v1/preferences when they differ. That is what turns this poll into the // delivery channel for an operator pushing someone's settings. "preferencesRevision": s.preferenceRevisionFor(r, sess), + // The theme, as an id and a revision rather than the palette itself — the + // preferencesRevision precedent, for the same reason. The set refetches /v1/theme + // only when one of these moves, which is what makes a season arriving at midnight + // cost one request per television instead of a palette on every ten-second poll. + // It rides the poll rather than the sign-in because that is the whole point: a + // season has to reach a set that is already switched on, without anybody doing + // anything. + "theme": themeStatus(s.themeFor(r.Context(), sess)), }) } diff --git a/server/internal/api/my_shows.go b/server/internal/api/my_shows.go index e8e3c4b..ab9cd5c 100644 --- a/server/internal/api/my_shows.go +++ b/server/internal/api/my_shows.go @@ -78,7 +78,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store. } sonarrSeries := []sonarr.Series{} if s.sonarr != nil { - if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil { + if value, seriesErr := s.sonarrSeriesCatalogue(r.Context()); seriesErr == nil { sonarrSeries = value } else { s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr) @@ -193,7 +193,7 @@ func (s *Server) syncReturnNotifications( if err != nil { return } - all, err := s.sonarr.Series(r.Context()) + all, err := s.sonarrSeriesCatalogue(r.Context()) if err != nil { return } diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 3dd6f30..b1f041d 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -39,6 +39,10 @@ type playbackResponse struct { // already holds this response, and one boolean on a request that is made once per // playback is cheaper than a field on the poll every open TV makes every ten seconds. SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"` + // Whether at least one subtitle on this title can be checked against another readable + // text track. Like the download flag, this rides on the playback response because only + // the subtitle drop-up needs it, and defaults to false for older gateways on the client. + SubtitleFixAvailable bool `json:"subtitleFixAvailable"` // Whether it is worth asking this gateway for seek previews. Only the answer rides // here; the manifest itself does not, because reading it costs a round trip to Emby // and this response is the one thing standing between a Play press and a decoder @@ -49,6 +53,11 @@ type playbackResponse struct { // trip to Emby, and nothing about a skip button is needed before the first frame. The // television asks for the segment itself once playback has settled. SkipIntroAvailable bool `json:"skipIntroAvailable"` + // Whether it is worth asking where the closing credits begin. Same reasoning again, and + // deliberately a second boolean rather than a reuse of SkipIntroAvailable: the two are + // separate features with separate switches, and a house that has turned the skip button + // off has not asked to lose the credits pane with it. + EndCreditsAvailable bool `json:"endCreditsAvailable"` } type playableSubtitle struct { @@ -205,8 +214,10 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto PlaySessionID: playSessionID, PlayMethod: playMethod, SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx), + SubtitleFixAvailable: s.subtitleFixAvailable(subtitles), TrickplayAvailable: s.trickplayEnabled(ctx), SkipIntroAvailable: s.skipIntroEnabled(ctx), + EndCreditsAvailable: s.endCreditsEnabled(ctx), }) } @@ -432,6 +443,11 @@ func (s *Server) playbackSubtitles( Codec: stream.Codec, }) } + // Anything the gateway fetched itself joins the list here, so a subtitle downloaded + // from a provider that cannot write beside the media file is an ordinary track on + // every later playback — not something that exists only in the response to the + // download that produced it. + out = mergeSubtitleTracks(out, s.storedSubtitlesFor(ctx, itemID)) delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil) if delivery != "" { delivery = s.emby.DeliveryURL(cred, delivery) @@ -742,7 +758,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" { return "" } - sonarrSeries, err := s.sonarr.Series(ctx) + sonarrSeries, err := s.sonarrSeriesCatalogue(ctx) if err != nil { s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err) return "" diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index 8f1c756..ab7c9b9 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -112,6 +112,21 @@ var preferenceCatalogue = []preferenceDefinition{ Description: "Use each title's logo artwork in place of plain text.", Kind: preferenceToggle, Default: true, }, + { + // The one thing about themes a viewer decides. The options are the selectable + // catalogue in themes.go rather than a list written out here, so a theme added + // there cannot become a value this rejects. + // + // Note what this key does *not* control: whether a season is in force. That is + // resolved server-side on top of this choice (resolveTheme), so a viewer's stored + // selection survives underneath Halloween rather than being overwritten by it. + // Note also that the per-user allowlist is not expressed here — this vocabulary is + // the same for everybody, and an operator's restriction is applied at resolution. + Key: "themeId", Name: "Colour scheme", Area: "Presentation", + Description: "Which palette this viewer's televisions paint themselves.", + Kind: preferenceChoice, Default: defaultThemeID, + Options: themeOptions(), + }, { Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation", Description: "Tone of the short line shown after signing in.", @@ -143,6 +158,17 @@ var preferenceCatalogue = []preferenceDefinition{ option(skipIntroOff, "Do nothing"), }, }, + { + // Default on: the whole feature is that it happens without being asked for, and it + // is visible, reversible and over in a minute — a viewer who dislikes it turns it + // off having seen exactly what it does. That is a different trade from + // skipIntroMode's, which defaults to the button rather than the automatic seek + // because a jump nobody can see coming is not recoverable by watching it. + Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback", + Description: "When an episode reaches its closing credits, shrink them to one side " + + "at double speed and show what is on next beside them.", + Kind: preferenceToggle, Default: true, + }, { Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback", Description: "Turn a subtitle track on automatically when the title has one.", diff --git a/server/internal/api/sonarr.go b/server/internal/api/sonarr.go index 081df5c..328c334 100644 --- a/server/internal/api/sonarr.go +++ b/server/internal/api/sonarr.go @@ -18,8 +18,67 @@ import ( // deployment until the previous daily cache expires. const sonarrCalendarCachePrefix = "sonarr:calendar:v4:" const sonarrPrerollCachePrefix = "sonarr:preroll:v2:" +const sonarrSeriesCacheKey = "sonarr:series:v1" const sonarrScheduleDays = 5 +// sonarrSeriesCatalogue is Sonarr's whole series list, cached the way the calendar is. +// +// Every caller here wants the same thing — the lifecycle, monitored flag and next airing +// for one or two shows — and each was paying `/api/v3/series` in full to get it. On a +// household with a few hundred followed shows that is a large response Sonarr assembles +// from its own database, and it sat in front of things a viewer is waiting on: adding a +// show to My Shows, which fetches it *after* the write, and the season-finale lookup on a +// detail page. This is where the second or two came from. +// +// It is shared rather than per user — Sonarr's catalogue belongs to the household, not to +// whoever asked — and it takes MEMBY_SONARR_TTL, the same five minutes the calendar rows +// take. Short enough that following a show in Sonarr shows up on the next visit, long +// enough that a viewer working through My Shows pays for it once. +// +// Every failure degrades to asking Sonarr directly: a cache that is down must cost latency, +// never the answer. +func (s *Server) sonarrSeriesCatalogue(ctx context.Context) ([]sonarr.Series, error) { + if s.sonarr == nil { + return nil, fmt.Errorf("sonarr: not configured") + } + if series := s.cachedSonarrSeries(ctx); series != nil { + return series, nil + } + + // The same kind of shared lock the calendar takes, for the same reason: several + // televisions opening together must not each stampede Sonarr on the one miss. Its own + // mutex rather than sonarrMu, so an add to My Shows never waits behind a launcher + // rebuilding the schedule row. + s.sonarrSeriesMu.Lock() + defer s.sonarrSeriesMu.Unlock() + if series := s.cachedSonarrSeries(ctx); series != nil { + return series, nil + } + + series, err := s.sonarr.Series(ctx) + if err != nil { + return nil, err + } + if body, marshalErr := json.Marshal(series); marshalErr == nil { + if cacheErr := s.cache.Set(ctx, sonarrSeriesCacheKey, body, s.cfg.SonarrTTL); cacheErr != nil { + s.loggerFor(ctx).Warn("sonarr series cache write failed", "error", cacheErr) + } + } + return series, nil +} + +func (s *Server) cachedSonarrSeries(ctx context.Context) []sonarr.Series { + raw, err := s.cache.Get(ctx, sonarrSeriesCacheKey) + if err != nil { + return nil + } + var series []sonarr.Series + if json.Unmarshal(raw, &series) != nil { + return nil + } + return series +} + type prerollScheduleResponse struct { Today []prerollScheduleEntry `json:"today"` ThisWeek []prerollScheduleEntry `json:"thisWeek"` diff --git a/server/internal/api/subtitle_download.go b/server/internal/api/subtitle_download.go index 2698207..2e8091c 100644 --- a/server/internal/api/subtitle_download.go +++ b/server/internal/api/subtitle_download.go @@ -5,31 +5,35 @@ import ( "encoding/json" "fmt" "net/http" - "sort" "strconv" "strings" "time" "github.com/ponzischeme89/memby/server/internal/bazarr" "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/opensubtitles" "github.com/ponzischeme89/memby/server/internal/store" ) // Fetching a subtitle a title does not have. // -// The whole feature rests on one property of Bazarr: it writes the subtitle file beside -// the media file. So the gateway never stores a subtitle, never serves one, and never -// learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and -// the track then arrives down the same PlaybackInfo path as an embedded one. That is why -// `playableSubtitle` needed no new shape and the player's existing selection rule works on -// a downloaded track with no special case. +// Two providers answer this now and they are not the same shape. **Bazarr** writes the +// subtitle file beside the media file, so the gateway asks and forgets — Emby finds the +// result on a refresh and the track arrives down the same PlaybackInfo path as an embedded +// one, which is why `playableSubtitle` needed no new shape. **OpenSubtitles** hands back +// bytes, and the gateway has no reach into the media directory, so a file fetched there is +// stored by the gateway and served back as a sidecar. `subtitle_providers.go` holds that +// difference and everything here is written against the one vocabulary. // -// The hard part is not the download, it is the identity. Bazarr keys everything on the -// *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows -// nothing about either, so an Emby item has to be matched onto one by title, year and — -// for an episode — season and episode number. That matching is pure and unit-tested -// (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a -// mismatch downloads a subtitle for the wrong film and writes it next to this one. +// The hard part is not the download, it is the identity, and the two providers make +// opposite trades on it. Bazarr keys everything on the *arr's id (`radarrid` for a film, +// Sonarr's `episodeid` for an episode) and Emby knows nothing about either, so an item has +// to be matched onto one by title, year and — for an episode — season and episode number. +// That matching is pure and unit-tested (`bazarrMovieFor`, `bazarrEpisodeFor`), because it +// is where a wrong answer is worst: a mismatch downloads a subtitle for the wrong film and +// writes it next to this one. OpenSubtitles keys on an imdb or tmdb id, which Memby +// already holds — the library import asks Emby for `ProviderIds` so external ratings can +// be looked up — so there is no guessing at all on that path. const ( bazarrMoviesCacheKey = "bazarr:movies" @@ -53,6 +57,12 @@ const ( // expire underneath them, which on a set being operated by remote control is the more // likely failure of the two. type subtitleCandidate struct { + // Source is which backend produced this row, and it is what the download call + // dispatches on. It round-trips through the television with the token, because the two + // providers' tokens are opaque in different ways and handing one to the other is a + // mistake nothing downstream could detect. Empty means Bazarr: an app built before + // there was a second provider sends no source, and its rows all came from one place. + Source string `json:"source,omitempty"` Token string `json:"token"` Language string `json:"language"` LanguageLabel string `json:"languageLabel"` @@ -61,6 +71,17 @@ type subtitleCandidate struct { Forced bool `json:"forced"` HearingImpaired bool `json:"hearingImpaired"` OriginalFormat bool `json:"originalFormat"` + // MachineOnly marks a translation nobody wrote. It is on the wire rather than folded + // into the label alone because it is the one property that changes whether a viewer + // wants the row at all, and the ranking sinks it below everything a person wrote. + MachineOnly bool `json:"machineOnly,omitempty"` + // Release is the file's own release string, carried for the log rather than the screen + // — "Interstellar.2014.1080p.BluRay" means nothing across a lounge. + Release string `json:"-"` + // format is the extension the provider's file carries. Lower case and unexported: it + // is the gateway's own bookkeeping for a file it is about to store, and there is + // nothing for a television to do with it. + format string // Label is what the drop-up prints. It is composed here rather than on the TV so an // older app renders a new wording correctly, the same reason alert labels are the // gateway's. @@ -96,11 +117,11 @@ type subtitleDownloadResponse struct { } // subtitleDownloadAvailable is the one thing the television needs to know: whether to -// offer the option at all. Both halves matter — an operator can turn the feature off on a -// deployment that has Bazarr, and a deployment without Bazarr must never show a row that -// cannot do anything. +// offer the option at all. It is true when the feature is on and at least one provider is +// both configured and switched on — a deployment with neither must never draw a row that +// leads to a request nothing can answer. func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool { - return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload) + return s.subtitleSources(ctx).any() } func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) { @@ -110,12 +131,13 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se writeError(w, http.StatusBadRequest, "item id is required") return } - if !s.subtitleDownloadAvailable(ctx) { + sources := s.subtitleSources(ctx) + if !sources.any() { writeError(w, http.StatusNotFound, "subtitle downloads are not available") return } - target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID) + target, err := s.resolveSubtitleTarget(ctx, credentials(sess), itemID, sources) if err != nil { s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err) writeJSON(w, http.StatusOK, subtitleSearchResponse{ @@ -125,33 +147,32 @@ func (s *Server) handleSubtitleSearch(w http.ResponseWriter, r *http.Request, se return } - found, err := s.searchBazarr(ctx, target) - if err != nil { - s.loggerFor(ctx).Warn("subtitle search failed", - "item", itemID, "title", target.Title, "error", err, - ) - writeJSON(w, http.StatusOK, subtitleSearchResponse{ - Results: []subtitleCandidate{}, - Message: "The subtitle service did not answer.", - }) - return - } - language := strings.TrimSpace(r.URL.Query().Get("language")) if language == "" { _, language = s.subtitlePreferenceFor(ctx, sess) } - results := rankSubtitleCandidates(found, language) + found, failures := s.providerSubtitles(ctx, sources, target, language) + for _, failure := range failures { + s.loggerFor(ctx).Warn("subtitle search failed", + "item", itemID, "title", target.Title, "error", failure, + ) + } + results := rankMergedCandidates(found, language) s.loggerFor(ctx).Info("subtitle search", "title", target.Title, "item", itemID, "language", clientLogValue(language), + "bazarr", sources.Bazarr && target.hasBazarr, + "opensubtitles", sources.OpenSubtitles && target.hasQuery, "found", len(found), "offered", len(results), ) response := subtitleSearchResponse{Results: results} if len(results) == 0 { - response.Message = "No subtitles were found for this release." + // Which of the two empty answers this is matters to somebody standing in front of + // the set: providers that found nothing is a different thing from providers that + // did not answer, and an exhausted allowance is a third. + response.Message = subtitleFailureMessage(failures) } writeJSON(w, http.StatusOK, response) } @@ -163,7 +184,8 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusBadRequest, "item id is required") return } - if !s.subtitleDownloadAvailable(ctx) { + sources := s.subtitleSources(ctx) + if !sources.any() { writeError(w, http.StatusNotFound, "subtitle downloads are not available") return } @@ -173,54 +195,49 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusBadRequest, "malformed request body") return } - if strings.TrimSpace(request.Candidate.Token) == "" { + candidate := request.Candidate + candidate.Language = normalizeSubtitleLanguage(candidate.Language) + if strings.TrimSpace(candidate.Token) == "" { writeError(w, http.StatusBadRequest, "a subtitle is required") return } cred := credentials(sess) - target, err := s.resolveBazarrTarget(ctx, cred, itemID) + target, err := s.resolveSubtitleTarget(ctx, cred, itemID, sources) if err != nil { s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err) writeError(w, http.StatusNotFound, "Memby could not work out which title this is") return } - subtitle := bazarr.Subtitle{ - Language: request.Candidate.Language, - Provider: request.Candidate.Provider, - Token: request.Candidate.Token, - Forced: request.Candidate.Forced, - HearingImpaired: request.Candidate.HearingImpaired, - OriginalFormat: request.Candidate.OriginalFormat, - } - if target.EpisodeID > 0 { - err = s.bazarr.DownloadEpisode(ctx, target.SeriesID, target.EpisodeID, subtitle) - } else { - err = s.bazarr.DownloadMovie(ctx, target.RadarrID, subtitle) - } + fetched, err := s.fetchSubtitle(ctx, cred, itemID, target, candidate) if err != nil { s.loggerFor(ctx).Warn("subtitle download failed", "title", target.Title, "item", itemID, - "provider", clientLogValue(subtitle.Provider), "error", err, + "source", clientLogValue(candidate.Source), + "provider", clientLogValue(candidate.Provider), "error", err, ) - writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded") + writeError(w, http.StatusBadGateway, subtitleDownloadFailureMessage(err)) return } - // Bazarr has written the file; Emby does not know it exists. Refreshing is what makes - // the track appear, and it is best-effort: if it fails the file is still on disk and - // the next ordinary scan picks it up, so the viewer is told to try again rather than - // told the download failed when it did not. - if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil { - s.loggerFor(ctx).Warn("emby refresh after subtitle download failed", - "item", itemID, "error", refreshErr, - ) - } - select { - case <-time.After(embyRefreshSettleDelay): - case <-ctx.Done(): - return + // Bazarr has written a file Emby does not know exists, and refreshing is what makes + // the track appear. It is best-effort: if it fails the file is still on disk and the + // next ordinary scan picks it up, so the viewer is told to try again rather than told + // the download failed when it did not. A subtitle the gateway serves itself needs none + // of this, and waiting anyway would spend a couple of seconds of somebody's film on + // nothing. + if fetched.RefreshEmby { + if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil { + s.loggerFor(ctx).Warn("emby refresh after subtitle download failed", + "item", itemID, "error", refreshErr, + ) + } + select { + case <-time.After(embyRefreshSettleDelay): + case <-ctx.Done(): + return + } } subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles( @@ -230,24 +247,43 @@ func (s *Server) handleSubtitleDownload(w http.ResponseWriter, r *http.Request, if negotiatedURL != "" { streamURL = negotiatedURL } + // A file the gateway stored names itself, so the player can be pointed at exactly the + // track that was just fetched. Only Bazarr's path has to guess, and it says so by + // answering empty. + selected := fetched.StoredID + if selected == "" { + selected = newestSubtitleID(subtitles, candidate) + } s.loggerFor(ctx).Info("subtitle downloaded", "title", target.Title, "item", itemID, - "language", clientLogValue(subtitle.Language), - "provider", clientLogValue(subtitle.Provider), + "language", clientLogValue(candidate.Language), + "source", clientLogValue(candidate.Source), + "provider", clientLogValue(candidate.Provider), + "release", clientLogValue(candidate.Release), "subtitles", len(subtitles), ) writeJSON(w, http.StatusOK, subtitleDownloadResponse{ - Message: downloadedSubtitleMessage(request.Candidate), + Message: downloadedSubtitleMessage(candidate), Subtitles: subtitles, - SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate), + SelectedSubtitleID: selected, MediaSourceID: mediaSourceID, PlaySessionID: playSessionID, URL: streamURL, }) } +// subtitleDownloadFailureMessage is the sentence a television prints when a fetch fails. +// The allowance running out keeps its own wording for the reason the search's does: +// pressing the button again will not fix it, and nothing else on the set can say so. +func subtitleDownloadFailureMessage(err error) string { + if _, ok := err.(*opensubtitles.QuotaError); ok { + return "today's subtitle downloads have been used up" + } + return "the subtitle could not be downloaded" +} + // bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of // RadarrID and EpisodeID is set. type bazarrTarget struct { @@ -419,83 +455,6 @@ func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Epi return nil } -// rankSubtitleCandidates orders what the viewer sees and caps the list. -// -// The viewer's language comes first, because it is the only thing they asked for; within -// that, Bazarr's own score decides, because it is the only number on the row that means -// anything on a television. Forced and hearing-impaired tracks sort below plain ones in -// the same language for the reason the selection rule already gives — somebody who chose -// Italian wants the dialogue, not the signs. -func rankSubtitleCandidates(found []bazarr.Subtitle, language string) []subtitleCandidate { - preferred := normalizeSubtitleLanguage(language) - if preferred == subtitleLanguageAuto { - preferred = "" - } - ordered := make([]bazarr.Subtitle, len(found)) - copy(ordered, found) - sort.SliceStable(ordered, func(i, j int) bool { - left, right := ordered[i], ordered[j] - leftPreferred := preferred != "" && normalizeSubtitleLanguage(left.Language) == preferred - rightPreferred := preferred != "" && normalizeSubtitleLanguage(right.Language) == preferred - if leftPreferred != rightPreferred { - return leftPreferred - } - if leftVariant, rightVariant := subtitleVariantRank(left), subtitleVariantRank(right); leftVariant != rightVariant { - return leftVariant < rightVariant - } - return left.Score > right.Score - }) - if len(ordered) > maxSubtitleResults { - ordered = ordered[:maxSubtitleResults] - } - results := make([]subtitleCandidate, 0, len(ordered)) - for _, subtitle := range ordered { - if strings.TrimSpace(subtitle.Token) == "" { - continue - } - results = append(results, subtitleCandidate{ - Token: subtitle.Token, - Language: normalizeSubtitleLanguage(subtitle.Language), - LanguageLabel: subtitleLanguageLabel(subtitle.Language), - Provider: subtitle.Provider, - Score: subtitle.Score, - Forced: subtitle.Forced, - HearingImpaired: subtitle.HearingImpaired, - OriginalFormat: subtitle.OriginalFormat, - Label: subtitleCandidateLabel(subtitle), - }) - } - return results -} - -func subtitleVariantRank(subtitle bazarr.Subtitle) int { - switch { - case subtitle.Forced: - return 2 - case subtitle.HearingImpaired: - return 1 - default: - return 0 - } -} - -// subtitleCandidateLabel is what one row says: the language, what kind of track it is, and -// how well Bazarr thinks it matches. The provider is deliberately absent — a viewer has no -// way to prefer one and the name would only crowd the row. -func subtitleCandidateLabel(subtitle bazarr.Subtitle) string { - label := subtitleLanguageLabel(subtitle.Language) - switch { - case subtitle.Forced: - label += " · Forced" - case subtitle.HearingImpaired: - label += " · Hearing impaired" - } - if subtitle.Score > 0 { - label += fmt.Sprintf(" · %d%% match", clampPercent(subtitle.Score)) - } - return label -} - func clampPercent(value int) int { if value < 0 { return 0 diff --git a/server/internal/api/subtitle_download_test.go b/server/internal/api/subtitle_download_test.go index 999dede..74a901c 100644 --- a/server/internal/api/subtitle_download_test.go +++ b/server/internal/api/subtitle_download_test.go @@ -94,7 +94,7 @@ func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) { {Language: "ita", Score: 98, Token: "it-forced", Forced: true}, {Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true}, } - results := rankSubtitleCandidates(found, "it") + results := rankMergedCandidates(bazarrCandidates(found), "it") if len(results) != 4 { t.Fatalf("results = %+v", results) } @@ -113,7 +113,7 @@ func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) { {Language: "eng", Score: 92, Token: "high"}, } for _, language := range []string{"", "auto"} { - results := rankSubtitleCandidates(found, language) + results := rankMergedCandidates(bazarrCandidates(found), language) if len(results) != 2 || results[0].Token != "high" { t.Fatalf("language %q gave %+v", language, results) } @@ -127,7 +127,7 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) { for i := 0; i < maxSubtitleResults+5; i++ { found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"}) } - results := rankSubtitleCandidates(found, "en") + results := rankMergedCandidates(bazarrCandidates(found), "en") if len(results) > maxSubtitleResults { t.Fatalf("len = %d", len(results)) } @@ -138,21 +138,24 @@ func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) { } } -func TestSubtitleCandidateLabelNamesTheLanguageAndKind(t *testing.T) { +func TestMergedCandidateLabelNamesTheLanguageAndKind(t *testing.T) { + label := func(subtitle bazarr.Subtitle) string { + return mergedCandidateLabel(bazarrCandidates([]bazarr.Subtitle{subtitle})[0]) + } for _, testCase := range []struct { subtitle bazarr.Subtitle want string }{ - {bazarr.Subtitle{Language: "ita", Score: 97}, "Italian · 97% match"}, - {bazarr.Subtitle{Language: "eng", Forced: true, Score: 80}, "English · Forced · 80% match"}, - {bazarr.Subtitle{Language: "eng", HearingImpaired: true}, "English · Hearing impaired"}, + {bazarr.Subtitle{Language: "ita", Score: 97, Token: "t"}, "Italian · 97% match"}, + {bazarr.Subtitle{Language: "eng", Forced: true, Score: 80, Token: "t"}, "English · Forced · 80% match"}, + {bazarr.Subtitle{Language: "eng", HearingImpaired: true, Token: "t"}, "English · Hearing impaired"}, // A language Memby has no entry for must still name itself rather than go blank. - {bazarr.Subtitle{Language: "mi"}, "MI"}, - {bazarr.Subtitle{}, "Unknown"}, + {bazarr.Subtitle{Language: "mi", Token: "t"}, "MI"}, + {bazarr.Subtitle{Token: "t"}, "Unknown"}, // Bazarr has been seen to return scores above 100; a "112% match" reads as a bug. - {bazarr.Subtitle{Language: "eng", Score: 112}, "English · 100% match"}, + {bazarr.Subtitle{Language: "eng", Score: 112, Token: "t"}, "English · 100% match"}, } { - if got := subtitleCandidateLabel(testCase.subtitle); got != testCase.want { + if got := label(testCase.subtitle); got != testCase.want { t.Errorf("label = %q, want %q", got, testCase.want) } } diff --git a/server/internal/api/subtitle_fix.go b/server/internal/api/subtitle_fix.go new file mode 100644 index 0000000..94408fd --- /dev/null +++ b/server/internal/api/subtitle_fix.go @@ -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." + } +} diff --git a/server/internal/api/subtitle_fix_test.go b/server/internal/api/subtitle_fix_test.go new file mode 100644 index 0000000..8f5e60c --- /dev/null +++ b/server/internal/api/subtitle_fix_test.go @@ -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()) + } +} diff --git a/server/internal/api/subtitle_providers.go b/server/internal/api/subtitle_providers.go new file mode 100644 index 0000000..db539b0 --- /dev/null +++ b/server/internal/api/subtitle_providers.go @@ -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." +} diff --git a/server/internal/api/subtitle_providers_test.go b/server/internal/api/subtitle_providers_test.go new file mode 100644 index 0000000..5d7e186 --- /dev/null +++ b/server/internal/api/subtitle_providers_test.go @@ -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) + } +} diff --git a/server/internal/api/themes.go b/server/internal/api/themes.go new file mode 100644 index 0000000..61487af --- /dev/null +++ b/server/internal/api/themes.go @@ -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, ",") +} diff --git a/server/internal/api/themes_test.go b/server/internal/api/themes_test.go new file mode 100644 index 0000000..be45af9 --- /dev/null +++ b/server/internal/api/themes_test.go @@ -0,0 +1,279 @@ +package api + +import ( + "slices" + "testing" + "time" +) + +func date(year int, month time.Month, day int) time.Time { + return time.Date(year, month, day, 12, 0, 0, 0, time.UTC) +} + +// The windows are tested at both edges rather than in the middle, because the middle is +// never what breaks: a season that starts a day late is a season nobody sees the start of, +// and one that ends a day late is a red launcher on the 27th of December. +func TestSeasonalThemeWindows(t *testing.T) { + cases := []struct { + name string + when time.Time + want string + }{ + {"the day before Halloween opens", date(2026, time.October, 24), ""}, + {"Halloween opens", date(2026, time.October, 25), themeHalloween}, + {"Halloween itself", date(2026, time.October, 31), themeHalloween}, + {"All Saints", date(2026, time.November, 1), themeHalloween}, + {"the day after Halloween closes", date(2026, time.November, 2), ""}, + + {"the day before December", date(2026, time.November, 30), ""}, + {"December opens", date(2026, time.December, 1), themeChristmas}, + {"Boxing Day", date(2026, time.December, 26), themeChristmas}, + {"the day after Boxing Day", date(2026, time.December, 27), ""}, + {"New Year's Eve", date(2026, time.December, 31), ""}, + + {"an ordinary day", date(2026, time.June, 14), ""}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := seasonalThemeFor(testCase.when); got != testCase.want { + t.Fatalf("seasonalThemeFor(%s) = %q, want %q", + testCase.when.Format(time.DateOnly), got, testCase.want) + } + }) + } +} + +// Easter moves, which is the entire reason it is computed rather than listed. These are the +// real dates; if the computus is wrong the feature silently observes Easter on the wrong +// weekend, which nobody would report as a bug. +func TestEasterSunday(t *testing.T) { + cases := map[int]string{ + 2024: "2024-03-31", 2025: "2025-04-20", 2026: "2026-04-05", + 2027: "2027-03-28", 2030: "2030-04-21", 2038: "2038-04-25", + } + for year, want := range cases { + if got := easterSunday(year).Format(time.DateOnly); got != want { + t.Fatalf("easterSunday(%d) = %s, want %s", year, got, want) + } + } +} + +func TestEasterWindowIsTheLongWeekend(t *testing.T) { + // Easter Sunday 2026 is 5 April, so the window is Good Friday the 3rd to Easter + // Monday the 6th. + cases := []struct { + day int + want string + }{ + {2, ""}, {3, themeEaster}, {4, themeEaster}, {5, themeEaster}, {6, themeEaster}, {7, ""}, + } + for _, testCase := range cases { + if got := seasonalThemeFor(date(2026, time.April, testCase.day)); got != testCase.want { + t.Fatalf("2026-04-%02d = %q, want %q", testCase.day, got, testCase.want) + } + } +} + +// A season is the one thing on this feature nobody on a television can decline, so the +// tests that matter most are the ones asserting that no argument suppresses it. +func TestSeasonOutranksTheViewer(t *testing.T) { + resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) + if resolved.ID != themeChristmas { + t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas) + } + if !resolved.Seasonal || !resolved.Locked { + t.Fatalf("seasonal=%v locked=%v, want both true", resolved.Seasonal, resolved.Locked) + } + if resolved.Reason == "" { + t.Fatal("a locked theme must say why; the picker has nothing else to print") + } + // The viewer's own choice survives underneath, or the picker would show nothing + // selected for the length of the season and look as though it had been forgotten. + if resolved.Chosen != themePlum { + t.Fatalf("chosen = %q, want %q", resolved.Chosen, themePlum) + } +} + +// An allowlist narrows what somebody may pick. It must not narrow a season: those are not +// grantable per person, and an operator restricting a viewer to one palette must not be a +// way of exempting them from Christmas. +func TestAllowlistDoesNotApplyToSeasons(t *testing.T) { + resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31)) + if resolved.ID != themeHalloween { + t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween) + } + // The disallowed choice falls back to the default rather than being kept, so the + // moment the season ends this set paints itself something the operator permits. + if resolved.Chosen != defaultThemeID { + t.Fatalf("chosen = %q, want the default %q", resolved.Chosen, defaultThemeID) + } +} + +func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) { + resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20)) + if resolved.ID != themeEmber { + t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber) + } + if resolved.Seasonal || resolved.Locked { + t.Fatal("with seasonal themes off nothing is locked") + } +} + +func TestResolveThemeFallbacks(t *testing.T) { + ordinary := date(2026, time.June, 14) + cases := []struct { + name string + chosen string + allowed []string + want string + }{ + {"nothing chosen", "", nil, defaultThemeID}, + {"a theme this server does not have", "chartreuse", nil, defaultThemeID}, + {"a theme the operator withheld", themePlum, []string{themeEmber}, defaultThemeID}, + {"a theme the operator allowed", themePlum, []string{themePlum, themeEmber}, themePlum}, + {"no restriction at all", themeForest, []string{}, themeForest}, + // A season named directly is not a way in. It is not selectable, so it is not a + // legal stored value however it got into the document. + {"a season asked for out of season", themeHalloween, nil, defaultThemeID}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary) + if got.ID != testCase.want { + t.Fatalf("resolveTheme(%q, %v) = %q, want %q", + testCase.chosen, testCase.allowed, got.ID, testCase.want) + } + }) + } +} + +// The revision is the whole delivery mechanism: a television refetches the palette only when +// this moves. If it did not move when a season began, no set in the house would repaint. +func TestThemeRevisionTracksTheAnswer(t *testing.T) { + ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) + christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20)) + if ordinary.Revision == christmas.Revision { + t.Fatal("the revision must change when the season does, or nothing refetches") + } + // And it must be stable, or every poll would look like a change and every set would + // fetch the palette six times a minute. + again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15)) + if ordinary.Revision != again.Revision { + t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision) + } +} + +// The decoration is the operator's second switch. It must be able to come off without +// taking the palette with it, and it must never appear on a theme somebody chose to look at +// every day of the year. +func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) { + christmas := date(2026, time.December, 20) + if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow { + t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow) + } + off := resolveTheme(themePlum, nil, true, false, christmas) + if off.Decoration != "" { + t.Fatalf("decorations off still returned %q", off.Decoration) + } + if off.ID != themeChristmas { + t.Fatal("turning decorations off must keep the seasonal palette") + } + // The revision has to move, or a set already snowing is never told to stop. + if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision { + t.Fatal("the revision must change when the decoration does") + } + ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14)) + if ordinary.Decoration != "" { + t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration) + } +} + +func TestOnlySeasonalThemesDeclareADecoration(t *testing.T) { + for _, theme := range selectableThemes() { + if theme.Decoration != "" { + t.Fatalf("%q is selectable and must not decorate: %q", theme.ID, theme.Decoration) + } + } + for _, theme := range themeCatalogue { + if theme.Seasonal && theme.Decoration == "" { + t.Fatalf("%q is a season with nothing to draw", theme.ID) + } + } +} + +func TestSelectableThemesExcludeSeasons(t *testing.T) { + for _, theme := range selectableThemes() { + if theme.Seasonal { + t.Fatalf("%q is seasonal and must never be offered as a choice", theme.ID) + } + } + if !slices.Contains(selectableThemeIDs(), defaultThemeID) { + t.Fatalf("the default theme %q must be selectable", defaultThemeID) + } +} + +// Every theme must set every token. A palette missing one is a theme that half-applies, +// which reads on a television as a rendering fault rather than as a colour scheme. +func TestEveryThemeSetsEveryColour(t *testing.T) { + for _, theme := range themeCatalogue { + palette := theme.Palette + for name, value := range map[string]string{ + "surface": palette.Surface, "surfaceRaised": palette.SurfaceRaised, + "accent": palette.Accent, "onSurface": palette.OnSurface, + "mutedText": palette.MutedText, "quietText": palette.QuietText, + "hairline": palette.Hairline, "ratingsSurface": palette.RatingsSurface, + } { + if len(value) != 9 || value[0] != '#' { + t.Fatalf("%s.%s = %q, want #AARRGGBB", theme.ID, name, value) + } + for _, digit := range value[1:] { + if !((digit >= '0' && digit <= '9') || (digit >= 'A' && digit <= 'F')) { + t.Fatalf("%s.%s = %q is not upper-case hex", theme.ID, name, value) + } + } + } + if theme.Name == "" || theme.Description == "" { + t.Fatalf("%s must carry a name and a line for the picker", theme.ID) + } + } +} + +// The catalogue and the preference vocabulary have to agree, or a viewer can be offered a +// theme the settings write will reject — which reads as a picker that does not work. +func TestThemePreferenceOptionsMatchTheCatalogue(t *testing.T) { + definition, ok := preferenceDefinitionFor("themeId") + if !ok { + t.Fatal("themeId is missing from the preference catalogue") + } + if len(definition.Options) != len(selectableThemes()) { + t.Fatalf("themeId offers %d options for %d selectable themes", + len(definition.Options), len(selectableThemes())) + } + for _, theme := range selectableThemes() { + if !hasOption(definition.Options, theme.ID) { + t.Fatalf("themeId does not offer %q", theme.ID) + } + } + if normalizePreference(definition, themeHalloween) != defaultThemeID { + t.Fatal("a seasonal id must not normalise to itself as a stored choice") + } +} + +func TestNormalizeThemeAllowlist(t *testing.T) { + everything := normalizeThemeAllowlist(selectableThemeIDs()) + if len(everything) != 0 { + // "Every box ticked" and "never configured" are the same decision, and storing + // them differently is how the two would drift apart as themes are added. + t.Fatalf("an unrestricted list must store as empty, got %v", everything) + } + kept := normalizeThemeAllowlist([]string{themeEmber, "chartreuse", themeHalloween, themeEmber}) + if len(kept) != 1 || kept[0] != themeEmber { + t.Fatalf("normalizeThemeAllowlist dropped the wrong things: %v", kept) + } + // Catalogue order, not request order, so two operators ticking the same boxes in a + // different sequence store the same row. + ordered := normalizeThemeAllowlist([]string{themePlum, themeMidnight}) + if !slices.Equal(ordered, []string{themeMidnight, themePlum}) { + t.Fatalf("allowlist is not in catalogue order: %v", ordered) + } +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index 85cdcf9..a2dd1d3 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.23 \ No newline at end of file +0.1.29 \ No newline at end of file diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index 64994df..9643bb1 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -373,6 +373,21 @@ func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, return c.userDataCall(ctx, method, path, cred) } +// HideFromResume removes an item from resume and next-up feeds without marking it played. +func (c *Client) HideFromResume(ctx context.Context, cred Credentials, itemID string) (json.RawMessage, error) { + params := url.Values{"Hide": {"true"}} + path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/HideFromResume" + req, err := c.newRequest(ctx, http.MethodPost, path, params, cred, nil) + if err != nil { + return nil, err + } + var raw json.RawMessage + if err := c.do(req, &raw); err != nil { + return nil, err + } + return raw, nil +} + // RefreshItem asks Emby to re-scan one item's files. // // It exists for the subtitle download: Bazarr writes the new .srt beside the media file @@ -501,6 +516,43 @@ func (c *Client) TrickplayBytes( return io.ReadAll(io.LimitReader(resp.Body, to-from+1)) } +// MaxSubtitleBytes caps a subtitle read. A feature-length SubRip file is tens of +// kilobytes; a megabyte is far past anything genuine and is what an Emby that answered +// with the media file instead would send. +const MaxSubtitleBytes = 4 << 20 + +// SubtitleBytes reads a text subtitle track's content. +// +// This is the one place the gateway pulls a subtitle *out* of Emby rather than pointing +// the television at it, and it exists so a track can be used as a timing reference. SubRip +// is asked for because that is what the sync reads and writes; Emby converts from whatever +// the container holds. +func (c *Client) SubtitleBytes( + ctx context.Context, cred Credentials, itemID, mediaSourceID string, index int, +) ([]byte, error) { + if mediaSourceID == "" { + mediaSourceID = itemID + } + path := fmt.Sprintf( + "/Videos/%s/%s/Subtitles/%d/Stream.srt", + url.PathEscape(itemID), url.PathEscape(mediaSourceID), index, + ) + req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} + } + return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes)) +} + // StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby // address: video never flows through the gateway, only metadata does. func (c *Client) StreamURL(cred Credentials, itemID string) string { diff --git a/server/internal/emby/client_resume_test.go b/server/internal/emby/client_resume_test.go new file mode 100644 index 0000000..b18719b --- /dev/null +++ b/server/internal/emby/client_resume_test.go @@ -0,0 +1,42 @@ +package emby + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestHideFromResumeUsesDedicatedEmbyEndpoint(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "unexpected method: "+r.Method, http.StatusBadRequest) + return + } + if r.URL.Path != "/Users/user-1/Items/item-2/HideFromResume" { + http.Error(w, "unexpected path: "+r.URL.Path, http.StatusBadRequest) + return + } + if r.URL.Query().Get("Hide") != "true" { + http.Error(w, "Hide must be true", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"Played":false,"PlaybackPositionTicks":0}`)) + })) + defer upstream.Close() + + client := New(upstream.URL, upstream.URL, "MbyATV", time.Second) + got, err := client.HideFromResume( + context.Background(), + Credentials{UserID: "user-1", Token: "token"}, + "item-2", + ) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"Played":false,"PlaybackPositionTicks":0}` { + t.Fatalf("response = %s", got) + } +} diff --git a/server/internal/opensubtitles/client.go b/server/internal/opensubtitles/client.go new file mode 100644 index 0000000..0a4c080 --- /dev/null +++ b/server/internal/opensubtitles/client.go @@ -0,0 +1,465 @@ +// Package opensubtitles provides the slice of opensubtitles.com Memby uses to fetch a +// subtitle a title does not have. +// +// It is the second subtitle provider and it is not shaped like the first. Bazarr's whole +// appeal is that it writes the file beside the media file, so the gateway asks and then +// forgets — Emby finds the result and the track arrives down the ordinary PlaybackInfo +// path. OpenSubtitles hands back bytes, and the gateway has no access to the media +// directory, so a file fetched here is stored by the gateway and served back as a sidecar. +// That difference is worth stating because everything else about the two providers is the +// same shape, and the storage is the only reason `downloaded_subtitles` exists. +// +// Three things about the API are not obvious and each would otherwise be found as a bug: +// +// - Identity is an id, not a title. Searching takes an imdb or tmdb id, which is a far +// better match than the title-and-year guessing the Bazarr path is stuck with — and +// Memby already has those ids, because the library import asks Emby for ProviderIds +// so external ratings can be looked up. +// - The search result is not the file. A row carries a `file_id`, and turning that into +// bytes is a second call to /download which returns a short-lived link. It is the +// /download call that spends the account's daily allowance, never the search. +// - /download wants a logged-in token in practice. An API key alone is accepted but the +// anonymous allowance is a handful of files a day, which in front of a television +// reads as the feature being broken. Credentials are optional here and the token is +// cached, because logging in per download would spend a different quota instead. +package opensubtitles + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// DefaultBaseURL is the REST API's home. It is a field on the client so the tests can +// point at a fake, never so an operator can redirect credentials somewhere else. +const DefaultBaseURL = "https://api.opensubtitles.com/api/v1" + +// maxSubtitleBytes bounds what will be read from a download link. A subtitle is tens of +// kilobytes; anything approaching this is not a subtitle, and the gateway stores what it +// fetches, so an unbounded read here would be an unbounded row in Postgres. +const maxSubtitleBytes = 4 << 20 + +// tokenLifetime is how long a login token is reused for. OpenSubtitles issues them for +// about a day; renewing well inside that costs one request and avoids the case nobody +// tests, which is the token expiring in the middle of somebody's film. +const tokenLifetime = 12 * time.Hour + +type Client struct { + baseURL string + apiKey string + userAgent string + username string + password string + http *http.Client + + // The login token is shared by every viewer in the house, because the account is the + // household's rather than anybody's. The mutex is held across the login request so a + // launcher full of televisions cannot log in six times at once. + mu sync.Mutex + token string + tokenExpiry time.Time +} + +// Subtitle is one candidate from a search. +// +// FileID is what /download takes and it is the only field that has to survive the round +// trip through a television. Rating and Downloads are the two numbers a viewer could +// sensibly choose by, and they are folded into a single Score by the caller so a row from +// here reads the same as a row from Bazarr. +type Subtitle struct { + FileID int + SubtitleID string + Language string + Release string + FileName string + Format string + Forced bool + HearingImpaired bool + MachineOnly bool + FromTrusted bool + Downloads int + Rating float64 +} + +// Query is what identifies a title. Exactly one of IMDBID, TMDBID and Query is normally +// set; for an episode, ParentIMDBID with Season and Episode is the reliable shape, since +// far more shows carry an id on the series than on every episode. +type Query struct { + IMDBID string + TMDBID string + ParentIMDBID string + ParentTMDBID string + Query string + Season int + Episode int + Languages []string + // Type narrows the search to "movie" or "episode". It is worth sending: a query by + // title alone otherwise returns a film and the show named after it together. + Type string +} + +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("opensubtitles: status %d: %s", e.StatusCode, e.Body) +} + +// QuotaError is the one failure worth telling a viewer about in its own words. Everything +// else is "the provider did not answer"; this one is "you have used today's downloads", +// which is not something pressing the button again will fix. +type QuotaError struct { + ResetTime string +} + +func (e *QuotaError) Error() string { + if e.ResetTime != "" { + return "opensubtitles: download quota exhausted, resets in " + e.ResetTime + } + return "opensubtitles: download quota exhausted" +} + +func New(apiKey, userAgent, username, password string, timeout time.Duration) *Client { + return &Client{ + baseURL: DefaultBaseURL, + apiKey: strings.TrimSpace(apiKey), + userAgent: strings.TrimSpace(userAgent), + username: strings.TrimSpace(username), + password: password, + http: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 5, + IdleConnTimeout: 90 * time.Second, + }, + }, + } +} + +// SetBaseURL points the client somewhere else. It exists for the tests. +func (c *Client) SetBaseURL(base string) { + c.baseURL = strings.TrimRight(base, "/") +} + +// HasAccount reports whether a download will be made against the household's own +// allowance rather than the anonymous one. The console shows it, because the difference +// is the difference between a working feature and one that stops after a few files. +func (c *Client) HasAccount() bool { + return c.username != "" && c.password != "" +} + +// Search asks the providers what exists for one title. +// +// A search costs nothing against the download allowance, which is why the television is +// allowed to run one per press. An empty result is an ordinary answer. +func (c *Client) Search(ctx context.Context, query Query) ([]Subtitle, error) { + params := searchParams(query) + if len(params) == 0 { + return nil, fmt.Errorf("opensubtitles: nothing to search by") + } + var payload struct { + Data []struct { + Attributes struct { + SubtitleID string `json:"subtitle_id"` + Language string `json:"language"` + DownloadCount int `json:"download_count"` + HearingImpaired bool `json:"hearing_impaired"` + ForeignPartsOnly bool `json:"foreign_parts_only"` + FromTrusted bool `json:"from_trusted"` + AITranslated bool `json:"ai_translated"` + MachineTranslated bool `json:"machine_translated"` + Ratings float64 `json:"ratings"` + Release string `json:"release"` + Files []struct { + FileID int `json:"file_id"` + FileName string `json:"file_name"` + } `json:"files"` + } `json:"attributes"` + } `json:"data"` + } + if err := c.get(ctx, "/subtitles", params, &payload); err != nil { + return nil, err + } + out := make([]Subtitle, 0, len(payload.Data)) + for _, row := range payload.Data { + attributes := row.Attributes + // A row with no file is not a candidate: there is nothing to hand to /download, + // and a chooseable row that cannot be fetched is worse than one fewer row. + if len(attributes.Files) == 0 || attributes.Files[0].FileID == 0 { + continue + } + file := attributes.Files[0] + out = append(out, Subtitle{ + FileID: file.FileID, + SubtitleID: attributes.SubtitleID, + Language: strings.TrimSpace(attributes.Language), + Release: strings.TrimSpace(attributes.Release), + FileName: strings.TrimSpace(file.FileName), + Format: formatFromName(file.FileName), + Forced: attributes.ForeignPartsOnly, + HearingImpaired: attributes.HearingImpaired, + MachineOnly: attributes.AITranslated || attributes.MachineTranslated, + FromTrusted: attributes.FromTrusted, + Downloads: attributes.DownloadCount, + Rating: attributes.Ratings, + }) + } + return out, nil +} + +// CanSearch reports whether a query identifies a title well enough to be worth sending. +// It is the same rule searchParams applies, exported so a caller can decide not to offer +// this provider for a title rather than sending a request that cannot answer. +func CanSearch(query Query) bool { + return len(searchParams(query)) > 0 +} + +// searchParams is pure so the shape of a query can be pinned by a test. The parent ids are +// only sent for an episode: on a film they mean nothing, and sending both an id and a +// season number is how a search comes back empty for a title that plainly exists. +func searchParams(query Query) url.Values { + params := url.Values{} + episode := query.Season > 0 || query.Episode > 0 + switch { + case strings.TrimSpace(query.IMDBID) != "": + params.Set("imdb_id", trimIMDB(query.IMDBID)) + case strings.TrimSpace(query.TMDBID) != "": + params.Set("tmdb_id", strings.TrimSpace(query.TMDBID)) + case episode && strings.TrimSpace(query.ParentIMDBID) != "": + params.Set("parent_imdb_id", trimIMDB(query.ParentIMDBID)) + case episode && strings.TrimSpace(query.ParentTMDBID) != "": + params.Set("parent_tmdb_id", strings.TrimSpace(query.ParentTMDBID)) + case strings.TrimSpace(query.Query) != "": + params.Set("query", strings.TrimSpace(query.Query)) + default: + return nil + } + if episode { + if query.Season >= 0 && (query.Season > 0 || query.Episode > 0) { + params.Set("season_number", strconv.Itoa(query.Season)) + } + if query.Episode > 0 { + params.Set("episode_number", strconv.Itoa(query.Episode)) + } + } + if languages := joinLanguages(query.Languages); languages != "" { + params.Set("languages", languages) + } + if kind := strings.TrimSpace(query.Type); kind != "" { + params.Set("type", kind) + } + return params +} + +// joinLanguages normalises the language list the API wants: lower case, comma separated, +// sorted, and deduplicated. It is fussy about this — an unsorted list is rejected — which +// is exactly the kind of thing that fails once in production and never in a review. +func joinLanguages(languages []string) string { + seen := map[string]bool{} + values := make([]string, 0, len(languages)) + for _, language := range languages { + language = strings.ToLower(strings.TrimSpace(language)) + if language == "" || seen[language] { + continue + } + seen[language] = true + values = append(values, language) + } + for i := 1; i < len(values); i++ { + for j := i; j > 0 && values[j] < values[j-1]; j-- { + values[j], values[j-1] = values[j-1], values[j] + } + } + return strings.Join(values, ",") +} + +func trimIMDB(value string) string { + return strings.TrimPrefix(strings.TrimSpace(value), "tt") +} + +func formatFromName(name string) string { + if index := strings.LastIndex(name, "."); index >= 0 && index < len(name)-1 { + extension := strings.ToLower(name[index+1:]) + if extension == "srt" || extension == "vtt" || extension == "ass" || extension == "ssa" { + return extension + } + } + return "srt" +} + +// Download turns a file id into bytes. +// +// It is two requests — a link, then the file — and it is the call that spends the +// account's allowance, so it is never made speculatively and never on the playback path. +func (c *Client) Download(ctx context.Context, fileID int) (name string, content []byte, err error) { + body, err := json.Marshal(map[string]any{"file_id": fileID}) + if err != nil { + return "", nil, err + } + var payload struct { + Link string `json:"link"` + FileName string `json:"file_name"` + Remaining int `json:"remaining"` + ResetTime string `json:"reset_time"` + Message string `json:"message"` + } + if err := c.post(ctx, "/download", body, &payload); err != nil { + var apiErr *APIError + // 406 is what the API answers with when the allowance is gone. It is the one + // failure a viewer can act on — by waiting — so it keeps its own type. + if ok := asAPIError(err, &apiErr); ok && apiErr.StatusCode == http.StatusNotAcceptable { + return "", nil, &QuotaError{} + } + return "", nil, err + } + if strings.TrimSpace(payload.Link) == "" { + return "", nil, &QuotaError{ResetTime: payload.ResetTime} + } + + // The link is a plain file on a CDN and carries neither the API key nor the token. + // Sending them would leak the household's credentials to a host that is not the API. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, payload.Link, nil) + if err != nil { + return "", nil, err + } + req.Header.Set("User-Agent", c.userAgent) + resp, err := c.http.Do(req) + if err != nil { + return "", nil, fmt.Errorf("opensubtitles: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", nil, &APIError{StatusCode: resp.StatusCode, Body: "download link"} + } + content, err = io.ReadAll(io.LimitReader(resp.Body, maxSubtitleBytes)) + if err != nil { + return "", nil, fmt.Errorf("opensubtitles: read subtitle: %w", err) + } + if len(content) == 0 { + return "", nil, fmt.Errorf("opensubtitles: empty subtitle file") + } + return payload.FileName, content, nil +} + +// Ping is the reachability and credential check the console uses. It reads the account +// endpoint when there is an account and the plain info endpoint otherwise, so "the key +// works" and "the login works" are two different answers. +func (c *Client) Ping(ctx context.Context) error { + if c.HasAccount() { + if _, err := c.authToken(ctx); err != nil { + return err + } + } + var payload struct { + Data map[string]any `json:"data"` + } + return c.get(ctx, "/infos/formats", nil, &payload) +} + +// authToken returns a login token, logging in if the cached one is missing or old. An +// account that will not log in is not fatal: the download is attempted anonymously, which +// works until the small anonymous allowance runs out and is a better answer than refusing +// to try. +func (c *Client) authToken(ctx context.Context) (string, error) { + if !c.HasAccount() { + return "", nil + } + c.mu.Lock() + defer c.mu.Unlock() + if c.token != "" && time.Now().Before(c.tokenExpiry) { + return c.token, nil + } + body, err := json.Marshal(map[string]string{"username": c.username, "password": c.password}) + if err != nil { + return "", err + } + var payload struct { + Token string `json:"token"` + } + // Deliberately not through post(): that asks for a token, and this is how one is got. + if err := c.do(ctx, http.MethodPost, "/login", nil, body, "", &payload); err != nil { + return "", err + } + if strings.TrimSpace(payload.Token) == "" { + return "", fmt.Errorf("opensubtitles: login returned no token") + } + c.token = payload.Token + c.tokenExpiry = time.Now().Add(tokenLifetime) + return c.token, nil +} + +func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error { + token, _ := c.authToken(ctx) + return c.do(ctx, http.MethodGet, path, params, nil, token, out) +} + +func (c *Client) post(ctx context.Context, path string, body []byte, out any) error { + token, _ := c.authToken(ctx) + return c.do(ctx, http.MethodPost, path, nil, body, token, out) +} + +func (c *Client) do( + ctx context.Context, method, path string, params url.Values, + body []byte, token string, out any, +) error { + endpoint := c.baseURL + path + if len(params) > 0 { + endpoint += "?" + params.Encode() + } + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return err + } + req.Header.Set("Api-Key", c.apiKey) + req.Header.Set("Accept", "application/json") + // The API rejects a request with no User-Agent naming the consumer, and it is the one + // header here that is about being a good citizen rather than about authentication. + req.Header.Set("User-Agent", c.userAgent) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("opensubtitles: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(raw))} + } + if out == nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("opensubtitles: decode response: %w", err) + } + return nil +} + +func asAPIError(err error, target **APIError) bool { + if converted, ok := err.(*APIError); ok { + *target = converted + return true + } + return false +} diff --git a/server/internal/opensubtitles/client_test.go b/server/internal/opensubtitles/client_test.go new file mode 100644 index 0000000..acce3b9 --- /dev/null +++ b/server/internal/opensubtitles/client_test.go @@ -0,0 +1,177 @@ +package opensubtitles + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSearchParamsPrefersItsOwnIDOverTheParents(t *testing.T) { + params := searchParams(Query{ + IMDBID: "tt0903747", ParentIMDBID: "tt0999999", Season: 1, Episode: 2, + Languages: []string{"it", "en", "en"}, Type: "episode", + }) + if got := params.Get("imdb_id"); got != "0903747" { + t.Fatalf("imdb_id = %q, want the tt stripped", got) + } + if params.Has("parent_imdb_id") { + t.Fatal("parent_imdb_id was sent alongside the episode's own id") + } + if got := params.Get("season_number"); got != "1" { + t.Fatalf("season_number = %q", got) + } + // Sorted and deduplicated: the API rejects the list in any other shape. + if got := params.Get("languages"); got != "en,it" { + t.Fatalf("languages = %q, want %q", got, "en,it") + } +} + +func TestSearchParamsFallsBackToTheSeriesForAnEpisode(t *testing.T) { + params := searchParams(Query{ParentIMDBID: "tt0903747", Season: 5, Episode: 14}) + if got := params.Get("parent_imdb_id"); got != "0903747" { + t.Fatalf("parent_imdb_id = %q", got) + } + if got := params.Get("episode_number"); got != "14" { + t.Fatalf("episode_number = %q", got) + } +} + +// A film must never be sent a season number: an id plus a season is how a search comes +// back empty for a title that plainly exists. +func TestSearchParamsSendsNoSeasonForAFilm(t *testing.T) { + params := searchParams(Query{TMDBID: "550", Type: "movie"}) + if params.Has("season_number") || params.Has("episode_number") { + t.Fatal("a film was searched for with episode numbers") + } + if got := params.Get("tmdb_id"); got != "550" { + t.Fatalf("tmdb_id = %q", got) + } +} + +func TestSearchParamsRefusesAQueryItCannotIdentify(t *testing.T) { + if params := searchParams(Query{Languages: []string{"en"}}); len(params) != 0 { + t.Fatalf("searchParams answered %v for a query with no identity", params) + } +} + +func TestSearchDropsRowsWithNoFileToFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Api-Key") != "key" { + t.Errorf("api key header = %q", r.Header.Get("Api-Key")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{ + map[string]any{"attributes": map[string]any{ + "language": "en", "ratings": 8.5, "download_count": 120, + "foreign_parts_only": true, "release": "BluRay", + "files": []any{map[string]any{"file_id": 42, "file_name": "x.srt"}}, + }}, + map[string]any{"attributes": map[string]any{"language": "it", "files": []any{}}}, + }}) + })) + defer server.Close() + + client := New("key", "Memby/test", "", "", 5*time.Second) + client.SetBaseURL(server.URL) + found, err := client.Search(context.Background(), Query{IMDBID: "tt1", Languages: []string{"en"}}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(found) != 1 { + t.Fatalf("got %d candidates, want the one with a file", len(found)) + } + if found[0].FileID != 42 || !found[0].Forced || found[0].Downloads != 120 { + t.Fatalf("candidate decoded as %+v", found[0]) + } +} + +func TestDownloadFollowsTheLinkWithoutTheCredentials(t *testing.T) { + var files *httptest.Server + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/login": + _ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"}) + case "/download": + if r.Header.Get("Authorization") != "Bearer jwt" { + t.Errorf("download authorization = %q", r.Header.Get("Authorization")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "link": files.URL + "/f.srt", "file_name": "f.srt", "remaining": 19, + }) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer api.Close() + files = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The CDN is not the API: neither the key nor the token belongs on this request. + if r.Header.Get("Api-Key") != "" || r.Header.Get("Authorization") != "" { + t.Error("credentials were sent to the download host") + } + _, _ = w.Write([]byte("1\n00:00:01,000 --> 00:00:02,000\nhello\n")) + })) + defer files.Close() + + client := New("key", "Memby/test", "someone", "secret", 5*time.Second) + client.SetBaseURL(api.URL) + name, content, err := client.Download(context.Background(), 42) + if err != nil { + t.Fatalf("Download: %v", err) + } + if name != "f.srt" || len(content) == 0 { + t.Fatalf("Download returned %q / %d bytes", name, len(content)) + } +} + +// The allowance running out is the one failure a viewer can act on, so it must not be +// flattened into "the provider did not answer". +func TestDownloadReportsAnExhaustedQuota(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotAcceptable) + _, _ = w.Write([]byte(`{"message":"quota exceeded"}`)) + })) + defer api.Close() + + client := New("key", "Memby/test", "", "", 5*time.Second) + client.SetBaseURL(api.URL) + _, _, err := client.Download(context.Background(), 7) + if _, ok := err.(*QuotaError); !ok { + t.Fatalf("Download error = %v, want a QuotaError", err) + } +} + +func TestLoginTokenIsFetchedOnce(t *testing.T) { + logins := 0 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/login" { + logins++ + _ = json.NewEncoder(w).Encode(map[string]string{"token": "jwt"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}}) + })) + defer api.Close() + + client := New("key", "Memby/test", "someone", "secret", 5*time.Second) + client.SetBaseURL(api.URL) + for range 3 { + if _, err := client.Search(context.Background(), Query{IMDBID: "tt1"}); err != nil { + t.Fatalf("Search: %v", err) + } + } + if logins != 1 { + t.Fatalf("logged in %d times, want once", logins) + } +} + +func TestFormatFromNameFallsBackToSubRip(t *testing.T) { + for name, want := range map[string]string{ + "a.srt": "srt", "b.VTT": "vtt", "c.ass": "ass", "d": "srt", "e.zip": "srt", + } { + if got := formatFromName(name); got != want { + t.Errorf("formatFromName(%q) = %q, want %q", name, got, want) + } + } +} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql index 2c62d4e..24dce7e 100644 --- a/server/internal/store/schema.sql +++ b/server/internal/store/schema.sql @@ -403,3 +403,53 @@ CREATE TABLE IF NOT EXISTS user_preference_acks ( CREATE INDEX IF NOT EXISTS user_preference_acks_user_revision_idx ON user_preference_acks (emby_user_id, revision DESC); + +-- Which colour schemes an operator has decided a particular viewer may choose from. +-- +-- Deliberately not part of user_preferences: that document is the viewer's own choices and +-- is written by every television they own, where this is policy about them and is written +-- only by the console. Keeping them apart is what stops a TV pushing itself a theme it was +-- not offered simply by including the id in a settings write. +-- +-- **A person with no rows here may choose anything.** Absence is permissive, because no row +-- exists for anybody until an operator restricts somebody — reading it the other way round +-- would empty every picker in the house the day this ships. It also means "allowed +-- everything" and "never configured" are stored identically, which is correct: they are the +-- same decision. +-- +-- Seasonal themes are never in here. They are not grantable per person; the only switch is +-- the seasonal_themes feature flag, and it is the operator's, for the whole household. +CREATE TABLE IF NOT EXISTS user_themes ( + emby_user_id TEXT NOT NULL, + theme_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (emby_user_id, theme_id) +); + +-- Subtitles the gateway fetched itself, which is the one place Memby holds a subtitle. +-- +-- Bazarr does not need this: it writes the file beside the media file, so Emby finds it +-- and the track arrives down the ordinary PlaybackInfo path. OpenSubtitles has no such +-- reach — the gateway has no access to the media directory — so a file fetched from it is +-- kept here and served back as a sidecar. That is the whole difference between the two +-- providers, and it is why this table exists at all. +-- +-- It is deliberately durable rather than a cache. A subtitle somebody fetched mid-film is +-- one they will want again on the next episode of the same evening and on a rewatch a year +-- later; spending a provider's daily download quota twice for the same file would be the +-- feature working against the household. Rows are small — a subtitle is tens of kilobytes +-- — and are deleted with nothing else, because nothing else knows the file exists. +CREATE TABLE IF NOT EXISTS downloaded_subtitles ( + id TEXT PRIMARY KEY, + item_id TEXT NOT NULL, + language TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + forced BOOLEAN NOT NULL DEFAULT false, + hearing_impaired BOOLEAN NOT NULL DEFAULT false, + format TEXT NOT NULL DEFAULT 'srt', + provider TEXT NOT NULL DEFAULT '', + content BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS downloaded_subtitles_item_idx ON downloaded_subtitles (item_id); diff --git a/server/internal/store/searches.go b/server/internal/store/searches.go new file mode 100644 index 0000000..c13111c --- /dev/null +++ b/server/internal/store/searches.go @@ -0,0 +1,190 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Search history is the record of what a household looks for, and it has two readers with +// quite different appetites: a television asking for one viewer's last few queries, and +// the console asking what the house as a whole has been searching. Both read the one +// table, which is why the writer's rules live here beside them. + +// SearchDedupeWindow is how long an identical query counts as the same search. +// +// Two things write this table for one search — the gateway's own /v1/search handler and +// the client's POST to /v1/search/history — and a television that repeats a query while +// somebody re-reads the results is not a second search either. The window is short enough +// that a query typed again a minute later is its own row, which is what makes the table a +// record of what a household looks for rather than of how its remote behaves. +const SearchDedupeWindow = 30 * time.Second + +// SearchRetention is how far back the table goes. RecordSearch prunes to it on every +// write, so it is also the honest ceiling on any window the console offers: a page +// promising 90 days would draw a flat line for two thirds of it. +const SearchRetention = 30 * 24 * time.Hour + +// RecordSearch stores a normalized query for future per-user ranking analysis. +// +// Case-insensitive within the dedupe window, matching RecentSearches, which collapses +// case-only duplicates when it reads them back. +func (s *Store) RecordSearch(ctx context.Context, userID, query string) error { + _, err := s.pool.Exec(ctx, + `WITH inserted AS ( + INSERT INTO search_history (emby_user_id, query) + SELECT $1, $2 + WHERE NOT EXISTS ( + SELECT 1 FROM search_history + WHERE emby_user_id = $1 + AND lower(query) = lower($2) + AND occurred_at > now() - $3::interval + ) + RETURNING id + ) + DELETE FROM search_history + WHERE emby_user_id = $1 + AND occurred_at < now() - $4::interval`, + userID, query, SearchDedupeWindow.String(), SearchRetention.String()) + return err +} + +// RecentSearches returns a user's distinct queries in most-recently-used order. +// Case-only duplicates collapse to the spelling used most recently. +func (s *Store) RecentSearches( + ctx context.Context, + userID string, + since time.Time, + limit int, +) ([]string, error) { + rows, err := s.pool.Query(ctx, ` + SELECT query + FROM ( + SELECT DISTINCT ON (lower(query)) query, occurred_at + FROM search_history + WHERE emby_user_id = $1 AND occurred_at >= $2 + ORDER BY lower(query), occurred_at DESC + ) AS latest + ORDER BY occurred_at DESC + LIMIT $3`, + userID, since, limit) + if err != nil { + return nil, fmt.Errorf("store: recent searches: %w", err) + } + defer rows.Close() + + queries := make([]string, 0, limit) + for rows.Next() { + var query string + if err := rows.Scan(&query); err != nil { + return nil, fmt.Errorf("store: scan recent search: %w", err) + } + queries = append(queries, query) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: read recent searches: %w", err) + } + return queries, nil +} + +// SearchTerm is one query the household searched for, aggregated across everyone. +type SearchTerm struct { + Query string `json:"query"` + Searches int `json:"searches"` + Viewers int `json:"viewers"` + LastAt time.Time `json:"lastAt"` +} + +// SearchEvent is one search as it happened: the log rather than the summary. +type SearchEvent struct { + Query string `json:"query"` + UserID string `json:"userId"` + Username string `json:"username"` + OccurredAt time.Time `json:"occurredAt"` +} + +// SearchTotals describes a window as a whole. Counted separately from SearchTerms because +// that list is capped — summing a top-twenty would report the top twenty's total as the +// household's, which is wrong by however long the tail is. +type SearchTotals struct { + Searches int `json:"searches"` + Queries int `json:"queries"` + Viewers int `json:"viewers"` +} + +// SearchTerms aggregates the household's queries since a point in time, most-searched +// first. Grouped case-insensitively and labelled with the spelling used most recently, +// the same rule RecentSearches applies, so one query cannot appear as two rows because +// somebody's on-screen keyboard capitalised it. +func (s *Store) SearchTerms(ctx context.Context, since time.Time, limit int) ([]SearchTerm, error) { + rows, err := s.pool.Query(ctx, ` + SELECT (array_agg(query ORDER BY occurred_at DESC))[1] AS query, + count(*) AS searches, + count(DISTINCT emby_user_id) AS viewers, + max(occurred_at) AS last_at + FROM search_history + WHERE occurred_at >= $1 + GROUP BY lower(query) + ORDER BY searches DESC, last_at DESC + LIMIT $2`, since, limit) + if err != nil { + return nil, fmt.Errorf("store: search terms: %w", err) + } + defer rows.Close() + + terms := []SearchTerm{} + for rows.Next() { + var term SearchTerm + if err := rows.Scan(&term.Query, &term.Searches, &term.Viewers, &term.LastAt); err != nil { + return nil, fmt.Errorf("store: scan search term: %w", err) + } + terms = append(terms, term) + } + return terms, rows.Err() +} + +// SearchEvents returns the raw log, newest first. +// +// Deliberately not collapsed: the summary above answers "what does this house look for", +// and this answers "what happened just now" — which is the one an operator needs when +// somebody says search is not finding something, because it shows the query exactly as it +// was typed, by whom, and at what time. Usernames are resolved by the caller from +// KnownUsers: they live in sessions and joining a log to them per row would make the +// query's cost depend on how many televisions the household has ever signed in. +func (s *Store) SearchEvents(ctx context.Context, since time.Time, limit int) ([]SearchEvent, error) { + rows, err := s.pool.Query(ctx, ` + SELECT query, emby_user_id, occurred_at + FROM search_history + WHERE occurred_at >= $1 + ORDER BY occurred_at DESC + LIMIT $2`, since, limit) + if err != nil { + return nil, fmt.Errorf("store: search events: %w", err) + } + defer rows.Close() + + events := []SearchEvent{} + for rows.Next() { + var event SearchEvent + if err := rows.Scan(&event.Query, &event.UserID, &event.OccurredAt); err != nil { + return nil, fmt.Errorf("store: scan search event: %w", err) + } + events = append(events, event) + } + return events, rows.Err() +} + +// SearchTotals counts a window: searches made, distinct queries behind them, and how many +// of the household did the searching. +func (s *Store) SearchTotals(ctx context.Context, since time.Time) (SearchTotals, error) { + var totals SearchTotals + err := s.pool.QueryRow(ctx, ` + SELECT count(*), count(DISTINCT lower(query)), count(DISTINCT emby_user_id) + FROM search_history + WHERE occurred_at >= $1`, since). + Scan(&totals.Searches, &totals.Queries, &totals.Viewers) + if err != nil { + return SearchTotals{}, fmt.Errorf("store: search totals: %w", err) + } + return totals, nil +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 2314f4e..a23546c 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -310,58 +310,6 @@ func (s *Store) Close() { s.pool.Close() } func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } -// RecordSearch stores a normalized query for future per-user ranking analysis. -func (s *Store) RecordSearch(ctx context.Context, userID, query string) error { - _, err := s.pool.Exec(ctx, - `WITH inserted AS ( - INSERT INTO search_history (emby_user_id, query) VALUES ($1, $2) - RETURNING id - ) - DELETE FROM search_history - WHERE emby_user_id = $1 - AND occurred_at < now() - interval '30 days'`, - userID, query) - return err -} - -// RecentSearches returns a user's distinct queries in most-recently-used order. -// Case-only duplicates collapse to the spelling used most recently. -func (s *Store) RecentSearches( - ctx context.Context, - userID string, - since time.Time, - limit int, -) ([]string, error) { - rows, err := s.pool.Query(ctx, ` - SELECT query - FROM ( - SELECT DISTINCT ON (lower(query)) query, occurred_at - FROM search_history - WHERE emby_user_id = $1 AND occurred_at >= $2 - ORDER BY lower(query), occurred_at DESC - ) AS latest - ORDER BY occurred_at DESC - LIMIT $3`, - userID, since, limit) - if err != nil { - return nil, fmt.Errorf("store: recent searches: %w", err) - } - defer rows.Close() - - queries := make([]string, 0, limit) - for rows.Next() { - var query string - if err := rows.Scan(&query); err != nil { - return nil, fmt.Errorf("store: scan recent search: %w", err) - } - queries = append(queries, query) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("store: read recent searches: %w", err) - } - return queries, nil -} - // Migrate applies the schema. It is idempotent, so it runs on every boot. func (s *Store) Migrate(ctx context.Context) error { if _, err := s.pool.Exec(ctx, schema); err != nil { diff --git a/server/internal/store/subtitles.go b/server/internal/store/subtitles.go new file mode 100644 index 0000000..7b8336c --- /dev/null +++ b/server/internal/store/subtitles.go @@ -0,0 +1,235 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// SubtitlePolicyKey is the app_settings row deciding which subtitle providers a household +// may fetch from, and holding the credentials for the one that needs them. +// +// It is an operator setting rather than an environment variable because the two providers +// answer different questions and a household changes its mind about them: Bazarr is a +// service somebody already runs, OpenSubtitles is an account with a daily allowance. Both +// are optional and either can be turned off from the console without a redeployment. +const SubtitlePolicyKey = "subtitle_policy" + +// Subtitle provider identifiers. They travel on the wire — a candidate carries the source +// it came from so the download call knows which backend to hand its token back to — so +// they are values, not display strings, and must not be renamed. +const ( + SubtitleProviderBazarr = "bazarr" + SubtitleProviderOpenSubtitles = "opensubtitles" + // SubtitleProviderMemby is a file the gateway made rather than fetched: a copy of an + // existing track with its timing corrected. It is its own provider so the console can + // tell a repair from a download, and so clearing the fetched files does not have to + // decide what to do with work nobody can re-fetch. + SubtitleProviderMemby = "memby" +) + +// SubtitlePolicy is what the console edits. +// +// The credentials live in this server-owned document and are never included in a client or +// admin status payload — the console is told only whether a key is saved, the same stance +// MDBList's takes. +type SubtitlePolicy struct { + // BazarrEnabled is honoured only where Bazarr is configured at all. A deployment with + // no MEMBY_BAZARR_URL has nothing to turn on. + BazarrEnabled bool `json:"bazarrEnabled"` + + OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"` + // OpenSubtitlesAPIKey is issued per consumer at opensubtitles.com. Searching needs + // only this. + OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"` + // The account is optional and buys a download allowance. Searching works without one; + // downloading against an anonymous key is quickly exhausted, which is a confusing + // failure to meet in front of a television, so the console says so. + OpenSubtitlesUsername string `json:"openSubtitlesUsername"` + OpenSubtitlesPassword string `json:"openSubtitlesPassword"` + + UpdatedAt time.Time `json:"updatedAt"` +} + +// DefaultSubtitlePolicy is what an untouched deployment gets: Bazarr on, because until +// this document existed a configured Bazarr was already in use and an upgrade must not +// quietly take a working feature away, and OpenSubtitles off, because it needs a key +// nobody has entered yet. +func DefaultSubtitlePolicy() SubtitlePolicy { + return SubtitlePolicy{BazarrEnabled: true} +} + +func normalizeSubtitlePolicy(policy SubtitlePolicy) SubtitlePolicy { + policy.OpenSubtitlesAPIKey = strings.TrimSpace(policy.OpenSubtitlesAPIKey) + policy.OpenSubtitlesUsername = strings.TrimSpace(policy.OpenSubtitlesUsername) + // A provider with no key cannot be on, whatever the document says. Storing the + // contradiction would leave the console showing a switch that does nothing. + if policy.OpenSubtitlesAPIKey == "" { + policy.OpenSubtitlesEnabled = false + } + return policy +} + +func (s *Store) SubtitlePolicy(ctx context.Context) (SubtitlePolicy, error) { + var raw []byte + err := s.pool.QueryRow(ctx, + `SELECT value FROM app_settings WHERE key = $1`, SubtitlePolicyKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return DefaultSubtitlePolicy(), nil + } + if err != nil { + return DefaultSubtitlePolicy(), fmt.Errorf("store: read subtitle policy: %w", err) + } + var policy SubtitlePolicy + if err := json.Unmarshal(raw, &policy); err != nil { + return DefaultSubtitlePolicy(), fmt.Errorf("store: decode subtitle policy: %w", err) + } + return normalizeSubtitlePolicy(policy), nil +} + +func (s *Store) SetSubtitlePolicy(ctx context.Context, policy SubtitlePolicy) error { + policy = normalizeSubtitlePolicy(policy) + policy.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(policy) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_at) + VALUES ($1, $2::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + SubtitlePolicyKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write subtitle policy: %w", err) + } + return nil +} + +// ErrSubtitleNotFound means the gateway holds no subtitle under that id. +var ErrSubtitleNotFound = errors.New("store: subtitle not found") + +// DownloadedSubtitle is one subtitle file the gateway fetched and now serves. +// +// Content is the file exactly as the provider sent it. Nothing here parses or re-encodes +// it: media3 reads SubRip and WebVTT directly, and a gateway that rewrote a subtitle would +// be a second thing that can be wrong about somebody's film. +type DownloadedSubtitle struct { + ID string + ItemID string + Language string + Label string + Forced bool + HearingImpaired bool + Format string + Provider string + Content []byte + CreatedAt time.Time +} + +// PutDownloadedSubtitle stores a fetched subtitle, replacing any previous file under the +// same id. The id is the gateway's own and is derived from what was asked for, so fetching +// the same language for the same title twice replaces the file rather than growing a +// second track the viewer has to tell apart. +func (s *Store) PutDownloadedSubtitle(ctx context.Context, subtitle DownloadedSubtitle) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO downloaded_subtitles + (id, item_id, language, label, forced, hearing_impaired, format, provider, content, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now()) + ON CONFLICT (id) DO UPDATE SET + item_id = EXCLUDED.item_id, language = EXCLUDED.language, label = EXCLUDED.label, + forced = EXCLUDED.forced, hearing_impaired = EXCLUDED.hearing_impaired, + format = EXCLUDED.format, provider = EXCLUDED.provider, + content = EXCLUDED.content, created_at = now()`, + subtitle.ID, subtitle.ItemID, subtitle.Language, subtitle.Label, + subtitle.Forced, subtitle.HearingImpaired, subtitle.Format, subtitle.Provider, + subtitle.Content) + if err != nil { + return fmt.Errorf("store: write downloaded subtitle: %w", err) + } + return nil +} + +// DownloadedSubtitle returns one stored file, content and all. It is the read the serving +// route makes, so it is a single primary-key lookup. +func (s *Store) DownloadedSubtitle(ctx context.Context, id string) (DownloadedSubtitle, error) { + var subtitle DownloadedSubtitle + err := s.pool.QueryRow(ctx, ` + SELECT id, item_id, language, label, forced, hearing_impaired, format, provider, + content, created_at + FROM downloaded_subtitles WHERE id = $1`, id).Scan( + &subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label, + &subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format, &subtitle.Provider, + &subtitle.Content, &subtitle.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return DownloadedSubtitle{}, ErrSubtitleNotFound + } + if err != nil { + return DownloadedSubtitle{}, fmt.Errorf("store: read downloaded subtitle: %w", err) + } + return subtitle, nil +} + +// DownloadedSubtitlesFor lists what the gateway holds for one item, without the file +// bodies. It runs on the playback path — every launch of a title asks — so it must never +// read the content column. +func (s *Store) DownloadedSubtitlesFor(ctx context.Context, itemID string) ([]DownloadedSubtitle, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, item_id, language, label, forced, hearing_impaired, format, provider, created_at + FROM downloaded_subtitles WHERE item_id = $1 ORDER BY created_at`, itemID) + if err != nil { + return nil, fmt.Errorf("store: list downloaded subtitles: %w", err) + } + defer rows.Close() + var out []DownloadedSubtitle + for rows.Next() { + var subtitle DownloadedSubtitle + if err := rows.Scan( + &subtitle.ID, &subtitle.ItemID, &subtitle.Language, &subtitle.Label, + &subtitle.Forced, &subtitle.HearingImpaired, &subtitle.Format, + &subtitle.Provider, &subtitle.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("store: scan downloaded subtitle: %w", err) + } + out = append(out, subtitle) + } + return out, rows.Err() +} + +// DownloadedSubtitleStats is what the console reports: how much has been fetched, and +// when the last one was. Both are one aggregate query, because the page polls. +type DownloadedSubtitleStats struct { + Count int `json:"count"` + Bytes int64 `json:"bytes"` + Latest time.Time `json:"latest,omitempty"` +} + +func (s *Store) DownloadedSubtitleStats(ctx context.Context) (DownloadedSubtitleStats, error) { + var stats DownloadedSubtitleStats + var latest *time.Time + err := s.pool.QueryRow(ctx, ` + SELECT count(*), coalesce(sum(length(content)), 0), max(created_at) + FROM downloaded_subtitles`).Scan(&stats.Count, &stats.Bytes, &latest) + if err != nil { + return DownloadedSubtitleStats{}, fmt.Errorf("store: downloaded subtitle stats: %w", err) + } + if latest != nil { + stats.Latest = *latest + } + return stats, nil +} + +// ClearDownloadedSubtitles empties the store. It is the console's one destructive control +// here, 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. +func (s *Store) ClearDownloadedSubtitles(ctx context.Context) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM downloaded_subtitles`) + if err != nil { + return 0, fmt.Errorf("store: clear downloaded subtitles: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/server/internal/store/themes.go b/server/internal/store/themes.go new file mode 100644 index 0000000..c4dcd4f --- /dev/null +++ b/server/internal/store/themes.go @@ -0,0 +1,83 @@ +package store + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// Which colour schemes an operator has decided a viewer may choose from. +// +// The store deliberately knows nothing about what a theme *is*: the catalogue, the palettes +// and the rule about seasons all live in internal/api next to the client contract, the same +// division user_preferences draws. What is here is only the set of ids, and the one property +// that has to be true at this level — that an empty result means "unrestricted", never "no +// themes at all". See the table comment in schema.sql. + +// UserThemes is the ids this viewer may pick between, or an empty slice for anybody the +// operator has never restricted — which is everybody, until they do. +func (s *Store) UserThemes(ctx context.Context, userID string) ([]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT theme_id FROM user_themes WHERE emby_user_id = $1 ORDER BY theme_id`, userID) + if err != nil { + return nil, fmt.Errorf("store: read user themes: %w", err) + } + defer rows.Close() + themes := []string{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: scan user theme: %w", err) + } + themes = append(themes, id) + } + return themes, rows.Err() +} + +// AllUserThemes is the admin console's read: one query for the whole accounts page rather +// than one per person, since that page already fans out over every account it lists. +func (s *Store) AllUserThemes(ctx context.Context) (map[string][]string, error) { + rows, err := s.pool.Query(ctx, + `SELECT emby_user_id, theme_id FROM user_themes ORDER BY emby_user_id, theme_id`) + if err != nil { + return nil, fmt.Errorf("store: list user themes: %w", err) + } + defer rows.Close() + all := map[string][]string{} + for rows.Next() { + var userID, themeID string + if err := rows.Scan(&userID, &themeID); err != nil { + return nil, fmt.Errorf("store: scan user themes: %w", err) + } + all[userID] = append(all[userID], themeID) + } + return all, rows.Err() +} + +// SetUserThemes replaces this viewer's allowlist wholesale. +// +// Replace rather than merge because the console sends the whole set of ticked boxes, and a +// merge would make unticking one impossible. It is one transaction so a viewer is never +// momentarily allowed nothing — a television resolving its theme in that window would be +// told the default and would repaint itself for no reason. +// +// An empty list deletes the rows rather than storing anything, which is what keeps +// "unrestricted" a single representation. api.normalizeThemeAllowlist is what turns "every +// box ticked" into that empty list before it arrives here. +func (s *Store) SetUserThemes(ctx context.Context, userID string, themes []string) error { + return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, + `DELETE FROM user_themes WHERE emby_user_id = $1`, userID); err != nil { + return fmt.Errorf("store: clear user themes: %w", err) + } + for _, id := range themes { + if _, err := tx.Exec(ctx, ` + INSERT INTO user_themes (emby_user_id, theme_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, userID, id); err != nil { + return fmt.Errorf("store: write user theme: %w", err) + } + } + return nil + }) +} diff --git a/server/internal/subsync/signal.go b/server/internal/subsync/signal.go new file mode 100644 index 0000000..022558c --- /dev/null +++ b/server/internal/subsync/signal.go @@ -0,0 +1,74 @@ +package subsync + +import "math/bits" + +// signal is "somebody is speaking" as one bit per time bin. +// +// A bitset rather than a []bool because the search is the whole cost of this feature: a +// two-hour film at 50ms is around 144,000 bins, and every one of ~2,400 shifts has to +// compare all of them against every stretch candidate. Packed into words, one comparison +// is an AND and a population count over 2,250 words instead of 144,000 byte reads, which +// is the difference between a button that answers while somebody is still looking at the +// menu and one they wait on. math/bits.OnesCount64 compiles to a single instruction on +// every architecture this runs on. +type signal struct { + words []uint64 + bins int + on int +} + +func newSignal(bins int) *signal { + if bins < 1 { + bins = 1 + } + return &signal{words: make([]uint64, (bins+63)/64), bins: bins} +} + +// set marks the half-open bin range [from, to) as speech. +func (s *signal) set(from, to int) { + if from < 0 { + from = 0 + } + if to > s.bins { + to = s.bins + } + for i := from; i < to; i++ { + word, bit := i/64, uint(i%64) + if s.words[word]&(1<= len(other.words) { + break + } + theirs := other.words[j] >> bitShift + // Go defines a shift of 64 or more as zero, so this term vanishes when bitShift + // is zero rather than needing a branch of its own. + if j+1 < len(other.words) { + theirs |= other.words[j+1] << (64 - bitShift) + } + total += bits.OnesCount64(mine & theirs) + } + return total +} diff --git a/server/internal/subsync/srt.go b/server/internal/subsync/srt.go new file mode 100644 index 0000000..758e274 --- /dev/null +++ b/server/internal/subsync/srt.go @@ -0,0 +1,138 @@ +package subsync + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +// ErrNoCues means nothing in the file looked like a subtitle. +var ErrNoCues = errors.New("subsync: no subtitle cues found") + +// Parse reads SRT or WebVTT. +// +// One parser for both because the only difference that matters here is a comma or a full +// stop between the seconds and the milliseconds, and Emby will hand back either depending +// on the route asked and the codec underneath. Everything a format carries that this +// package does not need — cue identifiers, WebVTT positioning, styling blocks, the byte +// order mark a Windows editor leaves behind — is skipped rather than rejected, because a +// subtitle somebody is trying to fix is by definition one that is already not perfect. +func Parse(data []byte) ([]Cue, error) { + text := strings.ReplaceAll(string(data), "\r\n", "\n") + text = strings.TrimPrefix(text, "\ufeff") + + var cues []Cue + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); i++ { + start, end, ok := parseTimingLine(lines[i]) + if !ok { + continue + } + body := []string{} + for i++; i < len(lines) && strings.TrimSpace(lines[i]) != ""; i++ { + // A timing line with no blank line before it ends the previous cue: some + // files in the wild are written that way, and reading the next cue's timing + // as this one's text would put the whole file one cue out. + if _, _, isTiming := parseTimingLine(lines[i]); isTiming { + i-- + break + } + body = append(body, lines[i]) + } + cues = append(cues, Cue{Start: start, End: end, Text: strings.Join(body, "\n")}) + } + if len(cues) == 0 { + return nil, ErrNoCues + } + return Normalise(cues), nil +} + +func parseTimingLine(line string) (time.Duration, time.Duration, bool) { + before, after, ok := strings.Cut(line, "-->") + if !ok { + return 0, 0, false + } + start, ok := parseTimestamp(before) + if !ok { + return 0, 0, false + } + // WebVTT puts cue settings after the end timestamp ("align:start position:50%"), so + // only the first field of what follows is a time. + end, ok := parseTimestamp(strings.Fields(after)[0]) + if !ok { + return 0, 0, false + } + return start, end, true +} + +// parseTimestamp reads HH:MM:SS,mmm and every variation of it that turns up: a full stop +// instead of the comma, the hours omitted as WebVTT allows, and fewer than three digits +// after the separator. +func parseTimestamp(field string) (time.Duration, bool) { + text := strings.TrimSpace(field) + if text == "" { + return 0, false + } + seconds, fraction, _ := strings.Cut(strings.ReplaceAll(text, ",", "."), ".") + + parts := strings.Split(seconds, ":") + if len(parts) < 2 || len(parts) > 3 { + return 0, false + } + var total time.Duration + units := []time.Duration{time.Hour, time.Minute, time.Second} + units = units[len(units)-len(parts):] + for i, part := range parts { + value, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || value < 0 { + return 0, false + } + total += time.Duration(value) * units[i] + } + + if fraction != "" { + digits := fraction + if len(digits) > 3 { + digits = digits[:3] + } + value, err := strconv.Atoi(digits) + if err != nil { + return 0, false + } + for len(digits) < 3 { + value *= 10 + digits += "0" + } + total += time.Duration(value) * time.Millisecond + } + return total, true +} + +// FormatSRT writes cues back out as SubRip. +// +// SRT rather than the format that came in, because it is the one every player reads and +// the one the stored-subtitle table already declares. Renumbered from one: the indices in +// a file being repaired are frequently wrong already, and they carry no meaning worth +// preserving. +func FormatSRT(cues []Cue) []byte { + var out strings.Builder + for i, cue := range cues { + fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n", + i+1, formatTimestamp(cue.Start), formatTimestamp(cue.End), cue.Text) + } + return []byte(out.String()) +} + +func formatTimestamp(d time.Duration) string { + if d < 0 { + d = 0 + } + milliseconds := d.Milliseconds() + return fmt.Sprintf("%02d:%02d:%02d,%03d", + milliseconds/3_600_000, + milliseconds/60_000%60, + milliseconds/1000%60, + milliseconds%1000) +} diff --git a/server/internal/subsync/subsync_test.go b/server/internal/subsync/subsync_test.go new file mode 100644 index 0000000..ac7c69c --- /dev/null +++ b/server/internal/subsync/subsync_test.go @@ -0,0 +1,314 @@ +package subsync + +import ( + "errors" + "math/rand" + "strings" + "testing" + "time" +) + +// A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where +// nothing is said. Regular cues would make every shift correlate with every other and +// prove nothing about the search. +func dialogue(count int, seed int64) []Cue { + rng := rand.New(rand.NewSource(seed)) + cues := make([]Cue, 0, count) + at := 12 * time.Second + for range count { + length := time.Duration(900+rng.Intn(2600)) * time.Millisecond + cues = append(cues, Cue{Start: at, End: at + length, Text: "line"}) + gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond + if rng.Intn(11) == 0 { + gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue + } + at += length + gap + } + return cues +} + +func TestAlignRecoversAKnownOffset(t *testing.T) { + reference := dialogue(400, 7) + for _, offset := range []time.Duration{ + -42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond, + 2 * time.Second, 11500 * time.Millisecond, 37 * time.Second, + } { + t.Run(offset.String(), func(t *testing.T) { + // The broken track is the reference pushed the wrong way, so the correction + // that fixes it is the opposite of what was applied. + broken := Shift(reference, -offset, 1) + + got, err := Align(broken, reference, DefaultOptions()) + if err != nil { + t.Fatalf("Align: %v", err) + } + if got.Scale != 1 { + t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale) + } + if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond { + t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff) + } + }) + } +} + +// The case a plain offset cannot fix: a track authored against one transfer and played +// against another runs further out as the film goes on. +func TestAlignRecoversAFrameRateStretch(t *testing.T) { + reference := dialogue(500, 11) + scale := 25.0 / 24 + broken := Shift(reference, 0, 1/scale) + + got, err := Align(broken, reference, DefaultOptions()) + if err != nil { + t.Fatalf("Align: %v", err) + } + if got.Scale != scale { + t.Fatalf("scale = %v, want %v", got.Scale, scale) + } + + // The real test is not the reported numbers but whether applying them lands the last + // cue where it belongs — a stretch that is right at the start and wrong at the end is + // exactly the fault being repaired. + fixed := Shift(broken, got.Offset, got.Scale) + drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs() + if drift > 200*time.Millisecond { + t.Fatalf("last cue is out by %v after correction", drift) + } +} + +// Timing that survives a translation. Different languages break lines differently and +// their cues do not start on the same frame, so the alignment has to hold when the two +// tracks agree only roughly. +func TestAlignSurvivesATranslatedReference(t *testing.T) { + reference := dialogue(400, 3) + rng := rand.New(rand.NewSource(99)) + translated := make([]Cue, 0, len(reference)) + for i, cue := range reference { + if i%9 == 0 { + continue // a line the other track merged into its neighbour + } + jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond + translated = append(translated, Cue{ + Start: cue.Start + jitter, + End: cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond, + Text: "ligne", + }) + } + + offset := -8 * time.Second + got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions()) + if err != nil { + t.Fatalf("Align: %v", err) + } + if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond { + t.Fatalf("offset = %v, want %v", got.Offset, offset) + } +} + +// Most of this package's job is producing no answer. A wrong correction is worse than +// none: the viewer is told it worked and has no way to know the file is now further out. +func TestAlignRefusesRatherThanGuess(t *testing.T) { + reference := dialogue(400, 5) + + t.Run("a different film", func(t *testing.T) { + _, err := Align(dialogue(400, 6), reference, DefaultOptions()) + var refusal *ErrNoAlignment + if !errors.As(err, &refusal) { + t.Fatalf("err = %v, want a refusal", err) + } + if refusal.Reason == "" { + t.Fatal("a refusal must carry something a viewer can read") + } + }) + + t.Run("too few lines to judge", func(t *testing.T) { + _, err := Align(dialogue(6, 1), reference, DefaultOptions()) + var refusal *ErrNoAlignment + if !errors.As(err, &refusal) { + t.Fatalf("err = %v, want a refusal", err) + } + }) + + // Evenly spaced cues fit their own reference at every multiple of the spacing, so a + // dozen shifts score alike and none of them is the answer. This is the case the margin + // test exists for — the score alone is a perfect 1.0 and would sail through. + t.Run("timing that fits equally well in several places", func(t *testing.T) { + metronome := func(offset time.Duration) []Cue { + cues := make([]Cue, 0, 60) + for i := range 60 { + at := offset + time.Duration(i)*4*time.Second + cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"}) + } + return cues + } + + _, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions()) + var refusal *ErrNoAlignment + if !errors.As(err, &refusal) { + t.Fatalf("err = %v, want a refusal", err) + } + if refusal.Score < 0.9 { + t.Fatalf("score = %.2f — this case must be refused on margin, not on score", + refusal.Score) + } + }) +} + +// A sparse track is not by itself a bad one. Taking every seventeenth line of a correct +// subtitle leaves something that still lines up in exactly one place, and refusing it +// would cost the fix for a forced or hearing-impaired track that is merely displaced. +// What disqualifies a sparse track is being the *reference*, which Reference handles. +func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) { + reference := dialogue(400, 5) + sparse := []Cue{} + for i, cue := range reference { + if i%17 == 0 { + sparse = append(sparse, cue) + } + } + + got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions()) + if err != nil { + t.Fatalf("Align: %v", err) + } + if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond { + t.Fatalf("offset = %v, want -6s", got.Offset) + } +} + +// A track already in sync must come back as no correction rather than a small nudge that +// makes the file different for no reason. +func TestAlignLeavesACorrectTrackAlone(t *testing.T) { + reference := dialogue(300, 21) + got, err := Align(reference, reference, DefaultOptions()) + if err != nil { + t.Fatalf("Align: %v", err) + } + if got.Correction() { + t.Fatalf("a matching track reported a correction of %v", got) + } +} + +func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) { + cues := []Cue{ + {Start: time.Second, End: 3 * time.Second, Text: "first"}, + {Start: 10 * time.Second, End: 12 * time.Second, Text: "second"}, + } + got := Shift(cues, -30*time.Second, 1) + if len(got) != 2 { + t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got)) + } + if got[0].Start < 0 || got[0].End < 0 { + t.Fatalf("negative timing survived: %+v", got[0]) + } +} + +// Which track to measure against is the one choice this package cannot check, so the rules +// that make a track unusable as a yardstick are pinned. +func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) { + candidates := [][]Cue{ + dialogue(30, 1), // 0: the broken one + dialogue(900, 2), // 1: forced, so unusable however long + dialogue(400, 3), // 2: the answer + dialogue(5, 4), // 3: too short to judge anything by + } + forced := []bool{false, true, false, false} + + got, ok := Reference(candidates, 0, forced, 20) + if !ok || got != 2 { + t.Fatalf("reference = %d, ok = %v, want index 2", got, ok) + } + + if _, ok := Reference(candidates[:1], 0, nil, 20); ok { + t.Fatal("a title whose only track is the broken one has no reference") + } +} + +func TestParseReadsBothFormats(t *testing.T) { + srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" + + "2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n" + vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" + + "01:02.000 --> 01:04.250\nSecond line\nover two rows\n" + + for name, data := range map[string]string{"srt": srt, "vtt": vtt} { + t.Run(name, func(t *testing.T) { + cues, err := Parse([]byte(data)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(cues) != 2 { + t.Fatalf("cue count = %d, want 2", len(cues)) + } + if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond { + t.Fatalf("first cue = %+v", cues[0]) + } + if cues[1].Start != 62*time.Second { + t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start) + } + if cues[1].Text != "Second line\nover two rows" { + t.Fatalf("second cue text = %q", cues[1].Text) + } + }) + } +} + +func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) { + t.Run("byte order mark and CRLF", func(t *testing.T) { + data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n" + cues, err := Parse([]byte(data)) + if err != nil || len(cues) != 1 || cues[0].Text != "Hi" { + t.Fatalf("cues = %+v, err = %v", cues, err) + } + }) + + t.Run("no blank line between cues", func(t *testing.T) { + data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n" + cues, err := Parse([]byte(data)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(cues) != 2 || cues[1].Start != 3*time.Second { + t.Fatalf("cues = %+v", cues) + } + }) + + t.Run("nothing that looks like a subtitle", func(t *testing.T) { + if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) { + t.Fatalf("err = %v, want ErrNoCues", err) + } + }) +} + +func TestFormatSRTRoundTrips(t *testing.T) { + cues := []Cue{ + {Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"}, + {Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"}, + } + out := FormatSRT(Normalise(cues)) + if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") { + t.Fatalf("timestamps not written as SubRip:\n%s", out) + } + + back, err := Parse(out) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" { + t.Fatalf("round trip changed the cues: %+v", back) + } +} + +// The search runs while somebody is looking at a menu over their film, so its cost is part +// of whether the feature is usable at all. +func BenchmarkAlignFeatureLength(b *testing.B) { + reference := dialogue(1400, 1) + broken := Shift(reference, -9*time.Second, 1) + opts := DefaultOptions() + b.ResetTimer() + for range b.N { + if _, err := Align(broken, reference, opts); err != nil { + b.Fatal(err) + } + } +} diff --git a/server/internal/subsync/subtitles.go b/server/internal/subsync/subtitles.go new file mode 100644 index 0000000..b359d4d --- /dev/null +++ b/server/internal/subsync/subtitles.go @@ -0,0 +1,345 @@ +// Package subsync fixes the timing of a subtitle by aligning it to one that is already +// right. +// +// The idea is borrowed from ffsubsync and alass, which AutoSubSync drives — but not the +// code, which is Python and Rust and needs FFmpeg to read a film's audio. This gateway's +// image is distroless with no ffmpeg and no shell, so the audio path is closed to it. What +// is open is the cheaper half of the same idea: both of those tools can align against a +// *reference subtitle* instead of audio, and a household's copy of a film usually carries +// a track that is already correct. Reduce both tracks to "somebody is speaking / nobody +// is" on a fixed time grid and the offset is the shift where the two agree most. +// +// Nothing here touches the network, Emby or the database, which is what lets the whole +// rule be tested against real subtitle text. +package subsync + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Cue is one subtitle line: when it appears, when it goes, and what it says. +// +// The text is carried through untouched. This package changes *when* a line is shown and +// never what it says — a resynchronised subtitle that had also been reflowed or re-escaped +// would be impossible to tell from a corrupted one. +type Cue struct { + Start time.Duration + End time.Duration + Text string +} + +// Duration is where the last cue ends, which stands in for the runtime the track was +// written against. +func Duration(cues []Cue) time.Duration { + var last time.Duration + for _, cue := range cues { + if cue.End > last { + last = cue.End + } + } + return last +} + +// Shift moves every cue by the correction Align found. +// +// Scale is applied before the offset, in that order, because that is the order the damage +// happened in: a subtitle written for a 25fps transfer and played against a 23.976 one +// runs progressively further out, and whatever fixed offset remains sits on top of the +// stretch. A cue dragged before zero is clamped rather than dropped — it belongs to a line +// somebody is about to hear, and a track that silently lost its opening would look like a +// worse fault than the one being fixed. +func Shift(cues []Cue, offset time.Duration, scale float64) []Cue { + if scale <= 0 { + scale = 1 + } + out := make([]Cue, 0, len(cues)) + for _, cue := range cues { + start := scaleDuration(cue.Start, scale) + offset + end := scaleDuration(cue.End, scale) + offset + if end < 0 { + // The whole cue was pushed off the front. Keep it at zero rather than + // discarding it: losing a line is worse than showing one early. + start, end = 0, 0 + } else if start < 0 { + start = 0 + } + out = append(out, Cue{Start: start, End: end, Text: cue.Text}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start }) + return out +} + +func scaleDuration(d time.Duration, scale float64) time.Duration { + return time.Duration(float64(d) * scale) +} + +// FrameRateScales are the stretch factors tried alongside a plain offset. +// +// They are the ratios between the frame rates films are actually delivered at, because +// the classic broken subtitle is not merely late — it is a track authored against one +// transfer and played against another, so it drifts, and no single offset can fix it. The +// list is deliberately short: every extra candidate is another chance for a wrong answer +// to win by luck, and these cover the transfers that exist. +var FrameRateScales = []float64{ + 1, + 23.976 / 24, 24 / 23.976, + 25.0 / 24, 24.0 / 25, + 25 / 23.976, 23.976 / 25, + 30 / 29.97, 29.97 / 30, +} + +// Options tunes the search. The zero value is not usable; use DefaultOptions. +type Options struct { + // Bin is the width of one cell of the time grid. Fine enough that a correction is + // worth making at all, coarse enough that two tracks written by different people + // still land in the same cells. + Bin time.Duration + // MaxShift bounds the search either way. A subtitle for a different release can be a + // minute out; beyond that the two are far more likely to be different cuts of the + // film, where a confident answer would be a wrong one. + MaxShift time.Duration + // MinCues is how much evidence is needed on each side before an answer is offered. + MinCues int + // MinScore is the share of the shorter track's speech that must line up. + MinScore float64 + // MinMargin is how far the winning shift must beat the best rival outside its own + // peak. This is what separates a real alignment from a track that correlates weakly + // with everything, which is what a wrong reference looks like. + MinMargin float64 + // Scales are the stretch candidates tried. Empty means offset only. + Scales []float64 +} + +// DefaultOptions is the tuning the gateway uses. +func DefaultOptions() Options { + return Options{ + Bin: 50 * time.Millisecond, + MaxShift: 60 * time.Second, + MinCues: 20, + MinScore: 0.45, + MinMargin: 0.10, + Scales: FrameRateScales, + } +} + +// Result describes a correction. +type Result struct { + // Offset is what to add to every cue, after Scale. + Offset time.Duration + // Scale is the stretch applied first. 1 means the timing was merely displaced. + Scale float64 + // Score is the share of the shorter track's speech that lines up once corrected — + // how much the two agree, not how confident we are. + Score float64 + // Margin is how far ahead of the best rival alignment this one finished. A high + // score with no margin is a track that matches everything, which matches nothing. + Margin float64 +} + +// Correction reports whether this result actually changes anything a viewer would see. +func (r Result) Correction() bool { + return r.Offset.Abs() >= 100*time.Millisecond || r.Scale != 1 +} + +// String renders the correction the way the console and the log want it. +func (r Result) String() string { + seconds := r.Offset.Seconds() + out := fmt.Sprintf("%+.2fs", seconds) + if r.Scale != 1 { + out += fmt.Sprintf(" at %.4f×", r.Scale) + } + return out +} + +// ErrNoAlignment is returned when the two tracks cannot be aligned with confidence. +// +// Most of this package is about producing this rather than a number. A subtitle nudged to +// the wrong place is worse than one left alone: the viewer asked for a fix, would be told +// it worked, and would have no way of knowing the file they now have is further out than +// the one they started with. +type ErrNoAlignment struct { + // Reason is safe to show a viewer. + Reason string + // Score and Margin are what the search actually found, for the log. + Score float64 + Margin float64 +} + +func (e *ErrNoAlignment) Error() string { + return fmt.Sprintf("subsync: %s (score %.2f, margin %.2f)", e.Reason, e.Score, e.Margin) +} + +// Align finds the correction that brings broken onto reference. +// +// The reference is assumed correct; nothing here checks that, because nothing could. The +// caller chooses it, and choosing badly is the one failure this package cannot detect — +// which is why the margin test exists, since a wrong reference tends to match everything +// equally rather than matching one shift particularly well. +func Align(broken, reference []Cue, opts Options) (Result, error) { + if opts.Bin <= 0 { + opts = DefaultOptions() + } + if len(broken) < opts.MinCues || len(reference) < opts.MinCues { + return Result{}, &ErrNoAlignment{ + Reason: "there are not enough lines in one of these subtitles to compare them", + } + } + + scales := opts.Scales + if len(scales) == 0 { + scales = []float64{1} + } + + // The reference is rasterised once; the broken track is rasterised per stretch + // candidate, which is a handful of times rather than once per shift. + ref := rasterise(reference, opts.Bin, 1) + maxShiftBins := int(opts.MaxShift / opts.Bin) + + best := Result{Scale: 1} + bestBins := 0 + found := false + // Rivals holds the best score at each shift far enough from the winner to be a + // different answer rather than the same peak's shoulder. + var runnerUp float64 + + for _, scale := range scales { + signal := rasterise(broken, opts.Bin, scale) + if signal.on == 0 { + continue + } + floor := min(signal.on, ref.on) + if floor == 0 { + continue + } + for shift := -maxShiftBins; shift <= maxShiftBins; shift++ { + score := float64(signal.overlap(ref, shift)) / float64(floor) + switch { + case !found || score > best.Score: + // The old winner becomes a rival only if it is a different answer. + if found && farApart(bestBins, shift, best.Scale, scale, opts.Bin) { + runnerUp = max(runnerUp, best.Score) + } + found = true + best = Result{ + Offset: time.Duration(shift) * opts.Bin, + Scale: scale, + Score: score, + } + bestBins = shift + case farApart(bestBins, shift, best.Scale, scale, opts.Bin): + runnerUp = max(runnerUp, score) + } + } + } + + if !found { + return Result{}, &ErrNoAlignment{Reason: "these subtitles have no speech in common"} + } + best.Margin = best.Score - runnerUp + if best.Score < opts.MinScore { + return Result{}, &ErrNoAlignment{ + Reason: "these two subtitles are too different to line up — they may be for " + + "different cuts of this title", + Score: best.Score, Margin: best.Margin, + } + } + if best.Margin < opts.MinMargin { + return Result{}, &ErrNoAlignment{ + Reason: "no single timing fits better than the others, so the result would be " + + "a guess", + Score: best.Score, Margin: best.Margin, + } + } + return best, nil +} + +// farApart says whether two candidates are different answers rather than two samples of +// one peak. Alignment produces a broad hill either side of the true shift, so the shifts +// immediately around the winner are not rivals — treating them as rivals would collapse +// every margin to nearly zero and refuse every correct answer. +func farApart(bestBins, shift int, bestScale, scale float64, bin time.Duration) bool { + if bestScale != scale { + return true + } + guard := int(time.Second / bin) + return abs(shift-bestBins) > guard +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +// Reference picks which of the tracks on offer to align against. +// +// The rules are about what makes a *usable* yardstick, not what makes a good subtitle. A +// forced track carries only the lines that are foreign to the film's own audio, so it is +// mostly silence and would correlate with almost any shift; a track with very few cues is +// the same problem in a different shape. Longest wins because coverage is what the +// correlation is measured against. It returns the index so the caller can name the track +// it used, which is the one thing a viewer needs to judge the answer. +func Reference(candidates [][]Cue, exclude int, forced []bool, minCues int) (int, bool) { + best, bestCount := -1, 0 + for i, cues := range candidates { + if i == exclude || len(cues) < minCues { + continue + } + if i < len(forced) && forced[i] { + continue + } + if len(cues) > bestCount { + best, bestCount = i, len(cues) + } + } + return best, best >= 0 +} + +// rasterise turns cues into one bit per time bin: is anybody speaking in this cell. +// +// The text is thrown away deliberately. Two subtitles for one film are in different +// languages as often as not, so the only thing they can be compared on is *when* lines +// happen — which turns out to be plenty, because dialogue rhythm is a property of the +// film rather than of the translation. +func rasterise(cues []Cue, bin time.Duration, scale float64) *signal { + end := time.Duration(0) + for _, cue := range cues { + if e := scaleDuration(cue.End, scale); e > end { + end = e + } + } + sig := newSignal(int(end/bin) + 2) + for _, cue := range cues { + start := scaleDuration(cue.Start, scale) + stop := scaleDuration(cue.End, scale) + if stop <= start { + // A zero-length or reversed cue still says a line happened here. + stop = start + bin + } + sig.set(int(start/bin), int(stop/bin)) + } + return sig +} + +// Normalise tidies a parsed track before it is compared or written back. +// +// Cues out of order, or overlapping, are ordinary in files people have edited by hand, and +// both would put speech in the wrong cell. +func Normalise(cues []Cue) []Cue { + out := make([]Cue, 0, len(cues)) + for _, cue := range cues { + if strings.TrimSpace(cue.Text) == "" { + continue + } + if cue.End < cue.Start { + cue.Start, cue.End = cue.End, cue.Start + } + out = append(out, cue) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start }) + return out +} diff --git a/server/internal/trickplay/bif.go b/server/internal/trickplay/bif.go index b6ce185..f913185 100644 --- a/server/internal/trickplay/bif.go +++ b/server/internal/trickplay/bif.go @@ -9,9 +9,11 @@ // on a television. Read the first few kilobytes and every frame's byte range is known, so // showing one thumbnail costs a ranged request of about seven kilobytes rather than a // five-megabyte download nobody would wait for mid-seek. Emby answers ranged requests on -// this route correctly, which it does not advertise: the response carries -// "Accept-Ranges: none" and a Content-Length borrowed from the media file. Trust the 206, -// not the headers. +// this route correctly — measured against 4.10.0.21, which returns a 206 with +// "Accept-Ranges: bytes" and a Content-Range naming the BIF's own length. Earlier builds +// were reported to answer the range while advertising "Accept-Ranges: none", so callers +// still trust the 206 rather than the headers and cap the read at what was asked for: +// being wrong about that must not turn a press of Right into a multi-megabyte download. package trickplay import (