@joewinke/jatui 0.31.2 → 0.33.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/CLAUDE.md +1 -1
- package/dist/components/VideoPlayer.svelte +824 -67
- package/dist/components/VideoPlayer.svelte.d.ts +98 -0
- package/dist/video/README.md +2 -1
- package/dist/video/chapterRail.d.ts +49 -0
- package/dist/video/chapterRail.js +79 -0
- package/dist/video/keys.d.ts +1 -1
- package/dist/video/keys.js +3 -0
- package/dist/video/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -246,6 +251,8 @@
|
|
|
246
251
|
|
|
247
252
|
let {
|
|
248
253
|
src,
|
|
254
|
+
kind = 'video',
|
|
255
|
+
transport: transportProp = undefined,
|
|
249
256
|
poster = null,
|
|
250
257
|
aspect = '16:9',
|
|
251
258
|
autoplay = false,
|
|
@@ -255,6 +262,9 @@
|
|
|
255
262
|
captionsLang = 'en',
|
|
256
263
|
captionsDefault = true,
|
|
257
264
|
chapters = [],
|
|
265
|
+
chapterRail: chapterRailProp = 'off',
|
|
266
|
+
onChapterSelect = undefined,
|
|
267
|
+
belowStage = undefined,
|
|
258
268
|
speakers = [],
|
|
259
269
|
transcript = [],
|
|
260
270
|
bookmarks = [],
|
|
@@ -331,6 +341,23 @@
|
|
|
331
341
|
class: className = ''
|
|
332
342
|
}: {
|
|
333
343
|
src: string;
|
|
344
|
+
/**
|
|
345
|
+
* What the media IS. `'audio'` (a podcast, a call recording, a brief)
|
|
346
|
+
* renders NO stage: there is no picture to frame, so the player is the
|
|
347
|
+
* band (the one timeline) plus its transport row. The media element
|
|
348
|
+
* still plays, just never shows. `aspect`/`poster` are ignored. Default
|
|
349
|
+
* `'video'`.
|
|
350
|
+
*/
|
|
351
|
+
kind?: 'video' | 'audio';
|
|
352
|
+
/**
|
|
353
|
+
* Who owns play/pause, time and volume. `'native'` keeps the browser's
|
|
354
|
+
* `<video controls>` bar (which carries its own timeline next to the
|
|
355
|
+
* band). `'band'` drops the native controls and puts a transport row
|
|
356
|
+
* on the band instead: play/pause, `current / total`, speed, mute +
|
|
357
|
+
* volume, and fullscreen for video. One timeline, one set of controls.
|
|
358
|
+
* Default: `'band'` for `kind="audio"`, `'native'` for video.
|
|
359
|
+
*/
|
|
360
|
+
transport?: 'native' | 'band';
|
|
334
361
|
poster?: string | null;
|
|
335
362
|
/**
|
|
336
363
|
* Aspect ratio for the video stage — presentation-primitive prop (PRD).
|
|
@@ -369,6 +396,33 @@
|
|
|
369
396
|
*/
|
|
370
397
|
captionsDefault?: boolean;
|
|
371
398
|
chapters?: VideoChapter[];
|
|
399
|
+
/**
|
|
400
|
+
* mos-777hj — the live chapter rail. `'side'` (with chapters present)
|
|
401
|
+
* renders a sticky chapter pane beside the main column at ≥64rem — one
|
|
402
|
+
* row per chapter with time, title, status dot and summary, the active
|
|
403
|
+
* row tracking the playhead (aria-current, auto-scroll), a progress
|
|
404
|
+
* fill inside the active row and a played check behind the playhead —
|
|
405
|
+
* and a horizontal chip strip under the player below 64rem. Rows seek
|
|
406
|
+
* to the chapter and play; the active row carries the replay affordance
|
|
407
|
+
* (clicking it again replays from its start). Keyboard: listNav j/k to
|
|
408
|
+
* move, Enter to play, while the rail holds focus. Default `'off'` —
|
|
409
|
+
* the rail is opt-in and leaves the player exactly as it was.
|
|
410
|
+
*/
|
|
411
|
+
chapterRail?: 'side' | 'off';
|
|
412
|
+
/**
|
|
413
|
+
* Fires when the viewer picks a chapter (row click / Enter / replay):
|
|
414
|
+
* the host pins its own view (e.g. a per-chapter visual panel) — the
|
|
415
|
+
* rail itself always keeps time-tracking the playhead.
|
|
416
|
+
*/
|
|
417
|
+
onChapterSelect?: (chapter: VideoChapter, index: number) => void;
|
|
418
|
+
/**
|
|
419
|
+
* Optional snippet rendered between the stage and the transcript — the
|
|
420
|
+
* host's own content in the player's main column (e.g. a "now
|
|
421
|
+
* discussing" panel bound to the active chapter). Designed for
|
|
422
|
+
* `transcriptPosition: 'below'`; beside a side transcript it renders
|
|
423
|
+
* under the stage in the stage's column.
|
|
424
|
+
*/
|
|
425
|
+
belowStage?: Snippet;
|
|
372
426
|
speakers?: VideoSpeakerTurn[];
|
|
373
427
|
transcript?: VideoTranscriptSegment[];
|
|
374
428
|
bookmarks?: VideoBookmark[];
|
|
@@ -688,6 +742,53 @@
|
|
|
688
742
|
let currentTime = $state(0);
|
|
689
743
|
let duration = $state(0);
|
|
690
744
|
|
|
745
|
+
// ── Band transport (kind="audio" / transport="band") ────────────────────
|
|
746
|
+
// Audio has no picture, so no stage; the band is the only timeline and
|
|
747
|
+
// the transport row under it carries play/pause, time, speed and volume.
|
|
748
|
+
const isAudio = $derived(kind === 'audio');
|
|
749
|
+
const bandTransport = $derived(isAudio || transportProp === 'band');
|
|
750
|
+
let paused = $state(true);
|
|
751
|
+
let volumeLevel = $state(1);
|
|
752
|
+
let mutedNow = $state(false);
|
|
753
|
+
let isFullscreen = $state(false);
|
|
754
|
+
function syncVolume() {
|
|
755
|
+
if (!videoEl) return;
|
|
756
|
+
volumeLevel = videoEl.volume;
|
|
757
|
+
mutedNow = videoEl.muted;
|
|
758
|
+
}
|
|
759
|
+
function togglePlay() {
|
|
760
|
+
const v = videoEl;
|
|
761
|
+
if (!v) return;
|
|
762
|
+
if (v.paused) void v.play().catch(() => {});
|
|
763
|
+
else v.pause();
|
|
764
|
+
}
|
|
765
|
+
function toggleVolumeMute() {
|
|
766
|
+
const v = videoEl;
|
|
767
|
+
if (!v) return;
|
|
768
|
+
v.muted = !v.muted;
|
|
769
|
+
if (!v.muted && v.volume === 0) v.volume = 0.5;
|
|
770
|
+
}
|
|
771
|
+
function onVolumeInput(e: Event) {
|
|
772
|
+
const v = videoEl;
|
|
773
|
+
if (!v) return;
|
|
774
|
+
const next = Number((e.currentTarget as HTMLInputElement).value);
|
|
775
|
+
v.volume = Math.min(1, Math.max(0, next));
|
|
776
|
+
v.muted = next === 0;
|
|
777
|
+
}
|
|
778
|
+
function toggleFullscreen() {
|
|
779
|
+
if (typeof document === 'undefined') return;
|
|
780
|
+
if (document.fullscreenElement) void document.exitFullscreen?.();
|
|
781
|
+
else void stageWrapEl?.requestFullscreen?.();
|
|
782
|
+
}
|
|
783
|
+
/** The speed button: the listening speeds people actually pick, in
|
|
784
|
+
* order, wrapping back to 1×. `-`/`=` still walk the fine staircase. */
|
|
785
|
+
const SPEED_CYCLE = [1, 1.25, 1.5, 1.75, 2];
|
|
786
|
+
function cycleSpeed() {
|
|
787
|
+
baseRate = SPEED_CYCLE.find((s) => s > baseRate + 0.001) ?? 1;
|
|
788
|
+
applyRate();
|
|
789
|
+
flashOsd(formatSpeedLabel(baseRate));
|
|
790
|
+
}
|
|
791
|
+
|
|
691
792
|
// Canvas box, tracked so a resize (panel toggle, window resize) repaints.
|
|
692
793
|
let canvasW = $state(0);
|
|
693
794
|
let canvasH = $state(0);
|
|
@@ -960,6 +1061,42 @@
|
|
|
960
1061
|
const useHours = $derived(effectiveDuration >= 3600);
|
|
961
1062
|
|
|
962
1063
|
const sortedChapters = $derived(sortChapters(chapters));
|
|
1064
|
+
|
|
1065
|
+
// ── Chapter rail (mos-777hj) ─────────────────────────────────────────
|
|
1066
|
+
/** The rail pane exists only when opted in AND there is something to rail. */
|
|
1067
|
+
const railOn = $derived(chapterRailProp === 'side' && sortedChapters.length > 0);
|
|
1068
|
+
/** Rows recomputed per playhead tick — ends, played checks, progress fill. */
|
|
1069
|
+
const railRows = $derived(railOn ? railChapters(sortedChapters, effectiveDuration, currentTime) : []);
|
|
1070
|
+
const railActive = $derived(railOn ? activeRailIndex(sortedChapters, currentTime) : -1);
|
|
1071
|
+
let chapterRailEl: HTMLElement | null = $state(null);
|
|
1072
|
+
|
|
1073
|
+
/** Row click / Enter / replay: seek to the chapter and play it. */
|
|
1074
|
+
function chapterGo(index: number) {
|
|
1075
|
+
const ch = sortedChapters[index];
|
|
1076
|
+
if (!ch) return;
|
|
1077
|
+
seekTo(ch.startSec);
|
|
1078
|
+
videoEl?.play()?.catch(() => {});
|
|
1079
|
+
onChapterSelect?.(ch, index);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/** listNav onSelect — the element carries its row index. */
|
|
1083
|
+
function onRailSelect(el: HTMLElement) {
|
|
1084
|
+
const idx = Number(el.dataset.chapterIndex);
|
|
1085
|
+
if (Number.isInteger(idx)) chapterGo(idx);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Keep the active row visible while playback tracks chapters. Nearest
|
|
1089
|
+
* only ever scrolls the rail's own overflow (never the page); smooth is
|
|
1090
|
+
* dropped under prefers-reduced-motion. */
|
|
1091
|
+
$effect(() => {
|
|
1092
|
+
if (!railOn || railActive < 0) return;
|
|
1093
|
+
const row = chapterRailEl?.querySelector<HTMLElement>('.vp-chapter-row.active');
|
|
1094
|
+
if (!row) return;
|
|
1095
|
+
row.scrollIntoView({
|
|
1096
|
+
block: 'nearest',
|
|
1097
|
+
behavior: reduceMotion ? 'auto' : 'smooth'
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
963
1100
|
const sortedTurns = $derived(sortTurns(speakers));
|
|
964
1101
|
const sortedSegments = $derived(sortSegments(transcript));
|
|
965
1102
|
const sortedBookmarks = $derived(sortBookmarks(bookmarks));
|
|
@@ -988,6 +1125,9 @@
|
|
|
988
1125
|
const speakerIndex = $derived(buildSpeakerIndex(sortedTurns, sortedSegments));
|
|
989
1126
|
|
|
990
1127
|
const hasSpeakers = $derived(speakerIndex.size > 0);
|
|
1128
|
+
/** Two or more voices: only then do talk time, solo and skip mean
|
|
1129
|
+
* anything, and only then does a transcript row need its speaker name. */
|
|
1130
|
+
const multiSpeaker = $derived(speakerIndex.size > 1);
|
|
991
1131
|
|
|
992
1132
|
/**
|
|
993
1133
|
* Theme-derived speaker identity: colours + the fallback cycle in one read
|
|
@@ -1094,6 +1234,16 @@
|
|
|
1094
1234
|
return { ...currentBandState };
|
|
1095
1235
|
}
|
|
1096
1236
|
|
|
1237
|
+
/**
|
|
1238
|
+
* Callable on the instance (`bind:this`) — seek to an absolute second.
|
|
1239
|
+
* The host-side half of deep links (mos-777hj): a host that resolves its
|
|
1240
|
+
* own anchors (e.g. #chapter-<id>) seeks through this after mount, the
|
|
1241
|
+
* same place `startAt`/`?t=` seed from.
|
|
1242
|
+
*/
|
|
1243
|
+
export function seekToTime(sec: number) {
|
|
1244
|
+
seekTo(sec);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1097
1247
|
const reportBand = createBandReporter(() => onBandChange);
|
|
1098
1248
|
$effect(() => {
|
|
1099
1249
|
const st = currentBandState;
|
|
@@ -1105,6 +1255,7 @@
|
|
|
1105
1255
|
PLAYER_KEYS.filter((row) => {
|
|
1106
1256
|
if (!row.when) return true;
|
|
1107
1257
|
if (row.when === 'chapters') return sortedChapters.length > 0;
|
|
1258
|
+
if (row.when === 'rail') return railOn;
|
|
1108
1259
|
if (row.when === 'bookmarks') return onAddBookmark != null;
|
|
1109
1260
|
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
1110
1261
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
@@ -2532,6 +2683,13 @@
|
|
|
2532
2683
|
const target = e.target as HTMLElement | null;
|
|
2533
2684
|
const tag = target?.tagName;
|
|
2534
2685
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
|
|
2686
|
+
// Space/Enter on a focused button activates THAT button (play, a
|
|
2687
|
+
// speaker chip, a transcript row); toggling playback too would undo it.
|
|
2688
|
+
if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
|
|
2689
|
+
// mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
|
|
2690
|
+
// activation) while focus is inside it; without this guard a rail `k`
|
|
2691
|
+
// would also toggle play/pause and a rail `j` also seek ±5s.
|
|
2692
|
+
if (chapterRailEl && target && chapterRailEl.contains(target)) return;
|
|
2535
2693
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
2536
2694
|
const v = videoEl;
|
|
2537
2695
|
if (!v) return;
|
|
@@ -2786,13 +2944,16 @@
|
|
|
2786
2944
|
class="vp-root {className}"
|
|
2787
2945
|
bind:this={rootEl}
|
|
2788
2946
|
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
2947
|
+
class:vp-rail-side={railOn}
|
|
2789
2948
|
class:vp-one-axis={nativeBarHidden}
|
|
2949
|
+
class:vp-band-transport={bandTransport}
|
|
2950
|
+
class:vp-audio={isAudio}
|
|
2790
2951
|
class:vp-night-capable={nightPresent}
|
|
2791
2952
|
class:vp-night-surround={surroundMix > 0.001}
|
|
2792
2953
|
style:--vp-night-surround={surroundMix > 0.001 ? surroundMix.toFixed(3) : undefined}
|
|
2793
2954
|
data-band-axis={currentBandState.axis}
|
|
2794
2955
|
role="region"
|
|
2795
|
-
aria-label=
|
|
2956
|
+
aria-label={isAudio ? 'Audio player' : 'Video player'}
|
|
2796
2957
|
tabindex="-1"
|
|
2797
2958
|
onkeydown={onRootKeyDown}
|
|
2798
2959
|
onpointerdowncapture={maybeAskIdle}
|
|
@@ -2801,7 +2962,12 @@
|
|
|
2801
2962
|
onkeydowncapture={maybeAskIdle}
|
|
2802
2963
|
>
|
|
2803
2964
|
<div class="vp-main">
|
|
2804
|
-
<div
|
|
2965
|
+
<div
|
|
2966
|
+
class="vp-stage-wrap"
|
|
2967
|
+
class:vp-stage-audio={isAudio}
|
|
2968
|
+
bind:this={stageWrapEl}
|
|
2969
|
+
onfullscreenchange={() => (isFullscreen = document.fullscreenElement === stageWrapEl)}
|
|
2970
|
+
>
|
|
2805
2971
|
{#if nightPresent}
|
|
2806
2972
|
<!-- Bias light: the frame's own colours, blurred, spilled behind the
|
|
2807
2973
|
picture. Earlier in the DOM than the (positioned) stage, so the
|
|
@@ -2813,9 +2979,13 @@
|
|
|
2813
2979
|
aria-hidden="true"
|
|
2814
2980
|
></div>
|
|
2815
2981
|
{/if}
|
|
2816
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2982
|
+
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
|
|
2983
|
+
<!-- With the band transport the native controls are gone, so a click
|
|
2984
|
+
on the picture plays/pauses (Space/K do the same from the keyboard). -->
|
|
2817
2985
|
<div
|
|
2818
2986
|
class="vp-stage"
|
|
2987
|
+
class:vp-stage-clickable={bandTransport && !isAudio}
|
|
2988
|
+
onclick={bandTransport && !isAudio ? togglePlay : undefined}
|
|
2819
2989
|
class:vp-stage-locked={stageAspect != null}
|
|
2820
2990
|
class:vp-night-capable={nightPresent || guardPresent}
|
|
2821
2991
|
class:vp-night-on={nightFrame != null}
|
|
@@ -2832,6 +3002,8 @@
|
|
|
2832
3002
|
bind:this={videoEl}
|
|
2833
3003
|
bind:currentTime
|
|
2834
3004
|
bind:duration
|
|
3005
|
+
bind:paused
|
|
3006
|
+
onvolumechange={syncVolume}
|
|
2835
3007
|
ontimeupdate={handleTimeUpdate}
|
|
2836
3008
|
onloadedmetadata={onLoadedMetadata}
|
|
2837
3009
|
onseeked={() => {
|
|
@@ -2857,8 +3029,8 @@
|
|
|
2857
3029
|
{loop}
|
|
2858
3030
|
crossorigin={crossorigin ?? undefined}
|
|
2859
3031
|
muted={effectiveAutoplay || undefined}
|
|
2860
|
-
poster={poster ?? undefined}
|
|
2861
|
-
controls
|
|
3032
|
+
poster={isAudio ? undefined : (poster ?? undefined)}
|
|
3033
|
+
controls={!bandTransport}
|
|
2862
3034
|
playsinline
|
|
2863
3035
|
class="vp-video"
|
|
2864
3036
|
>
|
|
@@ -2983,6 +3155,10 @@
|
|
|
2983
3155
|
</div>
|
|
2984
3156
|
|
|
2985
3157
|
<div class="vp-zoom" class:on={zoomLabelOn} aria-hidden="true">{zoomLabel}</div>
|
|
3158
|
+
{#if isAudio}
|
|
3159
|
+
<!-- No stage to float the readout over: it rides the band. -->
|
|
3160
|
+
<div class="vp-osd vp-osd-band" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
3161
|
+
{/if}
|
|
2986
3162
|
|
|
2987
3163
|
{#if hover}
|
|
2988
3164
|
<div
|
|
@@ -3012,8 +3188,7 @@
|
|
|
3012
3188
|
{/if}
|
|
3013
3189
|
</div>
|
|
3014
3190
|
|
|
3015
|
-
|
|
3016
|
-
<span>{formatTime(currentTime, useHours)}</span>
|
|
3191
|
+
{#snippet timesMid()}
|
|
3017
3192
|
<span class="vp-times-mid">
|
|
3018
3193
|
{#if isZoomedNow()}
|
|
3019
3194
|
<button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
|
|
@@ -3058,28 +3233,118 @@
|
|
|
3058
3233
|
</button>
|
|
3059
3234
|
{/if}
|
|
3060
3235
|
</span>
|
|
3061
|
-
|
|
3062
|
-
|
|
3236
|
+
{/snippet}
|
|
3237
|
+
|
|
3238
|
+
{#if bandTransport}
|
|
3239
|
+
<!-- The band transport: the band above is the one timeline; this row
|
|
3240
|
+
carries everything the native bar used to (play/pause, time,
|
|
3241
|
+
speed, volume, fullscreen for video). -->
|
|
3242
|
+
<div class="vp-times vp-transport">
|
|
3243
|
+
<span class="vp-transport-start">
|
|
3244
|
+
<button
|
|
3245
|
+
type="button"
|
|
3246
|
+
class="vp-play"
|
|
3247
|
+
aria-label={paused ? 'Play' : 'Pause'}
|
|
3248
|
+
title="{paused ? 'Play' : 'Pause'} · Space"
|
|
3249
|
+
onclick={togglePlay}
|
|
3250
|
+
>
|
|
3251
|
+
{#if paused}
|
|
3252
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
3253
|
+
<path d="M8 5.5v13l10.5-6.5z" fill="currentColor" />
|
|
3254
|
+
</svg>
|
|
3255
|
+
{:else}
|
|
3256
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
3257
|
+
<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
|
|
3258
|
+
</svg>
|
|
3259
|
+
{/if}
|
|
3260
|
+
</button>
|
|
3261
|
+
<span class="vp-clock">
|
|
3262
|
+
<span class="vp-clock-now">{formatTime(currentTime, useHours)}</span>
|
|
3263
|
+
<span class="vp-clock-sep" aria-hidden="true">/</span>
|
|
3264
|
+
<span class="vp-sr">of</span>
|
|
3265
|
+
<span>{formatTime(effectiveDuration, useHours)}</span>
|
|
3266
|
+
</span>
|
|
3267
|
+
</span>
|
|
3268
|
+
{@render timesMid()}
|
|
3269
|
+
<span class="vp-transport-end">
|
|
3270
|
+
<button
|
|
3271
|
+
type="button"
|
|
3272
|
+
class="vp-tbtn vp-speed"
|
|
3273
|
+
aria-label="Playback speed {formatSpeedLabel(baseRate)}"
|
|
3274
|
+
title="Speed · click to change, - and = step"
|
|
3275
|
+
onclick={cycleSpeed}
|
|
3276
|
+
>
|
|
3277
|
+
{formatSpeedLabel(baseRate)}
|
|
3278
|
+
</button>
|
|
3279
|
+
<button
|
|
3280
|
+
type="button"
|
|
3281
|
+
class="vp-tbtn"
|
|
3282
|
+
aria-label={mutedNow || volumeLevel === 0 ? 'Unmute' : 'Mute'}
|
|
3283
|
+
aria-pressed={mutedNow}
|
|
3284
|
+
title="{mutedNow ? 'Unmute' : 'Mute'} · M"
|
|
3285
|
+
onclick={toggleVolumeMute}
|
|
3286
|
+
>
|
|
3287
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
3288
|
+
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" stroke="none" />
|
|
3289
|
+
{#if mutedNow || volumeLevel === 0}
|
|
3290
|
+
<path d="M16 9.5l5 5M21 9.5l-5 5" />
|
|
3291
|
+
{:else}
|
|
3292
|
+
<path d="M15.5 9a4 4 0 0 1 0 6" />
|
|
3293
|
+
{#if volumeLevel > 0.5}<path d="M18 6.5a7.5 7.5 0 0 1 0 11" />{/if}
|
|
3294
|
+
{/if}
|
|
3295
|
+
</svg>
|
|
3296
|
+
</button>
|
|
3297
|
+
<input
|
|
3298
|
+
type="range"
|
|
3299
|
+
class="vp-volume"
|
|
3300
|
+
min="0"
|
|
3301
|
+
max="1"
|
|
3302
|
+
step="0.05"
|
|
3303
|
+
value={mutedNow ? 0 : volumeLevel}
|
|
3304
|
+
aria-label="Volume"
|
|
3305
|
+
aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
|
|
3306
|
+
oninput={onVolumeInput}
|
|
3307
|
+
/>
|
|
3308
|
+
{#if !isAudio}
|
|
3309
|
+
<button
|
|
3310
|
+
type="button"
|
|
3311
|
+
class="vp-tbtn"
|
|
3312
|
+
aria-label={isFullscreen ? 'Exit full screen' : 'Full screen'}
|
|
3313
|
+
title={isFullscreen ? 'Exit full screen' : 'Full screen'}
|
|
3314
|
+
onclick={toggleFullscreen}
|
|
3315
|
+
>
|
|
3316
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
3317
|
+
{#if isFullscreen}
|
|
3318
|
+
<path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5" />
|
|
3319
|
+
{:else}
|
|
3320
|
+
<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
|
|
3321
|
+
{/if}
|
|
3322
|
+
</svg>
|
|
3323
|
+
</button>
|
|
3324
|
+
{/if}
|
|
3325
|
+
</span>
|
|
3326
|
+
</div>
|
|
3327
|
+
{:else}
|
|
3328
|
+
<div class="vp-times">
|
|
3329
|
+
<span>{formatTime(currentTime, useHours)}</span>
|
|
3330
|
+
{@render timesMid()}
|
|
3331
|
+
<span>{formatTime(effectiveDuration, useHours)}</span>
|
|
3332
|
+
</div>
|
|
3333
|
+
{/if}
|
|
3063
3334
|
|
|
3064
|
-
{#if
|
|
3335
|
+
{#if multiSpeaker}
|
|
3065
3336
|
<!-- Talk-time doubles as the speaker legend: hover a chip to raise that
|
|
3066
|
-
speaker on the timeline, click to solo, and use its
|
|
3067
|
-
skip them. Identification through brightness, never a glow.
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {speakerColor(
|
|
3074
|
-
talkTimeKey(tt)
|
|
3075
|
-
)}"
|
|
3076
|
-
title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
|
|
3077
|
-
></div>
|
|
3078
|
-
{/each}
|
|
3079
|
-
</div>
|
|
3337
|
+
speaker on the timeline, click to solo, and use its skip toggle to
|
|
3338
|
+
skip them. Identification through brightness, never a glow. Only
|
|
3339
|
+
with 2+ speakers: one voice has nothing to compare, solo or skip,
|
|
3340
|
+
and its talk time reads as a second, different duration. Labelled
|
|
3341
|
+
"Talk time" so a chip's seconds never read as the media's length. -->
|
|
3342
|
+
<div class="vp-talktime" role="group" aria-label="Talk time by speaker">
|
|
3343
|
+
<span class="vp-talktime-label" aria-hidden="true">Talk time</span>
|
|
3080
3344
|
<div class="vp-legend">
|
|
3081
3345
|
{#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
|
|
3082
3346
|
{@const key = talkTimeKey(tt)}
|
|
3347
|
+
{@const name = tt.speakerName ?? 'Unknown speaker'}
|
|
3083
3348
|
<span class="vp-legend-item">
|
|
3084
3349
|
<button
|
|
3085
3350
|
type="button"
|
|
@@ -3092,10 +3357,11 @@
|
|
|
3092
3357
|
onblur={() => (highlightKey = null)}
|
|
3093
3358
|
onclick={() => toggleSolo(key)}
|
|
3094
3359
|
aria-pressed={soloKey === key}
|
|
3095
|
-
|
|
3360
|
+
aria-label="{name}, talked {formatDuration(tt.seconds)}. Play only {name}"
|
|
3361
|
+
title="Play only {name}"
|
|
3096
3362
|
>
|
|
3097
3363
|
<span class="vp-chip-dot" style="background: {speakerColor(key)}"></span>
|
|
3098
|
-
{
|
|
3364
|
+
{name}
|
|
3099
3365
|
<span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
|
|
3100
3366
|
</button>
|
|
3101
3367
|
<button
|
|
@@ -3104,11 +3370,12 @@
|
|
|
3104
3370
|
class:on={mutedKeys.has(key)}
|
|
3105
3371
|
onclick={() => toggleMute(key)}
|
|
3106
3372
|
aria-pressed={mutedKeys.has(key)}
|
|
3107
|
-
aria-label="
|
|
3108
|
-
|
|
3109
|
-
title="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} this speaker"
|
|
3373
|
+
aria-label="Skip {name}"
|
|
3374
|
+
title="{mutedKeys.has(key) ? 'Stop skipping' : 'Skip'} {name}"
|
|
3110
3375
|
>
|
|
3111
|
-
|
|
3376
|
+
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
3377
|
+
<path d="M5 6l8 6-8 6zM18 6v12" />
|
|
3378
|
+
</svg>
|
|
3112
3379
|
</button>
|
|
3113
3380
|
</span>
|
|
3114
3381
|
{/each}
|
|
@@ -3121,7 +3388,7 @@
|
|
|
3121
3388
|
mutedKeys = new Set();
|
|
3122
3389
|
}}
|
|
3123
3390
|
>
|
|
3124
|
-
|
|
3391
|
+
Show all speakers
|
|
3125
3392
|
</button>
|
|
3126
3393
|
{/if}
|
|
3127
3394
|
</div>
|
|
@@ -3144,6 +3411,79 @@
|
|
|
3144
3411
|
{/if}
|
|
3145
3412
|
</div>
|
|
3146
3413
|
|
|
3414
|
+
{#if railOn}
|
|
3415
|
+
<!-- mos-777hj — the live chapter rail: one row per chapter with time,
|
|
3416
|
+
title, status dot and summary. The active row tracks the playhead
|
|
3417
|
+
(aria-current + auto-scroll), fills its thin progress bar, and
|
|
3418
|
+
carries the replay affordance; played chapters wear a check. A row
|
|
3419
|
+
click seeks there and plays (clicking the active row again replays
|
|
3420
|
+
it from its start). listNav owns j/k + Enter while the rail holds
|
|
3421
|
+
focus (global:false scopes the listener to this pane; the root
|
|
3422
|
+
keydown handler skips events from inside it). ≥64rem the pane is a
|
|
3423
|
+
sticky column; below it collapses to a horizontal chip strip. -->
|
|
3424
|
+
<nav
|
|
3425
|
+
class="vp-chapters"
|
|
3426
|
+
bind:this={chapterRailEl}
|
|
3427
|
+
aria-label="Chapters"
|
|
3428
|
+
use:listNav={{
|
|
3429
|
+
itemSelector: '.vp-chapter-row',
|
|
3430
|
+
onSelect: onRailSelect,
|
|
3431
|
+
focusedClass: 'jk-focused',
|
|
3432
|
+
global: false
|
|
3433
|
+
}}
|
|
3434
|
+
>
|
|
3435
|
+
{#each railRows as ch, i (i)}
|
|
3436
|
+
<button
|
|
3437
|
+
type="button"
|
|
3438
|
+
class="vp-chapter-row"
|
|
3439
|
+
class:active={railActive === i}
|
|
3440
|
+
class:done={ch.played}
|
|
3441
|
+
data-chapter-index={i}
|
|
3442
|
+
aria-current={railActive === i ? 'true' : undefined}
|
|
3443
|
+
onclick={() => chapterGo(i)}
|
|
3444
|
+
>
|
|
3445
|
+
<span class="vp-chapter-head">
|
|
3446
|
+
{#if ch.status}
|
|
3447
|
+
<span class="vp-chapter-dot vp-chapter-dot-{ch.status}" aria-hidden="true"></span>
|
|
3448
|
+
{/if}
|
|
3449
|
+
<span class="vp-chapter-time">{formatTime(ch.startSec, useHours)}</span>
|
|
3450
|
+
<span class="vp-chapter-title">{ch.title}</span>
|
|
3451
|
+
{#if ch.played}
|
|
3452
|
+
<svg class="vp-chapter-check" viewBox="0 0 16 16" width="11" height="11" aria-hidden="true">
|
|
3453
|
+
<path d="M2.5 8.5 6 12l7.5-8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
|
3454
|
+
</svg>
|
|
3455
|
+
{/if}
|
|
3456
|
+
{#if railActive === i}
|
|
3457
|
+
<!-- Replay affordance: the active row's click already replays
|
|
3458
|
+
from its start, so the icon is the affordance's label. -->
|
|
3459
|
+
<svg class="vp-chapter-replay" viewBox="0 0 24 24" width="12" height="12" aria-hidden="true">
|
|
3460
|
+
<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" />
|
|
3461
|
+
</svg>
|
|
3462
|
+
{/if}
|
|
3463
|
+
</span>
|
|
3464
|
+
{#if ch.summary}
|
|
3465
|
+
<span class="vp-chapter-summary">{ch.summary}</span>
|
|
3466
|
+
{/if}
|
|
3467
|
+
{#if railActive === i}
|
|
3468
|
+
<span
|
|
3469
|
+
class="vp-chapter-bar"
|
|
3470
|
+
aria-hidden="true"
|
|
3471
|
+
style="--vp-chapter-progress: {ch.progress}"
|
|
3472
|
+
>
|
|
3473
|
+
<span class="vp-chapter-fill"></span>
|
|
3474
|
+
</span>
|
|
3475
|
+
{/if}
|
|
3476
|
+
</button>
|
|
3477
|
+
{/each}
|
|
3478
|
+
</nav>
|
|
3479
|
+
{/if}
|
|
3480
|
+
|
|
3481
|
+
{#if belowStage}
|
|
3482
|
+
<!-- mos-777hj — the host's content in the main column: under the stage
|
|
3483
|
+
(and its band), above the transcript. -->
|
|
3484
|
+
<div class="vp-below-stage">{@render belowStage()}</div>
|
|
3485
|
+
{/if}
|
|
3486
|
+
|
|
3147
3487
|
{#if sortedSegments.length > 0 && transcriptVisible}
|
|
3148
3488
|
<div
|
|
3149
3489
|
class="vp-transcript"
|
|
@@ -3160,6 +3500,7 @@
|
|
|
3160
3500
|
<button
|
|
3161
3501
|
type="button"
|
|
3162
3502
|
class="vp-row"
|
|
3503
|
+
class:vp-row-one={!multiSpeaker}
|
|
3163
3504
|
class:active={activeSegment?.id === seg.id}
|
|
3164
3505
|
class:vp-dim={highlightKey != null
|
|
3165
3506
|
? highlightKey !== speakerKeyOf(seg)
|
|
@@ -3168,9 +3509,11 @@
|
|
|
3168
3509
|
onclick={() => seekTo(seg.startSec)}
|
|
3169
3510
|
>
|
|
3170
3511
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3512
|
+
{#if multiSpeaker}
|
|
3513
|
+
<span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
|
|
3514
|
+
{seg.speakerName ?? 'Unknown speaker'}
|
|
3515
|
+
</span>
|
|
3516
|
+
{/if}
|
|
3174
3517
|
<span class="vp-row-text">{seg.text}</span>
|
|
3175
3518
|
</button>
|
|
3176
3519
|
{/each}
|
|
@@ -3180,6 +3523,7 @@
|
|
|
3180
3523
|
<button
|
|
3181
3524
|
type="button"
|
|
3182
3525
|
class="vp-row"
|
|
3526
|
+
class:vp-row-one={!multiSpeaker}
|
|
3183
3527
|
class:active={activeSegment?.id === seg.id}
|
|
3184
3528
|
class:vp-dim={highlightKey != null
|
|
3185
3529
|
? highlightKey !== speakerKeyOf(seg)
|
|
@@ -3188,9 +3532,11 @@
|
|
|
3188
3532
|
onclick={() => seekTo(seg.startSec)}
|
|
3189
3533
|
>
|
|
3190
3534
|
<span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3535
|
+
{#if multiSpeaker}
|
|
3536
|
+
<span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
|
|
3537
|
+
{seg.speakerName ?? 'Unknown speaker'}
|
|
3538
|
+
</span>
|
|
3539
|
+
{/if}
|
|
3194
3540
|
<span class="vp-row-text">{seg.text}</span>
|
|
3195
3541
|
</button>
|
|
3196
3542
|
{/each}
|
|
@@ -3276,6 +3622,244 @@
|
|
|
3276
3622
|
}
|
|
3277
3623
|
}
|
|
3278
3624
|
|
|
3625
|
+
/* ── Chapter rail (mos-777hj) ────────────────────────────────────────
|
|
3626
|
+
Opt-in (`chapterRail="side"` + chapters). ≥64rem the pane is a sticky
|
|
3627
|
+
column on the right of the main content (below the stage when the
|
|
3628
|
+
transcript sits below, a third column beside a side transcript);
|
|
3629
|
+
below 64rem the same DOM flows as a horizontal chip strip between the
|
|
3630
|
+
player and the below-stage content — grid areas re-place the panes,
|
|
3631
|
+
the DOM order never changes. Token-only chrome, dot tones resolve
|
|
3632
|
+
DaisyUI semantic vars. */
|
|
3633
|
+
.vp-below-stage {
|
|
3634
|
+
min-width: 0;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
.vp-chapters {
|
|
3638
|
+
display: flex;
|
|
3639
|
+
flex-direction: column;
|
|
3640
|
+
gap: 1px;
|
|
3641
|
+
min-width: 0;
|
|
3642
|
+
background: var(--color-base-100);
|
|
3643
|
+
border: var(--jatui-border-w, 1px) solid
|
|
3644
|
+
color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
3645
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
3646
|
+
padding: var(--jatui-space-sm, 4px);
|
|
3647
|
+
overflow-y: auto;
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
.vp-chapter-row {
|
|
3651
|
+
position: relative;
|
|
3652
|
+
display: flex;
|
|
3653
|
+
flex-direction: column;
|
|
3654
|
+
flex-shrink: 0;
|
|
3655
|
+
gap: 2px;
|
|
3656
|
+
background: none;
|
|
3657
|
+
border: none;
|
|
3658
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
3659
|
+
padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
|
|
3660
|
+
text-align: left;
|
|
3661
|
+
cursor: pointer;
|
|
3662
|
+
transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
.vp-chapter-row:hover {
|
|
3666
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
/* Active = a tinted row plus a primary title; no side stripe. */
|
|
3670
|
+
.vp-chapter-row.active {
|
|
3671
|
+
background: color-mix(in oklch, var(--color-primary) 12%, transparent);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
.vp-chapter-row.active .vp-chapter-title {
|
|
3675
|
+
color: var(--color-primary);
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
/* listNav's cursor (j/k) — same family as the hover/active surfaces. */
|
|
3679
|
+
.vp-chapter-row.jk-focused {
|
|
3680
|
+
outline: 2px solid color-mix(in oklch, var(--color-primary) 55%, transparent);
|
|
3681
|
+
outline-offset: -2px;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
.vp-chapter-row.done .vp-chapter-title {
|
|
3685
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
/* The playhead's chapter reads as current even once its span is played
|
|
3689
|
+
(playback ran past the last tagged line). */
|
|
3690
|
+
.vp-chapter-row.done.active .vp-chapter-title {
|
|
3691
|
+
color: var(--color-primary);
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
.vp-chapter-head {
|
|
3695
|
+
display: flex;
|
|
3696
|
+
align-items: baseline;
|
|
3697
|
+
gap: var(--jatui-space-sm, 4px);
|
|
3698
|
+
min-width: 0;
|
|
3699
|
+
}
|
|
3700
|
+
|
|
3701
|
+
.vp-chapter-dot {
|
|
3702
|
+
width: 0.5em;
|
|
3703
|
+
height: 0.5em;
|
|
3704
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
3705
|
+
flex: none;
|
|
3706
|
+
align-self: center;
|
|
3707
|
+
background: var(--color-base-content);
|
|
3708
|
+
opacity: 0.4;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
.vp-chapter-dot-info {
|
|
3712
|
+
background: var(--color-info);
|
|
3713
|
+
opacity: 1;
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
.vp-chapter-dot-success {
|
|
3717
|
+
background: var(--color-success);
|
|
3718
|
+
opacity: 1;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
.vp-chapter-dot-warning {
|
|
3722
|
+
background: var(--color-warning);
|
|
3723
|
+
opacity: 1;
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
.vp-chapter-dot-error {
|
|
3727
|
+
background: var(--color-error);
|
|
3728
|
+
opacity: 1;
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
.vp-chapter-time {
|
|
3732
|
+
font-family: ui-monospace, monospace;
|
|
3733
|
+
font-size: 0.66rem;
|
|
3734
|
+
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
|
|
3735
|
+
flex-shrink: 0;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
.vp-chapter-title {
|
|
3739
|
+
font-size: 0.76rem;
|
|
3740
|
+
font-weight: 600;
|
|
3741
|
+
color: var(--color-base-content);
|
|
3742
|
+
flex: 1;
|
|
3743
|
+
min-width: 0;
|
|
3744
|
+
overflow: hidden;
|
|
3745
|
+
text-overflow: ellipsis;
|
|
3746
|
+
white-space: nowrap;
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
.vp-chapter-check {
|
|
3750
|
+
color: var(--color-success);
|
|
3751
|
+
flex: none;
|
|
3752
|
+
align-self: center;
|
|
3753
|
+
}
|
|
3754
|
+
|
|
3755
|
+
.vp-chapter-replay {
|
|
3756
|
+
color: color-mix(in oklch, var(--color-primary) 80%, transparent);
|
|
3757
|
+
flex: none;
|
|
3758
|
+
align-self: center;
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
.vp-chapter-summary {
|
|
3762
|
+
font-size: 0.68rem;
|
|
3763
|
+
line-height: 1.35;
|
|
3764
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
3765
|
+
overflow: hidden;
|
|
3766
|
+
display: -webkit-box;
|
|
3767
|
+
-webkit-box-orient: vertical;
|
|
3768
|
+
-webkit-line-clamp: 2;
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
/* The active row's thin fill: transform, not width — a compositor move
|
|
3772
|
+
that never relayouts the row, stepping with the playhead tick. */
|
|
3773
|
+
.vp-chapter-bar {
|
|
3774
|
+
position: absolute;
|
|
3775
|
+
left: var(--jatui-space-md, 6px);
|
|
3776
|
+
right: var(--jatui-space-md, 6px);
|
|
3777
|
+
bottom: 1px;
|
|
3778
|
+
height: 2px;
|
|
3779
|
+
border-radius: 999px;
|
|
3780
|
+
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
3781
|
+
overflow: hidden;
|
|
3782
|
+
pointer-events: none;
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
.vp-chapter-fill {
|
|
3786
|
+
display: block;
|
|
3787
|
+
width: 100%;
|
|
3788
|
+
height: 100%;
|
|
3789
|
+
border-radius: inherit;
|
|
3790
|
+
background: var(--color-primary);
|
|
3791
|
+
transform: scaleX(var(--vp-chapter-progress, 0));
|
|
3792
|
+
transform-origin: left center;
|
|
3793
|
+
transition: transform 120ms linear;
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
@media (max-width: 63.999rem) {
|
|
3797
|
+
/* The chip strip: same rows, laid horizontally under the player. */
|
|
3798
|
+
.vp-chapters {
|
|
3799
|
+
flex-direction: row;
|
|
3800
|
+
gap: var(--jatui-space-xs, 2px);
|
|
3801
|
+
overflow-x: auto;
|
|
3802
|
+
overflow-y: hidden;
|
|
3803
|
+
padding: var(--jatui-space-xs, 2px);
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3806
|
+
.vp-chapter-row {
|
|
3807
|
+
flex: none;
|
|
3808
|
+
justify-content: center;
|
|
3809
|
+
max-width: 16rem;
|
|
3810
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-sm, 4px);
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
.vp-chapter-summary,
|
|
3814
|
+
.vp-chapter-bar {
|
|
3815
|
+
display: none;
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3818
|
+
.vp-chapter-title {
|
|
3819
|
+
max-width: 12rem;
|
|
3820
|
+
}
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
@media (min-width: 64rem) {
|
|
3824
|
+
.vp-root.vp-rail-side {
|
|
3825
|
+
display: grid;
|
|
3826
|
+
grid-template-columns: minmax(0, 1fr) minmax(13rem, 16rem);
|
|
3827
|
+
grid-template-areas:
|
|
3828
|
+
'main rail'
|
|
3829
|
+
'below rail'
|
|
3830
|
+
'transcript rail';
|
|
3831
|
+
gap: var(--jatui-space-lg, 12px);
|
|
3832
|
+
align-items: start;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
.vp-root.vp-rail-side > .vp-main {
|
|
3836
|
+
grid-area: main;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
.vp-root.vp-rail-side > .vp-chapters {
|
|
3840
|
+
grid-area: rail;
|
|
3841
|
+
position: sticky;
|
|
3842
|
+
top: var(--jatui-space-lg, 12px);
|
|
3843
|
+
max-height: calc(100vh - 2 * var(--jatui-space-lg, 12px));
|
|
3844
|
+
}
|
|
3845
|
+
|
|
3846
|
+
.vp-root.vp-rail-side > .vp-below-stage {
|
|
3847
|
+
grid-area: below;
|
|
3848
|
+
}
|
|
3849
|
+
|
|
3850
|
+
.vp-root.vp-rail-side > .vp-transcript {
|
|
3851
|
+
grid-area: transcript;
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
/* Rail beside a side transcript (theater): three columns. */
|
|
3855
|
+
.vp-root.vp-side.vp-rail-side {
|
|
3856
|
+
grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr) minmax(11rem, 14rem);
|
|
3857
|
+
grid-template-areas:
|
|
3858
|
+
'main transcript rail'
|
|
3859
|
+
'below transcript rail';
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3279
3863
|
.vp-stage {
|
|
3280
3864
|
position: relative;
|
|
3281
3865
|
}
|
|
@@ -3340,10 +3924,133 @@
|
|
|
3340
3924
|
}
|
|
3341
3925
|
|
|
3342
3926
|
.vp-root:hover .vp-band,
|
|
3343
|
-
.vp-band:focus-within
|
|
3927
|
+
.vp-band:focus-within,
|
|
3928
|
+
.vp-band-transport .vp-band {
|
|
3929
|
+
/* With the band transport the band is the ONLY timeline: never ambient. */
|
|
3344
3930
|
opacity: 1;
|
|
3345
3931
|
}
|
|
3346
3932
|
|
|
3933
|
+
/* Audio: no picture, so no stage at all. The element still plays. */
|
|
3934
|
+
.vp-stage-audio {
|
|
3935
|
+
display: none;
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
.vp-stage-clickable {
|
|
3939
|
+
cursor: pointer;
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
/* The OSD's band seat (audio has no stage to float it over). */
|
|
3943
|
+
.vp-osd-band {
|
|
3944
|
+
top: 0;
|
|
3945
|
+
left: 0;
|
|
3946
|
+
max-width: 100%;
|
|
3947
|
+
overflow: hidden;
|
|
3948
|
+
text-overflow: ellipsis;
|
|
3949
|
+
white-space: nowrap;
|
|
3950
|
+
z-index: 4;
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/* ── Band transport row ─────────────────────────────────────────────── */
|
|
3954
|
+
.vp-transport {
|
|
3955
|
+
font-size: 0.75rem;
|
|
3956
|
+
}
|
|
3957
|
+
|
|
3958
|
+
.vp-transport-start,
|
|
3959
|
+
.vp-transport-end {
|
|
3960
|
+
display: inline-flex;
|
|
3961
|
+
align-items: center;
|
|
3962
|
+
gap: var(--jatui-space-sm, 4px);
|
|
3963
|
+
min-width: 0;
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
.vp-play {
|
|
3967
|
+
display: inline-grid;
|
|
3968
|
+
place-items: center;
|
|
3969
|
+
width: 2rem;
|
|
3970
|
+
height: 2rem;
|
|
3971
|
+
flex: none;
|
|
3972
|
+
border: none;
|
|
3973
|
+
border-radius: 999px;
|
|
3974
|
+
background: var(--color-primary);
|
|
3975
|
+
color: var(--color-primary-content);
|
|
3976
|
+
cursor: pointer;
|
|
3977
|
+
transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
3978
|
+
}
|
|
3979
|
+
|
|
3980
|
+
.vp-play:hover {
|
|
3981
|
+
transform: scale(1.06);
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
.vp-play:active {
|
|
3985
|
+
transform: scale(0.96);
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
.vp-clock {
|
|
3989
|
+
display: inline-flex;
|
|
3990
|
+
gap: 0.3em;
|
|
3991
|
+
margin-left: var(--jatui-space-xs, 2px);
|
|
3992
|
+
white-space: nowrap;
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
.vp-clock-now {
|
|
3996
|
+
color: var(--color-base-content);
|
|
3997
|
+
font-weight: 600;
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
.vp-clock-sep {
|
|
4001
|
+
opacity: 0.5;
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
.vp-tbtn {
|
|
4005
|
+
display: inline-grid;
|
|
4006
|
+
place-items: center;
|
|
4007
|
+
min-width: 1.75rem;
|
|
4008
|
+
height: 1.75rem;
|
|
4009
|
+
padding: 0 var(--jatui-space-xs, 2px);
|
|
4010
|
+
border: none;
|
|
4011
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
4012
|
+
background: none;
|
|
4013
|
+
color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
|
|
4014
|
+
font-size: inherit;
|
|
4015
|
+
font-variant-numeric: tabular-nums;
|
|
4016
|
+
cursor: pointer;
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
.vp-tbtn:hover {
|
|
4020
|
+
color: var(--color-base-content);
|
|
4021
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
.vp-play:focus-visible,
|
|
4025
|
+
.vp-tbtn:focus-visible,
|
|
4026
|
+
.vp-volume:focus-visible {
|
|
4027
|
+
outline: 2px solid var(--color-primary);
|
|
4028
|
+
outline-offset: 2px;
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
.vp-speed {
|
|
4032
|
+
min-width: 2.5rem;
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
.vp-volume {
|
|
4036
|
+
width: 4.5rem;
|
|
4037
|
+
accent-color: var(--color-primary);
|
|
4038
|
+
cursor: pointer;
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
/* A fingertip sets volume with the device buttons; the slider is noise.
|
|
4042
|
+
The play button grows to a full 44px target (and stays round). */
|
|
4043
|
+
@media (pointer: coarse) {
|
|
4044
|
+
.vp-volume {
|
|
4045
|
+
display: none;
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
.vp-play {
|
|
4049
|
+
width: 2.75rem;
|
|
4050
|
+
height: 2.75rem;
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
|
|
3347
4054
|
/* One time axis: while the band owns it, the native timeline stands down.
|
|
3348
4055
|
Only Chromium exposes it; elsewhere this matches nothing and the native
|
|
3349
4056
|
bar simply stays (see video/band/axis.ts). */
|
|
@@ -3563,20 +4270,14 @@
|
|
|
3563
4270
|
/* Talk-time + legend */
|
|
3564
4271
|
.vp-talktime {
|
|
3565
4272
|
display: flex;
|
|
3566
|
-
flex-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
.vp-talktime-bar {
|
|
3571
|
-
display: flex;
|
|
3572
|
-
height: 0.6rem;
|
|
3573
|
-
border-radius: 999px;
|
|
3574
|
-
overflow: hidden;
|
|
3575
|
-
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
4273
|
+
flex-wrap: wrap;
|
|
4274
|
+
align-items: center;
|
|
4275
|
+
gap: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
|
|
3576
4276
|
}
|
|
3577
4277
|
|
|
3578
|
-
.vp-talktime-
|
|
3579
|
-
|
|
4278
|
+
.vp-talktime-label {
|
|
4279
|
+
font-size: 0.68rem;
|
|
4280
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
3580
4281
|
}
|
|
3581
4282
|
|
|
3582
4283
|
.vp-legend {
|
|
@@ -3613,7 +4314,10 @@
|
|
|
3613
4314
|
}
|
|
3614
4315
|
|
|
3615
4316
|
.vp-chip-clear {
|
|
4317
|
+
background: none;
|
|
3616
4318
|
color: var(--color-primary);
|
|
4319
|
+
text-decoration: underline;
|
|
4320
|
+
text-underline-offset: 2px;
|
|
3617
4321
|
}
|
|
3618
4322
|
|
|
3619
4323
|
.vp-chip-dot {
|
|
@@ -3629,18 +4333,31 @@
|
|
|
3629
4333
|
}
|
|
3630
4334
|
|
|
3631
4335
|
.vp-mute {
|
|
4336
|
+
display: inline-grid;
|
|
4337
|
+
place-items: center;
|
|
4338
|
+
width: 1.5rem;
|
|
4339
|
+
height: 1.5rem;
|
|
3632
4340
|
background: none;
|
|
3633
4341
|
border: none;
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
4342
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
4343
|
+
padding: 0;
|
|
4344
|
+
color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
|
|
3637
4345
|
cursor: pointer;
|
|
3638
|
-
opacity: 0.5;
|
|
3639
4346
|
}
|
|
3640
4347
|
|
|
3641
|
-
.vp-mute.on,
|
|
3642
4348
|
.vp-mute:hover {
|
|
3643
|
-
|
|
4349
|
+
color: var(--color-base-content);
|
|
4350
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
4351
|
+
}
|
|
4352
|
+
|
|
4353
|
+
.vp-mute.on {
|
|
4354
|
+
color: var(--color-primary);
|
|
4355
|
+
}
|
|
4356
|
+
|
|
4357
|
+
.vp-mute:focus-visible,
|
|
4358
|
+
.vp-chip:focus-visible {
|
|
4359
|
+
outline: 2px solid var(--color-primary);
|
|
4360
|
+
outline-offset: 1px;
|
|
3644
4361
|
}
|
|
3645
4362
|
|
|
3646
4363
|
/* Add-bookmark row — only rendered when the host wired onAddBookmark. */
|
|
@@ -3704,6 +4421,7 @@
|
|
|
3704
4421
|
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
3705
4422
|
padding: var(--jatui-space-sm, 4px);
|
|
3706
4423
|
min-width: 0;
|
|
4424
|
+
container: vp-transcript / inline-size;
|
|
3707
4425
|
}
|
|
3708
4426
|
|
|
3709
4427
|
/* Windowing stand-ins (mos-5lzns.95): fixed-height blocks sized by the
|
|
@@ -3714,11 +4432,21 @@
|
|
|
3714
4432
|
pointer-events: none;
|
|
3715
4433
|
}
|
|
3716
4434
|
|
|
4435
|
+
/* Rows are a three-column grid (time | speaker | text) so every line's
|
|
4436
|
+
text starts on one edge whatever the speaker's name length; one-voice
|
|
4437
|
+
transcripts drop the speaker column. A narrow pane (container query,
|
|
4438
|
+
not viewport: the player often sits in a narrow column) stacks the
|
|
4439
|
+
speaker above the text. */
|
|
3717
4440
|
.vp-row {
|
|
3718
|
-
display:
|
|
4441
|
+
display: grid;
|
|
4442
|
+
grid-template-columns: 2.75rem minmax(0, 6.5rem) minmax(0, 1fr);
|
|
3719
4443
|
align-items: baseline;
|
|
3720
|
-
|
|
3721
|
-
|
|
4444
|
+
/* Never shrink inside the capped pane: a host's touch min-height
|
|
4445
|
+
replaces min-height:auto, and the flex column would then squash
|
|
4446
|
+
rows to that floor and overlap their text. */
|
|
4447
|
+
flex-shrink: 0;
|
|
4448
|
+
column-gap: var(--jatui-space-base, 8px);
|
|
4449
|
+
row-gap: 0;
|
|
3722
4450
|
background: none;
|
|
3723
4451
|
border: none;
|
|
3724
4452
|
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
@@ -3733,8 +4461,11 @@
|
|
|
3733
4461
|
}
|
|
3734
4462
|
|
|
3735
4463
|
.vp-row.active {
|
|
3736
|
-
background: color-mix(in oklch, var(--color-primary)
|
|
3737
|
-
|
|
4464
|
+
background: color-mix(in oklch, var(--color-primary) 12%, transparent);
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4467
|
+
.vp-row.active .vp-row-time {
|
|
4468
|
+
color: var(--color-primary);
|
|
3738
4469
|
}
|
|
3739
4470
|
|
|
3740
4471
|
.vp-row-time {
|
|
@@ -3747,14 +4478,32 @@
|
|
|
3747
4478
|
.vp-row-speaker {
|
|
3748
4479
|
font-size: 0.72rem;
|
|
3749
4480
|
font-weight: 600;
|
|
3750
|
-
|
|
4481
|
+
min-width: 0;
|
|
4482
|
+
overflow: hidden;
|
|
4483
|
+
text-overflow: ellipsis;
|
|
4484
|
+
white-space: nowrap;
|
|
3751
4485
|
}
|
|
3752
4486
|
|
|
3753
4487
|
.vp-row-text {
|
|
3754
|
-
font-size: 0.
|
|
4488
|
+
font-size: 0.8125rem;
|
|
4489
|
+
line-height: 1.45;
|
|
3755
4490
|
color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
|
|
3756
|
-
|
|
3757
|
-
|
|
4491
|
+
min-width: 0;
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
.vp-row.vp-row-one {
|
|
4495
|
+
grid-template-columns: 2.75rem minmax(0, 1fr);
|
|
4496
|
+
}
|
|
4497
|
+
|
|
4498
|
+
@container vp-transcript (max-width: 26rem) {
|
|
4499
|
+
.vp-row {
|
|
4500
|
+
grid-template-columns: 2.75rem minmax(0, 1fr);
|
|
4501
|
+
}
|
|
4502
|
+
|
|
4503
|
+
.vp-row-speaker,
|
|
4504
|
+
.vp-row-text {
|
|
4505
|
+
grid-column: 2;
|
|
4506
|
+
}
|
|
3758
4507
|
}
|
|
3759
4508
|
|
|
3760
4509
|
/* Keyboard help overlay — the `?` surface. Keycaps first, description after;
|
|
@@ -3838,9 +4587,17 @@
|
|
|
3838
4587
|
.vp-lane-span,
|
|
3839
4588
|
.vp-chip,
|
|
3840
4589
|
.vp-add-btn,
|
|
3841
|
-
.vp-row
|
|
4590
|
+
.vp-row,
|
|
4591
|
+
.vp-chapter-row,
|
|
4592
|
+
.vp-chapter-fill,
|
|
4593
|
+
.vp-play {
|
|
3842
4594
|
transition: none;
|
|
3843
4595
|
}
|
|
4596
|
+
|
|
4597
|
+
.vp-play:hover,
|
|
4598
|
+
.vp-play:active {
|
|
4599
|
+
transform: none;
|
|
4600
|
+
}
|
|
3844
4601
|
}
|
|
3845
4602
|
|
|
3846
4603
|
/* ── Night (mos-5lzns.33) ─────────────────────────────────────────────
|