@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.
- package/LICENSE +201 -0
- package/README.md +22 -0
- package/package.json +47 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +22 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/accordion.d.ts +96 -0
- package/src/lib/components/accordion.js +392 -0
- package/src/lib/components/accordion.js.map +1 -0
- package/src/lib/components/app-bar.d.ts +32 -0
- package/src/lib/components/app-bar.js +86 -0
- package/src/lib/components/app-bar.js.map +1 -0
- package/src/lib/components/blocks.d.ts +46 -0
- package/src/lib/components/blocks.js +1007 -0
- package/src/lib/components/blocks.js.map +1 -0
- package/src/lib/components/box.d.ts +47 -0
- package/src/lib/components/box.js +125 -0
- package/src/lib/components/box.js.map +1 -0
- package/src/lib/components/breadcrumbs.d.ts +44 -0
- package/src/lib/components/breadcrumbs.js +172 -0
- package/src/lib/components/breadcrumbs.js.map +1 -0
- package/src/lib/components/button.d.ts +89 -0
- package/src/lib/components/button.js +272 -0
- package/src/lib/components/button.js.map +1 -0
- package/src/lib/components/card.d.ts +61 -0
- package/src/lib/components/card.js +336 -0
- package/src/lib/components/card.js.map +1 -0
- package/src/lib/components/collection.d.ts +514 -0
- package/src/lib/components/collection.js +2999 -0
- package/src/lib/components/collection.js.map +1 -0
- package/src/lib/components/container.d.ts +42 -0
- package/src/lib/components/container.js +169 -0
- package/src/lib/components/container.js.map +1 -0
- package/src/lib/components/custom-html.d.ts +78 -0
- package/src/lib/components/custom-html.js +219 -0
- package/src/lib/components/custom-html.js.map +1 -0
- package/src/lib/components/data-table.d.ts +57 -0
- package/src/lib/components/data-table.js +216 -0
- package/src/lib/components/data-table.js.map +1 -0
- package/src/lib/components/document-root.d.ts +77 -0
- package/src/lib/components/document-root.js +118 -0
- package/src/lib/components/document-root.js.map +1 -0
- package/src/lib/components/drawer.d.ts +93 -0
- package/src/lib/components/drawer.js +566 -0
- package/src/lib/components/drawer.js.map +1 -0
- package/src/lib/components/function-controls.d.ts +66 -0
- package/src/lib/components/function-controls.js +207 -0
- package/src/lib/components/function-controls.js.map +1 -0
- package/src/lib/components/function-scope.d.ts +110 -0
- package/src/lib/components/function-scope.js +478 -0
- package/src/lib/components/function-scope.js.map +1 -0
- package/src/lib/components/function-widget.d.ts +75 -0
- package/src/lib/components/function-widget.js +307 -0
- package/src/lib/components/function-widget.js.map +1 -0
- package/src/lib/components/grid.d.ts +91 -0
- package/src/lib/components/grid.js +322 -0
- package/src/lib/components/grid.js.map +1 -0
- package/src/lib/components/icon.d.ts +54 -0
- package/src/lib/components/icon.js +163 -0
- package/src/lib/components/icon.js.map +1 -0
- package/src/lib/components/image-list.d.ts +58 -0
- package/src/lib/components/image-list.js +336 -0
- package/src/lib/components/image-list.js.map +1 -0
- package/src/lib/components/image.d.ts +154 -0
- package/src/lib/components/image.js +451 -0
- package/src/lib/components/image.js.map +1 -0
- package/src/lib/components/inline-text.d.ts +74 -0
- package/src/lib/components/inline-text.js +407 -0
- package/src/lib/components/inline-text.js.map +1 -0
- package/src/lib/components/language-switcher.d.ts +35 -0
- package/src/lib/components/language-switcher.js +179 -0
- package/src/lib/components/language-switcher.js.map +1 -0
- package/src/lib/components/layout-slot.d.ts +68 -0
- package/src/lib/components/layout-slot.js +192 -0
- package/src/lib/components/layout-slot.js.map +1 -0
- package/src/lib/components/link-box-accessible-name.d.ts +79 -0
- package/src/lib/components/link-box-accessible-name.js +158 -0
- package/src/lib/components/link-box-accessible-name.js.map +1 -0
- package/src/lib/components/link-box.d.ts +66 -0
- package/src/lib/components/link-box.js +212 -0
- package/src/lib/components/link-box.js.map +1 -0
- package/src/lib/components/list-item-text.d.ts +22 -0
- package/src/lib/components/list-item-text.js +86 -0
- package/src/lib/components/list-item-text.js.map +1 -0
- package/src/lib/components/list-item.d.ts +32 -0
- package/src/lib/components/list-item.js +127 -0
- package/src/lib/components/list-item.js.map +1 -0
- package/src/lib/components/list.d.ts +34 -0
- package/src/lib/components/list.js +108 -0
- package/src/lib/components/list.js.map +1 -0
- package/src/lib/components/markdown.d.ts +129 -0
- package/src/lib/components/markdown.js +853 -0
- package/src/lib/components/markdown.js.map +1 -0
- package/src/lib/components/nav-menu.d.ts +68 -0
- package/src/lib/components/nav-menu.js +683 -0
- package/src/lib/components/nav-menu.js.map +1 -0
- package/src/lib/components/pagination.d.ts +59 -0
- package/src/lib/components/pagination.js +243 -0
- package/src/lib/components/pagination.js.map +1 -0
- package/src/lib/components/paper.d.ts +47 -0
- package/src/lib/components/paper.js +202 -0
- package/src/lib/components/paper.js.map +1 -0
- package/src/lib/components/plugin-frame.d.ts +64 -0
- package/src/lib/components/plugin-frame.js +335 -0
- package/src/lib/components/plugin-frame.js.map +1 -0
- package/src/lib/components/plugin.d.ts +95 -0
- package/src/lib/components/plugin.js +304 -0
- package/src/lib/components/plugin.js.map +1 -0
- package/src/lib/components/product.d.ts +40 -0
- package/src/lib/components/product.js +287 -0
- package/src/lib/components/product.js.map +1 -0
- package/src/lib/components/reusable-instance.d.ts +57 -0
- package/src/lib/components/reusable-instance.js +126 -0
- package/src/lib/components/reusable-instance.js.map +1 -0
- package/src/lib/components/screen-link.d.ts +85 -0
- package/src/lib/components/screen-link.js +324 -0
- package/src/lib/components/screen-link.js.map +1 -0
- package/src/lib/components/search-box.d.ts +41 -0
- package/src/lib/components/search-box.js +125 -0
- package/src/lib/components/search-box.js.map +1 -0
- package/src/lib/components/section.d.ts +56 -0
- package/src/lib/components/section.js +132 -0
- package/src/lib/components/section.js.map +1 -0
- package/src/lib/components/stack.d.ts +55 -0
- package/src/lib/components/stack.js +309 -0
- package/src/lib/components/stack.js.map +1 -0
- package/src/lib/components/tabs.d.ts +288 -0
- package/src/lib/components/tabs.js +788 -0
- package/src/lib/components/tabs.js.map +1 -0
- package/src/lib/components/theme-mode-switcher.d.ts +38 -0
- package/src/lib/components/theme-mode-switcher.js +168 -0
- package/src/lib/components/theme-mode-switcher.js.map +1 -0
- package/src/lib/components/toolbar.d.ts +37 -0
- package/src/lib/components/toolbar.js +117 -0
- package/src/lib/components/toolbar.js.map +1 -0
- package/src/lib/components/typography.d.ts +33 -0
- package/src/lib/components/typography.js +433 -0
- package/src/lib/components/typography.js.map +1 -0
- package/src/lib/components/video-lightbox-controls.d.ts +131 -0
- package/src/lib/components/video-lightbox-controls.js +536 -0
- package/src/lib/components/video-lightbox-controls.js.map +1 -0
- package/src/lib/components/video-lightbox.d.ts +60 -0
- package/src/lib/components/video-lightbox.js +247 -0
- package/src/lib/components/video-lightbox.js.map +1 -0
- package/src/lib/components/video-playback-beacon.d.ts +80 -0
- package/src/lib/components/video-playback-beacon.js +128 -0
- package/src/lib/components/video-playback-beacon.js.map +1 -0
- package/src/lib/components/video-player-frame.d.ts +58 -0
- package/src/lib/components/video-player-frame.js +51 -0
- package/src/lib/components/video-player-frame.js.map +1 -0
- package/src/lib/components/video.d.ts +220 -0
- package/src/lib/components/video.js +874 -0
- package/src/lib/components/video.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +17 -0
- package/src/lib/constants/bundle-common.js +18 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/field-presets.d.ts +93 -0
- package/src/lib/constants/field-presets.js +248 -0
- package/src/lib/constants/field-presets.js.map +1 -0
- package/src/lib/plugin.d.ts +80 -0
- package/src/lib/plugin.js +599 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/utils/author-html-label.d.ts +73 -0
- package/src/lib/utils/author-html-label.js +89 -0
- package/src/lib/utils/author-html-label.js.map +1 -0
- package/src/lib/utils/drop-cleared-props.d.ts +18 -0
- package/src/lib/utils/drop-cleared-props.js +74 -0
- package/src/lib/utils/drop-cleared-props.js.map +1 -0
- package/src/lib/utils/element-picker.d.ts +99 -0
- package/src/lib/utils/element-picker.js +140 -0
- package/src/lib/utils/element-picker.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +19 -0
- package/src/lib/utils/generate-preset-id.js +25 -0
- package/src/lib/utils/generate-preset-id.js.map +1 -0
- package/src/lib/utils/link-target-props.d.ts +53 -0
- package/src/lib/utils/link-target-props.js +53 -0
- 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
|
+
}
|