@aglyn/plugins-mui 1.0.0-beta.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +22 -0
  3. package/package.json +47 -0
  4. package/src/index.d.ts +21 -0
  5. package/src/index.js +22 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/accordion.d.ts +96 -0
  8. package/src/lib/components/accordion.js +392 -0
  9. package/src/lib/components/accordion.js.map +1 -0
  10. package/src/lib/components/app-bar.d.ts +32 -0
  11. package/src/lib/components/app-bar.js +86 -0
  12. package/src/lib/components/app-bar.js.map +1 -0
  13. package/src/lib/components/blocks.d.ts +46 -0
  14. package/src/lib/components/blocks.js +1007 -0
  15. package/src/lib/components/blocks.js.map +1 -0
  16. package/src/lib/components/box.d.ts +47 -0
  17. package/src/lib/components/box.js +125 -0
  18. package/src/lib/components/box.js.map +1 -0
  19. package/src/lib/components/breadcrumbs.d.ts +44 -0
  20. package/src/lib/components/breadcrumbs.js +172 -0
  21. package/src/lib/components/breadcrumbs.js.map +1 -0
  22. package/src/lib/components/button.d.ts +89 -0
  23. package/src/lib/components/button.js +272 -0
  24. package/src/lib/components/button.js.map +1 -0
  25. package/src/lib/components/card.d.ts +61 -0
  26. package/src/lib/components/card.js +336 -0
  27. package/src/lib/components/card.js.map +1 -0
  28. package/src/lib/components/collection.d.ts +514 -0
  29. package/src/lib/components/collection.js +2999 -0
  30. package/src/lib/components/collection.js.map +1 -0
  31. package/src/lib/components/container.d.ts +42 -0
  32. package/src/lib/components/container.js +169 -0
  33. package/src/lib/components/container.js.map +1 -0
  34. package/src/lib/components/custom-html.d.ts +78 -0
  35. package/src/lib/components/custom-html.js +219 -0
  36. package/src/lib/components/custom-html.js.map +1 -0
  37. package/src/lib/components/data-table.d.ts +57 -0
  38. package/src/lib/components/data-table.js +216 -0
  39. package/src/lib/components/data-table.js.map +1 -0
  40. package/src/lib/components/document-root.d.ts +77 -0
  41. package/src/lib/components/document-root.js +118 -0
  42. package/src/lib/components/document-root.js.map +1 -0
  43. package/src/lib/components/drawer.d.ts +93 -0
  44. package/src/lib/components/drawer.js +566 -0
  45. package/src/lib/components/drawer.js.map +1 -0
  46. package/src/lib/components/function-controls.d.ts +66 -0
  47. package/src/lib/components/function-controls.js +207 -0
  48. package/src/lib/components/function-controls.js.map +1 -0
  49. package/src/lib/components/function-scope.d.ts +110 -0
  50. package/src/lib/components/function-scope.js +478 -0
  51. package/src/lib/components/function-scope.js.map +1 -0
  52. package/src/lib/components/function-widget.d.ts +75 -0
  53. package/src/lib/components/function-widget.js +307 -0
  54. package/src/lib/components/function-widget.js.map +1 -0
  55. package/src/lib/components/grid.d.ts +91 -0
  56. package/src/lib/components/grid.js +322 -0
  57. package/src/lib/components/grid.js.map +1 -0
  58. package/src/lib/components/icon.d.ts +54 -0
  59. package/src/lib/components/icon.js +163 -0
  60. package/src/lib/components/icon.js.map +1 -0
  61. package/src/lib/components/image-list.d.ts +58 -0
  62. package/src/lib/components/image-list.js +336 -0
  63. package/src/lib/components/image-list.js.map +1 -0
  64. package/src/lib/components/image.d.ts +154 -0
  65. package/src/lib/components/image.js +451 -0
  66. package/src/lib/components/image.js.map +1 -0
  67. package/src/lib/components/inline-text.d.ts +74 -0
  68. package/src/lib/components/inline-text.js +407 -0
  69. package/src/lib/components/inline-text.js.map +1 -0
  70. package/src/lib/components/language-switcher.d.ts +35 -0
  71. package/src/lib/components/language-switcher.js +179 -0
  72. package/src/lib/components/language-switcher.js.map +1 -0
  73. package/src/lib/components/layout-slot.d.ts +68 -0
  74. package/src/lib/components/layout-slot.js +192 -0
  75. package/src/lib/components/layout-slot.js.map +1 -0
  76. package/src/lib/components/link-box-accessible-name.d.ts +79 -0
  77. package/src/lib/components/link-box-accessible-name.js +158 -0
  78. package/src/lib/components/link-box-accessible-name.js.map +1 -0
  79. package/src/lib/components/link-box.d.ts +66 -0
  80. package/src/lib/components/link-box.js +212 -0
  81. package/src/lib/components/link-box.js.map +1 -0
  82. package/src/lib/components/list-item-text.d.ts +22 -0
  83. package/src/lib/components/list-item-text.js +86 -0
  84. package/src/lib/components/list-item-text.js.map +1 -0
  85. package/src/lib/components/list-item.d.ts +32 -0
  86. package/src/lib/components/list-item.js +127 -0
  87. package/src/lib/components/list-item.js.map +1 -0
  88. package/src/lib/components/list.d.ts +34 -0
  89. package/src/lib/components/list.js +108 -0
  90. package/src/lib/components/list.js.map +1 -0
  91. package/src/lib/components/markdown.d.ts +129 -0
  92. package/src/lib/components/markdown.js +853 -0
  93. package/src/lib/components/markdown.js.map +1 -0
  94. package/src/lib/components/nav-menu.d.ts +68 -0
  95. package/src/lib/components/nav-menu.js +683 -0
  96. package/src/lib/components/nav-menu.js.map +1 -0
  97. package/src/lib/components/pagination.d.ts +59 -0
  98. package/src/lib/components/pagination.js +243 -0
  99. package/src/lib/components/pagination.js.map +1 -0
  100. package/src/lib/components/paper.d.ts +47 -0
  101. package/src/lib/components/paper.js +202 -0
  102. package/src/lib/components/paper.js.map +1 -0
  103. package/src/lib/components/plugin-frame.d.ts +64 -0
  104. package/src/lib/components/plugin-frame.js +335 -0
  105. package/src/lib/components/plugin-frame.js.map +1 -0
  106. package/src/lib/components/plugin.d.ts +95 -0
  107. package/src/lib/components/plugin.js +304 -0
  108. package/src/lib/components/plugin.js.map +1 -0
  109. package/src/lib/components/product.d.ts +40 -0
  110. package/src/lib/components/product.js +287 -0
  111. package/src/lib/components/product.js.map +1 -0
  112. package/src/lib/components/reusable-instance.d.ts +57 -0
  113. package/src/lib/components/reusable-instance.js +126 -0
  114. package/src/lib/components/reusable-instance.js.map +1 -0
  115. package/src/lib/components/screen-link.d.ts +85 -0
  116. package/src/lib/components/screen-link.js +324 -0
  117. package/src/lib/components/screen-link.js.map +1 -0
  118. package/src/lib/components/search-box.d.ts +41 -0
  119. package/src/lib/components/search-box.js +125 -0
  120. package/src/lib/components/search-box.js.map +1 -0
  121. package/src/lib/components/section.d.ts +56 -0
  122. package/src/lib/components/section.js +132 -0
  123. package/src/lib/components/section.js.map +1 -0
  124. package/src/lib/components/stack.d.ts +55 -0
  125. package/src/lib/components/stack.js +309 -0
  126. package/src/lib/components/stack.js.map +1 -0
  127. package/src/lib/components/tabs.d.ts +288 -0
  128. package/src/lib/components/tabs.js +788 -0
  129. package/src/lib/components/tabs.js.map +1 -0
  130. package/src/lib/components/theme-mode-switcher.d.ts +38 -0
  131. package/src/lib/components/theme-mode-switcher.js +168 -0
  132. package/src/lib/components/theme-mode-switcher.js.map +1 -0
  133. package/src/lib/components/toolbar.d.ts +37 -0
  134. package/src/lib/components/toolbar.js +117 -0
  135. package/src/lib/components/toolbar.js.map +1 -0
  136. package/src/lib/components/typography.d.ts +33 -0
  137. package/src/lib/components/typography.js +433 -0
  138. package/src/lib/components/typography.js.map +1 -0
  139. package/src/lib/components/video-lightbox-controls.d.ts +131 -0
  140. package/src/lib/components/video-lightbox-controls.js +536 -0
  141. package/src/lib/components/video-lightbox-controls.js.map +1 -0
  142. package/src/lib/components/video-lightbox.d.ts +60 -0
  143. package/src/lib/components/video-lightbox.js +247 -0
  144. package/src/lib/components/video-lightbox.js.map +1 -0
  145. package/src/lib/components/video-playback-beacon.d.ts +80 -0
  146. package/src/lib/components/video-playback-beacon.js +128 -0
  147. package/src/lib/components/video-playback-beacon.js.map +1 -0
  148. package/src/lib/components/video-player-frame.d.ts +58 -0
  149. package/src/lib/components/video-player-frame.js +51 -0
  150. package/src/lib/components/video-player-frame.js.map +1 -0
  151. package/src/lib/components/video.d.ts +220 -0
  152. package/src/lib/components/video.js +874 -0
  153. package/src/lib/components/video.js.map +1 -0
  154. package/src/lib/constants/bundle-common.d.ts +17 -0
  155. package/src/lib/constants/bundle-common.js +18 -0
  156. package/src/lib/constants/bundle-common.js.map +1 -0
  157. package/src/lib/constants/field-presets.d.ts +93 -0
  158. package/src/lib/constants/field-presets.js +248 -0
  159. package/src/lib/constants/field-presets.js.map +1 -0
  160. package/src/lib/plugin.d.ts +80 -0
  161. package/src/lib/plugin.js +599 -0
  162. package/src/lib/plugin.js.map +1 -0
  163. package/src/lib/utils/author-html-label.d.ts +73 -0
  164. package/src/lib/utils/author-html-label.js +89 -0
  165. package/src/lib/utils/author-html-label.js.map +1 -0
  166. package/src/lib/utils/drop-cleared-props.d.ts +18 -0
  167. package/src/lib/utils/drop-cleared-props.js +74 -0
  168. package/src/lib/utils/drop-cleared-props.js.map +1 -0
  169. package/src/lib/utils/element-picker.d.ts +99 -0
  170. package/src/lib/utils/element-picker.js +140 -0
  171. package/src/lib/utils/element-picker.js.map +1 -0
  172. package/src/lib/utils/generate-preset-id.d.ts +19 -0
  173. package/src/lib/utils/generate-preset-id.js +25 -0
  174. package/src/lib/utils/generate-preset-id.js.map +1 -0
  175. package/src/lib/utils/link-target-props.d.ts +53 -0
  176. package/src/lib/utils/link-target-props.js +53 -0
  177. package/src/lib/utils/link-target-props.js.map +1 -0
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type ReactNode } from 'react';
18
+ import { type VideoPlaybackBeaconOptions } from './video-playback-beacon';
19
+ export interface VideoLightboxProps {
20
+ open: boolean;
21
+ onClose: () => void;
22
+ /** Names the dialog for assistive technology; also the video's own title. */
23
+ title?: string;
24
+ /** Resolved video URL — already through `resolveMediaSrc`. */
25
+ src: string;
26
+ /** Resolved poster URL, shown for the instant before the first frame. */
27
+ poster?: string;
28
+ /** `width / height` from the asset, when the DAM measured it. */
29
+ aspectRatio?: string;
30
+ loop?: boolean;
31
+ muted?: boolean;
32
+ /** The `<track>` the inline element would have rendered, if any. */
33
+ captions?: ReactNode;
34
+ /**
35
+ * What a play in this dialog is counted against (AGL-2781). Each open is
36
+ * its own viewing, so the element passes the facts and the dialog keys the
37
+ * viewing to `open`.
38
+ */
39
+ playback?: Omit<VideoPlaybackBeaconOptions, 'viewingKey'>;
40
+ /**
41
+ * A hosted player's frame address (AGL-2826). When set, the dialog frames
42
+ * that player and never plays `src` itself.
43
+ */
44
+ embedSrc?: string;
45
+ }
46
+ /**
47
+ * The dialog. Rendered only while `open`, and unmounted when it closes.
48
+ *
49
+ * Unmounting is not tidiness — it is the only thing that reliably STOPS the
50
+ * download. A `<video>` left in the tree paused still holds whatever it has
51
+ * buffered and may keep filling that buffer; taking the element out of the
52
+ * document is what tells the browser the request is over. That matters more
53
+ * here than it would elsewhere, because the whole element exists to keep a
54
+ * multi-megabyte file off the wire until it is wanted.
55
+ */
56
+ export declare function VideoLightbox(props: VideoLightboxProps): import("react").JSX.Element;
57
+ export declare namespace VideoLightbox {
58
+ var displayName: string;
59
+ }
60
+ export default VideoLightbox;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * The Video element's lightbox (AGL-2744).
18
+ *
19
+ * ## Why this is its own module
20
+ *
21
+ * This file is the ONLY place in the tenant's component graph that names
22
+ * `@mui/material/Dialog`, and it must stay that way. `libs/shared/ui/jsx`'s
23
+ * barrel deliberately withholds `dialog-confirm`, `loading-modal` and
24
+ * `navigation-drawer` because the MUI Dialog/Drawer/Popper stack was measured
25
+ * sitting in the first-load chunks of `/pricing` — a page that opens no dialog
26
+ * (AGL-1290). `plugin.ts` imports every element eagerly, so a `Dialog` named
27
+ * at the top of `video.tsx` would put that whole stack back on every published
28
+ * page of every customer site, including the ones with no video on them.
29
+ *
30
+ * Split out, it is a chunk `video.tsx` reaches through `lazy(() => import())`
31
+ * and mounts only once a visitor has actually asked to watch something. A page
32
+ * with no video pays nothing; a page with a video pays nothing; a visitor who
33
+ * clicks pays once. The player's controls, `video-lightbox-controls.tsx`, are
34
+ * imported from here and from nowhere else, so they ride in the same chunk.
35
+ *
36
+ * It is still MUI's `Dialog` and still the site's own theme. The split is
37
+ * about WHEN the dialog arrives, not about hand-rolling a modal — and the
38
+ * accessibility is most of why. `Dialog` traps focus for the life of the
39
+ * overlay, restores it to the element that opened it on close, closes on
40
+ * `Escape`, and marks the rest of the document inert for assistive
41
+ * technology. Every one of those is a thing a bespoke overlay gets wrong.
42
+ *
43
+ * ## Why the film has no `controls` attribute here
44
+ *
45
+ * The browser's own controls swallow `Escape` (AGL-2802): from inside them no
46
+ * key event reaches the page, so the dialog could not be dismissed from the
47
+ * stops a keyboard visitor is on while watching. The player draws its own
48
+ * controls instead; `video-lightbox-controls.tsx` explains the measurement and
49
+ * carries the key map. The inline player keeps the browser's controls, because
50
+ * nothing on a page is listening for `Escape` there.
51
+ */ 'use client';
52
+ import { _ as _extends } from "@swc/helpers/_/_extends";
53
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
54
+ import { mdiClose } from "@aglyn/shared-data-mdi";
55
+ import { MdiIcon } from "@aglyn/shared-ui-jsx";
56
+ import Box from "@mui/material/Box";
57
+ import Dialog from "@mui/material/Dialog";
58
+ import IconButton from "@mui/material/IconButton";
59
+ import useMediaQuery from "@mui/material/useMediaQuery";
60
+ import { useRef } from "react";
61
+ import { controlButtonSx, handlePlayerShortcut, togglePlayback, VideoLightboxControls } from "./video-lightbox-controls.js";
62
+ import { useVideoPlaybackBeacon } from "./video-playback-beacon.js";
63
+ import { VideoPlayerFrame } from "./video-player-frame.js";
64
+ /** What a dialog is called when the author gave the video no title. */ const FALLBACK_LABEL = 'Video';
65
+ /**
66
+ * The dialog. Rendered only while `open`, and unmounted when it closes.
67
+ *
68
+ * Unmounting is not tidiness — it is the only thing that reliably STOPS the
69
+ * download. A `<video>` left in the tree paused still holds whatever it has
70
+ * buffered and may keep filling that buffer; taking the element out of the
71
+ * document is what tells the browser the request is over. That matters more
72
+ * here than it would elsewhere, because the whole element exists to keep a
73
+ * multi-megabyte file off the wire until it is wanted.
74
+ */ export function VideoLightbox(props) {
75
+ const { open, onClose, title, src, poster, aspectRatio, loop, muted, captions, playback, embedSrc } = props;
76
+ const playbackHandlers = useVideoPlaybackBeacon(_extends({}, playback, {
77
+ viewingKey: open
78
+ }));
79
+ const videoRef = useRef(null);
80
+ /** The frame that goes full screen, controls and all. */ const playerRef = useRef(null);
81
+ /**
82
+ * `prefers-reduced-motion` reaches the DIALOG, not the video.
83
+ *
84
+ * The overlay's fade-and-grow is incidental motion a visitor did not ask
85
+ * for, so it is dropped to zero — MUI reads `transitionDuration` for both
86
+ * the backdrop and the paper, so one value covers the whole open.
87
+ *
88
+ * The FILM is deliberately left alone, and that is the more interesting
89
+ * half. Autoplay here is not autoplay: the visitor pressed a play button,
90
+ * and a play button that opens a paused video is a defect, not an
91
+ * accommodation. WCAG's auto-motion requirement is about motion that
92
+ * starts without the user, and it is satisfied anyway — the player's
93
+ * controls are on screen, so the video can be paused at any moment.
94
+ */ const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
95
+ /**
96
+ * Pauses before closing, so the sound stops on the press rather than when
97
+ * the exit transition ends and the `<video>` leaves the tree.
98
+ */ const closeDialog = ()=>{
99
+ var _videoRef_current;
100
+ (_videoRef_current = videoRef.current) == null ? void 0 : _videoRef_current.pause();
101
+ onClose();
102
+ };
103
+ /**
104
+ * `Escape` in full screen leaves full screen and keeps the dialog open. A
105
+ * browser normally exits full screen on that key before the page hears it;
106
+ * this covers one that passes the key on as well, so one press never does
107
+ * both.
108
+ */ const handleClose = (_event, reason)=>{
109
+ var _playerRef_current;
110
+ const doc = (_playerRef_current = playerRef.current) == null ? void 0 : _playerRef_current.ownerDocument;
111
+ if (reason === 'escapeKeyDown' && (doc == null ? void 0 : doc.fullscreenElement)) {
112
+ void Promise.resolve(doc.exitFullscreen()).catch(()=>undefined);
113
+ return;
114
+ }
115
+ closeDialog();
116
+ };
117
+ return /*#__PURE__*/ _jsx(Dialog, {
118
+ open: open,
119
+ onClose: handleClose,
120
+ onKeyDown: (event)=>handlePlayerShortcut(event, {
121
+ video: videoRef.current,
122
+ player: playerRef.current
123
+ }),
124
+ maxWidth: "lg",
125
+ fullWidth: true,
126
+ transitionDuration: reduceMotion ? 0 : undefined,
127
+ slotProps: {
128
+ paper: {
129
+ /**
130
+ * ⚠️ The label goes on the PAPER, not on `<Dialog>`.
131
+ *
132
+ * `role="dialog"` lives on the paper; props spread onto `Dialog`
133
+ * reach the Modal root, which is `role="presentation"`. Written
134
+ * there first and measured in a browser: the presentation root
135
+ * carried the name and the dialog itself had none, which is an
136
+ * unlabelled dialog announced to a screen reader — the exact
137
+ * failure the label was added to prevent. No `DialogTitle`,
138
+ * deliberately: the film is the content, and a heading above it
139
+ * would only push it down.
140
+ */ 'aria-label': title || FALLBACK_LABEL,
141
+ // The paper is a frame around a video, not a sheet of content:
142
+ // no padding, and a black ground so a letterboxed film has
143
+ // something to sit on rather than a white margin.
144
+ sx: {
145
+ overflow: 'hidden',
146
+ bgcolor: 'common.black'
147
+ }
148
+ }
149
+ },
150
+ children: /*#__PURE__*/ _jsxs(Box, {
151
+ ref: playerRef,
152
+ sx: {
153
+ position: 'relative',
154
+ display: 'flex',
155
+ flexDirection: 'column',
156
+ // The paper stops growing at the viewport, less its margins. The
157
+ // film is what gives way, letterboxed, so the controls under it
158
+ // are never the part cut off on a short or landscape screen.
159
+ flex: '1 1 auto',
160
+ minHeight: 0,
161
+ bgcolor: 'common.black'
162
+ },
163
+ children: [
164
+ /*#__PURE__*/ _jsx(IconButton, {
165
+ onClick: closeDialog,
166
+ "aria-label": "Close video",
167
+ size: "small",
168
+ sx: [
169
+ controlButtonSx,
170
+ (theme)=>({
171
+ position: 'absolute',
172
+ top: theme.spacing(1),
173
+ right: theme.spacing(1),
174
+ zIndex: 1,
175
+ // Over frames of unknown brightness, so the control carries its
176
+ // own ground rather than trusting the film's.
177
+ bgcolor: 'common.black'
178
+ })
179
+ ],
180
+ children: /*#__PURE__*/ _jsx(MdiIcon, {
181
+ path: mdiClose.path,
182
+ fontSize: "small"
183
+ })
184
+ }),
185
+ embedSrc ? // A hosted player draws its own controls inside its frame, so the
186
+ // dialog adds none, and its shortcuts find no film to act on.
187
+ // Closing unmounts the frame with the rest of the dialog, which
188
+ // stops the player exactly as it stops the `<video>`, and the frame
189
+ // gives way on a short screen for the reason the film does.
190
+ /*#__PURE__*/ _jsx(VideoPlayerFrame, {
191
+ src: embedSrc,
192
+ title: title,
193
+ aspectRatio: aspectRatio,
194
+ style: {
195
+ flexGrow: 1,
196
+ flexShrink: 1,
197
+ minHeight: 0
198
+ }
199
+ }) : /*#__PURE__*/ _jsxs(_Fragment, {
200
+ children: [
201
+ /*#__PURE__*/ _jsx(Box, {
202
+ component: "video",
203
+ ref: videoRef,
204
+ src: src,
205
+ poster: poster || undefined,
206
+ title: title || undefined,
207
+ autoPlay: true,
208
+ playsInline: true,
209
+ loop: Boolean(loop),
210
+ muted: Boolean(muted),
211
+ // The one place in this element where fetching eagerly is right:
212
+ // the visitor has asked for the film and is looking at the player.
213
+ preload: "auto",
214
+ // A click on the picture plays and pauses, as it does on the
215
+ // browser's own player.
216
+ onClick: ()=>{
217
+ if (videoRef.current) togglePlayback(videoRef.current);
218
+ },
219
+ onPlay: playbackHandlers.onPlay,
220
+ onTimeUpdate: playbackHandlers.onTimeUpdate,
221
+ onEnded: playbackHandlers.onEnded,
222
+ sx: {
223
+ display: 'block',
224
+ width: '100%',
225
+ height: 'auto',
226
+ flex: '1 1 auto',
227
+ minHeight: 0,
228
+ objectFit: 'contain',
229
+ aspectRatio
230
+ },
231
+ children: captions
232
+ }),
233
+ /*#__PURE__*/ _jsx(VideoLightboxControls, {
234
+ videoRef: videoRef,
235
+ playerRef: playerRef,
236
+ captions: Boolean(captions)
237
+ })
238
+ ]
239
+ })
240
+ ]
241
+ })
242
+ });
243
+ }
244
+ VideoLightbox.displayName = 'VideoLightbox';
245
+ export default VideoLightbox;
246
+
247
+ //# sourceMappingURL=video-lightbox.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/video-lightbox.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The Video element's lightbox (AGL-2744).\n *\n * ## Why this is its own module\n *\n * This file is the ONLY place in the tenant's component graph that names\n * `@mui/material/Dialog`, and it must stay that way. `libs/shared/ui/jsx`'s\n * barrel deliberately withholds `dialog-confirm`, `loading-modal` and\n * `navigation-drawer` because the MUI Dialog/Drawer/Popper stack was measured\n * sitting in the first-load chunks of `/pricing` — a page that opens no dialog\n * (AGL-1290). `plugin.ts` imports every element eagerly, so a `Dialog` named\n * at the top of `video.tsx` would put that whole stack back on every published\n * page of every customer site, including the ones with no video on them.\n *\n * Split out, it is a chunk `video.tsx` reaches through `lazy(() => import())`\n * and mounts only once a visitor has actually asked to watch something. A page\n * with no video pays nothing; a page with a video pays nothing; a visitor who\n * clicks pays once. The player's controls, `video-lightbox-controls.tsx`, are\n * imported from here and from nowhere else, so they ride in the same chunk.\n *\n * It is still MUI's `Dialog` and still the site's own theme. The split is\n * about WHEN the dialog arrives, not about hand-rolling a modal — and the\n * accessibility is most of why. `Dialog` traps focus for the life of the\n * overlay, restores it to the element that opened it on close, closes on\n * `Escape`, and marks the rest of the document inert for assistive\n * technology. Every one of those is a thing a bespoke overlay gets wrong.\n *\n * ## Why the film has no `controls` attribute here\n *\n * The browser's own controls swallow `Escape` (AGL-2802): from inside them no\n * key event reaches the page, so the dialog could not be dismissed from the\n * stops a keyboard visitor is on while watching. The player draws its own\n * controls instead; `video-lightbox-controls.tsx` explains the measurement and\n * carries the key map. The inline player keeps the browser's controls, because\n * nothing on a page is listening for `Escape` there.\n */\n'use client'\n\nimport { mdiClose } from '@aglyn/shared-data-mdi'\nimport { MdiIcon } from '@aglyn/shared-ui-jsx'\nimport Box from '@mui/material/Box'\nimport Dialog, { type DialogProps } from '@mui/material/Dialog'\nimport IconButton from '@mui/material/IconButton'\nimport useMediaQuery from '@mui/material/useMediaQuery'\nimport { type ReactNode, useRef } from 'react'\nimport {\n controlButtonSx,\n handlePlayerShortcut,\n togglePlayback,\n VideoLightboxControls,\n} from './video-lightbox-controls'\nimport {\n useVideoPlaybackBeacon,\n type VideoPlaybackBeaconOptions,\n} from './video-playback-beacon'\nimport { VideoPlayerFrame } from './video-player-frame'\n\nexport interface VideoLightboxProps {\n open: boolean\n onClose: () => void\n /** Names the dialog for assistive technology; also the video's own title. */\n title?: string\n /** Resolved video URL — already through `resolveMediaSrc`. */\n src: string\n /** Resolved poster URL, shown for the instant before the first frame. */\n poster?: string\n /** `width / height` from the asset, when the DAM measured it. */\n aspectRatio?: string\n loop?: boolean\n muted?: boolean\n /** The `<track>` the inline element would have rendered, if any. */\n captions?: ReactNode\n /**\n * What a play in this dialog is counted against (AGL-2781). Each open is\n * its own viewing, so the element passes the facts and the dialog keys the\n * viewing to `open`.\n */\n playback?: Omit<VideoPlaybackBeaconOptions, 'viewingKey'>\n /**\n * A hosted player's frame address (AGL-2826). When set, the dialog frames\n * that player and never plays `src` itself.\n */\n embedSrc?: string\n}\n\n/** What a dialog is called when the author gave the video no title. */\nconst FALLBACK_LABEL = 'Video'\n\n/**\n * The dialog. Rendered only while `open`, and unmounted when it closes.\n *\n * Unmounting is not tidiness — it is the only thing that reliably STOPS the\n * download. A `<video>` left in the tree paused still holds whatever it has\n * buffered and may keep filling that buffer; taking the element out of the\n * document is what tells the browser the request is over. That matters more\n * here than it would elsewhere, because the whole element exists to keep a\n * multi-megabyte file off the wire until it is wanted.\n */\nexport function VideoLightbox(props: VideoLightboxProps) {\n const {\n open,\n onClose,\n title,\n src,\n poster,\n aspectRatio,\n loop,\n muted,\n captions,\n playback,\n embedSrc,\n } = props\n const playbackHandlers = useVideoPlaybackBeacon({\n ...playback,\n viewingKey: open,\n })\n const videoRef = useRef<HTMLVideoElement>(null)\n /** The frame that goes full screen, controls and all. */\n const playerRef = useRef<HTMLDivElement>(null)\n /**\n * `prefers-reduced-motion` reaches the DIALOG, not the video.\n *\n * The overlay's fade-and-grow is incidental motion a visitor did not ask\n * for, so it is dropped to zero — MUI reads `transitionDuration` for both\n * the backdrop and the paper, so one value covers the whole open.\n *\n * The FILM is deliberately left alone, and that is the more interesting\n * half. Autoplay here is not autoplay: the visitor pressed a play button,\n * and a play button that opens a paused video is a defect, not an\n * accommodation. WCAG's auto-motion requirement is about motion that\n * starts without the user, and it is satisfied anyway — the player's\n * controls are on screen, so the video can be paused at any moment.\n */\n const reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)')\n /**\n * Pauses before closing, so the sound stops on the press rather than when\n * the exit transition ends and the `<video>` leaves the tree.\n */\n const closeDialog = () => {\n videoRef.current?.pause()\n onClose()\n }\n /**\n * `Escape` in full screen leaves full screen and keeps the dialog open. A\n * browser normally exits full screen on that key before the page hears it;\n * this covers one that passes the key on as well, so one press never does\n * both.\n */\n const handleClose: DialogProps['onClose'] = (_event, reason) => {\n const doc = playerRef.current?.ownerDocument\n if (reason === 'escapeKeyDown' && doc?.fullscreenElement) {\n void Promise.resolve(doc.exitFullscreen()).catch(() => undefined)\n return\n }\n closeDialog()\n }\n return (\n <Dialog\n open={open}\n onClose={handleClose}\n onKeyDown={(event) =>\n handlePlayerShortcut(event, {\n video: videoRef.current,\n player: playerRef.current,\n })\n }\n maxWidth=\"lg\"\n fullWidth\n transitionDuration={reduceMotion ? 0 : undefined}\n slotProps={{\n paper: {\n /**\n * ⚠️ The label goes on the PAPER, not on `<Dialog>`.\n *\n * `role=\"dialog\"` lives on the paper; props spread onto `Dialog`\n * reach the Modal root, which is `role=\"presentation\"`. Written\n * there first and measured in a browser: the presentation root\n * carried the name and the dialog itself had none, which is an\n * unlabelled dialog announced to a screen reader — the exact\n * failure the label was added to prevent. No `DialogTitle`,\n * deliberately: the film is the content, and a heading above it\n * would only push it down.\n */\n 'aria-label': title || FALLBACK_LABEL,\n // The paper is a frame around a video, not a sheet of content:\n // no padding, and a black ground so a letterboxed film has\n // something to sit on rather than a white margin.\n sx: { overflow: 'hidden', bgcolor: 'common.black' },\n },\n }}\n >\n <Box\n ref={playerRef}\n sx={{\n position: 'relative',\n display: 'flex',\n flexDirection: 'column',\n // The paper stops growing at the viewport, less its margins. The\n // film is what gives way, letterboxed, so the controls under it\n // are never the part cut off on a short or landscape screen.\n flex: '1 1 auto',\n minHeight: 0,\n bgcolor: 'common.black',\n }}\n >\n <IconButton\n onClick={closeDialog}\n aria-label=\"Close video\"\n size=\"small\"\n sx={[\n controlButtonSx,\n (theme) => ({\n position: 'absolute',\n top: theme.spacing(1),\n right: theme.spacing(1),\n zIndex: 1,\n // Over frames of unknown brightness, so the control carries its\n // own ground rather than trusting the film's.\n bgcolor: 'common.black',\n }),\n ]}\n >\n <MdiIcon path={mdiClose.path} fontSize=\"small\" />\n </IconButton>\n {embedSrc ? (\n // A hosted player draws its own controls inside its frame, so the\n // dialog adds none, and its shortcuts find no film to act on.\n // Closing unmounts the frame with the rest of the dialog, which\n // stops the player exactly as it stops the `<video>`, and the frame\n // gives way on a short screen for the reason the film does.\n <VideoPlayerFrame\n src={embedSrc}\n title={title}\n aspectRatio={aspectRatio}\n style={{ flexGrow: 1, flexShrink: 1, minHeight: 0 }}\n />\n ) : (\n <>\n <Box\n component=\"video\"\n ref={videoRef}\n src={src}\n poster={poster || undefined}\n title={title || undefined}\n autoPlay\n playsInline\n loop={Boolean(loop)}\n muted={Boolean(muted)}\n // The one place in this element where fetching eagerly is right:\n // the visitor has asked for the film and is looking at the player.\n preload=\"auto\"\n // A click on the picture plays and pauses, as it does on the\n // browser's own player.\n onClick={() => {\n if (videoRef.current) togglePlayback(videoRef.current)\n }}\n onPlay={playbackHandlers.onPlay}\n onTimeUpdate={playbackHandlers.onTimeUpdate}\n onEnded={playbackHandlers.onEnded}\n sx={{\n display: 'block',\n width: '100%',\n height: 'auto',\n flex: '1 1 auto',\n minHeight: 0,\n objectFit: 'contain',\n aspectRatio,\n }}\n >\n {captions}\n </Box>\n <VideoLightboxControls\n videoRef={videoRef}\n playerRef={playerRef}\n captions={Boolean(captions)}\n />\n </>\n )}\n </Box>\n </Dialog>\n )\n}\nVideoLightbox.displayName = 'VideoLightbox'\n\nexport default VideoLightbox\n"],"names":["mdiClose","MdiIcon","Box","Dialog","IconButton","useMediaQuery","useRef","controlButtonSx","handlePlayerShortcut","togglePlayback","VideoLightboxControls","useVideoPlaybackBeacon","VideoPlayerFrame","FALLBACK_LABEL","VideoLightbox","props","open","onClose","title","src","poster","aspectRatio","loop","muted","captions","playback","embedSrc","playbackHandlers","viewingKey","videoRef","playerRef","reduceMotion","closeDialog","current","pause","handleClose","_event","reason","doc","ownerDocument","fullscreenElement","Promise","resolve","exitFullscreen","catch","undefined","onKeyDown","event","video","player","maxWidth","fullWidth","transitionDuration","slotProps","paper","sx","overflow","bgcolor","ref","position","display","flexDirection","flex","minHeight","onClick","aria-label","size","theme","top","spacing","right","zIndex","path","fontSize","style","flexGrow","flexShrink","component","autoPlay","playsInline","Boolean","preload","onPlay","onTimeUpdate","onEnded","width","height","objectFit","displayName"],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type SyntheticEvent } from 'react';
18
+ /**
19
+ * The DAM media id a Video element's STORED `src` names, or undefined.
20
+ *
21
+ * Plays are counted per asset, so only a value that names one is counted: a
22
+ * `media:` reference (pinned or not), or a root-relative CDN path written
23
+ * before references existed. A hotlink and a raw Storage URL name no asset,
24
+ * and neither does an absolute URL whose path merely looks like the CDN's —
25
+ * that could be anybody's server.
26
+ *
27
+ * The stored value rather than the resolved one, because the resolved URL
28
+ * carries `?r=auto` and a host-qualified scope, neither of which is the asset.
29
+ */
30
+ export declare function playbackMediaId(src: unknown): string | undefined;
31
+ export interface VideoPlaybackBeaconOptions {
32
+ /** The site the film plays on. Nothing is counted without one. */
33
+ hostId?: string;
34
+ /** The element's stored `src` — see {@link playbackMediaId}. */
35
+ src?: unknown;
36
+ /**
37
+ * An editing surface: the besigner canvas or the console preview. An author
38
+ * watching their own draft is not a visitor's play.
39
+ */
40
+ suppressed?: boolean;
41
+ /**
42
+ * A new viewing starts whenever this changes. The lightbox passes `open`,
43
+ * so each open that is played counts once, while pausing and resuming
44
+ * inside one open does not.
45
+ */
46
+ viewingKey?: unknown;
47
+ }
48
+ export interface VideoPlaybackHandlers {
49
+ onPlay: (event: SyntheticEvent<HTMLVideoElement>) => void;
50
+ onTimeUpdate: (event: SyntheticEvent<HTMLVideoElement>) => void;
51
+ onEnded: (event: SyntheticEvent<HTMLVideoElement>) => void;
52
+ }
53
+ /**
54
+ * The `<video>` handlers that report playback to `/api/analytics/collect`.
55
+ *
56
+ * ## What counts as one play
57
+ *
58
+ * The first `play` of a viewing. A browser fires `play` on every resume after
59
+ * a pause, so the event alone would count a visitor who paused twice as three
60
+ * viewers. A viewing ends on `ended` — a replay from the end is a second
61
+ * play — or when {@link VideoPlaybackBeaconOptions.viewingKey} changes.
62
+ *
63
+ * ## Quartiles
64
+ *
65
+ * Each is reported once per viewing, against the furthest point the playhead
66
+ * has REACHED rather than where it last was. `videoQuartileEvent` compares a
67
+ * position with a previous one, so handing it the last position would report
68
+ * the first quartile again for a visitor who rewound past it and watched it
69
+ * twice; the watch curve asks where people stop, and a rewatch is not a
70
+ * second arrival.
71
+ *
72
+ * State lives in a ref, never in React state: every one of these is a
73
+ * Firestore write on a public page, and a counter driven by render would be
74
+ * one write per `timeupdate`, four times a second.
75
+ *
76
+ * `sendVideoAnalyticsBeacon` keeps its own gate — production surfaces only,
77
+ * never a browser carrying the internal-traffic opt-in — so this adds the two
78
+ * refusals only the element can know about: no site, and an editing surface.
79
+ */
80
+ export declare function useVideoPlaybackBeacon(options: VideoPlaybackBeaconOptions): VideoPlaybackHandlers;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { sendVideoAnalyticsBeacon, videoQuartileEvent } from "@aglyn/aglyn/app-utils/analytics-beacon";
17
+ import { MEDIA_CDN_ROUTE, mediaRefFromCdnPath, parseMediaRef } from "@aglyn/aglyn/app-utils/media-ref";
18
+ import { useCallback, useEffect, useRef } from "react";
19
+ /**
20
+ * The DAM media id a Video element's STORED `src` names, or undefined.
21
+ *
22
+ * Plays are counted per asset, so only a value that names one is counted: a
23
+ * `media:` reference (pinned or not), or a root-relative CDN path written
24
+ * before references existed. A hotlink and a raw Storage URL name no asset,
25
+ * and neither does an absolute URL whose path merely looks like the CDN's —
26
+ * that could be anybody's server.
27
+ *
28
+ * The stored value rather than the resolved one, because the resolved URL
29
+ * carries `?r=auto` and a host-qualified scope, neither of which is the asset.
30
+ */ export function playbackMediaId(src) {
31
+ var _parseMediaRef;
32
+ if (typeof src !== 'string' || !src) return undefined;
33
+ // The prefix test comes first and `parseMediaRef` answers everything else:
34
+ // it refuses any value that is not a reference, so a hotlink resolves to
35
+ // nothing without a type predicate narrowing `src` away in the other branch.
36
+ const reference = src.startsWith(`${MEDIA_CDN_ROUTE}/`) ? mediaRefFromCdnPath(src) : src;
37
+ return (_parseMediaRef = parseMediaRef(reference)) == null ? void 0 : _parseMediaRef.mediaId;
38
+ }
39
+ /**
40
+ * The `<video>` handlers that report playback to `/api/analytics/collect`.
41
+ *
42
+ * ## What counts as one play
43
+ *
44
+ * The first `play` of a viewing. A browser fires `play` on every resume after
45
+ * a pause, so the event alone would count a visitor who paused twice as three
46
+ * viewers. A viewing ends on `ended` — a replay from the end is a second
47
+ * play — or when {@link VideoPlaybackBeaconOptions.viewingKey} changes.
48
+ *
49
+ * ## Quartiles
50
+ *
51
+ * Each is reported once per viewing, against the furthest point the playhead
52
+ * has REACHED rather than where it last was. `videoQuartileEvent` compares a
53
+ * position with a previous one, so handing it the last position would report
54
+ * the first quartile again for a visitor who rewound past it and watched it
55
+ * twice; the watch curve asks where people stop, and a rewatch is not a
56
+ * second arrival.
57
+ *
58
+ * State lives in a ref, never in React state: every one of these is a
59
+ * Firestore write on a public page, and a counter driven by render would be
60
+ * one write per `timeupdate`, four times a second.
61
+ *
62
+ * `sendVideoAnalyticsBeacon` keeps its own gate — production surfaces only,
63
+ * never a browser carrying the internal-traffic opt-in — so this adds the two
64
+ * refusals only the element can know about: no site, and an editing surface.
65
+ */ export function useVideoPlaybackBeacon(options) {
66
+ const { hostId, src, suppressed, viewingKey } = options;
67
+ const mediaId = playbackMediaId(src);
68
+ const viewing = useRef({
69
+ played: false,
70
+ reached: 0
71
+ });
72
+ useEffect(()=>{
73
+ viewing.current = {
74
+ played: false,
75
+ reached: 0
76
+ };
77
+ }, [
78
+ viewingKey,
79
+ mediaId
80
+ ]);
81
+ const send = useCallback((event)=>{
82
+ if (suppressed || !hostId || !mediaId) return;
83
+ sendVideoAnalyticsBeacon({
84
+ hostId,
85
+ mediaId,
86
+ event
87
+ });
88
+ }, [
89
+ suppressed,
90
+ hostId,
91
+ mediaId
92
+ ]);
93
+ const onPlay = useCallback(()=>{
94
+ if (viewing.current.played) return;
95
+ viewing.current.played = true;
96
+ send('play');
97
+ }, [
98
+ send
99
+ ]);
100
+ const onTimeUpdate = useCallback((event)=>{
101
+ const { currentTime, duration } = event.currentTarget;
102
+ // `Infinity` for a stream and for some WebM files, `NaN` before
103
+ // metadata: neither has a fraction to report.
104
+ if (!Number.isFinite(duration) || duration <= 0) return;
105
+ const fraction = currentTime / duration;
106
+ const crossed = videoQuartileEvent(fraction, viewing.current.reached);
107
+ if (fraction > viewing.current.reached) viewing.current.reached = fraction;
108
+ if (crossed) send(crossed);
109
+ }, [
110
+ send
111
+ ]);
112
+ const onEnded = useCallback(()=>{
113
+ send('complete');
114
+ viewing.current = {
115
+ played: false,
116
+ reached: 0
117
+ };
118
+ }, [
119
+ send
120
+ ]);
121
+ return {
122
+ onPlay,
123
+ onTimeUpdate,
124
+ onEnded
125
+ };
126
+ }
127
+
128
+ //# sourceMappingURL=video-playback-beacon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/mui/src/lib/components/video-playback-beacon.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n sendVideoAnalyticsBeacon,\n type VideoAnalyticsEvent,\n videoQuartileEvent,\n} from '@aglyn/aglyn/app-utils/analytics-beacon'\nimport {\n isMediaRef,\n MEDIA_CDN_ROUTE,\n mediaRefFromCdnPath,\n parseMediaRef,\n} from '@aglyn/aglyn/app-utils/media-ref'\nimport { type SyntheticEvent, useCallback, useEffect, useRef } from 'react'\n\n/**\n * The DAM media id a Video element's STORED `src` names, or undefined.\n *\n * Plays are counted per asset, so only a value that names one is counted: a\n * `media:` reference (pinned or not), or a root-relative CDN path written\n * before references existed. A hotlink and a raw Storage URL name no asset,\n * and neither does an absolute URL whose path merely looks like the CDN's —\n * that could be anybody's server.\n *\n * The stored value rather than the resolved one, because the resolved URL\n * carries `?r=auto` and a host-qualified scope, neither of which is the asset.\n */\nexport function playbackMediaId(src: unknown): string | undefined {\n if (typeof src !== 'string' || !src) return undefined\n // The prefix test comes first and `parseMediaRef` answers everything else:\n // it refuses any value that is not a reference, so a hotlink resolves to\n // nothing without a type predicate narrowing `src` away in the other branch.\n const reference = src.startsWith(`${MEDIA_CDN_ROUTE}/`)\n ? mediaRefFromCdnPath(src)\n : src\n return parseMediaRef(reference)?.mediaId\n}\n\nexport interface VideoPlaybackBeaconOptions {\n /** The site the film plays on. Nothing is counted without one. */\n hostId?: string\n /** The element's stored `src` — see {@link playbackMediaId}. */\n src?: unknown\n /**\n * An editing surface: the besigner canvas or the console preview. An author\n * watching their own draft is not a visitor's play.\n */\n suppressed?: boolean\n /**\n * A new viewing starts whenever this changes. The lightbox passes `open`,\n * so each open that is played counts once, while pausing and resuming\n * inside one open does not.\n */\n viewingKey?: unknown\n}\n\nexport interface VideoPlaybackHandlers {\n onPlay: (event: SyntheticEvent<HTMLVideoElement>) => void\n onTimeUpdate: (event: SyntheticEvent<HTMLVideoElement>) => void\n onEnded: (event: SyntheticEvent<HTMLVideoElement>) => void\n}\n\n/**\n * The `<video>` handlers that report playback to `/api/analytics/collect`.\n *\n * ## What counts as one play\n *\n * The first `play` of a viewing. A browser fires `play` on every resume after\n * a pause, so the event alone would count a visitor who paused twice as three\n * viewers. A viewing ends on `ended` — a replay from the end is a second\n * play — or when {@link VideoPlaybackBeaconOptions.viewingKey} changes.\n *\n * ## Quartiles\n *\n * Each is reported once per viewing, against the furthest point the playhead\n * has REACHED rather than where it last was. `videoQuartileEvent` compares a\n * position with a previous one, so handing it the last position would report\n * the first quartile again for a visitor who rewound past it and watched it\n * twice; the watch curve asks where people stop, and a rewatch is not a\n * second arrival.\n *\n * State lives in a ref, never in React state: every one of these is a\n * Firestore write on a public page, and a counter driven by render would be\n * one write per `timeupdate`, four times a second.\n *\n * `sendVideoAnalyticsBeacon` keeps its own gate — production surfaces only,\n * never a browser carrying the internal-traffic opt-in — so this adds the two\n * refusals only the element can know about: no site, and an editing surface.\n */\nexport function useVideoPlaybackBeacon(\n options: VideoPlaybackBeaconOptions,\n): VideoPlaybackHandlers {\n const { hostId, src, suppressed, viewingKey } = options\n const mediaId = playbackMediaId(src)\n const viewing = useRef({ played: false, reached: 0 })\n useEffect(() => {\n viewing.current = { played: false, reached: 0 }\n }, [viewingKey, mediaId])\n\n const send = useCallback(\n (event: VideoAnalyticsEvent) => {\n if (suppressed || !hostId || !mediaId) return\n sendVideoAnalyticsBeacon({ hostId, mediaId, event })\n },\n [suppressed, hostId, mediaId],\n )\n\n const onPlay = useCallback(() => {\n if (viewing.current.played) return\n viewing.current.played = true\n send('play')\n }, [send])\n\n const onTimeUpdate = useCallback(\n (event: SyntheticEvent<HTMLVideoElement>) => {\n const { currentTime, duration } = event.currentTarget\n // `Infinity` for a stream and for some WebM files, `NaN` before\n // metadata: neither has a fraction to report.\n if (!Number.isFinite(duration) || duration <= 0) return\n const fraction = currentTime / duration\n const crossed = videoQuartileEvent(fraction, viewing.current.reached)\n if (fraction > viewing.current.reached) viewing.current.reached = fraction\n if (crossed) send(crossed)\n },\n [send],\n )\n\n const onEnded = useCallback(() => {\n send('complete')\n viewing.current = { played: false, reached: 0 }\n }, [send])\n\n return { onPlay, onTimeUpdate, onEnded }\n}\n"],"names":["sendVideoAnalyticsBeacon","videoQuartileEvent","MEDIA_CDN_ROUTE","mediaRefFromCdnPath","parseMediaRef","useCallback","useEffect","useRef","playbackMediaId","src","undefined","reference","startsWith","mediaId","useVideoPlaybackBeacon","options","hostId","suppressed","viewingKey","viewing","played","reached","current","send","event","onPlay","onTimeUpdate","currentTime","duration","currentTarget","Number","isFinite","fraction","crossed","onEnded"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EAExBC,kBAAkB,QACb,0CAAyC;AAChD,SAEEC,eAAe,EACfC,mBAAmB,EACnBC,aAAa,QACR,mCAAkC;AACzC,SAA8BC,WAAW,EAAEC,SAAS,EAAEC,MAAM,QAAQ,QAAO;AAE3E;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,gBAAgBC,GAAY;QAQnCL;IAPP,IAAI,OAAOK,QAAQ,YAAY,CAACA,KAAK,OAAOC;IAC5C,2EAA2E;IAC3E,yEAAyE;IACzE,6EAA6E;IAC7E,MAAMC,YAAYF,IAAIG,UAAU,CAAC,GAAGV,gBAAgB,CAAC,CAAC,IAClDC,oBAAoBM,OACpBA;IACJ,QAAOL,iBAAAA,cAAcO,+BAAdP,eAA0BS,OAAO;AAC1C;AA0BA;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASC,uBACdC,OAAmC;IAEnC,MAAM,EAAEC,MAAM,EAAEP,GAAG,EAAEQ,UAAU,EAAEC,UAAU,EAAE,GAAGH;IAChD,MAAMF,UAAUL,gBAAgBC;IAChC,MAAMU,UAAUZ,OAAO;QAAEa,QAAQ;QAAOC,SAAS;IAAE;IACnDf,UAAU;QACRa,QAAQG,OAAO,GAAG;YAAEF,QAAQ;YAAOC,SAAS;QAAE;IAChD,GAAG;QAACH;QAAYL;KAAQ;IAExB,MAAMU,OAAOlB,YACX,CAACmB;QACC,IAAIP,cAAc,CAACD,UAAU,CAACH,SAAS;QACvCb,yBAAyB;YAAEgB;YAAQH;YAASW;QAAM;IACpD,GACA;QAACP;QAAYD;QAAQH;KAAQ;IAG/B,MAAMY,SAASpB,YAAY;QACzB,IAAIc,QAAQG,OAAO,CAACF,MAAM,EAAE;QAC5BD,QAAQG,OAAO,CAACF,MAAM,GAAG;QACzBG,KAAK;IACP,GAAG;QAACA;KAAK;IAET,MAAMG,eAAerB,YACnB,CAACmB;QACC,MAAM,EAAEG,WAAW,EAAEC,QAAQ,EAAE,GAAGJ,MAAMK,aAAa;QACrD,gEAAgE;QAChE,8CAA8C;QAC9C,IAAI,CAACC,OAAOC,QAAQ,CAACH,aAAaA,YAAY,GAAG;QACjD,MAAMI,WAAWL,cAAcC;QAC/B,MAAMK,UAAUhC,mBAAmB+B,UAAUb,QAAQG,OAAO,CAACD,OAAO;QACpE,IAAIW,WAAWb,QAAQG,OAAO,CAACD,OAAO,EAAEF,QAAQG,OAAO,CAACD,OAAO,GAAGW;QAClE,IAAIC,SAASV,KAAKU;IACpB,GACA;QAACV;KAAK;IAGR,MAAMW,UAAU7B,YAAY;QAC1BkB,KAAK;QACLJ,QAAQG,OAAO,GAAG;YAAEF,QAAQ;YAAOC,SAAS;QAAE;IAChD,GAAG;QAACE;KAAK;IAET,OAAO;QAAEE;QAAQC;QAAcQ;IAAQ;AACzC"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CSSProperties } from 'react';
18
+ /**
19
+ * The frame a hosted player plays in (AGL-2826).
20
+ *
21
+ * Shared by the Video element, which puts it where the poster was, and by the
22
+ * lightbox, which opens it in a dialog, so the two cannot disagree about what
23
+ * the frame may do. Nothing renders this before a press, except the player an
24
+ * element loads with the page (AGL-2962), and that only for a visitor whose
25
+ * analytics consent is on record. Otherwise the element's poster is a button
26
+ * until the press, which is what keeps the player's origin off the wire for a
27
+ * visitor who only looks at the page.
28
+ */
29
+ export interface VideoPlayerFrameProps {
30
+ /** The player's address, rebuilt by the caller from a parsed media id. */
31
+ src: string;
32
+ /** Names the frame for assistive technology. */
33
+ title?: string;
34
+ /** `width / height`; 16:9 when nothing measured the film. */
35
+ aspectRatio?: string;
36
+ /** A CSS height the author pinned, which replaces the ratio. */
37
+ height?: string;
38
+ /** Corner radius in px, matching the poster it replaced. */
39
+ radius?: number;
40
+ /**
41
+ * Move focus into the player as it mounts. For the in-place player only,
42
+ * where the button that was pressed has just left the page and focus would
43
+ * otherwise fall back to the body.
44
+ *
45
+ * ⛔ Never inside the lightbox. A cross-origin frame keeps every key pressed
46
+ * while it has focus, so `Escape` never reaches the dialog. Measured on
47
+ * 2026-09-10: with focus moved into the Wistia frame, `Escape` left the
48
+ * dialog open and the film playing. Left on the dialog, `Escape` closes it,
49
+ * and `Tab` still reaches the player.
50
+ */
51
+ focusOnMount?: boolean;
52
+ /** Layout the placement adds, such as the lightbox's flex, applied last. */
53
+ style?: CSSProperties;
54
+ }
55
+ export declare function VideoPlayerFrame(props: VideoPlayerFrameProps): import("react").JSX.Element;
56
+ export declare namespace VideoPlayerFrame {
57
+ var displayName: string;
58
+ }