如何自製 Video Player

準備一個原生的

Video Player

如何自製 Video Player

如何自製 Video Player

客製化原本的

Video Player 樣式

具體需要做哪些事?

Event Handling

  • Basic Events (Play/Pause/waiting...)
  • Time & Progress
  • Seeking

Basic Events

Basic Events

  • Load
  • Waiting
  • Play
  • Pause
  • End
  // video events
  React.useEffect(() => {
    const vid = videoRef.current;

    const duration = durationRef.current;
    if (duration)
      vid.onloadeddata = () => {
        duration.textContent = getTimeStr(vid.duration);
      };

    vid.onplay = () => {
      setPlaying(true);
    };
    vid.onpause = () => {
      setPlaying(false);
    };
    vid.onwaiting = () => {
      setLoading(true);
    };
    vid.oncanplay = () => {
      if (!vid.seeking) {
        setLoading(false);
        setEnded(false);
      }
    };

    return () => {
      vid.onplay = null;
      vid.onpause = null;
      vid.onwaiting = null;
      vid.oncanplay = null;
    };
  }, [durationRef, readyToLoad, setEnded, setLoading, setPlaying, videoRef]);

Play & Pause

var playPromise = document.querySelector('video').play();

// In browsers that don’t yet support this functionality,
// playPromise won’t be defined.
if (playPromise !== undefined) {
  playPromise.then(function() {
    // Automatic playback started!
  }).catch(function(error) {
    // Automatic playback failed.
    // Show a UI element to let the user manually start playback.
  });
}

Time & Progress

  • Current Time / Duration
  • Loaded Progress

Current Time & Duration

Current Time & Duration

React.useEffect(() => {
  const vid = videoRef.current;
  if (!readyToLoad || !vid) return;

  vid.ontimeupdate = () => {
    if (!vid.seeking) handleTimeChange(vid.currentTime, vid.duration);
  };
  vid.onseeking = () => {
    handleTimeChange(vid.currentTime, vid.duration);
  };

  return () => {
    vid.ontimeupdate = null;
    vid.onseeking = null;
  };
}, [
  handleTimeChange,
  readyToLoad,
  videoRef,
]);

Loaded Progress Bar

Progress Bar

const backgroundImage = `linear-gradient(90deg, rgb(51, 151, 207) 0%, rgb(51, 151, 207) 30%, transparent 30%, transparent 100%);`

Loaded Progress Bar

let linearGradientBody = '';
for (let i = 0; i < vid.buffered.length; i++) {
  // firefox will have error
  const startPercent = trimTimePercent(
    (vid.buffered.start(i) / duration) * 100
  );
  const endPercent = trimTimePercent(
    (vid.buffered.end(i) / duration) * 100
  );
  if (endPercent > currentPercent)
    linearGradientBody += `${basicColor} ${startPercent}%, ${bufferedColor} ${startPercent}%, ${bufferedColor} ${endPercent}%, ${basicColor} ${endPercent}%, `;
}

Seeking

React.useEffect(() => {
  let timer: number | undefined;
  const handleMouseDown = () => {
    prevPlayingStatusRef.current = playing;
    // NOTE: PART1 prevent video didn't receive onplay event if click too fast
    timer = window.setTimeout(() => vid.pause(), 50);
  };
  const handleMouseUp = () => {
    if (prevPlayingStatusRef.current && vid.currentTime < vid.duration) {
      // NOTE: PART2 prevent video didn't receive on play event if click too fast
      if (typeof timer === 'number') window.clearTimeout(timer);
      vid.play();
    }
  };
}, [readyToLoad, playing, videoRef, progressBarRef]);

Fullscreen

Fullscreen

  • Different Browsers' API
  • The Behavior of Safari

Fullscreen

(function () {
  'use strict';

  const document =
    typeof window !== 'undefined' && typeof window.document !== 'undefined'
      ? window.document
      : {};
  const isCommonjs = typeof module !== 'undefined' && module.exports;

  const fn = (function () {
    let val;

    const fnMap = [
      [
        'requestFullscreen',
        'exitFullscreen',
        'fullscreenElement',
        'fullscreenEnabled',
        'fullscreenchange',
        'fullscreenerror',
      ],
      // New WebKit
      [
        'webkitRequestFullscreen',
        'webkitExitFullscreen',
        'webkitFullscreenElement',
        'webkitFullscreenEnabled',
        'webkitfullscreenchange',
        'webkitfullscreenerror',
      ],
      // Old WebKit
      [
        'webkitRequestFullScreen',
        'webkitCancelFullScreen',
        'webkitCurrentFullScreenElement',
        'webkitCancelFullScreen',
        'webkitfullscreenchange',
        'webkitfullscreenerror',
      ],
      [
        'mozRequestFullScreen',
        'mozCancelFullScreen',
        'mozFullScreenElement',
        'mozFullScreenEnabled',
        'mozfullscreenchange',
        'mozfullscreenerror',
      ],
      [
        'msRequestFullscreen',
        'msExitFullscreen',
        'msFullscreenElement',
        'msFullscreenEnabled',
        'MSFullscreenChange',
        'MSFullscreenError',
      ],
    ];

    let i = 0;
    const l = fnMap.length;
    const ret = {};

    for (; i < l; i++) {
      val = fnMap[i];
      if (val && val[1] in document) {
        for (i = 0; i < val.length; i++) {
          ret[fnMap[0][i]] = val[i];
        }
        return ret;
      }
    }

    return false;
  })();

  const eventNameMap = {
    change: fn.fullscreenchange,
    error: fn.fullscreenerror,
  };

  const screenfull = {
    request: function (element, options) {
      return new Promise(
        function (resolve, reject) {
          var onFullScreenEntered = function () {
            this.off('change', onFullScreenEntered);
            resolve();
          }.bind(this);

          this.on('change', onFullScreenEntered);

          element = element || document.documentElement;

          const returnPromise = element[fn.requestFullscreen](options);

          if (returnPromise instanceof Promise) {
            returnPromise.then(onFullScreenEntered).catch(reject);
          }
        }.bind(this)
      );
    },
    exit: function () {
      return new Promise(
        function (resolve, reject) {
          if (!this.isFullscreen) {
            resolve();
            return;
          }

          var onFullScreenExit = function () {
            this.off('change', onFullScreenExit);
            resolve();
          }.bind(this);

          this.on('change', onFullScreenExit);

          const returnPromise = document[fn.exitFullscreen]();

          if (returnPromise instanceof Promise) {
            returnPromise.then(onFullScreenExit).catch(reject);
          }
        }.bind(this)
      );
    },
    toggle: function (element, options) {
      return this.isFullscreen ? this.exit() : this.request(element, options);
    },
    onchange: function (callback) {
      this.on('change', callback);
    },
    onerror: function (callback) {
      this.on('error', callback);
    },
    on: function (event, callback) {
      const eventName = eventNameMap[event];
      if (eventName) {
        document.addEventListener(eventName, callback, false);
      }
    },
    off: function (event, callback) {
      const eventName = eventNameMap[event];
      if (eventName) {
        document.removeEventListener(eventName, callback, false);
      }
    },
    raw: fn,
  };

  if (!fn) {
    if (isCommonjs) {
      module.exports = { isEnabled: false };
    } else {
      window.screenfull = { isEnabled: false };
    }

    return;
  }

  Object.defineProperties(screenfull, {
    isFullscreen: {
      get: function () {
        return Boolean(document[fn.fullscreenElement]);
      },
    },
    element: {
      enumerable: true,
      get: function () {
        return document[fn.fullscreenElement];
      },
    },
    isEnabled: {
      enumerable: true,
      get: function () {
        // Coerce to boolean in case of old WebKit
        return Boolean(document[fn.fullscreenEnabled]);
      },
    },
  });

  if (isCommonjs) {
    module.exports = screenfull;
  } else {
    window.screenfull = screenfull;
  }
})();

Fullscreen

Safari

  • macOS safari
  • iOS safari

Safari

  • macOS safari
  • iOS safari
const toggleFullscreen = useCallback(() => {
  const vid = videoRef.current;

  if (screenfull.isEnabled) {
    if (screenfull.isFullscreen) {
      screenfull.exit();
    } else {
      const target = targetRef.current;

      if (isSafariBrowser) {
        screenfull.request(target ? target : undefined);
      } else {
        screenfull.request();
      }
    }
  } else {
    if (vid && vid.webkitEnterFullscreen) {
      onEnteriOSFullscreen();
      vid.webkitEnterFullscreen();
    }
  }
}, [targetRef, onEnteriOSFullscreen, videoRef]);

Autoplay System

怎麼做?

Todos

  • detect the first video
  • autoplay

Todos

  • detect the first video
  • autoplay
  • pause previous playing video

Detect First Video

Detect First Video

Detect First Video

Detect First Video

Detect First Video

onScreen

Detect First Video

onScreen

onScreen

onScreen

onScreen

Detect First Video

export const getFirstEngagement = <T extends HTMLElement>(
  container: T | null,
  dataAttribute: string
): string | null => {
  if (typeof document === 'undefined') return null;

  const selector = `${scoped}[${dataAttribute}]:not([${dataAttribute}='false'])`;
  const element = (container || document).querySelector(selector);

  if (element) return element.getAttribute(dataAttribute);

  return null;
};

Detect First Video

onScreen

onScreen

onScreen

onScreen

Detect First Video

true

false

true

true

Autoplay

Autoplay

Current Playing Video

Global State

Autoplay

useEffect(() => {
  // trigger if currentPlayingVideo changed and not ended
  if (!ended) {
    if (currentPlayingVideo === uniqAutoPlayId) {
      setTimeout(() => {
        onAutoPlay?.();
        handlePlay();
      }, 50);
    } else {
      handlePause();
    }
  }
});

Linearize

Linearlize

Linearlize

A

B

Play A

Linearlize

A

B

Play A

Pause A

Linearlize

A

B

Play A

Pause A

Play B

Linearlize

A

B

Play A

Pause A

Play B

Pause B

Linearlize

A

B

Play B

Linearlize

A

B

Play B

Play A

Linearlize

A

B

Play B

Play A

Pause B

Linearlize

A

B

Play B

Play A

Pause B

Pause A

Autoplay

useEffect(() => {
  // trigger if currentPlayingVideo changed and not ended
  if (!ended) {
    if (currentPlayingVideo === uniqAutoPlayId) {
      setTimeout(() => {
        onAutoPlay?.();
        handlePlay();
      }, 50);
    } else {
      handlePause();
    }
  }
});

Solution

Atomic

VideoController

Play A

Queue

Pause A

Play B

Pause B

Play C

Pause C

Solution

Play A

Queue

Pause A

Play B

Pause B

Play C

Pause C

Atomic

VideoController

Solution

Play A

Queue

Pause A

Play B

Pause B

Play C

Pause C

Atomic

VideoController

Solution

Play A

Queue

Play B

Pause B

Play C

Pause C

Pause A

Play C

Atomic

VideoController

Solution

Play A

Queue

Play B

Pause B

Play C

Pause C

Pause A

Play C

Atomic

VideoController

Solution

Play A

Queue

Play B

Pause B

Play C

Pause C

Pause A

Play C

Atomic

VideoController

Solution

// handle atomic play video
const { handlePlay, handlePause } = useAtomicVideoController({
  handlePlay: _handlePlay,
  handlePause: _handlePause,
});
export default function useAtomicVideoController({
  handlePlay,
  handlePause,
}: {
  handlePlay: () => void;
  handlePause: () => void;
}) {
  // handle atomic play video
  const _handlePlay = useCallback(() => {
    atomicVideoController.play({
      play: handlePlay,
      pause: handlePause,
    });
  }, [handlePlay, handlePause]);

  return { handlePlay: _handlePlay, handlePause } as const;
}

Q&A

Thanks

deck

By 邱俊霖