0.2.66 - End Credits improvements / Gateway: 0.1.47 - End credits redesign
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-7
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user