@joewinke/jatui 0.11.0 → 0.13.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.
@@ -13,6 +13,10 @@
13
13
  export type { BandAxis, BandState } from '../video/band/axis';
14
14
  export type { VideoLumaTrack } from '../video/band/luma';
15
15
  export type { VideoMarker } from '../video/band/markers';
16
+ // mos-5lzns.33 — night dimming.
17
+ export type { NightMode, NightWindow } from '../video/night/clock';
18
+ // mos-5lzns.31 — audio graph.
19
+ export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
16
20
  </script>
17
21
 
18
22
  <script lang="ts">
@@ -135,6 +139,33 @@
135
139
  import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
136
140
  import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
137
141
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
142
+ import {
143
+ biasCells,
144
+ biasGradient,
145
+ frameLuma,
146
+ METER_START,
147
+ NIGHT_H,
148
+ NIGHT_W,
149
+ smoothMeter
150
+ } from '../video/night/meter';
151
+ import { NIGHT_DEPTH_DEFAULT, clampDepth, nudgeDepth, relativeLuminance, surroundLevel } from '../video/night/levels';
152
+ import { nextNightMode, nightArmed, nightModeLabel, type NightMode, type NightWindow } from '../video/night/clock';
153
+ import {
154
+ composeNight,
155
+ inFlash,
156
+ lumaAhead,
157
+ NIGHT_IDLE,
158
+ normalizeFlashes,
159
+ type NightFrame,
160
+ type NightState
161
+ } from '../video/night/track';
162
+ import {
163
+ createAudioSession,
164
+ routeSafety,
165
+ AUDIO_TARGET_DEFAULT,
166
+ type AudioBrokenReason,
167
+ type AudioState
168
+ } from '../video/audio';
138
169
 
139
170
  let {
140
171
  src,
@@ -184,6 +215,20 @@
184
215
  onTimeUpdate = undefined,
185
216
  onAddBookmark = undefined,
186
217
  onBookmarkContext = undefined,
218
+ night = $bindable(),
219
+ nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
220
+ nightWindow = null,
221
+ biasLight = true,
222
+ predictive = true,
223
+ strobeGuard = true,
224
+ onNightModeChange = undefined,
225
+ onNightBlind = undefined,
226
+ loudness = undefined,
227
+ loudnessTarget = AUDIO_TARGET_DEFAULT,
228
+ quietListening = undefined,
229
+ limiter = undefined,
230
+ onAudioState = undefined,
231
+ onAudioBroken = undefined,
187
232
  class: className = ''
188
233
  }: {
189
234
  src: string;
@@ -281,6 +326,85 @@
281
326
  clientX: number,
282
327
  clientY: number
283
328
  ) => void;
329
+ /**
330
+ * Night dimming (mos-5lzns.33) — a METER, not a preference: ~1×/s while
331
+ * playing it reads the frame's actual brightness (32×18 canvas) and pulls
332
+ * the picture down in proportion, warming it through multiply layers so
333
+ * blacks stay black. A dark scene passes through untouched.
334
+ *
335
+ * `'auto'` arms it from the local clock (20:00–07:00; 18:00–08:00 when the
336
+ * OS prefers dark), `'on'` always, `'off'` never. Omitted (default) ⇒ the
337
+ * feature does not exist on this player: no N key, no button, no layers.
338
+ * Bindable — `N` and the moon button cycle it; `onNightModeChange` reports.
339
+ *
340
+ * Frame reading needs a CORS-clean load: pass `crossorigin="anonymous"`
341
+ * for cross-origin media. A tainted canvas runs night on a fixed level
342
+ * (no meter, no bias light) and calls `onNightBlind`.
343
+ */
344
+ night?: NightMode;
345
+ /** How far night goes, 0.2–1 (default 0.7). Bindable — scrolling the moon button sets it. */
346
+ nightDepth?: number;
347
+ /** Override the auto window, local hours `{ from, to }` (wraps midnight when from > to). */
348
+ nightWindow?: NightWindow | null;
349
+ /** While night acts, spill the frame's own colours, blurred, behind the player. */
350
+ biasLight?: boolean;
351
+ /** Read the `luma` track ~1s ahead so dimming starts BEFORE a bright cut. Only ever adds dimming. */
352
+ predictive?: boolean;
353
+ /**
354
+ * Photosensitivity guard: across the `luma` track's flash intervals,
355
+ * drop the picture to about 55% brightness (instant in, released per
356
+ * tick — never a jump back up). Always at full strength: it does NOT
357
+ * follow `nightDepth`, and it acts whether night is on, off or absent,
358
+ * whenever the track carries `flashes`. `false` to disable.
359
+ *
360
+ * It REDUCES flashes; it does not make content WCAG 2.3.1-safe. Flash
361
+ * DETECTION is the host's job — the player guards only the intervals the
362
+ * host supplies in `luma.flashes`.
363
+ */
364
+ strobeGuard?: boolean;
365
+ onNightModeChange?: (mode: NightMode) => void;
366
+ /** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
367
+ onNightBlind?: () => void;
368
+ /**
369
+ * Audio graph (mos-5lzns.31) — all three default OFF (absent = the
370
+ * feature does not exist on this player and nothing is ever routed).
371
+ *
372
+ * `loudness`: a gated RMS meter rides the gain slowly toward
373
+ * `loudnessTarget` (dBFS, −30…−12, default −20) so a whispered video and
374
+ * a blaring one arrive at the same level; clamped 0.3–1.35×, it rescues a
375
+ * quiet mix by a few dB and never becomes an amplifier.
376
+ * `quietListening`: loudness-compensated tone that rises as the volume
377
+ * drops (and sits a little on while night is armed).
378
+ * `limiter`: catches peaks above −3 dBFS that the slow rider can't.
379
+ *
380
+ * ⚠ Routing into WebAudio is permanent for the element, and a source
381
+ * that isn't CORS-clean routes to SILENCE. So the player routes only
382
+ * when the src is blob:/data: or `crossorigin` is set ("anonymous" /
383
+ * "use-credentials", with the server answering Access-Control-Allow-
384
+ * Origin) — a same-origin URL is NOT enough, since it may redirect to
385
+ * storage on another origin. Anything else is refused and plays
386
+ * unprocessed (`status: 'cors'`). It also waits for the AudioContext to
387
+ * be `running` (a user gesture); until then native audio plays.
388
+ *
389
+ * After routing it cannot be undone, so the player reports
390
+ * `status: 'broken'` and calls `onAudioBroken(reason)`:
391
+ * · 'unsafe-src' — the src later changed to one that isn't route-safe
392
+ * (or `crossorigin` was removed). Remount the player (a fresh element)
393
+ * for that source.
394
+ * · 'silent' — bit-exact silence for 4s while the video plays audibly
395
+ * AND the browser shows decoded audio flowing (a video with no audio
396
+ * track never fires). Show a notice and/or turn the audio features
397
+ * off for that src — do NOT remount; it would loop.
398
+ * · 'build-failed' — construction threw: plain audio for the session.
399
+ */
400
+ loudness?: boolean;
401
+ loudnessTarget?: number;
402
+ quietListening?: boolean;
403
+ limiter?: boolean;
404
+ /** Graph status + the rider's live gain (≤5×/s while matching acts). */
405
+ onAudioState?: (state: AudioState) => void;
406
+ /** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
407
+ onAudioBroken?: (reason: AudioBrokenReason) => void;
284
408
  class?: string;
285
409
  } = $props();
286
410
 
@@ -292,6 +416,8 @@
292
416
  // Autoplay forces muted — browsers refuse to autoplay audible video, and a
293
417
  // silent presentation clip is the point of the primitive anyway.
294
418
  const effectiveAutoplay = $derived(autoplay && !reduceMotion);
419
+ // The stage's locked ratio (null ⇒ the video's intrinsic ratio drives the box).
420
+ const stageAspect = $derived(aspectRatioCss(aspect));
295
421
  // `(pointer: coarse)` — a fingertip gets a wider comment-dot target.
296
422
  let coarsePointer = $state(false);
297
423
 
@@ -478,6 +604,7 @@
478
604
  if (row.when === 'bookmarks') return onAddBookmark != null;
479
605
  if (row.when === 'transcript') return sortedSegments.length > 0;
480
606
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
607
+ if (row.when === 'night') return nightPresent;
481
608
  return true;
482
609
  })
483
610
  );
@@ -548,6 +675,276 @@
548
675
  flashOsd(formatSpeedLabel(baseRate));
549
676
  }
550
677
 
678
+ // ── Audio (video/audio/*) ───────────────────────────────────────────────
679
+ // The session owns the graph and its one-way door; this block only hands
680
+ // it the element (inside `route`) and re-applies on every input it reads.
681
+ // Inert until a feature is on: no context, no routing. Gestures anywhere
682
+ // on the page resume a suspended context and retry a pending route.
683
+ let audio: ReturnType<typeof createAudioSession> | null = null;
684
+ $effect(() => {
685
+ const v = videoEl;
686
+ if (!v) return;
687
+ const s = createAudioSession({
688
+ // The attribute is what will load next; currentSrc lags it until loadstart.
689
+ routable: () => routeSafety(v.getAttribute('src') || v.currentSrc, v.getAttribute('crossorigin')),
690
+ context: () => new AudioContext(),
691
+ route: (ctx) => (ctx as AudioContext).createMediaElementSource(v),
692
+ playback: () => {
693
+ const m = v as HTMLVideoElement & {
694
+ webkitAudioDecodedByteCount?: number;
695
+ mozHasAudio?: boolean;
696
+ audioTracks?: { length: number };
697
+ };
698
+ return {
699
+ playing: !v.paused && !v.ended,
700
+ hidden: document.hidden,
701
+ audible: !v.muted && v.volume > 0,
702
+ time: v.currentTime,
703
+ audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
704
+ audioPresent:
705
+ typeof m.mozHasAudio === 'boolean' ? m.mozHasAudio : m.audioTracks ? m.audioTracks.length > 0 : null
706
+ };
707
+ },
708
+ onState: (st) => onAudioState?.(st),
709
+ onBroken: (reason) => onAudioBroken?.(reason)
710
+ });
711
+ audio = s;
712
+ const sync = () => untrack(() => s.apply(audioOpts(v)));
713
+ const gesture = () => s.gesture();
714
+ const media = ['volumechange', 'loadstart', 'loadedmetadata'];
715
+ const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
716
+ for (const e of media) v.addEventListener(e, sync);
717
+ v.addEventListener('play', gesture);
718
+ for (const e of gestures) window.addEventListener(e, gesture, { capture: true, passive: true });
719
+ return () => {
720
+ for (const e of media) v.removeEventListener(e, sync);
721
+ v.removeEventListener('play', gesture);
722
+ for (const e of gestures) window.removeEventListener(e, gesture, { capture: true });
723
+ s.dispose();
724
+ audio = null;
725
+ };
726
+ });
727
+ function audioOpts(v: HTMLVideoElement) {
728
+ return {
729
+ level: loudness === true,
730
+ target: loudnessTarget,
731
+ limiter: limiter === true,
732
+ quiet: quietListening === true,
733
+ volume: v.volume,
734
+ muted: v.muted,
735
+ night: nightArmedNow
736
+ };
737
+ }
738
+ $effect(() => {
739
+ const v = videoEl;
740
+ void crossorigin; // a CORS opt-in clears a 'cors' refusal; removing it after routing is 'unsafe-src'
741
+ void src;
742
+ const o = v ? audioOpts(v) : null;
743
+ if (o) untrack(() => audio?.apply(o));
744
+ });
745
+
746
+ // ── Night (video/night/*) ───────────────────────────────────────────────
747
+ // The modules decide; this block samples the frame and applies the answer
748
+ // as CSS variables that exist ONLY while night (or the strobe guard) is
749
+ // acting — off leaves no trace. The meter canvas and the running levels
750
+ // are plain (non-reactive): written and read inside one tick.
751
+ const NIGHT_TICK_MS = 900;
752
+ /** Bias light refreshes at most this often — peripheral light, not a second picture. */
753
+ const BIAS_EVERY_MS = 1800;
754
+ const nightPresent = $derived(night !== undefined);
755
+ const nightFlashes = $derived(normalizeFlashes(luma));
756
+ const guardPresent = $derived(strobeGuard && nightFlashes.length > 0);
757
+ let prefersDark = $state(false);
758
+ let nightArmedNow = $state(false);
759
+ let nightBlind = $state(false);
760
+ let nightFrame = $state<NightFrame | null>(null);
761
+ let biasImage = $state('');
762
+ let surroundLuma = $state(0);
763
+ let nightMeter = METER_START;
764
+ /** The composer's running state — night's level and the guard's, released independently. */
765
+ let nightOut: NightState = { ...NIGHT_IDLE };
766
+ let biasAt = 0;
767
+ let meterCtx: CanvasRenderingContext2D | null = null;
768
+
769
+ /** 32×18 RGBA bytes of the current frame, or null (no frame yet / canvas tainted). */
770
+ function sampleFrame(v: HTMLVideoElement): Uint8ClampedArray | null {
771
+ if (v.readyState < 2 || !v.videoWidth || !v.videoHeight) return null;
772
+ if (!meterCtx) {
773
+ const c = document.createElement('canvas');
774
+ c.width = NIGHT_W;
775
+ c.height = NIGHT_H;
776
+ meterCtx = c.getContext('2d', { willReadFrequently: true });
777
+ if (!meterCtx) return null;
778
+ }
779
+ try {
780
+ meterCtx.drawImage(v, 0, 0, NIGHT_W, NIGHT_H);
781
+ return meterCtx.getImageData(0, 0, NIGHT_W, NIGHT_H).data;
782
+ } catch (err) {
783
+ // Only a SecurityError means the canvas is tainted (cross-origin
784
+ // without CORS). Anything else — a decoder hiccup, a frame mid-swap —
785
+ // is transient: skip this sample and try again next tick.
786
+ if (!(err instanceof DOMException && err.name === 'SecurityError')) return null;
787
+ // Tainted: escalate to the host (it owns `crossorigin`) and run blind
788
+ // on a fixed level — never guess brightness. Bias light has nothing to
789
+ // sample, so it stands down.
790
+ nightBlind = true;
791
+ biasImage = '';
792
+ onNightBlind?.();
793
+ return null;
794
+ }
795
+ }
796
+
797
+ function nightTick() {
798
+ const v = videoEl;
799
+ if (!v) return;
800
+ const armed = night !== undefined && nightArmed(night, new Date(), prefersDark, nightWindow);
801
+ if (nightArmedNow !== armed) nightArmedNow = armed;
802
+ const hidden = typeof document !== 'undefined' && document.hidden;
803
+ const t = v.currentTime || 0;
804
+ // One tick's worth of media time: prediction and the guard look this
805
+ // much further ahead, so a coarse tick can never let the first flash through.
806
+ const horizon = (NIGHT_TICK_MS / 1000) * (v.playbackRate > 0 ? v.playbackRate : 1);
807
+ const flash = strobeGuard && inFlash(nightFlashes, t, horizon);
808
+ if (armed && !nightBlind && !v.paused && !hidden) {
809
+ const px = sampleFrame(v);
810
+ if (px) {
811
+ nightMeter = smoothMeter(nightMeter, frameLuma(px));
812
+ // The bias light reads the bytes the meter JUST drew — any other
813
+ // time the canvas is stale or empty, and empty reads as pitch black.
814
+ // Frozen while the guard is down: the glow's background-image swap
815
+ // is un-faded, and a strobe would drive it at the strobe's own rate.
816
+ if (biasLight && !flash && Date.now() - biasAt > BIAS_EVERY_MS) {
817
+ const g = biasGradient(biasCells(px));
818
+ if (g) {
819
+ biasImage = g;
820
+ biasAt = Date.now();
821
+ }
822
+ }
823
+ }
824
+ }
825
+ const f = composeNight(nightOut, {
826
+ armed,
827
+ blind: nightBlind,
828
+ meter: nightMeter,
829
+ ahead: armed && predictive ? lumaAhead(lumaSeries, t, horizon) : null,
830
+ flash,
831
+ depth: nightDepth
832
+ });
833
+ nightOut = { luma: f.luma, guardLuma: f.guardLuma };
834
+ nightFrame = f.mode === 'none' ? null : f;
835
+ }
836
+
837
+ /**
838
+ * Drop night's traces at once. The guard's level is kept unless `all`:
839
+ * switching night off is a statement about night, and must not cut short a
840
+ * guard that is still releasing (that would be a jump back up).
841
+ */
842
+ function nightClear(all = false) {
843
+ nightOut = { luma: 0, guardLuma: all ? 0 : nightOut.guardLuma };
844
+ nightFrame = null;
845
+ biasImage = '';
846
+ biasAt = 0;
847
+ }
848
+
849
+ // The tick runs only while the feature exists on this player (night opted
850
+ // in, or a track with flashes for the guard). Visibility changes re-tick at
851
+ // once so a returning tab doesn't wait out the interval.
852
+ $effect(() => {
853
+ const on = nightPresent || guardPresent;
854
+ const v = videoEl;
855
+ if (!on || !v) return;
856
+ const id = setInterval(nightTick, NIGHT_TICK_MS);
857
+ const onVis = () => nightTick();
858
+ document.addEventListener('visibilitychange', onVis);
859
+ return () => {
860
+ clearInterval(id);
861
+ document.removeEventListener('visibilitychange', onVis);
862
+ untrack(() => nightClear(true));
863
+ };
864
+ });
865
+
866
+ // A new source (or CORS mode) is a new picture: forget the meter, the
867
+ // blind verdict and the glow.
868
+ $effect(() => {
869
+ void src;
870
+ void crossorigin;
871
+ untrack(() => {
872
+ nightBlind = false;
873
+ nightMeter = METER_START;
874
+ nightClear(true);
875
+ });
876
+ });
877
+
878
+ // Knob changes re-evaluate now. Switching night off clears at once rather
879
+ // than releasing tick by tick — off is a statement, not a fade.
880
+ $effect(() => {
881
+ const mode = night;
882
+ void nightDepth;
883
+ void nightWindow;
884
+ void predictive;
885
+ void strobeGuard;
886
+ void biasLight;
887
+ void prefersDark;
888
+ void videoEl;
889
+ untrack(() => {
890
+ if (mode === 'off' || mode === undefined) nightClear();
891
+ else if (!biasLight) biasImage = '';
892
+ nightTick();
893
+ });
894
+ });
895
+
896
+ // The theme's surround (base-200) as relative luminance: on a light theme
897
+ // the surround is pulled down with the picture (levels.ts surroundLevel).
898
+ $effect(() => {
899
+ void themeTick;
900
+ if (!nightPresent) return;
901
+ untrack(() => {
902
+ surroundLuma = colorLuminance(getDaisyUIColor('base-200'));
903
+ });
904
+ });
905
+
906
+ /** Any CSS colour → relative luminance, via a 1×1 same-origin canvas (never tainted). */
907
+ function colorLuminance(color: string): number {
908
+ if (typeof document === 'undefined') return 0;
909
+ try {
910
+ const c = document.createElement('canvas');
911
+ c.width = c.height = 1;
912
+ const ctx = c.getContext('2d', { willReadFrequently: true });
913
+ if (!ctx) return 0;
914
+ ctx.fillStyle = color; // unparseable ⇒ stays the default black ⇒ 0 ⇒ surround untouched
915
+ ctx.fillRect(0, 0, 1, 1);
916
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
917
+ return relativeLuminance(r, g, b);
918
+ } catch {
919
+ return 0;
920
+ }
921
+ }
922
+
923
+ const nightLayersOn = $derived(nightFrame?.mode === 'night');
924
+ const biasOn = $derived(nightLayersOn && biasLight && !nightBlind && biasImage !== '');
925
+ const surroundMix = $derived(nightLayersOn ? surroundLevel(surroundLuma, nightDepth) : 0);
926
+ const nightLabel = $derived(nightModeLabel(night, nightArmedNow));
927
+
928
+ function setNightMode(mode: NightMode) {
929
+ night = mode;
930
+ onNightModeChange?.(mode);
931
+ }
932
+ function cycleNight() {
933
+ if (night === undefined) return;
934
+ const next = nextNightMode(night);
935
+ setNightMode(next);
936
+ flashOsd(nightModeLabel(next, nightArmed(next, new Date(), prefersDark, nightWindow)));
937
+ }
938
+ /** Scroll the moon: the depth dial. Reaching for it means you want to see it work, whatever the clock says. */
939
+ function onNightWheel(e: WheelEvent) {
940
+ if (night === undefined) return;
941
+ e.preventDefault();
942
+ e.stopPropagation();
943
+ if (!nightArmedNow) setNightMode('on');
944
+ nightDepth = nudgeDepth(nightDepth, e.deltaY < 0 ? 1 : -1);
945
+ flashOsd(`Night ${Math.round(clampDepth(nightDepth) * 100)}%`);
946
+ }
947
+
551
948
  // ── Canvas ──────────────────────────────────────────────────────────────
552
949
  // Repaint on any input the canvas reads. Deps are listed explicitly and the
553
950
  // body is untracked — letting paintState's own reads register as deps is
@@ -628,6 +1025,15 @@
628
1025
  mq.addEventListener('change', onChange);
629
1026
  stopReduce = () => mq.removeEventListener('change', onChange);
630
1027
  }
1028
+ let stopDark: (() => void) | undefined;
1029
+ if (typeof window !== 'undefined' && window.matchMedia) {
1030
+ // Only ever WIDENS night's auto window (night/clock.ts) — never the trigger.
1031
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
1032
+ prefersDark = mq.matches;
1033
+ const onChange = () => (prefersDark = mq.matches);
1034
+ mq.addEventListener('change', onChange);
1035
+ stopDark = () => mq.removeEventListener('change', onChange);
1036
+ }
631
1037
  let stopCoarse: (() => void) | undefined;
632
1038
  if (typeof window !== 'undefined' && window.matchMedia) {
633
1039
  const mq = window.matchMedia('(pointer: coarse)');
@@ -641,6 +1047,7 @@
641
1047
  stopTheme();
642
1048
  stopReduce?.();
643
1049
  stopCoarse?.();
1050
+ stopDark?.();
644
1051
  if (dragRaf) cancelAnimationFrame(dragRaf);
645
1052
  if (zoomTimer) clearTimeout(zoomTimer);
646
1053
  if (osdTimer) clearTimeout(osdTimer);
@@ -1060,6 +1467,13 @@
1060
1467
  flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
1061
1468
  }
1062
1469
  return;
1470
+ case 'n':
1471
+ case 'N':
1472
+ if (night !== undefined) {
1473
+ e.preventDefault();
1474
+ cycleNight();
1475
+ }
1476
+ return;
1063
1477
  case 'Home':
1064
1478
  seekTo(0);
1065
1479
  break;
@@ -1155,6 +1569,9 @@
1155
1569
  class="vp-root {className}"
1156
1570
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
1157
1571
  class:vp-one-axis={nativeBarHidden}
1572
+ class:vp-night-capable={nightPresent}
1573
+ class:vp-night-surround={surroundMix > 0.001}
1574
+ style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
1158
1575
  data-band-axis={currentBandState.axis}
1159
1576
  role="region"
1160
1577
  aria-label="Video player"
@@ -1162,11 +1579,30 @@
1162
1579
  onkeydown={onRootKeyDown}
1163
1580
  >
1164
1581
  <div class="vp-main">
1582
+ <div class="vp-stage-wrap">
1583
+ {#if nightPresent}
1584
+ <!-- Bias light: the frame's own colours, blurred, spilled behind the
1585
+ picture. Earlier in the DOM than the (positioned) stage, so the
1586
+ stage paints over it with no z-index and no stacking context. -->
1587
+ <div
1588
+ class="vp-bias"
1589
+ class:on={biasOn}
1590
+ style:--vp-bias-image={biasOn ? biasImage : undefined}
1591
+ aria-hidden="true"
1592
+ ></div>
1593
+ {/if}
1165
1594
  <!-- svelte-ignore a11y_no_static_element_interactions -->
1166
1595
  <div
1167
1596
  class="vp-stage"
1168
- class:vp-stage-locked={aspectRatioCss != null}
1169
- style={aspectRatioCss != null ? `--vp-aspect: ${aspectRatioCss};` : undefined}
1597
+ class:vp-stage-locked={stageAspect != null}
1598
+ class:vp-night-capable={nightPresent || guardPresent}
1599
+ class:vp-night-on={nightFrame != null}
1600
+ class:vp-flash-guard={nightFrame?.guard === true}
1601
+ style={stageAspect != null ? `--vp-aspect: ${stageAspect};` : undefined}
1602
+ style:--vp-night-bright={nightFrame ? nightFrame.levels.bright.toFixed(3) : undefined}
1603
+ style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
1604
+ style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
1605
+ style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
1170
1606
  onwheel={onStageWheel}
1171
1607
  >
1172
1608
  <video
@@ -1175,7 +1611,13 @@
1175
1611
  bind:duration
1176
1612
  ontimeupdate={handleTimeUpdate}
1177
1613
  onloadedmetadata={onLoadedMetadata}
1178
- onseeked={syncUrlNow}
1614
+ onseeked={() => {
1615
+ syncUrlNow();
1616
+ if (nightPresent || guardPresent) nightTick();
1617
+ }}
1618
+ onplay={() => {
1619
+ if (nightPresent || guardPresent) nightTick();
1620
+ }}
1179
1621
  onpause={syncUrlNow}
1180
1622
  {src}
1181
1623
  {loop}
@@ -1194,8 +1636,16 @@
1194
1636
  menu but isn't painted on top of the frames' own text (jat-abwim). -->
1195
1637
  <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
1196
1638
  </video>
1639
+ {#if nightPresent}
1640
+ <!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
1641
+ point and fog the picture; multiplying leaves blacks black. At
1642
+ opacity 0 (their variables unset) they change no pixel. -->
1643
+ <div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
1644
+ <div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
1645
+ {/if}
1197
1646
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
1198
1647
  </div>
1648
+ </div>
1199
1649
 
1200
1650
  <!-- The band: one time axis for the envelope, chapters, speakers and
1201
1651
  bookmarks. Its height is reserved from the first frame, so data
@@ -1314,11 +1764,31 @@
1314
1764
 
1315
1765
  <div class="vp-times">
1316
1766
  <span>{formatTime(currentTime, useHours)}</span>
1317
- {#if isZoomedNow()}
1318
- <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1319
- Reset zoom
1320
- </button>
1321
- {/if}
1767
+ <span class="vp-times-mid">
1768
+ {#if isZoomedNow()}
1769
+ <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1770
+ Reset zoom
1771
+ </button>
1772
+ {/if}
1773
+ {#if nightPresent}
1774
+ <!-- The veil's switch: click (or N) cycles auto → on → off; scroll
1775
+ sets the depth. Lit in the ribbon's warm token while dimming. -->
1776
+ <button
1777
+ type="button"
1778
+ class="vp-night-btn"
1779
+ class:active={nightLayersOn}
1780
+ aria-label={nightLabel}
1781
+ title="{nightLabel} · N cycles, scroll sets depth"
1782
+ onclick={cycleNight}
1783
+ onwheel={onNightWheel}
1784
+ >
1785
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1786
+ <path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" />
1787
+ {#if night === 'off'}<path d="M4 20 20 4" />{/if}
1788
+ </svg>
1789
+ </button>
1790
+ {/if}
1791
+ </span>
1322
1792
  <span>{formatTime(effectiveDuration, useHours)}</span>
1323
1793
  </div>
1324
1794
 
@@ -2053,4 +2523,153 @@
2053
2523
  transition: none;
2054
2524
  }
2055
2525
  }
2526
+
2527
+ /* ── Night (mos-5lzns.33) ─────────────────────────────────────────────
2528
+ Every value arrives as a custom property that exists ONLY while night
2529
+ (or the strobe guard) acts; unset, each layer falls back to identity —
2530
+ brightness(1), opacity 0 — so off changes no pixel. The long ramps are
2531
+ the point: the eye must never catch the change, only notice afterwards
2532
+ that the room got easier. */
2533
+
2534
+ /* The transition lives on the video whenever the feature exists and the
2535
+ filter only under .vp-night-on, so switching off eases back up the way
2536
+ it came down (a rule carrying both would snap on the way out). */
2537
+ .vp-night-capable .vp-video {
2538
+ transition: filter 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2539
+ }
2540
+
2541
+ .vp-night-on .vp-video {
2542
+ filter: brightness(var(--vp-night-bright, 1)) saturate(var(--vp-night-sat, 1));
2543
+ }
2544
+
2545
+ .vp-night-layer {
2546
+ position: absolute;
2547
+ inset: 0;
2548
+ pointer-events: none;
2549
+ border-radius: inherit;
2550
+ mix-blend-mode: multiply;
2551
+ opacity: 0;
2552
+ transition: opacity 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2553
+ }
2554
+
2555
+ /* Warmth: multiplying by the warm token (the ribbon's own colour, so the
2556
+ ribbon reads as "how much night is coming") leaves red intact and takes
2557
+ most of the blue — the night-shift lever, applied per pixel. */
2558
+ .vp-night-warm {
2559
+ background: var(--color-warning);
2560
+ opacity: var(--vp-night-warm, 0);
2561
+ }
2562
+
2563
+ /* A lens falloff, off-centre high where a face usually sits; nothing until
2564
+ 42% out, so the subject never darkens. */
2565
+ .vp-night-vignette {
2566
+ background: radial-gradient(
2567
+ ellipse 82% 86% at 50% 44%,
2568
+ transparent 42%,
2569
+ color-mix(in oklch, var(--color-neutral) 55%, transparent) 100%
2570
+ );
2571
+ opacity: var(--vp-night-vig, 0);
2572
+ }
2573
+
2574
+ /* Strobe guard: protection has to arrive before the flash, so this one
2575
+ moment is fast. Everything else about night is deliberately slow. */
2576
+ .vp-flash-guard .vp-video {
2577
+ transition-duration: 120ms;
2578
+ }
2579
+
2580
+ .vp-flash-guard .vp-night-layer {
2581
+ transition-duration: 160ms;
2582
+ }
2583
+
2584
+ .vp-stage-wrap {
2585
+ position: relative;
2586
+ }
2587
+
2588
+ /* Bias light: peripheral light, so the blur is large on purpose — any
2589
+ detail in it reads as a distracting second image. */
2590
+ .vp-bias {
2591
+ position: absolute;
2592
+ inset: -6% -4%;
2593
+ pointer-events: none;
2594
+ background-image: var(--vp-bias-image, none);
2595
+ filter: blur(46px) saturate(1.15);
2596
+ opacity: 0;
2597
+ transition: opacity 1200ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2598
+ }
2599
+
2600
+ .vp-bias.on {
2601
+ opacity: 0.5;
2602
+ }
2603
+
2604
+ /* The surround. On a LIGHT theme a dimmed picture in a white frame makes
2605
+ the screen-to-surround ratio worse, so the player's own surface is pulled
2606
+ toward neutral with it (levels.ts surroundLevel — zero on dark themes,
2607
+ capped well short of dark because text sits here). */
2608
+ .vp-root.vp-night-capable {
2609
+ transition: background-color 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
2610
+ }
2611
+
2612
+ .vp-root.vp-night-surround {
2613
+ background-color: color-mix(
2614
+ in oklch,
2615
+ var(--color-base-200),
2616
+ var(--color-neutral) calc(var(--vp-night-surround, 0) * 100%)
2617
+ );
2618
+ }
2619
+
2620
+ .vp-times-mid {
2621
+ display: inline-flex;
2622
+ align-items: center;
2623
+ gap: var(--jatui-space-base, 8px);
2624
+ }
2625
+
2626
+ .vp-night-btn {
2627
+ display: inline-flex;
2628
+ align-items: center;
2629
+ justify-content: center;
2630
+ background: none;
2631
+ border: none;
2632
+ padding: var(--jatui-space-xs, 2px);
2633
+ border-radius: var(--jatui-radius-field, 0.25rem);
2634
+ color: inherit;
2635
+ cursor: pointer;
2636
+ transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
2637
+ }
2638
+
2639
+ .vp-night-btn:hover {
2640
+ color: var(--color-base-content);
2641
+ }
2642
+
2643
+ .vp-night-btn:focus-visible {
2644
+ outline: 2px solid var(--color-primary);
2645
+ outline-offset: 1px;
2646
+ }
2647
+
2648
+ /* Lit in the veil's own warm token while dimming is actually happening. */
2649
+ .vp-night-btn.active {
2650
+ color: var(--color-warning);
2651
+ }
2652
+
2653
+ /* Under reduced motion the veil still dims — the dimming IS the feature —
2654
+ but the long 900ms/1200ms ramps shrink to a short luminance fade. A
2655
+ change in brightness is not motion, and a hard step every tick would be
2656
+ harsher on the eye than the fade it replaced. The strobe guard keeps its
2657
+ own faster ramp. (The composer also releases brightness a fraction per
2658
+ tick, so the end of a dim is never a jump back up.) */
2659
+ @media (prefers-reduced-motion: reduce) {
2660
+ .vp-night-capable .vp-video,
2661
+ .vp-night-layer,
2662
+ .vp-bias,
2663
+ .vp-root.vp-night-capable {
2664
+ transition-duration: 150ms;
2665
+ }
2666
+
2667
+ .vp-flash-guard .vp-video {
2668
+ transition-duration: 120ms;
2669
+ }
2670
+
2671
+ .vp-night-btn {
2672
+ transition: none;
2673
+ }
2674
+ }
2056
2675
  </style>