@windstream/react-shared-components 0.2.50 → 0.2.51
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/dist/contentful/index.d.ts +2 -1
- package/dist/contentful/index.esm.js +3 -3
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +3 -3
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +2 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +6 -6
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/next/index.esm.js +2 -2
- package/dist/next/index.esm.js.map +1 -1
- package/dist/next/index.js +2 -2
- package/dist/next/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +2 -1
- package/src/components/next-image/index.tsx +10 -1
- package/src/components/video-link/contentful-video-embed.tsx +17 -1
- package/src/components/video-link/index.test.tsx +196 -24
- package/src/components/video-link/index.tsx +84 -15
- package/src/components/video-link/types.ts +15 -1
- package/src/components/video-link/vimeo-embed.tsx +51 -2
- package/src/components/video-link/youtube-embed.test.tsx +81 -54
- package/src/components/video-link/youtube-embed.tsx +97 -16
- package/src/components/video-link/youtube-iframe-api-loader.ts +45 -0
- package/src/contentful/blocks/image-promo-bar/index.test.tsx +2 -2
- package/src/contentful/blocks/image-promo-bar/index.tsx +2 -0
- package/src/contentful/blocks/image-promo-bar/types.ts +5 -1
- package/src/hooks/use-fullscreen-toggle.ts +23 -0
- package/src/hooks/use-video-analytics.ts +126 -0
- package/src/types/vimeo-player.d.ts +11 -0
|
@@ -1,78 +1,114 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { YoutubeEmbed } from "./youtube-embed";
|
|
2
|
+
import { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
|
|
3
3
|
|
|
4
|
-
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
5
5
|
|
|
6
6
|
jest.mock("@shared/utils", () => ({
|
|
7
7
|
cx: (...args: any[]) => args.filter(Boolean).join(" "),
|
|
8
8
|
}));
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
const mockPlayVideo = jest.fn();
|
|
11
|
+
let capturedEvents: any;
|
|
12
|
+
let capturedVideoId: string | undefined;
|
|
13
|
+
let capturedPlayerVars: any;
|
|
14
|
+
|
|
15
|
+
jest.mock("./youtube-iframe-api-loader", () => ({
|
|
16
|
+
loadYoutubeIframeApi: () =>
|
|
17
|
+
Promise.resolve({
|
|
18
|
+
PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 },
|
|
19
|
+
Player: jest.fn().mockImplementation((_el, options) => {
|
|
20
|
+
capturedEvents = options.events;
|
|
21
|
+
capturedVideoId = options.videoId;
|
|
22
|
+
capturedPlayerVars = options.playerVars;
|
|
23
|
+
return { playVideo: mockPlayVideo, destroy: jest.fn() };
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("getYoutubeEmbedId", () => {
|
|
29
|
+
it("extracts id from watch URL", () => {
|
|
30
|
+
expect(
|
|
31
|
+
getYoutubeEmbedId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
|
|
32
|
+
).toBe("dQw4w9WgXcQ");
|
|
17
33
|
});
|
|
18
34
|
|
|
19
35
|
it("extracts id from youtu.be short URL", () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"/embed/dQw4w9WgXcQ"
|
|
36
|
+
expect(getYoutubeEmbedId("https://youtu.be/dQw4w9WgXcQ")).toBe(
|
|
37
|
+
"dQw4w9WgXcQ"
|
|
23
38
|
);
|
|
24
39
|
});
|
|
25
40
|
|
|
26
41
|
it("extracts id from embed URL", () => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"/embed/dQw4w9WgXcQ"
|
|
42
|
+
expect(getYoutubeEmbedId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(
|
|
43
|
+
"dQw4w9WgXcQ"
|
|
30
44
|
);
|
|
31
45
|
});
|
|
32
46
|
|
|
33
|
-
it("returns empty
|
|
34
|
-
|
|
35
|
-
expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
|
|
36
|
-
"https://www.youtube.com/embed/"
|
|
37
|
-
);
|
|
47
|
+
it("returns empty string for invalid URL", () => {
|
|
48
|
+
expect(getYoutubeEmbedId("https://example.com/notavideo")).toBe("");
|
|
38
49
|
});
|
|
39
50
|
|
|
40
|
-
it("returns empty
|
|
41
|
-
|
|
42
|
-
expect(
|
|
43
|
-
"https://www.youtube.com/embed/"
|
|
44
|
-
);
|
|
51
|
+
it("returns empty string when link is empty or undefined", () => {
|
|
52
|
+
expect(getYoutubeEmbedId("")).toBe("");
|
|
53
|
+
expect(getYoutubeEmbedId(undefined)).toBe("");
|
|
45
54
|
});
|
|
46
55
|
|
|
47
|
-
it("returns empty
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
it("returns empty string when match length is not 11", () => {
|
|
57
|
+
expect(getYoutubeEmbedId("https://www.youtube.com/watch?v=short")).toBe("");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("YoutubeEmbed", () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
mockPlayVideo.mockClear();
|
|
64
|
+
capturedEvents = undefined;
|
|
65
|
+
capturedVideoId = undefined;
|
|
66
|
+
capturedPlayerVars = undefined;
|
|
52
67
|
});
|
|
53
68
|
|
|
54
|
-
it("
|
|
69
|
+
it("renders a placeholder with an accessible title", () => {
|
|
70
|
+
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
|
|
71
|
+
expect(screen.getByTitle("Embedded youtube")).toBeInTheDocument();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("constructs the player with the extracted videoId and enablejsapi, autoplay/mute off by default", async () => {
|
|
75
|
+
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
|
|
76
|
+
await waitFor(() => expect(capturedVideoId).toBeDefined());
|
|
77
|
+
expect(capturedVideoId).toBe("dQw4w9WgXcQ");
|
|
78
|
+
expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 0, mute: 0 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("passes autoplay and mute declaratively via playerVars when autoplay is true", async () => {
|
|
55
82
|
render(
|
|
56
83
|
<YoutubeEmbed
|
|
57
84
|
link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
58
85
|
autoplay={true}
|
|
59
86
|
/>
|
|
60
87
|
);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
);
|
|
88
|
+
await waitFor(() => expect(capturedPlayerVars).toBeDefined());
|
|
89
|
+
expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 1, mute: 1 });
|
|
64
90
|
});
|
|
65
91
|
|
|
66
|
-
it("
|
|
92
|
+
it("reports play/pause/ended via onStateChange", async () => {
|
|
93
|
+
const onPlay = jest.fn();
|
|
94
|
+
const onPause = jest.fn();
|
|
95
|
+
const onEnded = jest.fn();
|
|
67
96
|
render(
|
|
68
97
|
<YoutubeEmbed
|
|
69
98
|
link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
70
|
-
|
|
99
|
+
onPlay={onPlay}
|
|
100
|
+
onPause={onPause}
|
|
101
|
+
onEnded={onEnded}
|
|
71
102
|
/>
|
|
72
103
|
);
|
|
73
|
-
expect(
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
await waitFor(() => expect(capturedEvents).toBeDefined());
|
|
105
|
+
|
|
106
|
+
capturedEvents.onStateChange({ data: 1 });
|
|
107
|
+
expect(onPlay).toHaveBeenCalled();
|
|
108
|
+
capturedEvents.onStateChange({ data: 2 });
|
|
109
|
+
expect(onPause).toHaveBeenCalled();
|
|
110
|
+
capturedEvents.onStateChange({ data: 0 });
|
|
111
|
+
expect(onEnded).toHaveBeenCalled();
|
|
76
112
|
});
|
|
77
113
|
|
|
78
114
|
it("applies containerClassName", () => {
|
|
@@ -85,20 +121,11 @@ describe("YoutubeEmbed", () => {
|
|
|
85
121
|
expect(container.firstElementChild?.className).toContain("extra");
|
|
86
122
|
});
|
|
87
123
|
|
|
88
|
-
it("
|
|
89
|
-
render(<YoutubeEmbed link="https://
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
expect(iframe).toHaveAttribute("allowFullScreen");
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("returns empty embedId when match length is not 11", () => {
|
|
99
|
-
render(<YoutubeEmbed link="https://www.youtube.com/watch?v=short" />);
|
|
100
|
-
expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
|
|
101
|
-
"https://www.youtube.com/embed/"
|
|
102
|
-
);
|
|
124
|
+
it("does not attempt to load the API when embedId is empty", async () => {
|
|
125
|
+
render(<YoutubeEmbed link="https://example.com/notavideo" />);
|
|
126
|
+
await new Promise(resolve => {
|
|
127
|
+
setTimeout(resolve, 0);
|
|
128
|
+
});
|
|
129
|
+
expect(capturedVideoId).toBeUndefined();
|
|
103
130
|
});
|
|
104
131
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import React, { useMemo } from "react";
|
|
1
|
+
import React, { useEffect, useMemo, useRef } from "react";
|
|
2
2
|
import { VideoEmbedProps } from "./types";
|
|
3
|
+
import { loadYoutubeIframeApi } from "./youtube-iframe-api-loader";
|
|
3
4
|
|
|
5
|
+
import { useFullscreenToggle } from "@shared/hooks/use-fullscreen-toggle";
|
|
4
6
|
import { cx } from "@shared/utils";
|
|
5
7
|
|
|
6
8
|
// import styles from "./styles.module.scss";
|
|
@@ -10,22 +12,103 @@ export type Props = {
|
|
|
10
12
|
autoplay?: boolean; // Add the autoplay prop
|
|
11
13
|
} & VideoEmbedProps;
|
|
12
14
|
|
|
15
|
+
export const getYoutubeEmbedId = (link?: string): string => {
|
|
16
|
+
if (!link) return "";
|
|
17
|
+
|
|
18
|
+
const href = link.toString();
|
|
19
|
+
const regExp =
|
|
20
|
+
/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
|
|
21
|
+
const match = href.match(regExp);
|
|
22
|
+
|
|
23
|
+
return match && match[7].length === 11 ? match[7] : "";
|
|
24
|
+
};
|
|
25
|
+
|
|
13
26
|
export const YoutubeEmbed: React.FC<Props> = props => {
|
|
14
|
-
const {
|
|
27
|
+
const {
|
|
28
|
+
link,
|
|
29
|
+
containerClassName,
|
|
30
|
+
autoplay = false,
|
|
31
|
+
onPlay,
|
|
32
|
+
onPause,
|
|
33
|
+
onTimeUpdate,
|
|
34
|
+
onEnded,
|
|
35
|
+
onFullscreenToggle,
|
|
36
|
+
} = props;
|
|
37
|
+
|
|
38
|
+
const embedId = useMemo(() => getYoutubeEmbedId(link), [link]);
|
|
39
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
41
|
+
const progressInterval = useRef<ReturnType<typeof setInterval>>();
|
|
42
|
+
|
|
43
|
+
useFullscreenToggle(iframeRef, onFullscreenToggle);
|
|
15
44
|
|
|
16
|
-
|
|
17
|
-
if (!
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!embedId || !containerRef.current) return undefined;
|
|
47
|
+
let player: any;
|
|
48
|
+
let cancelled = false;
|
|
18
49
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
// The YT.Player-managed iframe is mounted into a plain DOM node created
|
|
51
|
+
// here, outside of React's own render tree/reconciliation - React only
|
|
52
|
+
// ever renders an empty container div for this component, so it never
|
|
53
|
+
// diffs/touches whatever the API puts inside it. Letting React manage
|
|
54
|
+
// the same node the API mutates is a well-known source of onReady/
|
|
55
|
+
// onStateChange silently never firing (a reconciliation pass can revert
|
|
56
|
+
// the API's DOM changes without going through this component's own
|
|
57
|
+
// effect cleanup).
|
|
58
|
+
const mountNode = document.createElement("div");
|
|
59
|
+
mountNode.style.width = "100%";
|
|
60
|
+
mountNode.style.height = "100%";
|
|
61
|
+
containerRef.current.appendChild(mountNode);
|
|
23
62
|
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
loadYoutubeIframeApi().then(YT => {
|
|
64
|
+
if (cancelled) return;
|
|
65
|
+
player = new YT.Player(mountNode, {
|
|
66
|
+
videoId: embedId,
|
|
67
|
+
playerVars: {
|
|
68
|
+
enablejsapi: 1,
|
|
69
|
+
autoplay: autoplay ? 1 : 0,
|
|
70
|
+
mute: autoplay ? 1 : 0,
|
|
71
|
+
},
|
|
72
|
+
events: {
|
|
73
|
+
onReady: () => {
|
|
74
|
+
const iframe = player.getIframe?.();
|
|
75
|
+
if (iframe) {
|
|
76
|
+
iframe.className = "absolute left-0 top-0 h-full w-full";
|
|
77
|
+
iframeRef.current = iframe;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onStateChange: (event: any) => {
|
|
81
|
+
const State = YT.PlayerState;
|
|
82
|
+
if (event.data === State.PLAYING) {
|
|
83
|
+
onPlay?.();
|
|
84
|
+
clearInterval(progressInterval.current);
|
|
85
|
+
progressInterval.current = setInterval(() => {
|
|
86
|
+
const duration = player?.getDuration?.() ?? 0;
|
|
87
|
+
const position = player?.getCurrentTime?.() ?? 0;
|
|
88
|
+
onTimeUpdate?.(position, duration);
|
|
89
|
+
}, 1000);
|
|
90
|
+
} else if (event.data === State.PAUSED) {
|
|
91
|
+
onPause?.();
|
|
92
|
+
clearInterval(progressInterval.current);
|
|
93
|
+
} else if (event.data === State.ENDED) {
|
|
94
|
+
onEnded?.();
|
|
95
|
+
clearInterval(progressInterval.current);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}).catch(() => {
|
|
101
|
+
// ignore YouTube API load failures; component will remain empty
|
|
102
|
+
});
|
|
26
103
|
|
|
27
|
-
|
|
28
|
-
|
|
104
|
+
return () => {
|
|
105
|
+
cancelled = true;
|
|
106
|
+
clearInterval(progressInterval.current);
|
|
107
|
+
player?.destroy?.();
|
|
108
|
+
mountNode.remove();
|
|
109
|
+
};
|
|
110
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
111
|
+
}, [embedId]);
|
|
29
112
|
|
|
30
113
|
return (
|
|
31
114
|
<div
|
|
@@ -34,11 +117,9 @@ export const YoutubeEmbed: React.FC<Props> = props => {
|
|
|
34
117
|
containerClassName
|
|
35
118
|
)}
|
|
36
119
|
>
|
|
37
|
-
<
|
|
120
|
+
<div
|
|
121
|
+
ref={containerRef}
|
|
38
122
|
className="absolute left-0 top-0 h-full w-full"
|
|
39
|
-
src={`https://www.youtube.com/embed/${embedId}${autoplayParam}`}
|
|
40
|
-
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
41
|
-
allowFullScreen={true}
|
|
42
123
|
title="Embedded youtube"
|
|
43
124
|
/>
|
|
44
125
|
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
let apiPromise: Promise<typeof window.YT> | null = null;
|
|
2
|
+
|
|
3
|
+
export function loadYoutubeIframeApi(): Promise<typeof window.YT> {
|
|
4
|
+
if (typeof window === "undefined") {
|
|
5
|
+
return Promise.reject(new Error("YouTube IFrame API requires a browser"));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (window.YT?.Player) {
|
|
9
|
+
return Promise.resolve(window.YT);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (apiPromise) {
|
|
13
|
+
return apiPromise;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
apiPromise = new Promise((resolve, reject) => {
|
|
17
|
+
const previousCallback = window.onYouTubeIframeAPIReady;
|
|
18
|
+
window.onYouTubeIframeAPIReady = () => {
|
|
19
|
+
previousCallback?.();
|
|
20
|
+
resolve(window.YT);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
let script = document.querySelector<HTMLScriptElement>(
|
|
24
|
+
'script[src*="youtube.com/iframe_api"]'
|
|
25
|
+
);
|
|
26
|
+
if (!script) {
|
|
27
|
+
script = document.createElement("script");
|
|
28
|
+
script.src = "https://www.youtube.com/iframe_api";
|
|
29
|
+
script.onerror = () => {
|
|
30
|
+
apiPromise = null;
|
|
31
|
+
reject(new Error("Failed to load YouTube IFrame API"));
|
|
32
|
+
};
|
|
33
|
+
document.head.appendChild(script);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return apiPromise;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
declare global {
|
|
41
|
+
interface Window {
|
|
42
|
+
YT: any;
|
|
43
|
+
onYouTubeIframeAPIReady?: () => void;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -177,14 +177,14 @@ describe("ImagePromoBar", () => {
|
|
|
177
177
|
expect(container.querySelector(".max-w-120")).not.toBeInTheDocument();
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
-
it("applies
|
|
180
|
+
it("applies xl:flex-row-reverse when mediaPosition is true", () => {
|
|
181
181
|
const { container } = render(
|
|
182
182
|
<ImagePromoBar {...defaultProps} mediaPosition={true} />
|
|
183
183
|
);
|
|
184
184
|
expect(container.innerHTML).toContain("xl:flex-row-reverse");
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
-
it("applies
|
|
187
|
+
it("applies xl:flex-row when mediaPosition is false", () => {
|
|
188
188
|
const { container } = render(
|
|
189
189
|
<ImagePromoBar {...defaultProps} mediaPosition={false} />
|
|
190
190
|
);
|
|
@@ -34,6 +34,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
|
|
|
34
34
|
isAboveTheFold = false,
|
|
35
35
|
onModalButtonClick,
|
|
36
36
|
onClick,
|
|
37
|
+
onVideoEvent,
|
|
37
38
|
renderCheckPlans,
|
|
38
39
|
enableAnimation = false,
|
|
39
40
|
}) => {
|
|
@@ -200,6 +201,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
|
|
|
200
201
|
imageHeight={imageHeight}
|
|
201
202
|
useOriginalImageAspectRatio={useOriginalImageAspectRatio}
|
|
202
203
|
isAboveTheFold={isAboveTheFold}
|
|
204
|
+
onVideoEvent={onVideoEvent}
|
|
203
205
|
/>
|
|
204
206
|
</aside>
|
|
205
207
|
</div>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
VideoAnalyticsHandler,
|
|
5
|
+
VideoLinkProps,
|
|
6
|
+
} from "@shared/components/video-link";
|
|
4
7
|
import { CheckPlansProps } from "@shared/types/micro-components";
|
|
5
8
|
|
|
6
9
|
export type ImagePromoBarProps = {
|
|
@@ -27,6 +30,7 @@ export type ImagePromoBarProps = {
|
|
|
27
30
|
maxWidth?: boolean;
|
|
28
31
|
color: "light" | "dark";
|
|
29
32
|
enableAnimation?: boolean;
|
|
33
|
+
onVideoEvent?: VideoAnalyticsHandler;
|
|
30
34
|
onModalButtonClick?: (id?: string) => void;
|
|
31
35
|
onClick?: (event: React.MouseEvent, source: "primary" | "secondary") => void;
|
|
32
36
|
renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export function useFullscreenToggle(
|
|
4
|
+
ref: RefObject<Element>,
|
|
5
|
+
onToggle?: (isFullscreen: boolean) => void
|
|
6
|
+
) {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!onToggle) return;
|
|
9
|
+
|
|
10
|
+
const handleChange = () => {
|
|
11
|
+
const fullscreenElement =
|
|
12
|
+
document.fullscreenElement || (document as any).webkitFullscreenElement;
|
|
13
|
+
onToggle(fullscreenElement === ref.current);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
document.addEventListener("fullscreenchange", handleChange);
|
|
17
|
+
document.addEventListener("webkitfullscreenchange", handleChange);
|
|
18
|
+
return () => {
|
|
19
|
+
document.removeEventListener("fullscreenchange", handleChange);
|
|
20
|
+
document.removeEventListener("webkitfullscreenchange", handleChange);
|
|
21
|
+
};
|
|
22
|
+
}, [onToggle, ref]);
|
|
23
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export type VideoProvider = "youtube" | "vimeo" | "contentful";
|
|
4
|
+
|
|
5
|
+
export type VideoAnalyticsEventName =
|
|
6
|
+
| "played"
|
|
7
|
+
| "paused"
|
|
8
|
+
| "progress"
|
|
9
|
+
| "completed"
|
|
10
|
+
| "fullscreen_toggled"
|
|
11
|
+
| "closed";
|
|
12
|
+
|
|
13
|
+
export type VideoAnalyticsPayload = {
|
|
14
|
+
videoId: string;
|
|
15
|
+
videoTitle?: string;
|
|
16
|
+
provider: VideoProvider;
|
|
17
|
+
position: number;
|
|
18
|
+
duration: number;
|
|
19
|
+
percentComplete: number;
|
|
20
|
+
isFullscreen?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type VideoAnalyticsHandler = (
|
|
24
|
+
event: VideoAnalyticsEventName,
|
|
25
|
+
payload: VideoAnalyticsPayload
|
|
26
|
+
) => void;
|
|
27
|
+
|
|
28
|
+
export type VideoAnalyticsMeta = {
|
|
29
|
+
videoId: string;
|
|
30
|
+
videoTitle?: string;
|
|
31
|
+
provider: VideoProvider;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const PROGRESS_MILESTONES = [25, 50, 75];
|
|
35
|
+
|
|
36
|
+
// Centralizes progress-milestone/completion dedup so each provider embed
|
|
37
|
+
// only has to report raw play/pause/timeupdate/ended/fullscreen callbacks -
|
|
38
|
+
// none of them know about Segment or the unified event schema.
|
|
39
|
+
export function useVideoAnalytics(onVideoEvent?: VideoAnalyticsHandler) {
|
|
40
|
+
const firedMilestones = useRef<Set<number>>(new Set());
|
|
41
|
+
const hasCompleted = useRef(false);
|
|
42
|
+
const lastPosition = useRef(0);
|
|
43
|
+
const lastDuration = useRef(0);
|
|
44
|
+
|
|
45
|
+
const emit = (
|
|
46
|
+
meta: VideoAnalyticsMeta,
|
|
47
|
+
event: VideoAnalyticsEventName,
|
|
48
|
+
extra?: Partial<VideoAnalyticsPayload>
|
|
49
|
+
) => {
|
|
50
|
+
if (!onVideoEvent) return;
|
|
51
|
+
onVideoEvent(event, {
|
|
52
|
+
videoId: meta.videoId,
|
|
53
|
+
videoTitle: meta.videoTitle,
|
|
54
|
+
provider: meta.provider,
|
|
55
|
+
position: lastPosition.current,
|
|
56
|
+
duration: lastDuration.current,
|
|
57
|
+
percentComplete:
|
|
58
|
+
lastDuration.current > 0
|
|
59
|
+
? Math.min(
|
|
60
|
+
100,
|
|
61
|
+
Math.round((lastPosition.current / lastDuration.current) * 100)
|
|
62
|
+
)
|
|
63
|
+
: 0,
|
|
64
|
+
...extra,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const reset = () => {
|
|
69
|
+
firedMilestones.current = new Set();
|
|
70
|
+
hasCompleted.current = false;
|
|
71
|
+
lastPosition.current = 0;
|
|
72
|
+
lastDuration.current = 0;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handlePlay = (meta: VideoAnalyticsMeta) => emit(meta, "played");
|
|
76
|
+
|
|
77
|
+
const handlePause = (meta: VideoAnalyticsMeta) => emit(meta, "paused");
|
|
78
|
+
|
|
79
|
+
const handleFullscreenToggle = (
|
|
80
|
+
meta: VideoAnalyticsMeta,
|
|
81
|
+
isFullscreen: boolean
|
|
82
|
+
) => emit(meta, "fullscreen_toggled", { isFullscreen });
|
|
83
|
+
|
|
84
|
+
const handleClosed = (meta: VideoAnalyticsMeta) => emit(meta, "closed");
|
|
85
|
+
|
|
86
|
+
const handleTimeUpdate = (
|
|
87
|
+
meta: VideoAnalyticsMeta,
|
|
88
|
+
position: number,
|
|
89
|
+
duration: number
|
|
90
|
+
) => {
|
|
91
|
+
const safePosition = Number.isFinite(position) ? position : 0;
|
|
92
|
+
const safeDuration = Number.isFinite(duration) ? duration : 0;
|
|
93
|
+
lastPosition.current = safePosition;
|
|
94
|
+
lastDuration.current = safeDuration;
|
|
95
|
+
if (safeDuration <= 0) return;
|
|
96
|
+
const percent = Math.min(100, (safePosition / safeDuration) * 100);
|
|
97
|
+
PROGRESS_MILESTONES.forEach(milestone => {
|
|
98
|
+
if (percent >= milestone && !firedMilestones.current.has(milestone)) {
|
|
99
|
+
firedMilestones.current.add(milestone);
|
|
100
|
+
emit(meta, "progress");
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (percent >= 99.5 && !hasCompleted.current) {
|
|
105
|
+
hasCompleted.current = true;
|
|
106
|
+
emit(meta, "completed");
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleEnded = (meta: VideoAnalyticsMeta) => {
|
|
111
|
+
if (hasCompleted.current) return;
|
|
112
|
+
hasCompleted.current = true;
|
|
113
|
+
if (lastDuration.current > 0) lastPosition.current = lastDuration.current;
|
|
114
|
+
emit(meta, "completed");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
handlePlay,
|
|
119
|
+
handlePause,
|
|
120
|
+
handleFullscreenToggle,
|
|
121
|
+
handleClosed,
|
|
122
|
+
handleTimeUpdate,
|
|
123
|
+
handleEnded,
|
|
124
|
+
reset,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare module "@vimeo/player" {
|
|
2
|
+
export default class Player {
|
|
3
|
+
constructor(
|
|
4
|
+
element: HTMLIFrameElement | HTMLElement | string,
|
|
5
|
+
options?: any
|
|
6
|
+
);
|
|
7
|
+
on(event: string, callback: (data: any) => void): void;
|
|
8
|
+
off(event: string, callback?: (data: any) => void): void;
|
|
9
|
+
destroy(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
}
|