@joewinke/jatui 0.31.2 → 0.32.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.
@@ -9,6 +9,8 @@
9
9
  VideoWaveform,
10
10
  VideoSpeakerTalkTime
11
11
  } from '../video/types';
12
+ // mos-777hj — chapter rail public types.
13
+ export type { RailChapter, RailChapterStatus } from '../video/chapterRail';
12
14
  // mos-5lzns.30 — one-axis state, the light track, social markers.
13
15
  export type { BandAxis, BandState } from '../video/band/axis';
14
16
  export type { VideoLumaTrack } from '../video/band/luma';
@@ -79,8 +81,11 @@
79
81
  * colour, cycled per speaker — not brand colour).
80
82
  */
81
83
 
82
- import { onMount, untrack } from 'svelte';
84
+ import { onMount, untrack, type Snippet } from 'svelte';
83
85
  import { getDaisyUIColor, watchThemeChanges, clearColorCache } from '../utils/daisyuiColors';
86
+ import { listNav } from '../actions/listNav';
87
+ // mos-777hj — the chapter rail's pure state (ends, played checks, progress).
88
+ import { railChapters, activeRailIndex, type RailChapter } from '../video/chapterRail';
84
89
  import type {
85
90
  VideoBookmark,
86
91
  VideoChapter,
@@ -255,6 +260,9 @@
255
260
  captionsLang = 'en',
256
261
  captionsDefault = true,
257
262
  chapters = [],
263
+ chapterRail: chapterRailProp = 'off',
264
+ onChapterSelect = undefined,
265
+ belowStage = undefined,
258
266
  speakers = [],
259
267
  transcript = [],
260
268
  bookmarks = [],
@@ -369,6 +377,33 @@
369
377
  */
370
378
  captionsDefault?: boolean;
371
379
  chapters?: VideoChapter[];
380
+ /**
381
+ * mos-777hj — the live chapter rail. `'side'` (with chapters present)
382
+ * renders a sticky chapter pane beside the main column at ≥64rem — one
383
+ * row per chapter with time, title, status dot and summary, the active
384
+ * row tracking the playhead (aria-current, auto-scroll), a progress
385
+ * fill inside the active row and a played check behind the playhead —
386
+ * and a horizontal chip strip under the player below 64rem. Rows seek
387
+ * to the chapter and play; the active row carries the replay affordance
388
+ * (clicking it again replays from its start). Keyboard: listNav j/k to
389
+ * move, Enter to play, while the rail holds focus. Default `'off'` —
390
+ * the rail is opt-in and leaves the player exactly as it was.
391
+ */
392
+ chapterRail?: 'side' | 'off';
393
+ /**
394
+ * Fires when the viewer picks a chapter (row click / Enter / replay):
395
+ * the host pins its own view (e.g. a per-chapter visual panel) — the
396
+ * rail itself always keeps time-tracking the playhead.
397
+ */
398
+ onChapterSelect?: (chapter: VideoChapter, index: number) => void;
399
+ /**
400
+ * Optional snippet rendered between the stage and the transcript — the
401
+ * host's own content in the player's main column (e.g. a "now
402
+ * discussing" panel bound to the active chapter). Designed for
403
+ * `transcriptPosition: 'below'`; beside a side transcript it renders
404
+ * under the stage in the stage's column.
405
+ */
406
+ belowStage?: Snippet;
372
407
  speakers?: VideoSpeakerTurn[];
373
408
  transcript?: VideoTranscriptSegment[];
374
409
  bookmarks?: VideoBookmark[];
@@ -960,6 +995,42 @@
960
995
  const useHours = $derived(effectiveDuration >= 3600);
961
996
 
962
997
  const sortedChapters = $derived(sortChapters(chapters));
998
+
999
+ // ── Chapter rail (mos-777hj) ─────────────────────────────────────────
1000
+ /** The rail pane exists only when opted in AND there is something to rail. */
1001
+ const railOn = $derived(chapterRailProp === 'side' && sortedChapters.length > 0);
1002
+ /** Rows recomputed per playhead tick — ends, played checks, progress fill. */
1003
+ const railRows = $derived(railOn ? railChapters(sortedChapters, effectiveDuration, currentTime) : []);
1004
+ const railActive = $derived(railOn ? activeRailIndex(sortedChapters, currentTime) : -1);
1005
+ let chapterRailEl: HTMLElement | null = $state(null);
1006
+
1007
+ /** Row click / Enter / replay: seek to the chapter and play it. */
1008
+ function chapterGo(index: number) {
1009
+ const ch = sortedChapters[index];
1010
+ if (!ch) return;
1011
+ seekTo(ch.startSec);
1012
+ videoEl?.play()?.catch(() => {});
1013
+ onChapterSelect?.(ch, index);
1014
+ }
1015
+
1016
+ /** listNav onSelect — the element carries its row index. */
1017
+ function onRailSelect(el: HTMLElement) {
1018
+ const idx = Number(el.dataset.chapterIndex);
1019
+ if (Number.isInteger(idx)) chapterGo(idx);
1020
+ }
1021
+
1022
+ /** Keep the active row visible while playback tracks chapters. Nearest
1023
+ * only ever scrolls the rail's own overflow (never the page); smooth is
1024
+ * dropped under prefers-reduced-motion. */
1025
+ $effect(() => {
1026
+ if (!railOn || railActive < 0) return;
1027
+ const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
1028
+ if (!row) return;
1029
+ row.scrollIntoView({
1030
+ block: 'nearest',
1031
+ behavior: reduceMotion ? 'auto' : 'smooth'
1032
+ });
1033
+ });
963
1034
  const sortedTurns = $derived(sortTurns(speakers));
964
1035
  const sortedSegments = $derived(sortSegments(transcript));
965
1036
  const sortedBookmarks = $derived(sortBookmarks(bookmarks));
@@ -1094,6 +1165,16 @@
1094
1165
  return { ...currentBandState };
1095
1166
  }
1096
1167
 
1168
+ /**
1169
+ * Callable on the instance (`bind:this`) — seek to an absolute second.
1170
+ * The host-side half of deep links (mos-777hj): a host that resolves its
1171
+ * own anchors (e.g. #chapter-<id>) seeks through this after mount, the
1172
+ * same place `startAt`/`?t=` seed from.
1173
+ */
1174
+ export function seekToTime(sec: number) {
1175
+ seekTo(sec);
1176
+ }
1177
+
1097
1178
  const reportBand = createBandReporter(() => onBandChange);
1098
1179
  $effect(() => {
1099
1180
  const st = currentBandState;
@@ -1105,6 +1186,7 @@
1105
1186
  PLAYER_KEYS.filter((row) => {
1106
1187
  if (!row.when) return true;
1107
1188
  if (row.when === 'chapters') return sortedChapters.length > 0;
1189
+ if (row.when === 'rail') return railOn;
1108
1190
  if (row.when === 'bookmarks') return onAddBookmark != null;
1109
1191
  if (row.when === 'transcript') return sortedSegments.length > 0;
1110
1192
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
@@ -2532,6 +2614,10 @@
2532
2614
  const target = e.target as HTMLElement | null;
2533
2615
  const tag = target?.tagName;
2534
2616
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
2617
+ // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
2618
+ // activation) while focus is inside it; without this guard a rail `k`
2619
+ // would also toggle play/pause and a rail `j` also seek ±5s.
2620
+ if (chapterRailEl && target && chapterRailEl.contains(target)) return;
2535
2621
  if (e.metaKey || e.ctrlKey || e.altKey) return;
2536
2622
  const v = videoEl;
2537
2623
  if (!v) return;
@@ -2786,6 +2872,7 @@
2786
2872
  class="vp-root {className}"
2787
2873
  bind:this={rootEl}
2788
2874
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
2875
+ class:vp-rail-side={railOn}
2789
2876
  class:vp-one-axis={nativeBarHidden}
2790
2877
  class:vp-night-capable={nightPresent}
2791
2878
  class:vp-night-surround={surroundMix > 0.001}
@@ -3144,6 +3231,79 @@
3144
3231
  {/if}
3145
3232
  </div>
3146
3233
 
3234
+ {#if railOn}
3235
+ <!-- mos-777hj — the live chapter rail: one row per chapter with time,
3236
+ title, status dot and summary. The active row tracks the playhead
3237
+ (aria-current + auto-scroll), fills its thin progress bar, and
3238
+ carries the replay affordance; played chapters wear a check. A row
3239
+ click seeks there and plays (clicking the active row again replays
3240
+ it from its start). listNav owns j/k + Enter while the rail holds
3241
+ focus (global:false scopes the listener to this pane; the root
3242
+ keydown handler skips events from inside it). ≥64rem the pane is a
3243
+ sticky column; below it collapses to a horizontal chip strip. -->
3244
+ <nav
3245
+ class="vp-chapters"
3246
+ bind:this={chapterRailEl}
3247
+ aria-label="Chapters"
3248
+ use:listNav={{
3249
+ itemSelector: '.vp-chapter-row',
3250
+ onSelect: onRailSelect,
3251
+ focusedClass: 'jk-focused',
3252
+ global: false
3253
+ }}
3254
+ >
3255
+ {#each railRows as ch, i (i)}
3256
+ <button
3257
+ type="button"
3258
+ class="vp-chapter-row"
3259
+ class:active={railActive === i}
3260
+ class:done={ch.played}
3261
+ data-chapter-index={i}
3262
+ aria-current={railActive === i ? 'true' : undefined}
3263
+ onclick={() => chapterGo(i)}
3264
+ >
3265
+ <span class="vp-chapter-head">
3266
+ {#if ch.status}
3267
+ <span class="vp-chapter-dot vp-chapter-dot-{ch.status}" aria-hidden="true"></span>
3268
+ {/if}
3269
+ <span class="vp-chapter-time">{formatTime(ch.startSec, useHours)}</span>
3270
+ <span class="vp-chapter-title">{ch.title}</span>
3271
+ {#if ch.played}
3272
+ <svg class="vp-chapter-check" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
3273
+ <path d="M2.5 8.5 6 12l7.5-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3274
+ </svg>
3275
+ {/if}
3276
+ {#if railActive === i}
3277
+ <!-- Replay affordance: the active row's click already replays
3278
+ from its start, so the icon is the affordance's label. -->
3279
+ <svg class="vp-chapter-replay" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
3280
+ <path d="M3 12a9 9 0 1 0 2.64-6.36M3 4v4h4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
3281
+ </svg>
3282
+ {/if}
3283
+ </span>
3284
+ {#if ch.summary}
3285
+ <span class="vp-chapter-summary">{ch.summary}</span>
3286
+ {/if}
3287
+ {#if railActive === i}
3288
+ <span
3289
+ class="vp-chapter-bar"
3290
+ aria-hidden="true"
3291
+ style="--vp-chapter-progress: {ch.progress}"
3292
+ >
3293
+ <span class="vp-chapter-fill"></span>
3294
+ </span>
3295
+ {/if}
3296
+ </button>
3297
+ {/each}
3298
+ </nav>
3299
+ {/if}
3300
+
3301
+ {#if belowStage}
3302
+ <!-- mos-777hj — the host's content in the main column: under the stage
3303
+ (and its band), above the transcript. -->
3304
+ <div class="vp-below-stage">{@render belowStage()}</div>
3305
+ {/if}
3306
+
3147
3307
  {#if sortedSegments.length > 0 && transcriptVisible}
3148
3308
  <div
3149
3309
  class="vp-transcript"
@@ -3276,6 +3436,232 @@
3276
3436
  }
3277
3437
  }
3278
3438
 
3439
+ /* ── Chapter rail (mos-777hj) ────────────────────────────────────────
3440
+ Opt-in (`chapterRail="side"` + chapters). ≥64rem the pane is a sticky
3441
+ column on the right of the main content (below the stage when the
3442
+ transcript sits below, a third column beside a side transcript);
3443
+ below 64rem the same DOM flows as a horizontal chip strip between the
3444
+ player and the below-stage content — grid areas re-place the panes,
3445
+ the DOM order never changes. Token-only chrome, dot tones resolve
3446
+ DaisyUI semantic vars. */
3447
+ .vp-below-stage {
3448
+ min-width: 0;
3449
+ }
3450
+
3451
+ .vp-chapters {
3452
+ display: flex;
3453
+ flex-direction: column;
3454
+ gap: 1px;
3455
+ min-width: 0;
3456
+ background: var(--color-base-100);
3457
+ border: var(--jatui-border-w, 1px) solid
3458
+ color-mix(in oklch, var(--color-base-content) 12%, transparent);
3459
+ border-radius: var(--jatui-radius-box, 0.5rem);
3460
+ padding: var(--jatui-space-sm, 4px);
3461
+ overflow-y: auto;
3462
+ }
3463
+
3464
+ .vp-chapter-row {
3465
+ position: relative;
3466
+ display: flex;
3467
+ flex-direction: column;
3468
+ gap: 2px;
3469
+ background: none;
3470
+ border: none;
3471
+ border-radius: var(--jatui-radius-field, 0.25rem);
3472
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
3473
+ text-align: left;
3474
+ cursor: pointer;
3475
+ transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
3476
+ }
3477
+
3478
+ .vp-chapter-row:hover {
3479
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
3480
+ }
3481
+
3482
+ .vp-chapter-row.active {
3483
+ background: color-mix(in oklch, var(--color-primary) 14%, transparent);
3484
+ box-shadow: inset 2px 0 0 var(--color-primary);
3485
+ }
3486
+
3487
+ /* listNav's cursor (j/k) — same family as the hover/active surfaces. */
3488
+ .vp-chapter-row.jk-focused {
3489
+ outline: 2px solid color-mix(in oklch, var(--color-primary) 55%, transparent);
3490
+ outline-offset: -2px;
3491
+ }
3492
+
3493
+ .vp-chapter-row.done .vp-chapter-title {
3494
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
3495
+ }
3496
+
3497
+ .vp-chapter-head {
3498
+ display: flex;
3499
+ align-items: baseline;
3500
+ gap: var(--jatui-space-sm, 4px);
3501
+ min-width: 0;
3502
+ }
3503
+
3504
+ .vp-chapter-dot {
3505
+ width: 0.5em;
3506
+ height: 0.5em;
3507
+ border-radius: var(--jatui-radius-selector, 0.375rem);
3508
+ flex: none;
3509
+ align-self: center;
3510
+ background: var(--color-base-content);
3511
+ opacity: 0.4;
3512
+ }
3513
+
3514
+ .vp-chapter-dot-info {
3515
+ background: var(--color-info);
3516
+ opacity: 1;
3517
+ }
3518
+
3519
+ .vp-chapter-dot-success {
3520
+ background: var(--color-success);
3521
+ opacity: 1;
3522
+ }
3523
+
3524
+ .vp-chapter-dot-warning {
3525
+ background: var(--color-warning);
3526
+ opacity: 1;
3527
+ }
3528
+
3529
+ .vp-chapter-dot-error {
3530
+ background: var(--color-error);
3531
+ opacity: 1;
3532
+ }
3533
+
3534
+ .vp-chapter-time {
3535
+ font-family: ui-monospace, monospace;
3536
+ font-size: 0.66rem;
3537
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
3538
+ flex-shrink: 0;
3539
+ }
3540
+
3541
+ .vp-chapter-title {
3542
+ font-size: 0.76rem;
3543
+ font-weight: 600;
3544
+ color: var(--color-base-content);
3545
+ flex: 1;
3546
+ min-width: 0;
3547
+ overflow: hidden;
3548
+ text-overflow: ellipsis;
3549
+ white-space: nowrap;
3550
+ }
3551
+
3552
+ .vp-chapter-check {
3553
+ color: var(--color-success);
3554
+ flex: none;
3555
+ align-self: center;
3556
+ }
3557
+
3558
+ .vp-chapter-replay {
3559
+ color: color-mix(in oklch, var(--color-primary) 80%, transparent);
3560
+ flex: none;
3561
+ align-self: center;
3562
+ }
3563
+
3564
+ .vp-chapter-summary {
3565
+ font-size: 0.68rem;
3566
+ line-height: 1.35;
3567
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
3568
+ overflow: hidden;
3569
+ display: -webkit-box;
3570
+ -webkit-box-orient: vertical;
3571
+ -webkit-line-clamp: 2;
3572
+ }
3573
+
3574
+ /* The active row's thin fill: transform, not width — a compositor move
3575
+ that never relayouts the row, stepping with the playhead tick. */
3576
+ .vp-chapter-bar {
3577
+ position: absolute;
3578
+ left: var(--jatui-space-md, 6px);
3579
+ right: var(--jatui-space-md, 6px);
3580
+ bottom: 1px;
3581
+ height: 2px;
3582
+ border-radius: 999px;
3583
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
3584
+ overflow: hidden;
3585
+ pointer-events: none;
3586
+ }
3587
+
3588
+ .vp-chapter-fill {
3589
+ display: block;
3590
+ width: 100%;
3591
+ height: 100%;
3592
+ border-radius: inherit;
3593
+ background: var(--color-primary);
3594
+ transform: scaleX(var(--vp-chapter-progress, 0));
3595
+ transform-origin: left center;
3596
+ transition: transform 120ms linear;
3597
+ }
3598
+
3599
+ @media (max-width: 63.999rem) {
3600
+ /* The chip strip: same rows, laid horizontally under the player. */
3601
+ .vp-chapters {
3602
+ flex-direction: row;
3603
+ gap: var(--jatui-space-xs, 2px);
3604
+ overflow-x: auto;
3605
+ overflow-y: hidden;
3606
+ padding: var(--jatui-space-xs, 2px);
3607
+ }
3608
+
3609
+ .vp-chapter-row {
3610
+ flex: none;
3611
+ max-width: 16rem;
3612
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-sm, 4px);
3613
+ }
3614
+
3615
+ .vp-chapter-summary,
3616
+ .vp-chapter-bar {
3617
+ display: none;
3618
+ }
3619
+
3620
+ .vp-chapter-title {
3621
+ max-width: 12rem;
3622
+ }
3623
+ }
3624
+
3625
+ @media (min-width: 64rem) {
3626
+ .vp-root.vp-rail-side {
3627
+ display: grid;
3628
+ grid-template-columns: minmax(0, 1fr) minmax(13rem, 16rem);
3629
+ grid-template-areas:
3630
+ 'main rail'
3631
+ 'below rail'
3632
+ 'transcript rail';
3633
+ gap: var(--jatui-space-lg, 12px);
3634
+ align-items: start;
3635
+ }
3636
+
3637
+ .vp-root.vp-rail-side > .vp-main {
3638
+ grid-area: main;
3639
+ }
3640
+
3641
+ .vp-root.vp-rail-side > .vp-chapters {
3642
+ grid-area: rail;
3643
+ position: sticky;
3644
+ top: var(--jatui-space-lg, 12px);
3645
+ max-height: calc(100vh - 2 * var(--jatui-space-lg, 12px));
3646
+ }
3647
+
3648
+ .vp-root.vp-rail-side > .vp-below-stage {
3649
+ grid-area: below;
3650
+ }
3651
+
3652
+ .vp-root.vp-rail-side > .vp-transcript {
3653
+ grid-area: transcript;
3654
+ }
3655
+
3656
+ /* Rail beside a side transcript (theater): three columns. */
3657
+ .vp-root.vp-side.vp-rail-side {
3658
+ grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr) minmax(11rem, 14rem);
3659
+ grid-template-areas:
3660
+ 'main transcript rail'
3661
+ 'below transcript rail';
3662
+ }
3663
+ }
3664
+
3279
3665
  .vp-stage {
3280
3666
  position: relative;
3281
3667
  }
@@ -3838,7 +4224,9 @@
3838
4224
  .vp-lane-span,
3839
4225
  .vp-chip,
3840
4226
  .vp-add-btn,
3841
- .vp-row {
4227
+ .vp-row,
4228
+ .vp-chapter-row,
4229
+ .vp-chapter-fill {
3842
4230
  transition: none;
3843
4231
  }
3844
4232
  }
@@ -1,4 +1,5 @@
1
1
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime } from '../video/types';
2
+ export type { RailChapter, RailChapterStatus } from '../video/chapterRail';
2
3
  export type { BandAxis, BandState } from '../video/band/axis';
3
4
  export type { VideoLumaTrack } from '../video/band/luma';
4
5
  export type { VideoMarker } from '../video/band/markers';
@@ -8,6 +9,58 @@ export type { VideoAdSpan, RateReport } from '../video/rate';
8
9
  export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
9
10
  export type { ResumePoint } from '../video/advisors/resume-point';
10
11
  export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
12
+ /**
13
+ * VideoPlayer — one video surface for everything, from a bare mp4 to a
14
+ * fully-analysed recording.
15
+ *
16
+ * Marries two prior implementations rather than inventing a third:
17
+ *
18
+ * 1. extension-library's framework-agnostic `mountPlayer` (player.js) —
19
+ * the timeline band: waveform envelope as the scrubber, chapters as
20
+ * structure inside it, diarized speaker colouring, bookmark diamonds,
21
+ * scroll-to-zoom, hover card with "what's being said here".
22
+ * 2. jatui's TranscriptPlayer — the transcript pane: click a line to seek,
23
+ * active line auto-scroll, solo-speaker playback, talk-time bars.
24
+ *
25
+ * EVERY intelligence prop is optional. A bare `<VideoPlayer {src} />` still
26
+ * gets the band (a real scrubber with hover-time, click-to-seek, keyboard
27
+ * seek), wheel volume and the keyboard map; chapters, speakers, waveform,
28
+ * transcript, bookmarks and talk-time each light up their own pane as the
29
+ * data exists. Nothing shifts layout when data arrives late (diarization is
30
+ * slow) — the band's slots are reserved from the first frame.
31
+ *
32
+ * ── Design notes carried over from player.js ────────────────────────────
33
+ * · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
34
+ * the speaker lane, click/hover-to-seek — maps through the band window's
35
+ * secToFrac/fracToSec (video/band.ts), so scroll-to-zoom is one piece of
36
+ * state everything else respects for free.
37
+ * · ONE speaker surface. With a waveform, the envelope's own bars wear the
38
+ * turn colours; the slim lane under it paints only as the no-waveform
39
+ * fallback. Never two stacked time-parallel strips.
40
+ * · Chapter flags sit on the TOP edge, bookmark diamonds on the BOTTOM edge —
41
+ * same idea (a marker on the timeline), different origin (the model's vs.
42
+ * yours), so the two families are never confused at a glance.
43
+ * · The wheel means different things in different regions, and each region is
44
+ * its own hover target so nothing is ambiguous: over the video it's volume
45
+ * (shift = seek, alt = speed); over the band it's zoom.
46
+ * · The keyboard map (video/keys.ts) is the single source the keydown
47
+ * handler and the `?` overlay both read — a key that exists is documented,
48
+ * a documented key exists.
49
+ *
50
+ * Internals live in `src/lib/video/` (band / keys / rate / deepLink /
51
+ * settings + types/time/model; audio, night and presence are contracts for
52
+ * mos-5lzns.31/.33/.34 — see that directory's README for the ownership map
53
+ * and the advisor doctrine).
54
+ *
55
+ * Deliberately NOT ported: player.js's trim region (in/out handles). That's
56
+ * clip-editing, not playback, and belongs in an editor component.
57
+ *
58
+ * Theming is token-only — DaisyUI semantic vars for chrome, resolved at
59
+ * runtime via `getDaisyUIColor` for the canvas, repainted on theme change.
60
+ * Speaker hues follow TranscriptPlayer's oklch palette (data-driven identity
61
+ * colour, cycled per speaker — not brand colour).
62
+ */
63
+ import { type Snippet } from 'svelte';
11
64
  import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
12
65
  import { type BandState } from '../video/band/axis';
13
66
  import { type VideoLumaTrack } from '../video/band/luma';
@@ -58,6 +111,33 @@ type $$ComponentProps = {
58
111
  */
59
112
  captionsDefault?: boolean;
60
113
  chapters?: VideoChapter[];
114
+ /**
115
+ * mos-777hj — the live chapter rail. `'side'` (with chapters present)
116
+ * renders a sticky chapter pane beside the main column at ≥64rem — one
117
+ * row per chapter with time, title, status dot and summary, the active
118
+ * row tracking the playhead (aria-current, auto-scroll), a progress
119
+ * fill inside the active row and a played check behind the playhead —
120
+ * and a horizontal chip strip under the player below 64rem. Rows seek
121
+ * to the chapter and play; the active row carries the replay affordance
122
+ * (clicking it again replays from its start). Keyboard: listNav j/k to
123
+ * move, Enter to play, while the rail holds focus. Default `'off'` —
124
+ * the rail is opt-in and leaves the player exactly as it was.
125
+ */
126
+ chapterRail?: 'side' | 'off';
127
+ /**
128
+ * Fires when the viewer picks a chapter (row click / Enter / replay):
129
+ * the host pins its own view (e.g. a per-chapter visual panel) — the
130
+ * rail itself always keeps time-tracking the playhead.
131
+ */
132
+ onChapterSelect?: (chapter: VideoChapter, index: number) => void;
133
+ /**
134
+ * Optional snippet rendered between the stage and the transcript — the
135
+ * host's own content in the player's main column (e.g. a "now
136
+ * discussing" panel bound to the active chapter). Designed for
137
+ * `transcriptPosition: 'below'`; beside a side transcript it renders
138
+ * under the stage in the stage's column.
139
+ */
140
+ belowStage?: Snippet;
61
141
  speakers?: VideoSpeakerTurn[];
62
142
  transcript?: VideoTranscriptSegment[];
63
143
  bookmarks?: VideoBookmark[];
@@ -321,6 +401,7 @@ type $$ComponentProps = {
321
401
  };
322
402
  declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
323
403
  bandState: () => BandState;
404
+ seekToTime: (sec: number) => void;
324
405
  }, "night" | "nightDepth">;
325
406
  type VideoPlayer = ReturnType<typeof VideoPlayer>;
326
407
  export default VideoPlayer;
@@ -11,7 +11,8 @@ props + callback surfaces.
11
11
 
12
12
  | File | Owner | Contents |
13
13
  |------|-------|----------|
14
- | `types.ts` | .28 (done) | The public data contracts — `VideoChapter`, `VideoSpeakerTurn`, `VideoTranscriptSegment`, `VideoBookmark`, `VideoWaveform`, `VideoSpeakerTalkTime`. Re-exported by the component, so `import type … from 'jatui'` is unchanged. |
14
+ | `types.ts` | .28 (done) | The public data contracts — `VideoChapter`, `VideoSpeakerTurn`, `VideoTranscriptSegment`, `VideoBookmark`, `VideoWaveform`, `VideoSpeakerTalkTime`. Re-exported by the component, so `import type … from 'jatui'` is unchanged. .777hj adds `VideoChapter.endSec/summary/status` (all optional — the band ignores them). |
15
+ | `chapterRail.ts` | **mos-777hj** | The opt-in `chapterRail: 'side'` pane's pure state: `railChapters(chapters, duration, t)` derives row ends (explicit `endSec` → next start → duration), the played check and the 0–1 progress fill for the row containing `t`; `activeRailIndex` is the row-highlight index. The component owns the DOM: sticky right column ≥64rem (chip strip below), rows seek+play on click, replay affordance on the active row, listNav j/k+Enter (the root keydown handler skips events from inside the rail), `onChapterSelect` for host-side pinning, `belowStage` snippet slot between stage and transcript, `seekToTime()` instance export for host deep links. |
15
16
  | `time.ts` | .28 (done) | Pure formatting: `formatTime`, `formatDuration`, `truncate`, `aspectRatioCss`. |
16
17
  | `model.ts` | .28 (done) | Speaker registry + identity colours + talk-time derivation + the sort normalisations. `ALL_KEY`/`UNKNOWN_KEY` live here. |
17
18
  | `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Chapter rail state (mos-777hj) — the pure half of VideoPlayer's opt-in
3
+ * `chapterRail: 'side'` pane: one row per chapter (title, time, status dot,
4
+ * summary), the active row tracking the playhead, a progress fill inside the
5
+ * active row and a played check once the playhead moves past a chapter's end.
6
+ *
7
+ * The component owns the DOM (rows, click-to-seek-and-play, listNav, sticky
8
+ * column, small-screen chip strip); everything derivable from
9
+ * (chapters, duration, playhead) lives HERE so it is node-testable under
10
+ * `npm run test:unit` — same discipline as band.ts / transcriptWindow.ts.
11
+ *
12
+ * Chapter ends: a chapter without an explicit `endSec` runs to the NEXT
13
+ * chapter's start; the last one runs to the media duration when known. With
14
+ * no duration and no following chapter the chapter is open-ended — its row
15
+ * simply never shows a played check or progress (division guards to 0).
16
+ */
17
+ import type { VideoChapter } from './types';
18
+ /** Dot tones, resolved to DaisyUI semantic vars by the component's CSS. */
19
+ export type RailChapterStatus = 'info' | 'success' | 'warning' | 'error';
20
+ /** A chapter as the rail renders it: end derived when absent. */
21
+ export interface RailChapter {
22
+ startSec: number;
23
+ endSec: number;
24
+ title: string;
25
+ summary: string | null;
26
+ status: RailChapterStatus | null;
27
+ /** True once the playhead has moved past this chapter's end. */
28
+ played: boolean;
29
+ /** 0..1 fill for the row containing `t`; exactly 0 for every other row. */
30
+ progress: number;
31
+ }
32
+ /**
33
+ * The rail's rows for one playhead instant. `chapters` must be sorted by
34
+ * startSec (the component passes its `sortedChapters`); entries with a
35
+ * non-finite start are dropped, mirroring sortChapters' own filter.
36
+ *
37
+ * `t` before the first chapter (or exactly on it) marks that first chapter
38
+ * active with progress from its own start; there is no "before the program"
39
+ * row — the program starts at its first chapter.
40
+ */
41
+ export declare function railChapters(chapters: VideoChapter[], durationSec: number | null, t: number): RailChapter[];
42
+ /**
43
+ * Index of the row containing `t` (the LAST chapter whose startSec <= t), or
44
+ * -1 when `t` sits before every chapter / the list is empty. The component
45
+ * uses this for the row highlight, aria-current and the auto-scroll.
46
+ */
47
+ export declare function activeRailIndex(chapters: {
48
+ startSec: number;
49
+ }[], t: number): number;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Chapter rail state (mos-777hj) — the pure half of VideoPlayer's opt-in
3
+ * `chapterRail: 'side'` pane: one row per chapter (title, time, status dot,
4
+ * summary), the active row tracking the playhead, a progress fill inside the
5
+ * active row and a played check once the playhead moves past a chapter's end.
6
+ *
7
+ * The component owns the DOM (rows, click-to-seek-and-play, listNav, sticky
8
+ * column, small-screen chip strip); everything derivable from
9
+ * (chapters, duration, playhead) lives HERE so it is node-testable under
10
+ * `npm run test:unit` — same discipline as band.ts / transcriptWindow.ts.
11
+ *
12
+ * Chapter ends: a chapter without an explicit `endSec` runs to the NEXT
13
+ * chapter's start; the last one runs to the media duration when known. With
14
+ * no duration and no following chapter the chapter is open-ended — its row
15
+ * simply never shows a played check or progress (division guards to 0).
16
+ */
17
+ const STATUS_VALUES = ['info', 'success', 'warning', 'error'];
18
+ function statusOf(value) {
19
+ return value != null && STATUS_VALUES.includes(value)
20
+ ? value
21
+ : null;
22
+ }
23
+ function clamp01(n) {
24
+ if (!Number.isFinite(n))
25
+ return 0;
26
+ return n < 0 ? 0 : n > 1 ? 1 : n;
27
+ }
28
+ /**
29
+ * The rail's rows for one playhead instant. `chapters` must be sorted by
30
+ * startSec (the component passes its `sortedChapters`); entries with a
31
+ * non-finite start are dropped, mirroring sortChapters' own filter.
32
+ *
33
+ * `t` before the first chapter (or exactly on it) marks that first chapter
34
+ * active with progress from its own start; there is no "before the program"
35
+ * row — the program starts at its first chapter.
36
+ */
37
+ export function railChapters(chapters, durationSec, t) {
38
+ const rows = [];
39
+ for (let i = 0; i < chapters.length; i++) {
40
+ const c = chapters[i];
41
+ if (!c || !Number.isFinite(c.startSec) || c.startSec < 0)
42
+ continue;
43
+ const next = chapters[i + 1];
44
+ let end = c.endSec;
45
+ if (end == null || !Number.isFinite(end) || end <= c.startSec) {
46
+ end = next && Number.isFinite(next.startSec) ? next.startSec : durationSec;
47
+ }
48
+ if (end == null || !Number.isFinite(end) || end < c.startSec)
49
+ end = c.startSec;
50
+ const active = t >= c.startSec && (i === chapters.length - 1 || t < chapters[i + 1].startSec);
51
+ const span = end - c.startSec;
52
+ const progress = active && span > 0 ? clamp01((t - c.startSec) / span) : 0;
53
+ rows.push({
54
+ startSec: c.startSec,
55
+ endSec: end,
56
+ title: c.title,
57
+ summary: c.summary ?? null,
58
+ status: statusOf(c.status),
59
+ played: t >= end && end > c.startSec,
60
+ progress
61
+ });
62
+ }
63
+ return rows;
64
+ }
65
+ /**
66
+ * Index of the row containing `t` (the LAST chapter whose startSec <= t), or
67
+ * -1 when `t` sits before every chapter / the list is empty. The component
68
+ * uses this for the row highlight, aria-current and the auto-scroll.
69
+ */
70
+ export function activeRailIndex(chapters, t) {
71
+ let idx = -1;
72
+ for (let i = 0; i < chapters.length; i++) {
73
+ if (chapters[i].startSec <= t)
74
+ idx = i;
75
+ else
76
+ break;
77
+ }
78
+ return idx;
79
+ }
@@ -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' | 'comments' | 'night' | 'settings';
23
+ when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings';
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
+ // mos-777hj — the rail's own listNav motions, active only while the rail
27
+ // holds focus (the same keys drive seek/play-pause everywhere else).
28
+ { keys: 'j / k / Enter', description: 'Chapter rail: move / play chapter', when: 'rail' },
26
29
  // Shift+[ / Shift+] — the chapter keys' shifted twins, so "structure"
27
30
  // and "people's reactions to it" sit under one hand (mos-5lzns.30).
28
31
  { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
@@ -17,6 +17,16 @@ export interface VideoChapter {
17
17
  title: string;
18
18
  /** Optional captured still, shown in the band's hover card. */
19
19
  thumbUrl?: string | null;
20
+ /**
21
+ * Chapter end, seconds. Absent (mos-777hj rail) the chapter runs to the
22
+ * NEXT chapter's start — or the media duration for the last one. The rail
23
+ * uses it for the played check and the progress fill; the band ignores it.
24
+ */
25
+ endSec?: number | null;
26
+ /** One-line summary shown under the title in the chapter rail. */
27
+ summary?: string | null;
28
+ /** Rail status dot tone (DaisyUI semantic) — null renders no dot. */
29
+ status?: 'info' | 'success' | 'warning' | 'error' | null;
20
30
  }
21
31
  /** A diarized turn (or an ad read, `ad: true`). Structurally accepts meeting data. */
22
32
  export interface VideoSpeakerTurn {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.31.2",
3
+ "version": "0.32.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",