@joewinke/jatui 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Button.svelte +13 -1
- package/dist/components/Button.svelte.d.ts +7 -0
- package/dist/components/VideoPlayer.svelte +279 -17
- package/dist/components/VideoPlayer.svelte.d.ts +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/video/README.md +1 -1
- package/dist/video/keys.d.ts +5 -5
- package/dist/video/keys.js +5 -4
- package/dist/video/settings/SettingsPanel.svelte +414 -0
- package/dist/video/settings/SettingsPanel.svelte.d.ts +16 -0
- package/dist/video/settings/panel.d.ts +24 -0
- package/dist/video/settings/panel.js +58 -0
- package/dist/video/settings/registry.d.ts +82 -0
- package/dist/video/settings/registry.js +331 -0
- package/dist/video/settings/store.d.ts +55 -0
- package/dist/video/settings/store.js +116 -0
- package/dist/video/settings.d.ts +15 -46
- package/dist/video/settings.js +15 -98
- package/package.json +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
let nextId = 0;
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* The VideoPlayer settings panel (mos-5lzns.35) — extension-library's
|
|
8
|
+
* `openSettings()` ported. Generated wholly from `VIDEO_SETTINGS` via
|
|
9
|
+
* `panelGroups`: every row is a label, one control, and the sentence saying
|
|
10
|
+
* what the thing does. The sentence is not decoration — it is the reason
|
|
11
|
+
* this screen exists rather than a list of switches.
|
|
12
|
+
*
|
|
13
|
+
* Keyboard: focus lands on the first control and Tab cycles inside the
|
|
14
|
+
* dialog; Esc (or `,`) closes and hands focus back. Controls follow the
|
|
15
|
+
* ARIA APG patterns — a switch, a radio group (arrows move the choice), a
|
|
16
|
+
* native range. It is a FORM, not a list: arrow keys belong to the
|
|
17
|
+
* controls (a range and a radio group both need them), so there is no
|
|
18
|
+
* row-level listNav to fight them.
|
|
19
|
+
*
|
|
20
|
+
* Values are live, not on close: these are things you judge by watching
|
|
21
|
+
* the result, so the result has to move while you drag.
|
|
22
|
+
*
|
|
23
|
+
* Token-only chrome; the only motion (a short fade) is dropped under
|
|
24
|
+
* prefers-reduced-motion.
|
|
25
|
+
*/
|
|
26
|
+
import { onMount } from 'svelte';
|
|
27
|
+
import type { SettingSpec, SettingValue } from './registry';
|
|
28
|
+
import { formatSettingValue, stepOption, type SettingsPanelGroup } from './panel';
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
groups,
|
|
32
|
+
values,
|
|
33
|
+
onchange,
|
|
34
|
+
onclose,
|
|
35
|
+
helpHref = null,
|
|
36
|
+
persisted = false
|
|
37
|
+
}: {
|
|
38
|
+
groups: SettingsPanelGroup[];
|
|
39
|
+
/** Effective value per id — what the player is doing right now. */
|
|
40
|
+
values: Record<string, SettingValue>;
|
|
41
|
+
onchange: (id: string, value: SettingValue) => void;
|
|
42
|
+
onclose: () => void;
|
|
43
|
+
/** A host help page explaining all of this ("How all of this works →"). */
|
|
44
|
+
helpHref?: string | null;
|
|
45
|
+
/** The host supplied a store — the footer says choices are remembered. */
|
|
46
|
+
persisted?: boolean;
|
|
47
|
+
} = $props();
|
|
48
|
+
|
|
49
|
+
const uid = `vp-set-${++nextId}`;
|
|
50
|
+
let cardEl: HTMLDivElement | undefined = $state();
|
|
51
|
+
|
|
52
|
+
function rowId(spec: SettingSpec) {
|
|
53
|
+
return `${uid}-${spec.id.replace(/[^a-z0-9]/gi, '-')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function focusables(): HTMLElement[] {
|
|
57
|
+
if (!cardEl) return [];
|
|
58
|
+
return Array.from(
|
|
59
|
+
cardEl.querySelectorAll<HTMLElement>('button:not([tabindex="-1"]), input, a[href]')
|
|
60
|
+
).filter((el) => !el.hasAttribute('disabled'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
onMount(() => {
|
|
64
|
+
const first = cardEl?.querySelector<HTMLElement>('.vp-set-control button:not([tabindex="-1"]), .vp-set-control input');
|
|
65
|
+
(first ?? cardEl)?.focus();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
69
|
+
// The player's own shortcuts must not fire from inside the dialog
|
|
70
|
+
// (Space on a switch would also play/pause).
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
if (e.key === 'Escape' || (e.key === ',' && (e.target as HTMLElement)?.tagName !== 'INPUT')) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
onclose();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (e.key === 'Tab') {
|
|
78
|
+
const els = focusables();
|
|
79
|
+
if (!els.length) return;
|
|
80
|
+
const i = els.indexOf(document.activeElement as HTMLElement);
|
|
81
|
+
const next = e.shiftKey ? (i <= 0 ? els.length - 1 : i - 1) : i === els.length - 1 ? 0 : i + 1;
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
els[next].focus();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onRadioKey(e: KeyboardEvent, spec: SettingSpec) {
|
|
88
|
+
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
|
|
89
|
+
if (!dir) return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
const next = stepOption(spec, values[spec.id], dir);
|
|
92
|
+
if (next === undefined) return;
|
|
93
|
+
onchange(spec.id, next);
|
|
94
|
+
const group = (e.currentTarget as HTMLElement).closest('[role="radiogroup"]');
|
|
95
|
+
queueMicrotask(() => group?.querySelector<HTMLElement>(`[data-value="${next}"]`)?.focus());
|
|
96
|
+
}
|
|
97
|
+
</script>
|
|
98
|
+
|
|
99
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
100
|
+
<div class="vp-set-backdrop" onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}>
|
|
101
|
+
<div
|
|
102
|
+
class="vp-set"
|
|
103
|
+
role="dialog"
|
|
104
|
+
aria-modal="true"
|
|
105
|
+
aria-labelledby="{uid}-title"
|
|
106
|
+
tabindex="-1"
|
|
107
|
+
bind:this={cardEl}
|
|
108
|
+
onkeydown={onKeyDown}
|
|
109
|
+
>
|
|
110
|
+
<div class="vp-set-head">
|
|
111
|
+
<h2 class="vp-set-title" id="{uid}-title">Settings</h2>
|
|
112
|
+
<button type="button" class="vp-set-close" aria-label="Close settings" onclick={onclose}>
|
|
113
|
+
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
114
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
115
|
+
</svg>
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div class="vp-set-body">
|
|
120
|
+
{#each groups as g (g.group)}
|
|
121
|
+
<section class="vp-set-group" aria-labelledby="{uid}-g-{g.group}">
|
|
122
|
+
<h3 class="vp-set-group-title" id="{uid}-g-{g.group}">{g.group}</h3>
|
|
123
|
+
{#each g.rows as spec (spec.id)}
|
|
124
|
+
{@const id = rowId(spec)}
|
|
125
|
+
{@const value = values[spec.id] ?? spec.default}
|
|
126
|
+
<div class="vp-set-row" data-setting={spec.id}>
|
|
127
|
+
<span class="vp-set-label" id="{id}-label">{spec.label}</span>
|
|
128
|
+
<div class="vp-set-control">
|
|
129
|
+
{#if spec.type === 'bool'}
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="vp-set-switch"
|
|
133
|
+
role="switch"
|
|
134
|
+
aria-checked={value === true}
|
|
135
|
+
aria-labelledby="{id}-label"
|
|
136
|
+
aria-describedby="{id}-hint"
|
|
137
|
+
onclick={() => onchange(spec.id, value !== true)}
|
|
138
|
+
>
|
|
139
|
+
<span class="vp-set-knob" aria-hidden="true"></span>
|
|
140
|
+
<span class="vp-set-switch-text">{value === true ? 'On' : 'Off'}</span>
|
|
141
|
+
</button>
|
|
142
|
+
{:else if spec.type === 'enum'}
|
|
143
|
+
<div
|
|
144
|
+
class="vp-set-seg"
|
|
145
|
+
role="radiogroup"
|
|
146
|
+
aria-labelledby="{id}-label"
|
|
147
|
+
aria-describedby="{id}-hint"
|
|
148
|
+
>
|
|
149
|
+
{#each spec.options ?? [] as opt (opt.value)}
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
role="radio"
|
|
153
|
+
data-value={opt.value}
|
|
154
|
+
aria-checked={value === opt.value}
|
|
155
|
+
tabindex={value === opt.value ? 0 : -1}
|
|
156
|
+
onclick={() => onchange(spec.id, opt.value)}
|
|
157
|
+
onkeydown={(e) => onRadioKey(e, spec)}
|
|
158
|
+
>
|
|
159
|
+
{opt.label}
|
|
160
|
+
</button>
|
|
161
|
+
{/each}
|
|
162
|
+
</div>
|
|
163
|
+
{:else}
|
|
164
|
+
<input
|
|
165
|
+
type="range"
|
|
166
|
+
class="vp-set-range"
|
|
167
|
+
min={spec.min}
|
|
168
|
+
max={spec.max}
|
|
169
|
+
step={spec.step}
|
|
170
|
+
value={value}
|
|
171
|
+
aria-labelledby="{id}-label"
|
|
172
|
+
aria-describedby="{id}-hint"
|
|
173
|
+
aria-valuetext={formatSettingValue(spec, value)}
|
|
174
|
+
oninput={(e) => onchange(spec.id, Number((e.currentTarget as HTMLInputElement).value))}
|
|
175
|
+
/>
|
|
176
|
+
<span class="vp-set-value" aria-hidden="true">{formatSettingValue(spec, value)}</span>
|
|
177
|
+
{/if}
|
|
178
|
+
</div>
|
|
179
|
+
<p class="vp-set-hint" id="{id}-hint">{spec.hint}</p>
|
|
180
|
+
</div>
|
|
181
|
+
{/each}
|
|
182
|
+
</section>
|
|
183
|
+
{:else}
|
|
184
|
+
<p class="vp-set-empty">Nothing on this video can be adjusted yet — settings appear as the features they control arrive.</p>
|
|
185
|
+
{/each}
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div class="vp-set-foot">
|
|
189
|
+
<span>{persisted ? 'Your choices are remembered.' : 'Choices last while this player is open.'} Esc closes.</span>
|
|
190
|
+
{#if helpHref}
|
|
191
|
+
<a class="vp-set-help" href={helpHref}>How all of this works →</a>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<style>
|
|
198
|
+
.vp-set-backdrop {
|
|
199
|
+
position: absolute;
|
|
200
|
+
inset: 0;
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
padding: var(--jatui-space-lg, 12px);
|
|
205
|
+
background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
|
|
206
|
+
z-index: 11;
|
|
207
|
+
border-radius: inherit;
|
|
208
|
+
animation: vp-set-in var(--motion-quick, 150ms) var(--motion-glide, ease-out);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.vp-set {
|
|
212
|
+
width: min(28rem, 100%);
|
|
213
|
+
max-height: 100%;
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
background: var(--color-base-100);
|
|
217
|
+
color: var(--color-base-content);
|
|
218
|
+
border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
219
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
220
|
+
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
221
|
+
outline: none;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.vp-set-head {
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: space-between;
|
|
228
|
+
padding: var(--jatui-space-md, 6px) var(--jatui-space-lg, 12px);
|
|
229
|
+
border-bottom: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.vp-set-title {
|
|
233
|
+
margin: 0;
|
|
234
|
+
font-size: 0.85rem;
|
|
235
|
+
font-weight: 700;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.vp-set-close {
|
|
239
|
+
display: inline-flex;
|
|
240
|
+
padding: var(--jatui-space-xs, 2px);
|
|
241
|
+
background: none;
|
|
242
|
+
border: none;
|
|
243
|
+
color: inherit;
|
|
244
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
245
|
+
cursor: pointer;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.vp-set-body {
|
|
249
|
+
overflow-y: auto;
|
|
250
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px) var(--jatui-space-md, 6px);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.vp-set-group-title {
|
|
254
|
+
margin: var(--jatui-space-md, 6px) 0 var(--jatui-space-xs, 2px);
|
|
255
|
+
font-size: 0.66rem;
|
|
256
|
+
font-weight: 700;
|
|
257
|
+
text-transform: uppercase;
|
|
258
|
+
letter-spacing: 0.04em;
|
|
259
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.vp-set-row {
|
|
263
|
+
display: grid;
|
|
264
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
265
|
+
align-items: center;
|
|
266
|
+
gap: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
|
|
267
|
+
padding: var(--jatui-space-sm, 4px) 0;
|
|
268
|
+
border-top: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.vp-set-group-title + .vp-set-row {
|
|
272
|
+
border-top: none;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.vp-set-label {
|
|
276
|
+
font-size: 0.78rem;
|
|
277
|
+
font-weight: 600;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.vp-set-control {
|
|
281
|
+
display: flex;
|
|
282
|
+
align-items: center;
|
|
283
|
+
gap: var(--jatui-space-sm, 4px);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.vp-set-hint {
|
|
287
|
+
grid-column: 1 / -1;
|
|
288
|
+
margin: 0;
|
|
289
|
+
font-size: 0.7rem;
|
|
290
|
+
line-height: 1.4;
|
|
291
|
+
color: color-mix(in oklch, var(--color-base-content) 68%, transparent);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* Switch: the state reads in text AND position, never colour alone. */
|
|
295
|
+
.vp-set-switch {
|
|
296
|
+
display: inline-flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: var(--jatui-space-sm, 4px);
|
|
299
|
+
padding: 2px var(--jatui-space-sm, 4px) 2px 2px;
|
|
300
|
+
font: inherit;
|
|
301
|
+
font-size: 0.7rem;
|
|
302
|
+
color: inherit;
|
|
303
|
+
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
304
|
+
border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
|
|
305
|
+
border-radius: 999px;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.vp-set-knob {
|
|
310
|
+
width: 0.8rem;
|
|
311
|
+
height: 0.8rem;
|
|
312
|
+
border-radius: 999px;
|
|
313
|
+
background: color-mix(in oklch, var(--color-base-content) 45%, transparent);
|
|
314
|
+
transition: transform var(--motion-quick, 150ms) var(--motion-glide, ease-out);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.vp-set-switch[aria-checked='true'] {
|
|
318
|
+
background: color-mix(in oklch, var(--color-primary) 22%, transparent);
|
|
319
|
+
border-color: color-mix(in oklch, var(--color-primary) 55%, transparent);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.vp-set-switch[aria-checked='true'] .vp-set-knob {
|
|
323
|
+
background: var(--color-primary);
|
|
324
|
+
transform: translateX(0.15rem);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.vp-set-switch-text {
|
|
328
|
+
min-width: 1.6em;
|
|
329
|
+
text-align: left;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.vp-set-seg {
|
|
333
|
+
display: inline-flex;
|
|
334
|
+
border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
|
|
335
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.vp-set-seg button {
|
|
340
|
+
padding: 2px var(--jatui-space-md, 6px);
|
|
341
|
+
font: inherit;
|
|
342
|
+
font-size: 0.7rem;
|
|
343
|
+
color: inherit;
|
|
344
|
+
background: none;
|
|
345
|
+
border: none;
|
|
346
|
+
cursor: pointer;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.vp-set-seg button + button {
|
|
350
|
+
border-left: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 20%, transparent);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.vp-set-seg button[aria-checked='true'] {
|
|
354
|
+
background: color-mix(in oklch, var(--color-primary) 22%, transparent);
|
|
355
|
+
font-weight: 700;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.vp-set-range {
|
|
359
|
+
width: 7rem;
|
|
360
|
+
accent-color: var(--color-primary);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.vp-set-value {
|
|
364
|
+
min-width: 3.2em;
|
|
365
|
+
font-size: 0.7rem;
|
|
366
|
+
font-variant-numeric: tabular-nums;
|
|
367
|
+
text-align: right;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.vp-set-switch:focus-visible,
|
|
371
|
+
.vp-set-seg button:focus-visible,
|
|
372
|
+
.vp-set-range:focus-visible,
|
|
373
|
+
.vp-set-close:focus-visible,
|
|
374
|
+
.vp-set-help:focus-visible {
|
|
375
|
+
outline: 2px solid var(--color-primary);
|
|
376
|
+
outline-offset: 1px;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.vp-set-empty {
|
|
380
|
+
margin: var(--jatui-space-md, 6px) 0;
|
|
381
|
+
font-size: 0.75rem;
|
|
382
|
+
color: color-mix(in oklch, var(--color-base-content) 68%, transparent);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.vp-set-foot {
|
|
386
|
+
display: flex;
|
|
387
|
+
flex-wrap: wrap;
|
|
388
|
+
justify-content: space-between;
|
|
389
|
+
gap: var(--jatui-space-sm, 4px);
|
|
390
|
+
padding: var(--jatui-space-md, 6px) var(--jatui-space-lg, 12px);
|
|
391
|
+
font-size: 0.66rem;
|
|
392
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
393
|
+
border-top: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.vp-set-help {
|
|
397
|
+
color: var(--color-primary);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
@keyframes vp-set-in {
|
|
401
|
+
from {
|
|
402
|
+
opacity: 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
@media (prefers-reduced-motion: reduce) {
|
|
407
|
+
.vp-set-backdrop {
|
|
408
|
+
animation: none;
|
|
409
|
+
}
|
|
410
|
+
.vp-set-knob {
|
|
411
|
+
transition: none;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SettingValue } from './registry';
|
|
2
|
+
import { type SettingsPanelGroup } from './panel';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
groups: SettingsPanelGroup[];
|
|
5
|
+
/** Effective value per id — what the player is doing right now. */
|
|
6
|
+
values: Record<string, SettingValue>;
|
|
7
|
+
onchange: (id: string, value: SettingValue) => void;
|
|
8
|
+
onclose: () => void;
|
|
9
|
+
/** A host help page explaining all of this ("How all of this works →"). */
|
|
10
|
+
helpHref?: string | null;
|
|
11
|
+
/** The host supplied a store — the footer says choices are remembered. */
|
|
12
|
+
persisted?: boolean;
|
|
13
|
+
};
|
|
14
|
+
declare const SettingsPanel: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
|
+
type SettingsPanel = ReturnType<typeof SettingsPanel>;
|
|
16
|
+
export default SettingsPanel;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings panel's model (mos-5lzns.35) — pure, so what the panel shows
|
|
3
|
+
* is node-testable without a DOM. The component renders exactly what these
|
|
4
|
+
* return; nothing about which rows exist is decided in markup.
|
|
5
|
+
*/
|
|
6
|
+
import { type SettingAvailability, type SettingSpec, type SettingValue } from './registry';
|
|
7
|
+
export interface SettingsPanelGroup {
|
|
8
|
+
group: string;
|
|
9
|
+
rows: SettingSpec[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The rows this player can honestly offer, grouped in panel order. A row
|
|
13
|
+
* whose `when` is not true in `available` is omitted — never shown disabled:
|
|
14
|
+
* a switch for a feature this player doesn't have is noise, and a disabled
|
|
15
|
+
* one invites the question it can't answer.
|
|
16
|
+
*/
|
|
17
|
+
export declare function panelGroups(available: SettingAvailability, specs?: SettingSpec[]): SettingsPanelGroup[];
|
|
18
|
+
/** How a value reads in the panel (the range's readout, an enum's label, On/Off). */
|
|
19
|
+
export declare function formatSettingValue(spec: SettingSpec, value: SettingValue): string;
|
|
20
|
+
/**
|
|
21
|
+
* Radio-group arrow keys: the next option in `dir`, wrapping (APG radio
|
|
22
|
+
* pattern). An unknown current value starts from the first option.
|
|
23
|
+
*/
|
|
24
|
+
export declare function stepOption(spec: SettingSpec, current: SettingValue, dir: 1 | -1): string | undefined;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings panel's model (mos-5lzns.35) — pure, so what the panel shows
|
|
3
|
+
* is node-testable without a DOM. The component renders exactly what these
|
|
4
|
+
* return; nothing about which rows exist is decided in markup.
|
|
5
|
+
*/
|
|
6
|
+
import { SETTING_GROUPS, VIDEO_SETTINGS } from './registry';
|
|
7
|
+
/**
|
|
8
|
+
* The rows this player can honestly offer, grouped in panel order. A row
|
|
9
|
+
* whose `when` is not true in `available` is omitted — never shown disabled:
|
|
10
|
+
* a switch for a feature this player doesn't have is noise, and a disabled
|
|
11
|
+
* one invites the question it can't answer.
|
|
12
|
+
*/
|
|
13
|
+
export function panelGroups(available, specs = VIDEO_SETTINGS) {
|
|
14
|
+
const order = [...SETTING_GROUPS];
|
|
15
|
+
for (const s of specs)
|
|
16
|
+
if (!order.includes(s.group))
|
|
17
|
+
order.push(s.group);
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const group of order) {
|
|
20
|
+
const rows = specs.filter((s) => s.group === group && s.wired !== false && available[s.when] === true);
|
|
21
|
+
if (rows.length)
|
|
22
|
+
out.push({ group, rows });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** How a value reads in the panel (the range's readout, an enum's label, On/Off). */
|
|
27
|
+
export function formatSettingValue(spec, value) {
|
|
28
|
+
try {
|
|
29
|
+
if (spec.type === 'bool')
|
|
30
|
+
return value ? 'On' : 'Off';
|
|
31
|
+
if (spec.type === 'enum')
|
|
32
|
+
return spec.options?.find((o) => o.value === value)?.label ?? String(value);
|
|
33
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
34
|
+
if (!Number.isFinite(n))
|
|
35
|
+
return '';
|
|
36
|
+
// Round to the step so 0.30000000000000004 never reaches the screen.
|
|
37
|
+
const step = spec.step && spec.step > 0 ? spec.step : 0;
|
|
38
|
+
const decimals = step ? Math.max(0, Math.min(6, (String(step).split('.')[1] ?? '').length)) : 2;
|
|
39
|
+
const shown = Number(n.toFixed(decimals));
|
|
40
|
+
return spec.format ? spec.format(shown) : String(shown);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Radio-group arrow keys: the next option in `dir`, wrapping (APG radio
|
|
48
|
+
* pattern). An unknown current value starts from the first option.
|
|
49
|
+
*/
|
|
50
|
+
export function stepOption(spec, current, dir) {
|
|
51
|
+
const opts = spec.options ?? [];
|
|
52
|
+
if (!opts.length)
|
|
53
|
+
return undefined;
|
|
54
|
+
const i = opts.findIndex((o) => o.value === current);
|
|
55
|
+
if (i < 0)
|
|
56
|
+
return opts[0].value;
|
|
57
|
+
return opts[(i + dir + opts.length) % opts.length].value;
|
|
58
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings registry (mos-5lzns.35) — extension-library's `settings.js`
|
|
3
|
+
* ported: one array declares every standing CHOICE the player's automatic
|
|
4
|
+
* behaviours read, and the settings panel is generated from it, so a knob
|
|
5
|
+
* added here shows up in the UI by existing.
|
|
6
|
+
*
|
|
7
|
+
* Three rules carried over verbatim:
|
|
8
|
+
*
|
|
9
|
+
* · `coerce` never throws and never returns junk. A bool accepts
|
|
10
|
+
* true/"1"/"true" (and their false twins); a range parses and clamps to
|
|
11
|
+
* [min, max]; an enum must match a declared option. Anything else returns
|
|
12
|
+
* undefined and the caller falls back to the default. A corrupted blob in
|
|
13
|
+
* a host's store degrades to defaults, never to a broken player.
|
|
14
|
+
* · The hint is mandatory. The sentence saying what the thing actually does
|
|
15
|
+
* is the entire reason a settings screen exists rather than a list of
|
|
16
|
+
* switches; a spec without one fails the unit suite.
|
|
17
|
+
* · A knob lands WITH its reader. `wired: true` means the `prop` it names is
|
|
18
|
+
* read by VideoPlayer today — a switch that visibly does nothing is the
|
|
19
|
+
* one thing this registry must never contain. (That is why the extension's
|
|
20
|
+
* `night.page`, `night.sunset` and `player.smartSpeedBoost` are absent: the
|
|
21
|
+
* player has no reader for them.)
|
|
22
|
+
*
|
|
23
|
+
* Labels and hints here are the single source for the panel AND any help
|
|
24
|
+
* page a host renders (MOS's /help reads `VIDEO_SETTINGS` directly) — never
|
|
25
|
+
* restate them elsewhere.
|
|
26
|
+
*
|
|
27
|
+
* Ephemeral UI state (transcript layout, volume, whether a pane was left
|
|
28
|
+
* open) is deliberately NOT here: remembering those is not the same thing as
|
|
29
|
+
* being configurable, and mixing them makes a settings screen that reads
|
|
30
|
+
* like a debug dump.
|
|
31
|
+
*/
|
|
32
|
+
export type SettingType = 'bool' | 'range' | 'enum';
|
|
33
|
+
export type SettingValue = boolean | number | string;
|
|
34
|
+
/**
|
|
35
|
+
* When a row exists on a given player. Each names the host opt-in and/or
|
|
36
|
+
* data the feature needs — a row for a feature this player doesn't have is
|
|
37
|
+
* a switch that does nothing. The component resolves them (see
|
|
38
|
+
* `SettingAvailability`); the registry only names them.
|
|
39
|
+
*/
|
|
40
|
+
export type SettingWhen = 'waveform' | 'speakers' | 'ads' | 'loudness' | 'quiet' | 'limiter' | 'night' | 'nightLuma' | 'flashes' | 'windDown' | 'idle' | 'resume' | 'open';
|
|
41
|
+
export type SettingAvailability = Partial<Record<SettingWhen, boolean>>;
|
|
42
|
+
export interface SettingOption {
|
|
43
|
+
value: string;
|
|
44
|
+
label: string;
|
|
45
|
+
}
|
|
46
|
+
export interface SettingSpec {
|
|
47
|
+
/** Stable storage key — a host's store is keyed by this. Never rename. */
|
|
48
|
+
id: string;
|
|
49
|
+
type: SettingType;
|
|
50
|
+
default: SettingValue;
|
|
51
|
+
/** Panel section heading. Rows keep registry order within their group. */
|
|
52
|
+
group: string;
|
|
53
|
+
/** The row's name. Short, a noun phrase or an imperative. */
|
|
54
|
+
label: string;
|
|
55
|
+
/** What it actually does, in one or two sentences. Mandatory. */
|
|
56
|
+
hint: string;
|
|
57
|
+
/** The VideoPlayer prop this choice drives. */
|
|
58
|
+
prop: string;
|
|
59
|
+
/** Availability gate (see `SettingWhen`). */
|
|
60
|
+
when: SettingWhen;
|
|
61
|
+
/** range only */
|
|
62
|
+
min?: number;
|
|
63
|
+
max?: number;
|
|
64
|
+
step?: number;
|
|
65
|
+
/** range only: how the current value reads next to the slider. */
|
|
66
|
+
format?: (v: number) => string;
|
|
67
|
+
/** enum only */
|
|
68
|
+
options?: SettingOption[];
|
|
69
|
+
/** Which sub-task wired the reader. */
|
|
70
|
+
owner: string;
|
|
71
|
+
/** The reader exists (grep VideoPlayer for `prop`). */
|
|
72
|
+
wired?: boolean;
|
|
73
|
+
}
|
|
74
|
+
/** Groups in panel order. */
|
|
75
|
+
export declare const SETTING_GROUPS: readonly ["Night", "Sound", "Wind-down", "Playback"];
|
|
76
|
+
export declare const VIDEO_SETTINGS: SettingSpec[];
|
|
77
|
+
/** The spec for an id, or undefined. */
|
|
78
|
+
export declare function settingSpec(id: string): SettingSpec | undefined;
|
|
79
|
+
/** Coerce a raw stored value against a spec — never throws, never returns junk. */
|
|
80
|
+
export declare function coerce<T extends SettingSpec>(spec: T, raw: unknown): T['default'] | undefined;
|
|
81
|
+
/** A stored value for an id, coerced; the spec's default when absent or junk. */
|
|
82
|
+
export declare function settingValue(id: string, raw: unknown): SettingValue | undefined;
|