lots of UI changes for video player

This commit is contained in:
Jelle van Snik 2023-01-10 19:53:55 +01:00
parent 02ef6c5bf1
commit 35c7ac4b8d
24 changed files with 516 additions and 92 deletions

View File

@ -13,12 +13,14 @@
"i18next-browser-languagedetector": "^7.0.1",
"i18next-http-backend": "^2.1.0",
"json5": "^2.2.0",
"lodash.throttle": "^4.1.1",
"nanoid": "^4.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-i18next": "^12.1.1",
"react-router-dom": "^5.2.0",
"react-stickynode": "^4.1.0",
"react-transition-group": "^4.4.5",
"srt-webvtt": "^2.0.0",
"unpacker": "^1.0.1"
},
@ -46,12 +48,14 @@
"@tailwindcss/line-clamp": "^0.4.2",
"@types/crypto-js": "^4.1.1",
"@types/fscreen": "^1.0.1",
"@types/lodash.throttle": "^4.1.7",
"@types/node": "^17.0.15",
"@types/react": "^17.0.39",
"@types/react-dom": "^17.0.11",
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.3",
"@types/react-stickynode": "^4.0.0",
"@types/react-transition-group": "^4.4.5",
"@typescript-eslint/eslint-plugin": "^5.13.0",
"@typescript-eslint/parser": "^5.13.0",
"@vitejs/plugin-react-swc": "^3.0.0",

View File

@ -1,3 +1,5 @@
import { memo } from "react";
export enum Icons {
SEARCH = "search",
BOOKMARK = "bookmark",
@ -51,11 +53,11 @@ const iconList: Record<Icons, string> = {
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
};
export function Icon(props: IconProps) {
export const Icon = memo((props: IconProps) => {
return (
<span
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
className={props.className}
/>
);
}
});

View File

@ -0,0 +1,19 @@
.spinner {
width: 48px;
height: 48px;
border: 5px solid white;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: spinner-rotation 800ms linear infinite;
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,5 @@
import "./Spinner.css";
export function Spinner() {
return <div className="spinner" />;
}

View File

@ -1,35 +1,106 @@
import { useCallback, useRef, useState } from "react";
import { CSSTransition } from "react-transition-group";
import { BackdropControl } from "./controls/BackdropControl";
import { FullscreenControl } from "./controls/FullscreenControl";
import { LoadingControl } from "./controls/LoadingControl";
import { MiddlePauseControl } from "./controls/MiddlePauseControl";
import { PauseControl } from "./controls/PauseControl";
import { ProgressControl } from "./controls/ProgressControl";
import { TimeControl } from "./controls/TimeControl";
import { VolumeControl } from "./controls/VolumeControl";
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
import { useVideoPlayerState } from "./VideoContext";
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
// TODO animate items away when hidden
function LeftSideControls() {
const { videoState } = useVideoPlayerState();
const handleMouseEnter = useCallback(() => {
videoState.setLeftControlsHover(true);
}, [videoState]);
const handleMouseLeave = useCallback(() => {
videoState.setLeftControlsHover(false);
}, [videoState]);
return (
<div
className="flex items-center px-2"
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
>
<PauseControl />
<VolumeControl className="mr-2" />
<TimeControl />
</div>
);
}
export function DecoratedVideoPlayer(props: VideoPlayerProps) {
const top = useRef<HTMLDivElement>(null);
const bottom = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const onBackdropChange = useCallback(
(showing: boolean) => {
setShow(showing);
},
[setShow]
);
return (
<VideoPlayer autoPlay={props.autoPlay}>
<BackdropControl>
<BackdropControl onBackdropChange={onBackdropChange}>
<div className="absolute inset-0 flex items-center justify-center">
<LoadingControl />
</div>
<div className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2">
<ProgressControl />
<div className="flex items-center px-2">
<PauseControl />
<VolumeControl className="mr-2" />
<TimeControl />
<div className="flex-1" />
<FullscreenControl />
<div className="absolute inset-0 flex items-center justify-center">
<MiddlePauseControl />
</div>
<CSSTransition
nodeRef={bottom}
in={show}
timeout={200}
classNames={{
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
exitActive: "!translate-y-4 !opacity-0",
exitDone: "hidden",
enter:
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0",
enterActive: "!translate-y-0 !opacity-100",
}}
>
<div
ref={bottom}
className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2"
>
<ProgressControl />
<div className="flex items-center">
<LeftSideControls />
<div className="flex-1" />
<FullscreenControl />
</div>
</div>
</div>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
<VideoPlayerHeader title="Spiderman: Coming House" />
</div>
</CSSTransition>
<CSSTransition
nodeRef={top}
in={show}
timeout={200}
classNames={{
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
exitActive: "!-translate-y-4 !opacity-0",
exitDone: "hidden",
enter:
"transition-[transform,opacity] -translate-y-4 duration-200 opacity-0",
enterActive: "!translate-y-0 !opacity-100",
}}
>
<div
ref={top}
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
>
<VideoPlayerHeader title="Spiderman: Coming House" />
</div>
</CSSTransition>
</BackdropControl>
{props.children}
</VideoPlayer>

View File

@ -7,24 +7,26 @@ import React, {
} from "react";
import {
initialPlayerState,
PlayerState,
PlayerContext,
useVideoPlayer,
} from "./hooks/useVideoPlayer";
interface VideoPlayerContextType {
source: string | null;
state: PlayerState;
sourceType: "m3u8" | "mp4";
state: PlayerContext;
}
const initial: VideoPlayerContextType = {
source: null,
sourceType: "mp4",
state: initialPlayerState,
};
type VideoPlayerContextAction =
| { type: "SET_SOURCE"; url: string }
| { type: "SET_SOURCE"; url: string; sourceType: "m3u8" | "mp4" }
| {
type: "UPDATE_PLAYER";
state: PlayerState;
state: PlayerContext;
};
function videoPlayerContextReducer(
@ -34,6 +36,7 @@ function videoPlayerContextReducer(
const video = { ...original };
if (action.type === "SET_SOURCE") {
video.source = action.url;
video.sourceType = action.sourceType;
return video;
}
if (action.type === "UPDATE_PLAYER") {

View File

@ -1,4 +1,4 @@
import { forwardRef, useContext, useRef } from "react";
import { forwardRef, useContext, useEffect, useRef } from "react";
import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext";
export interface VideoPlayerProps {
@ -11,16 +11,24 @@ const VideoPlayerInternals = forwardRef<
{ autoPlay: boolean }
>((props, ref) => {
const video = useContext(VideoPlayerContext);
const didInitialize = useRef<true | null>(null);
useEffect(() => {
if (didInitialize.current) return;
if (!video.state.hasInitialized || !video.source) return;
video.state.initPlayer(video.source, video.sourceType);
didInitialize.current = true;
}, [didInitialize, video]);
// muted attribute is required for safari, as they cant change the volume itself
return (
<video
ref={ref}
autoPlay={props.autoPlay}
muted={video.state.volume === 0}
playsInline
className="h-full w-full"
>
{video.source ? <source src={video.source} type="video/mp4" /> : null}
</video>
/>
);
});
@ -31,7 +39,7 @@ export function VideoPlayer(props: VideoPlayerProps) {
return (
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}>
<div
className="relative aspect-video w-full select-none bg-black"
className="relative aspect-video w-full select-none overflow-hidden bg-black"
ref={playerWrapperRef}
>
<VideoPlayerInternals

View File

@ -1,12 +1,11 @@
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useVideoPlayerState } from "../VideoContext";
interface BackdropControlProps {
children?: React.ReactNode;
onBackdropChange?: (showing: boolean) => void;
}
// TODO add double click to toggle fullscreen
export function BackdropControl(props: BackdropControlProps) {
const { videoState } = useVideoPlayerState();
const [moved, setMoved] = useState(false);
@ -35,7 +34,19 @@ export function BackdropControl(props: BackdropControlProps) {
},
[videoState, clickareaRef]
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!clickareaRef.current || clickareaRef.current !== e.target) return;
if (!videoState.isFullscreen) videoState.enterFullscreen();
else videoState.exitFullscreen();
},
[videoState, clickareaRef]
);
useEffect(() => {
props.onBackdropChange?.(moved || videoState.isPaused);
}, [videoState, moved, props]);
const showUI = moved || videoState.isPaused;
return (
@ -45,6 +56,7 @@ export function BackdropControl(props: BackdropControlProps) {
onMouseLeave={handleMouseLeave}
ref={clickareaRef}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div
className={`pointer-events-none absolute inset-0 bg-black bg-opacity-20 transition-opacity duration-200 ${
@ -62,7 +74,7 @@ export function BackdropControl(props: BackdropControlProps) {
}`}
/>
<div className="pointer-events-none absolute inset-0">
{showUI ? props.children : null}
{props.children}
</div>
</div>
);

View File

@ -1,8 +1,8 @@
import { Icons } from "@/components/Icon";
import { canFullscreen } from "@/utils/detectFeatures";
import { useCallback } from "react";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
import { useVideoPlayerState } from "../VideoContext";
import { canFullscreen } from "../hooks/fullscreen";
interface Props {
className?: string;
@ -16,7 +16,7 @@ export function FullscreenControl(props: Props) {
else videoState.enterFullscreen();
}, [videoState]);
if (!canFullscreen) return null;
if (!canFullscreen()) return null;
return (
<VideoPlayerIconButton

View File

@ -1,3 +1,4 @@
import { Spinner } from "@/components/layout/Spinner";
import { useVideoPlayerState } from "../VideoContext";
export function LoadingControl() {
@ -5,5 +6,5 @@ export function LoadingControl() {
if (!videoState.isLoading) return null;
return <p>Loading...</p>;
return <Spinner />;
}

View File

@ -0,0 +1,27 @@
import { Icon, Icons } from "@/components/Icon";
import { useCallback } from "react";
import { useVideoPlayerState } from "../VideoContext";
export function MiddlePauseControl() {
const { videoState } = useVideoPlayerState();
const handleClick = useCallback(() => {
if (videoState?.isPlaying) videoState.pause();
else videoState.play();
}, [videoState]);
if (videoState.hasPlayedOnce) return null;
if (videoState.isPlaying) return null;
return (
<div
onClick={handleClick}
className="group pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-denim-400 text-white transition-[background-color,transform] hover:scale-125 hover:bg-denim-500 active:scale-100"
>
<Icon
icon={Icons.PLAY}
className="text-2xl transition-transform group-hover:scale-125"
/>
</div>
);
}

View File

@ -3,12 +3,13 @@ import {
makePercentageString,
useProgressBar,
} from "@/hooks/useProgressBar";
import { useCallback, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useVideoPlayerState } from "../VideoContext";
export function ProgressControl() {
const { videoState } = useVideoPlayerState();
const ref = useRef<HTMLDivElement>(null);
const dragRef = useRef<boolean>(false);
const commitTime = useCallback(
(percentage) => {
@ -20,6 +21,11 @@ export function ProgressControl() {
ref,
commitTime
);
useEffect(() => {
if (dragRef.current === dragging) return;
dragRef.current = dragging;
videoState.setSeeking(dragging);
}, [dragRef, dragging, videoState]);
let watchProgress = makePercentageString(
makePercentage((videoState.time / videoState.duration) * 100)

View File

@ -0,0 +1,39 @@
import { useEffect, useMemo, useRef } from "react";
import throttle from "lodash.throttle";
import { useVideoPlayerState } from "../VideoContext";
interface Props {
startAt?: number;
onProgress?: (time: number, duration: number) => void;
}
export function ProgressListenerControl(props: Props) {
const { videoState } = useVideoPlayerState();
const didInitialize = useRef<true | null>(null);
// time updates (throttled)
const updateTime = useMemo(
() => throttle((a: number, b: number) => props.onProgress?.(a, b), 1000),
[props]
);
useEffect(() => {
if (!videoState.isPlaying) return;
if (videoState.duration === 0 || videoState.time === 0) return;
updateTime(videoState.time, videoState.duration);
}, [videoState, updateTime]);
useEffect(() => {
return () => {
updateTime.cancel();
};
}, [updateTime]);
// initialize
useEffect(() => {
if (didInitialize.current) return;
if (!videoState.hasInitialized || Number.isNaN(videoState.duration)) return;
if (props.startAt !== undefined) videoState.setTime(props.startAt);
didInitialize.current = true;
}, [didInitialize, videoState, props]);
return null;
}

View File

@ -3,6 +3,7 @@ import { VideoPlayerDispatchContext } from "../VideoContext";
interface SourceControlProps {
source: string;
type: "m3u8" | "mp4";
}
export function SourceControl(props: SourceControlProps) {
@ -12,8 +13,9 @@ export function SourceControl(props: SourceControlProps) {
dispatch({
type: "SET_SOURCE",
url: props.source,
sourceType: props.type,
});
}, [props.source, dispatch]);
}, [props, dispatch]);
return null;
}

View File

@ -5,6 +5,11 @@ function durationExceedsHour(secs: number): boolean {
}
function formatSeconds(secs: number, showHours = false): string {
if (Number.isNaN(secs)) {
if (showHours) return "0:00:00";
return "0:00";
}
let time = secs;
const seconds = time % 60;
@ -14,12 +19,13 @@ function formatSeconds(secs: number, showHours = false): string {
time /= 60;
const hours = minutes % 60;
const minuteString = `${Math.round(minutes)
if (!showHours)
return `${Math.round(minutes).toString()}:${Math.round(seconds)
.toString()
.padStart(2, "0")}`;
return `${Math.round(hours).toString()}:${Math.round(minutes)
.toString()
.padStart(2)}:${Math.round(seconds).toString().padStart(2, "0")}`;
if (!showHours) return minuteString;
return `${Math.round(hours).toString()}:${minuteString}`;
.padStart(2, "0")}:${Math.round(seconds).toString().padStart(2, "0")}`;
}
interface Props {

View File

@ -4,15 +4,14 @@ import {
makePercentageString,
useProgressBar,
} from "@/hooks/useProgressBar";
import { useCallback, useRef, useState } from "react";
import { canChangeVolume } from "@/utils/detectFeatures";
import { useCallback, useEffect, useRef, useState } from "react";
import { useVideoPlayerState } from "../VideoContext";
interface Props {
className?: string;
}
// TODO make hoveredOnce false when control bar appears
export function VolumeControl(props: Props) {
const { videoState } = useVideoPlayerState();
const ref = useRef<HTMLDivElement>(null);
@ -32,6 +31,10 @@ export function VolumeControl(props: Props) {
true
);
useEffect(() => {
if (!videoState.leftControlHovering) setHoveredOnce(false);
}, [videoState, setHoveredOnce]);
const handleClick = useCallback(() => {
if (videoState.volume > 0) {
videoState.setVolume(0);
@ -41,8 +44,8 @@ export function VolumeControl(props: Props) {
}
}, [videoState, setStoredVolume, storedVolume]);
const handleMouseEnter = useCallback(() => {
setHoveredOnce(true);
const handleMouseEnter = useCallback(async () => {
if (await canChangeVolume()) setHoveredOnce(true);
}, [setHoveredOnce]);
let percentage = makePercentage(videoState.volume * 100);
@ -59,7 +62,7 @@ export function VolumeControl(props: Props) {
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} />
</div>
<div
className={`-ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in ${
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${
hoveredOnce ? "!w-24 opacity-100" : "w-4 opacity-0"
}`}
>

View File

@ -1,5 +1,14 @@
import Hls from "hls.js";
import {
canChangeVolume,
canFullscreen,
canFullscreenAnyElement,
canWebkitFullscreen,
} from "@/utils/detectFeatures";
import fscreen from "fscreen";
import { canFullscreen, isSafari } from "./fullscreen";
import React, { RefObject } from "react";
import { PlayerState } from "./useVideoPlayer";
import { getStoredVolume, setStoredVolume } from "./volumeStore";
export interface PlayerControls {
play(): void;
@ -8,6 +17,9 @@ export interface PlayerControls {
enterFullscreen(): void;
setTime(time: number): void;
setVolume(volume: number): void;
setSeeking(active: boolean): void;
setLeftControlsHover(hovering: boolean): void;
initPlayer(sourceUrl: string, sourceType: "m3u8" | "mp4"): void;
}
export const initialControls: PlayerControls = {
@ -17,12 +29,20 @@ export const initialControls: PlayerControls = {
exitFullscreen: () => null,
setTime: () => null,
setVolume: () => null,
setSeeking: () => null,
setLeftControlsHover: () => null,
initPlayer: () => null,
};
export function populateControls(
player: HTMLVideoElement,
wrapper: HTMLDivElement
playerEl: HTMLVideoElement,
wrapperEl: HTMLDivElement,
update: (s: React.SetStateAction<PlayerState>) => void,
state: RefObject<PlayerState>
): PlayerControls {
const player = playerEl;
const wrapper = wrapperEl;
return {
play() {
player.play();
@ -31,12 +51,12 @@ export function populateControls(
player.pause();
},
enterFullscreen() {
if (!canFullscreen || fscreen.fullscreenElement) return;
if (fscreen.fullscreenEnabled) {
if (!canFullscreen() || fscreen.fullscreenElement) return;
if (canFullscreenAnyElement()) {
fscreen.requestFullscreen(wrapper);
return;
}
if (isSafari) {
if (canWebkitFullscreen()) {
(player as any).webkitEnterFullscreen();
}
},
@ -48,15 +68,66 @@ export function populateControls(
// 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
if (Number.isNaN(time)) return;
// update state
player.currentTime = time;
update((s) => ({ ...s, time }));
},
setVolume(v) {
async 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;
// update state
if (await canChangeVolume()) player.volume = volume;
update((s) => ({ ...s, volume }));
// update localstorage
setStoredVolume(volume);
},
setSeeking(active) {
const currentState = state.current;
if (!currentState) return;
// if it was playing when starting to seek, play again
if (!active) {
if (!currentState.pausedWhenSeeking) this.play();
return;
}
// when seeking we pause the video
update((s) => ({ ...s, pausedWhenSeeking: s.isPaused }));
this.pause();
},
setLeftControlsHover(hovering) {
update((s) => ({ ...s, leftControlHovering: hovering }));
},
initPlayer(sourceUrl: string, sourceType: "m3u8" | "mp4") {
this.setVolume(getStoredVolume());
if (sourceType === "m3u8") {
if (player.canPlayType("application/vnd.apple.mpegurl")) {
player.src = sourceUrl;
} else {
// HLS support
if (!Hls.isSupported()) throw new Error("HLS not supported"); // TODO handle errors
const hls = new Hls();
hls.on(Hls.Events.ERROR, (event, data) => {
// eslint-disable-next-line no-alert
if (data.fatal) alert("HLS fatal error");
console.error("HLS error", data); // TODO handle errors
});
hls.attachMedia(player);
hls.loadSource(sourceUrl);
}
} else if (sourceType === "mp4") {
player.src = sourceUrl;
}
},
};
}

View File

@ -1,6 +0,0 @@
import fscreen from "fscreen";
export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
);
export const canFullscreen = fscreen.fullscreenEnabled || isSafari;

View File

@ -1,5 +1,6 @@
import { canChangeVolume } from "@/utils/detectFeatures";
import fscreen from "fscreen";
import React, { MutableRefObject, useEffect, useState } from "react";
import React, { MutableRefObject, useEffect, useRef, useState } from "react";
import {
initialControls,
PlayerControls,
@ -17,9 +18,15 @@ export type PlayerState = {
duration: number;
volume: number;
buffered: number;
} & PlayerControls;
pausedWhenSeeking: boolean;
hasInitialized: boolean;
leftControlHovering: boolean;
hasPlayedOnce: boolean;
};
export const initialPlayerState: PlayerState = {
export type PlayerContext = PlayerState & PlayerControls;
export const initialPlayerState: PlayerContext = {
isPlaying: false,
isPaused: true,
isFullscreen: false,
@ -29,10 +36,14 @@ export const initialPlayerState: PlayerState = {
duration: 0,
volume: 0,
buffered: 0,
pausedWhenSeeking: false,
hasInitialized: false,
leftControlHovering: false,
hasPlayedOnce: false,
...initialControls,
};
type SetPlayer = (s: React.SetStateAction<PlayerState>) => void;
type SetPlayer = (s: React.SetStateAction<PlayerContext>) => void;
function readState(player: HTMLVideoElement, update: SetPlayer) {
const state = {
@ -47,8 +58,13 @@ function readState(player: HTMLVideoElement, update: SetPlayer) {
state.volume = player.volume;
state.buffered = handleBuffered(player.currentTime, player.buffered);
state.isLoading = false;
state.hasInitialized = true;
update(state);
update((s) => ({
...state,
pausedWhenSeeking: s.pausedWhenSeeking,
hasPlayedOnce: s.hasPlayedOnce,
}));
}
function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
@ -65,6 +81,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
isPaused: false,
isPlaying: true,
isLoading: false,
hasPlayedOnce: true,
}));
};
const seeking = () => {
@ -92,11 +109,12 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
duration: player.duration,
}));
};
const volumechange = () => {
update((s) => ({
...s,
volume: player.volume,
}));
const volumechange = async () => {
if (await canChangeVolume())
update((s) => ({
...s,
volume: player.volume,
}));
};
const progress = () => {
update((s) => ({
@ -135,6 +153,7 @@ export function useVideoPlayer(
wrapperRef: MutableRefObject<HTMLDivElement | null>
) {
const [state, setState] = useState(initialPlayerState);
const stateRef = useRef<PlayerState | null>(null);
useEffect(() => {
const player = ref.current;
@ -142,9 +161,16 @@ export function useVideoPlayer(
if (player && wrapper) {
readState(player, setState);
registerListeners(player, setState);
setState((s) => ({ ...s, ...populateControls(player, wrapper) }));
setState((s) => ({
...s,
...populateControls(player, wrapper, setState as any, stateRef),
}));
}
}, [ref, wrapperRef]);
}, [ref, wrapperRef, stateRef]);
useEffect(() => {
stateRef.current = state;
}, [state, stateRef]);
return {
playerState: state,

View File

@ -0,0 +1,25 @@
import { versionedStoreBuilder } from "@/utils/storage";
export const volumeStore = versionedStoreBuilder()
.setKey("mw-volume")
.addVersion({
version: 0,
create() {
return {
volume: 1,
};
},
})
.build();
export function getStoredVolume(): number {
const store = volumeStore.get();
return store.volume;
}
export function setStoredVolume(volume: number) {
const store = volumeStore.get();
store.save({
volume,
});
}

View File

@ -20,8 +20,8 @@ export function useProgressBar(
function mouseMove(ev: MouseEvent) {
if (!mouseDown || !barRef.current) return;
const rect = barRef.current.getBoundingClientRect();
const pos = ((ev.pageX - rect.left) / barRef.current.offsetWidth) * 100;
setProgress(pos);
const pos = (ev.pageX - rect.left) / barRef.current.offsetWidth;
setProgress(pos * 100);
if (commitImmediately) commit(pos);
}

View File

@ -0,0 +1,40 @@
import fscreen from "fscreen";
export const isSafari = /^((?!chrome|android).)*safari/i.test(
navigator.userAgent
);
let cachedVolumeResult: boolean | null = null;
export async function canChangeVolume(): Promise<boolean> {
if (cachedVolumeResult === null) {
const timeoutPromise = new Promise<false>((resolve) => {
setTimeout(() => resolve(false), 1e3);
});
const promise = new Promise<true>((resolve) => {
const video = document.createElement("video");
const handler = () => {
video.removeEventListener("volumechange", handler);
resolve(true);
};
video.addEventListener("volumechange", handler);
video.volume = 0.5;
});
cachedVolumeResult = await Promise.race([promise, timeoutPromise]);
}
return cachedVolumeResult;
}
export function canFullscreenAnyElement(): boolean {
return fscreen.fullscreenEnabled;
}
export function canWebkitFullscreen(): boolean {
return canFullscreenAnyElement() || isSafari;
}
export function canFullscreen(): boolean {
return canFullscreenAnyElement() || canWebkitFullscreen();
}

View File

@ -1,3 +1,4 @@
import { ProgressListenerControl } from "@/components/video/controls/ProgressListenerControl";
import { SourceControl } from "@/components/video/controls/SourceControl";
import { DecoratedVideoPlayer } from "@/components/video/DecoratedVideoPlayer";
import { useCallback, useState } from "react";
@ -5,21 +6,16 @@ import { useCallback, useState } from "react";
// test videos: https://gist.github.com/jsturgis/3b19447b304616f18657
// TODO video todos:
// - improve seekables (if possible)
// - error handling
// - buffering
// - middle pause button
// - double click backdrop to toggle fullscreen
// - make volume bar collapse when hovering away from left control section
// - animate UI when showing/hiding
// - shortcuts when player is active
// - save volume in localstorage so persists between page reloads
// - improve pausing while seeking/buffering
// - volume control flashes old value when updating
// - progress control flashes old value when updating
// - captions
// - IOS & IpadOS support: (no volume)
// - HLS support: feature detection otherwise use HLS.js
// - mobile UI
// - safari fullscreen will make video overlap player controls
// - safari progress bar is fucked
// TODO optional todos:
// - shortcuts when player is active
// - improve seekables (if possible)
export function TestView() {
const [show, setShow] = useState(true);
const handleClick = useCallback(() => {
@ -33,7 +29,14 @@ export function TestView() {
return (
<div className="w-[40rem] max-w-full">
<DecoratedVideoPlayer>
<SourceControl source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4" />
<SourceControl
source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
type="mp4"
/>
<ProgressListenerControl
startAt={283}
onProgress={(a, b) => console.log(a, b)}
/>
</DecoratedVideoPlayer>
</div>
);

View File

@ -10,7 +10,7 @@
"core-js-pure" "^3.25.1"
"regenerator-runtime" "^0.13.11"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4", "@babel/runtime@^7.20.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
"integrity" "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA=="
"resolved" "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz"
"version" "7.20.6"
@ -287,6 +287,18 @@
"resolved" "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
"version" "0.0.29"
"@types/lodash.throttle@^4.1.7":
"integrity" "sha512-znwGDpjCHQ4FpLLx19w4OXDqq8+OvREa05H89obtSyXyOFKL3dDjCslsmfBz0T2FU8dmf5Wx1QvogbINiGIu9g=="
"resolved" "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.7.tgz"
"version" "4.1.7"
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
"integrity" "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ=="
"resolved" "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz"
"version" "4.14.191"
"@types/node@^17.0.15", "@types/node@>= 14":
"integrity" "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="
"resolved" "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz"
@ -328,6 +340,13 @@
dependencies:
"@types/react" "*"
"@types/react-transition-group@^4.4.5":
"integrity" "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA=="
"resolved" "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz"
"version" "4.4.5"
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^17", "@types/react@^17.0.39":
"integrity" "sha512-vwk8QqVODi0VaZZpDXQCmEmiOuyjEFPY7Ttaw5vjM112LOq37yz1CDJGrRJwA1fYEq4Iitd5rnjd1yWAc/bT+A=="
"resolved" "https://registry.npmjs.org/@types/react/-/react-17.0.52.tgz"
@ -998,6 +1017,14 @@
dependencies:
"esutils" "^2.0.2"
"dom-helpers@^5.0.1":
"integrity" "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="
"resolved" "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz"
"version" "5.2.1"
dependencies:
"@babel/runtime" "^7.8.7"
"csstype" "^3.0.2"
"electron-to-chromium@^1.4.251":
"integrity" "sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA=="
"resolved" "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz"
@ -1011,6 +1038,13 @@
"resolved" "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz"
"version" "9.2.2"
"encoding@^0.1.0":
"integrity" "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="
"resolved" "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz"
"version" "0.1.13"
dependencies:
"iconv-lite" "^0.6.2"
"encoding@^0.1.13":
"version" "0.1.13"
dependencies:
@ -1725,6 +1759,8 @@
"@babel/runtime" "^7.20.6"
"iconv-lite@^0.6.2":
"integrity" "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="
"resolved" "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz"
"version" "0.6.3"
dependencies:
"safer-buffer" ">= 2.1.2 < 3.0.0"
@ -2123,6 +2159,11 @@
"resolved" "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"
"version" "4.6.2"
"lodash.throttle@^4.1.1":
"integrity" "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
"resolved" "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz"
"version" "4.1.1"
"lodash@^4.17.15":
"integrity" "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
"resolved" "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
@ -2850,7 +2891,7 @@
dependencies:
"performance-now" "^2.1.0"
"react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^17.0.2":
"react-dom@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom@^16 || ^17 || ^18", "react-dom@^17.0.2", "react-dom@>=16.6.0":
"integrity" "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA=="
"resolved" "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz"
"version" "17.0.2"
@ -2911,7 +2952,17 @@
"shallowequal" "^1.0.0"
"subscribe-ui-event" "^2.0.6"
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@17.0.2":
"react-transition-group@^4.4.5":
"integrity" "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="
"resolved" "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz"
"version" "4.4.5"
dependencies:
"@babel/runtime" "^7.5.5"
"dom-helpers" "^5.0.1"
"loose-envify" "^1.4.0"
"prop-types" "^15.6.2"
"react@^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react@^16 || ^17 || ^18", "react@^17.0.2", "react@>= 16.8.0", "react@>=15", "react@>=16.6.0", "react@17.0.2":
"integrity" "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="
"resolved" "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
"version" "17.0.2"
@ -3047,6 +3098,8 @@
"queue-microtask" "^1.2.2"
"safe-buffer@~5.2.0":
"integrity" "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
"resolved" "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
"version" "5.2.1"
"safe-regex-test@^1.0.0":
@ -3059,6 +3112,8 @@
"is-regex" "^1.1.4"
"safer-buffer@>= 2.1.2 < 3.0.0":
"integrity" "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
"resolved" "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
"version" "2.1.2"
"scheduler@^0.20.2":
@ -3173,6 +3228,8 @@
"minipass" "^3.1.1"
"string_decoder@^1.1.1":
"integrity" "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="
"resolved" "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
"version" "1.3.0"
dependencies:
"safe-buffer" "~5.2.0"