@joewinke/jatui 0.11.0 → 0.12.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/VideoPlayer.svelte +504 -8
- package/dist/components/VideoPlayer.svelte.d.ts +42 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +1 -1
- package/dist/video/keys.d.ts +4 -4
- package/dist/video/keys.js +4 -3
- package/dist/video/night/clock.d.ts +31 -0
- package/dist/video/night/clock.js +58 -0
- package/dist/video/night/levels.d.ts +78 -0
- package/dist/video/night/levels.js +117 -0
- package/dist/video/night/meter.d.ts +53 -0
- package/dist/video/night/meter.js +115 -0
- package/dist/video/night/track.d.ts +105 -0
- package/dist/video/night/track.js +138 -0
- package/dist/video/night.d.ts +25 -20
- package/dist/video/night.js +25 -20
- package/dist/video/settings.js +10 -1
- package/package.json +1 -1
|
@@ -13,6 +13,8 @@
|
|
|
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';
|
|
16
18
|
</script>
|
|
17
19
|
|
|
18
20
|
<script lang="ts">
|
|
@@ -135,6 +137,26 @@
|
|
|
135
137
|
import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
|
|
136
138
|
import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
|
|
137
139
|
import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
|
|
140
|
+
import {
|
|
141
|
+
biasCells,
|
|
142
|
+
biasGradient,
|
|
143
|
+
frameLuma,
|
|
144
|
+
METER_START,
|
|
145
|
+
NIGHT_H,
|
|
146
|
+
NIGHT_W,
|
|
147
|
+
smoothMeter
|
|
148
|
+
} from '../video/night/meter';
|
|
149
|
+
import { NIGHT_DEPTH_DEFAULT, clampDepth, nudgeDepth, relativeLuminance, surroundLevel } from '../video/night/levels';
|
|
150
|
+
import { nextNightMode, nightArmed, nightModeLabel, type NightMode, type NightWindow } from '../video/night/clock';
|
|
151
|
+
import {
|
|
152
|
+
composeNight,
|
|
153
|
+
inFlash,
|
|
154
|
+
lumaAhead,
|
|
155
|
+
NIGHT_IDLE,
|
|
156
|
+
normalizeFlashes,
|
|
157
|
+
type NightFrame,
|
|
158
|
+
type NightState
|
|
159
|
+
} from '../video/night/track';
|
|
138
160
|
|
|
139
161
|
let {
|
|
140
162
|
src,
|
|
@@ -184,6 +206,14 @@
|
|
|
184
206
|
onTimeUpdate = undefined,
|
|
185
207
|
onAddBookmark = undefined,
|
|
186
208
|
onBookmarkContext = undefined,
|
|
209
|
+
night = $bindable(),
|
|
210
|
+
nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
|
|
211
|
+
nightWindow = null,
|
|
212
|
+
biasLight = true,
|
|
213
|
+
predictive = true,
|
|
214
|
+
strobeGuard = true,
|
|
215
|
+
onNightModeChange = undefined,
|
|
216
|
+
onNightBlind = undefined,
|
|
187
217
|
class: className = ''
|
|
188
218
|
}: {
|
|
189
219
|
src: string;
|
|
@@ -281,6 +311,45 @@
|
|
|
281
311
|
clientX: number,
|
|
282
312
|
clientY: number
|
|
283
313
|
) => void;
|
|
314
|
+
/**
|
|
315
|
+
* Night dimming (mos-5lzns.33) — a METER, not a preference: ~1×/s while
|
|
316
|
+
* playing it reads the frame's actual brightness (32×18 canvas) and pulls
|
|
317
|
+
* the picture down in proportion, warming it through multiply layers so
|
|
318
|
+
* blacks stay black. A dark scene passes through untouched.
|
|
319
|
+
*
|
|
320
|
+
* `'auto'` arms it from the local clock (20:00–07:00; 18:00–08:00 when the
|
|
321
|
+
* OS prefers dark), `'on'` always, `'off'` never. Omitted (default) ⇒ the
|
|
322
|
+
* feature does not exist on this player: no N key, no button, no layers.
|
|
323
|
+
* Bindable — `N` and the moon button cycle it; `onNightModeChange` reports.
|
|
324
|
+
*
|
|
325
|
+
* Frame reading needs a CORS-clean load: pass `crossorigin="anonymous"`
|
|
326
|
+
* for cross-origin media. A tainted canvas runs night on a fixed level
|
|
327
|
+
* (no meter, no bias light) and calls `onNightBlind`.
|
|
328
|
+
*/
|
|
329
|
+
night?: NightMode;
|
|
330
|
+
/** How far night goes, 0.2–1 (default 0.7). Bindable — scrolling the moon button sets it. */
|
|
331
|
+
nightDepth?: number;
|
|
332
|
+
/** Override the auto window, local hours `{ from, to }` (wraps midnight when from > to). */
|
|
333
|
+
nightWindow?: NightWindow | null;
|
|
334
|
+
/** While night acts, spill the frame's own colours, blurred, behind the player. */
|
|
335
|
+
biasLight?: boolean;
|
|
336
|
+
/** Read the `luma` track ~1s ahead so dimming starts BEFORE a bright cut. Only ever adds dimming. */
|
|
337
|
+
predictive?: boolean;
|
|
338
|
+
/**
|
|
339
|
+
* Photosensitivity guard: across the `luma` track's flash intervals,
|
|
340
|
+
* drop the picture to about 55% brightness (instant in, released per
|
|
341
|
+
* tick — never a jump back up). Always at full strength: it does NOT
|
|
342
|
+
* follow `nightDepth`, and it acts whether night is on, off or absent,
|
|
343
|
+
* whenever the track carries `flashes`. `false` to disable.
|
|
344
|
+
*
|
|
345
|
+
* It REDUCES flashes; it does not make content WCAG 2.3.1-safe. Flash
|
|
346
|
+
* DETECTION is the host's job — the player guards only the intervals the
|
|
347
|
+
* host supplies in `luma.flashes`.
|
|
348
|
+
*/
|
|
349
|
+
strobeGuard?: boolean;
|
|
350
|
+
onNightModeChange?: (mode: NightMode) => void;
|
|
351
|
+
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
352
|
+
onNightBlind?: () => void;
|
|
284
353
|
class?: string;
|
|
285
354
|
} = $props();
|
|
286
355
|
|
|
@@ -292,6 +361,8 @@
|
|
|
292
361
|
// Autoplay forces muted — browsers refuse to autoplay audible video, and a
|
|
293
362
|
// silent presentation clip is the point of the primitive anyway.
|
|
294
363
|
const effectiveAutoplay = $derived(autoplay && !reduceMotion);
|
|
364
|
+
// The stage's locked ratio (null ⇒ the video's intrinsic ratio drives the box).
|
|
365
|
+
const stageAspect = $derived(aspectRatioCss(aspect));
|
|
295
366
|
// `(pointer: coarse)` — a fingertip gets a wider comment-dot target.
|
|
296
367
|
let coarsePointer = $state(false);
|
|
297
368
|
|
|
@@ -478,6 +549,7 @@
|
|
|
478
549
|
if (row.when === 'bookmarks') return onAddBookmark != null;
|
|
479
550
|
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
480
551
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
552
|
+
if (row.when === 'night') return nightPresent;
|
|
481
553
|
return true;
|
|
482
554
|
})
|
|
483
555
|
);
|
|
@@ -548,6 +620,208 @@
|
|
|
548
620
|
flashOsd(formatSpeedLabel(baseRate));
|
|
549
621
|
}
|
|
550
622
|
|
|
623
|
+
// ── Night (video/night/*) ───────────────────────────────────────────────
|
|
624
|
+
// The modules decide; this block samples the frame and applies the answer
|
|
625
|
+
// as CSS variables that exist ONLY while night (or the strobe guard) is
|
|
626
|
+
// acting — off leaves no trace. The meter canvas and the running levels
|
|
627
|
+
// are plain (non-reactive): written and read inside one tick.
|
|
628
|
+
const NIGHT_TICK_MS = 900;
|
|
629
|
+
/** Bias light refreshes at most this often — peripheral light, not a second picture. */
|
|
630
|
+
const BIAS_EVERY_MS = 1800;
|
|
631
|
+
const nightPresent = $derived(night !== undefined);
|
|
632
|
+
const nightFlashes = $derived(normalizeFlashes(luma));
|
|
633
|
+
const guardPresent = $derived(strobeGuard && nightFlashes.length > 0);
|
|
634
|
+
let prefersDark = $state(false);
|
|
635
|
+
let nightArmedNow = $state(false);
|
|
636
|
+
let nightBlind = $state(false);
|
|
637
|
+
let nightFrame = $state<NightFrame | null>(null);
|
|
638
|
+
let biasImage = $state('');
|
|
639
|
+
let surroundLuma = $state(0);
|
|
640
|
+
let nightMeter = METER_START;
|
|
641
|
+
/** The composer's running state — night's level and the guard's, released independently. */
|
|
642
|
+
let nightOut: NightState = { ...NIGHT_IDLE };
|
|
643
|
+
let biasAt = 0;
|
|
644
|
+
let meterCtx: CanvasRenderingContext2D | null = null;
|
|
645
|
+
|
|
646
|
+
/** 32×18 RGBA bytes of the current frame, or null (no frame yet / canvas tainted). */
|
|
647
|
+
function sampleFrame(v: HTMLVideoElement): Uint8ClampedArray | null {
|
|
648
|
+
if (v.readyState < 2 || !v.videoWidth || !v.videoHeight) return null;
|
|
649
|
+
if (!meterCtx) {
|
|
650
|
+
const c = document.createElement('canvas');
|
|
651
|
+
c.width = NIGHT_W;
|
|
652
|
+
c.height = NIGHT_H;
|
|
653
|
+
meterCtx = c.getContext('2d', { willReadFrequently: true });
|
|
654
|
+
if (!meterCtx) return null;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
meterCtx.drawImage(v, 0, 0, NIGHT_W, NIGHT_H);
|
|
658
|
+
return meterCtx.getImageData(0, 0, NIGHT_W, NIGHT_H).data;
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// Only a SecurityError means the canvas is tainted (cross-origin
|
|
661
|
+
// without CORS). Anything else — a decoder hiccup, a frame mid-swap —
|
|
662
|
+
// is transient: skip this sample and try again next tick.
|
|
663
|
+
if (!(err instanceof DOMException && err.name === 'SecurityError')) return null;
|
|
664
|
+
// Tainted: escalate to the host (it owns `crossorigin`) and run blind
|
|
665
|
+
// on a fixed level — never guess brightness. Bias light has nothing to
|
|
666
|
+
// sample, so it stands down.
|
|
667
|
+
nightBlind = true;
|
|
668
|
+
biasImage = '';
|
|
669
|
+
onNightBlind?.();
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function nightTick() {
|
|
675
|
+
const v = videoEl;
|
|
676
|
+
if (!v) return;
|
|
677
|
+
const armed = night !== undefined && nightArmed(night, new Date(), prefersDark, nightWindow);
|
|
678
|
+
if (nightArmedNow !== armed) nightArmedNow = armed;
|
|
679
|
+
const hidden = typeof document !== 'undefined' && document.hidden;
|
|
680
|
+
const t = v.currentTime || 0;
|
|
681
|
+
// One tick's worth of media time: prediction and the guard look this
|
|
682
|
+
// much further ahead, so a coarse tick can never let the first flash through.
|
|
683
|
+
const horizon = (NIGHT_TICK_MS / 1000) * (v.playbackRate > 0 ? v.playbackRate : 1);
|
|
684
|
+
const flash = strobeGuard && inFlash(nightFlashes, t, horizon);
|
|
685
|
+
if (armed && !nightBlind && !v.paused && !hidden) {
|
|
686
|
+
const px = sampleFrame(v);
|
|
687
|
+
if (px) {
|
|
688
|
+
nightMeter = smoothMeter(nightMeter, frameLuma(px));
|
|
689
|
+
// The bias light reads the bytes the meter JUST drew — any other
|
|
690
|
+
// time the canvas is stale or empty, and empty reads as pitch black.
|
|
691
|
+
// Frozen while the guard is down: the glow's background-image swap
|
|
692
|
+
// is un-faded, and a strobe would drive it at the strobe's own rate.
|
|
693
|
+
if (biasLight && !flash && Date.now() - biasAt > BIAS_EVERY_MS) {
|
|
694
|
+
const g = biasGradient(biasCells(px));
|
|
695
|
+
if (g) {
|
|
696
|
+
biasImage = g;
|
|
697
|
+
biasAt = Date.now();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const f = composeNight(nightOut, {
|
|
703
|
+
armed,
|
|
704
|
+
blind: nightBlind,
|
|
705
|
+
meter: nightMeter,
|
|
706
|
+
ahead: armed && predictive ? lumaAhead(lumaSeries, t, horizon) : null,
|
|
707
|
+
flash,
|
|
708
|
+
depth: nightDepth
|
|
709
|
+
});
|
|
710
|
+
nightOut = { luma: f.luma, guardLuma: f.guardLuma };
|
|
711
|
+
nightFrame = f.mode === 'none' ? null : f;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Drop night's traces at once. The guard's level is kept unless `all`:
|
|
716
|
+
* switching night off is a statement about night, and must not cut short a
|
|
717
|
+
* guard that is still releasing (that would be a jump back up).
|
|
718
|
+
*/
|
|
719
|
+
function nightClear(all = false) {
|
|
720
|
+
nightOut = { luma: 0, guardLuma: all ? 0 : nightOut.guardLuma };
|
|
721
|
+
nightFrame = null;
|
|
722
|
+
biasImage = '';
|
|
723
|
+
biasAt = 0;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// The tick runs only while the feature exists on this player (night opted
|
|
727
|
+
// in, or a track with flashes for the guard). Visibility changes re-tick at
|
|
728
|
+
// once so a returning tab doesn't wait out the interval.
|
|
729
|
+
$effect(() => {
|
|
730
|
+
const on = nightPresent || guardPresent;
|
|
731
|
+
const v = videoEl;
|
|
732
|
+
if (!on || !v) return;
|
|
733
|
+
const id = setInterval(nightTick, NIGHT_TICK_MS);
|
|
734
|
+
const onVis = () => nightTick();
|
|
735
|
+
document.addEventListener('visibilitychange', onVis);
|
|
736
|
+
return () => {
|
|
737
|
+
clearInterval(id);
|
|
738
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
739
|
+
untrack(() => nightClear(true));
|
|
740
|
+
};
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// A new source (or CORS mode) is a new picture: forget the meter, the
|
|
744
|
+
// blind verdict and the glow.
|
|
745
|
+
$effect(() => {
|
|
746
|
+
void src;
|
|
747
|
+
void crossorigin;
|
|
748
|
+
untrack(() => {
|
|
749
|
+
nightBlind = false;
|
|
750
|
+
nightMeter = METER_START;
|
|
751
|
+
nightClear(true);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// Knob changes re-evaluate now. Switching night off clears at once rather
|
|
756
|
+
// than releasing tick by tick — off is a statement, not a fade.
|
|
757
|
+
$effect(() => {
|
|
758
|
+
const mode = night;
|
|
759
|
+
void nightDepth;
|
|
760
|
+
void nightWindow;
|
|
761
|
+
void predictive;
|
|
762
|
+
void strobeGuard;
|
|
763
|
+
void biasLight;
|
|
764
|
+
void prefersDark;
|
|
765
|
+
void videoEl;
|
|
766
|
+
untrack(() => {
|
|
767
|
+
if (mode === 'off' || mode === undefined) nightClear();
|
|
768
|
+
else if (!biasLight) biasImage = '';
|
|
769
|
+
nightTick();
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// The theme's surround (base-200) as relative luminance: on a light theme
|
|
774
|
+
// the surround is pulled down with the picture (levels.ts surroundLevel).
|
|
775
|
+
$effect(() => {
|
|
776
|
+
void themeTick;
|
|
777
|
+
if (!nightPresent) return;
|
|
778
|
+
untrack(() => {
|
|
779
|
+
surroundLuma = colorLuminance(getDaisyUIColor('base-200'));
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
/** Any CSS colour → relative luminance, via a 1×1 same-origin canvas (never tainted). */
|
|
784
|
+
function colorLuminance(color: string): number {
|
|
785
|
+
if (typeof document === 'undefined') return 0;
|
|
786
|
+
try {
|
|
787
|
+
const c = document.createElement('canvas');
|
|
788
|
+
c.width = c.height = 1;
|
|
789
|
+
const ctx = c.getContext('2d', { willReadFrequently: true });
|
|
790
|
+
if (!ctx) return 0;
|
|
791
|
+
ctx.fillStyle = color; // unparseable ⇒ stays the default black ⇒ 0 ⇒ surround untouched
|
|
792
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
793
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
794
|
+
return relativeLuminance(r, g, b);
|
|
795
|
+
} catch {
|
|
796
|
+
return 0;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const nightLayersOn = $derived(nightFrame?.mode === 'night');
|
|
801
|
+
const biasOn = $derived(nightLayersOn && biasLight && !nightBlind && biasImage !== '');
|
|
802
|
+
const surroundMix = $derived(nightLayersOn ? surroundLevel(surroundLuma, nightDepth) : 0);
|
|
803
|
+
const nightLabel = $derived(nightModeLabel(night, nightArmedNow));
|
|
804
|
+
|
|
805
|
+
function setNightMode(mode: NightMode) {
|
|
806
|
+
night = mode;
|
|
807
|
+
onNightModeChange?.(mode);
|
|
808
|
+
}
|
|
809
|
+
function cycleNight() {
|
|
810
|
+
if (night === undefined) return;
|
|
811
|
+
const next = nextNightMode(night);
|
|
812
|
+
setNightMode(next);
|
|
813
|
+
flashOsd(nightModeLabel(next, nightArmed(next, new Date(), prefersDark, nightWindow)));
|
|
814
|
+
}
|
|
815
|
+
/** Scroll the moon: the depth dial. Reaching for it means you want to see it work, whatever the clock says. */
|
|
816
|
+
function onNightWheel(e: WheelEvent) {
|
|
817
|
+
if (night === undefined) return;
|
|
818
|
+
e.preventDefault();
|
|
819
|
+
e.stopPropagation();
|
|
820
|
+
if (!nightArmedNow) setNightMode('on');
|
|
821
|
+
nightDepth = nudgeDepth(nightDepth, e.deltaY < 0 ? 1 : -1);
|
|
822
|
+
flashOsd(`Night ${Math.round(clampDepth(nightDepth) * 100)}%`);
|
|
823
|
+
}
|
|
824
|
+
|
|
551
825
|
// ── Canvas ──────────────────────────────────────────────────────────────
|
|
552
826
|
// Repaint on any input the canvas reads. Deps are listed explicitly and the
|
|
553
827
|
// body is untracked — letting paintState's own reads register as deps is
|
|
@@ -628,6 +902,15 @@
|
|
|
628
902
|
mq.addEventListener('change', onChange);
|
|
629
903
|
stopReduce = () => mq.removeEventListener('change', onChange);
|
|
630
904
|
}
|
|
905
|
+
let stopDark: (() => void) | undefined;
|
|
906
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
907
|
+
// Only ever WIDENS night's auto window (night/clock.ts) — never the trigger.
|
|
908
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
909
|
+
prefersDark = mq.matches;
|
|
910
|
+
const onChange = () => (prefersDark = mq.matches);
|
|
911
|
+
mq.addEventListener('change', onChange);
|
|
912
|
+
stopDark = () => mq.removeEventListener('change', onChange);
|
|
913
|
+
}
|
|
631
914
|
let stopCoarse: (() => void) | undefined;
|
|
632
915
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
633
916
|
const mq = window.matchMedia('(pointer: coarse)');
|
|
@@ -641,6 +924,7 @@
|
|
|
641
924
|
stopTheme();
|
|
642
925
|
stopReduce?.();
|
|
643
926
|
stopCoarse?.();
|
|
927
|
+
stopDark?.();
|
|
644
928
|
if (dragRaf) cancelAnimationFrame(dragRaf);
|
|
645
929
|
if (zoomTimer) clearTimeout(zoomTimer);
|
|
646
930
|
if (osdTimer) clearTimeout(osdTimer);
|
|
@@ -1060,6 +1344,13 @@
|
|
|
1060
1344
|
flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
|
|
1061
1345
|
}
|
|
1062
1346
|
return;
|
|
1347
|
+
case 'n':
|
|
1348
|
+
case 'N':
|
|
1349
|
+
if (night !== undefined) {
|
|
1350
|
+
e.preventDefault();
|
|
1351
|
+
cycleNight();
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1063
1354
|
case 'Home':
|
|
1064
1355
|
seekTo(0);
|
|
1065
1356
|
break;
|
|
@@ -1155,6 +1446,9 @@
|
|
|
1155
1446
|
class="vp-root {className}"
|
|
1156
1447
|
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
1157
1448
|
class:vp-one-axis={nativeBarHidden}
|
|
1449
|
+
class:vp-night-capable={nightPresent}
|
|
1450
|
+
class:vp-night-surround={surroundMix > 0.001}
|
|
1451
|
+
style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
|
|
1158
1452
|
data-band-axis={currentBandState.axis}
|
|
1159
1453
|
role="region"
|
|
1160
1454
|
aria-label="Video player"
|
|
@@ -1162,11 +1456,30 @@
|
|
|
1162
1456
|
onkeydown={onRootKeyDown}
|
|
1163
1457
|
>
|
|
1164
1458
|
<div class="vp-main">
|
|
1459
|
+
<div class="vp-stage-wrap">
|
|
1460
|
+
{#if nightPresent}
|
|
1461
|
+
<!-- Bias light: the frame's own colours, blurred, spilled behind the
|
|
1462
|
+
picture. Earlier in the DOM than the (positioned) stage, so the
|
|
1463
|
+
stage paints over it with no z-index and no stacking context. -->
|
|
1464
|
+
<div
|
|
1465
|
+
class="vp-bias"
|
|
1466
|
+
class:on={biasOn}
|
|
1467
|
+
style:--vp-bias-image={biasOn ? biasImage : undefined}
|
|
1468
|
+
aria-hidden="true"
|
|
1469
|
+
></div>
|
|
1470
|
+
{/if}
|
|
1165
1471
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1166
1472
|
<div
|
|
1167
1473
|
class="vp-stage"
|
|
1168
|
-
class:vp-stage-locked={
|
|
1169
|
-
|
|
1474
|
+
class:vp-stage-locked={stageAspect != null}
|
|
1475
|
+
class:vp-night-capable={nightPresent || guardPresent}
|
|
1476
|
+
class:vp-night-on={nightFrame != null}
|
|
1477
|
+
class:vp-flash-guard={nightFrame?.guard === true}
|
|
1478
|
+
style={stageAspect != null ? `--vp-aspect: ${stageAspect};` : undefined}
|
|
1479
|
+
style:--vp-night-bright={nightFrame ? nightFrame.levels.bright.toFixed(3) : undefined}
|
|
1480
|
+
style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
|
|
1481
|
+
style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
|
|
1482
|
+
style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
|
|
1170
1483
|
onwheel={onStageWheel}
|
|
1171
1484
|
>
|
|
1172
1485
|
<video
|
|
@@ -1175,7 +1488,13 @@
|
|
|
1175
1488
|
bind:duration
|
|
1176
1489
|
ontimeupdate={handleTimeUpdate}
|
|
1177
1490
|
onloadedmetadata={onLoadedMetadata}
|
|
1178
|
-
onseeked={
|
|
1491
|
+
onseeked={() => {
|
|
1492
|
+
syncUrlNow();
|
|
1493
|
+
if (nightPresent || guardPresent) nightTick();
|
|
1494
|
+
}}
|
|
1495
|
+
onplay={() => {
|
|
1496
|
+
if (nightPresent || guardPresent) nightTick();
|
|
1497
|
+
}}
|
|
1179
1498
|
onpause={syncUrlNow}
|
|
1180
1499
|
{src}
|
|
1181
1500
|
{loop}
|
|
@@ -1194,8 +1513,16 @@
|
|
|
1194
1513
|
menu but isn't painted on top of the frames' own text (jat-abwim). -->
|
|
1195
1514
|
<track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
|
|
1196
1515
|
</video>
|
|
1516
|
+
{#if nightPresent}
|
|
1517
|
+
<!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
|
|
1518
|
+
point and fog the picture; multiplying leaves blacks black. At
|
|
1519
|
+
opacity 0 (their variables unset) they change no pixel. -->
|
|
1520
|
+
<div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
|
|
1521
|
+
<div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
|
|
1522
|
+
{/if}
|
|
1197
1523
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
1198
1524
|
</div>
|
|
1525
|
+
</div>
|
|
1199
1526
|
|
|
1200
1527
|
<!-- The band: one time axis for the envelope, chapters, speakers and
|
|
1201
1528
|
bookmarks. Its height is reserved from the first frame, so data
|
|
@@ -1314,11 +1641,31 @@
|
|
|
1314
1641
|
|
|
1315
1642
|
<div class="vp-times">
|
|
1316
1643
|
<span>{formatTime(currentTime, useHours)}</span>
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1644
|
+
<span class="vp-times-mid">
|
|
1645
|
+
{#if isZoomedNow()}
|
|
1646
|
+
<button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
|
|
1647
|
+
Reset zoom
|
|
1648
|
+
</button>
|
|
1649
|
+
{/if}
|
|
1650
|
+
{#if nightPresent}
|
|
1651
|
+
<!-- The veil's switch: click (or N) cycles auto → on → off; scroll
|
|
1652
|
+
sets the depth. Lit in the ribbon's warm token while dimming. -->
|
|
1653
|
+
<button
|
|
1654
|
+
type="button"
|
|
1655
|
+
class="vp-night-btn"
|
|
1656
|
+
class:active={nightLayersOn}
|
|
1657
|
+
aria-label={nightLabel}
|
|
1658
|
+
title="{nightLabel} · N cycles, scroll sets depth"
|
|
1659
|
+
onclick={cycleNight}
|
|
1660
|
+
onwheel={onNightWheel}
|
|
1661
|
+
>
|
|
1662
|
+
<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">
|
|
1663
|
+
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5Z" />
|
|
1664
|
+
{#if night === 'off'}<path d="M4 20 20 4" />{/if}
|
|
1665
|
+
</svg>
|
|
1666
|
+
</button>
|
|
1667
|
+
{/if}
|
|
1668
|
+
</span>
|
|
1322
1669
|
<span>{formatTime(effectiveDuration, useHours)}</span>
|
|
1323
1670
|
</div>
|
|
1324
1671
|
|
|
@@ -2053,4 +2400,153 @@
|
|
|
2053
2400
|
transition: none;
|
|
2054
2401
|
}
|
|
2055
2402
|
}
|
|
2403
|
+
|
|
2404
|
+
/* ── Night (mos-5lzns.33) ─────────────────────────────────────────────
|
|
2405
|
+
Every value arrives as a custom property that exists ONLY while night
|
|
2406
|
+
(or the strobe guard) acts; unset, each layer falls back to identity —
|
|
2407
|
+
brightness(1), opacity 0 — so off changes no pixel. The long ramps are
|
|
2408
|
+
the point: the eye must never catch the change, only notice afterwards
|
|
2409
|
+
that the room got easier. */
|
|
2410
|
+
|
|
2411
|
+
/* The transition lives on the video whenever the feature exists and the
|
|
2412
|
+
filter only under .vp-night-on, so switching off eases back up the way
|
|
2413
|
+
it came down (a rule carrying both would snap on the way out). */
|
|
2414
|
+
.vp-night-capable .vp-video {
|
|
2415
|
+
transition: filter 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
.vp-night-on .vp-video {
|
|
2419
|
+
filter: brightness(var(--vp-night-bright, 1)) saturate(var(--vp-night-sat, 1));
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
.vp-night-layer {
|
|
2423
|
+
position: absolute;
|
|
2424
|
+
inset: 0;
|
|
2425
|
+
pointer-events: none;
|
|
2426
|
+
border-radius: inherit;
|
|
2427
|
+
mix-blend-mode: multiply;
|
|
2428
|
+
opacity: 0;
|
|
2429
|
+
transition: opacity 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/* Warmth: multiplying by the warm token (the ribbon's own colour, so the
|
|
2433
|
+
ribbon reads as "how much night is coming") leaves red intact and takes
|
|
2434
|
+
most of the blue — the night-shift lever, applied per pixel. */
|
|
2435
|
+
.vp-night-warm {
|
|
2436
|
+
background: var(--color-warning);
|
|
2437
|
+
opacity: var(--vp-night-warm, 0);
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
/* A lens falloff, off-centre high where a face usually sits; nothing until
|
|
2441
|
+
42% out, so the subject never darkens. */
|
|
2442
|
+
.vp-night-vignette {
|
|
2443
|
+
background: radial-gradient(
|
|
2444
|
+
ellipse 82% 86% at 50% 44%,
|
|
2445
|
+
transparent 42%,
|
|
2446
|
+
color-mix(in oklch, var(--color-neutral) 55%, transparent) 100%
|
|
2447
|
+
);
|
|
2448
|
+
opacity: var(--vp-night-vig, 0);
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
/* Strobe guard: protection has to arrive before the flash, so this one
|
|
2452
|
+
moment is fast. Everything else about night is deliberately slow. */
|
|
2453
|
+
.vp-flash-guard .vp-video {
|
|
2454
|
+
transition-duration: 120ms;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
.vp-flash-guard .vp-night-layer {
|
|
2458
|
+
transition-duration: 160ms;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
.vp-stage-wrap {
|
|
2462
|
+
position: relative;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/* Bias light: peripheral light, so the blur is large on purpose — any
|
|
2466
|
+
detail in it reads as a distracting second image. */
|
|
2467
|
+
.vp-bias {
|
|
2468
|
+
position: absolute;
|
|
2469
|
+
inset: -6% -4%;
|
|
2470
|
+
pointer-events: none;
|
|
2471
|
+
background-image: var(--vp-bias-image, none);
|
|
2472
|
+
filter: blur(46px) saturate(1.15);
|
|
2473
|
+
opacity: 0;
|
|
2474
|
+
transition: opacity 1200ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
.vp-bias.on {
|
|
2478
|
+
opacity: 0.5;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
/* The surround. On a LIGHT theme a dimmed picture in a white frame makes
|
|
2482
|
+
the screen-to-surround ratio worse, so the player's own surface is pulled
|
|
2483
|
+
toward neutral with it (levels.ts surroundLevel — zero on dark themes,
|
|
2484
|
+
capped well short of dark because text sits here). */
|
|
2485
|
+
.vp-root.vp-night-capable {
|
|
2486
|
+
transition: background-color 900ms var(--motion-glide, cubic-bezier(0.25, 1, 0.5, 1));
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
.vp-root.vp-night-surround {
|
|
2490
|
+
background-color: color-mix(
|
|
2491
|
+
in oklch,
|
|
2492
|
+
var(--color-base-200),
|
|
2493
|
+
var(--color-neutral) calc(var(--vp-night-surround, 0) * 100%)
|
|
2494
|
+
);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
.vp-times-mid {
|
|
2498
|
+
display: inline-flex;
|
|
2499
|
+
align-items: center;
|
|
2500
|
+
gap: var(--jatui-space-base, 8px);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
.vp-night-btn {
|
|
2504
|
+
display: inline-flex;
|
|
2505
|
+
align-items: center;
|
|
2506
|
+
justify-content: center;
|
|
2507
|
+
background: none;
|
|
2508
|
+
border: none;
|
|
2509
|
+
padding: var(--jatui-space-xs, 2px);
|
|
2510
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
2511
|
+
color: inherit;
|
|
2512
|
+
cursor: pointer;
|
|
2513
|
+
transition: color var(--motion-move, 300ms) var(--motion-glide, ease-out);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
.vp-night-btn:hover {
|
|
2517
|
+
color: var(--color-base-content);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
.vp-night-btn:focus-visible {
|
|
2521
|
+
outline: 2px solid var(--color-primary);
|
|
2522
|
+
outline-offset: 1px;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
/* Lit in the veil's own warm token while dimming is actually happening. */
|
|
2526
|
+
.vp-night-btn.active {
|
|
2527
|
+
color: var(--color-warning);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/* Under reduced motion the veil still dims — the dimming IS the feature —
|
|
2531
|
+
but the long 900ms/1200ms ramps shrink to a short luminance fade. A
|
|
2532
|
+
change in brightness is not motion, and a hard step every tick would be
|
|
2533
|
+
harsher on the eye than the fade it replaced. The strobe guard keeps its
|
|
2534
|
+
own faster ramp. (The composer also releases brightness a fraction per
|
|
2535
|
+
tick, so the end of a dim is never a jump back up.) */
|
|
2536
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2537
|
+
.vp-night-capable .vp-video,
|
|
2538
|
+
.vp-night-layer,
|
|
2539
|
+
.vp-bias,
|
|
2540
|
+
.vp-root.vp-night-capable {
|
|
2541
|
+
transition-duration: 150ms;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
.vp-flash-guard .vp-video {
|
|
2545
|
+
transition-duration: 120ms;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
.vp-night-btn {
|
|
2549
|
+
transition: none;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2056
2552
|
</style>
|
|
@@ -2,10 +2,12 @@ export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookm
|
|
|
2
2
|
export type { BandAxis, BandState } from '../video/band/axis';
|
|
3
3
|
export type { VideoLumaTrack } from '../video/band/luma';
|
|
4
4
|
export type { VideoMarker } from '../video/band/markers';
|
|
5
|
+
export type { NightMode, NightWindow } from '../video/night/clock';
|
|
5
6
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
6
7
|
import { type BandState } from '../video/band/axis';
|
|
7
8
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
8
9
|
import { type VideoMarker } from '../video/band/markers';
|
|
10
|
+
import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
9
11
|
type $$ComponentProps = {
|
|
10
12
|
src: string;
|
|
11
13
|
poster?: string | null;
|
|
@@ -97,10 +99,49 @@ type $$ComponentProps = {
|
|
|
97
99
|
onAddBookmark?: (playbackSec: number, label: string) => void;
|
|
98
100
|
/** Right-click on the band. The host owns the panel; this only hit-tests. */
|
|
99
101
|
onBookmarkContext?: (sec: number, existing: VideoBookmark | null, clientX: number, clientY: number) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Night dimming (mos-5lzns.33) — a METER, not a preference: ~1×/s while
|
|
104
|
+
* playing it reads the frame's actual brightness (32×18 canvas) and pulls
|
|
105
|
+
* the picture down in proportion, warming it through multiply layers so
|
|
106
|
+
* blacks stay black. A dark scene passes through untouched.
|
|
107
|
+
*
|
|
108
|
+
* `'auto'` arms it from the local clock (20:00–07:00; 18:00–08:00 when the
|
|
109
|
+
* OS prefers dark), `'on'` always, `'off'` never. Omitted (default) ⇒ the
|
|
110
|
+
* feature does not exist on this player: no N key, no button, no layers.
|
|
111
|
+
* Bindable — `N` and the moon button cycle it; `onNightModeChange` reports.
|
|
112
|
+
*
|
|
113
|
+
* Frame reading needs a CORS-clean load: pass `crossorigin="anonymous"`
|
|
114
|
+
* for cross-origin media. A tainted canvas runs night on a fixed level
|
|
115
|
+
* (no meter, no bias light) and calls `onNightBlind`.
|
|
116
|
+
*/
|
|
117
|
+
night?: NightMode;
|
|
118
|
+
/** How far night goes, 0.2–1 (default 0.7). Bindable — scrolling the moon button sets it. */
|
|
119
|
+
nightDepth?: number;
|
|
120
|
+
/** Override the auto window, local hours `{ from, to }` (wraps midnight when from > to). */
|
|
121
|
+
nightWindow?: NightWindow | null;
|
|
122
|
+
/** While night acts, spill the frame's own colours, blurred, behind the player. */
|
|
123
|
+
biasLight?: boolean;
|
|
124
|
+
/** Read the `luma` track ~1s ahead so dimming starts BEFORE a bright cut. Only ever adds dimming. */
|
|
125
|
+
predictive?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Photosensitivity guard: across the `luma` track's flash intervals,
|
|
128
|
+
* drop the picture to about 55% brightness (instant in, released per
|
|
129
|
+
* tick — never a jump back up). Always at full strength: it does NOT
|
|
130
|
+
* follow `nightDepth`, and it acts whether night is on, off or absent,
|
|
131
|
+
* whenever the track carries `flashes`. `false` to disable.
|
|
132
|
+
*
|
|
133
|
+
* It REDUCES flashes; it does not make content WCAG 2.3.1-safe. Flash
|
|
134
|
+
* DETECTION is the host's job — the player guards only the intervals the
|
|
135
|
+
* host supplies in `luma.flashes`.
|
|
136
|
+
*/
|
|
137
|
+
strobeGuard?: boolean;
|
|
138
|
+
onNightModeChange?: (mode: NightMode) => void;
|
|
139
|
+
/** Frame readback was refused (CORS-tainted canvas) — night fell back to a fixed level. */
|
|
140
|
+
onNightBlind?: () => void;
|
|
100
141
|
class?: string;
|
|
101
142
|
};
|
|
102
143
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
|
103
144
|
bandState: () => BandState;
|
|
104
|
-
}, "">;
|
|
145
|
+
}, "night" | "nightDepth">;
|
|
105
146
|
type VideoPlayer = ReturnType<typeof VideoPlayer>;
|
|
106
147
|
export default VideoPlayer;
|
package/dist/index.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export type { ChapterBlock } from './components/replay/ChapterTimeline.svelte';
|
|
|
12
12
|
export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
|
|
13
13
|
export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
|
|
14
14
|
export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
15
|
-
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, } from './components/VideoPlayer.svelte';
|
|
15
|
+
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, } from './components/VideoPlayer.svelte';
|
|
16
16
|
export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
|
|
17
17
|
export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
|
|
18
18
|
export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
|