From 065730805a0f2772b15f452ec413ffb74a33577e Mon Sep 17 00:00:00 2001 From: Iacopo Leardini Date: Fri, 28 Jun 2024 19:05:16 +0200 Subject: [PATCH] feat: Add Picture-in-Picture functionality to Player component --- src/stories/player/_types.tsx | 3 +- .../player/hooks/usePictureInPicture.ts | 93 +++++++++++++++++++ src/stories/player/index.stories.tsx | 57 +++++++++++- src/stories/player/index.tsx | 5 +- 4 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/stories/player/hooks/usePictureInPicture.ts diff --git a/src/stories/player/_types.tsx b/src/stories/player/_types.tsx index af86580d..d27e45dc 100644 --- a/src/stories/player/_types.tsx +++ b/src/stories/player/_types.tsx @@ -4,7 +4,8 @@ export interface PlayerArgs extends HTMLAttributes { url: string; start?: number; end?: number; - enablePipOnScroll?: boolean; + pipMode?: "auto" | (() => boolean) | boolean; + onPipChange?: (isPip: boolean) => void; onCutHandler?: (time: number) => void; isCutting?: boolean; bookmarks?: IBookmark[]; diff --git a/src/stories/player/hooks/usePictureInPicture.ts b/src/stories/player/hooks/usePictureInPicture.ts new file mode 100644 index 00000000..1ceb769b --- /dev/null +++ b/src/stories/player/hooks/usePictureInPicture.ts @@ -0,0 +1,93 @@ +import { useEffect } from "react"; +import { PlayerArgs } from "../_types"; + +type PictureInPictureHook = ( + videoRef?: HTMLVideoElement | null, + pipMode?: PlayerArgs["pipMode"], + onPipChange?: PlayerArgs["onPipChange"] +) => void; + +export const usePictureInPicture: PictureInPictureHook = (videoRef, pipMode, onPipChange) => { + + const getObserver = (videoRef: HTMLVideoElement, isVideoPlaying: () => boolean) => { + return new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting && isVideoPlaying()) { + videoRef.requestPictureInPicture(); + } + if ( + document.pictureInPictureElement && + entry.isIntersecting && + !isVideoPlaying() + ) { + document.exitPictureInPicture(); + } + }); + }, + { threshold: 0.5 } + ); + }; + + const handleManualPipMode = (videoRef: HTMLVideoElement, pipMode: boolean) => { + if (pipMode) { + videoRef.requestPictureInPicture(); + } + if (!pipMode && document.pictureInPictureElement) { + document.exitPictureInPicture(); + } + } + + useEffect(() => { + // bail out if pipMode is not defined or videoRef is not defined or pip is not supported + if (typeof pipMode === "undefined") + return; + if (!document.pictureInPictureEnabled) { + console.warn("Picture-in-Picture is not supported in this browser"); + return; + } + if (!videoRef) + return; + + // if pipMode is auto, we will enter picture in picture mode when the video is not in view + if (pipMode === "auto") { + const isVideoPlaying = () => + videoRef.currentTime > 0 && + !videoRef.paused && + !videoRef.ended && + videoRef.readyState > 2; + + const observer = getObserver(videoRef, isVideoPlaying); + observer.observe(videoRef); + + return () => { + observer.disconnect(); + }; + } else if (typeof pipMode === "boolean") { + handleManualPipMode(videoRef, pipMode); + } else { + handleManualPipMode(videoRef, pipMode()); + } + }, [pipMode, videoRef]); + + useEffect(() => { + if (!document.pictureInPictureEnabled) { + return; + } + document.addEventListener("enterpictureinpicture", () => { + onPipChange?.(true); + }); + document.addEventListener("leavepictureinpicture", () => { + onPipChange?.(false); + }); + + return () => { + document.removeEventListener("enterpictureinpicture", () => { + onPipChange?.(true); + }); + document.removeEventListener("leavepictureinpicture", () => { + onPipChange?.(false); + }); + }; + }, [onPipChange]); +} \ No newline at end of file diff --git a/src/stories/player/index.stories.tsx b/src/stories/player/index.stories.tsx index 29ced1ae..87b2b96a 100644 --- a/src/stories/player/index.stories.tsx +++ b/src/stories/player/index.stories.tsx @@ -5,12 +5,13 @@ import { Player, PlayerProvider } from "."; import { IBookmark, PlayerArgs } from "./_types"; import { theme } from "../theme"; import { Tag } from "@zendeskgarden/react-tags"; +import { Button } from "../buttons/button"; const Container = styled.div` height: 80vh; `; -interface PlayerStoryArgs extends PlayerArgs {} +interface PlayerStoryArgs extends PlayerArgs { } const defaultArgs: PlayerStoryArgs = { url: "https://s3.eu-west-1.amazonaws.com/appq.static/demo/098648899205a00f8311d929d3073499ef9d664b_1715352138.mp4", @@ -81,11 +82,65 @@ const TemplateWithCutter: StoryFn = ({ ); }; +const TemplateWithParagraphs: StoryFn = (args) => ( + + + {Array.from({ length: 10 }).map((_, index) => ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate + velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt + mollit anim id est laborum. +

+ ))} +
+); + +const TemplateWithButtonForPip: StoryFn = (args) => { + const [isPip, setIsPip] = useState(false); + const handlePipChange = useCallback((isPipFromPlayer: boolean) => { + setIsPip(isPipFromPlayer); + }, [setIsPip]); + return ( + + + + {Array.from({ length: 10 }).map((_, index) => ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate + velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint + occaecat cupidatat non proident, sunt in culpa qui officia deserunt + mollit anim id est laborum. +

+ ))} +
+ ) +}; + export const Basic = Template.bind({}); Basic.args = { ...defaultArgs, }; +export const AutoPip = TemplateWithParagraphs.bind({}); +AutoPip.args = { + ...defaultArgs, + pipMode: "auto", +}; + +export const ButtonPip = TemplateWithButtonForPip.bind({}); +ButtonPip.args = { + ...defaultArgs +}; + export const Streaming = Template.bind({}); Streaming.args = { ...defaultArgs, diff --git a/src/stories/player/index.tsx b/src/stories/player/index.tsx index c12b955b..ed0c5042 100644 --- a/src/stories/player/index.tsx +++ b/src/stories/player/index.tsx @@ -12,6 +12,7 @@ import { Controls } from "./parts/controls"; import { FloatingControls } from "./parts/floatingControls"; import { VideoSpinner } from "./parts/spinner"; import { ProgressContextProvider } from "./context/progressContext"; +import { usePictureInPicture } from "./hooks/usePictureInPicture"; /** * The Player is a styled media tag with custom controls @@ -28,7 +29,7 @@ const Player = forwardRef((props, forwardRef) => ( const PlayerCore = forwardRef( (props, forwardRef) => { const { context, togglePlay, setIsPlaying } = useVideoContext(); - const { onCutHandler, bookmarks, isCutting } = props; + const { onCutHandler, bookmarks, isCutting, pipMode, onPipChange } = props; const videoRef = context.player?.ref.current; const isLoaded = !!videoRef; const containerRef = useRef(null); @@ -37,6 +38,8 @@ const PlayerCore = forwardRef( videoRef, ]); + usePictureInPicture(videoRef, pipMode, onPipChange); + useEffect(() => { if (videoRef) { videoRef.addEventListener("pause", () => {