0.2.66 - End Credits improvements / Gateway: 0.1.47 - End credits redesign

This commit is contained in:
ponzischeme89
2026-08-15 22:26:17 +12:00
parent e528d04b43
commit 4bc4b075ec
28 changed files with 643 additions and 211 deletions
@@ -331,9 +331,6 @@ 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,
/**
* Whether surround formats are bitstreamed to the receiver by what the hardware probe
* reported, or by the viewer's own per-codec switches.
@@ -498,7 +495,6 @@ 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.
@@ -548,7 +544,9 @@ 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")
// `speed_up_credits` was here. Nothing reads or writes it now that the closing-
// credits pane is not a viewer preference; an install predating the removal still
// carries the value on disk, where it is inert.
val AUDIO_PASSTHROUGH_MODE = stringPreferencesKey("audio_passthrough_mode")
val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs")
val RING_COLOR = stringPreferencesKey("ring_color")
@@ -730,13 +728,6 @@ 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
@@ -817,7 +808,6 @@ 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")
@@ -842,7 +832,6 @@ 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"),
@@ -1269,7 +1258,6 @@ 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)
@@ -1419,7 +1407,6 @@ 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)
}
@@ -1463,7 +1450,6 @@ 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,
)
}
@@ -1501,7 +1487,6 @@ 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,
audioPassthroughMode = AudioPassthroughMode
.from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value,
audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(),
@@ -58,8 +58,6 @@ data class UserPreferences(
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
/** Shrink the closing credits to one side at double speed with what is on next beside. */
val speedUpCredits: Boolean = true,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
@@ -93,7 +91,6 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
speedUpCredits = speedUpCredits,
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
@@ -145,7 +142,6 @@ 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),
@@ -170,7 +166,6 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
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)) } }
@@ -0,0 +1,21 @@
package com.ponzischeme89.memby.ui.player
/**
* How long until the next episode takes over, as the credits countdown prints it.
*
* Pure and unit-tested, apart from the player, for the same reason the speed arithmetic
* beside it is: this is the one number on the credits pane that changes, it is drawn four
* times a second, and the ways it can be wrong — a stray "0:60", a negative value at the
* moment of handover — are ones nobody would catch by looking at a television.
*
* A clock value rather than a count of seconds. The pane can open with more than a minute
* still to run, and "in 78" is not a time anybody reads; the seconds are padded so the figure
* does not change width as it counts down, which on a 30sp bold number is a visible twitch.
*/
fun creditsCountdownLabel(remainingMs: Long): String {
// Rounded up, so the figure reaches "0:01" and is replaced by the next episode rather
// than resting on "0:00" for the last tick. Clamped at zero because the playhead can
// legitimately pass the end between the tick and the read.
val seconds = if (remainingMs <= 0L) 0L else (remainingMs + 999L) / 1_000L
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -99,20 +99,6 @@ fun creditsCeilingAfterStall(ceiling: Float): Float {
/** 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
@@ -344,7 +344,12 @@ class PlayerActivity : ComponentActivity() {
private var endCreditsAvailable = false
private var creditsView: View? = null
private var creditsCountdown: TextView? = null
private var creditsSpeedChip: TextView? = null
private var creditsCountdownGroup: View? = null
// What is *ending*, opposite the panel describing what is next. Exactly one of the two is
// ever shown: an Emby logo is commonly black on transparent, so a set that drew the image
// whenever there was one would print an invisible heading on this near-black screen.
private var creditsLogo: ImageView? = null
private var creditsLogoText: TextView? = null
private var creditsStartMs: Long? = null
private var creditsActive = false
/**
@@ -363,6 +368,8 @@ class PlayerActivity : ComponentActivity() {
*/
private var creditsSpeedCeiling = CREDITS_TARGET_SPEED
private var creditsEnteredAtMs = 0L
/** When the speed was last actually changed, so the refill it causes is not read as a stall. */
private var creditsSpeedChangedAtMs = 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.
@@ -2779,13 +2786,11 @@ class PlayerActivity : ComponentActivity() {
// 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 in 1L..NEXT_UP_LEAD_MS) {
creditsCountdown?.text = creditsCountdownLabel(remainingMs)
creditsCountdownGroup?.visibility = View.VISIBLE
} else {
creditsCountdownGroup?.visibility = View.GONE
}
if (remainingMs == 0L) playNext(next)
return
@@ -2995,7 +3000,9 @@ class PlayerActivity : ComponentActivity() {
val view = findViewById<View>(R.id.player_end_credits)
creditsView = view
creditsCountdown = view.findViewById(R.id.player_end_credits_countdown)
creditsSpeedChip = view.findViewById(R.id.player_end_credits_speed)
creditsCountdownGroup = view.findViewById(R.id.player_end_credits_countdown_group)
creditsLogo = view.findViewById(R.id.player_end_credits_logo)
creditsLogoText = view.findViewById(R.id.player_end_credits_logo_text)
view.findViewById<View>(R.id.player_end_credits_play).setOnClickListener {
nextEpisode?.let(::playNext)
}
@@ -3036,13 +3043,14 @@ class PlayerActivity : ComponentActivity() {
* 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.
*
* There is no per-viewer switch for the pane itself. `creditsDismissed` is the only way
* out and it is deliberately per episode: "Watch credits" is a decision about the thing
* on screen now, which is what somebody wanting the roll actually means, where a setting
* buried two screens away was a decision made once and never revisited.
*/
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
@@ -3074,8 +3082,8 @@ class PlayerActivity : ComponentActivity() {
view.findViewById<ImageView>(R.id.player_end_credits_image).load(next.imageUrl) {
crossfade(true)
}
creditsCountdown?.visibility = View.GONE
updateCreditsSpeedChip(CREDITS_NORMAL_SPEED)
creditsCountdownGroup?.visibility = View.GONE
bindCreditsLogo()
view.alpha = 0f
view.visibility = View.VISIBLE
@@ -3092,6 +3100,48 @@ class PlayerActivity : ComponentActivity() {
startCreditsSpeedRamp()
}
/**
* Puts what is ending in the top left, opposite the panel describing what is next.
*
* The same treatment [bindTitleArtwork] gives the transport's title, and for the same
* reason: an Emby logo is commonly black on transparent, so it is tinted against a dark
* background before being shown, and a logo that fails to load falls back to words rather
* than leaving the corner empty. Exactly one of the image and the text is ever visible.
*
* The fallback prefers the *series* name over [playbackTitle], which on an episode is the
* episode's own name — this corner is about the programme, and the episode title is
* already printed opposite for the one coming next.
*/
private fun bindCreditsLogo() {
val logo = creditsLogo ?: return
val fallback = creditsLogoText ?: return
val name = nextEpisode?.seriesName?.takeIf(String::isNotBlank)
?: playbackTitle.takeIf(String::isNotBlank)
fallback.text = name.orEmpty()
val url = logoUrl?.takeIf(String::isNotBlank)
if (url == null) {
logo.clearColorFilter()
logo.visibility = View.GONE
fallback.visibility = if (name.isNullOrBlank()) View.GONE else View.VISIBLE
return
}
logo.load(url) {
crossfade(false)
listener(
onSuccess = { _, result ->
makeLogoVisibleOnDarkBackground(logo, result.drawable)
logo.visibility = View.VISIBLE
fallback.visibility = View.GONE
},
onError = { _, _ ->
logo.visibility = View.GONE
fallback.visibility = if (name.isNullOrBlank()) View.GONE else View.VISIBLE
},
)
}
}
/**
* Puts the picture and the speed back, without recording a decision.
*
@@ -3148,21 +3198,17 @@ class PlayerActivity : ComponentActivity() {
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
// Every speed change makes the pipeline refill, and that refill is a rebuffer. The
// moment it happened is recorded so the stall it causes is not read as evidence that
// the stream cannot sustain the speed that caused it — see [stepDownCreditsSpeed].
creditsSpeedChangedAtMs = SystemClock.elapsedRealtime()
MembyDiagnostics.debug(
"credits_speed", "playback" to playSessionId, "item" to itemId,
"speed" to speed, "ceiling" to creditsSpeedCeiling,
)
}
private fun restoreNormalSpeed() {
creditsSpeedChip?.visibility = View.GONE
val playback = player ?: return
if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) {
return
@@ -3181,7 +3227,25 @@ class PlayerActivity : ComponentActivity() {
*/
private fun stepDownCreditsSpeed() {
if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return
// A speed change empties and refills the pipeline, which media3 reports as buffering
// — so the stall that *follows* raising the speed is caused by the raise rather than
// by the stream being unable to hold it. Counting it was reading the cause as the
// symptom: entering the credits produced one stall immediately, which dropped the
// ceiling to 1.5×, whose own change produced another, which dropped it to 1×. The
// credits then ran at normal speed with nothing on screen saying why.
val sinceChange = SystemClock.elapsedRealtime() - creditsSpeedChangedAtMs
if (sinceChange < CREDITS_STALL_GRACE_MS) {
MembyDiagnostics.debug(
"credits_stall_ignored", "playback" to playSessionId, "item" to itemId,
"since_speed_change_ms" to sinceChange,
)
return
}
creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling)
MembyDiagnostics.debug(
"credits_speed_stepped_down", "playback" to playSessionId, "item" to itemId,
"ceiling" to creditsSpeedCeiling,
)
creditsSpeedJob?.cancel()
creditsSpeedJob = null
if (!creditsSpeedIsActive(creditsSpeedCeiling)) {
@@ -3230,7 +3294,8 @@ class PlayerActivity : ComponentActivity() {
creditsDismissed = false
creditsStartMs = null
creditsSpeedCeiling = CREDITS_TARGET_SPEED
creditsSpeedChip?.visibility = View.GONE
creditsSpeedChangedAtMs = 0L
creditsCountdownGroup?.visibility = View.GONE
creditsView?.apply {
animate().cancel()
visibility = View.GONE
@@ -4963,6 +5028,16 @@ class PlayerActivity : ComponentActivity() {
/** Floats compared by threshold, so an unchanged speed is never re-sent. */
private const val CREDITS_SPEED_EPSILON = 0.001f
/**
* How long after a speed change a stall is attributed to the change rather than to
* the stream.
*
* Comfortably longer than a pipeline refill and comfortably shorter than the roll, so
* a stream that genuinely cannot hold the speed still gives itself away — it stalls
* again, and the second one counts.
*/
private const val CREDITS_STALL_GRACE_MS = 2_500L
/**
* 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
@@ -237,7 +237,6 @@ 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 audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO,
val audioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
val detectedAudioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
@@ -298,7 +297,6 @@ internal data class SettingsPanelActions(
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {},
val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> },
val onRingColorChanged: (String) -> Unit = {},
@@ -360,7 +358,6 @@ fun SettingsSheet(
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 audioPassthroughMode by remember {
mutableStateOf(settings.audioPassthroughPreference.mode)
}
@@ -455,7 +452,6 @@ fun SettingsSheet(
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
settings.speedUpCredits,
settings.audioPassthroughMode,
settings.audioPassthroughCodecs,
settings.welcomeQuoteStyle,
@@ -466,7 +462,6 @@ fun SettingsSheet(
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
speedUpCredits = settings.speedUpCredits
audioPassthroughMode = settings.audioPassthroughPreference.mode
audioPassthroughCodecs = settings.audioPassthroughPreference.codecs
ringColor = settings.ringColorHex
@@ -495,7 +490,6 @@ fun SettingsSheet(
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
speedUpCredits = speedUpCredits,
audioPassthroughMode = audioPassthroughMode,
audioPassthroughCodecs = audioPassthroughCodecs,
detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough,
@@ -561,11 +555,6 @@ fun SettingsSheet(
skipIntroMode = it
persistSetting { store.setSkipIntroMode(it) }
},
onSpeedUpCreditsChanged = {
onAnalyticsEvent("speed_up_credits", "toggle")
speedUpCredits = it
persistSetting { store.setSpeedUpCredits(it) }
},
onAudioPassthroughModeChanged = { mode ->
onAnalyticsEvent("audio_passthrough", "change")
audioPassthroughMode = mode
@@ -982,14 +971,6 @@ 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.",