import { css, sva } from "../../../styled-system/css" ;
import { useActor } from "@xstate/react" ;
import { videoPlayerMachine } from "./machine" ;
} from "../../../styled-system/patterns" ;
import type { ActorOptions, AnyActorLogic } from "xstate" ;
import { useEffect, useRef } from "react" ;
} from "@heroicons/react/24/solid" ;
import { Transition } from "@headlessui/react" ;
import { Slider, Tooltip } from "@ark-ui/react" ;
import { intervalToDuration } from "date-fns" ;
actorOptions : ActorOptions < AnyActorLogic > | undefined ;
export function Demo ({ actorOptions } : Props ) {
const videoContainerRef = useRef < HTMLDivElement | null >( null );
const videoRef = useRef < HTMLVideoElement | null >( null );
const [ snapshot , send ] = useActor (
videoPlayerMachine. provide ({
"Play the video" : () => {
videoRef.current ! . play ();
"Pause the video" : () => {
videoRef.current ! . pause ();
"Set video current time" : ( _ , { seekTo }) => {
videoRef.current ! .currentTime = seekTo;
"Set video muted" : ( _ , { muted }) => {
videoRef.current ! .muted = muted;
"Set video volume" : ( _ , { volume }) => {
videoRef.current ! .volume = volume;
"Set video fullscreen state" : ( _ , { setFullScreen }) => {
const containerRef = videoContainerRef.current ! ;
const videoElement = videoRef.current ! as HTMLVideoElement & {
webkitSetPresentationMode ?: (
mode : "fullscreen" | "inline" | "picture-in-picture"
if ( typeof containerRef.requestFullscreen !== "undefined" ) {
if (setFullScreen === true ) {
containerRef. requestFullscreen ();
document. exitFullscreen ();
typeof videoElement.webkitSetPresentationMode === "function"
* Must use webkit specific functions on iOS to go fullscreen.
if (setFullScreen === true ) {
videoElement. webkitSetPresentationMode ( "fullscreen" );
videoElement. webkitSetPresentationMode ( "inline" );
console. error ( "Can't set fullscreen state" );
"https://upload.wikimedia.org/wikipedia/commons/a/a7/Big_Buck_Bunny_thumbnail_vlc.png" ,
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" ,
const videoTitle = "Big Buck Bunny" ;
* iOS specific fullscreen event listeners.
const videoElement = videoRef.current ! ;
function handlePresentationModeChanged () {
const presentationMode = (
videoElement as HTMLVideoElement & {
).webkitPresentationMode;
if (presentationMode === "inline" ) {
type: "fullscreen.exited" ,
} else if (presentationMode === "fullscreen" ) {
type: "fullscreen.expanded" ,
videoElement. addEventListener (
"webkitpresentationmodechanged" ,
handlePresentationModeChanged
videoElement. removeEventListener (
"webkitpresentationmodechanged" ,
handlePresentationModeChanged
* Fullscreen event listeners
function handleFullscreenChange () {
if (document.fullscreenElement === null ) {
type: "fullscreen.exited" ,
if (document.fullscreenElement === videoContainerRef.current) {
type: "fullscreen.expanded" ,
function handleFullscreenError () {
type: "fullscreen.error" ,
document. addEventListener ( "fullscreenchange" , handleFullscreenChange);
document. addEventListener ( "fullscreenerror" , handleFullscreenError);
document. removeEventListener ( "fullscreenchange" , handleFullscreenChange);
document. removeEventListener ( "fullscreenerror" , handleFullscreenError);
< div className = { css ({ py: "2" , sm: { px: "4" } })}>
// With the tabIndex, allow the video container to receive the focus, that way, we can listen to keyboard events
// when the video is focused.
* The data-ui-contril attribute is an espace hatch to not take click events
* coming from ui controls into account.
* This would usually be solved by stopping the propagation of the click event listeners
* of all ui controls, but I'm not responsible from the one from the Ark UI library.
const uiControlAncestor = (e.target as HTMLElement ). closest (
const hasUiControlAncestor = uiControlAncestor !== null ;
if (hasUiControlAncestor === true ) {
type: "fullscreen.toggle" ,
type: "time.backward.keyboard" ,
type: "time.forward.keyboard" ,
type: "fullscreen.toggle" ,
// Stop processing unknown events.
className = { center ({ pos: "relative" })}
poster = {snapshot.context.videoPoster}
src = {snapshot.context.currentVideoSrc}
// playsInline is required by iOS to not put the video in fullscreen automatically when played.
onLoadedMetadata = {() => {
videoDuration: videoRef.current ! .duration,
// To sync when the video snapshot was changed not from the UI (device controls, pip)
// To sync when the video snapshot was changed not from the UI (device controls, pip)
currentTime: videoRef.current ! .currentTime,
currentTime: videoRef.current ! .currentTime,
The animated playing state icons
Not part of the Transition component as we want the animation to continue even if controls are hidden.
{snapshot. hasTag ( "Animate action" ) === true ? (
// Use the key to ensure the animation is restarted when the playing state changes quickly.
key = {snapshot.context.animationActionTimestamp}
type: "play-state-animation.end" ,
snapshot. hasTag ( "Animate backward" ) === true
: snapshot. hasTag ( "Animate forward" ) === true
animationIterationCount: "1!" ,
{snapshot. hasTag ( "Animate playing state" ) === true ? (
className = { css ({ h: "16" , w: "16" , color: "white" })}
) : snapshot. hasTag ( "Animate paused state" ) === true ? (
className = { css ({ h: "16" , w: "16" , color: "white" })}
) : snapshot. hasTag ( "Animate backward" ) === true ? (
className = { css ({ h: "16" , w: "16" , color: "white" })}
) : snapshot. hasTag ( "Animate forward" ) === true ? (
className = { css ({ h: "16" , w: "16" , color: "white" })}
snapshot. hasTag ( "Show loading overlay" ) === true ||
snapshot. hasTag ( "Show loader" ) === true ||
snapshot. hasTag ( "Show controls" ) === true
transitionDuration: "fastest" ,
enterFrom = { css ({ opacity: 0 })}
enterTo = { css ({ opacity: 1 })}
leave = { css ({ transition: "opacity" , transitionDuration: "fast" })}
leaveFrom = { css ({ opacity: 1 })}
leaveTo = { css ({ opacity: 0 })}
base: "linear-gradient(rgba(35, 35, 35, 0.8) 0%, rgba(35, 35, 35, 0) 40%, rgba(35, 35, 35, 0) 60%, rgba(35, 35, 35, 0.8) 100%)" ,
_deviceNoHover: "gray.950/60" ,
left: { base: "2" , sm: "4" },
top: { base: "1" , sm: "2" },
fontSize: { base: "sm" , sm: "md" , md: "lg" },
{ /* Initial loader + Play button for the Stopped state */ }
{snapshot. hasTag ( "Show loader" ) === true ? (
) : snapshot. matches ({ Video: "Stopped" }) === true ? (
className = { css ({ h: "16" , w: "16" , color: "white" })}
{ /* Controls for touch devices (play/pause, backward, forward) */ }
snapshot. hasTag ( "Show controls" ) === true ? "flex" : "none" ,
justifyContent: "space-evenly" ,
className = { css ({ h: "12" , w: "12" , color: "white" })}
Video: { Ready: { Controls: "Playing" } },
className = { css ({ h: "16" , w: "16" , color: "white" })}
className = { css ({ h: "16" , w: "16" , color: "white" })}
className = { css ({ h: "12" , w: "12" , color: "white" })}
{ /* Main controls (play/pause, backward, forward, volume, fullscreen, timeline) */ }
snapshot. hasTag ( "Show controls" ) === true ? "flex" : "none" ,
px: { base: "2" , sm: "4" },
py: { base: "1" , sm: "2" },
"& > [data-no-touch-device]" : { display: "none" },
className = { css ({ h: "6" , w: "6" , color: "white" })}
Video: { Ready: { Controls: "Playing" } },
className = { css ({ h: "10" , w: "10" , color: "white" })}
className = { css ({ h: "10" , w: "10" , color: "white" })}
className = { css ({ h: "6" , w: "6" , color: "white" })}
< div className = { spacer ()} />
They will be hidden on touch devices as I assume volume is externally managed on these devices
_deviceNoHover: { display: "none" },
positioning = {{ placement: "top" }}
< Tooltip.Trigger asChild >
type: "volume.mute.toggle" ,
{snapshot.context.muted === true ? (
className = { css ({ h: "6" , w: "6" , color: "white" })}
className = { css ({ h: "6" , w: "6" , color: "white" })}
volume = {snapshot.context.volume}
onVolumeChange = {( volume ) => {
type: "fullscreen.toggle" ,
{snapshot. matches ({ Fullscreen: "On" }) === true ? (
< ArrowsPointingOutIcon />
fontSize: { base: "sm" , sm: "md" },
fontVariantNumeric: "tabular-nums" ,
{ formatTime (snapshot.context.videoCurrentTime ?? 0 )}
< div data-ui-control className = { css ({ flexGrow: 1 })}>
snapshot.context.videoDuration === undefined
: ( 100 * snapshot.context.videoCurrentTime) /
snapshot.context.videoDuration
onValueChange = {( valuePercentage ) => {
console. log ( "slider value changed" , valuePercentage);
seekToPercentage: valuePercentage,
fontSize: { base: "sm" , sm: "md" },
fontVariantNumeric: "tabular-nums" ,
{ formatTime (snapshot.context.videoDuration ?? 0 )}
function formatTime ( seconds : number ) {
const duration = intervalToDuration ({ start: 0 , end: seconds * 1000 });
return `${ String ( duration . hours ?? 0 ). padStart ( 2 , "0" ) }:${ String (
). padStart ( 2 , "0" ) }:${ String ( duration . seconds ?? 0 ). padStart ( 2 , "0" ) }` ;
* Inspired by the Slider component of Park-UI.
* See https://park-ui.com/docs/panda/components/slider.
const sliderStyle = sva ({
slots: [ "root" , "control" , "range" , "thumb" , "track" ],
backgroundColor: "gray.300" ,
onValueChange : ( valuePercentage : number ) => void ;
const styles = sliderStyle ({});
value = {[valuePercentage]}
onValueChange = {({ value }) => {
< Slider.Control className = {styles.control}>
< Slider.Track className = {styles.track}>
< Slider.Range className = {styles.range} />
< Slider.Thumb index = { 0 } className = {styles.thumb} />
onVolumeChange : ( volume : number ) => void ;
const styles = sliderStyle ({ size: "sm" });
onValueChange = {({ value }) => {
onVolumeChange (value[ 0 ]);
< Slider.Control className = {styles.control}>
< Slider.Track className = {styles.track}>
< Slider.Range className = {styles.range} />
< Slider.Thumb index = { 0 } className = {styles.thumb} />