@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.
- package/dist/components/VideoPlayer.svelte +627 -8
- package/dist/components/VideoPlayer.svelte.d.ts +84 -1
- package/dist/index.d.ts +1 -1
- package/dist/video/README.md +5 -4
- package/dist/video/audio/graph.d.ts +115 -0
- package/dist/video/audio/graph.js +400 -0
- package/dist/video/audio/rider.d.ts +134 -0
- package/dist/video/audio/rider.js +187 -0
- package/dist/video/audio.d.ts +30 -19
- package/dist/video/audio.js +30 -19
- 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 +18 -2
- package/package.json +1 -1
|
@@ -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={
|
|
1169
|
-
|
|
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={
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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>
|