From 35c7ac4b8d5e7a8a3d5d5241213258e36d5144bd Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Tue, 10 Jan 2023 19:53:55 +0100 Subject: [PATCH] lots of UI changes for video player --- package.json | 4 + src/components/Icon.tsx | 6 +- src/components/layout/Spinner.css | 19 ++++ src/components/layout/Spinner.tsx | 5 + src/components/video/DecoratedVideoPlayer.tsx | 99 ++++++++++++++++--- src/components/video/VideoContext.tsx | 11 ++- src/components/video/VideoPlayer.tsx | 18 +++- .../video/controls/BackdropControl.tsx | 20 +++- .../video/controls/FullscreenControl.tsx | 4 +- .../video/controls/LoadingControl.tsx | 3 +- .../video/controls/MiddlePauseControl.tsx | 27 +++++ .../video/controls/ProgressControl.tsx | 8 +- .../controls/ProgressListenerControl.tsx | 39 ++++++++ .../video/controls/SourceControl.tsx | 4 +- src/components/video/controls/TimeControl.tsx | 16 ++- .../video/controls/VolumeControl.tsx | 15 +-- src/components/video/hooks/controlVideo.ts | 91 +++++++++++++++-- src/components/video/hooks/fullscreen.ts | 6 -- src/components/video/hooks/useVideoPlayer.ts | 50 +++++++--- src/components/video/hooks/volumeStore.ts | 25 +++++ src/hooks/useProgressBar.ts | 4 +- src/utils/detectFeatures.ts | 40 ++++++++ src/views/TestView.tsx | 31 +++--- yarn.lock | 63 +++++++++++- 24 files changed, 516 insertions(+), 92 deletions(-) create mode 100644 src/components/layout/Spinner.css create mode 100644 src/components/layout/Spinner.tsx create mode 100644 src/components/video/controls/MiddlePauseControl.tsx create mode 100644 src/components/video/controls/ProgressListenerControl.tsx delete mode 100644 src/components/video/hooks/fullscreen.ts create mode 100644 src/components/video/hooks/volumeStore.ts create mode 100644 src/utils/detectFeatures.ts diff --git a/package.json b/package.json index f6678488..02c93cc2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 45ea593b..18398ace 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,3 +1,5 @@ +import { memo } from "react"; + export enum Icons { SEARCH = "search", BOOKMARK = "bookmark", @@ -51,11 +53,11 @@ const iconList: Record = { volume_x: ``, }; -export function Icon(props: IconProps) { +export const Icon = memo((props: IconProps) => { return ( ); -} +}); diff --git a/src/components/layout/Spinner.css b/src/components/layout/Spinner.css new file mode 100644 index 00000000..0ec7f274 --- /dev/null +++ b/src/components/layout/Spinner.css @@ -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); + } +} diff --git a/src/components/layout/Spinner.tsx b/src/components/layout/Spinner.tsx new file mode 100644 index 00000000..98dae6e3 --- /dev/null +++ b/src/components/layout/Spinner.tsx @@ -0,0 +1,5 @@ +import "./Spinner.css"; + +export function Spinner() { + return
; +} diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index 01891a26..222fd920 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -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 ( +
+ + + +
+ ); +} export function DecoratedVideoPlayer(props: VideoPlayerProps) { + const top = useRef(null); + const bottom = useRef(null); + const [show, setShow] = useState(false); + + const onBackdropChange = useCallback( + (showing: boolean) => { + setShow(showing); + }, + [setShow] + ); + return ( - +
-
- -
- - - -
- +
+ +
+ +
+ +
+ +
+ +
-
-
- -
+
+ +
+ +
+
{props.children} diff --git a/src/components/video/VideoContext.tsx b/src/components/video/VideoContext.tsx index bf8d4e95..cbee333b 100644 --- a/src/components/video/VideoContext.tsx +++ b/src/components/video/VideoContext.tsx @@ -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") { diff --git a/src/components/video/VideoPlayer.tsx b/src/components/video/VideoPlayer.tsx index 89f632e4..0f914693 100644 --- a/src/components/video/VideoPlayer.tsx +++ b/src/components/video/VideoPlayer.tsx @@ -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(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 ( + /> ); }); @@ -31,7 +39,7 @@ export function VideoPlayer(props: VideoPlayerProps) { return (
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) => { + 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} >
- {showUI ? props.children : null} + {props.children}
); diff --git a/src/components/video/controls/FullscreenControl.tsx b/src/components/video/controls/FullscreenControl.tsx index 321d5665..9d44264a 100644 --- a/src/components/video/controls/FullscreenControl.tsx +++ b/src/components/video/controls/FullscreenControl.tsx @@ -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 ( Loading...

; + return ; } diff --git a/src/components/video/controls/MiddlePauseControl.tsx b/src/components/video/controls/MiddlePauseControl.tsx new file mode 100644 index 00000000..9bbbe08c --- /dev/null +++ b/src/components/video/controls/MiddlePauseControl.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/video/controls/ProgressControl.tsx b/src/components/video/controls/ProgressControl.tsx index ec1cae1d..b8e277a0 100644 --- a/src/components/video/controls/ProgressControl.tsx +++ b/src/components/video/controls/ProgressControl.tsx @@ -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(null); + const dragRef = useRef(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) diff --git a/src/components/video/controls/ProgressListenerControl.tsx b/src/components/video/controls/ProgressListenerControl.tsx new file mode 100644 index 00000000..bdcc8f07 --- /dev/null +++ b/src/components/video/controls/ProgressListenerControl.tsx @@ -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(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; +} diff --git a/src/components/video/controls/SourceControl.tsx b/src/components/video/controls/SourceControl.tsx index e7ad0c9d..ddb13984 100644 --- a/src/components/video/controls/SourceControl.tsx +++ b/src/components/video/controls/SourceControl.tsx @@ -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; } diff --git a/src/components/video/controls/TimeControl.tsx b/src/components/video/controls/TimeControl.tsx index 35d86351..aeeb99d4 100644 --- a/src/components/video/controls/TimeControl.tsx +++ b/src/components/video/controls/TimeControl.tsx @@ -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 { diff --git a/src/components/video/controls/VolumeControl.tsx b/src/components/video/controls/VolumeControl.tsx index 72f2a196..cfad9441 100644 --- a/src/components/video/controls/VolumeControl.tsx +++ b/src/components/video/controls/VolumeControl.tsx @@ -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(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) { 0 ? Icons.VOLUME : Icons.VOLUME_X} />
diff --git a/src/components/video/hooks/controlVideo.ts b/src/components/video/hooks/controlVideo.ts index 33413cab..e5c7c8bc 100644 --- a/src/components/video/hooks/controlVideo.ts +++ b/src/components/video/hooks/controlVideo.ts @@ -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) => void, + state: RefObject ): 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; + } }, }; } diff --git a/src/components/video/hooks/fullscreen.ts b/src/components/video/hooks/fullscreen.ts deleted file mode 100644 index f5bd96ae..00000000 --- a/src/components/video/hooks/fullscreen.ts +++ /dev/null @@ -1,6 +0,0 @@ -import fscreen from "fscreen"; - -export const isSafari = /^((?!chrome|android).)*safari/i.test( - navigator.userAgent -); -export const canFullscreen = fscreen.fullscreenEnabled || isSafari; diff --git a/src/components/video/hooks/useVideoPlayer.ts b/src/components/video/hooks/useVideoPlayer.ts index 1dddf81a..ae2a199d 100644 --- a/src/components/video/hooks/useVideoPlayer.ts +++ b/src/components/video/hooks/useVideoPlayer.ts @@ -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) => void; +type SetPlayer = (s: React.SetStateAction) => 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 ) { const [state, setState] = useState(initialPlayerState); + const stateRef = useRef(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, diff --git a/src/components/video/hooks/volumeStore.ts b/src/components/video/hooks/volumeStore.ts new file mode 100644 index 00000000..3b328810 --- /dev/null +++ b/src/components/video/hooks/volumeStore.ts @@ -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, + }); +} diff --git a/src/hooks/useProgressBar.ts b/src/hooks/useProgressBar.ts index d0fee788..7bb7070b 100644 --- a/src/hooks/useProgressBar.ts +++ b/src/hooks/useProgressBar.ts @@ -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); } diff --git a/src/utils/detectFeatures.ts b/src/utils/detectFeatures.ts new file mode 100644 index 00000000..15be4c69 --- /dev/null +++ b/src/utils/detectFeatures.ts @@ -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 { + if (cachedVolumeResult === null) { + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(false), 1e3); + }); + const promise = new Promise((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(); +} diff --git a/src/views/TestView.tsx b/src/views/TestView.tsx index a1820f34..5dd391f3 100644 --- a/src/views/TestView.tsx +++ b/src/views/TestView.tsx @@ -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 (
- + + console.log(a, b)} + />
); diff --git a/yarn.lock b/yarn.lock index 9f0c1b5d..e3e9dda4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"