@bendyline/squisq-react 2.3.3 → 2.4.1

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.
@@ -7,40 +7,137 @@ import {
7
7
  useDocPlayback,
8
8
  useMediaSchedule,
9
9
  useViewportOrientation
10
- } from "./chunk-NWZQGZIJ.js";
10
+ } from "./chunk-BOZJ655L.js";
11
11
  import {
12
12
  useAutoSurface
13
13
  } from "./chunk-TT6ENR6T.js";
14
14
  import {
15
- useMediaUrl
15
+ useMediaProvider,
16
+ useMediaUrl,
17
+ useResourcePolicy
16
18
  } from "./chunk-LR3AIGDD.js";
17
19
 
18
20
  // src/MediaClipLayer.tsx
19
21
  import { useEffect, useRef } from "react";
20
- import { jsx } from "react/jsx-runtime";
22
+ import { Fragment, jsx } from "react/jsx-runtime";
21
23
  var DRIFT = 0.25;
24
+ function mediaGroupStyle(presentation) {
25
+ return {
26
+ position: "absolute",
27
+ inset: 0,
28
+ zIndex: presentation === "background" ? 0 : 10,
29
+ pointerEvents: "none"
30
+ };
31
+ }
32
+ function pipCornerStyle(position) {
33
+ switch (position) {
34
+ case "top-left":
35
+ return { top: "4%", left: "3%" };
36
+ case "top-right":
37
+ return { top: "4%", right: "3%" };
38
+ case "bottom-left":
39
+ return { bottom: "6%", left: "3%" };
40
+ case "bottom-right":
41
+ return { right: "3%", bottom: "6%" };
42
+ }
43
+ }
44
+ function pipWidth(size, orientation) {
45
+ if (orientation === "portrait") return size === "large" ? "23%" : "15%";
46
+ return size === "large" ? "15%" : "9%";
47
+ }
48
+ function mediaVideoStyle({
49
+ presentation,
50
+ pipSize,
51
+ pipShape,
52
+ pipPosition,
53
+ pipOrientation,
54
+ pipFrameStyle,
55
+ active
56
+ }) {
57
+ const base = {
58
+ position: "absolute",
59
+ display: "block",
60
+ objectFit: "cover",
61
+ opacity: active ? 1 : 0,
62
+ visibility: active ? "visible" : "hidden",
63
+ pointerEvents: "none"
64
+ };
65
+ if (presentation !== "picture-in-picture") {
66
+ return { ...base, inset: 0, width: "100%", height: "100%" };
67
+ }
68
+ return {
69
+ ...base,
70
+ width: pipWidth(pipSize, pipOrientation),
71
+ aspectRatio: pipShape === "wide" ? "16 / 9" : "1",
72
+ ...pipFrameStyle,
73
+ ...pipCornerStyle(pipPosition)
74
+ };
75
+ }
22
76
  function MediaClipLayer({
23
77
  schedule,
24
78
  currentTime,
25
79
  isPlaying,
26
80
  basePath,
27
81
  renderMode = false,
28
- muted = false
82
+ muted = false,
83
+ presentation = "background",
84
+ pipSize = "small",
85
+ pipShape = "square",
86
+ pipPosition = "bottom-right",
87
+ pipOrientation = "landscape",
88
+ pipFrameStyle,
89
+ honorClipPresentation = true
29
90
  }) {
30
91
  const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
31
92
  if (renderClips.length === 0) return null;
32
- return /* @__PURE__ */ jsx("div", { className: "doc-player__media-clips", "aria-hidden": true, children: renderClips.map((clip) => /* @__PURE__ */ jsx(
33
- MediaClipElement,
93
+ const groups = /* @__PURE__ */ new Map();
94
+ for (const clip of renderClips) {
95
+ const clipPresentation = honorClipPresentation && clip.kind === "video" ? clip.placement === "picture-in-picture" ? "picture-in-picture" : clip.placement === "overlay" ? "full-frame" : presentation : presentation;
96
+ const clipPipSize = clip.pipSize ?? pipSize;
97
+ const clipPipShape = clip.pipShape ?? pipShape;
98
+ const clipPipPosition = clip.pipPosition ?? pipPosition;
99
+ const key = `${clipPresentation}:${clipPipSize}:${clipPipShape}:${clipPipPosition}`;
100
+ const group = groups.get(key) ?? {
101
+ presentation: clipPresentation,
102
+ pipSize: clipPipSize,
103
+ pipShape: clipPipShape,
104
+ pipPosition: clipPipPosition,
105
+ clips: []
106
+ };
107
+ group.clips.push(clip);
108
+ groups.set(key, group);
109
+ }
110
+ return /* @__PURE__ */ jsx(Fragment, { children: [...groups].map(([key, group]) => /* @__PURE__ */ jsx(
111
+ "div",
34
112
  {
35
- clip,
36
- active: activeIds.has(clip.id),
37
- currentTime,
38
- isPlaying,
39
- basePath,
40
- renderMode,
41
- muted
113
+ className: `doc-player__media-clips doc-player__media-clips--${group.presentation}`,
114
+ "data-presentation": group.presentation,
115
+ "data-pip-size": group.pipSize,
116
+ "data-pip-shape": group.pipShape,
117
+ "data-pip-position": group.pipPosition,
118
+ "aria-hidden": true,
119
+ style: mediaGroupStyle(group.presentation),
120
+ children: group.clips.map((clip) => /* @__PURE__ */ jsx(
121
+ MediaClipElement,
122
+ {
123
+ clip,
124
+ active: activeIds.has(clip.id),
125
+ currentTime,
126
+ isPlaying,
127
+ basePath,
128
+ renderMode,
129
+ muted,
130
+ presentation: group.presentation,
131
+ pipSize: group.pipSize,
132
+ pipShape: group.pipShape,
133
+ pipPosition: group.pipPosition,
134
+ pipOrientation,
135
+ pipFrameStyle
136
+ },
137
+ clip.id
138
+ ))
42
139
  },
43
- clip.id
140
+ key
44
141
  )) });
45
142
  }
46
143
  function MediaClipElement({
@@ -50,7 +147,13 @@ function MediaClipElement({
50
147
  isPlaying,
51
148
  basePath,
52
149
  renderMode,
53
- muted
150
+ muted,
151
+ presentation,
152
+ pipSize,
153
+ pipShape,
154
+ pipPosition,
155
+ pipOrientation,
156
+ pipFrameStyle
54
157
  }) {
55
158
  const ref = useRef(null);
56
159
  const src = useMediaUrl(clip.src, basePath);
@@ -62,7 +165,7 @@ function MediaClipElement({
62
165
  return;
63
166
  }
64
167
  const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
65
- if (renderMode || Math.abs(el.currentTime - target) > DRIFT) {
168
+ if (renderMode || !isPlaying || Math.abs(el.currentTime - target) > DRIFT) {
66
169
  try {
67
170
  el.currentTime = target;
68
171
  } catch {
@@ -91,17 +194,20 @@ function MediaClipElement({
91
194
  "video",
92
195
  {
93
196
  ...common,
94
- muted: true,
197
+ className: `doc-player__media-video${active ? " doc-player__media-video--active" : ""}`,
198
+ "data-active": active ? "true" : "false",
199
+ "data-video-placement": clip.placement ?? "default",
200
+ muted: renderMode || muted,
95
201
  playsInline: true,
96
- style: {
97
- position: "absolute",
98
- inset: 0,
99
- width: "100%",
100
- height: "100%",
101
- objectFit: "cover",
102
- zIndex: 0,
103
- pointerEvents: "none"
104
- }
202
+ style: mediaVideoStyle({
203
+ presentation,
204
+ pipSize,
205
+ pipShape,
206
+ pipPosition,
207
+ pipOrientation,
208
+ pipFrameStyle,
209
+ active
210
+ })
105
211
  }
106
212
  );
107
213
  }
@@ -115,8 +221,123 @@ function MediaClipElement({
115
221
  );
116
222
  }
117
223
 
224
+ // src/hooks/useMediaClipDurations.ts
225
+ import { useEffect as useEffect2, useMemo, useState } from "react";
226
+ import { isResourceUrlAllowed } from "@bendyline/squisq/markdown";
227
+ var durationCache = /* @__PURE__ */ new Map();
228
+ var PROBE_TIMEOUT_MS = 8e3;
229
+ async function resolveMediaUrl(src, provider, basePath, policy) {
230
+ const isAbsolute = !src || /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(src);
231
+ const fallback = isAbsolute ? src : `${basePath.replace(/\/$/, "")}/${src}`;
232
+ const gate = (url) => isResourceUrlAllowed(url, policy) ? url : "";
233
+ if (isAbsolute || !provider) return gate(fallback);
234
+ try {
235
+ return gate(await provider.resolveUrl(src));
236
+ } catch {
237
+ return gate(fallback);
238
+ }
239
+ }
240
+ function probeVideoDuration(url) {
241
+ if (typeof document === "undefined") return Promise.resolve(null);
242
+ return new Promise((resolve) => {
243
+ const el = document.createElement("video");
244
+ el.preload = "metadata";
245
+ el.muted = true;
246
+ el.setAttribute("playsinline", "");
247
+ let settled = false;
248
+ const timeout = {};
249
+ const finish = (seconds) => {
250
+ if (settled) return;
251
+ settled = true;
252
+ if (timeout.id !== void 0) clearTimeout(timeout.id);
253
+ el.removeEventListener("durationchange", onDurationProbe);
254
+ el.removeEventListener("timeupdate", onDurationProbe);
255
+ el.removeAttribute("src");
256
+ try {
257
+ el.load();
258
+ } catch {
259
+ }
260
+ resolve(seconds);
261
+ };
262
+ const readFinite = () => {
263
+ const d = el.duration;
264
+ if (Number.isFinite(d) && d > 0) {
265
+ finish(d);
266
+ return true;
267
+ }
268
+ return false;
269
+ };
270
+ const onDurationProbe = () => {
271
+ readFinite();
272
+ };
273
+ el.addEventListener(
274
+ "loadedmetadata",
275
+ () => {
276
+ if (readFinite()) return;
277
+ el.addEventListener("durationchange", onDurationProbe);
278
+ el.addEventListener("timeupdate", onDurationProbe);
279
+ try {
280
+ el.currentTime = 1e101;
281
+ } catch {
282
+ finish(null);
283
+ }
284
+ },
285
+ { once: true }
286
+ );
287
+ el.addEventListener("error", () => finish(null), { once: true });
288
+ timeout.id = setTimeout(() => finish(null), PROBE_TIMEOUT_MS);
289
+ el.src = url;
290
+ });
291
+ }
292
+ function useMediaClipDurations(schedule, basePath, mediaProviderOverride) {
293
+ const contextProvider = useMediaProvider();
294
+ const provider = mediaProviderOverride !== void 0 ? mediaProviderOverride : contextProvider;
295
+ const resourcePolicy = useResourcePolicy();
296
+ const srcKey = useMemo(() => {
297
+ const set = /* @__PURE__ */ new Set();
298
+ for (const clip of schedule) {
299
+ if (clip.kind === "video" && clip.src) set.add(clip.src);
300
+ }
301
+ return Array.from(set).sort().join("\n");
302
+ }, [schedule]);
303
+ const [durations, setDurations] = useState(() => /* @__PURE__ */ new Map());
304
+ useEffect2(() => {
305
+ const srcs = srcKey ? srcKey.split("\n") : [];
306
+ if (srcs.length === 0) {
307
+ setDurations((prev) => prev.size === 0 ? prev : /* @__PURE__ */ new Map());
308
+ return;
309
+ }
310
+ let cancelled = false;
311
+ const resolved = /* @__PURE__ */ new Map();
312
+ const commit = () => {
313
+ if (!cancelled) setDurations(new Map(resolved));
314
+ };
315
+ for (const src of srcs) {
316
+ resolveMediaUrl(src, provider, basePath, resourcePolicy).then((url) => {
317
+ if (cancelled || !url) return;
318
+ const cached = durationCache.get(url);
319
+ if (cached != null) {
320
+ resolved.set(src, cached);
321
+ commit();
322
+ return;
323
+ }
324
+ probeVideoDuration(url).then((seconds) => {
325
+ if (cancelled || seconds == null) return;
326
+ durationCache.set(url, seconds);
327
+ resolved.set(src, seconds);
328
+ commit();
329
+ });
330
+ });
331
+ }
332
+ return () => {
333
+ cancelled = true;
334
+ };
335
+ }, [srcKey, provider, basePath, resourcePolicy]);
336
+ return durations;
337
+ }
338
+
118
339
  // src/SocialCaptionOverlay.tsx
119
- import { useMemo } from "react";
340
+ import { useMemo as useMemo2 } from "react";
120
341
  import { resolveFontFamily } from "@bendyline/squisq/schemas";
121
342
  import { jsx as jsx2 } from "react/jsx-runtime";
122
343
  var TARGET_CHUNK_SIZE = 4;
@@ -172,7 +393,7 @@ function SocialCaptionOverlay({
172
393
  theme,
173
394
  viewport
174
395
  }) {
175
- const { chunks } = useMemo(
396
+ const { chunks } = useMemo2(
176
397
  () => captions ? buildWordStream(captions) : { words: [], chunks: [] },
177
398
  [captions]
178
399
  );
@@ -374,7 +595,7 @@ function formatTime(seconds) {
374
595
  }
375
596
 
376
597
  // src/DocProgressBar.tsx
377
- import { useRef as useRef2, useState, useCallback } from "react";
598
+ import { useRef as useRef2, useState as useState2, useCallback } from "react";
378
599
  import { jsx as jsx4, jsxs } from "react/jsx-runtime";
379
600
  function DocProgressBar({
380
601
  state,
@@ -384,7 +605,7 @@ function DocProgressBar({
384
605
  getBlockTitle
385
606
  }) {
386
607
  const progressBarRef = useRef2(null);
387
- const [hoverPosition, setHoverPosition] = useState(null);
608
+ const [hoverPosition, setHoverPosition] = useState2(null);
388
609
  const playProgress = state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
389
610
  const handleProgressHover = useCallback((e) => {
390
611
  const bar = progressBarRef.current;
@@ -711,7 +932,7 @@ function DocControlsOverlay({
711
932
  }
712
933
 
713
934
  // src/DocControlsSlideshow.tsx
714
- import { useCallback as useCallback2, useEffect as useEffect2, useId, useLayoutEffect, useRef as useRef3, useState as useState2 } from "react";
935
+ import { useCallback as useCallback2, useEffect as useEffect3, useId, useLayoutEffect, useRef as useRef3, useState as useState3 } from "react";
715
936
  import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
716
937
  function DocControlsSlideshow({
717
938
  state,
@@ -720,7 +941,7 @@ function DocControlsSlideshow({
720
941
  pickerOpen,
721
942
  onPickerOpenChange
722
943
  }) {
723
- const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState2(false);
944
+ const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState3(false);
724
945
  const isPickerOpen = pickerOpen ?? uncontrolledPickerOpen;
725
946
  const setPickerOpen = useCallback2(
726
947
  (open) => {
@@ -729,7 +950,7 @@ function DocControlsSlideshow({
729
950
  },
730
951
  [pickerOpen, onPickerOpenChange]
731
952
  );
732
- const [pickerMaxHeight, setPickerMaxHeight] = useState2(280);
953
+ const [pickerMaxHeight, setPickerMaxHeight] = useState3(280);
733
954
  const controlsRef = useRef3(null);
734
955
  const triggerRef = useRef3(null);
735
956
  const menuRef = useRef3(null);
@@ -744,7 +965,7 @@ function DocControlsSlideshow({
744
965
  const isFirst = currentBlockIndex <= 0;
745
966
  const isLast = currentBlockIndex >= totalBlocks - 1;
746
967
  const counterText = totalBlocks > 0 ? currentSlideLabel ?? `${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}` : "\u2014";
747
- useEffect2(() => {
968
+ useEffect3(() => {
748
969
  if (!isPickerOpen) return;
749
970
  const handlePointerDown = (event) => {
750
971
  if (!controlsRef.current?.contains(event.target)) setPickerOpen(false);
@@ -783,7 +1004,7 @@ function DocControlsSlideshow({
783
1004
  window.removeEventListener("resize", updateMaxHeight);
784
1005
  };
785
1006
  }, [isPickerOpen]);
786
- useEffect2(() => {
1007
+ useEffect3(() => {
787
1008
  if (!isPickerOpen) return;
788
1009
  menuRef.current?.querySelector('[aria-current="true"]')?.focus();
789
1010
  }, [isPickerOpen]);
@@ -1021,18 +1242,105 @@ function DocControlsSlideshow({
1021
1242
  );
1022
1243
  }
1023
1244
 
1245
+ // src/docPlayer/playerAppearance.ts
1246
+ import { resolveThemeForDoc } from "@bendyline/squisq/doc";
1247
+ function readFrontmatterSetting(frontmatter, canonical, legacy) {
1248
+ if (!frontmatter) return void 0;
1249
+ return Object.prototype.hasOwnProperty.call(frontmatter, canonical) ? frontmatter[canonical] : frontmatter[legacy];
1250
+ }
1251
+ function resolveVideoPresentation(value) {
1252
+ if (typeof value !== "string") return void 0;
1253
+ const normalized = value.trim().toLowerCase();
1254
+ if (normalized === "background" || normalized === "behind") return "background";
1255
+ if (normalized === "full-frame" || normalized === "fullscreen" || normalized === "full") {
1256
+ return "full-frame";
1257
+ }
1258
+ if (normalized === "picture-in-picture" || normalized === "pip" || normalized === "overlay") {
1259
+ return "picture-in-picture";
1260
+ }
1261
+ return void 0;
1262
+ }
1263
+ function resolvePipSize(value) {
1264
+ if (typeof value !== "string") return void 0;
1265
+ const normalized = value.trim().toLowerCase();
1266
+ if (normalized === "small" || normalized === "sm" || normalized === "pip-small") return "small";
1267
+ if (normalized === "large" || normalized === "lg" || normalized === "big" || normalized === "pip-large") {
1268
+ return "large";
1269
+ }
1270
+ return void 0;
1271
+ }
1272
+ function resolvePipShape(value) {
1273
+ if (typeof value !== "string") return void 0;
1274
+ const normalized = value.trim().toLowerCase();
1275
+ if (normalized === "square" || normalized === "1:1") return "square";
1276
+ if (normalized === "wide" || normalized === "16:9" || normalized === "widescreen") return "wide";
1277
+ return void 0;
1278
+ }
1279
+ function resolvePipPosition(value) {
1280
+ if (typeof value !== "string") return void 0;
1281
+ const normalized = value.trim().toLowerCase().replace(/[_\s]+/g, "-");
1282
+ const aliases = {
1283
+ "top-left": "top-left",
1284
+ "upper-left": "top-left",
1285
+ "top-right": "top-right",
1286
+ "upper-right": "top-right",
1287
+ "bottom-left": "bottom-left",
1288
+ "lower-left": "bottom-left",
1289
+ "bottom-right": "bottom-right",
1290
+ "lower-right": "bottom-right"
1291
+ };
1292
+ return aliases[normalized];
1293
+ }
1294
+ function resolveBoolean(value) {
1295
+ if (typeof value === "boolean") return value;
1296
+ if (typeof value !== "string") return void 0;
1297
+ const normalized = value.trim().toLowerCase();
1298
+ if (normalized === "true" || normalized === "yes" || normalized === "on" || normalized === "show" || normalized === "visible") {
1299
+ return true;
1300
+ }
1301
+ if (normalized === "false" || normalized === "no" || normalized === "off" || normalized === "hide" || normalized === "hidden") {
1302
+ return false;
1303
+ }
1304
+ return void 0;
1305
+ }
1306
+ function resolveDocPlayerAppearance(doc, overrides = {}) {
1307
+ const frontmatter = doc.frontmatter;
1308
+ return {
1309
+ theme: overrides.theme ?? resolveThemeForDoc(doc),
1310
+ videoPresentation: overrides.videoPresentation ?? resolveVideoPresentation(
1311
+ readFrontmatterSetting(frontmatter, "squisq-video-presentation", "video-presentation")
1312
+ ) ?? "background",
1313
+ pipSize: overrides.pipSize ?? resolvePipSize(readFrontmatterSetting(frontmatter, "squisq-pip-size", "pip-size")) ?? "small",
1314
+ pipShape: overrides.pipShape ?? resolvePipShape(readFrontmatterSetting(frontmatter, "squisq-pip-shape", "pip-shape")) ?? "square",
1315
+ pipPosition: overrides.pipPosition ?? resolvePipPosition(
1316
+ readFrontmatterSetting(frontmatter, "squisq-pip-position", "pip-position")
1317
+ ) ?? "bottom-right",
1318
+ showCoverSlide: overrides.showCoverSlide ?? resolveBoolean(readFrontmatterSetting(frontmatter, "squisq-cover-slide", "cover-slide")) ?? true
1319
+ };
1320
+ }
1321
+
1024
1322
  // src/DocPlayer.tsx
1025
- import { Fragment, useId as useId2, useRef as useRef5, useState as useState4, useEffect as useEffect4, useCallback as useCallback4, useMemo as useMemo2 } from "react";
1323
+ import {
1324
+ Fragment as Fragment2,
1325
+ useId as useId2,
1326
+ useRef as useRef5,
1327
+ useState as useState5,
1328
+ useEffect as useEffect5,
1329
+ useLayoutEffect as useLayoutEffect2,
1330
+ useCallback as useCallback4,
1331
+ useMemo as useMemo3
1332
+ } from "react";
1333
+ import { flushSync } from "react-dom";
1026
1334
  import {
1027
1335
  isTemplateBlock as isTemplateBlock2,
1028
1336
  getCaptionAtTime as getCaptionAtTime2,
1029
1337
  resolveMediaSchedule,
1030
1338
  getDocPlaybackDuration
1031
1339
  } from "@bendyline/squisq/schemas";
1032
- import { applySurface } from "@bendyline/squisq/schemas";
1340
+ import { applySurface, pipStyleVars } from "@bendyline/squisq/schemas";
1033
1341
 
1034
1342
  // src/hooks/useSlideSwipe.ts
1035
- import { useCallback as useCallback3, useEffect as useEffect3, useRef as useRef4, useState as useState3 } from "react";
1343
+ import { useCallback as useCallback3, useEffect as useEffect4, useRef as useRef4, useState as useState4 } from "react";
1036
1344
  var DISTANCE_RATIO = 0.3;
1037
1345
  var FLICK_VELOCITY = 0.5;
1038
1346
  var MIN_FLICK_DISTANCE = 12;
@@ -1056,8 +1364,8 @@ function decideSwipe({
1056
1364
  }
1057
1365
  function useSlideSwipe(opts) {
1058
1366
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
1059
- const [offsetPx, setOffsetPx] = useState3(0);
1060
- const [phase, setPhase] = useState3("idle");
1367
+ const [offsetPx, setOffsetPx] = useState4(0);
1368
+ const [phase, setPhase] = useState4("idle");
1061
1369
  const optsRef = useRef4(opts);
1062
1370
  optsRef.current = opts;
1063
1371
  const dragRef = useRef4(null);
@@ -1095,7 +1403,7 @@ function useSlideSwipe(opts) {
1095
1403
  setPhase("dragging");
1096
1404
  setOffsetPx(0);
1097
1405
  }, []);
1098
- useEffect3(() => {
1406
+ useEffect4(() => {
1099
1407
  function currentWidth() {
1100
1408
  return optsRef.current.containerRef.current?.getBoundingClientRect().width ?? 0;
1101
1409
  }
@@ -1165,7 +1473,7 @@ function useSlideSwipe(opts) {
1165
1473
  window.removeEventListener("pointercancel", onCancel);
1166
1474
  };
1167
1475
  }, [settleMs]);
1168
- useEffect3(() => {
1476
+ useEffect4(() => {
1169
1477
  if (!opts.enabled) {
1170
1478
  dragRef.current = null;
1171
1479
  clearPending();
@@ -1173,7 +1481,7 @@ function useSlideSwipe(opts) {
1173
1481
  setOffsetPx(0);
1174
1482
  }
1175
1483
  }, [opts.enabled, clearPending]);
1176
- useEffect3(() => clearPending, [clearPending]);
1484
+ useEffect4(() => clearPending, [clearPending]);
1177
1485
  return { offsetPx, phase, onPointerDown };
1178
1486
  }
1179
1487
 
@@ -1182,7 +1490,6 @@ import {
1182
1490
  expandCoverBlock,
1183
1491
  createTemplateContext,
1184
1492
  markdownToDoc,
1185
- DEFAULT_THEME,
1186
1493
  VIEWPORT_PRESETS
1187
1494
  } from "@bendyline/squisq/doc";
1188
1495
  import { parseMarkdown } from "@bendyline/squisq/markdown";
@@ -1231,6 +1538,86 @@ function buildSegmentTitleMap(doc) {
1231
1538
  return map;
1232
1539
  }
1233
1540
 
1541
+ // src/docPlayer/renderReadiness.ts
1542
+ var DEFAULT_VIDEO_FRAME_TIMEOUT_MS = 2e3;
1543
+ var VIDEO_TIME_TOLERANCE_SECONDS = 0.01;
1544
+ function formatMediaTime(time) {
1545
+ return Number.isFinite(time) ? `${time.toFixed(3)}s` : String(time);
1546
+ }
1547
+ function isVisiblyPresented(video) {
1548
+ if (!video.isConnected) return false;
1549
+ const view = video.ownerDocument.defaultView;
1550
+ if (!view) return false;
1551
+ for (let element = video; element; element = element.parentElement) {
1552
+ const style = view.getComputedStyle(element);
1553
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity || "1") <= 0) {
1554
+ return false;
1555
+ }
1556
+ }
1557
+ return true;
1558
+ }
1559
+ function seekVideoToFrame(video, targetTime, timeoutMs = DEFAULT_VIDEO_FRAME_TIMEOUT_MS) {
1560
+ video.pause();
1561
+ const alreadyReady = !video.seeking && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && Math.abs(video.currentTime - targetTime) <= VIDEO_TIME_TOLERANCE_SECONDS;
1562
+ if (alreadyReady) return Promise.resolve();
1563
+ return new Promise((resolve, reject) => {
1564
+ let settled = false;
1565
+ let videoFrameRequest = null;
1566
+ const cleanup = () => {
1567
+ clearTimeout(timeout);
1568
+ video.removeEventListener("seeked", handleMediaReady);
1569
+ video.removeEventListener("loadeddata", handleMediaReady);
1570
+ video.removeEventListener("canplay", handleMediaReady);
1571
+ if (videoFrameRequest !== null && typeof video.cancelVideoFrameCallback === "function") {
1572
+ video.cancelVideoFrameCallback(videoFrameRequest);
1573
+ }
1574
+ };
1575
+ const finish = () => {
1576
+ if (settled) return;
1577
+ settled = true;
1578
+ cleanup();
1579
+ resolve();
1580
+ };
1581
+ const fail = () => {
1582
+ if (settled) return;
1583
+ settled = true;
1584
+ cleanup();
1585
+ reject(
1586
+ new Error(
1587
+ `Video frame did not become ready at ${formatMediaTime(targetTime)} within ${timeoutMs}ms (currentTime=${formatMediaTime(video.currentTime)}, readyState=${video.readyState}, seeking=${String(video.seeking)}, visible=${String(isVisiblyPresented(video))}).`
1588
+ )
1589
+ );
1590
+ };
1591
+ const requestPresentedFrame = () => {
1592
+ if (settled || video.seeking || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
1593
+ if (typeof video.requestVideoFrameCallback !== "function" || !isVisiblyPresented(video)) {
1594
+ finish();
1595
+ return;
1596
+ }
1597
+ if (videoFrameRequest !== null) return;
1598
+ videoFrameRequest = video.requestVideoFrameCallback(() => {
1599
+ videoFrameRequest = null;
1600
+ finish();
1601
+ });
1602
+ };
1603
+ function handleMediaReady() {
1604
+ requestPresentedFrame();
1605
+ }
1606
+ const timeout = setTimeout(fail, timeoutMs);
1607
+ video.addEventListener("seeked", handleMediaReady);
1608
+ video.addEventListener("loadeddata", handleMediaReady);
1609
+ video.addEventListener("canplay", handleMediaReady);
1610
+ try {
1611
+ video.currentTime = targetTime;
1612
+ queueMicrotask(requestPresentedFrame);
1613
+ } catch (error) {
1614
+ settled = true;
1615
+ cleanup();
1616
+ reject(error);
1617
+ }
1618
+ });
1619
+ }
1620
+
1234
1621
  // src/DocPlayer.tsx
1235
1622
  import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
1236
1623
  function isDevEnvironment() {
@@ -1242,6 +1629,7 @@ function isDevEnvironment() {
1242
1629
  }
1243
1630
  var warnedMissingStyles = false;
1244
1631
  var VISUAL_UPDATE_FALLBACK_MS = 100;
1632
+ var RENDER_TIME_EPSILON_SECONDS = 1e-6;
1245
1633
  function waitForVisualUpdate() {
1246
1634
  return new Promise((resolve) => {
1247
1635
  let settled = false;
@@ -1263,7 +1651,7 @@ function waitForVisualUpdate() {
1263
1651
  }
1264
1652
  function DocPlayer(props) {
1265
1653
  const { doc, markdown } = props;
1266
- const markdownDoc = useMemo2(
1654
+ const markdownDoc = useMemo3(
1267
1655
  () => !doc && markdown !== void 0 ? markdownToDoc(parseMarkdown(markdown)) : void 0,
1268
1656
  [doc, markdown]
1269
1657
  );
@@ -1279,6 +1667,7 @@ function DocPlayerContent({
1279
1667
  renderMode = false,
1280
1668
  animationsEnabled = true,
1281
1669
  autoPlay = false,
1670
+ loop = false,
1282
1671
  onEnded,
1283
1672
  onTimeUpdate,
1284
1673
  audioController: externalAudioController,
@@ -1296,11 +1685,15 @@ function DocPlayerContent({
1296
1685
  onBlockMarkers,
1297
1686
  forceViewport,
1298
1687
  displayMode = "video",
1299
- showCoverSlide = true,
1688
+ showCoverSlide,
1300
1689
  coverVisible,
1301
1690
  theme,
1302
1691
  surface,
1303
1692
  captionStyle = "standard",
1693
+ videoPresentation,
1694
+ pipSize,
1695
+ pipShape,
1696
+ pipPosition,
1304
1697
  enableSwipe = true,
1305
1698
  globalKeyboardShortcuts = false
1306
1699
  }) {
@@ -1309,11 +1702,12 @@ function DocPlayerContent({
1309
1702
  const audioRef = useRef5(null);
1310
1703
  const containerRef = useRef5(null);
1311
1704
  const playerId = `squisq-player-${useId2().replace(/:/g, "")}`;
1312
- const [tapFeedback, setTapFeedback] = useState4(null);
1705
+ const [tapFeedback, setTapFeedback] = useState5(null);
1313
1706
  const tapFeedbackTimer = useRef5();
1314
- const { viewport, orientation } = useViewportOrientation();
1707
+ const { viewport } = useViewportOrientation();
1315
1708
  const activeViewport = forceViewport || (renderMode ? VIEWPORT_PRESETS.landscape : viewport);
1316
- const isDebugMode = useMemo2(() => {
1709
+ const activeOrientation = activeViewport.height > activeViewport.width ? "portrait" : "landscape";
1710
+ const isDebugMode = useMemo3(() => {
1317
1711
  if (typeof window === "undefined") return false;
1318
1712
  const params = new URLSearchParams(window.location.search);
1319
1713
  return params.get("debug") === "true";
@@ -1326,7 +1720,7 @@ function DocPlayerContent({
1326
1720
  audioMode
1327
1721
  );
1328
1722
  const audio = externalAudioController || internalAudio;
1329
- useEffect4(() => {
1723
+ useEffect5(() => {
1330
1724
  if (warnedMissingStyles || !isDevEnvironment()) return;
1331
1725
  const el = containerRef.current;
1332
1726
  if (!el || typeof getComputedStyle !== "function") return;
@@ -1339,7 +1733,7 @@ function DocPlayerContent({
1339
1733
  }
1340
1734
  }, []);
1341
1735
  const {
1342
- currentTime,
1736
+ currentTime: audioCurrentTime,
1343
1737
  isPlaying,
1344
1738
  currentSegment,
1345
1739
  totalDuration,
@@ -1354,12 +1748,46 @@ function DocPlayerContent({
1354
1748
  skipToSegment: _skipToSegment,
1355
1749
  restart
1356
1750
  } = audio;
1357
- const mediaSchedule = useMemo2(() => resolveMediaSchedule(doc), [doc]);
1751
+ const [renderClock, setRenderClock] = useState5(null);
1752
+ const renderTimeOverride = renderClock?.doc === doc ? renderClock.time : null;
1753
+ const currentTime = renderMode ? renderTimeOverride ?? audioCurrentTime : audioCurrentTime;
1754
+ const rawSchedule = useMemo3(() => resolveMediaSchedule(doc), [doc]);
1755
+ const clipDurations = useMediaClipDurations(rawSchedule, basePath);
1756
+ const mediaSchedule = useMemo3(
1757
+ () => resolveMediaSchedule(doc, { intrinsicDuration: (clip) => clipDurations.get(clip.src) }),
1758
+ [doc, clipDurations]
1759
+ );
1358
1760
  const currentTimeRef = useRef5(currentTime);
1359
1761
  currentTimeRef.current = currentTime;
1762
+ const committedRenderTimeRef = useRef5(currentTime);
1763
+ const renderCommitWaitersRef = useRef5([]);
1360
1764
  const totalDurationRef = useRef5(totalDuration);
1361
1765
  totalDurationRef.current = totalDuration;
1362
1766
  const expandedBlocksLenRef = useRef5(0);
1767
+ useLayoutEffect2(() => {
1768
+ committedRenderTimeRef.current = currentTime;
1769
+ const pending = renderCommitWaitersRef.current;
1770
+ renderCommitWaitersRef.current = pending.filter((waiter) => {
1771
+ if (Math.abs(waiter.time - currentTime) > RENDER_TIME_EPSILON_SECONDS) return true;
1772
+ waiter.resolve();
1773
+ return false;
1774
+ });
1775
+ }, [currentTime]);
1776
+ useEffect5(
1777
+ () => () => {
1778
+ const error = new Error("DocPlayer unmounted before the requested frame committed.");
1779
+ renderCommitWaitersRef.current.splice(0).forEach((waiter) => waiter.reject(error));
1780
+ },
1781
+ []
1782
+ );
1783
+ const waitForRenderCommit = useCallback4((time) => {
1784
+ if (Math.abs(committedRenderTimeRef.current - time) <= RENDER_TIME_EPSILON_SECONDS) {
1785
+ return Promise.resolve();
1786
+ }
1787
+ return new Promise((resolve, reject) => {
1788
+ renderCommitWaitersRef.current.push({ time, resolve, reject });
1789
+ });
1790
+ }, []);
1363
1791
  const handleContainerClick = useCallback4(
1364
1792
  (e) => {
1365
1793
  if (renderMode || isLinearMode) return;
@@ -1385,10 +1813,31 @@ function DocPlayerContent({
1385
1813
  );
1386
1814
  const autoSurface = useAutoSurface(surface === "auto");
1387
1815
  const resolvedSurface = surface === "auto" ? autoSurface : surface;
1388
- const effectiveTheme = useMemo2(() => {
1389
- const base = theme ?? DEFAULT_THEME;
1816
+ const appearance = useMemo3(
1817
+ () => resolveDocPlayerAppearance(doc, {
1818
+ theme,
1819
+ videoPresentation,
1820
+ pipSize,
1821
+ pipShape,
1822
+ pipPosition,
1823
+ showCoverSlide
1824
+ }),
1825
+ [doc, theme, videoPresentation, pipSize, pipShape, pipPosition, showCoverSlide]
1826
+ );
1827
+ const effectiveTheme = useMemo3(() => {
1828
+ const base = appearance.theme;
1390
1829
  return resolvedSurface ? applySurface(base, resolvedSurface) : base;
1391
- }, [theme, resolvedSurface]);
1830
+ }, [appearance.theme, resolvedSurface]);
1831
+ const resolvedPipStyle = useMemo3(() => pipStyleVars(effectiveTheme), [effectiveTheme]);
1832
+ const pipVars = resolvedPipStyle;
1833
+ const pipFrameStyle = useMemo3(
1834
+ () => ({
1835
+ border: resolvedPipStyle["--squisq-pip-border"],
1836
+ borderRadius: resolvedPipStyle["--squisq-pip-radius"],
1837
+ boxShadow: resolvedPipStyle["--squisq-pip-shadow"]
1838
+ }),
1839
+ [resolvedPipStyle]
1840
+ );
1392
1841
  const {
1393
1842
  currentBlock,
1394
1843
  currentBlockIndex,
@@ -1405,11 +1854,15 @@ function DocPlayerContent({
1405
1854
  } = useDocPlayback(doc, currentTime, {
1406
1855
  viewport: activeViewport,
1407
1856
  theme: effectiveTheme,
1408
- onSeek: seekTo
1857
+ onSeek: seekTo,
1858
+ // A synthetic track is only the timer used by an unnarrated preview.
1859
+ // Narration pacing may compact short visual beats, but it must never
1860
+ // remove authored slides from the default loss-averse projection.
1861
+ useAudioSegmentTiming: audioMode !== "synthetic"
1409
1862
  });
1410
- const coverBlock = useMemo2(() => {
1863
+ const coverBlock = useMemo3(() => {
1411
1864
  const startBlockConfig = doc.startBlock;
1412
- if (!showCoverSlide) return null;
1865
+ if (!appearance.showCoverSlide) return null;
1413
1866
  if (!startBlockConfig) return null;
1414
1867
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
1415
1868
  const layers = expandCoverBlock(startBlockConfig, context);
@@ -1422,15 +1875,15 @@ function DocPlayerContent({
1422
1875
  audioSegment: -1,
1423
1876
  layers
1424
1877
  };
1425
- }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
1878
+ }, [doc.startBlock, activeViewport, effectiveTheme, appearance.showCoverSlide]);
1426
1879
  const hasManagedCover = !!coverBlock;
1427
- const [slideshowCoverVisible, setSlideshowCoverVisible] = useState4(false);
1428
- const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState4(false);
1880
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState5(false);
1881
+ const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState5(false);
1429
1882
  const slideshowCoverInitKeyRef = useRef5("");
1430
- useEffect4(() => {
1883
+ useEffect5(() => {
1431
1884
  slideshowCoverInitKeyRef.current = "";
1432
1885
  }, [doc]);
1433
- useEffect4(() => {
1886
+ useEffect5(() => {
1434
1887
  const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
1435
1888
  if (slideshowCoverInitKeyRef.current === initKey) return;
1436
1889
  slideshowCoverInitKeyRef.current = initKey;
@@ -1441,12 +1894,12 @@ function DocPlayerContent({
1441
1894
  setSlideshowCoverVisible(false);
1442
1895
  }
1443
1896
  }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
1444
- const [coverForced, setCoverForced] = useState4(false);
1445
- const [coverGraceActive, setCoverGraceActive] = useState4(false);
1897
+ const [coverForced, setCoverForced] = useState5(false);
1898
+ const [coverGraceActive, setCoverGraceActive] = useState5(false);
1446
1899
  const coverGraceTimer = useRef5();
1447
1900
  const coverWasShowing = useRef5(false);
1448
1901
  const hasPlayedOnce = useRef5(false);
1449
- useEffect4(() => {
1902
+ useEffect5(() => {
1450
1903
  hasPlayedOnce.current = false;
1451
1904
  coverWasShowing.current = false;
1452
1905
  clearTimeout(coverGraceTimer.current);
@@ -1455,7 +1908,7 @@ function DocPlayerContent({
1455
1908
  }, [doc]);
1456
1909
  const atRest = !!(coverBlock && !isSlideshowMode && !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1457
1910
  if (atRest) coverWasShowing.current = true;
1458
- useEffect4(() => {
1911
+ useEffect5(() => {
1459
1912
  if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
1460
1913
  coverWasShowing.current = false;
1461
1914
  hasPlayedOnce.current = true;
@@ -1463,8 +1916,8 @@ function DocPlayerContent({
1463
1916
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3e3);
1464
1917
  }
1465
1918
  }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
1466
- useEffect4(() => () => clearTimeout(coverGraceTimer.current), []);
1467
- useEffect4(() => () => clearTimeout(tapFeedbackTimer.current), []);
1919
+ useEffect5(() => () => clearTimeout(coverGraceTimer.current), []);
1920
+ useEffect5(() => () => clearTimeout(tapFeedbackTimer.current), []);
1468
1921
  const showVideoCoverBlock = !isSlideshowMode && !isLinearMode && !!coverBlock && (coverForced || coverGraceActive || !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1469
1922
  const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
1470
1923
  const showSlideshowCover = !!(isSlideshowMode && !isLinearMode && !renderMode && coverBlock && effectiveSlideshowCoverVisible);
@@ -1473,23 +1926,26 @@ function DocPlayerContent({
1473
1926
  const slideshowSlideIndex = slideshowHasCover ? effectiveSlideshowCoverVisible ? 0 : currentBlockIndex + 1 : currentBlockIndex;
1474
1927
  const slideshowTotalSlides = slideshowHasCover ? expandedBlocks.length + 1 : expandedBlocks.length;
1475
1928
  const hasAutoPlayed = useRef5(false);
1476
- useEffect4(() => {
1929
+ useEffect5(() => {
1477
1930
  hasAutoPlayed.current = false;
1478
1931
  }, [doc]);
1479
- useEffect4(() => {
1932
+ useEffect5(() => {
1480
1933
  if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
1481
1934
  hasAutoPlayed.current = true;
1482
1935
  play();
1483
1936
  }
1484
1937
  }, [isAudioReady, autoPlay, play]);
1485
- useEffect4(() => {
1938
+ useEffect5(() => {
1486
1939
  onTimeUpdate?.(currentTime);
1487
1940
  }, [currentTime, onTimeUpdate]);
1488
- useEffect4(() => {
1941
+ useEffect5(() => {
1489
1942
  if (isEnded) {
1490
1943
  onEnded?.();
1944
+ if (loop && !isSlideshowMode && !isLinearMode) {
1945
+ void restart();
1946
+ }
1491
1947
  }
1492
- }, [isEnded, onEnded]);
1948
+ }, [isEnded, isLinearMode, isSlideshowMode, loop, onEnded, restart]);
1493
1949
  const liveRenderAPIRef = useRef5(null);
1494
1950
  const stableRenderAPIRef = useRef5(null);
1495
1951
  if (!stableRenderAPIRef.current) {
@@ -1500,6 +1956,7 @@ function DocPlayerContent({
1500
1956
  };
1501
1957
  stableRenderAPIRef.current = {
1502
1958
  seekTo: (time) => current().seekTo(time),
1959
+ getRenderedTime: () => current().getRenderedTime(),
1503
1960
  getDuration: () => current().getDuration(),
1504
1961
  getBlocks: () => current().getBlocks(),
1505
1962
  getAudioSegments: () => current().getAudioSegments(),
@@ -1511,7 +1968,7 @@ function DocPlayerContent({
1511
1968
  };
1512
1969
  }
1513
1970
  const stableRenderAPI = stableRenderAPIRef.current;
1514
- useEffect4(() => {
1971
+ useEffect5(() => {
1515
1972
  if (!renderMode && !isDebugMode) {
1516
1973
  liveRenderAPIRef.current = null;
1517
1974
  return;
@@ -1521,77 +1978,58 @@ function DocPlayerContent({
1521
1978
  liveRenderAPIRef.current = null;
1522
1979
  return;
1523
1980
  }
1524
- const renderSeekTo = (time) => {
1525
- seekTo(time);
1526
- return new Promise((resolve) => {
1527
- void waitForVisualUpdate().then(() => {
1528
- let blockStartTime = 0;
1529
- for (let i = expandedBlocks.length - 1; i >= 0; i--) {
1530
- if (time >= expandedBlocks[i].startTime) {
1531
- blockStartTime = expandedBlocks[i].startTime;
1532
- break;
1533
- }
1534
- }
1535
- const elapsedMs = (time - blockStartTime) * 1e3;
1536
- (root.getAnimations?.() ?? []).forEach((anim) => {
1537
- const target = anim.effect?.target;
1538
- if (!target) return;
1539
- if (target.closest(".doc-player__block--active")) {
1540
- anim.currentTime = Math.max(0, elapsedMs);
1541
- } else if (target.closest(".doc-player__block--previous")) {
1542
- anim.currentTime = Math.max(0, elapsedMs);
1543
- }
1544
- });
1545
- const blockElapsed = time - blockStartTime;
1546
- const videoSeekPromises = [];
1547
- const activeBlockEl = root.querySelector(".doc-player__block--active");
1548
- if (activeBlockEl) {
1549
- const videos = activeBlockEl.querySelectorAll("video[data-clip-start]");
1550
- videos.forEach((el) => {
1551
- const video = el;
1552
- const clipStart = parseFloat(video.dataset.clipStart || "0");
1553
- const clipEnd = parseFloat(video.dataset.clipEnd || "0");
1554
- const startAt = parseFloat(video.dataset.startAt || "0");
1555
- const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
1556
- video.pause();
1557
- video.currentTime = targetTime;
1558
- videoSeekPromises.push(
1559
- new Promise((r) => {
1560
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
1561
- r();
1562
- } else {
1563
- video.addEventListener("seeked", () => r(), { once: true });
1564
- setTimeout(r, 200);
1565
- }
1566
- })
1567
- );
1568
- });
1569
- }
1570
- root.querySelectorAll("video[data-clip-id]").forEach((el) => {
1571
- const video = el;
1572
- const absStart = parseFloat(video.dataset.absStart || "0");
1573
- const absEnd = parseFloat(video.dataset.absEnd || "0");
1574
- const sourceIn = parseFloat(video.dataset.sourceIn || "0");
1575
- video.pause();
1576
- if (time < absStart || time >= absEnd) return;
1577
- const targetTime = sourceIn + (time - absStart);
1578
- video.currentTime = targetTime;
1579
- videoSeekPromises.push(
1580
- new Promise((r) => {
1581
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
1582
- r();
1583
- } else {
1584
- video.addEventListener("seeked", () => r(), { once: true });
1585
- setTimeout(r, 200);
1586
- }
1587
- })
1588
- );
1589
- });
1590
- Promise.all(videoSeekPromises).then(() => {
1591
- void waitForVisualUpdate().then(resolve);
1592
- });
1981
+ const renderSeekTo = async (time) => {
1982
+ if (renderMode) {
1983
+ const committed = waitForRenderCommit(time);
1984
+ flushSync(() => setRenderClock({ doc, time }));
1985
+ if (externalAudioController) void seekTo(time).catch(() => void 0);
1986
+ await committed;
1987
+ } else {
1988
+ await seekTo(time);
1989
+ }
1990
+ let blockStartTime = 0;
1991
+ for (let i = expandedBlocks.length - 1; i >= 0; i--) {
1992
+ if (time >= expandedBlocks[i].startTime) {
1993
+ blockStartTime = expandedBlocks[i].startTime;
1994
+ break;
1995
+ }
1996
+ }
1997
+ const elapsedMs = (time - blockStartTime) * 1e3;
1998
+ (root.getAnimations?.() ?? []).forEach((anim) => {
1999
+ const target = anim.effect?.target;
2000
+ if (!target) return;
2001
+ if (target.closest(".doc-player__block--active")) {
2002
+ anim.currentTime = Math.max(0, elapsedMs);
2003
+ } else if (target.closest(".doc-player__block--previous")) {
2004
+ anim.currentTime = Math.max(0, elapsedMs);
2005
+ }
2006
+ });
2007
+ const blockElapsed = time - blockStartTime;
2008
+ const videoSeekPromises = [];
2009
+ const activeBlockEl = root.querySelector(".doc-player__block--active");
2010
+ if (activeBlockEl) {
2011
+ const videos = activeBlockEl.querySelectorAll("video[data-clip-start]");
2012
+ videos.forEach((el) => {
2013
+ const video = el;
2014
+ const clipStart = parseFloat(video.dataset.clipStart || "0");
2015
+ const clipEnd = parseFloat(video.dataset.clipEnd || "0");
2016
+ const startAt = parseFloat(video.dataset.startAt || "0");
2017
+ const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
2018
+ videoSeekPromises.push(seekVideoToFrame(video, targetTime));
1593
2019
  });
2020
+ }
2021
+ root.querySelectorAll("video[data-clip-id]").forEach((el) => {
2022
+ const video = el;
2023
+ const absStart = parseFloat(video.dataset.absStart || "0");
2024
+ const absEnd = parseFloat(video.dataset.absEnd || "0");
2025
+ const sourceIn = parseFloat(video.dataset.sourceIn || "0");
2026
+ video.pause();
2027
+ if (time < absStart || time >= absEnd) return;
2028
+ const targetTime = sourceIn + (time - absStart);
2029
+ videoSeekPromises.push(seekVideoToFrame(video, targetTime));
1594
2030
  });
2031
+ await Promise.all(videoSeekPromises);
2032
+ await waitForVisualUpdate();
1595
2033
  };
1596
2034
  const getDuration = () => {
1597
2035
  const mediaDuration = getDocPlaybackDuration(doc);
@@ -1634,6 +2072,7 @@ function DocPlayerContent({
1634
2072
  const hasCoverBlock = () => !!coverBlock;
1635
2073
  const api = {
1636
2074
  seekTo: renderSeekTo,
2075
+ getRenderedTime: () => committedRenderTimeRef.current,
1637
2076
  getDuration,
1638
2077
  getBlocks,
1639
2078
  getAudioSegments,
@@ -1647,8 +2086,18 @@ function DocPlayerContent({
1647
2086
  return () => {
1648
2087
  if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
1649
2088
  };
1650
- }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock, doc]);
1651
- useEffect4(() => {
2089
+ }, [
2090
+ renderMode,
2091
+ isDebugMode,
2092
+ seekTo,
2093
+ totalDuration,
2094
+ expandedBlocks,
2095
+ coverBlock,
2096
+ doc,
2097
+ externalAudioController,
2098
+ waitForRenderCommit
2099
+ ]);
2100
+ useEffect5(() => {
1652
2101
  if (!renderMode && !isDebugMode || !containerRef.current) {
1653
2102
  onRenderAPIReady?.(null);
1654
2103
  return;
@@ -1657,8 +2106,8 @@ function DocPlayerContent({
1657
2106
  return () => onRenderAPIReady?.(null);
1658
2107
  }, [renderMode, isDebugMode, onRenderAPIReady, stableRenderAPI]);
1659
2108
  const defaultMode = captionsEnabledProp === false ? "off" : captionStyle || "standard";
1660
- const [captionMode, setCaptionMode] = useState4(defaultMode);
1661
- useEffect4(() => {
2109
+ const [captionMode, setCaptionMode] = useState5(defaultMode);
2110
+ useEffect5(() => {
1662
2111
  setCaptionMode(defaultMode);
1663
2112
  }, [defaultMode]);
1664
2113
  const captionsEnabled = captionMode !== "off";
@@ -1678,8 +2127,8 @@ function DocPlayerContent({
1678
2127
  });
1679
2128
  }, [onCaptionsToggle]);
1680
2129
  const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
1681
- const segmentTitleMap = useMemo2(() => buildSegmentTitleMap(doc), [doc]);
1682
- const playbackState = useMemo2(
2130
+ const segmentTitleMap = useMemo3(() => buildSegmentTitleMap(doc), [doc]);
2131
+ const playbackState = useMemo3(
1683
2132
  () => ({
1684
2133
  isPlaying,
1685
2134
  currentTime,
@@ -1722,7 +2171,7 @@ function DocPlayerContent({
1722
2171
  expandedBlocks.length
1723
2172
  ]
1724
2173
  );
1725
- const playbackActions = useMemo2(
2174
+ const playbackActions = useMemo3(
1726
2175
  () => ({
1727
2176
  toggle,
1728
2177
  restart,
@@ -1733,7 +2182,7 @@ function DocPlayerContent({
1733
2182
  }),
1734
2183
  [toggle, restart, seekTo, setCaptionsEnabled, cycleCaptionMode, onFullscreenToggle]
1735
2184
  );
1736
- const slideNavActions = useMemo2(
2185
+ const slideNavActions = useMemo3(
1737
2186
  () => ({
1738
2187
  nextSlide: () => {
1739
2188
  if (slideshowHasCover && slideshowCoverVisible) {
@@ -1822,10 +2271,10 @@ function DocPlayerContent({
1822
2271
  onNext: handleSwipeNext,
1823
2272
  onPrev: handleSwipePrev
1824
2273
  });
1825
- useEffect4(() => {
2274
+ useEffect5(() => {
1826
2275
  onPlaybackStateChange?.(playbackState);
1827
2276
  }, [playbackState, onPlaybackStateChange]);
1828
- useEffect4(() => {
2277
+ useEffect5(() => {
1829
2278
  onControlsReady?.({ play, pause, ...playbackActions });
1830
2279
  }, [play, pause, playbackActions, onControlsReady]);
1831
2280
  const getBlockTitle = useCallback4((block) => {
@@ -1852,7 +2301,7 @@ function DocPlayerContent({
1852
2301
  }
1853
2302
  return block.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1854
2303
  }, []);
1855
- const slideshowPickerItems = useMemo2(() => {
2304
+ const slideshowPickerItems = useMemo3(() => {
1856
2305
  const blockItems = expandedBlocks.map((block, index) => ({
1857
2306
  id: block.id,
1858
2307
  label: String(index + 1),
@@ -1868,7 +2317,7 @@ function DocPlayerContent({
1868
2317
  ...blockItems
1869
2318
  ];
1870
2319
  }, [coverBlock, expandedBlocks, getBlockTitle, slideshowHasCover]);
1871
- const blockMarkers = useMemo2(() => {
2320
+ const blockMarkers = useMemo3(() => {
1872
2321
  if (!totalDuration || !expandedBlocks.length) return [];
1873
2322
  let prevSegment = -1;
1874
2323
  return expandedBlocks.map((block, index) => {
@@ -1883,7 +2332,7 @@ function DocPlayerContent({
1883
2332
  };
1884
2333
  });
1885
2334
  }, [expandedBlocks, totalDuration, getBlockTitle]);
1886
- useEffect4(() => {
2335
+ useEffect5(() => {
1887
2336
  if (blockMarkers.length > 0) {
1888
2337
  onBlockMarkers?.(blockMarkers);
1889
2338
  }
@@ -1960,7 +2409,7 @@ function DocPlayerContent({
1960
2409
  (e) => handleKeyboardShortcut(e, false),
1961
2410
  [handleKeyboardShortcut]
1962
2411
  );
1963
- useEffect4(() => {
2412
+ useEffect5(() => {
1964
2413
  if (!globalKeyboardShortcuts || renderMode || isLinearMode) return;
1965
2414
  const handleDocumentKeyDown = (event) => {
1966
2415
  handleKeyboardShortcut(event, true);
@@ -2000,6 +2449,9 @@ function DocPlayerContent({
2000
2449
  {
2001
2450
  ref: containerRef,
2002
2451
  "data-player-id": playerId,
2452
+ "data-orientation": activeOrientation,
2453
+ "data-playback-state": isPlaying ? "playing" : "paused",
2454
+ "data-swipe-phase": swipe.phase,
2003
2455
  tabIndex: renderMode ? -1 : 0,
2004
2456
  "aria-label": "Document player",
2005
2457
  onKeyDown: renderMode ? void 0 : handleKeyDown,
@@ -2007,6 +2459,7 @@ function DocPlayerContent({
2007
2459
  onClick: handleContainerClick,
2008
2460
  onPointerDown: swipe.onPointerDown,
2009
2461
  style: {
2462
+ ...pipVars,
2010
2463
  position: "relative",
2011
2464
  width: "100%",
2012
2465
  aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
@@ -2027,7 +2480,13 @@ function DocPlayerContent({
2027
2480
  isPlaying,
2028
2481
  basePath,
2029
2482
  renderMode,
2030
- muted
2483
+ muted,
2484
+ presentation: appearance.videoPresentation,
2485
+ pipSize: appearance.pipSize,
2486
+ pipShape: appearance.pipShape,
2487
+ pipPosition: appearance.pipPosition,
2488
+ pipOrientation: activeOrientation,
2489
+ pipFrameStyle
2031
2490
  }
2032
2491
  ),
2033
2492
  /* @__PURE__ */ jsxs4("div", { className: "doc-player__viewport", children: [
@@ -2179,7 +2638,7 @@ function DocPlayerContent({
2179
2638
  " ",
2180
2639
  /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2181
2640
  "(",
2182
- orientation,
2641
+ activeOrientation,
2183
2642
  ")"
2184
2643
  ] })
2185
2644
  ] }),
@@ -2192,7 +2651,7 @@ function DocPlayerContent({
2192
2651
  hasCaptions && (() => {
2193
2652
  const debugPhrase = getCaptionAtTime2(doc.captions, currentTime);
2194
2653
  const debugEnabled = captionsEnabled && (isPlaying || currentTime > 0);
2195
- return /* @__PURE__ */ jsxs4(Fragment, { children: [
2654
+ return /* @__PURE__ */ jsxs4(Fragment2, { children: [
2196
2655
  /* @__PURE__ */ jsxs4("div", { children: [
2197
2656
  /* @__PURE__ */ jsx7("span", { style: { color: "#888" }, children: "captions:" }),
2198
2657
  " ",
@@ -2445,7 +2904,7 @@ function DocControlsSidebar({ state, actions }) {
2445
2904
  }
2446
2905
 
2447
2906
  // src/DocPlayerWithSidebar.tsx
2448
- import { useRef as useRef6, useState as useState5, useCallback as useCallback5, useEffect as useEffect5 } from "react";
2907
+ import { useRef as useRef6, useState as useState6, useCallback as useCallback5, useEffect as useEffect6 } from "react";
2449
2908
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
2450
2909
  var DEFAULT_STATE = {
2451
2910
  isPlaying: false,
@@ -2465,6 +2924,7 @@ function DocPlayerWithSidebar({
2465
2924
  doc,
2466
2925
  basePath,
2467
2926
  autoPlay = false,
2927
+ loop = false,
2468
2928
  onEnded,
2469
2929
  onTimeUpdate,
2470
2930
  audioController,
@@ -2480,7 +2940,7 @@ function DocPlayerWithSidebar({
2480
2940
  const stateRef = useRef6(DEFAULT_STATE);
2481
2941
  const actionsRef = useRef6(null);
2482
2942
  const wasPlayingRef = useRef6(false);
2483
- const [, setTick] = useState5(0);
2943
+ const [, setTick] = useState6(0);
2484
2944
  const handleStateChange = useCallback5(
2485
2945
  (state) => {
2486
2946
  stateRef.current = state;
@@ -2500,7 +2960,7 @@ function DocPlayerWithSidebar({
2500
2960
  },
2501
2961
  []
2502
2962
  );
2503
- useEffect5(() => {
2963
+ useEffect6(() => {
2504
2964
  const interval = setInterval(() => {
2505
2965
  if (!stateRef.current.isPlaying) return;
2506
2966
  setTick((t) => t + 1);
@@ -2515,6 +2975,7 @@ function DocPlayerWithSidebar({
2515
2975
  theme,
2516
2976
  basePath,
2517
2977
  autoPlay,
2978
+ loop,
2518
2979
  onEnded,
2519
2980
  onTimeUpdate,
2520
2981
  audioController,
@@ -2536,12 +2997,14 @@ function DocPlayerWithSidebar({
2536
2997
 
2537
2998
  export {
2538
2999
  MediaClipLayer,
3000
+ useMediaClipDurations,
2539
3001
  SocialCaptionOverlay,
2540
3002
  CaptionOverlay,
2541
3003
  formatTime,
2542
3004
  DocProgressBar,
2543
3005
  DocControlsOverlay,
2544
3006
  DocControlsSlideshow,
3007
+ resolveDocPlayerAppearance,
2545
3008
  DocPlayer,
2546
3009
  DocControlsBottom,
2547
3010
  DocControlsSidebar,