@bendyline/squisq-react 2.4.0 → 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.
@@ -12,13 +12,67 @@ 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
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,
@@ -27,8 +81,11 @@ function MediaClipLayer({
27
81
  renderMode = false,
28
82
  muted = false,
29
83
  presentation = "background",
30
- pipShape = "rounded",
84
+ pipSize = "small",
85
+ pipShape = "square",
31
86
  pipPosition = "bottom-right",
87
+ pipOrientation = "landscape",
88
+ pipFrameStyle,
32
89
  honorClipPresentation = true
33
90
  }) {
34
91
  const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
@@ -36,19 +93,31 @@ function MediaClipLayer({
36
93
  const groups = /* @__PURE__ */ new Map();
37
94
  for (const clip of renderClips) {
38
95
  const clipPresentation = honorClipPresentation && clip.kind === "video" ? clip.placement === "picture-in-picture" ? "picture-in-picture" : clip.placement === "overlay" ? "full-frame" : presentation : presentation;
39
- const group = groups.get(clipPresentation) ?? [];
40
- group.push(clip);
41
- groups.set(clipPresentation, group);
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);
42
109
  }
43
- return /* @__PURE__ */ jsx(Fragment, { children: [...groups].map(([groupPresentation, clips]) => /* @__PURE__ */ jsx(
110
+ return /* @__PURE__ */ jsx(Fragment, { children: [...groups].map(([key, group]) => /* @__PURE__ */ jsx(
44
111
  "div",
45
112
  {
46
- className: `doc-player__media-clips doc-player__media-clips--${groupPresentation}`,
47
- "data-presentation": groupPresentation,
48
- "data-pip-shape": pipShape,
49
- "data-pip-position": pipPosition,
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,
50
118
  "aria-hidden": true,
51
- children: clips.map((clip) => /* @__PURE__ */ jsx(
119
+ style: mediaGroupStyle(group.presentation),
120
+ children: group.clips.map((clip) => /* @__PURE__ */ jsx(
52
121
  MediaClipElement,
53
122
  {
54
123
  clip,
@@ -57,12 +126,18 @@ function MediaClipLayer({
57
126
  isPlaying,
58
127
  basePath,
59
128
  renderMode,
60
- muted
129
+ muted,
130
+ presentation: group.presentation,
131
+ pipSize: group.pipSize,
132
+ pipShape: group.pipShape,
133
+ pipPosition: group.pipPosition,
134
+ pipOrientation,
135
+ pipFrameStyle
61
136
  },
62
137
  clip.id
63
138
  ))
64
139
  },
65
- groupPresentation
140
+ key
66
141
  )) });
67
142
  }
68
143
  function MediaClipElement({
@@ -72,7 +147,13 @@ function MediaClipElement({
72
147
  isPlaying,
73
148
  basePath,
74
149
  renderMode,
75
- muted
150
+ muted,
151
+ presentation,
152
+ pipSize,
153
+ pipShape,
154
+ pipPosition,
155
+ pipOrientation,
156
+ pipFrameStyle
76
157
  }) {
77
158
  const ref = useRef(null);
78
159
  const src = useMediaUrl(clip.src, basePath);
@@ -118,7 +199,15 @@ function MediaClipElement({
118
199
  "data-video-placement": clip.placement ?? "default",
119
200
  muted: renderMode || muted,
120
201
  playsInline: true,
121
- style: { pointerEvents: "none" }
202
+ style: mediaVideoStyle({
203
+ presentation,
204
+ pipSize,
205
+ pipShape,
206
+ pipPosition,
207
+ pipOrientation,
208
+ pipFrameStyle,
209
+ active
210
+ })
122
211
  }
123
212
  );
124
213
  }
@@ -132,8 +221,123 @@ function MediaClipElement({
132
221
  );
133
222
  }
134
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
+
135
339
  // src/SocialCaptionOverlay.tsx
136
- import { useMemo } from "react";
340
+ import { useMemo as useMemo2 } from "react";
137
341
  import { resolveFontFamily } from "@bendyline/squisq/schemas";
138
342
  import { jsx as jsx2 } from "react/jsx-runtime";
139
343
  var TARGET_CHUNK_SIZE = 4;
@@ -189,7 +393,7 @@ function SocialCaptionOverlay({
189
393
  theme,
190
394
  viewport
191
395
  }) {
192
- const { chunks } = useMemo(
396
+ const { chunks } = useMemo2(
193
397
  () => captions ? buildWordStream(captions) : { words: [], chunks: [] },
194
398
  [captions]
195
399
  );
@@ -391,7 +595,7 @@ function formatTime(seconds) {
391
595
  }
392
596
 
393
597
  // src/DocProgressBar.tsx
394
- import { useRef as useRef2, useState, useCallback } from "react";
598
+ import { useRef as useRef2, useState as useState2, useCallback } from "react";
395
599
  import { jsx as jsx4, jsxs } from "react/jsx-runtime";
396
600
  function DocProgressBar({
397
601
  state,
@@ -401,7 +605,7 @@ function DocProgressBar({
401
605
  getBlockTitle
402
606
  }) {
403
607
  const progressBarRef = useRef2(null);
404
- const [hoverPosition, setHoverPosition] = useState(null);
608
+ const [hoverPosition, setHoverPosition] = useState2(null);
405
609
  const playProgress = state.totalDuration > 0 ? Math.max(0, Math.min(1, state.currentTime / state.totalDuration)) : 0;
406
610
  const handleProgressHover = useCallback((e) => {
407
611
  const bar = progressBarRef.current;
@@ -728,7 +932,7 @@ function DocControlsOverlay({
728
932
  }
729
933
 
730
934
  // src/DocControlsSlideshow.tsx
731
- 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";
732
936
  import { jsx as jsx6, jsxs as jsxs3 } from "react/jsx-runtime";
733
937
  function DocControlsSlideshow({
734
938
  state,
@@ -737,7 +941,7 @@ function DocControlsSlideshow({
737
941
  pickerOpen,
738
942
  onPickerOpenChange
739
943
  }) {
740
- const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState2(false);
944
+ const [uncontrolledPickerOpen, setUncontrolledPickerOpen] = useState3(false);
741
945
  const isPickerOpen = pickerOpen ?? uncontrolledPickerOpen;
742
946
  const setPickerOpen = useCallback2(
743
947
  (open) => {
@@ -746,7 +950,7 @@ function DocControlsSlideshow({
746
950
  },
747
951
  [pickerOpen, onPickerOpenChange]
748
952
  );
749
- const [pickerMaxHeight, setPickerMaxHeight] = useState2(280);
953
+ const [pickerMaxHeight, setPickerMaxHeight] = useState3(280);
750
954
  const controlsRef = useRef3(null);
751
955
  const triggerRef = useRef3(null);
752
956
  const menuRef = useRef3(null);
@@ -761,7 +965,7 @@ function DocControlsSlideshow({
761
965
  const isFirst = currentBlockIndex <= 0;
762
966
  const isLast = currentBlockIndex >= totalBlocks - 1;
763
967
  const counterText = totalBlocks > 0 ? currentSlideLabel ?? `${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}` : "\u2014";
764
- useEffect2(() => {
968
+ useEffect3(() => {
765
969
  if (!isPickerOpen) return;
766
970
  const handlePointerDown = (event) => {
767
971
  if (!controlsRef.current?.contains(event.target)) setPickerOpen(false);
@@ -800,7 +1004,7 @@ function DocControlsSlideshow({
800
1004
  window.removeEventListener("resize", updateMaxHeight);
801
1005
  };
802
1006
  }, [isPickerOpen]);
803
- useEffect2(() => {
1007
+ useEffect3(() => {
804
1008
  if (!isPickerOpen) return;
805
1009
  menuRef.current?.querySelector('[aria-current="true"]')?.focus();
806
1010
  }, [isPickerOpen]);
@@ -1038,18 +1242,105 @@ function DocControlsSlideshow({
1038
1242
  );
1039
1243
  }
1040
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
+
1041
1322
  // src/DocPlayer.tsx
1042
- import { Fragment as Fragment2, 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";
1043
1334
  import {
1044
1335
  isTemplateBlock as isTemplateBlock2,
1045
1336
  getCaptionAtTime as getCaptionAtTime2,
1046
1337
  resolveMediaSchedule,
1047
1338
  getDocPlaybackDuration
1048
1339
  } from "@bendyline/squisq/schemas";
1049
- import { applySurface } from "@bendyline/squisq/schemas";
1340
+ import { applySurface, pipStyleVars } from "@bendyline/squisq/schemas";
1050
1341
 
1051
1342
  // src/hooks/useSlideSwipe.ts
1052
- 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";
1053
1344
  var DISTANCE_RATIO = 0.3;
1054
1345
  var FLICK_VELOCITY = 0.5;
1055
1346
  var MIN_FLICK_DISTANCE = 12;
@@ -1073,8 +1364,8 @@ function decideSwipe({
1073
1364
  }
1074
1365
  function useSlideSwipe(opts) {
1075
1366
  const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
1076
- const [offsetPx, setOffsetPx] = useState3(0);
1077
- const [phase, setPhase] = useState3("idle");
1367
+ const [offsetPx, setOffsetPx] = useState4(0);
1368
+ const [phase, setPhase] = useState4("idle");
1078
1369
  const optsRef = useRef4(opts);
1079
1370
  optsRef.current = opts;
1080
1371
  const dragRef = useRef4(null);
@@ -1112,7 +1403,7 @@ function useSlideSwipe(opts) {
1112
1403
  setPhase("dragging");
1113
1404
  setOffsetPx(0);
1114
1405
  }, []);
1115
- useEffect3(() => {
1406
+ useEffect4(() => {
1116
1407
  function currentWidth() {
1117
1408
  return optsRef.current.containerRef.current?.getBoundingClientRect().width ?? 0;
1118
1409
  }
@@ -1182,7 +1473,7 @@ function useSlideSwipe(opts) {
1182
1473
  window.removeEventListener("pointercancel", onCancel);
1183
1474
  };
1184
1475
  }, [settleMs]);
1185
- useEffect3(() => {
1476
+ useEffect4(() => {
1186
1477
  if (!opts.enabled) {
1187
1478
  dragRef.current = null;
1188
1479
  clearPending();
@@ -1190,7 +1481,7 @@ function useSlideSwipe(opts) {
1190
1481
  setOffsetPx(0);
1191
1482
  }
1192
1483
  }, [opts.enabled, clearPending]);
1193
- useEffect3(() => clearPending, [clearPending]);
1484
+ useEffect4(() => clearPending, [clearPending]);
1194
1485
  return { offsetPx, phase, onPointerDown };
1195
1486
  }
1196
1487
 
@@ -1199,7 +1490,6 @@ import {
1199
1490
  expandCoverBlock,
1200
1491
  createTemplateContext,
1201
1492
  markdownToDoc,
1202
- DEFAULT_THEME,
1203
1493
  VIEWPORT_PRESETS
1204
1494
  } from "@bendyline/squisq/doc";
1205
1495
  import { parseMarkdown } from "@bendyline/squisq/markdown";
@@ -1248,6 +1538,86 @@ function buildSegmentTitleMap(doc) {
1248
1538
  return map;
1249
1539
  }
1250
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
+
1251
1621
  // src/DocPlayer.tsx
1252
1622
  import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
1253
1623
  function isDevEnvironment() {
@@ -1259,6 +1629,7 @@ function isDevEnvironment() {
1259
1629
  }
1260
1630
  var warnedMissingStyles = false;
1261
1631
  var VISUAL_UPDATE_FALLBACK_MS = 100;
1632
+ var RENDER_TIME_EPSILON_SECONDS = 1e-6;
1262
1633
  function waitForVisualUpdate() {
1263
1634
  return new Promise((resolve) => {
1264
1635
  let settled = false;
@@ -1280,7 +1651,7 @@ function waitForVisualUpdate() {
1280
1651
  }
1281
1652
  function DocPlayer(props) {
1282
1653
  const { doc, markdown } = props;
1283
- const markdownDoc = useMemo2(
1654
+ const markdownDoc = useMemo3(
1284
1655
  () => !doc && markdown !== void 0 ? markdownToDoc(parseMarkdown(markdown)) : void 0,
1285
1656
  [doc, markdown]
1286
1657
  );
@@ -1314,14 +1685,15 @@ function DocPlayerContent({
1314
1685
  onBlockMarkers,
1315
1686
  forceViewport,
1316
1687
  displayMode = "video",
1317
- showCoverSlide = true,
1688
+ showCoverSlide,
1318
1689
  coverVisible,
1319
1690
  theme,
1320
1691
  surface,
1321
1692
  captionStyle = "standard",
1322
- videoPresentation = "background",
1323
- pipShape = "rounded",
1324
- pipPosition = "bottom-right",
1693
+ videoPresentation,
1694
+ pipSize,
1695
+ pipShape,
1696
+ pipPosition,
1325
1697
  enableSwipe = true,
1326
1698
  globalKeyboardShortcuts = false
1327
1699
  }) {
@@ -1330,11 +1702,12 @@ function DocPlayerContent({
1330
1702
  const audioRef = useRef5(null);
1331
1703
  const containerRef = useRef5(null);
1332
1704
  const playerId = `squisq-player-${useId2().replace(/:/g, "")}`;
1333
- const [tapFeedback, setTapFeedback] = useState4(null);
1705
+ const [tapFeedback, setTapFeedback] = useState5(null);
1334
1706
  const tapFeedbackTimer = useRef5();
1335
- const { viewport, orientation } = useViewportOrientation();
1707
+ const { viewport } = useViewportOrientation();
1336
1708
  const activeViewport = forceViewport || (renderMode ? VIEWPORT_PRESETS.landscape : viewport);
1337
- const isDebugMode = useMemo2(() => {
1709
+ const activeOrientation = activeViewport.height > activeViewport.width ? "portrait" : "landscape";
1710
+ const isDebugMode = useMemo3(() => {
1338
1711
  if (typeof window === "undefined") return false;
1339
1712
  const params = new URLSearchParams(window.location.search);
1340
1713
  return params.get("debug") === "true";
@@ -1347,7 +1720,7 @@ function DocPlayerContent({
1347
1720
  audioMode
1348
1721
  );
1349
1722
  const audio = externalAudioController || internalAudio;
1350
- useEffect4(() => {
1723
+ useEffect5(() => {
1351
1724
  if (warnedMissingStyles || !isDevEnvironment()) return;
1352
1725
  const el = containerRef.current;
1353
1726
  if (!el || typeof getComputedStyle !== "function") return;
@@ -1360,7 +1733,7 @@ function DocPlayerContent({
1360
1733
  }
1361
1734
  }, []);
1362
1735
  const {
1363
- currentTime,
1736
+ currentTime: audioCurrentTime,
1364
1737
  isPlaying,
1365
1738
  currentSegment,
1366
1739
  totalDuration,
@@ -1375,12 +1748,46 @@ function DocPlayerContent({
1375
1748
  skipToSegment: _skipToSegment,
1376
1749
  restart
1377
1750
  } = audio;
1378
- 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
+ );
1379
1760
  const currentTimeRef = useRef5(currentTime);
1380
1761
  currentTimeRef.current = currentTime;
1762
+ const committedRenderTimeRef = useRef5(currentTime);
1763
+ const renderCommitWaitersRef = useRef5([]);
1381
1764
  const totalDurationRef = useRef5(totalDuration);
1382
1765
  totalDurationRef.current = totalDuration;
1383
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
+ }, []);
1384
1791
  const handleContainerClick = useCallback4(
1385
1792
  (e) => {
1386
1793
  if (renderMode || isLinearMode) return;
@@ -1406,10 +1813,31 @@ function DocPlayerContent({
1406
1813
  );
1407
1814
  const autoSurface = useAutoSurface(surface === "auto");
1408
1815
  const resolvedSurface = surface === "auto" ? autoSurface : surface;
1409
- const effectiveTheme = useMemo2(() => {
1410
- 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;
1411
1829
  return resolvedSurface ? applySurface(base, resolvedSurface) : base;
1412
- }, [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
+ );
1413
1841
  const {
1414
1842
  currentBlock,
1415
1843
  currentBlockIndex,
@@ -1432,9 +1860,9 @@ function DocPlayerContent({
1432
1860
  // remove authored slides from the default loss-averse projection.
1433
1861
  useAudioSegmentTiming: audioMode !== "synthetic"
1434
1862
  });
1435
- const coverBlock = useMemo2(() => {
1863
+ const coverBlock = useMemo3(() => {
1436
1864
  const startBlockConfig = doc.startBlock;
1437
- if (!showCoverSlide) return null;
1865
+ if (!appearance.showCoverSlide) return null;
1438
1866
  if (!startBlockConfig) return null;
1439
1867
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
1440
1868
  const layers = expandCoverBlock(startBlockConfig, context);
@@ -1447,15 +1875,15 @@ function DocPlayerContent({
1447
1875
  audioSegment: -1,
1448
1876
  layers
1449
1877
  };
1450
- }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
1878
+ }, [doc.startBlock, activeViewport, effectiveTheme, appearance.showCoverSlide]);
1451
1879
  const hasManagedCover = !!coverBlock;
1452
- const [slideshowCoverVisible, setSlideshowCoverVisible] = useState4(false);
1453
- const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState4(false);
1880
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState5(false);
1881
+ const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState5(false);
1454
1882
  const slideshowCoverInitKeyRef = useRef5("");
1455
- useEffect4(() => {
1883
+ useEffect5(() => {
1456
1884
  slideshowCoverInitKeyRef.current = "";
1457
1885
  }, [doc]);
1458
- useEffect4(() => {
1886
+ useEffect5(() => {
1459
1887
  const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
1460
1888
  if (slideshowCoverInitKeyRef.current === initKey) return;
1461
1889
  slideshowCoverInitKeyRef.current = initKey;
@@ -1466,12 +1894,12 @@ function DocPlayerContent({
1466
1894
  setSlideshowCoverVisible(false);
1467
1895
  }
1468
1896
  }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
1469
- const [coverForced, setCoverForced] = useState4(false);
1470
- const [coverGraceActive, setCoverGraceActive] = useState4(false);
1897
+ const [coverForced, setCoverForced] = useState5(false);
1898
+ const [coverGraceActive, setCoverGraceActive] = useState5(false);
1471
1899
  const coverGraceTimer = useRef5();
1472
1900
  const coverWasShowing = useRef5(false);
1473
1901
  const hasPlayedOnce = useRef5(false);
1474
- useEffect4(() => {
1902
+ useEffect5(() => {
1475
1903
  hasPlayedOnce.current = false;
1476
1904
  coverWasShowing.current = false;
1477
1905
  clearTimeout(coverGraceTimer.current);
@@ -1480,7 +1908,7 @@ function DocPlayerContent({
1480
1908
  }, [doc]);
1481
1909
  const atRest = !!(coverBlock && !isSlideshowMode && !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1482
1910
  if (atRest) coverWasShowing.current = true;
1483
- useEffect4(() => {
1911
+ useEffect5(() => {
1484
1912
  if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
1485
1913
  coverWasShowing.current = false;
1486
1914
  hasPlayedOnce.current = true;
@@ -1488,8 +1916,8 @@ function DocPlayerContent({
1488
1916
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3e3);
1489
1917
  }
1490
1918
  }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
1491
- useEffect4(() => () => clearTimeout(coverGraceTimer.current), []);
1492
- useEffect4(() => () => clearTimeout(tapFeedbackTimer.current), []);
1919
+ useEffect5(() => () => clearTimeout(coverGraceTimer.current), []);
1920
+ useEffect5(() => () => clearTimeout(tapFeedbackTimer.current), []);
1493
1921
  const showVideoCoverBlock = !isSlideshowMode && !isLinearMode && !!coverBlock && (coverForced || coverGraceActive || !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
1494
1922
  const effectiveSlideshowCoverVisible = coverVisible ?? slideshowCoverVisible;
1495
1923
  const showSlideshowCover = !!(isSlideshowMode && !isLinearMode && !renderMode && coverBlock && effectiveSlideshowCoverVisible);
@@ -1498,19 +1926,19 @@ function DocPlayerContent({
1498
1926
  const slideshowSlideIndex = slideshowHasCover ? effectiveSlideshowCoverVisible ? 0 : currentBlockIndex + 1 : currentBlockIndex;
1499
1927
  const slideshowTotalSlides = slideshowHasCover ? expandedBlocks.length + 1 : expandedBlocks.length;
1500
1928
  const hasAutoPlayed = useRef5(false);
1501
- useEffect4(() => {
1929
+ useEffect5(() => {
1502
1930
  hasAutoPlayed.current = false;
1503
1931
  }, [doc]);
1504
- useEffect4(() => {
1932
+ useEffect5(() => {
1505
1933
  if (isAudioReady && autoPlay && !hasAutoPlayed.current) {
1506
1934
  hasAutoPlayed.current = true;
1507
1935
  play();
1508
1936
  }
1509
1937
  }, [isAudioReady, autoPlay, play]);
1510
- useEffect4(() => {
1938
+ useEffect5(() => {
1511
1939
  onTimeUpdate?.(currentTime);
1512
1940
  }, [currentTime, onTimeUpdate]);
1513
- useEffect4(() => {
1941
+ useEffect5(() => {
1514
1942
  if (isEnded) {
1515
1943
  onEnded?.();
1516
1944
  if (loop && !isSlideshowMode && !isLinearMode) {
@@ -1528,6 +1956,7 @@ function DocPlayerContent({
1528
1956
  };
1529
1957
  stableRenderAPIRef.current = {
1530
1958
  seekTo: (time) => current().seekTo(time),
1959
+ getRenderedTime: () => current().getRenderedTime(),
1531
1960
  getDuration: () => current().getDuration(),
1532
1961
  getBlocks: () => current().getBlocks(),
1533
1962
  getAudioSegments: () => current().getAudioSegments(),
@@ -1539,7 +1968,7 @@ function DocPlayerContent({
1539
1968
  };
1540
1969
  }
1541
1970
  const stableRenderAPI = stableRenderAPIRef.current;
1542
- useEffect4(() => {
1971
+ useEffect5(() => {
1543
1972
  if (!renderMode && !isDebugMode) {
1544
1973
  liveRenderAPIRef.current = null;
1545
1974
  return;
@@ -1549,77 +1978,58 @@ function DocPlayerContent({
1549
1978
  liveRenderAPIRef.current = null;
1550
1979
  return;
1551
1980
  }
1552
- const renderSeekTo = (time) => {
1553
- seekTo(time);
1554
- return new Promise((resolve) => {
1555
- void waitForVisualUpdate().then(() => {
1556
- let blockStartTime = 0;
1557
- for (let i = expandedBlocks.length - 1; i >= 0; i--) {
1558
- if (time >= expandedBlocks[i].startTime) {
1559
- blockStartTime = expandedBlocks[i].startTime;
1560
- break;
1561
- }
1562
- }
1563
- const elapsedMs = (time - blockStartTime) * 1e3;
1564
- (root.getAnimations?.() ?? []).forEach((anim) => {
1565
- const target = anim.effect?.target;
1566
- if (!target) return;
1567
- if (target.closest(".doc-player__block--active")) {
1568
- anim.currentTime = Math.max(0, elapsedMs);
1569
- } else if (target.closest(".doc-player__block--previous")) {
1570
- anim.currentTime = Math.max(0, elapsedMs);
1571
- }
1572
- });
1573
- const blockElapsed = time - blockStartTime;
1574
- const videoSeekPromises = [];
1575
- const activeBlockEl = root.querySelector(".doc-player__block--active");
1576
- if (activeBlockEl) {
1577
- const videos = activeBlockEl.querySelectorAll("video[data-clip-start]");
1578
- videos.forEach((el) => {
1579
- const video = el;
1580
- const clipStart = parseFloat(video.dataset.clipStart || "0");
1581
- const clipEnd = parseFloat(video.dataset.clipEnd || "0");
1582
- const startAt = parseFloat(video.dataset.startAt || "0");
1583
- const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
1584
- video.pause();
1585
- video.currentTime = targetTime;
1586
- videoSeekPromises.push(
1587
- new Promise((r) => {
1588
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
1589
- r();
1590
- } else {
1591
- video.addEventListener("seeked", () => r(), { once: true });
1592
- setTimeout(r, 200);
1593
- }
1594
- })
1595
- );
1596
- });
1597
- }
1598
- root.querySelectorAll("video[data-clip-id]").forEach((el) => {
1599
- const video = el;
1600
- const absStart = parseFloat(video.dataset.absStart || "0");
1601
- const absEnd = parseFloat(video.dataset.absEnd || "0");
1602
- const sourceIn = parseFloat(video.dataset.sourceIn || "0");
1603
- video.pause();
1604
- if (time < absStart || time >= absEnd) return;
1605
- const targetTime = sourceIn + (time - absStart);
1606
- video.currentTime = targetTime;
1607
- videoSeekPromises.push(
1608
- new Promise((r) => {
1609
- if (Math.abs(video.currentTime - targetTime) < 0.1) {
1610
- r();
1611
- } else {
1612
- video.addEventListener("seeked", () => r(), { once: true });
1613
- setTimeout(r, 200);
1614
- }
1615
- })
1616
- );
1617
- });
1618
- Promise.all(videoSeekPromises).then(() => {
1619
- void waitForVisualUpdate().then(resolve);
1620
- });
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));
1621
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));
1622
2030
  });
2031
+ await Promise.all(videoSeekPromises);
2032
+ await waitForVisualUpdate();
1623
2033
  };
1624
2034
  const getDuration = () => {
1625
2035
  const mediaDuration = getDocPlaybackDuration(doc);
@@ -1662,6 +2072,7 @@ function DocPlayerContent({
1662
2072
  const hasCoverBlock = () => !!coverBlock;
1663
2073
  const api = {
1664
2074
  seekTo: renderSeekTo,
2075
+ getRenderedTime: () => committedRenderTimeRef.current,
1665
2076
  getDuration,
1666
2077
  getBlocks,
1667
2078
  getAudioSegments,
@@ -1675,8 +2086,18 @@ function DocPlayerContent({
1675
2086
  return () => {
1676
2087
  if (liveRenderAPIRef.current === api) liveRenderAPIRef.current = null;
1677
2088
  };
1678
- }, [renderMode, isDebugMode, seekTo, totalDuration, expandedBlocks, coverBlock, doc]);
1679
- useEffect4(() => {
2089
+ }, [
2090
+ renderMode,
2091
+ isDebugMode,
2092
+ seekTo,
2093
+ totalDuration,
2094
+ expandedBlocks,
2095
+ coverBlock,
2096
+ doc,
2097
+ externalAudioController,
2098
+ waitForRenderCommit
2099
+ ]);
2100
+ useEffect5(() => {
1680
2101
  if (!renderMode && !isDebugMode || !containerRef.current) {
1681
2102
  onRenderAPIReady?.(null);
1682
2103
  return;
@@ -1685,8 +2106,8 @@ function DocPlayerContent({
1685
2106
  return () => onRenderAPIReady?.(null);
1686
2107
  }, [renderMode, isDebugMode, onRenderAPIReady, stableRenderAPI]);
1687
2108
  const defaultMode = captionsEnabledProp === false ? "off" : captionStyle || "standard";
1688
- const [captionMode, setCaptionMode] = useState4(defaultMode);
1689
- useEffect4(() => {
2109
+ const [captionMode, setCaptionMode] = useState5(defaultMode);
2110
+ useEffect5(() => {
1690
2111
  setCaptionMode(defaultMode);
1691
2112
  }, [defaultMode]);
1692
2113
  const captionsEnabled = captionMode !== "off";
@@ -1706,8 +2127,8 @@ function DocPlayerContent({
1706
2127
  });
1707
2128
  }, [onCaptionsToggle]);
1708
2129
  const hasCaptions = doc.captions && doc.captions.phrases.length > 0;
1709
- const segmentTitleMap = useMemo2(() => buildSegmentTitleMap(doc), [doc]);
1710
- const playbackState = useMemo2(
2130
+ const segmentTitleMap = useMemo3(() => buildSegmentTitleMap(doc), [doc]);
2131
+ const playbackState = useMemo3(
1711
2132
  () => ({
1712
2133
  isPlaying,
1713
2134
  currentTime,
@@ -1750,7 +2171,7 @@ function DocPlayerContent({
1750
2171
  expandedBlocks.length
1751
2172
  ]
1752
2173
  );
1753
- const playbackActions = useMemo2(
2174
+ const playbackActions = useMemo3(
1754
2175
  () => ({
1755
2176
  toggle,
1756
2177
  restart,
@@ -1761,7 +2182,7 @@ function DocPlayerContent({
1761
2182
  }),
1762
2183
  [toggle, restart, seekTo, setCaptionsEnabled, cycleCaptionMode, onFullscreenToggle]
1763
2184
  );
1764
- const slideNavActions = useMemo2(
2185
+ const slideNavActions = useMemo3(
1765
2186
  () => ({
1766
2187
  nextSlide: () => {
1767
2188
  if (slideshowHasCover && slideshowCoverVisible) {
@@ -1850,10 +2271,10 @@ function DocPlayerContent({
1850
2271
  onNext: handleSwipeNext,
1851
2272
  onPrev: handleSwipePrev
1852
2273
  });
1853
- useEffect4(() => {
2274
+ useEffect5(() => {
1854
2275
  onPlaybackStateChange?.(playbackState);
1855
2276
  }, [playbackState, onPlaybackStateChange]);
1856
- useEffect4(() => {
2277
+ useEffect5(() => {
1857
2278
  onControlsReady?.({ play, pause, ...playbackActions });
1858
2279
  }, [play, pause, playbackActions, onControlsReady]);
1859
2280
  const getBlockTitle = useCallback4((block) => {
@@ -1880,7 +2301,7 @@ function DocPlayerContent({
1880
2301
  }
1881
2302
  return block.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1882
2303
  }, []);
1883
- const slideshowPickerItems = useMemo2(() => {
2304
+ const slideshowPickerItems = useMemo3(() => {
1884
2305
  const blockItems = expandedBlocks.map((block, index) => ({
1885
2306
  id: block.id,
1886
2307
  label: String(index + 1),
@@ -1896,7 +2317,7 @@ function DocPlayerContent({
1896
2317
  ...blockItems
1897
2318
  ];
1898
2319
  }, [coverBlock, expandedBlocks, getBlockTitle, slideshowHasCover]);
1899
- const blockMarkers = useMemo2(() => {
2320
+ const blockMarkers = useMemo3(() => {
1900
2321
  if (!totalDuration || !expandedBlocks.length) return [];
1901
2322
  let prevSegment = -1;
1902
2323
  return expandedBlocks.map((block, index) => {
@@ -1911,7 +2332,7 @@ function DocPlayerContent({
1911
2332
  };
1912
2333
  });
1913
2334
  }, [expandedBlocks, totalDuration, getBlockTitle]);
1914
- useEffect4(() => {
2335
+ useEffect5(() => {
1915
2336
  if (blockMarkers.length > 0) {
1916
2337
  onBlockMarkers?.(blockMarkers);
1917
2338
  }
@@ -1988,7 +2409,7 @@ function DocPlayerContent({
1988
2409
  (e) => handleKeyboardShortcut(e, false),
1989
2410
  [handleKeyboardShortcut]
1990
2411
  );
1991
- useEffect4(() => {
2412
+ useEffect5(() => {
1992
2413
  if (!globalKeyboardShortcuts || renderMode || isLinearMode) return;
1993
2414
  const handleDocumentKeyDown = (event) => {
1994
2415
  handleKeyboardShortcut(event, true);
@@ -2028,7 +2449,9 @@ function DocPlayerContent({
2028
2449
  {
2029
2450
  ref: containerRef,
2030
2451
  "data-player-id": playerId,
2031
- "data-orientation": orientation,
2452
+ "data-orientation": activeOrientation,
2453
+ "data-playback-state": isPlaying ? "playing" : "paused",
2454
+ "data-swipe-phase": swipe.phase,
2032
2455
  tabIndex: renderMode ? -1 : 0,
2033
2456
  "aria-label": "Document player",
2034
2457
  onKeyDown: renderMode ? void 0 : handleKeyDown,
@@ -2036,6 +2459,7 @@ function DocPlayerContent({
2036
2459
  onClick: handleContainerClick,
2037
2460
  onPointerDown: swipe.onPointerDown,
2038
2461
  style: {
2462
+ ...pipVars,
2039
2463
  position: "relative",
2040
2464
  width: "100%",
2041
2465
  aspectRatio: `${activeViewport.width} / ${activeViewport.height}`,
@@ -2057,9 +2481,12 @@ function DocPlayerContent({
2057
2481
  basePath,
2058
2482
  renderMode,
2059
2483
  muted,
2060
- presentation: videoPresentation,
2061
- pipShape,
2062
- pipPosition
2484
+ presentation: appearance.videoPresentation,
2485
+ pipSize: appearance.pipSize,
2486
+ pipShape: appearance.pipShape,
2487
+ pipPosition: appearance.pipPosition,
2488
+ pipOrientation: activeOrientation,
2489
+ pipFrameStyle
2063
2490
  }
2064
2491
  ),
2065
2492
  /* @__PURE__ */ jsxs4("div", { className: "doc-player__viewport", children: [
@@ -2211,7 +2638,7 @@ function DocPlayerContent({
2211
2638
  " ",
2212
2639
  /* @__PURE__ */ jsxs4("span", { style: { color: "#666" }, children: [
2213
2640
  "(",
2214
- orientation,
2641
+ activeOrientation,
2215
2642
  ")"
2216
2643
  ] })
2217
2644
  ] }),
@@ -2477,7 +2904,7 @@ function DocControlsSidebar({ state, actions }) {
2477
2904
  }
2478
2905
 
2479
2906
  // src/DocPlayerWithSidebar.tsx
2480
- 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";
2481
2908
  import { jsx as jsx10, jsxs as jsxs7 } from "react/jsx-runtime";
2482
2909
  var DEFAULT_STATE = {
2483
2910
  isPlaying: false,
@@ -2513,7 +2940,7 @@ function DocPlayerWithSidebar({
2513
2940
  const stateRef = useRef6(DEFAULT_STATE);
2514
2941
  const actionsRef = useRef6(null);
2515
2942
  const wasPlayingRef = useRef6(false);
2516
- const [, setTick] = useState5(0);
2943
+ const [, setTick] = useState6(0);
2517
2944
  const handleStateChange = useCallback5(
2518
2945
  (state) => {
2519
2946
  stateRef.current = state;
@@ -2533,7 +2960,7 @@ function DocPlayerWithSidebar({
2533
2960
  },
2534
2961
  []
2535
2962
  );
2536
- useEffect5(() => {
2963
+ useEffect6(() => {
2537
2964
  const interval = setInterval(() => {
2538
2965
  if (!stateRef.current.isPlaying) return;
2539
2966
  setTick((t) => t + 1);
@@ -2570,12 +2997,14 @@ function DocPlayerWithSidebar({
2570
2997
 
2571
2998
  export {
2572
2999
  MediaClipLayer,
3000
+ useMediaClipDurations,
2573
3001
  SocialCaptionOverlay,
2574
3002
  CaptionOverlay,
2575
3003
  formatTime,
2576
3004
  DocProgressBar,
2577
3005
  DocControlsOverlay,
2578
3006
  DocControlsSlideshow,
3007
+ resolveDocPlayerAppearance,
2579
3008
  DocPlayer,
2580
3009
  DocControlsBottom,
2581
3010
  DocControlsSidebar,