1070 lines
52 KiB
JSON
1070 lines
52 KiB
JSON
{
|
|
"api_version": 1,
|
|
"revision": 6,
|
|
"option_sets": {
|
|
"playback_audio_languages": {
|
|
"type": "language_tag",
|
|
"options": [
|
|
{ "value": "ar", "introduced_in": 1 },
|
|
{ "value": "bn", "introduced_in": 1 },
|
|
{ "value": "bg", "introduced_in": 1 },
|
|
{ "value": "zh", "introduced_in": 1 },
|
|
{ "value": "hr", "introduced_in": 1 },
|
|
{ "value": "cs", "introduced_in": 1 },
|
|
{ "value": "da", "introduced_in": 1 },
|
|
{ "value": "nl", "introduced_in": 1 },
|
|
{ "value": "en", "introduced_in": 1 },
|
|
{ "value": "fi", "introduced_in": 1 },
|
|
{ "value": "fr", "introduced_in": 1 },
|
|
{ "value": "de", "introduced_in": 1 },
|
|
{ "value": "el", "introduced_in": 1 },
|
|
{ "value": "he", "introduced_in": 1 },
|
|
{ "value": "hi", "introduced_in": 1 },
|
|
{ "value": "hu", "introduced_in": 1 },
|
|
{ "value": "id", "introduced_in": 1 },
|
|
{ "value": "it", "introduced_in": 1 },
|
|
{ "value": "ja", "introduced_in": 1 },
|
|
{ "value": "ko", "introduced_in": 1 },
|
|
{ "value": "ms", "introduced_in": 1 },
|
|
{ "value": "no", "introduced_in": 1 },
|
|
{ "value": "fa", "introduced_in": 1 },
|
|
{ "value": "pl", "introduced_in": 1 },
|
|
{ "value": "pt", "introduced_in": 1 },
|
|
{ "value": "ro", "introduced_in": 1 },
|
|
{ "value": "ru", "introduced_in": 1 },
|
|
{ "value": "sk", "introduced_in": 1 },
|
|
{ "value": "sl", "introduced_in": 1 },
|
|
{ "value": "es", "introduced_in": 1 },
|
|
{ "value": "sv", "introduced_in": 1 },
|
|
{ "value": "ta", "introduced_in": 1 },
|
|
{ "value": "te", "introduced_in": 1 },
|
|
{ "value": "th", "introduced_in": 1 },
|
|
{ "value": "tr", "introduced_in": 1 },
|
|
{ "value": "uk", "introduced_in": 1 },
|
|
{ "value": "vi", "introduced_in": 1 }
|
|
]
|
|
},
|
|
"playback_subtitle_languages": {
|
|
"type": "language_tag",
|
|
"options": [
|
|
{ "value": "ar", "introduced_in": 1 },
|
|
{ "value": "bn", "introduced_in": 1 },
|
|
{ "value": "bg", "introduced_in": 1 },
|
|
{ "value": "zh", "introduced_in": 1 },
|
|
{ "value": "hr", "introduced_in": 1 },
|
|
{ "value": "cs", "introduced_in": 1 },
|
|
{ "value": "da", "introduced_in": 1 },
|
|
{ "value": "nl", "introduced_in": 1 },
|
|
{ "value": "en", "introduced_in": 1 },
|
|
{ "value": "fi", "introduced_in": 1 },
|
|
{ "value": "fr", "introduced_in": 1 },
|
|
{ "value": "de", "introduced_in": 1 },
|
|
{ "value": "el", "introduced_in": 1 },
|
|
{ "value": "he", "introduced_in": 1 },
|
|
{ "value": "hi", "introduced_in": 1 },
|
|
{ "value": "hu", "introduced_in": 1 },
|
|
{ "value": "id", "introduced_in": 1 },
|
|
{ "value": "it", "introduced_in": 1 },
|
|
{ "value": "ja", "introduced_in": 1 },
|
|
{ "value": "ko", "introduced_in": 1 },
|
|
{ "value": "ms", "introduced_in": 1 },
|
|
{ "value": "no", "introduced_in": 1 },
|
|
{ "value": "fa", "introduced_in": 1 },
|
|
{ "value": "pl", "introduced_in": 1 },
|
|
{ "value": "pt", "introduced_in": 1 },
|
|
{ "value": "ro", "introduced_in": 1 },
|
|
{ "value": "ru", "introduced_in": 1 },
|
|
{ "value": "sk", "introduced_in": 1 },
|
|
{ "value": "sl", "introduced_in": 1 },
|
|
{ "value": "es", "introduced_in": 1 },
|
|
{ "value": "sv", "introduced_in": 1 },
|
|
{ "value": "ta", "introduced_in": 1 },
|
|
{ "value": "te", "introduced_in": 1 },
|
|
{ "value": "th", "introduced_in": 1 },
|
|
{ "value": "tr", "introduced_in": 1 },
|
|
{ "value": "uk", "introduced_in": 1 },
|
|
{ "value": "vi", "introduced_in": 1 }
|
|
]
|
|
},
|
|
"catalog_metadata_languages": {
|
|
"type": "language_tag",
|
|
"options": [
|
|
{ "value": "ar", "introduced_in": 1 },
|
|
{ "value": "bn", "introduced_in": 1 },
|
|
{ "value": "bg", "introduced_in": 1 },
|
|
{ "value": "zh", "introduced_in": 1 },
|
|
{ "value": "hr", "introduced_in": 1 },
|
|
{ "value": "cs", "introduced_in": 1 },
|
|
{ "value": "da", "introduced_in": 1 },
|
|
{ "value": "nl", "introduced_in": 1 },
|
|
{ "value": "en", "introduced_in": 1 },
|
|
{ "value": "fi", "introduced_in": 1 },
|
|
{ "value": "fr", "introduced_in": 1 },
|
|
{ "value": "de", "introduced_in": 1 },
|
|
{ "value": "el", "introduced_in": 1 },
|
|
{ "value": "he", "introduced_in": 1 },
|
|
{ "value": "hi", "introduced_in": 1 },
|
|
{ "value": "hu", "introduced_in": 1 },
|
|
{ "value": "id", "introduced_in": 1 },
|
|
{ "value": "it", "introduced_in": 1 },
|
|
{ "value": "ja", "introduced_in": 1 },
|
|
{ "value": "ko", "introduced_in": 1 },
|
|
{ "value": "ms", "introduced_in": 1 },
|
|
{ "value": "no", "introduced_in": 1 },
|
|
{ "value": "fa", "introduced_in": 1 },
|
|
{ "value": "pl", "introduced_in": 1 },
|
|
{ "value": "pt", "introduced_in": 1 },
|
|
{ "value": "ro", "introduced_in": 1 },
|
|
{ "value": "ru", "introduced_in": 1 },
|
|
{ "value": "sk", "introduced_in": 1 },
|
|
{ "value": "sl", "introduced_in": 1 },
|
|
{ "value": "es", "introduced_in": 1 },
|
|
{ "value": "sv", "introduced_in": 1 },
|
|
{ "value": "ta", "introduced_in": 1 },
|
|
{ "value": "te", "introduced_in": 1 },
|
|
{ "value": "th", "introduced_in": 1 },
|
|
{ "value": "tr", "introduced_in": 1 },
|
|
{ "value": "uk", "introduced_in": 1 },
|
|
{ "value": "vi", "introduced_in": 1 }
|
|
]
|
|
}
|
|
},
|
|
"definitions": [
|
|
{
|
|
"key": "playback.audio_language",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"],
|
|
"resolution_order": [
|
|
"profile_series",
|
|
"profile_library",
|
|
"profile_device",
|
|
"profile",
|
|
"default"
|
|
],
|
|
"value_schema": { "type": "language_tag", "nullable": true },
|
|
"default_value": null,
|
|
"category": "playback",
|
|
"label": "Preferred audio language",
|
|
"description": "Choose which spoken language Silo should prefer first.",
|
|
"recommended_control": "select",
|
|
"suggested_options": "playback_audio_languages",
|
|
"unset_label": "No preference",
|
|
"notes": "Migrates user_profiles.language as the roaming fallback. Existing user_device_settings values become real overrides, and the per-series value comes from AudioPreference.audio_language. AudioPreference.audio_track_index and track_signature stay specialized: they identify a concrete track, not a default. The legacy string-only endpoint has no way to send null, so it spells \"no preference\" as the empty string and both Android and web send that to clear the choice; its validator accepts \"\" and otherwise requires a well-formed tag via settingscontract.NormalizeLanguageTag. Migration maps \"\" to no stored row, the same way playback.subtitle_mode handles it."
|
|
},
|
|
{
|
|
"key": "playback.subtitle_language",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"],
|
|
"resolution_order": [
|
|
"profile_series",
|
|
"profile_library",
|
|
"profile_device",
|
|
"profile",
|
|
"default"
|
|
],
|
|
"value_schema": { "type": "language_tag", "nullable": true },
|
|
"default_value": null,
|
|
"category": "playback",
|
|
"label": "Preferred subtitle language",
|
|
"description": "Choose which subtitle language Silo should prefer first.",
|
|
"recommended_control": "select",
|
|
"suggested_options": "playback_subtitle_languages",
|
|
"unset_label": "None",
|
|
"notes": "Migrates user_profiles.subtitle_language, LibraryPlaybackPreference.subtitle_language, and SubtitlePreference.subtitle_language. SubtitlePreference.subtitle_track_index, external_subtitle_path, and track_signature stay specialized."
|
|
},
|
|
{
|
|
"key": "playback.subtitle_mode",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"],
|
|
"resolution_order": [
|
|
"profile_series",
|
|
"profile_library",
|
|
"profile_device",
|
|
"profile",
|
|
"default"
|
|
],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "auto", "label": "Auto" },
|
|
{ "value": "always", "label": "Always on" },
|
|
{ "value": "off", "label": "Off" }
|
|
]
|
|
},
|
|
"default_value": "auto",
|
|
"category": "playback",
|
|
"label": "Subtitles",
|
|
"description": "When Silo should turn subtitles on.",
|
|
"recommended_control": "select",
|
|
"notes": "The legacy empty string means unset, not a fourth mode. Migration maps \"\" to no stored row so it resolves to the next scope."
|
|
},
|
|
{
|
|
"key": "playback.show_forced_subtitles",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device", "profile_library", "profile_series"],
|
|
"resolution_order": [
|
|
"profile_series",
|
|
"profile_library",
|
|
"profile_device",
|
|
"profile",
|
|
"default"
|
|
],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"category": "playback",
|
|
"label": "Show forced subtitles",
|
|
"description": "Show subtitles for foreign-language dialogue even when subtitles are off.",
|
|
"recommended_control": "switch",
|
|
"notes": "Default is true because that is what the server resolves today: user_profiles.show_forced_subtitles is NOT NULL DEFAULT true (migration 029) and profile creation sets it true. A false default here would silently turn forced subtitles off for every profile that never touched the toggle. The Has* companion booleans on LibraryPlaybackPreference and SubtitlePreference encode set-vs-unset at the library and series scopes, so migration writes rows there only where Has* is true. The profile column has no companion and cannot distinguish an explicit true from the column default, so migration writes a profile row only where the value is false — the value that differs from the default."
|
|
},
|
|
{
|
|
"key": "playback.subtitle_appearance",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "object", "schema_ref": "subtitle-appearance.json" },
|
|
"default_value": {
|
|
"fontSize": "large",
|
|
"fontFamily": "sans-serif",
|
|
"fontColor": "#ffffff",
|
|
"backgroundColor": "#000000",
|
|
"backgroundStyle": "box",
|
|
"backgroundOpacity": 75,
|
|
"textOutline": false,
|
|
"textOutlineColor": "#000000",
|
|
"position": "bottom"
|
|
},
|
|
"category": "playback",
|
|
"label": "Subtitle appearance",
|
|
"description": "How subtitles are drawn during playback.",
|
|
"recommended_control": "panel",
|
|
"notes": "Renamed from the unprefixed legacy key \"subtitle_appearance\". Every other canonical key carries a domain prefix, and preserving accidental key names is an explicit non-goal of the design. The rename touches three URL paths in internal/api/router.go, the admin device-settings routes, and the key constant in every client, so it cannot land without them. Migration copies the account-level legacy fallback to every existing profile and leaves device overrides unchanged, rewriting the key on each row. Revision 6 changes the fallback background from shadow to a 75% opaque box, matching the previous intended web default and the current Apple fallback. Connected clients receive the complete effective default from the server; clients should adopt revision 6 so their local and sparse-object decoding fallbacks match it. Stored profile and device overrides remain authoritative."
|
|
},
|
|
{
|
|
"key": "playback.preferred_quality",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"ordered": true,
|
|
"values": [
|
|
{ "value": "auto", "label": "Auto" },
|
|
{ "value": "480p", "label": "480p" },
|
|
{ "value": "720p", "label": "720p" },
|
|
{ "value": "1080p", "label": "1080p" },
|
|
{ "value": "2160p", "label": "2160p / 4K" },
|
|
{ "value": "original", "label": "Original quality" }
|
|
]
|
|
},
|
|
"default_value": "auto",
|
|
"constrained_by": {
|
|
"policy_input": "max_playback_quality",
|
|
"constraint": "ceiling"
|
|
},
|
|
"category": "playback",
|
|
"label": "Preferred quality",
|
|
"description": "Pick the quality Silo should prefer.",
|
|
"recommended_control": "select",
|
|
"notes": "The resolution axis. Members are exactly the vocabulary the server already speaks: NormalizeQualityV3 in internal/playback/protocol_v3.go accepts auto, 480p, 720p, 1080p, 2160p and original and normalizes anything else to auto with a degradation warning. Transcode ladder rungs (328p, 720p-high, 1080p-8 and friends) are deliberately absent — they were never a third dimension, only a bitrate spelled into the resolution string. web/src/player/hooks/useTranscodeQuality.ts already decomposes them, defining 1080p-high as {resolution: 1080p, bitrate: 10000} and sending the two to the server separately, so the compound form never reached the wire. playback.max_bitrate_kbps is now that second axis, and clients compose the two into whatever presets they want to show. Members are listed ascending so the ceiling constraint has a defined direction; \"auto\" sorts lowest because it never exceeds a cap, and \"original\" highest because it is the uncapped source. Enforcing the ceiling needs internal/access/quality.go to learn both sentinels: qualityRank ranks neither today, so auto and original both tie at 0 with unset and a cap would let original through. Migrates user_profiles.quality_preference as the profile fallback. The legacy column is NOT NULL DEFAULT '1080p', and that default was the effective playback cap, so existing profiles receive explicit 1080p and 6000 kbps rows; newly created profiles use the contract's auto/null defaults. The account/profile max_playback_quality columns stay in internal/policy and are NOT settings."
|
|
},
|
|
{
|
|
"key": "playback.max_bitrate_kbps",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "integer",
|
|
"nullable": true,
|
|
"minimum": 100,
|
|
"maximum": 200000
|
|
},
|
|
"default_value": null,
|
|
"unit": "kbps",
|
|
"category": "playback",
|
|
"label": "Maximum bitrate",
|
|
"description": "Cap how much bandwidth playback may use. No cap means Silo picks for the chosen resolution.",
|
|
"recommended_control": "select",
|
|
"notes": "The bitrate axis, orthogonal to playback.preferred_quality. Splitting them is what the clients were already doing: the in-player switcher sends resolution and bitrate as separate fields, and downloads (DownloadQuality in silo-android) dropped resolution entirely and kept only a bitrate ladder. Two values rather than one compound enum means a client can offer \"1080p High\" without the server having to agree on what \"High\" means — retuning a preset is a client release, not a contract break, and it stays additive under the widening rule. null is uncapped, which is why this is nullable rather than defaulting to a large number: absent and \"as much as you like\" are the same statement, and a numeric sentinel would have to be widened every time hardware improves. The bounds are deliberately loose — 100 kbps is below any watchable stream and 200 Mbps is above any remux — because this caps a preference, not a policy; entitlement limits live in internal/policy. Migration decomposes the legacy compound values: 1080p-high becomes (1080p, 10000), 720p-medium becomes (720p, 3000), 420p becomes (480p, 720), following the bitrates in web/src/player/hooks/useTranscodeQuality.ts, so no stored preference is lost to the rejects table."
|
|
},
|
|
{
|
|
"key": "playback.auto_skip_intro",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"category": "playback",
|
|
"label": "Auto-skip intros",
|
|
"description": "Jump past intros automatically when Silo can detect them.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "playback.auto_skip_credits",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"category": "playback",
|
|
"label": "Auto-skip credits",
|
|
"description": "Move through end credits automatically when a skip is available.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "playback.auto_skip_recap",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"category": "playback",
|
|
"label": "Auto-skip recaps",
|
|
"description": "Skip \"previously on\" recaps automatically when Silo can detect them.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "playback.auto_play_next",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"category": "playback",
|
|
"label": "Auto-play next episode",
|
|
"description": "Continue to the next episode automatically.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "playback.auto_play_next_preview",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"category": "playback",
|
|
"label": "Preview next episode",
|
|
"description": "Show a preview of the next episode while credits play.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "playback.next_up_prompt_seconds",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "integer", "minimum": 0, "maximum": 120 },
|
|
"default_value": 30,
|
|
"unit": "seconds",
|
|
"category": "playback",
|
|
"label": "Next up prompt",
|
|
"description": "How long before the end of an episode the next-up prompt appears.",
|
|
"recommended_control": "slider",
|
|
"notes": "Android currently writes player.next_up_prompt_seconds. That alias is migrated to this key and removed from production writes."
|
|
},
|
|
{
|
|
"key": "catalog.metadata_language",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": { "type": "language_tag", "nullable": true },
|
|
"default_value": null,
|
|
"category": "catalog",
|
|
"label": "Metadata language",
|
|
"description": "Fallback language Silo prefers for titles, descriptions, and artwork.",
|
|
"recommended_control": "select",
|
|
"suggested_options": "catalog_metadata_languages",
|
|
"unset_label": "Library default",
|
|
"notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes. From revision 3, the private-use tag x-silo-original means resolve the target from each media item's original_language. It remains a valid value of the existing language_tag schema, so this is additive rather than a response-field type change."
|
|
},
|
|
{
|
|
"key": "catalog.metadata_language_overrides",
|
|
"introduced_in": 3,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "metadata-language-overrides.json"
|
|
},
|
|
"default_value": {},
|
|
"category": "catalog",
|
|
"label": "Metadata language exceptions",
|
|
"description": "Preferred metadata language for items in specific original languages.",
|
|
"recommended_control": "panel",
|
|
"notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions."
|
|
},
|
|
{
|
|
"key": "player.hdr_enabled",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"category": "player",
|
|
"label": "HDR",
|
|
"description": "Allow HDR output on this device.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "player.dolby_vision_enabled",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "player",
|
|
"label": "Dolby Vision",
|
|
"description": "Allow Dolby Vision output on this device.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "player.dv_profile7_hdr10_fallback",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "player",
|
|
"label": "Dolby Vision Profile 7 fallback",
|
|
"description": "Play Profile 7 sources as HDR10 when this device cannot decode them natively.",
|
|
"recommended_control": "switch",
|
|
"notes": "Android currently defaults this to true before hydration. The contract default is false, matching the server and Apple."
|
|
},
|
|
{
|
|
"key": "player.seek_cache_enabled",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"category": "player",
|
|
"label": "Seek cache",
|
|
"description": "Keep recently played segments buffered for faster seeking.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "player.match_frame_rate",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["android", "android_tv", "tvos"],
|
|
"category": "player",
|
|
"label": "Match content frame rate",
|
|
"description": "Switch the display refresh rate to match what is playing.",
|
|
"recommended_control": "switch",
|
|
"notes": "Android keeps this device-local today: it is absent from PlaybackSettingsKeys.DeviceSettings and documented there as deliberately not synced, so it was never written to the server rather than written and rejected. Registered here because a display-matching preference belongs to the device and should follow a profile across reinstalls."
|
|
},
|
|
{
|
|
"key": "player.playback_speed",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "number", "minimum": 0.25, "maximum": 3.0, "step": 0.05 },
|
|
"default_value": 1.0,
|
|
"unit": "x",
|
|
"category": "player",
|
|
"label": "Playback speed",
|
|
"description": "Default playback speed on this device.",
|
|
"recommended_control": "slider",
|
|
"notes": "Range matches the server and the shipped clients: Android already clamps to 0.25..3.0 and no picker offers above 3.0. The 0.05 step is enforced by ValidateValue, not just advertised, so every client's stepper lands on values the server accepts."
|
|
},
|
|
{
|
|
"key": "player.audio_sync_ms",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "integer", "minimum": -5000, "maximum": 5000 },
|
|
"default_value": 0,
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv"],
|
|
"unit": "milliseconds",
|
|
"category": "player",
|
|
"label": "Audio sync offset",
|
|
"description": "Shift audio earlier or later to correct lip sync on this device.",
|
|
"recommended_control": "slider"
|
|
},
|
|
{
|
|
"key": "player.subtitle_sync_ms",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "integer", "minimum": -10000, "maximum": 10000 },
|
|
"default_value": 0,
|
|
"unit": "milliseconds",
|
|
"category": "player",
|
|
"label": "Subtitle sync offset",
|
|
"description": "Shift subtitles earlier or later on this device.",
|
|
"recommended_control": "slider"
|
|
},
|
|
{
|
|
"key": "player.video_gravity",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "fit", "label": "Fit" },
|
|
{ "value": "fill", "label": "Fill" },
|
|
{ "value": "stretch", "label": "Stretch" }
|
|
]
|
|
},
|
|
"default_value": "fit",
|
|
"category": "player",
|
|
"label": "Video sizing",
|
|
"description": "How video fills the screen on this device.",
|
|
"recommended_control": "select"
|
|
},
|
|
{
|
|
"key": "player.orientation_mode",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "landscapeLocked", "label": "Landscape" },
|
|
{ "value": "rotateFreely", "label": "Rotate freely" }
|
|
]
|
|
},
|
|
"default_value": "landscapeLocked",
|
|
"platforms": ["ios", "android"],
|
|
"category": "player",
|
|
"label": "Screen orientation",
|
|
"description": "Whether the player rotates with the device.",
|
|
"recommended_control": "select"
|
|
},
|
|
{
|
|
"key": "player.sleep_timer_default_minutes",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "integer", "minimum": 0, "maximum": 240 },
|
|
"default_value": 30,
|
|
"unit": "minutes",
|
|
"category": "player",
|
|
"label": "Default sleep timer",
|
|
"description": "Duration the sleep timer starts on when you turn it on. 0 leaves it off.",
|
|
"recommended_control": "stepper",
|
|
"notes": "Android keeps this device-local today and clamps to 0..240; it was never written to the server rather than written and rejected. The maximum matches that clamp rather than exceeding it, and the default matches Android's shipped 30, because a manifest that disagrees with the only client implementing a setting is the drift this contract exists to remove — and a default of 0 would silently turn the preset off for everyone at cutover. Raising the maximum later is additive under the widening rule: replace the bare maximum with its history so a client can still see the 240 an older server enforces. This is the duration the timer starts on, not whether one is running: the design classes a running sleep timer as private local, so only the persisted default is registered."
|
|
},
|
|
{
|
|
"key": "ui.theme",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "midnight-cinema", "label": "Midnight Cinema" },
|
|
{ "value": "cinema-light", "label": "Cinema Light" },
|
|
{ "value": "cobalt-studio", "label": "Cobalt Studio" },
|
|
{ "value": "oxblood-noir", "label": "Oxblood Noir" },
|
|
{ "value": "evergreen-studio", "label": "Evergreen Studio" }
|
|
]
|
|
},
|
|
"default_value": "midnight-cinema",
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "Theme",
|
|
"description": "Colour theme for the Silo interface.",
|
|
"recommended_control": "select",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_theme\", which the extension bag accepted without validation. Moved from account to profile scope: appearance is per household member, and the account row is copied to every profile during migration. Carries a device override because the right theme is partly a function of the screen and the room — a light theme on a phone in daylight, a dark one on a TV at night — which is the same reasoning that gives ui.text_scale one. Note that ui.custom_theme_vars and ui.custom_css stay profile-wide, so a profile's custom styling still applies on top of a device's theme override. Adding a theme is an additive enum widening. The admin-set default theme stays in server_settings and is not a user setting. Migration must also update internal/plugins/user_theme_lookup.go, which reads this value with raw SQL bound to both the old name and the account scope (SELECT value FROM user_settings WHERE user_id = $1 AND key = 'ui_theme') and feeds the X-Silo-Theme header on every plugin request. Left alone, that query matches nothing after the rename and every plugin UI silently falls back to its own theme, with no error to notice."
|
|
},
|
|
{
|
|
"key": "ui.text_scale",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"ordered": true,
|
|
"values": [
|
|
{ "value": "default", "label": "Default" },
|
|
{ "value": "large", "label": "Large" },
|
|
{ "value": "x-large", "label": "Extra large" }
|
|
]
|
|
},
|
|
"default_value": "default",
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "Text size",
|
|
"description": "Overall interface text size.",
|
|
"recommended_control": "select",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_text_scale\". Allows a device override because readable text size is partly a function of the screen you are sitting in front of."
|
|
},
|
|
{
|
|
"key": "ui.text_weight",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "default", "label": "Default" },
|
|
{ "value": "strong", "label": "Bolder" }
|
|
]
|
|
},
|
|
"default_value": "default",
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "Text weight",
|
|
"description": "Use heavier interface text for readability.",
|
|
"recommended_control": "select",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_text_weight\"."
|
|
},
|
|
{
|
|
"key": "ui.high_contrast",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "High contrast",
|
|
"description": "Increase contrast across the interface.",
|
|
"recommended_control": "switch",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_high_contrast\"."
|
|
},
|
|
{
|
|
"key": "ui.custom_theme_vars",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "theme-var-overrides.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "Custom theme variables",
|
|
"description": "Per-token overrides applied on top of the selected theme.",
|
|
"recommended_control": "panel",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_custom_theme_vars\", which stored arbitrary unvalidated JSON."
|
|
},
|
|
{
|
|
"key": "ui.custom_css",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": { "type": "string", "max_length": 65536, "nullable": true },
|
|
"default_value": null,
|
|
"platforms": ["web"],
|
|
"category": "appearance",
|
|
"label": "Custom CSS",
|
|
"description": "Raw CSS applied on top of the selected theme.",
|
|
"recommended_control": "text",
|
|
"notes": "Renamed from the unregistered legacy key \"ui_custom_css\". Sanitization stays in the web client (web/src/lib/cssSanitizer.ts); the contract only bounds length. This value is per-profile and is never applied to another profile's session."
|
|
},
|
|
{
|
|
"key": "ui.date_format",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "auto", "label": "Match device" },
|
|
{ "value": "DD/MM/YYYY" },
|
|
{ "value": "MM/DD/YYYY" },
|
|
{ "value": "YYYY-MM-DD" }
|
|
]
|
|
},
|
|
"default_value": "auto",
|
|
"category": "appearance",
|
|
"label": "Date format",
|
|
"description": "How dates are written across the interface.",
|
|
"recommended_control": "select",
|
|
"notes": "Moved from account to profile scope; the account row is copied to every profile during migration."
|
|
},
|
|
{
|
|
"key": "ui.time_format",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "auto", "label": "Match device" },
|
|
{ "value": "12h", "label": "12-hour" },
|
|
{ "value": "24h", "label": "24-hour" }
|
|
]
|
|
},
|
|
"default_value": "auto",
|
|
"category": "appearance",
|
|
"label": "Time format",
|
|
"description": "How clock times are written across the interface.",
|
|
"recommended_control": "select",
|
|
"notes": "Moved from account to profile scope; the account row is copied to every profile during migration."
|
|
},
|
|
{
|
|
"key": "ui.library_page_state",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "library-page-state.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"platforms": ["web"],
|
|
"category": "navigation",
|
|
"label": "Remembered library view",
|
|
"description": "Saved browse state for each library.",
|
|
"notes": "Navigation state, not a user-authored preference. Stays tied to one profile on one device and is not shown as a normal setting control."
|
|
},
|
|
{
|
|
"key": "ui.remember_library_page_state",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_device"],
|
|
"resolution_order": ["profile_device", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"platforms": ["web"],
|
|
"category": "navigation",
|
|
"label": "Remember library view",
|
|
"description": "Return to where you left off when reopening a library.",
|
|
"recommended_control": "switch"
|
|
},
|
|
{
|
|
"key": "search.media_scope",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "all", "label": "Everything" },
|
|
{ "value": "video", "label": "Movies and series" },
|
|
{ "value": "audiobook", "label": "Audiobooks" }
|
|
]
|
|
},
|
|
"default_value": "video",
|
|
"category": "search",
|
|
"label": "Search scope",
|
|
"description": "What search covers by default.",
|
|
"recommended_control": "select",
|
|
"notes": "Moved from account to profile scope; the account row is copied to every profile during migration."
|
|
},
|
|
{
|
|
"key": "ui.card_overlays",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "card-overlays.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "appearance",
|
|
"label": "Poster badges",
|
|
"description": "Which badges appear on poster cards, and where.",
|
|
"notes": "Registered from the legacy unprefixed key card_overlays, which reached the server only through the unknown-key extension bag — stored as an arbitrary string with no validation. null means the user has expressed no preference, which is what lets the server-wide admin default in the overlay-config endpoint apply; writing a resolved-but-unchosen value would silently pin them. The admin default and the enabled kill switch stay in server_settings and are not user settings."
|
|
},
|
|
{
|
|
"key": "ui.next_up_mode",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"values": [
|
|
{ "value": "combined", "label": "With Continue Watching" },
|
|
{ "value": "separate", "label": "Separate row" }
|
|
]
|
|
},
|
|
"default_value": "combined",
|
|
"category": "navigation",
|
|
"label": "Next up episodes",
|
|
"description": "Whether upcoming episodes stay with Continue Watching or get their own row.",
|
|
"recommended_control": "select",
|
|
"notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member."
|
|
},
|
|
{
|
|
"key": "nav.primary_menu",
|
|
"introduced_in": 5,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile_client", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile_client", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "primary-menu.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "navigation",
|
|
"label": "Primary menu",
|
|
"description": "The ordered visible destinations shared by like clients.",
|
|
"recommended_control": "panel",
|
|
"notes": "Search and profile remain fixed client utilities. Home is required by primary-menu.json; omitting any other supported built-in hides it. Semantic destination identities are unique even when labels differ. A null default lets each family keep its native baseline until the user customizes it. Family scope synchronizes like clients while profile_device remains an explicit escape hatch for one screen; clients ignore built-in destinations they do not support."
|
|
},
|
|
{
|
|
"key": "nav.shortcuts",
|
|
"introduced_in": 5,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "navigation-shortcuts.json"
|
|
},
|
|
"default_value": { "items": [] },
|
|
"category": "navigation",
|
|
"label": "Navigation shortcuts",
|
|
"description": "Libraries, sections, and collections pinned for use across navigation surfaces.",
|
|
"notes": "Profile-wide catalog; individual client families decide which shortcuts to place in their primary menu. Semantic destination identities are unique even when labels differ. The profile_client migration keeps ui.sidebar_pins unchanged and seeds this key from convertible legacy web pins only when this key has no authored row."
|
|
},
|
|
{
|
|
"key": "ui.card_presentation",
|
|
"introduced_in": 5,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile", "profile_client", "profile_device"],
|
|
"resolution_order": ["profile_device", "profile_client", "profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "card-presentation.json"
|
|
},
|
|
"default_value": { "poster_size": "standard", "caption": "title_metadata" },
|
|
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "appearance",
|
|
"label": "Media cards",
|
|
"description": "Poster size and caption detail used by media cards.",
|
|
"recommended_control": "panel",
|
|
"notes": "Semantic presets roam between like devices without forcing identical pixel dimensions across platforms. A profile fallback can opt into one presentation everywhere; family and exact-device values remain more specific."
|
|
},
|
|
{
|
|
"key": "ui.sidebar_pins",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "sidebar-pins.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"platforms": ["web"],
|
|
"category": "navigation",
|
|
"label": "Pinned sidebar items",
|
|
"description": "Sections and collections pinned into the sidebar.",
|
|
"notes": "Registered from the legacy unprefixed key sidebar_pins. Navigation state rather than an authored preference, so it has no control; it is written by the pin affordances themselves."
|
|
},
|
|
{
|
|
"key": "ui.disabled_library_ids",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "library-id-list.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"category": "navigation",
|
|
"label": "Hidden libraries",
|
|
"description": "Libraries you have hidden from your own browsing.",
|
|
"notes": "Registered from the legacy unprefixed key disabled_library_ids. This is the user hiding a library from themselves — it is not an access control. Library visibility enforcement lives in internal/access and internal/policy, and nothing here may be read as a permission. Profile scope rather than profile_device because hiding a library is a statement about what you want to see, not about one screen."
|
|
},
|
|
{
|
|
"key": "ui.library_order",
|
|
"introduced_in": 1,
|
|
"persistence": "remote",
|
|
"allowed_scopes": ["profile"],
|
|
"resolution_order": ["profile", "default"],
|
|
"value_schema": {
|
|
"type": "object",
|
|
"schema_ref": "library-id-list.json",
|
|
"nullable": true
|
|
},
|
|
"default_value": null,
|
|
"category": "navigation",
|
|
"label": "Library order",
|
|
"description": "The order your libraries appear in.",
|
|
"notes": "Registered from the legacy unprefixed key library_order. Shares library-id-list.json with ui.disabled_library_ids: both are normalized by the same normalizeLibraryIDs in web/src/hooks/queries/libraries.ts, which drops non-integers and duplicates. A library id absent from the list sorts after the ones present, so a stale id for a deleted library is inert and needs no cleanup hook."
|
|
},
|
|
{
|
|
"key": "downloads.wifi_only",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"platforms": ["ios", "android"],
|
|
"category": "downloads",
|
|
"label": "Download over Wi-Fi only",
|
|
"description": "Only download while connected to Wi-Fi.",
|
|
"recommended_control": "switch",
|
|
"notes": "Contract-known local: the value governs OS-level network constraints on the device holding the files, so it does not roam. Shared semantics across Apple and Android make it contract-owned rather than private."
|
|
},
|
|
{
|
|
"key": "downloads.keep_watched",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["ios", "android"],
|
|
"category": "downloads",
|
|
"label": "Keep watched downloads",
|
|
"description": "Do not suggest reclaiming space from downloads you have finished.",
|
|
"recommended_control": "switch",
|
|
"notes": "Contract-known local. Governs on-device storage cleanup prompts."
|
|
},
|
|
{
|
|
"key": "downloads.default_quality",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": {
|
|
"type": "enum",
|
|
"ordered": true,
|
|
"values": [
|
|
{ "value": "1mbps", "label": "1 Mbps" },
|
|
{ "value": "2mbps", "label": "2 Mbps" },
|
|
{ "value": "5mbps", "label": "5 Mbps" },
|
|
{ "value": "10mbps", "label": "10 Mbps" },
|
|
{ "value": "20mbps", "label": "20 Mbps" },
|
|
{ "value": "original", "label": "Original" }
|
|
]
|
|
},
|
|
"default_value": "original",
|
|
"platforms": ["ios", "android"],
|
|
"category": "downloads",
|
|
"label": "Download quality",
|
|
"description": "Quality preset used for new downloads.",
|
|
"recommended_control": "select",
|
|
"notes": "Contract-known local: the value is chosen on the device holding the files and is sent on each POST /downloads rather than stored server-side. Members are the DownloadQuality wire presets, ascending. Registered as client_local rather than left unregistered because it is a user-facing preference with shared semantics, and the manifest's invariant is that no production setting exists without an entry."
|
|
},
|
|
{
|
|
"key": "subtitle.matches_device",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "playback",
|
|
"label": "Match device caption settings",
|
|
"description": "Use the operating system's caption style instead of Silo's.",
|
|
"recommended_control": "switch",
|
|
"notes": "Contract-known local: reads OS accessibility settings that only exist on the device. When enabled, playback.subtitle_appearance is not applied. Apple's existing copy separating this from profile subtitle behavior is the UX baseline. A contract key names a setting; it is not a storage key. Clients keep whatever local key they already use — Android stores this at subtitle.matches_device.local, Apple at player.subtitleMatchesSystemAppearance — so adopting the contract does not reset anyone's local preferences. The same applies to downloads.wifi_only and downloads.keep_watched, which Apple stores as downloads.wifiOnly and downloads.keepWatchedDownloads."
|
|
},
|
|
{
|
|
"key": "player.resume_rewind_seconds",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "integer", "minimum": 0, "maximum": 30 },
|
|
"default_value": 7,
|
|
"unit": "seconds",
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"],
|
|
"category": "player",
|
|
"label": "Rewind on resume",
|
|
"description": "Skip back this far when resuming a partly watched item, to re-establish context. 0 turns it off.",
|
|
"recommended_control": "stepper",
|
|
"notes": "Contract-known local: it tunes playback feel on the device doing the playing. Registered so the name, range and default are shared rather than reinvented per platform."
|
|
},
|
|
{
|
|
"key": "player.passout_threshold",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "integer", "minimum": 0, "maximum": 20 },
|
|
"default_value": 3,
|
|
"unit": "episodes",
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv", "web"],
|
|
"category": "player",
|
|
"label": "Still watching prompt",
|
|
"description": "How many episodes auto-play before Silo asks whether you are still watching. 0 never asks.",
|
|
"recommended_control": "stepper",
|
|
"notes": "Contract-known local: pass-out protection counts consecutive auto-advances in one client session, which no other device can observe."
|
|
},
|
|
{
|
|
"key": "player.picture_in_picture_enabled",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": true,
|
|
"platforms": ["ios", "macos", "android"],
|
|
"category": "player",
|
|
"label": "Picture in picture",
|
|
"description": "Keep playing in a floating window when you leave the player.",
|
|
"recommended_control": "switch",
|
|
"notes": "Contract-known local: picture-in-picture is an OS capability of the device, not a playback preference the server resolves."
|
|
},
|
|
{
|
|
"key": "nav.show_audiobooks",
|
|
"introduced_in": 1,
|
|
"persistence": "client_local",
|
|
"allowed_scopes": ["client_local"],
|
|
"resolution_order": ["client_local", "default"],
|
|
"value_schema": { "type": "boolean" },
|
|
"default_value": false,
|
|
"platforms": ["ios", "tvos", "macos", "android", "android_tv"],
|
|
"category": "nav",
|
|
"label": "Show audiobooks",
|
|
"description": "Show the Audiobooks section in navigation.",
|
|
"recommended_control": "switch",
|
|
"notes": "Contract-known local: an opt-in navigation surface, hidden by default, with existing Apple (AppNavPreferences.showAudiobooks) and Android parity. Android stores it locally at nav.show_audiobooks.local."
|
|
}
|
|
]
|
|
}
|