0.2.66 - End Credits improvements / Gateway: 0.1.47 - End credits redesign
This commit is contained in:
+6
-1
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
-12
File diff suppressed because one or more lines are too long
+12
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
0.1.45
|
||||
0.1.47
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user