@joewinke/jatui 0.9.1 → 0.11.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.
Files changed (35) hide show
  1. package/CLAUDE.md +10 -0
  2. package/dist/components/VideoPlayer.svelte +371 -1
  3. package/dist/components/VideoPlayer.svelte.d.ts +44 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +10 -0
  6. package/dist/video/README.md +18 -3
  7. package/dist/video/advisors/README.md +20 -0
  8. package/dist/video/advisors/qr.d.ts +95 -0
  9. package/dist/video/advisors/qr.js +546 -0
  10. package/dist/video/advisors/quiet-listening.d.ts +73 -0
  11. package/dist/video/advisors/quiet-listening.js +292 -0
  12. package/dist/video/advisors/resume-point.d.ts +71 -0
  13. package/dist/video/advisors/resume-point.js +302 -0
  14. package/dist/video/advisors/smart-speed.d.ts +82 -0
  15. package/dist/video/advisors/smart-speed.js +314 -0
  16. package/dist/video/advisors/speaker-rate.d.ts +74 -0
  17. package/dist/video/advisors/speaker-rate.js +322 -0
  18. package/dist/video/band/axis.d.ts +81 -0
  19. package/dist/video/band/axis.js +106 -0
  20. package/dist/video/band/drag.d.ts +38 -0
  21. package/dist/video/band/drag.js +45 -0
  22. package/dist/video/band/luma.d.ts +62 -0
  23. package/dist/video/band/luma.js +97 -0
  24. package/dist/video/band/markers.d.ts +73 -0
  25. package/dist/video/band/markers.js +146 -0
  26. package/dist/video/band/memo.d.ts +21 -0
  27. package/dist/video/band/memo.js +15 -0
  28. package/dist/video/band/window.d.ts +35 -0
  29. package/dist/video/band/window.js +61 -0
  30. package/dist/video/band.d.ts +35 -29
  31. package/dist/video/band.js +77 -56
  32. package/dist/video/keys.d.ts +1 -1
  33. package/dist/video/keys.js +3 -0
  34. package/dist/video/settings.js +6 -5
  35. package/package.json +3 -2
package/CLAUDE.md CHANGED
@@ -242,6 +242,16 @@ if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ...
242
242
  ## Utilities
243
243
 
244
244
  ```typescript
245
+ // Video playback advisors (ported from extension-library — mos-5lzns.27).
246
+ // Pure functions: no DOM, no settings reads, never write playbackRate. The
247
+ // player's rate composer / resume-at-sentence / audio-graph tasks consume
248
+ // them; hosts can call them directly too.
249
+ import { buildSilenceMap, boostAt, estimateSaving } from "jatui" // silence map (smart speed)
250
+ import { speakerPace, suggestRates, rateAt, describeRates } from "jatui" // measured per-speaker wpm
251
+ import { snapResume, resumeLabel } from "jatui" // resume at a sentence, never a second
252
+ import { createQuietChain, amountFor } from "jatui" // loudness-compensated tone (WebAudio)
253
+ import { qrMatrix, qrCanvas } from "jatui" // deep-link QR, ISO 18004 v1–10, no deps
254
+
245
255
  // Date formatting
246
256
  import { formatRelativeTime, formatDate, formatShortDate, formatDateTime } from "jatui"
247
257
 
@@ -9,6 +9,10 @@
9
9
  VideoWaveform,
10
10
  VideoSpeakerTalkTime
11
11
  } from '../video/types';
12
+ // mos-5lzns.30 — one-axis state, the light track, social markers.
13
+ export type { BandAxis, BandState } from '../video/band/axis';
14
+ export type { VideoLumaTrack } from '../video/band/luma';
15
+ export type { VideoMarker } from '../video/band/markers';
12
16
  </script>
13
17
 
14
18
  <script lang="ts">
@@ -90,6 +94,7 @@
90
94
  UNKNOWN_KEY
91
95
  } from '../video/model';
92
96
  import {
97
+ bandClusters,
93
98
  clampCardX,
94
99
  followPlayhead,
95
100
  fracToSec,
@@ -106,6 +111,27 @@
106
111
  zoomAt,
107
112
  type BandWindow
108
113
  } from '../video/band';
114
+ import {
115
+ barHidden,
116
+ bandStateOf,
117
+ createBandReporter,
118
+ overviewBracket,
119
+ playheadX,
120
+ type BandState
121
+ } from '../video/band/axis';
122
+ import { normalizeLuma, ribbonColumns, RIBBON_STOPS, type VideoLumaTrack } from '../video/band/luma';
123
+ import {
124
+ commentAt,
125
+ commentJump,
126
+ describeComments,
127
+ MARKER_HIT_PX,
128
+ MARKER_HIT_PX_COARSE,
129
+ sortMarkers,
130
+ splitMarkers,
131
+ type VideoMarker
132
+ } from '../video/band/markers';
133
+ import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
134
+ import { createBandLayerCache } from '../video/band/memo';
109
135
  import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
110
136
  import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
111
137
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
@@ -150,6 +176,11 @@
150
176
  * refresh/copy/share picks up where playback left off.
151
177
  */
152
178
  syncUrlTime = false,
179
+ luma = null,
180
+ markers = [],
181
+ oneAxis = true,
182
+ onBandChange = undefined,
183
+ onMarkerClick = undefined,
153
184
  onTimeUpdate = undefined,
154
185
  onAddBookmark = undefined,
155
186
  onBookmarkContext = undefined,
@@ -205,6 +236,41 @@
205
236
  transcriptMaxHeight?: string;
206
237
  crossorigin?: 'anonymous' | 'use-credentials' | null;
207
238
  syncUrlTime?: boolean;
239
+ /**
240
+ * The light track — the host's pre-scanned brightness sidecar (integers
241
+ * 0–99 at `rate` samples/s; extension-library's `.luma` verbatim).
242
+ * Present ⇒ the band paints the light ribbon under its envelope, so
243
+ * bright passages glow on the timeline before you reach them. Night
244
+ * dimming (mos-5lzns.33) reads the same prop. Garbage or < 4 samples ⇒
245
+ * no ribbon, never a wrong one.
246
+ */
247
+ luma?: VideoLumaTrack | null;
248
+ /**
249
+ * Social markers on the band: `kind: 'comment'` draws a dot under the
250
+ * chapter flags (hover = the comment, click = seek to its exact second);
251
+ * `kind: 'like'` folds into a heat strip along the bottom edge.
252
+ */
253
+ markers?: VideoMarker[];
254
+ /**
255
+ * One time axis (default on): while the band has an envelope it OWNS the
256
+ * axis and the native timeline stands down (Chromium; elsewhere it can't
257
+ * be hidden and simply stays); zoomed, the band grows a whole-video
258
+ * overview rail with the window bracketed. Off ⇒ both, permanently.
259
+ */
260
+ oneAxis?: boolean;
261
+ /**
262
+ * The four-state arbitration, reported on real change only and deferred
263
+ * a microtask (the host's ref to this component may not be set yet when
264
+ * the first report fires). A host that draws its OWN seek bar reads
265
+ * `axis` to decide whether to show it — see video/band/axis.ts.
266
+ */
267
+ onBandChange?: (state: BandState) => void;
268
+ /**
269
+ * A comment marker was clicked (after the seek to it). Dots within a few
270
+ * px merge into one; `cluster` carries every comment under that dot,
271
+ * earliest first, and `marker` is `cluster[0]` — the one seeked to.
272
+ */
273
+ onMarkerClick?: (marker: VideoMarker, cluster: VideoMarker[]) => void;
208
274
  onTimeUpdate?: (video: HTMLVideoElement) => void;
209
275
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
210
276
  onAddBookmark?: (playbackSec: number, label: string) => void;
@@ -226,6 +292,8 @@
226
292
  // Autoplay forces muted — browsers refuse to autoplay audible video, and a
227
293
  // silent presentation clip is the point of the primitive anyway.
228
294
  const effectiveAutoplay = $derived(autoplay && !reduceMotion);
295
+ // `(pointer: coarse)` — a fingertip gets a wider comment-dot target.
296
+ let coarsePointer = $state(false);
229
297
 
230
298
  // ── Element refs + media state ──────────────────────────────────────────
231
299
  let videoEl: HTMLVideoElement | undefined = $state();
@@ -250,6 +318,7 @@
250
318
  text: string;
251
319
  thumb: string | null;
252
320
  isBookmark: boolean;
321
+ isComment?: boolean;
253
322
  } | null = $state(null);
254
323
  let zoomLabel = $state('');
255
324
  let zoomLabelOn = $state(false);
@@ -302,6 +371,12 @@
302
371
  });
303
372
  const hasWave = $derived(!!peaks);
304
373
 
374
+ const lumaSeries = $derived(normalizeLuma(luma));
375
+ // Memo slots for the ribbon / heat / cluster arrays — plain object, never a
376
+ // reactive source (band/memo.ts): a playhead repaint reuses them.
377
+ const layerCache = createBandLayerCache();
378
+ const socialMarkers = $derived(splitMarkers(sortMarkers(markers)));
379
+
305
380
  /**
306
381
  * Ordered speaker registry, built from turns first then transcript, so the
307
382
  * palette index is stable across both panes and the legend.
@@ -343,14 +418,58 @@
343
418
  mutedKeys,
344
419
  window: bandWindow,
345
420
  hoverChapter,
421
+ luma: lumaSeries,
422
+ comments: socialMarkers.comments,
423
+ likes: socialMarkers.likes,
424
+ cache: layerCache,
346
425
  colors: {
347
426
  accent: getDaisyUIColor('primary'),
348
427
  neutral: getDaisyUIColor('base-content'),
349
- marker: getDaisyUIColor('warning')
428
+ marker: getDaisyUIColor('warning'),
429
+ ribbon: getDaisyUIColor('warning'),
430
+ comment: getDaisyUIColor('info'),
431
+ like: getDaisyUIColor('secondary'),
432
+ surface: getDaisyUIColor('base-100')
350
433
  }
351
434
  };
352
435
  });
353
436
 
437
+ // ── One time axis (video/band/axis.ts) ─────────────────────────────────
438
+ const currentBandState = $derived<BandState>(
439
+ bandStateOf(hasWave, bandWindow, effectiveDuration, oneAxis)
440
+ );
441
+ const nativeBarHidden = $derived(barHidden(currentBandState.axis));
442
+ /** The overview rail: whole video, window bracketed — only while zoomed. */
443
+ const bracket = $derived(overviewBracket(bandWindow, effectiveDuration));
444
+ const overviewPlayhead = $derived(
445
+ effectiveDuration > 0 ? Math.max(0, Math.min(1, currentTime / effectiveDuration)) : 0
446
+ );
447
+ /** The ribbon on the overview too, as a gradient — FEATURES.md's 120 stops. */
448
+ const overviewRibbon = $derived.by(() => {
449
+ void themeTick;
450
+ if (!lumaSeries || !(effectiveDuration > 0)) return '';
451
+ const alphas = ribbonColumns(lumaSeries, { start: 0, end: effectiveDuration }, effectiveDuration, RIBBON_STOPS);
452
+ const stops = alphas.map(
453
+ (a, i) =>
454
+ `color-mix(in oklch, var(--color-warning) ${(a * 100).toFixed(1)}%, transparent) ${((i / (alphas.length - 1)) * 100).toFixed(2)}%`
455
+ );
456
+ return `linear-gradient(to right, ${stops.join(', ')})`;
457
+ });
458
+
459
+ /**
460
+ * The current four-state arbitration — the extension's `bandState()`.
461
+ * Callable on the instance (`bind:this`) for hosts that prefer to pull.
462
+ */
463
+ export function bandState(): BandState {
464
+ return { ...currentBandState };
465
+ }
466
+
467
+ const reportBand = createBandReporter(() => onBandChange);
468
+ $effect(() => {
469
+ const st = currentBandState;
470
+ untrack(() => reportBand(st));
471
+ });
472
+
354
473
  /** Help-overlay rows: only keys whose feature's data exists are advertised. */
355
474
  const availableKeys = $derived(
356
475
  PLAYER_KEYS.filter((row) => {
@@ -358,6 +477,7 @@
358
477
  if (row.when === 'chapters') return sortedChapters.length > 0;
359
478
  if (row.when === 'bookmarks') return onAddBookmark != null;
360
479
  if (row.when === 'transcript') return sortedSegments.length > 0;
480
+ if (row.when === 'comments') return socialMarkers.comments.length > 0;
361
481
  return true;
362
482
  })
363
483
  );
@@ -508,10 +628,20 @@
508
628
  mq.addEventListener('change', onChange);
509
629
  stopReduce = () => mq.removeEventListener('change', onChange);
510
630
  }
631
+ let stopCoarse: (() => void) | undefined;
632
+ if (typeof window !== 'undefined' && window.matchMedia) {
633
+ const mq = window.matchMedia('(pointer: coarse)');
634
+ coarsePointer = mq.matches;
635
+ const onChange = () => (coarsePointer = mq.matches);
636
+ mq.addEventListener('change', onChange);
637
+ stopCoarse = () => mq.removeEventListener('change', onChange);
638
+ }
511
639
 
512
640
  return () => {
513
641
  stopTheme();
514
642
  stopReduce?.();
643
+ stopCoarse?.();
644
+ if (dragRaf) cancelAnimationFrame(dragRaf);
515
645
  if (zoomTimer) clearTimeout(zoomTimer);
516
646
  if (osdTimer) clearTimeout(osdTimer);
517
647
  };
@@ -565,12 +695,115 @@
565
695
  return { sec: fracToSec(bandWindow, effectiveDuration, x / r.width), x, width: r.width };
566
696
  }
567
697
 
698
+ /**
699
+ * The comment cluster under the pointer, hit-tested in the band's own
700
+ * window against the SAME memoised clusters the paint drew (clientWidth,
701
+ * like the paint — so the hit test and the dots can never disagree).
702
+ */
703
+ function commentUnder(hit: { x: number; width: number }) {
704
+ const canvas = canvasEl;
705
+ if (socialMarkers.comments.length === 0 || !canvas || !hit.width) return null;
706
+ const cw = canvas.clientWidth || hit.width;
707
+ const clusters = bandClusters(socialMarkers.comments, bandWindow, effectiveDuration, cw, layerCache);
708
+ return commentAt(hit.x * (cw / hit.width), clusters, coarsePointer ? MARKER_HIT_PX_COARSE : MARKER_HIT_PX);
709
+ }
710
+
711
+ // ── Screen-reader announcements (comment jumps / clicks) ───────────────
712
+ let announcement = $state('');
713
+ function announce(text: string) {
714
+ // Clear first so repeating the same comment is announced again.
715
+ announcement = '';
716
+ queueMicrotask(() => (announcement = text));
717
+ }
718
+ function landOnComments(group: VideoMarker[]) {
719
+ seekTo(group[0].atSec);
720
+ announce(describeComments(group, (sec) => formatTime(sec, useHours)));
721
+ }
722
+
568
723
  function onBandClick(e: MouseEvent) {
724
+ // The click the browser fires after a drag-scrub's release is not a click.
725
+ if (suppressNextClick) {
726
+ suppressNextClick = false;
727
+ return;
728
+ }
569
729
  const hit = canvasSec(e);
570
730
  if (!hit) return;
731
+ // A comment is ABOUT its instant: land exactly there, no transcript bias.
732
+ const c = commentUnder(hit);
733
+ if (c) {
734
+ landOnComments(c.markers);
735
+ onMarkerClick?.(c.markers[0], c.markers.slice());
736
+ return;
737
+ }
571
738
  seekTo(biasToSegmentStart(hit.sec, sortedSegments));
572
739
  }
573
740
 
741
+ // ── Drag-to-scrub (video/band/drag.ts) ──────────────────────────────────
742
+ // One-axis hides the native timeline, so the band must scrub like it:
743
+ // pointer capture keeps the drag alive off the canvas, seeks are throttled
744
+ // to one per animation frame, and the release lands exactly under the
745
+ // pointer (no transcript bias — a scrub is deliberate).
746
+ let drag: DragState | null = null;
747
+ let dragRaf = 0;
748
+ let suppressNextClick = false;
749
+
750
+ function flushDragSeek() {
751
+ dragRaf = 0;
752
+ if (drag?.pendingSec != null) seekTo(drag.pendingSec);
753
+ }
754
+
755
+ function onBandPointerDown(e: PointerEvent) {
756
+ if (e.button !== 0 || !canvasEl) return;
757
+ drag = dragStart(e.pointerId, e.clientX, currentTime);
758
+ suppressNextClick = false;
759
+ try {
760
+ canvasEl.setPointerCapture(e.pointerId);
761
+ } catch {
762
+ // capture can fail on a pointer that's already gone — the drag just ends early
763
+ }
764
+ }
765
+
766
+ function onBandPointerMove(e: PointerEvent) {
767
+ if (!drag) return;
768
+ const hit = canvasSec(e);
769
+ if (!hit) return;
770
+ const sec = Math.max(0, Math.min(effectiveDuration, hit.sec));
771
+ const next = dragMove(drag, e.pointerId, e.clientX, sec);
772
+ if (next === drag) return;
773
+ drag = next;
774
+ if (!dragRaf) dragRaf = requestAnimationFrame(flushDragSeek);
775
+ }
776
+
777
+ function finishDrag(e: PointerEvent, cancelled: boolean) {
778
+ if (!drag || drag.pointerId !== e.pointerId) return;
779
+ const hit = canvasSec(e);
780
+ const sec = hit ? Math.max(0, Math.min(effectiveDuration, hit.sec)) : (drag.pendingSec ?? currentTime);
781
+ const out = cancelled ? dragCancel(drag, e.pointerId) : dragEnd(drag, e.pointerId, sec);
782
+ if (dragRaf) {
783
+ cancelAnimationFrame(dragRaf);
784
+ dragRaf = 0;
785
+ }
786
+ drag = null;
787
+ if (canvasEl?.hasPointerCapture?.(e.pointerId)) canvasEl.releasePointerCapture(e.pointerId);
788
+ if (out.seekSec != null) seekTo(out.seekSec);
789
+ suppressNextClick = out.suppressClick;
790
+ }
791
+
792
+ /** Overview rail: click seeks, and brings the window along if it lands outside it. */
793
+ function onOverviewClick(e: MouseEvent) {
794
+ const d = effectiveDuration;
795
+ const el = e.currentTarget as HTMLElement;
796
+ const r = el.getBoundingClientRect();
797
+ if (!d || !r.width) return;
798
+ const sec = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d;
799
+ seekTo(sec);
800
+ const follow = followPlayhead(bandWindow, d, sec);
801
+ if (follow) {
802
+ viewStart = follow.start;
803
+ viewEnd = follow.end;
804
+ }
805
+ }
806
+
574
807
  function onBandDblClick() {
575
808
  if (!isZoomedNow()) return;
576
809
  resetZoom();
@@ -604,6 +837,23 @@
604
837
  if (hoverChapter !== -1) hoverChapter = -1;
605
838
  return;
606
839
  }
840
+ const c = commentUnder(hit);
841
+ if (c) {
842
+ // Other people's words at this instant — they outrank the model's
843
+ // chapter guess, but not your own bookmark.
844
+ const first = c.markers[0];
845
+ const more = c.markers.length > 1 ? ` · +${c.markers.length - 1}` : '';
846
+ hover = {
847
+ x: cardX,
848
+ title: `${first.author || 'Comment'} · ${formatTime(first.atSec, useHours)}${more}`,
849
+ text: first.text ? truncate(first.text, 90) : '',
850
+ thumb: null,
851
+ isBookmark: false,
852
+ isComment: true
853
+ };
854
+ if (hoverChapter !== -1) hoverChapter = -1;
855
+ return;
856
+ }
607
857
  const ch = chapterAt(hit.sec, sortedChapters);
608
858
  const title = ch >= 0 ? sortedChapters[ch]?.title : '';
609
859
  const at = segmentAt(hit.sec, sortedSegments);
@@ -782,6 +1032,19 @@
782
1032
  }
783
1033
  return;
784
1034
  }
1035
+ case '{':
1036
+ case '}': {
1037
+ if (socialMarkers.comments.length === 0) return;
1038
+ e.preventDefault();
1039
+ const group = commentJump(currentTime, socialMarkers.comments, e.key === '}' ? 1 : -1);
1040
+ if (group) {
1041
+ landOnComments(group);
1042
+ flashOsd(group[0].author || 'Comment');
1043
+ } else {
1044
+ flashOsd(e.key === '}' ? 'No later comments' : 'No earlier comments');
1045
+ }
1046
+ return;
1047
+ }
785
1048
  case 'b':
786
1049
  case 'B':
787
1050
  if (onAddBookmark) {
@@ -891,6 +1154,8 @@
891
1154
  <div
892
1155
  class="vp-root {className}"
893
1156
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
1157
+ class:vp-one-axis={nativeBarHidden}
1158
+ data-band-axis={currentBandState.axis}
894
1159
  role="region"
895
1160
  aria-label="Video player"
896
1161
  tabindex="-1"
@@ -942,6 +1207,26 @@
942
1207
  onmouseleave={onBandLeave}
943
1208
  role="presentation"
944
1209
  >
1210
+ <!-- Overview rail (one-axis "overview" state): the whole video, with
1211
+ the zoomed window as an outlined bracket. Lives in the band's
1212
+ reserved top padding, so appearing is never a layout shove, and
1213
+ shares the canvas's exact box so the two axes line up. -->
1214
+ {#if bracket}
1215
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1216
+ <div
1217
+ class="vp-overview"
1218
+ role="presentation"
1219
+ style={overviewRibbon ? `--vp-ribbon: ${overviewRibbon};` : undefined}
1220
+ onclick={onOverviewClick}
1221
+ >
1222
+ <div class="vp-overview-played" style="width: {overviewPlayhead * 100}%"></div>
1223
+ <div
1224
+ class="vp-overview-bracket"
1225
+ style="left: {bracket.left * 100}%; width: {bracket.width * 100}%"
1226
+ ></div>
1227
+ <div class="vp-overview-head" style="left: {overviewPlayhead * 100}%"></div>
1228
+ </div>
1229
+ {/if}
945
1230
  <canvas
946
1231
  bind:this={canvasEl}
947
1232
  class="vp-wave"
@@ -954,10 +1239,17 @@
954
1239
  aria-valuenow={Math.round(currentTime)}
955
1240
  aria-valuetext={formatTime(currentTime, useHours)}
956
1241
  onclick={onBandClick}
1242
+ onpointerdown={onBandPointerDown}
1243
+ onpointermove={onBandPointerMove}
1244
+ onpointerup={(e) => finishDrag(e, false)}
1245
+ onpointercancel={(e) => finishDrag(e, true)}
957
1246
  ondblclick={onBandDblClick}
958
1247
  oncontextmenu={onBandContextMenu}
959
1248
  onkeydown={onBandKeyDown}
960
1249
  ></canvas>
1250
+ <!-- Polite announcements for comment jumps/clicks — the dots are
1251
+ canvas pixels, so without this a comment is invisible to AT. -->
1252
+ <div class="vp-sr" aria-live="polite" aria-atomic="true">{announcement}</div>
961
1253
 
962
1254
  <!-- Speaker lane. Painted ONLY as the no-waveform fallback: with an
963
1255
  envelope its own bars wear the turn colours, and two stacked
@@ -1003,6 +1295,11 @@
1003
1295
  <img class="vp-card-thumb" src={hover.thumb} alt="" />
1004
1296
  {/if}
1005
1297
  <div class="vp-card-title">
1298
+ {#if hover.isComment}
1299
+ <svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-cm-icon">
1300
+ <path d="M4 4h16a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H9l-5 4V5a1 1 0 0 1 1-1z" fill="currentColor" />
1301
+ </svg>
1302
+ {/if}
1006
1303
  {#if hover.isBookmark}
1007
1304
  <svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-bm-icon">
1008
1305
  <path d="M6 2a2 2 0 0 0-2 2v18l8-5 8 5V4a2 2 0 0 0-2-2H6z" fill="currentColor" />
@@ -1266,6 +1563,57 @@
1266
1563
  opacity: 1;
1267
1564
  }
1268
1565
 
1566
+ /* One time axis: while the band owns it, the native timeline stands down.
1567
+ Only Chromium exposes it; elsewhere this matches nothing and the native
1568
+ bar simply stays (see video/band/axis.ts). */
1569
+ .vp-one-axis .vp-video::-webkit-media-controls-timeline {
1570
+ display: none;
1571
+ }
1572
+
1573
+ /* Overview rail — sits in the band's padding-top, exactly the canvas's
1574
+ width, so its mapping and the band's share one box. */
1575
+ .vp-overview {
1576
+ position: absolute;
1577
+ top: 1px;
1578
+ left: 0;
1579
+ right: 0;
1580
+ height: 5px;
1581
+ border-radius: var(--jatui-radius-field, 0.25rem);
1582
+ background: var(--vp-ribbon, none), color-mix(in oklch, var(--color-base-content) 12%, transparent);
1583
+ cursor: pointer;
1584
+ overflow: visible;
1585
+ }
1586
+
1587
+ .vp-overview-played {
1588
+ position: absolute;
1589
+ inset: 0 auto 0 0;
1590
+ border-radius: inherit;
1591
+ background: color-mix(in oklch, var(--color-primary) 70%, transparent);
1592
+ }
1593
+
1594
+ /* A RANGE of the axis: an outline, never a fill — the played bar under it
1595
+ stays readable, and range ≠ position at a glance. */
1596
+ .vp-overview-bracket {
1597
+ position: absolute;
1598
+ top: -1px;
1599
+ bottom: -1px;
1600
+ box-sizing: border-box;
1601
+ border: 1px solid var(--color-base-content);
1602
+ border-radius: 2px;
1603
+ pointer-events: none;
1604
+ }
1605
+
1606
+ /* Same line as the canvas playhead: 1.5px, centred on the position. */
1607
+ .vp-overview-head {
1608
+ position: absolute;
1609
+ top: -1px;
1610
+ bottom: -1px;
1611
+ width: 1.5px;
1612
+ transform: translateX(-50%);
1613
+ background: var(--color-base-content);
1614
+ pointer-events: none;
1615
+ }
1616
+
1269
1617
  .vp-wave {
1270
1618
  display: block;
1271
1619
  width: 100%;
@@ -1274,6 +1622,23 @@
1274
1622
  border-radius: var(--jatui-radius-field, 0.25rem);
1275
1623
  }
1276
1624
 
1625
+ /* Horizontal drags belong to the scrubber; vertical still scrolls the page. */
1626
+ .vp-wave {
1627
+ touch-action: pan-y;
1628
+ }
1629
+
1630
+ .vp-sr {
1631
+ position: absolute;
1632
+ width: 1px;
1633
+ height: 1px;
1634
+ padding: 0;
1635
+ margin: -1px;
1636
+ overflow: hidden;
1637
+ clip: rect(0 0 0 0);
1638
+ white-space: nowrap;
1639
+ border: 0;
1640
+ }
1641
+
1277
1642
  .vp-wave:focus-visible {
1278
1643
  outline: 2px solid var(--color-primary);
1279
1644
  outline-offset: 2px;
@@ -1382,6 +1747,11 @@
1382
1747
  flex-shrink: 0;
1383
1748
  }
1384
1749
 
1750
+ .vp-cm-icon {
1751
+ color: var(--color-info);
1752
+ flex-shrink: 0;
1753
+ }
1754
+
1385
1755
  .vp-card-text {
1386
1756
  margin-top: var(--jatui-space-xs, 2px);
1387
1757
  color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
@@ -1,5 +1,11 @@
1
1
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime } from '../video/types';
2
+ export type { BandAxis, BandState } from '../video/band/axis';
3
+ export type { VideoLumaTrack } from '../video/band/luma';
4
+ export type { VideoMarker } from '../video/band/markers';
2
5
  import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
6
+ import { type BandState } from '../video/band/axis';
7
+ import { type VideoLumaTrack } from '../video/band/luma';
8
+ import { type VideoMarker } from '../video/band/markers';
3
9
  type $$ComponentProps = {
4
10
  src: string;
5
11
  poster?: string | null;
@@ -51,6 +57,41 @@ type $$ComponentProps = {
51
57
  transcriptMaxHeight?: string;
52
58
  crossorigin?: 'anonymous' | 'use-credentials' | null;
53
59
  syncUrlTime?: boolean;
60
+ /**
61
+ * The light track — the host's pre-scanned brightness sidecar (integers
62
+ * 0–99 at `rate` samples/s; extension-library's `.luma` verbatim).
63
+ * Present ⇒ the band paints the light ribbon under its envelope, so
64
+ * bright passages glow on the timeline before you reach them. Night
65
+ * dimming (mos-5lzns.33) reads the same prop. Garbage or < 4 samples ⇒
66
+ * no ribbon, never a wrong one.
67
+ */
68
+ luma?: VideoLumaTrack | null;
69
+ /**
70
+ * Social markers on the band: `kind: 'comment'` draws a dot under the
71
+ * chapter flags (hover = the comment, click = seek to its exact second);
72
+ * `kind: 'like'` folds into a heat strip along the bottom edge.
73
+ */
74
+ markers?: VideoMarker[];
75
+ /**
76
+ * One time axis (default on): while the band has an envelope it OWNS the
77
+ * axis and the native timeline stands down (Chromium; elsewhere it can't
78
+ * be hidden and simply stays); zoomed, the band grows a whole-video
79
+ * overview rail with the window bracketed. Off ⇒ both, permanently.
80
+ */
81
+ oneAxis?: boolean;
82
+ /**
83
+ * The four-state arbitration, reported on real change only and deferred
84
+ * a microtask (the host's ref to this component may not be set yet when
85
+ * the first report fires). A host that draws its OWN seek bar reads
86
+ * `axis` to decide whether to show it — see video/band/axis.ts.
87
+ */
88
+ onBandChange?: (state: BandState) => void;
89
+ /**
90
+ * A comment marker was clicked (after the seek to it). Dots within a few
91
+ * px merge into one; `cluster` carries every comment under that dot,
92
+ * earliest first, and `marker` is `cluster[0]` — the one seeked to.
93
+ */
94
+ onMarkerClick?: (marker: VideoMarker, cluster: VideoMarker[]) => void;
54
95
  onTimeUpdate?: (video: HTMLVideoElement) => void;
55
96
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
56
97
  onAddBookmark?: (playbackSec: number, label: string) => void;
@@ -58,6 +99,8 @@ type $$ComponentProps = {
58
99
  onBookmarkContext?: (sec: number, existing: VideoBookmark | null, clientX: number, clientY: number) => void;
59
100
  class?: string;
60
101
  };
61
- declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {}, "">;
102
+ declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
103
+ bandState: () => BandState;
104
+ }, "">;
62
105
  type VideoPlayer = ReturnType<typeof VideoPlayer>;
63
106
  export default VideoPlayer;
package/dist/index.d.ts CHANGED
@@ -12,7 +12,12 @@ 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, } from './components/VideoPlayer.svelte';
15
+ export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, } from './components/VideoPlayer.svelte';
16
+ export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
17
+ export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
18
+ export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
19
+ export { createQuietChain, amountFor, type QuietChain, type QuietAudioContext, type AmountOpts } from './video/advisors/quiet-listening';
20
+ export { qrMatrix, qrCanvas, type QrMatrix, type QrCanvasOpts } from './video/advisors/qr';
16
21
  export { default as Flythrough } from './components/Flythrough.svelte';
17
22
  export { default as LessonEmbed } from './components/LessonEmbed.svelte';
18
23
  export type { Collection, CollectionCut, CollectionBeat, } from './components/LessonEmbed.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,16 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
13
13
  // optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
14
14
  // stays exported unchanged — meetings already depends on it.
15
15
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
16
+ // Pure advisors (mos-5lzns.27): the extension-library self-test modules,
17
+ // ported verbatim. They measure and advise; the player's feature tasks
18
+ // (.31 audio / .32 rate / .34 presence) consume them. qrCanvas is the one
19
+ // DOM-touching surface (its only guard is a missing document), everything
20
+ // else is node-testable pure.
21
+ export { buildSilenceMap, boostAt, estimateSaving } from './video/advisors/smart-speed';
22
+ export { speakerPace, suggestRates, rateAt, describeRates } from './video/advisors/speaker-rate';
23
+ export { snapResume, resumeLabel } from './video/advisors/resume-point';
24
+ export { createQuietChain, amountFor } from './video/advisors/quiet-listening';
25
+ export { qrMatrix, qrCanvas } from './video/advisors/qr';
16
26
  // Presentation-primitive video trio (unified-video-pipeline PRD): the frozen
17
27
  // surface academy + mardukos consume. VideoPlayer (above) doubles as the full
18
28
  // player AND the presentation player — extended with aspect/autoplay/loop +