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
+1 -1
View File
@@ -46,7 +46,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.65"
val defaultVersionName = "0.2.66"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -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.",
+91 -36
View File
@@ -11,6 +11,17 @@
So the left half of this layout is empty on purpose: it is a hole for the picture that
is already there to show through.
Three things sit on it, and the arrangement is the design: what is *ending* in the top
left, how long until it hands over in the top right, and what is *next* in the panel
down the right-hand side. The two corners are deliberately opposite each other — they
are the two facts a viewer wants at a glance, and putting either inside the panel buries
it in prose the eye has already finished reading.
Nothing here says how fast the picture is running. A chip stating "2× speed" was the
only thing on screen that ever mentioned it, and it explained a mechanism rather than
answering a question: the countdown opposite already says the thing a viewer actually
wants to know, and it stays true whatever speed the stream turned out to sustain.
There is no scrim and no card behind the picture. It sits on somebody's film, and the
only lit surface is the button under focus. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
@@ -21,33 +32,92 @@
android:focusable="false"
android:visibility="gone">
<!-- Why the picture is running fast. Without it a viewer who looks up mid-roll has a
sped-up image and no stated reason for it, which reads as the stream misbehaving.
It sits under the picture's left half rather than in the panel, because it is a
label on the video and not part of what is on next.
<!-- What is playing, top left, above the shrunken picture.
The logo rather than the title because it is the show's own treatment and the one
thing on this screen that is unmistakably *this* programme — the panel opposite is
entirely about the next episode, so without this the screen says what is coming and
never what is ending. The start margin is the shrunken picture's own left edge,
which CREDITS_VIDEO_SCALE and CREDITS_VIDEO_SHIFT_X put at ~34dp on a 16:9 set, so
it reads as belonging to the picture beneath it.
The text fallback under it is not optional decoration: transparent Emby logos are
commonly black, and a black title treatment on this near-black screen is an
invisible heading — the same trap `ui/TitleLogo.kt` exists for. Exactly one of the
two is ever shown. -->
<ImageView
android:id="@+id/player_end_credits_logo"
android:layout_width="wrap_content"
android:layout_height="46dp"
android:layout_gravity="top|start"
android:layout_marginStart="34dp"
android:layout_marginTop="42dp"
android:adjustViewBounds="true"
android:contentDescription="@null"
android:maxWidth="260dp"
android:scaleType="fitStart"
android:visibility="gone"
tools:ignore="ContentDescription"
tools:visibility="visible" />
The start margin is aligned with the shrunken picture's own left edge, which
CREDITS_VIDEO_SCALE and CREDITS_VIDEO_SHIFT_X put at ~34dp on a 16:9 set, so it reads
as a caption on the credits rather than as something floating in the black beside
them. The bottom margin is the seek chip's, for the same reason: it is the height
this app already puts a chip at. -->
<TextView
android:id="@+id/player_end_credits_speed"
android:id="@+id/player_end_credits_logo_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_gravity="top|start"
android:layout_marginStart="34dp"
android:layout_marginBottom="104dp"
android:background="@drawable/time_remaining_cue_background"
android:letterSpacing="0.06"
android:paddingStart="14dp"
android:paddingTop="7dp"
android:paddingEnd="14dp"
android:paddingBottom="7dp"
android:textColor="#FF69CD61"
android:textSize="14sp"
android:layout_marginTop="42dp"
android:ellipsize="end"
android:maxLines="1"
android:maxWidth="260dp"
android:textColor="#E6FFFFFF"
android:textSize="21sp"
android:textStyle="bold"
tools:text="2×" />
android:visibility="gone"
tools:text="Love Story"
tools:visibility="visible" />
<!-- The countdown, top right, opposite the logo.
It is the one number on the screen that changes, and it answers the only question a
viewer has while the credits run: how long until the next episode takes over. Out
here rather than in the panel because the panel is a block of description that the
eye reads once, and a figure counting down inside it drags attention back to prose
somebody has already finished with.
Gone rather than blank until the last minute — the pane can open several minutes
before the file ends, and a countdown reading "4:12" would be the most prominent
thing on screen for most of its life. -->
<LinearLayout
android:id="@+id/player_end_credits_countdown_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_marginTop="42dp"
android:layout_marginEnd="72dp"
android:gravity="end"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/end_credits_next_in_label"
android:textColor="#FF69CD61"
android:textSize="11sp" />
<TextView
android:id="@+id/player_end_credits_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold"
tools:text="0:28" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
@@ -111,21 +181,6 @@
android:textSize="13sp"
tools:text="S02E05 · Northbound" />
<!-- Gone rather than blank until the last minute. The pane opens minutes before
the file ends, and a countdown that sat there reading "starting in 4:12"
would be the most prominent thing on it for most of its life. -->
<TextView
android:id="@+id/player_end_credits_countdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:textColor="#FF69CD61"
android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="Starting in 28s"
tools:visibility="visible" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
+4 -1
View File
@@ -91,7 +91,10 @@
credits rather than as dismissing a panel: it is the only thing this button does,
and somebody pressing it is asking to watch them. -->
<string name="end_credits_watch">Watch credits</string>
<string name="end_credits_speed">%1$s speed</string>
<!-- The eyebrow over the credits countdown. The figure below it is a clock value rather
than a count of seconds, because the pane can open with minutes still to run and
"in 128" is not a time anybody reads. -->
<string name="end_credits_next_in_label">NEXT EPISODE STARTING IN</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
@@ -108,15 +108,7 @@ class CreditsSpeedTest {
* 1.5 is not exactly 1.5 in binary, so `toString` on it prints "1.5000001", and
* `String.format` would print a comma for the point on a set configured in half of Europe.
*/
@Test
fun `the label reads as a person would write it`() {
assertEquals("2×", creditsSpeedLabel(CREDITS_TARGET_SPEED))
assertEquals("1.5×", creditsSpeedLabel(1.5f))
assertEquals("1×", creditsSpeedLabel(CREDITS_NORMAL_SPEED))
// And off the ramp, where the speed is whatever the curve produced.
assertEquals("1.8×", creditsSpeedLabel(1.7996f))
}
private companion object {
const val TOLERANCE = 0.03f
}
@@ -46,15 +46,16 @@ class EndCreditsScreenshotTest {
name = "end-credits",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
seriesName = "Northbound",
countdown = null,
)
}
/**
* Inside the last minute, where the countdown appears. It lives in the pane rather than
* in the next-up banner, which is suppressed while this is up two of them would fight
* over the same transform and print the episode twice.
* Inside the last minute, where the countdown appears in the top right opposite the
* logo, and the only figure on the pane that moves. It lives here rather than in the
* next-up banner, which is suppressed while this is up: two of them would fight over the
* same transform and print the episode twice.
*/
@Test
fun `the last minute, counting down`() {
@@ -62,23 +63,38 @@ class EndCreditsScreenshotTest {
name = "end-credits-countdown",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = CREDITS_TARGET_SPEED,
countdown = "Starting in 28s",
seriesName = "Northbound",
countdown = "0:28",
)
}
/**
* The stream could not hold 2×, so the ceiling fell. Worth a capture because the chip is
* the only thing on screen that ever says so, and "1.5× speed" has to sit in the same
* plate as "2× speed" without changing the layout around it.
* Both corners at their longest at once: a series name that fills the width the logo is
* allowed, against a countdown still in minutes. This is the capture that would show the
* two colliding, which is the only way that failure appears.
*/
@Test
fun `a stream that could only manage one and a half`() {
fun `a long series name beside a long countdown`() {
capture(
name = "end-credits-reduced-speed",
name = "end-credits-long-series-name",
title = "The Crossing",
meta = "S02E05 · Northbound",
speed = 1.5f,
seriesName = "The Cartographer of the Lower Reaches",
countdown = "1:04",
)
}
/**
* Nothing to put in the top left. A film has no series and an item may carry no logo at
* all, and the corner must then be empty rather than holding a blank plate.
*/
@Test
fun `no logo and no series name`() {
capture(
name = "end-credits-no-logo",
title = "The Crossing",
meta = "S02E05 · Northbound",
seriesName = "",
countdown = null,
)
}
@@ -94,7 +110,7 @@ class EndCreditsScreenshotTest {
name = "end-credits-long-title",
title = "The Cartographer of the Lower Reaches",
meta = "",
speed = CREDITS_TARGET_SPEED,
seriesName = "Northbound",
countdown = null,
)
}
@@ -103,7 +119,7 @@ class EndCreditsScreenshotTest {
name: String,
title: String,
meta: String,
speed: Float,
seriesName: String,
countdown: String?,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
@@ -136,12 +152,18 @@ class EndCreditsScreenshotTest {
text = meta
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
}
pane.findViewById<TextView>(R.id.player_end_credits_speed).text =
activity.getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
pane.findViewById<TextView>(R.id.player_end_credits_countdown).apply {
text = countdown.orEmpty()
visibility = if (countdown == null) View.GONE else View.VISIBLE
// What is ending, top left. The text treatment rather than the image, because a
// capture is about whether the two corners balance and a stand-in logo bitmap would
// only prove that a rectangle fits.
pane.findViewById<TextView>(R.id.player_end_credits_logo_text).apply {
text = seriesName
visibility = if (seriesName.isBlank()) View.GONE else View.VISIBLE
}
// The countdown, top right. Its absence is a case worth capturing on its own: the
// pane spends most of its life without one.
pane.findViewById<TextView>(R.id.player_end_credits_countdown).text = countdown.orEmpty()
pane.findViewById<View>(R.id.player_end_credits_countdown_group).visibility =
if (countdown == null) View.GONE else View.VISIBLE
pane.findViewById<ImageView>(R.id.player_end_credits_image)
.setImageBitmap(nextEpisodeArtwork())
// The remote lands on Play, which is what the pane does on opening — so the capture
@@ -128,10 +128,12 @@ class SettingsSheetScreenshotTest {
val togglesByPage = mapOf(
SettingsPage.APPEARANCE to listOf("Show logos"),
// The closing-credits pane was here. It is no longer a viewer preference — the
// operator's `end_credits` flag is the only switch — so there is nothing on this
// page to turn it off with.
SettingsPage.PLAYBACK to listOf(
"Ten minutes left",
"Play the next episode",
"Closing credits",
),
SettingsPage.HOME to listOf(
"Continue watching",
@@ -188,7 +190,6 @@ class SettingsSheetScreenshotTest {
var showLogo by remember { mutableStateOf(false) }
var autoPlayNext by remember { mutableStateOf(false) }
var tenMinutes by remember { mutableStateOf(false) }
var speedUpCredits by remember { mutableStateOf(false) }
var hideWatched by remember { mutableStateOf(false) }
var cardMetadata by remember { mutableStateOf(false) }
var ratings by remember { mutableStateOf(false) }
@@ -209,7 +210,6 @@ class SettingsSheetScreenshotTest {
showLogo = showLogo,
autoPlayNext = autoPlayNext,
showTenMinuteReminder = tenMinutes,
speedUpCredits = speedUpCredits,
hideWatchedMovies = hideWatched,
showCardMetadata = cardMetadata,
showRatingsStrip = ratings,
@@ -228,10 +228,6 @@ class SettingsSheetScreenshotTest {
tenMinutes = it
record("Ten minutes left", it)
},
onSpeedUpCreditsChanged = {
speedUpCredits = it
record("Closing credits", it)
},
onHideWatchedMoviesChanged = {
hideWatched = it
record("Hide films you have seen", it)