feat(settings): add playback.intro_skip_mode and deprecate auto_skip_intro
Skipping intros stops being a switch and becomes a three-way choice —
never / ask / always — matching what Jellyfin offers and giving viewers a
way to turn the prompt off, which the boolean could not express.
Contract revision 6 → 7: adds playback.intro_skip_mode (enum, default
"ask", profile + profile_device scopes) and marks playback.auto_skip_intro
deprecated without removing it. Every shipped client still reads the
boolean, so for one release the server keeps the pair in step at write
time: canonical PUT/DELETE, the legacy /profiles route, and the legacy
runtime /settings/{key} route all land both rows, and a profile-scope enum
write refreshes user_profiles.auto_skip_intro so GET /profiles stays
truthful. Existing rows are carried onto the new key by a Goose migration
(Postgres) and an InitSchema twin (per-user SQLite); the settings-migrate
planner emits the companion for installs whose backfill runs later.
The spec in docs/design/2026-08-16-intro-skip-mode.md also defines the
prompt state machine every client (web, Android, Apple; browser, tablet,
mobile, TV) implements against this key. It builds on the Android TV
Skip Intro work in silo-android#210 — wall-clock timer, rebuffer-vs-pause
debounce, root-level key handling.
Co-authored-by: evulhotdog <365456+evulhotdog@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
evulhotdog
Claude Fable 5
parent
edd919c5f7
commit
af974edfe9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 6,
|
||||
"manifest_revision": 7,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
@@ -668,6 +668,48 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intro_skip_mode_defaults_to_ask",
|
||||
"description": "A profile that has never chosen an intro behaviour gets the contract default, which is the same prompt the old auto_skip_intro=false produced.",
|
||||
"keys": ["playback.intro_skip_mode"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.intro_skip_mode",
|
||||
"value": "ask",
|
||||
"source": "default"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "intro_skip_mode_device_override_beats_profile",
|
||||
"description": "A living-room television set to skip intros automatically does not change the profile-wide choice to leave them alone.",
|
||||
"keys": ["playback.intro_skip_mode"],
|
||||
"context": { "profile_id": "p1", "device_id": "d1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "playback.intro_skip_mode",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": "never"
|
||||
},
|
||||
{
|
||||
"key": "playback.intro_skip_mode",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "d1",
|
||||
"value": "always"
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "playback.intro_skip_mode",
|
||||
"value": "always",
|
||||
"source": "profile_device"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "subtitle_appearance_uses_shared_default",
|
||||
"description": "With no stored profile or device value, every client receives the contract's shared Box 75% subtitle appearance.",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
export const SETTINGS_API_VERSION = 1;
|
||||
export const SETTINGS_REVISION = 6;
|
||||
export const SETTINGS_REVISION = 7;
|
||||
|
||||
export interface SettingSuggestedOption {
|
||||
value: string;
|
||||
@@ -181,6 +181,8 @@ export const SETTING_KEYS = {
|
||||
PLAYBACK_AUTO_SKIP_INTRO: "playback.auto_skip_intro",
|
||||
/** Auto-skip recaps */
|
||||
PLAYBACK_AUTO_SKIP_RECAP: "playback.auto_skip_recap",
|
||||
/** Skip intros */
|
||||
PLAYBACK_INTRO_SKIP_MODE: "playback.intro_skip_mode",
|
||||
/** Maximum bitrate */
|
||||
PLAYBACK_MAX_BITRATE_KBPS: "playback.max_bitrate_kbps",
|
||||
/** Next up prompt */
|
||||
@@ -535,6 +537,27 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
|
||||
category: "playback",
|
||||
control: "switch",
|
||||
},
|
||||
"playback.intro_skip_mode": {
|
||||
key: "playback.intro_skip_mode",
|
||||
type: "enum",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 7,
|
||||
scopes: ["profile", "profile_device"],
|
||||
scopeIntroducedIn: [7, 7],
|
||||
resolutionOrder: ["profile_device", "profile", "default"],
|
||||
defaultValue: "ask",
|
||||
label: "Skip intros",
|
||||
description:
|
||||
"What Silo does when an intro starts: leave it alone, offer a Skip Intro button, or skip it and offer an undo.",
|
||||
category: "playback",
|
||||
control: "select",
|
||||
values: [
|
||||
{ value: "never", label: "Never", introducedIn: 7 },
|
||||
{ value: "ask", label: "Ask to skip", introducedIn: 7 },
|
||||
{ value: "always", label: "Skip automatically", introducedIn: 7 },
|
||||
],
|
||||
},
|
||||
"playback.max_bitrate_kbps": {
|
||||
key: "playback.max_bitrate_kbps",
|
||||
type: "integer",
|
||||
|
||||
Reference in New Issue
Block a user