buffering

This commit is contained in:
Jelle van Snik 2023-01-08 17:51:38 +01:00
parent 218a14d5f6
commit 61abce9386
8 changed files with 197 additions and 13 deletions

View File

@ -9,7 +9,7 @@ const VideoPlayerInternals = forwardRef<HTMLVideoElement>((_, ref) => {
const video = useContext(VideoPlayerContext); const video = useContext(VideoPlayerContext);
return ( return (
<video ref={ref} className="h-full w-full"> <video ref={ref} preload="auto" playsInline className="h-full w-full">
{video.source ? <source src={video.source} type="video/mp4" /> : null} {video.source ? <source src={video.source} type="video/mp4" /> : null}
</video> </video>
); );

View File

@ -9,7 +9,7 @@ export function PauseControl() {
else videoState.play(); else videoState.play();
}, [videoState]); }, [videoState]);
let text = const text =
videoState.isPlaying || videoState.isSeeking ? "playing" : "paused"; videoState.isPlaying || videoState.isSeeking ? "playing" : "paused";
return ( return (

View File

@ -0,0 +1,47 @@
import { useCallback, useRef } from "react";
import { useVideoPlayerState } from "../VideoContext";
export function ProgressControl() {
const { videoState } = useVideoPlayerState();
const ref = useRef<HTMLDivElement>(null);
const watchProgress = `${(
(videoState.time / videoState.duration) *
100
).toFixed(2)}%`;
const bufferProgress = `${(
(videoState.buffered / videoState.duration) *
100
).toFixed(2)}%`;
const handleClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pos = (e.pageX - rect.left) / ref.current.offsetWidth;
videoState.setTime(pos * videoState.duration);
},
[videoState, ref]
);
return (
<div
ref={ref}
className="relative m-1 my-4 h-4 w-48 overflow-hidden rounded-full border border-white bg-denim-100"
onClick={handleClick}
>
<div
className="absolute inset-y-0 left-0 bg-denim-700 opacity-50"
style={{
width: bufferProgress,
}}
/>
<div
className="absolute inset-y-0 left-0 bg-denim-700"
style={{
width: watchProgress,
}}
/>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { useCallback, useRef } from "react";
import { useVideoPlayerState } from "../VideoContext";
export function VolumeControl() {
const { videoState } = useVideoPlayerState();
const ref = useRef<HTMLDivElement>(null);
const percentage = `${(videoState.volume * 100).toFixed(2)}%`;
const handleClick = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const pos = (e.pageX - rect.left) / ref.current.offsetWidth;
videoState.setVolume(pos);
},
[videoState, ref]
);
return (
<div
ref={ref}
className="relative m-1 my-4 h-4 w-48 overflow-hidden rounded-full border border-white bg-bink-300"
onClick={handleClick}
>
<div
className="absolute inset-y-0 left-0 bg-bink-700"
style={{
width: percentage,
}}
/>
</div>
);
}

View File

@ -3,6 +3,8 @@ export interface PlayerControls {
pause(): void; pause(): void;
exitFullscreen(): void; exitFullscreen(): void;
enterFullscreen(): void; enterFullscreen(): void;
setTime(time: number): void;
setVolume(volume: number): void;
} }
export const initialControls: PlayerControls = { export const initialControls: PlayerControls = {
@ -10,6 +12,8 @@ export const initialControls: PlayerControls = {
pause: () => null, pause: () => null,
enterFullscreen: () => null, enterFullscreen: () => null,
exitFullscreen: () => null, exitFullscreen: () => null,
setTime: () => null,
setVolume: () => null,
}; };
export function populateControls( export function populateControls(
@ -31,5 +35,19 @@ export function populateControls(
if (!document.fullscreenElement) return; if (!document.fullscreenElement) return;
document.exitFullscreen(); document.exitFullscreen();
}, },
setTime(t) {
// clamp time between 0 and max duration
let time = Math.min(t, player.duration);
time = Math.max(0, time);
// eslint-disable-next-line no-param-reassign
player.currentTime = time;
},
setVolume(v) {
// clamp time between 0 and 1
let volume = Math.min(v, 1);
volume = Math.max(0, volume);
// eslint-disable-next-line no-param-reassign
player.volume = volume;
},
}; };
} }

View File

@ -4,12 +4,17 @@ import {
PlayerControls, PlayerControls,
populateControls, populateControls,
} from "./controlVideo"; } from "./controlVideo";
import { handleBuffered } from "./utils";
export type PlayerState = { export type PlayerState = {
isPlaying: boolean; isPlaying: boolean;
isPaused: boolean; isPaused: boolean;
isSeeking: boolean; isSeeking: boolean;
isFullscreen: boolean; isFullscreen: boolean;
time: number;
duration: number;
volume: number;
buffered: number;
} & PlayerControls; } & PlayerControls;
export const initialPlayerState: PlayerState = { export const initialPlayerState: PlayerState = {
@ -17,6 +22,10 @@ export const initialPlayerState: PlayerState = {
isPaused: true, isPaused: true,
isFullscreen: false, isFullscreen: false,
isSeeking: false, isSeeking: false,
time: 0,
duration: 0,
volume: 0,
buffered: 0,
...initialControls, ...initialControls,
}; };
@ -30,26 +39,77 @@ function readState(player: HTMLVideoElement, update: SetPlayer) {
state.isPlaying = !player.paused; state.isPlaying = !player.paused;
state.isFullscreen = !!document.fullscreenElement; state.isFullscreen = !!document.fullscreenElement;
state.isSeeking = player.seeking; state.isSeeking = player.seeking;
state.time = player.currentTime;
state.duration = player.duration;
state.volume = player.volume;
state.buffered = handleBuffered(player.currentTime, player.buffered);
update(state); update(state);
} }
function registerListeners(player: HTMLVideoElement, update: SetPlayer) { function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
player.addEventListener("pause", () => { const pause = () => {
update((s) => ({ ...s, isPaused: true, isPlaying: false })); update((s) => ({ ...s, isPaused: true, isPlaying: false }));
}); };
player.addEventListener("play", () => { const play = () => {
update((s) => ({ ...s, isPaused: false, isPlaying: true })); update((s) => ({ ...s, isPaused: false, isPlaying: true }));
}); };
player.addEventListener("seeking", () => { const seeking = () => {
update((s) => ({ ...s, isSeeking: true })); update((s) => ({ ...s, isSeeking: true }));
}); };
player.addEventListener("seeked", () => { const seeked = () => {
update((s) => ({ ...s, isSeeking: false })); update((s) => ({ ...s, isSeeking: false }));
}); };
document.addEventListener("fullscreenchange", () => { const fullscreenchange = () => {
update((s) => ({ ...s, isFullscreen: !!document.fullscreenElement })); update((s) => ({ ...s, isFullscreen: !!document.fullscreenElement }));
}); };
const timeupdate = () => {
update((s) => ({
...s,
duration: player.duration,
time: player.currentTime,
}));
};
const loadedmetadata = () => {
update((s) => ({
...s,
duration: player.duration,
}));
};
const volumechange = () => {
update((s) => ({
...s,
volume: player.volume,
}));
};
const progress = () => {
update((s) => ({
...s,
buffered: handleBuffered(player.currentTime, player.buffered),
}));
};
player.addEventListener("pause", pause);
player.addEventListener("play", play);
player.addEventListener("seeking", seeking);
player.addEventListener("seeked", seeked);
document.addEventListener("fullscreenchange", fullscreenchange);
player.addEventListener("timeupdate", timeupdate);
player.addEventListener("loadedmetadata", loadedmetadata);
player.addEventListener("volumechange", volumechange);
player.addEventListener("progress", progress);
return () => {
player.removeEventListener("pause", pause);
player.removeEventListener("play", play);
player.removeEventListener("seeking", seeking);
player.removeEventListener("seeked", seeked);
document.removeEventListener("fullscreenchange", fullscreenchange);
player.removeEventListener("timeupdate", timeupdate);
player.removeEventListener("loadedmetadata", loadedmetadata);
player.removeEventListener("volumechange", volumechange);
player.removeEventListener("progress", progress);
};
} }
export function useVideoPlayer( export function useVideoPlayer(

View File

@ -0,0 +1,8 @@
export function handleBuffered(time: number, buffered: TimeRanges): number {
for (let i = 0; i < buffered.length; i += 1) {
if (buffered.start(buffered.length - 1 - i) < time) {
return buffered.end(buffered.length - 1 - i);
}
}
return 0;
}

View File

@ -1,17 +1,34 @@
import { FullscreenControl } from "@/components/video/controls/FullscreenControl"; import { FullscreenControl } from "@/components/video/controls/FullscreenControl";
import { PauseControl } from "@/components/video/controls/PauseControl"; import { PauseControl } from "@/components/video/controls/PauseControl";
import { ProgressControl } from "@/components/video/controls/ProgressControl";
import { SourceControl } from "@/components/video/controls/SourceControl"; import { SourceControl } from "@/components/video/controls/SourceControl";
import { VolumeControl } from "@/components/video/controls/VolumeControl";
import { VideoPlayer } from "@/components/video/VideoPlayer"; import { VideoPlayer } from "@/components/video/VideoPlayer";
// test videos: https://gist.github.com/jsturgis/3b19447b304616f18657 // test videos: https://gist.github.com/jsturgis/3b19447b304616f18657
// TODO video todos:
// - captions
// - make pretty
// - show fullscreen button depending on is available (document.fullscreenEnabled)
// - better seeking
// - improve seekables
// - buffering
// - error handling
// - auto-play prop option
// - middle pause button
// - improve pausing while seeking/buffering
// - captions
// - show formatted time
export function TestView() { export function TestView() {
return ( return (
<div className="w-[40rem] max-w-full"> <div className="w-[40rem] max-w-full">
<VideoPlayer> <VideoPlayer>
<PauseControl /> <PauseControl />
<FullscreenControl /> <FullscreenControl />
<SourceControl source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" /> <ProgressControl />
<VolumeControl />
<SourceControl source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4" />
</VideoPlayer> </VideoPlayer>
</div> </div>
); );