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
+6 -1
View File
@@ -19,7 +19,7 @@ MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net
#
# Unset, it falls back to MEMBY_EMBY_URL and behaves exactly as before. Confirm the port your
# Emby listens on over plain HTTP on the LAN before uncommenting.
#MEMBY_EMBY_MEDIA_URL=http://10.0.0.2:8096
MEMBY_EMBY_MEDIA_URL=http://10.0.0.2:8096
# Postgres password for the memby role. Generate one, e.g.
# openssl rand -base64 24
@@ -140,6 +140,11 @@ MEMBY_TRACEARR_FULL_INTERVAL=24h
# It is the only thing in the gateway that reads media bytes, which is why it is off by
# default. With ffmpeg absent it still runs, writing markers from where viewers actually
# stopped — which on a well-watched show is the better signal anyway.
#
# Where those bytes are read FROM is MEMBY_EMBY_MEDIA_URL, near the top of this file beside
# the other Emby addresses. It is the setting that decides whether a scan crosses the LAN to
# the HTPC or goes out to the DDNS name and back through the reverse proxy, so it is worth
# setting before turning this on.
MEMBY_CREDITS_ENABLED=true
# Leave blank to find ffmpeg on the path, which is where the image puts it.
MEMBY_CREDITS_FFMPEG=
+7
View File
@@ -1,3 +1,10 @@
## 0.2.66 - 2026-08-15
- Improved: Design of skip end credits display.
## 0.2.65 - 2026-08-15
- Added: Server now detects end credits more accurately.
- Fixed: Security improvements to API endpoints.
## 0.2.64 — 2026-08-14
- Bug fixes & general improvements.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,7 +13,7 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='16' fill='%2352b54b'/%3E%3Ctext x='16' y='23' font-family='system-ui,sans-serif' font-size='19' font-weight='800' text-anchor='middle' fill='%2306240a'%3EM%3C/text%3E%3C/svg%3E"
/>
<script type="module" crossorigin src="/admin/assets/index-Ax7UuMTz.js"></script>
<script type="module" crossorigin src="/admin/assets/index-B-5op1DD.js"></script>
<link rel="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
</head>
+2
View File
@@ -355,6 +355,8 @@ export interface ScheduledTask {
description: string;
group: string;
intervalSeconds: number;
/** The cadence declared in code. Differs from intervalSeconds only when overridden. */
defaultIntervalSeconds: number;
enabled: boolean;
running: boolean;
nextRun?: string;
+107
View File
@@ -28,6 +28,48 @@ import type { Tone } from '../lib/format';
const IDLE_POLL_MS = 20_000;
const BUSY_POLL_MS = 3_000;
/* The cadences an operator may choose from.
*
* A fixed list rather than a free-text duration, because the useful range here spans three
* orders of magnitude and the two ways to get it wrong are both silent: a number typed in
* the wrong unit, and a cadence so tight the job never finishes before it is due again. The
* floor matches the scheduler's own it clamps anything under a minute so the console
* cannot offer a value the server would quietly change underneath it.
*
* Zero is absent on purpose. The API reads it as "restore the declared cadence" rather than
* as "never", so a "run by hand only" entry here would appear to do nothing on any task that
* declares an interval. That is a server-side limitation and it belongs in the server, not
* in a control that lies about it. */
const CADENCE_CHOICES: { value: number; label: string }[] = [
{ value: 60, label: 'Every minute' },
{ value: 300, label: 'Every 5 minutes' },
{ value: 600, label: 'Every 10 minutes' },
{ value: 900, label: 'Every 15 minutes' },
{ value: 1_800, label: 'Every 30 minutes' },
{ value: 3_600, label: 'Hourly' },
{ value: 10_800, label: 'Every 3 hours' },
{ value: 21_600, label: 'Every 6 hours' },
{ value: 43_200, label: 'Every 12 hours' },
{ value: 86_400, label: 'Daily' },
{ value: 604_800, label: 'Weekly' },
];
/* The choices for one task: the presets, plus its own declared cadence and whatever it is
* currently set to if either falls outside the list.
*
* Adding them rather than snapping to the nearest preset is what stops the control being
* destructive to look at a task declaring 45 minutes must not silently become hourly
* because somebody opened the page and the select had to show *something*. */
function cadenceChoices(task: ScheduledTask): { value: number; label: string }[] {
const choices = [...CADENCE_CHOICES];
for (const seconds of [task.defaultIntervalSeconds, task.intervalSeconds]) {
if (seconds > 0 && !choices.some((choice) => choice.value === seconds)) {
choices.push({ value: seconds, label: interval(seconds).replace(/^every /, 'Every ') });
}
}
return choices.sort((a, b) => a.value - b.value);
}
function statusTone(status: TaskRun['status']): Tone {
if (status === 'failed') return 'bad';
if (status === 'running') return 'info';
@@ -65,7 +107,32 @@ export function TasksPage() {
await reload();
});
// Named setCadence rather than setInterval so it cannot shadow the global of that
// name inside this component, which is a trap for anything added here later.
const setCadence = (task: ScheduledTask, intervalSeconds: number) =>
run(`${task.id}:interval`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds }),
`${task.name} now runs ${interval(intervalSeconds)}.`,
);
await reload();
});
// Sending zero is how the API is told to forget an override, so this is a separate call
// from the select rather than an option inside it — see CADENCE_CHOICES.
const resetCadence = (task: ScheduledTask) =>
run(`${task.id}:interval`, async () => {
await wrap(
() => api.put(`/admin/api/tasks/${encodeURIComponent(task.id)}`, { intervalSeconds: 0 }),
`${task.name} back to its default cadence.`,
);
await reload();
});
const failures = tasks.filter((task) => task.lastRun?.status === 'failed').length;
const retimed = tasks.filter(
(task) => task.defaultIntervalSeconds > 0 && task.intervalSeconds !== task.defaultIntervalSeconds,
).length;
const disabled = tasks.filter((task) => !task.enabled).length;
const groups = data?.groups ?? [];
@@ -105,6 +172,15 @@ export function TasksPage() {
icon: 'power',
tone: disabled > 0 ? 'warn' : undefined,
},
// Worth a tile of its own: a retimed task is the most likely explanation for
// "why has this not run", and it is invisible on a page that only prints the
// cadence currently in force.
{
label: 'Retimed',
value: num(retimed),
icon: 'clock',
tone: retimed > 0 ? 'note' : undefined,
},
]}
/>
@@ -133,6 +209,10 @@ export function TasksPage() {
{task.name}{' '}
{task.running ? <Tag tone="info">running</Tag> : null}
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
{task.defaultIntervalSeconds > 0 &&
task.intervalSeconds !== task.defaultIntervalSeconds ? (
<Tag tone="note">retimed</Tag>
) : null}
</b>
<p>{task.description}</p>
<p className="quiet">
@@ -161,6 +241,33 @@ export function TasksPage() {
) : (
<Tag>never run</Tag>
)}
{/* Disabled while a run is in flight: changing the cadence
reschedules from now, and doing that underneath a running job
is how one run silently becomes two. */}
<select
aria-label={`How often ${task.name} runs`}
value={task.intervalSeconds}
disabled={busy === `${task.id}:interval` || task.running}
onChange={(event) => void setCadence(task, Number(event.target.value))}
>
{cadenceChoices(task).map((choice) => (
<option key={choice.value} value={choice.value}>
{choice.label}
{choice.value === task.defaultIntervalSeconds ? ' (default)' : ''}
</option>
))}
</select>
{task.defaultIntervalSeconds > 0 &&
task.intervalSeconds !== task.defaultIntervalSeconds ? (
<Button
size="sm"
icon="refresh"
busy={busy === `${task.id}:interval`}
onClick={() => void resetCadence(task)}
>
Default
</Button>
) : null}
<Toggle
label=""
checked={task.enabled}
+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
creditsCountdown?.text = creditsCountdownLabel(remainingMs)
creditsCountdownGroup?.visibility = View.VISIBLE
} else {
visibility = View.GONE
}
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,14 +108,6 @@ 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)
+13
View File
@@ -27,6 +27,11 @@ services:
# What the TVs are told to stream from. Only set this when it differs from the
# address above (video goes device -> Emby directly, never through the gateway).
MEMBY_EMBY_PUBLIC_URL: "${MEMBY_EMBY_PUBLIC_URL:-}"
# Where the gateway reads media *bytes* from, which only credits detection does.
# Blank falls back to MEMBY_EMBY_URL; set it to the LAN address when Emby is on a
# different host, or every ranged read takes the public route and a buffering proxy
# turns it into a whole-file read.
MEMBY_EMBY_MEDIA_URL: "${MEMBY_EMBY_MEDIA_URL:-}"
MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable"
MEMBY_REDIS_URL: "redis://redis:6379/0"
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
@@ -81,6 +86,14 @@ services:
MEMBY_FOR_YOU_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-24h}"
MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-24h}"
MEMBY_FOR_YOU_REBUILD_HOUR: "${MEMBY_FOR_YOU_REBUILD_HOUR:-4}"
# Credits detection, which reads the demand the Tracearr settings above import: it
# scans a few episodes ahead of each viewer rather than the library. Off unless the
# .env says otherwise, because it is the only thing here that opens a media file.
MEMBY_CREDITS_ENABLED: "${MEMBY_CREDITS_ENABLED:-false}"
MEMBY_CREDITS_FFMPEG: "${MEMBY_CREDITS_FFMPEG:-}"
MEMBY_CREDITS_PREFETCH_EPISODES: "${MEMBY_CREDITS_PREFETCH_EPISODES:-3}"
MEMBY_CREDITS_MAX_PREFETCH: "${MEMBY_CREDITS_MAX_PREFETCH:-5}"
MEMBY_CREDITS_QUEUE_LIMIT: "${MEMBY_CREDITS_QUEUE_LIMIT:-20}"
mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}"
volumes:
- memby-releases:/data/releases
+2
View File
@@ -35,3 +35,5 @@ android.builtInKotlin=false
# `android { }` report itself deprecated in the build files; that warning cannot be
# resolved from this side, and clears when the Kotlin plugin adopts the new DSL.
android.newDsl=false
android.sync.suppressAgpWarnings=UNSUPPORTED_PROJECT_OPTION_USE,DEPRECATED_DSL
+11 -16
View File
@@ -270,23 +270,18 @@ func TestMaskMarkersWithholdsOnlyTheDisabledHalf(t *testing.T) {
}
}
// The toggle a television is told to obey has to be one this build knows.
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
definition, ok := preferenceDefinitionFor("speedUpCredits")
if !ok {
t.Fatal("speedUpCredits is missing from the preference catalogue")
}
if definition.Kind != preferenceToggle {
t.Fatalf("kind = %v, want a toggle", definition.Kind)
}
if definition.Default != true {
t.Fatalf("default = %v, want true — the feature is that it happens unasked, and it "+
"is visible and reversible in a way an automatic seek is not", definition.Default)
// The credits pane is not a viewer preference any more, and this pins the removal from both
// ends: the key is gone from the catalogue, and a document still carrying it — written by a
// television or an operator before the removal — has it dropped rather than round-tripped.
// Without the second half a stored `false` would survive every sync and quietly keep the
// feature off on the one set that had turned it off.
func TestSpeedUpCreditsIsNoLongerAPreference(t *testing.T) {
if _, ok := preferenceDefinitionFor("speedUpCredits"); ok {
t.Fatal("speedUpCredits is still in the preference catalogue")
}
// An illegal value must come back as the default rather than reaching a player.
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
if normalised["speedUpCredits"] != true {
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
normalised := normalizePreferences(map[string]any{"speedUpCredits": false})
if _, present := normalised["speedUpCredits"]; present {
t.Fatal("a stored speedUpCredits must be dropped, not carried forward")
}
}
+9 -11
View File
@@ -163,17 +163,15 @@ var preferenceCatalogue = []preferenceDefinition{
option(skipIntroOff, "Do nothing"),
},
},
{
// Default on: the whole feature is that it happens without being asked for, and it
// is visible, reversible and over in a minute — a viewer who dislikes it turns it
// off having seen exactly what it does. That is a different trade from
// skipIntroMode's, which defaults to the button rather than the automatic seek
// because a jump nobody can see coming is not recoverable by watching it.
Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
Description: "When an episode reaches its closing credits, shrink them to one side " +
"at double speed and show what is on next beside them.",
Kind: preferenceToggle, Default: true,
},
// `speedUpCredits` was here. It is deliberately not a viewer preference any more: the
// closing-credits pane is how this client ends an episode, and an opt-out made it a
// feature half the household never saw. The operator's `end_credits` flag remains the
// one switch, which is the right level for it — it governs a subsystem that reads media
// bytes, and turning it off is a decision about the server rather than about taste.
//
// Removing the key from the catalogue is also how the stored values are cleaned up:
// normalizePreferences drops what it does not recognise, so a viewer who had turned it
// off gets the pane back on their next sync with nothing having to migrate anything.
{
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
Description: "Turn a subtitle track on automatically when the title has one.",
+1 -1
View File
@@ -1 +1 @@
0.1.45
0.1.47
+61 -1
View File
@@ -38,6 +38,27 @@ const (
// primary guard against answering on noise, and it is set high because the cost of a
// wrong marker is somebody losing the end of an episode.
minSeparation = 0.18
// onsetFraction is how credit-like a frame has to be, relative to the established roll,
// to count as already part of it.
//
// The split itself lands where the credits are *established*, because the sustain window
// after it has to clear creditLikeFloor on average — and credits usually fade in, so the
// first second or two of the fade scores below that floor and pushes the split forward.
// Measured against real media the gap is two to three seconds, which is visible: the roll
// has plainly begun before the picture moves.
//
// So the split is walked backwards through the fade to the first frame that is already
// mostly credit-like. Two fifths is deliberately generous — this is looking for the start
// of a ramp, not for more credits — and it is a fraction of the established level rather
// than an absolute, because a roll over a bright background never reaches the same score
// as one over black and would otherwise never be walked back at all.
onsetFraction = 0.4
// onsetBackoffMs bounds that walk. A fade is a second or two; anything walking further is
// no longer following one, and the bound is what stops a gradual dimming at the end of a
// scene dragging the marker back into the programme.
onsetBackoffMs = 4000
)
// VisualDetector implements Detector over the ffmpeg sampler.
@@ -195,7 +216,46 @@ func findTransition(frames []frameStats, interval time.Duration) (int, float64,
}
bestIndex, bestSeparation, found = split, separation, true
}
return bestIndex, bestSeparation, found
if !found {
return 0, 0, false
}
// The separation is reported for the split that earned it, never for the walked-back
// frame: confidence is a statement about how clearly the transition was found, and
// recomputing it over a fade would report the answer as weaker for having been improved.
return backOffToOnset(scores, bestIndex, segmentMean(bestIndex, min(bestIndex+sustain, len(scores))), interval),
bestSeparation, true
}
// backOffToOnset walks a split backwards through the credits' fade-in.
//
// Pure, bounded, and it can only ever move the marker earlier — which is the direction that
// needs the care, so both bounds matter: it stops at the first frame that is not already
// mostly credit-like, and it never travels further than onsetBackoffMs. On a hard cut from
// programme to credits the preceding frame scores near zero and the walk stops immediately,
// which is correct: there is no fade to find and the split was already right.
func backOffToOnset(scores []float64, split int, established float64, interval time.Duration) int {
if split <= 0 || established <= 0 || interval <= 0 {
return split
}
limit := int(float64(onsetBackoffMs) / float64(interval.Milliseconds()))
if limit < 1 {
// A sampling interval coarser than the whole allowance cannot resolve a fade at all.
// The coarse pass is this case, and it is the one whose answer the fine pass replaces.
return split
}
floor := onsetFraction * established
earliest := split - limit
if earliest < 0 {
earliest = 0
}
onset := split
for index := split - 1; index >= earliest; index-- {
if scores[index] < floor {
break
}
onset = index
}
return onset
}
// visualConfidence maps separation onto a score.
+84
View File
@@ -160,3 +160,87 @@ func TestVisualConfidenceIsCapped(t *testing.T) {
t.Fatalf("a detection at the separation threshold scored %.2f, below the bar", score)
}
}
// The onset walk-back, tested on scores directly.
//
// It is pure and it is the only thing in the detector that can move a marker *earlier*, so
// its two bounds matter more than the movement itself: a hard cut must not move at all, and
// a long ramp must not drag the marker back into the programme.
// establishedCredits is the score a fully-established roll produces, near enough — the
// literal matters only as the thing onsetFraction is measured against.
const establishedCredits = 0.90
func TestBackOffWalksIntoTheFade(t *testing.T) {
// Forty frames of programme, three of fade, then the roll. The split arrives at the first
// fully-established frame; the fade before it is already the credits.
scores := make([]float64, 0, 60)
for range 40 {
scores = append(scores, 0.02)
}
scores = append(scores, 0.40, 0.55, 0.75)
for range 17 {
scores = append(scores, establishedCredits)
}
onset := backOffToOnset(scores, 43, establishedCredits, fineInterval)
if onset != 40 {
t.Fatalf("onset at frame %d, want 40 (the first frame of the fade)", onset)
}
}
// A cut straight from programme to credits has no fade to find, and the split was already
// right. Moving it would be inventing a ramp that is not there.
func TestBackOffStopsAtAHardCut(t *testing.T) {
scores := make([]float64, 0, 60)
for range 40 {
scores = append(scores, 0.02)
}
for range 20 {
scores = append(scores, establishedCredits)
}
if onset := backOffToOnset(scores, 40, establishedCredits, fineInterval); onset != 40 {
t.Fatalf("a hard cut moved from 40 to %d", onset)
}
}
// The bound is what stops a scene dimming gradually towards the credits pulling the marker
// back through the last minute of the programme.
func TestBackOffIsBounded(t *testing.T) {
// Everything qualifies, so only the bound can stop the walk.
scores := make([]float64, 80)
for index := range scores {
scores[index] = establishedCredits
}
onset := backOffToOnset(scores, 60, establishedCredits, fineInterval)
limit := int(float64(onsetBackoffMs) / float64(fineInterval.Milliseconds()))
if onset != 60-limit {
t.Fatalf("walked back to %d, want %d (%d frames)", onset, 60-limit, limit)
}
if travelled := (60 - onset) * int(fineInterval.Milliseconds()); travelled > onsetBackoffMs {
t.Fatalf("walked back %dms, past the %dms bound", travelled, onsetBackoffMs)
}
}
// A frame that is dark but not yet mostly credit-like is still the programme.
func TestBackOffRefusesAFrameBelowTheOnsetFraction(t *testing.T) {
scores := []float64{0.02, 0.02, 0.10, establishedCredits, establishedCredits}
// 0.10 is well under onsetFraction of the established level.
if onset := backOffToOnset(scores, 3, establishedCredits, fineInterval); onset != 3 {
t.Fatalf("a below-threshold frame was walked into: onset %d, want 3", onset)
}
}
// Guarding the degenerate inputs rather than trusting callers, since this runs on whatever
// the sampler produced.
func TestBackOffHandlesDegenerateInput(t *testing.T) {
scores := []float64{establishedCredits, establishedCredits}
if onset := backOffToOnset(scores, 0, establishedCredits, fineInterval); onset != 0 {
t.Fatalf("a split at zero moved to %d", onset)
}
if onset := backOffToOnset(scores, 1, 0, fineInterval); onset != 1 {
t.Fatalf("an established level of zero moved the split to %d", onset)
}
}
+7
View File
@@ -56,6 +56,12 @@ type Status struct {
Description string `json:"description"`
Group string `json:"group"`
Interval int64 `json:"intervalSeconds"`
// DefaultInterval is the cadence declared in code, which Interval hides whenever an
// operator has overridden it. Both are sent because the console cannot otherwise tell
// "every ten minutes because that is the default" from "every ten minutes because
// somebody chose it" — and without that distinction its cadence control has no way to
// offer a way back, or to say that a task is no longer running as shipped.
DefaultInterval int64 `json:"defaultIntervalSeconds"`
Enabled bool `json:"enabled"`
Running bool `json:"running"`
NextRun *time.Time `json:"nextRun,omitempty"`
@@ -459,6 +465,7 @@ func (s *Scheduler) Snapshot() []Status {
ID: entry.task.ID, Name: entry.task.Name,
Description: entry.task.Description, Group: entry.task.Group,
Interval: int64(entry.effectiveInterval() / time.Second),
DefaultInterval: int64(entry.task.Interval / time.Second),
Enabled: entry.enabled, Running: entry.running,
}
if !entry.nextRun.IsZero() && entry.enabled {
@@ -219,3 +219,44 @@ func waitForRun(t *testing.T, sched *Scheduler, id string) Status {
}
var _ sync.Locker = (*sync.Mutex)(nil)
// The console needs both cadences to draw its control honestly: the one in force and the
// one the code declares. Reporting only the effective interval made "every ten minutes
// because that is the default" and "every ten minutes because somebody chose it" identical
// on the wire, so nothing could offer a way back to the default or mark a task as no longer
// running as shipped.
func TestSnapshotReportsTheDeclaredCadenceBesideTheEffectiveOne(t *testing.T) {
sched := quietScheduler()
sched.Register(Task{ID: "credits", Name: "Credits", Interval: 10 * time.Minute, Run: noop})
before := sched.Snapshot()[0]
if before.Interval != 600 || before.DefaultInterval != 600 {
t.Fatalf("unoverridden task: interval %d, default %d, want 600 and 600",
before.Interval, before.DefaultInterval)
}
if err := sched.SetInterval(context.Background(), "credits", time.Hour); err != nil {
t.Fatalf("SetInterval: %v", err)
}
after := sched.Snapshot()[0]
if after.Interval != 3600 {
t.Fatalf("effective interval %d, want 3600", after.Interval)
}
// The declared cadence must survive the override, or the way back is lost.
if after.DefaultInterval != 600 {
t.Fatalf("declared cadence %d, want 600 — an override must not overwrite it",
after.DefaultInterval)
}
}
// A task that declares no cadence at all runs only when somebody presses the button, and
// the console has to be able to say so rather than printing "every 0 seconds".
func TestATaskWithNoDeclaredCadenceReportsZeroForBoth(t *testing.T) {
sched := quietScheduler()
sched.Register(Task{ID: "manual", Name: "Manual", Run: noop})
status := sched.Snapshot()[0]
if status.Interval != 0 || status.DefaultInterval != 0 {
t.Fatalf("interval %d, default %d, want 0 and 0", status.Interval, status.DefaultInterval)
}
}