@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
@@ -22,61 +22,13 @@
22
22
  * re-render of this module.
23
23
  */
24
24
  import { ALL_KEY, filterDims, speakerKeyOf } from './model';
25
- /**
26
- * The visible window in seconds. Falls back to the whole video until the
27
- * duration effect has seeded `end`, so a first paint that races metadata
28
- * still maps positions correctly instead of collapsing to zero.
29
- */
30
- export function spanSec(w, duration) {
31
- return w.end > w.start ? w.end - w.start : duration;
32
- }
33
- export function isZoomed(w, duration) {
34
- return w.end > 0 && spanSec(w, duration) < duration - 0.01;
35
- }
36
- export function resetWindow(duration) {
37
- return { start: 0, end: duration };
38
- }
39
- export function secToFrac(w, duration, sec) {
40
- const span = spanSec(w, duration);
41
- return span > 0 ? (sec - w.start) / span : 0;
42
- }
43
- export function fracToSec(w, duration, frac) {
44
- return w.start + frac * spanSec(w, duration);
45
- }
46
- /**
47
- * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
48
- * under the pointer stays fixed while the window resizes around it, so
49
- * scrolling repeatedly walks you inward on exactly the spot you're pointing
50
- * at. `deltaY < 0` zooms in.
51
- */
52
- export const ZOOM_FACTOR = 1.3;
53
- export const MIN_WINDOW_SEC = 3;
54
- export function zoomAt(w, duration, cursorSec, deltaY) {
55
- if (!duration)
56
- return w;
57
- if (w.end <= 0)
58
- w = resetWindow(duration);
59
- const span = spanSec(w, duration);
60
- let newSpan = deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
61
- newSpan = Math.min(duration, Math.max(MIN_WINDOW_SEC, newSpan));
62
- const frac = span > 0 ? (cursorSec - w.start) / span : 0.5;
63
- const start = Math.max(0, Math.min(cursorSec - frac * newSpan, duration - newSpan));
64
- return { start, end: start + newSpan };
65
- }
66
- /**
67
- * Zoomed in and playback walked past the edge of the window — follow it, or
68
- * you'd watch a frozen slice of waveform while the video plays on. Returns
69
- * null when no follow is needed.
70
- */
71
- export function followPlayhead(w, duration, t) {
72
- if (!isZoomed(w, duration))
73
- return null;
74
- if (t >= w.start && t <= w.end)
75
- return null;
76
- const span = spanSec(w, duration);
77
- const start = Math.max(0, Math.min(t - span / 2, Math.max(0, duration - span)));
78
- return { start, end: start + span };
79
- }
25
+ // ── Zoom window: lives in band/window.ts (shared with the band/ layers) ─────
26
+ import { fracToSec, secToFrac } from './band/window';
27
+ import { PLAYHEAD_W, playheadX } from './band/axis';
28
+ import { ribbonColumns } from './band/luma';
29
+ import { commentClusters, HEAT_BIN_PX, likeHeat } from './band/markers';
30
+ import { memo } from './band/memo';
31
+ export { fracToSec, followPlayhead, isZoomed, MIN_WINDOW_SEC, resetWindow, secToFrac, spanSec, ZOOM_FACTOR, zoomAt } from './band/window';
80
32
  // ── Hit tests / position helpers ────────────────────────────────────────────
81
33
  /**
82
34
  * Click-to-seek biases toward the START of the spoken line under the click —
@@ -141,6 +93,11 @@ export function clampCardX(x, width) {
141
93
  }
142
94
  // ── Canvas paint ────────────────────────────────────────────────────────────
143
95
  export const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
96
+ /** Like heat strip height, px, along the band's bottom edge (under the bookmark diamonds). */
97
+ export const HEAT_H = 3;
98
+ /** Comment dot radius and centre line (just under the chapter flags, which occupy y 0–4). */
99
+ export const COMMENT_R = 2.5;
100
+ export const COMMENT_Y = 7.5;
144
101
  /**
145
102
  * Size the canvas backing store for devicePixelRatio and return its 2d
146
103
  * context with the transform applied — or null when there is nothing honest
@@ -183,6 +140,27 @@ export function paintBand(ctx, W, H, s) {
183
140
  ctx.globalAlpha = 1;
184
141
  return;
185
142
  }
143
+ // The light ribbon: a wash under the envelope, through the same window as
144
+ // the peaks so it zooms with everything else. Painted FIRST so every
145
+ // other layer sits on top of it — it is a property of the timeline, not
146
+ // a fourth bar (FEATURES.md §The light track).
147
+ if (s.luma) {
148
+ const RIBBON_COL_PX = 2;
149
+ const cols = Math.max(1, Math.ceil(W / RIBBON_COL_PX));
150
+ const luma = s.luma;
151
+ const compute = () => ribbonColumns(luma, s.window, dur, cols);
152
+ const alphas = s.cache
153
+ ? memo(s.cache.ribbon, [luma, s.window.start, s.window.end, dur, cols], compute)
154
+ : compute();
155
+ ctx.fillStyle = s.colors.ribbon ?? marker;
156
+ for (let i = 0; i < alphas.length; i++) {
157
+ if (alphas[i] <= 0.004)
158
+ continue;
159
+ ctx.globalAlpha = alphas[i];
160
+ ctx.fillRect((i * W) / cols, 0, W / cols + 0.5, H);
161
+ }
162
+ ctx.globalAlpha = 1;
163
+ }
186
164
  const p = s.peaks;
187
165
  const withWave = !!p;
188
166
  const n = p?.length ?? 0;
@@ -277,6 +255,38 @@ export function paintBand(ctx, W, H, s) {
277
255
  ctx.closePath();
278
256
  ctx.fill();
279
257
  }
258
+ // Likes: one heat strip along the bottom edge, never individual glyphs.
259
+ if (s.likes && s.likes.length) {
260
+ const bins = Math.max(1, Math.ceil(W / HEAT_BIN_PX));
261
+ const likes = s.likes;
262
+ const compute = () => likeHeat(likes, w, dur, bins);
263
+ const heat = s.cache ? memo(s.cache.heat, [likes, w.start, w.end, dur, bins], compute) : compute();
264
+ ctx.fillStyle = s.colors.like ?? accent;
265
+ for (let i = 0; i < heat.length; i++) {
266
+ if (heat[i] <= 0)
267
+ continue;
268
+ ctx.globalAlpha = 0.15 + 0.8 * heat[i];
269
+ ctx.fillRect((i * W) / bins, H - HEAT_H, W / bins + 0.5, HEAT_H);
270
+ }
271
+ ctx.globalAlpha = 1;
272
+ }
273
+ // Comments: round dots just under the chapter flags, merged when close.
274
+ if (s.comments && s.comments.length) {
275
+ const surface = s.colors.surface;
276
+ for (const c of bandClusters(s.comments, w, dur, W, s.cache)) {
277
+ ctx.beginPath();
278
+ ctx.arc(c.x, COMMENT_Y, COMMENT_R + (c.markers.length > 1 ? 0.75 : 0), 0, Math.PI * 2);
279
+ if (surface) {
280
+ ctx.globalAlpha = 0.9;
281
+ ctx.lineWidth = 1.5;
282
+ ctx.strokeStyle = surface;
283
+ ctx.stroke();
284
+ }
285
+ ctx.globalAlpha = 1;
286
+ ctx.fillStyle = s.colors.comment ?? accent;
287
+ ctx.fill();
288
+ }
289
+ }
280
290
  // Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
281
291
  // of the chapter flags on top, so yours and the model's never blur together.
282
292
  ctx.fillStyle = marker;
@@ -295,7 +305,9 @@ export function paintBand(ctx, W, H, s) {
295
305
  // Playhead
296
306
  ctx.globalAlpha = 0.9;
297
307
  ctx.fillStyle = neutral;
298
- ctx.fillRect(Math.max(0, Math.min(1, secToFrac(w, dur, s.currentTime))) * W - 0.5, 0, 1.5, H);
308
+ // Centred on playheadX — the one function every "where you are" widget
309
+ // uses (band/axis.ts), so the overview rail and the band agree to ≤0.2px.
310
+ ctx.fillRect(playheadX(s.currentTime, w, dur, W) - PLAYHEAD_W / 2, 0, PLAYHEAD_W, H);
299
311
  ctx.globalAlpha = 1;
300
312
  }
301
313
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
@@ -309,3 +321,12 @@ export function toBandTurns(turns) {
309
321
  }
310
322
  // Re-exports so the component (and sibling modules) have one band import site.
311
323
  export { ALL_KEY };
324
+ /**
325
+ * Comment clusters for this window/width — memoised when a cache is given.
326
+ * The component's hit test calls this with the SAME cache as the paint, so a
327
+ * mousemove never re-clusters what the last repaint already did.
328
+ */
329
+ export function bandClusters(comments, w, duration, width, cache) {
330
+ const compute = () => commentClusters(comments, w, duration, width);
331
+ return cache ? memo(cache.clusters, [comments, w.start, w.end, duration, width], compute) : compute();
332
+ }
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
20
20
  * Availability gate: the row is only advertised (and the key only bound)
21
21
  * when the feature's data exists. Undefined ⇒ always active.
22
22
  */
23
- when?: 'chapters' | 'bookmarks' | 'transcript';
23
+ when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments';
24
24
  }
25
25
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
26
26
  export declare const PLAYER_KEYS: PlayerKeyRow[];
@@ -23,6 +23,9 @@ export const PLAYER_KEYS = [
23
23
  { keys: 'Backspace', description: 'Reset speed to 1×' },
24
24
  { keys: '< / >', description: 'Frame step (pauses; ~1/30s per press)' },
25
25
  { keys: '[ / ]', description: 'Previous / next chapter', when: 'chapters' },
26
+ // Shift+[ / Shift+] — the chapter keys' shifted twins, so "structure"
27
+ // and "people's reactions to it" sit under one hand (mos-5lzns.30).
28
+ { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
26
29
  { keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
27
30
  { keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
28
31
  { keys: 'Home / End', description: 'Jump to start / end' },
@@ -30,11 +30,12 @@
30
30
  * the honest state while every behaviour is prop-driven.
31
31
  */
32
32
  export const VIDEO_SETTINGS = [
33
- // { id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30' },
34
- // { id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31' },
35
- // { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
36
- // { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto','on','off'], owner: 'mos-5lzns.33' },
37
- // { id: 'sleep.enabled', type: 'bool', default: true, owner: 'mos-5lzns.34' },
33
+ // Read by VideoPlayer's `oneAxis` prop → video/band/axis.ts resolveAxis().
34
+ { id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30', wired: true },
35
+ // { id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31' },
36
+ // { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
37
+ // { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto','on','off'], owner: 'mos-5lzns.33' },
38
+ // { id: 'sleep.enabled', type: 'bool', default: true, owner: 'mos-5lzns.34' },
38
39
  ];
39
40
  /** Coerce a raw stored value against a spec — never throws, never returns junk. */
40
41
  export function coerce(spec, raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -76,7 +76,8 @@
76
76
  "check:demos": "node scripts/check-demo-coverage.mjs",
77
77
  "guard": "node scripts/check-demo-coverage.mjs",
78
78
  "test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
79
- "test:unit": "node --import ./tests/unit/register.mjs --test tests/unit/",
79
+ "test:unit": "node --import ./tests/unit/register.mjs --test tests/unit/*.test.ts",
80
+ "test:advisors": "svelte-kit sync && vitest run --config vitest.unit.config.ts",
80
81
  "prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
81
82
  },
82
83
  "peerDependencies": {