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
|
# 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.
|
# 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.
|
# Postgres password for the memby role. Generate one, e.g.
|
||||||
# openssl rand -base64 24
|
# 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
|
# 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
|
# 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.
|
# 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
|
MEMBY_CREDITS_ENABLED=true
|
||||||
# Leave blank to find ffmpeg on the path, which is where the image puts it.
|
# Leave blank to find ffmpeg on the path, which is where the image puts it.
|
||||||
MEMBY_CREDITS_FFMPEG=
|
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
|
## 0.2.64 — 2026-08-14
|
||||||
- Bug fixes & general improvements.
|
- 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"
|
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"
|
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="modulepreload" crossorigin href="/admin/assets/router-D9WH5XEU.js">
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-DSlxKU2t.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -355,6 +355,8 @@ export interface ScheduledTask {
|
|||||||
description: string;
|
description: string;
|
||||||
group: string;
|
group: string;
|
||||||
intervalSeconds: number;
|
intervalSeconds: number;
|
||||||
|
/** The cadence declared in code. Differs from intervalSeconds only when overridden. */
|
||||||
|
defaultIntervalSeconds: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
nextRun?: string;
|
nextRun?: string;
|
||||||
|
|||||||
@@ -28,6 +28,48 @@ import type { Tone } from '../lib/format';
|
|||||||
const IDLE_POLL_MS = 20_000;
|
const IDLE_POLL_MS = 20_000;
|
||||||
const BUSY_POLL_MS = 3_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 {
|
function statusTone(status: TaskRun['status']): Tone {
|
||||||
if (status === 'failed') return 'bad';
|
if (status === 'failed') return 'bad';
|
||||||
if (status === 'running') return 'info';
|
if (status === 'running') return 'info';
|
||||||
@@ -65,7 +107,32 @@ export function TasksPage() {
|
|||||||
await reload();
|
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 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 disabled = tasks.filter((task) => !task.enabled).length;
|
||||||
|
|
||||||
const groups = data?.groups ?? [];
|
const groups = data?.groups ?? [];
|
||||||
@@ -105,6 +172,15 @@ export function TasksPage() {
|
|||||||
icon: 'power',
|
icon: 'power',
|
||||||
tone: disabled > 0 ? 'warn' : undefined,
|
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.name}{' '}
|
||||||
{task.running ? <Tag tone="info">running</Tag> : null}
|
{task.running ? <Tag tone="info">running</Tag> : null}
|
||||||
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
|
{!task.enabled ? <Tag tone="warn">off</Tag> : null}
|
||||||
|
{task.defaultIntervalSeconds > 0 &&
|
||||||
|
task.intervalSeconds !== task.defaultIntervalSeconds ? (
|
||||||
|
<Tag tone="note">retimed</Tag>
|
||||||
|
) : null}
|
||||||
</b>
|
</b>
|
||||||
<p>{task.description}</p>
|
<p>{task.description}</p>
|
||||||
<p className="quiet">
|
<p className="quiet">
|
||||||
@@ -161,6 +241,33 @@ export function TasksPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Tag>never run</Tag>
|
<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
|
<Toggle
|
||||||
label=""
|
label=""
|
||||||
checked={task.enabled}
|
checked={task.enabled}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// 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.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.65"
|
val defaultVersionName = "0.2.66"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -331,9 +331,6 @@ data class Settings(
|
|||||||
// What to do when an episode reaches its opening titles: offer a button, skip without
|
// 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.
|
// asking, or nothing. Per-profile and synced for the same reason the two above are.
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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
|
* Whether surround formats are bitstreamed to the receiver by what the hardware probe
|
||||||
* reported, or by the viewer's own per-codec switches.
|
* reported, or by the viewer's own per-codec switches.
|
||||||
@@ -498,7 +495,6 @@ data class EmbyProfile(
|
|||||||
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
|
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
|
||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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
|
* 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.
|
* 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 SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
|
||||||
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
|
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
|
||||||
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
|
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_MODE = stringPreferencesKey("audio_passthrough_mode")
|
||||||
val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs")
|
val AUDIO_PASSTHROUGH_CODECS = stringPreferencesKey("audio_passthrough_codecs")
|
||||||
val RING_COLOR = stringPreferencesKey("ring_color")
|
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 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
|
* 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] =
|
store[Keys.SEEK_INTERVAL_SECONDS] =
|
||||||
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
|
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
|
||||||
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
|
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
|
||||||
store[Keys.SPEED_UP_CREDITS] = preferences.speedUpCredits
|
|
||||||
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
|
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
|
||||||
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
|
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
|
||||||
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
|
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
|
||||||
@@ -842,7 +832,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
seekIntervalSeconds =
|
seekIntervalSeconds =
|
||||||
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
|
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
|
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
|
||||||
speedUpCredits = preferences.speedUpCredits,
|
|
||||||
forYouMinutes = preferences.forYouMinutes,
|
forYouMinutes = preferences.forYouMinutes,
|
||||||
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
|
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
|
||||||
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
|
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
|
||||||
@@ -1269,7 +1258,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
seekIntervalSeconds = previous?.seekIntervalSeconds
|
seekIntervalSeconds = previous?.seekIntervalSeconds
|
||||||
?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
|
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
|
||||||
speedUpCredits = previous?.speedUpCredits ?: true,
|
|
||||||
)
|
)
|
||||||
profiles.removeAll { it.id == id }
|
profiles.removeAll { it.id == id }
|
||||||
profiles.add(profile)
|
profiles.add(profile)
|
||||||
@@ -1419,7 +1407,6 @@ class SettingsStore(private val context: Context) {
|
|||||||
preferences[Keys.SEEK_INTERVAL_SECONDS] =
|
preferences[Keys.SEEK_INTERVAL_SECONDS] =
|
||||||
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
|
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
|
||||||
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
|
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
|
||||||
preferences[Keys.SPEED_UP_CREDITS] = profile.speedUpCredits
|
|
||||||
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
|
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
|
||||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
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,
|
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
),
|
),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
||||||
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
|
|
||||||
themeId = preferences[Keys.THEME_ID] ?: Settings.DEFAULT_THEME_ID,
|
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,
|
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
),
|
),
|
||||||
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
|
||||||
speedUpCredits = preferences[Keys.SPEED_UP_CREDITS] ?: true,
|
|
||||||
audioPassthroughMode = AudioPassthroughMode
|
audioPassthroughMode = AudioPassthroughMode
|
||||||
.from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value,
|
.from(preferences[Keys.AUDIO_PASSTHROUGH_MODE]).value,
|
||||||
audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(),
|
audioPassthroughCodecs = preferences[Keys.AUDIO_PASSTHROUGH_CODECS].orEmpty(),
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ data class UserPreferences(
|
|||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
|
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
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 forYouMinutes: Int = 0,
|
||||||
val homeRowOrder: List<String> = emptyList(),
|
val homeRowOrder: List<String> = emptyList(),
|
||||||
val homePinnedRows: List<String> = emptyList(),
|
val homePinnedRows: List<String> = emptyList(),
|
||||||
@@ -93,7 +91,6 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
|
|||||||
subtitleLanguage = subtitleLanguage,
|
subtitleLanguage = subtitleLanguage,
|
||||||
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
|
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
|
||||||
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
|
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
|
||||||
speedUpCredits = speedUpCredits,
|
|
||||||
forYouMinutes = forYouMinutes,
|
forYouMinutes = forYouMinutes,
|
||||||
homeRowOrder = homeRowOrder.decodeLineList(),
|
homeRowOrder = homeRowOrder.decodeLineList(),
|
||||||
homePinnedRows = homePinnedRows.decodeLineList(),
|
homePinnedRows = homePinnedRows.decodeLineList(),
|
||||||
@@ -145,7 +142,6 @@ fun decodeUserPreferences(
|
|||||||
skipIntroMode = normalizeSkipIntroMode(
|
skipIntroMode = normalizeSkipIntroMode(
|
||||||
json.string("skipIntroMode", fallback.skipIntroMode),
|
json.string("skipIntroMode", fallback.skipIntroMode),
|
||||||
),
|
),
|
||||||
speedUpCredits = json.boolean("speedUpCredits", fallback.speedUpCredits),
|
|
||||||
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
|
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
|
||||||
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
|
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
|
||||||
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
|
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
|
||||||
@@ -170,7 +166,6 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
|
|||||||
put("subtitleLanguage", subtitleLanguage)
|
put("subtitleLanguage", subtitleLanguage)
|
||||||
put("seekIntervalSeconds", seekIntervalSeconds)
|
put("seekIntervalSeconds", seekIntervalSeconds)
|
||||||
put("skipIntroMode", skipIntroMode)
|
put("skipIntroMode", skipIntroMode)
|
||||||
put("speedUpCredits", speedUpCredits)
|
|
||||||
put("forYouMinutes", forYouMinutes)
|
put("forYouMinutes", forYouMinutes)
|
||||||
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
|
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
|
||||||
putJsonArray("homePinnedRows") { homePinnedRows.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. */
|
/** Whether a ceiling still has any speeding up left in it. */
|
||||||
fun creditsSpeedIsActive(ceiling: Float): Boolean = ceiling > CREDITS_NORMAL_SPEED + SPEED_EPSILON
|
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
|
* 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
|
* 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 endCreditsAvailable = false
|
||||||
private var creditsView: View? = null
|
private var creditsView: View? = null
|
||||||
private var creditsCountdown: TextView? = 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 creditsStartMs: Long? = null
|
||||||
private var creditsActive = false
|
private var creditsActive = false
|
||||||
/**
|
/**
|
||||||
@@ -363,6 +368,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
private var creditsSpeedCeiling = CREDITS_TARGET_SPEED
|
private var creditsSpeedCeiling = CREDITS_TARGET_SPEED
|
||||||
private var creditsEnteredAtMs = 0L
|
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
|
// 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.
|
// 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
|
// 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.
|
// drawing inside the last minute, where it was always the banner's job.
|
||||||
if (creditsActive) {
|
if (creditsActive) {
|
||||||
creditsCountdown?.apply {
|
if (remainingMs in 1L..NEXT_UP_LEAD_MS) {
|
||||||
if (remainingMs in 1L..NEXT_UP_LEAD_MS) {
|
creditsCountdown?.text = creditsCountdownLabel(remainingMs)
|
||||||
text = getString(R.string.next_up_starting_in, ceil(remainingMs / 1_000.0).toInt())
|
creditsCountdownGroup?.visibility = View.VISIBLE
|
||||||
visibility = View.VISIBLE
|
} else {
|
||||||
} else {
|
creditsCountdownGroup?.visibility = View.GONE
|
||||||
visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (remainingMs == 0L) playNext(next)
|
if (remainingMs == 0L) playNext(next)
|
||||||
return
|
return
|
||||||
@@ -2995,7 +3000,9 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val view = findViewById<View>(R.id.player_end_credits)
|
val view = findViewById<View>(R.id.player_end_credits)
|
||||||
creditsView = view
|
creditsView = view
|
||||||
creditsCountdown = view.findViewById(R.id.player_end_credits_countdown)
|
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 {
|
view.findViewById<View>(R.id.player_end_credits_play).setOnClickListener {
|
||||||
nextEpisode?.let(::playNext)
|
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
|
* 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
|
* 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.
|
* 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) {
|
private fun updateEndCreditsFromPlayhead(playback: Player, next: NextEpisode) {
|
||||||
if (creditsDismissed) return
|
if (creditsDismissed) return
|
||||||
if (!(ServiceLocator.settings.current?.speedUpCredits ?: true)) {
|
|
||||||
if (creditsActive) leaveEndCredits(restoreSpeed = true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val duration = playback.duration
|
val duration = playback.duration
|
||||||
if (!creditsWorthShowing(creditsStartMs, duration)) return
|
if (!creditsWorthShowing(creditsStartMs, duration)) return
|
||||||
val start = creditsStartMs ?: return
|
val start = creditsStartMs ?: return
|
||||||
@@ -3074,8 +3082,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
view.findViewById<ImageView>(R.id.player_end_credits_image).load(next.imageUrl) {
|
view.findViewById<ImageView>(R.id.player_end_credits_image).load(next.imageUrl) {
|
||||||
crossfade(true)
|
crossfade(true)
|
||||||
}
|
}
|
||||||
creditsCountdown?.visibility = View.GONE
|
creditsCountdownGroup?.visibility = View.GONE
|
||||||
updateCreditsSpeedChip(CREDITS_NORMAL_SPEED)
|
bindCreditsLogo()
|
||||||
|
|
||||||
view.alpha = 0f
|
view.alpha = 0f
|
||||||
view.visibility = View.VISIBLE
|
view.visibility = View.VISIBLE
|
||||||
@@ -3092,6 +3100,48 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
startCreditsSpeedRamp()
|
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.
|
* Puts the picture and the speed back, without recording a decision.
|
||||||
*
|
*
|
||||||
@@ -3148,21 +3198,17 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return
|
if (abs(playback.playbackParameters.speed - speed) < CREDITS_SPEED_EPSILON) return
|
||||||
playback.setPlaybackSpeed(speed)
|
playback.setPlaybackSpeed(speed)
|
||||||
updateCreditsSpeedChip(speed)
|
// 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].
|
||||||
private fun updateCreditsSpeedChip(speed: Float) {
|
creditsSpeedChangedAtMs = SystemClock.elapsedRealtime()
|
||||||
val chip = creditsSpeedChip ?: return
|
MembyDiagnostics.debug(
|
||||||
if (!creditsSpeedIsActive(speed)) {
|
"credits_speed", "playback" to playSessionId, "item" to itemId,
|
||||||
chip.visibility = View.GONE
|
"speed" to speed, "ceiling" to creditsSpeedCeiling,
|
||||||
return
|
)
|
||||||
}
|
|
||||||
chip.text = getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
|
|
||||||
chip.visibility = View.VISIBLE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreNormalSpeed() {
|
private fun restoreNormalSpeed() {
|
||||||
creditsSpeedChip?.visibility = View.GONE
|
|
||||||
val playback = player ?: return
|
val playback = player ?: return
|
||||||
if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) {
|
if (abs(playback.playbackParameters.speed - CREDITS_NORMAL_SPEED) < CREDITS_SPEED_EPSILON) {
|
||||||
return
|
return
|
||||||
@@ -3181,7 +3227,25 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
*/
|
*/
|
||||||
private fun stepDownCreditsSpeed() {
|
private fun stepDownCreditsSpeed() {
|
||||||
if (!creditsActive || !creditsSpeedIsActive(creditsSpeedCeiling)) return
|
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)
|
creditsSpeedCeiling = creditsCeilingAfterStall(creditsSpeedCeiling)
|
||||||
|
MembyDiagnostics.debug(
|
||||||
|
"credits_speed_stepped_down", "playback" to playSessionId, "item" to itemId,
|
||||||
|
"ceiling" to creditsSpeedCeiling,
|
||||||
|
)
|
||||||
creditsSpeedJob?.cancel()
|
creditsSpeedJob?.cancel()
|
||||||
creditsSpeedJob = null
|
creditsSpeedJob = null
|
||||||
if (!creditsSpeedIsActive(creditsSpeedCeiling)) {
|
if (!creditsSpeedIsActive(creditsSpeedCeiling)) {
|
||||||
@@ -3230,7 +3294,8 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
creditsDismissed = false
|
creditsDismissed = false
|
||||||
creditsStartMs = null
|
creditsStartMs = null
|
||||||
creditsSpeedCeiling = CREDITS_TARGET_SPEED
|
creditsSpeedCeiling = CREDITS_TARGET_SPEED
|
||||||
creditsSpeedChip?.visibility = View.GONE
|
creditsSpeedChangedAtMs = 0L
|
||||||
|
creditsCountdownGroup?.visibility = View.GONE
|
||||||
creditsView?.apply {
|
creditsView?.apply {
|
||||||
animate().cancel()
|
animate().cancel()
|
||||||
visibility = View.GONE
|
visibility = View.GONE
|
||||||
@@ -4963,6 +5028,16 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
|
|
||||||
/** Floats compared by threshold, so an unchanged speed is never re-sent. */
|
/** Floats compared by threshold, so an unchanged speed is never re-sent. */
|
||||||
private const val CREDITS_SPEED_EPSILON = 0.001f
|
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
|
* 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
|
* 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 showTenMinuteReminder: Boolean = true,
|
||||||
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
|
||||||
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
|
||||||
val speedUpCredits: Boolean = true,
|
|
||||||
val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO,
|
val audioPassthroughMode: AudioPassthroughMode = AudioPassthroughMode.AUTO,
|
||||||
val audioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
|
val audioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
|
||||||
val detectedAudioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
|
val detectedAudioPassthroughCodecs: Set<SurroundCodec> = emptySet(),
|
||||||
@@ -298,7 +297,6 @@ internal data class SettingsPanelActions(
|
|||||||
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
|
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
|
||||||
val onSeekIntervalChanged: (Int) -> Unit = {},
|
val onSeekIntervalChanged: (Int) -> Unit = {},
|
||||||
val onSkipIntroModeChanged: (String) -> Unit = {},
|
val onSkipIntroModeChanged: (String) -> Unit = {},
|
||||||
val onSpeedUpCreditsChanged: (Boolean) -> Unit = {},
|
|
||||||
val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {},
|
val onAudioPassthroughModeChanged: (AudioPassthroughMode) -> Unit = {},
|
||||||
val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> },
|
val onAudioPassthroughCodecChanged: (SurroundCodec, Boolean) -> Unit = { _, _ -> },
|
||||||
val onRingColorChanged: (String) -> Unit = {},
|
val onRingColorChanged: (String) -> Unit = {},
|
||||||
@@ -360,7 +358,6 @@ fun SettingsSheet(
|
|||||||
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
|
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
|
||||||
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
|
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
|
||||||
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
|
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
|
||||||
var speedUpCredits by rememberSaveable { mutableStateOf(settings.speedUpCredits) }
|
|
||||||
var audioPassthroughMode by remember {
|
var audioPassthroughMode by remember {
|
||||||
mutableStateOf(settings.audioPassthroughPreference.mode)
|
mutableStateOf(settings.audioPassthroughPreference.mode)
|
||||||
}
|
}
|
||||||
@@ -455,7 +452,6 @@ fun SettingsSheet(
|
|||||||
settings.showTenMinuteReminder,
|
settings.showTenMinuteReminder,
|
||||||
settings.seekIntervalSeconds,
|
settings.seekIntervalSeconds,
|
||||||
settings.skipIntroMode,
|
settings.skipIntroMode,
|
||||||
settings.speedUpCredits,
|
|
||||||
settings.audioPassthroughMode,
|
settings.audioPassthroughMode,
|
||||||
settings.audioPassthroughCodecs,
|
settings.audioPassthroughCodecs,
|
||||||
settings.welcomeQuoteStyle,
|
settings.welcomeQuoteStyle,
|
||||||
@@ -466,7 +462,6 @@ fun SettingsSheet(
|
|||||||
showTenMinuteReminder = settings.showTenMinuteReminder
|
showTenMinuteReminder = settings.showTenMinuteReminder
|
||||||
seekInterval = settings.seekIntervalSeconds
|
seekInterval = settings.seekIntervalSeconds
|
||||||
skipIntroMode = settings.skipIntroMode
|
skipIntroMode = settings.skipIntroMode
|
||||||
speedUpCredits = settings.speedUpCredits
|
|
||||||
audioPassthroughMode = settings.audioPassthroughPreference.mode
|
audioPassthroughMode = settings.audioPassthroughPreference.mode
|
||||||
audioPassthroughCodecs = settings.audioPassthroughPreference.codecs
|
audioPassthroughCodecs = settings.audioPassthroughPreference.codecs
|
||||||
ringColor = settings.ringColorHex
|
ringColor = settings.ringColorHex
|
||||||
@@ -495,7 +490,6 @@ fun SettingsSheet(
|
|||||||
showTenMinuteReminder = showTenMinuteReminder,
|
showTenMinuteReminder = showTenMinuteReminder,
|
||||||
seekIntervalSeconds = seekInterval,
|
seekIntervalSeconds = seekInterval,
|
||||||
skipIntroMode = skipIntroMode,
|
skipIntroMode = skipIntroMode,
|
||||||
speedUpCredits = speedUpCredits,
|
|
||||||
audioPassthroughMode = audioPassthroughMode,
|
audioPassthroughMode = audioPassthroughMode,
|
||||||
audioPassthroughCodecs = audioPassthroughCodecs,
|
audioPassthroughCodecs = audioPassthroughCodecs,
|
||||||
detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough,
|
detectedAudioPassthroughCodecs = deviceAudioCapabilities.passthrough,
|
||||||
@@ -561,11 +555,6 @@ fun SettingsSheet(
|
|||||||
skipIntroMode = it
|
skipIntroMode = it
|
||||||
persistSetting { store.setSkipIntroMode(it) }
|
persistSetting { store.setSkipIntroMode(it) }
|
||||||
},
|
},
|
||||||
onSpeedUpCreditsChanged = {
|
|
||||||
onAnalyticsEvent("speed_up_credits", "toggle")
|
|
||||||
speedUpCredits = it
|
|
||||||
persistSetting { store.setSpeedUpCredits(it) }
|
|
||||||
},
|
|
||||||
onAudioPassthroughModeChanged = { mode ->
|
onAudioPassthroughModeChanged = { mode ->
|
||||||
onAnalyticsEvent("audio_passthrough", "change")
|
onAnalyticsEvent("audio_passthrough", "change")
|
||||||
audioPassthroughMode = mode
|
audioPassthroughMode = mode
|
||||||
@@ -982,14 +971,6 @@ internal fun SettingsPanelContent(
|
|||||||
onSelected = actions.onSkipIntroModeChanged,
|
onSelected = actions.onSkipIntroModeChanged,
|
||||||
)
|
)
|
||||||
SettingDivider()
|
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(
|
SettingsChoiceRow(
|
||||||
title = "Skip with left and right",
|
title = "Skip with left and right",
|
||||||
description = "How far one press moves what you are watching.",
|
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
|
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.
|
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
|
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. -->
|
only lit surface is the button under focus. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
@@ -21,33 +32,92 @@
|
|||||||
android:focusable="false"
|
android:focusable="false"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<!-- Why the picture is running fast. Without it a viewer who looks up mid-roll has a
|
<!-- What is playing, top left, above the shrunken picture.
|
||||||
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
|
The logo rather than the title because it is the show's own treatment and the one
|
||||||
label on the video and not part of what is on next.
|
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
|
<TextView
|
||||||
android:id="@+id/player_end_credits_speed"
|
android:id="@+id/player_end_credits_logo_text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|start"
|
android:layout_gravity="top|start"
|
||||||
android:layout_marginStart="34dp"
|
android:layout_marginStart="34dp"
|
||||||
android:layout_marginBottom="104dp"
|
android:layout_marginTop="42dp"
|
||||||
android:background="@drawable/time_remaining_cue_background"
|
android:ellipsize="end"
|
||||||
android:letterSpacing="0.06"
|
android:maxLines="1"
|
||||||
android:paddingStart="14dp"
|
android:maxWidth="260dp"
|
||||||
android:paddingTop="7dp"
|
android:textColor="#E6FFFFFF"
|
||||||
android:paddingEnd="14dp"
|
android:textSize="21sp"
|
||||||
android:paddingBottom="7dp"
|
|
||||||
android:textColor="#FF69CD61"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
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
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -111,21 +181,6 @@
|
|||||||
android:textSize="13sp"
|
android:textSize="13sp"
|
||||||
tools:text="S02E05 · Northbound" />
|
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
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="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,
|
credits rather than as dismissing a panel: it is the only thing this button does,
|
||||||
and somebody pressing it is asking to watch them. -->
|
and somebody pressing it is asking to watch them. -->
|
||||||
<string name="end_credits_watch">Watch credits</string>
|
<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="next_up_starting_now">Starting now…</string>
|
||||||
<string name="app_name">Memby</string>
|
<string name="app_name">Memby</string>
|
||||||
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
|
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
|
||||||
|
|||||||
@@ -108,15 +108,7 @@ class CreditsSpeedTest {
|
|||||||
* 1.5 is not exactly 1.5 in binary, so `toString` on it prints "1.5000001", and
|
* 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.
|
* `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 {
|
private companion object {
|
||||||
const val TOLERANCE = 0.03f
|
const val TOLERANCE = 0.03f
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,15 +46,16 @@ class EndCreditsScreenshotTest {
|
|||||||
name = "end-credits",
|
name = "end-credits",
|
||||||
title = "The Crossing",
|
title = "The Crossing",
|
||||||
meta = "S02E05 · Northbound",
|
meta = "S02E05 · Northbound",
|
||||||
speed = CREDITS_TARGET_SPEED,
|
seriesName = "Northbound",
|
||||||
countdown = null,
|
countdown = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inside the last minute, where the countdown appears. It lives in the pane rather than
|
* Inside the last minute, where the countdown appears in the top right — opposite the
|
||||||
* in the next-up banner, which is suppressed while this is up — two of them would fight
|
* logo, and the only figure on the pane that moves. It lives here rather than in the
|
||||||
* over the same transform and print the episode twice.
|
* 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
|
@Test
|
||||||
fun `the last minute, counting down`() {
|
fun `the last minute, counting down`() {
|
||||||
@@ -62,23 +63,38 @@ class EndCreditsScreenshotTest {
|
|||||||
name = "end-credits-countdown",
|
name = "end-credits-countdown",
|
||||||
title = "The Crossing",
|
title = "The Crossing",
|
||||||
meta = "S02E05 · Northbound",
|
meta = "S02E05 · Northbound",
|
||||||
speed = CREDITS_TARGET_SPEED,
|
seriesName = "Northbound",
|
||||||
countdown = "Starting in 28s",
|
countdown = "0:28",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The stream could not hold 2×, so the ceiling fell. Worth a capture because the chip is
|
* Both corners at their longest at once: a series name that fills the width the logo is
|
||||||
* the only thing on screen that ever says so, and "1.5× speed" has to sit in the same
|
* allowed, against a countdown still in minutes. This is the capture that would show the
|
||||||
* plate as "2× speed" without changing the layout around it.
|
* two colliding, which is the only way that failure appears.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `a stream that could only manage one and a half`() {
|
fun `a long series name beside a long countdown`() {
|
||||||
capture(
|
capture(
|
||||||
name = "end-credits-reduced-speed",
|
name = "end-credits-long-series-name",
|
||||||
title = "The Crossing",
|
title = "The Crossing",
|
||||||
meta = "S02E05 · Northbound",
|
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,
|
countdown = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,7 +110,7 @@ class EndCreditsScreenshotTest {
|
|||||||
name = "end-credits-long-title",
|
name = "end-credits-long-title",
|
||||||
title = "The Cartographer of the Lower Reaches",
|
title = "The Cartographer of the Lower Reaches",
|
||||||
meta = "",
|
meta = "",
|
||||||
speed = CREDITS_TARGET_SPEED,
|
seriesName = "Northbound",
|
||||||
countdown = null,
|
countdown = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -103,7 +119,7 @@ class EndCreditsScreenshotTest {
|
|||||||
name: String,
|
name: String,
|
||||||
title: String,
|
title: String,
|
||||||
meta: String,
|
meta: String,
|
||||||
speed: Float,
|
seriesName: String,
|
||||||
countdown: String?,
|
countdown: String?,
|
||||||
) {
|
) {
|
||||||
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
|
||||||
@@ -136,12 +152,18 @@ class EndCreditsScreenshotTest {
|
|||||||
text = meta
|
text = meta
|
||||||
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
|
visibility = if (meta.isBlank()) View.GONE else View.VISIBLE
|
||||||
}
|
}
|
||||||
pane.findViewById<TextView>(R.id.player_end_credits_speed).text =
|
// What is ending, top left. The text treatment rather than the image, because a
|
||||||
activity.getString(R.string.end_credits_speed, creditsSpeedLabel(speed))
|
// capture is about whether the two corners balance and a stand-in logo bitmap would
|
||||||
pane.findViewById<TextView>(R.id.player_end_credits_countdown).apply {
|
// only prove that a rectangle fits.
|
||||||
text = countdown.orEmpty()
|
pane.findViewById<TextView>(R.id.player_end_credits_logo_text).apply {
|
||||||
visibility = if (countdown == null) View.GONE else View.VISIBLE
|
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)
|
pane.findViewById<ImageView>(R.id.player_end_credits_image)
|
||||||
.setImageBitmap(nextEpisodeArtwork())
|
.setImageBitmap(nextEpisodeArtwork())
|
||||||
// The remote lands on Play, which is what the pane does on opening — so the capture
|
// 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(
|
val togglesByPage = mapOf(
|
||||||
SettingsPage.APPEARANCE to listOf("Show logos"),
|
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(
|
SettingsPage.PLAYBACK to listOf(
|
||||||
"Ten minutes left",
|
"Ten minutes left",
|
||||||
"Play the next episode",
|
"Play the next episode",
|
||||||
"Closing credits",
|
|
||||||
),
|
),
|
||||||
SettingsPage.HOME to listOf(
|
SettingsPage.HOME to listOf(
|
||||||
"Continue watching",
|
"Continue watching",
|
||||||
@@ -188,7 +190,6 @@ class SettingsSheetScreenshotTest {
|
|||||||
var showLogo by remember { mutableStateOf(false) }
|
var showLogo by remember { mutableStateOf(false) }
|
||||||
var autoPlayNext by remember { mutableStateOf(false) }
|
var autoPlayNext by remember { mutableStateOf(false) }
|
||||||
var tenMinutes by remember { mutableStateOf(false) }
|
var tenMinutes by remember { mutableStateOf(false) }
|
||||||
var speedUpCredits by remember { mutableStateOf(false) }
|
|
||||||
var hideWatched by remember { mutableStateOf(false) }
|
var hideWatched by remember { mutableStateOf(false) }
|
||||||
var cardMetadata by remember { mutableStateOf(false) }
|
var cardMetadata by remember { mutableStateOf(false) }
|
||||||
var ratings by remember { mutableStateOf(false) }
|
var ratings by remember { mutableStateOf(false) }
|
||||||
@@ -209,7 +210,6 @@ class SettingsSheetScreenshotTest {
|
|||||||
showLogo = showLogo,
|
showLogo = showLogo,
|
||||||
autoPlayNext = autoPlayNext,
|
autoPlayNext = autoPlayNext,
|
||||||
showTenMinuteReminder = tenMinutes,
|
showTenMinuteReminder = tenMinutes,
|
||||||
speedUpCredits = speedUpCredits,
|
|
||||||
hideWatchedMovies = hideWatched,
|
hideWatchedMovies = hideWatched,
|
||||||
showCardMetadata = cardMetadata,
|
showCardMetadata = cardMetadata,
|
||||||
showRatingsStrip = ratings,
|
showRatingsStrip = ratings,
|
||||||
@@ -228,10 +228,6 @@ class SettingsSheetScreenshotTest {
|
|||||||
tenMinutes = it
|
tenMinutes = it
|
||||||
record("Ten minutes left", it)
|
record("Ten minutes left", it)
|
||||||
},
|
},
|
||||||
onSpeedUpCreditsChanged = {
|
|
||||||
speedUpCredits = it
|
|
||||||
record("Closing credits", it)
|
|
||||||
},
|
|
||||||
onHideWatchedMoviesChanged = {
|
onHideWatchedMoviesChanged = {
|
||||||
hideWatched = it
|
hideWatched = it
|
||||||
record("Hide films you have seen", 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
|
# 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).
|
# address above (video goes device -> Emby directly, never through the gateway).
|
||||||
MEMBY_EMBY_PUBLIC_URL: "${MEMBY_EMBY_PUBLIC_URL:-}"
|
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_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable"
|
||||||
MEMBY_REDIS_URL: "redis://redis:6379/0"
|
MEMBY_REDIS_URL: "redis://redis:6379/0"
|
||||||
MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}"
|
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_MIN_REBUILD_AGE: "${MEMBY_FOR_YOU_MIN_REBUILD_AGE:-24h}"
|
||||||
MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-24h}"
|
MEMBY_FOR_YOU_REFRESH_INTERVAL: "${MEMBY_FOR_YOU_REFRESH_INTERVAL:-24h}"
|
||||||
MEMBY_FOR_YOU_REBUILD_HOUR: "${MEMBY_FOR_YOU_REBUILD_HOUR:-4}"
|
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}"
|
mem_limit: "${MEMBY_SERVER_MEMORY_LIMIT:-512m}"
|
||||||
volumes:
|
volumes:
|
||||||
- memby-releases:/data/releases
|
- memby-releases:/data/releases
|
||||||
|
|||||||
@@ -35,3 +35,5 @@ android.builtInKotlin=false
|
|||||||
# `android { }` report itself deprecated in the build files; that warning cannot be
|
# `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.
|
# resolved from this side, and clears when the Kotlin plugin adopts the new DSL.
|
||||||
android.newDsl=false
|
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.
|
// The credits pane is not a viewer preference any more, and this pins the removal from both
|
||||||
func TestSpeedUpCreditsIsCatalogued(t *testing.T) {
|
// ends: the key is gone from the catalogue, and a document still carrying it — written by a
|
||||||
definition, ok := preferenceDefinitionFor("speedUpCredits")
|
// television or an operator before the removal — has it dropped rather than round-tripped.
|
||||||
if !ok {
|
// Without the second half a stored `false` would survive every sync and quietly keep the
|
||||||
t.Fatal("speedUpCredits is missing from the preference catalogue")
|
// feature off on the one set that had turned it off.
|
||||||
}
|
func TestSpeedUpCreditsIsNoLongerAPreference(t *testing.T) {
|
||||||
if definition.Kind != preferenceToggle {
|
if _, ok := preferenceDefinitionFor("speedUpCredits"); ok {
|
||||||
t.Fatalf("kind = %v, want a toggle", definition.Kind)
|
t.Fatal("speedUpCredits is still in the preference catalogue")
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// An illegal value must come back as the default rather than reaching a player.
|
normalised := normalizePreferences(map[string]any{"speedUpCredits": false})
|
||||||
normalised := normalizePreferences(map[string]any{"speedUpCredits": "sometimes"})
|
if _, present := normalised["speedUpCredits"]; present {
|
||||||
if normalised["speedUpCredits"] != true {
|
t.Fatal("a stored speedUpCredits must be dropped, not carried forward")
|
||||||
t.Fatalf("normalised = %v, want true", normalised["speedUpCredits"])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,17 +163,15 @@ var preferenceCatalogue = []preferenceDefinition{
|
|||||||
option(skipIntroOff, "Do nothing"),
|
option(skipIntroOff, "Do nothing"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
// `speedUpCredits` was here. It is deliberately not a viewer preference any more: the
|
||||||
// Default on: the whole feature is that it happens without being asked for, and it
|
// closing-credits pane is how this client ends an episode, and an opt-out made it a
|
||||||
// is visible, reversible and over in a minute — a viewer who dislikes it turns it
|
// feature half the household never saw. The operator's `end_credits` flag remains the
|
||||||
// off having seen exactly what it does. That is a different trade from
|
// one switch, which is the right level for it — it governs a subsystem that reads media
|
||||||
// skipIntroMode's, which defaults to the button rather than the automatic seek
|
// bytes, and turning it off is a decision about the server rather than about taste.
|
||||||
// because a jump nobody can see coming is not recoverable by watching it.
|
//
|
||||||
Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
|
// Removing the key from the catalogue is also how the stored values are cleaned up:
|
||||||
Description: "When an episode reaches its closing credits, shrink them to one side " +
|
// normalizePreferences drops what it does not recognise, so a viewer who had turned it
|
||||||
"at double speed and show what is on next beside them.",
|
// off gets the pane back on their next sync with nothing having to migrate anything.
|
||||||
Kind: preferenceToggle, Default: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
|
||||||
Description: "Turn a subtitle track on automatically when the title has one.",
|
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
|
// 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.
|
// wrong marker is somebody losing the end of an episode.
|
||||||
minSeparation = 0.18
|
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.
|
// 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
|
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.
|
// 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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,15 +51,21 @@ type Task struct {
|
|||||||
// Status is one task as the console reads it: the declaration, the operator's overrides,
|
// Status is one task as the console reads it: the declaration, the operator's overrides,
|
||||||
// the last run and when the next one is due.
|
// the last run and when the next one is due.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Interval int64 `json:"intervalSeconds"`
|
Interval int64 `json:"intervalSeconds"`
|
||||||
Enabled bool `json:"enabled"`
|
// DefaultInterval is the cadence declared in code, which Interval hides whenever an
|
||||||
Running bool `json:"running"`
|
// operator has overridden it. Both are sent because the console cannot otherwise tell
|
||||||
NextRun *time.Time `json:"nextRun,omitempty"`
|
// "every ten minutes because that is the default" from "every ten minutes because
|
||||||
LastRun *store.TaskRun `json:"lastRun,omitempty"`
|
// 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"`
|
||||||
|
LastRun *store.TaskRun `json:"lastRun,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type registered struct {
|
type registered struct {
|
||||||
@@ -458,8 +464,9 @@ func (s *Scheduler) Snapshot() []Status {
|
|||||||
status := Status{
|
status := Status{
|
||||||
ID: entry.task.ID, Name: entry.task.Name,
|
ID: entry.task.ID, Name: entry.task.Name,
|
||||||
Description: entry.task.Description, Group: entry.task.Group,
|
Description: entry.task.Description, Group: entry.task.Group,
|
||||||
Interval: int64(entry.effectiveInterval() / time.Second),
|
Interval: int64(entry.effectiveInterval() / time.Second),
|
||||||
Enabled: entry.enabled, Running: entry.running,
|
DefaultInterval: int64(entry.task.Interval / time.Second),
|
||||||
|
Enabled: entry.enabled, Running: entry.running,
|
||||||
}
|
}
|
||||||
if !entry.nextRun.IsZero() && entry.enabled {
|
if !entry.nextRun.IsZero() && entry.enabled {
|
||||||
next := entry.nextRun
|
next := entry.nextRun
|
||||||
|
|||||||
@@ -219,3 +219,44 @@ func waitForRun(t *testing.T, sched *Scheduler, id string) Status {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _ sync.Locker = (*sync.Mutex)(nil)
|
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