implement more progres controls

This commit is contained in:
Jelle van Snik 2023-02-03 16:34:41 +01:00
parent c5a8065db9
commit a0c24209bb
18 changed files with 510 additions and 10 deletions

View File

@ -0,0 +1,14 @@
import { Spinner } from "@/components/layout/Spinner";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
export function LoadingAction() {
const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor);
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
if (!isLoading) return null;
return <Spinner />;
}

View File

@ -0,0 +1,32 @@
import { Icon, Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useCallback } from "react";
export function MiddlePauseAction() {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const mediaPlaying = useMediaPlaying(descriptor);
const handleClick = useCallback(() => {
if (mediaPlaying?.isPlaying) controls.pause();
else controls.play();
}, [controls, mediaPlaying]);
if (mediaPlaying.hasPlayedOnce) return null;
if (mediaPlaying.isPlaying) return null;
if (mediaPlaying.isFirstLoading) 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

@ -0,0 +1,81 @@
import {
makePercentage,
makePercentageString,
useProgressBar,
} from "@/hooks/useProgressBar";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useProgress } from "@/video/state/logic/progress";
import { useCallback, useEffect, useRef } from "react";
export function ProgressAction() {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const videoTime = useProgress(descriptor);
const ref = useRef<HTMLDivElement>(null);
const dragRef = useRef<boolean>(false);
const commitTime = useCallback(
(percentage) => {
controls.setTime(percentage * videoTime.duration);
},
[controls, videoTime]
);
const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
ref,
commitTime
);
// TODO make dragging update timer
useEffect(() => {
if (dragRef.current === dragging) return;
dragRef.current = dragging;
controls.setSeeking(dragging);
}, [dragRef, dragging, controls]);
let watchProgress = makePercentageString(
makePercentage((videoTime.time / videoTime.duration) * 100)
);
if (dragging)
watchProgress = makePercentageString(makePercentage(dragPercentage));
const bufferProgress = makePercentageString(
makePercentage((videoTime.buffered / videoTime.duration) * 100)
);
return (
<div className="group pointer-events-auto w-full cursor-pointer rounded-full px-2">
<div
ref={ref}
className="-my-3 flex h-8 items-center"
onMouseDown={dragMouseDown}
onTouchStart={dragMouseDown}
>
<div
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${
dragging ? "!h-2" : ""
}`}
>
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-gray-300 bg-opacity-20"
style={{
width: bufferProgress,
}}
/>
<div
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-600"
style={{
width: watchProgress,
}}
>
<div
className={`absolute h-1 w-1 translate-x-1/2 rounded-full bg-white opacity-0 transition-[transform,opacity] group-hover:scale-[400%] group-hover:opacity-100 ${
dragging ? "!scale-[400%] !opacity-100" : ""
}`}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
import { Icons } from "@/components/Icon";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useProgress } from "@/video/state/logic/progress";
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
interface Props {
className?: string;
}
export function SkipTimeBackwardAction() {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const videoTime = useProgress(descriptor);
const skipBackward = () => {
controls.setTime(videoTime.time - 10);
};
return (
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} />
);
}
export function SkipTimeForwardAction() {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const videoTime = useProgress(descriptor);
const skipForward = () => {
controls.setTime(videoTime.time + 10);
};
return (
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} />
);
}
export function SkipTimeAction(props: Props) {
return (
<div className={props.className}>
<div className="flex select-none items-center text-white">
<SkipTimeBackwardAction />
<SkipTimeForwardAction />
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useProgress } from "@/video/state/logic/progress";
function durationExceedsHour(secs: number): boolean {
return secs > 60 * 60;
}
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 = Math.floor(time % 60);
time /= 60;
const minutes = Math.floor(time % 60);
time /= 60;
const hours = Math.floor(time);
const paddedSecs = seconds.toString().padStart(2, "0");
const paddedMins = minutes.toString().padStart(2, "0");
if (!showHours) return [paddedMins, paddedSecs].join(":");
return [hours, paddedMins, paddedSecs].join(":");
}
interface Props {
className?: string;
noDuration?: boolean;
}
export function TimeAction(props: Props) {
const descriptor = useVideoPlayerDescriptor();
const videoTime = useProgress(descriptor);
const hasHours = durationExceedsHour(videoTime.duration);
const time = formatSeconds(videoTime.time, hasHours);
const duration = formatSeconds(videoTime.duration, hasHours);
return (
<div className={props.className}>
<p className="select-none text-white">
{time} {props.noDuration ? "" : `/ ${duration}`}
</p>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls";
import { useEffect, useRef } from "react";
interface SourceControllerProps {
source: string;
type: MWStreamType;
quality: MWStreamQuality;
}
export function SourceController(props: SourceControllerProps) {
const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor);
const didInitialize = useRef<boolean>(false);
useEffect(() => {
if (didInitialize.current) return;
controls.setSource(props);
didInitialize.current = true;
}, [props, controls]);
return null;
}

View File

@ -19,12 +19,6 @@ export function VideoElementInternal() {
}, [descriptor]); }, [descriptor]);
// TODO autoplay and muted // TODO autoplay and muted
return ( // this element is remotely controlled by a state provider
<video return <video ref={ref} playsInline className="h-full w-full" />;
ref={ref}
playsInline
className="h-full w-full"
src="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
/>
);
} }

View File

@ -1,4 +1,4 @@
export type VideoPlayerEvent = "mediaplaying"; export type VideoPlayerEvent = "mediaplaying" | "source" | "progress";
function createEventString(id: string, event: VideoPlayerEvent): string { function createEventString(id: string, event: VideoPlayerEvent): string {
return `_vid:::${id}:::${event}`; return `_vid:::${id}:::${event}`;

View File

@ -25,6 +25,8 @@ function initPlayer(): VideoPlayerState {
isSeries: false, isSeries: false,
}, },
canAirplay: false, canAirplay: false,
stateProvider: null,
source: null,
}; };
} }

View File

@ -11,5 +11,14 @@ export function useControls(descriptor: string): VideoPlayerStateController {
play() { play() {
state.stateProvider?.play(); state.stateProvider?.play();
}, },
setSource(source) {
state.stateProvider?.setSource(source);
},
setSeeking(active) {
state.stateProvider?.setSeeking(active);
},
setTime(time) {
state.stateProvider?.setTime(time);
},
}; };
} }

View File

@ -7,7 +7,9 @@ export type VideoMediaPlayingEvent = {
isPlaying: boolean; isPlaying: boolean;
isPaused: boolean; isPaused: boolean;
isLoading: boolean; isLoading: boolean;
isSeeking: boolean;
hasPlayedOnce: boolean; hasPlayedOnce: boolean;
isFirstLoading: boolean;
}; };
function getMediaPlayingFromState( function getMediaPlayingFromState(
@ -18,6 +20,8 @@ function getMediaPlayingFromState(
isLoading: state.isLoading, isLoading: state.isLoading,
isPaused: state.isPaused, isPaused: state.isPaused,
isPlaying: state.isPlaying, isPlaying: state.isPlaying,
isSeeking: state.isSeeking,
isFirstLoading: state.isFirstLoading,
}; };
} }

View File

@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events";
import { VideoPlayerState } from "../types";
export type VideoProgressEvent = {
time: number;
duration: number;
buffered: number;
};
function getProgressFromState(state: VideoPlayerState): VideoProgressEvent {
return {
time: state.time,
duration: state.duration,
buffered: state.buffered,
};
}
export function updateProgress(descriptor: string, state: VideoPlayerState) {
sendEvent<VideoProgressEvent>(
descriptor,
"progress",
getProgressFromState(state)
);
}
export function useProgress(descriptor: string): VideoProgressEvent {
const state = getPlayerState(descriptor);
const [data, setData] = useState<VideoProgressEvent>(
getProgressFromState(state)
);
useEffect(() => {
function update(payload: CustomEvent<VideoProgressEvent>) {
setData(payload.detail);
}
listenEvent(descriptor, "progress", update);
return () => {
unlistenEvent(descriptor, "progress", update);
};
}, [descriptor]);
return data;
}

View File

@ -0,0 +1,40 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { useEffect, useState } from "react";
import { getPlayerState } from "../cache";
import { listenEvent, sendEvent, unlistenEvent } from "../events";
import { VideoPlayerState } from "../types";
export type VideoSourceEvent = {
source: null | {
quality: MWStreamQuality;
url: string;
type: MWStreamType;
};
};
function getSourceFromState(state: VideoPlayerState): VideoSourceEvent {
return {
source: state.source ? { ...state.source } : null,
};
}
export function updateSource(descriptor: string, state: VideoPlayerState) {
sendEvent<VideoSourceEvent>(descriptor, "source", getSourceFromState(state));
}
export function useSource(descriptor: string): VideoSourceEvent {
const state = getPlayerState(descriptor);
const [data, setData] = useState<VideoSourceEvent>(getSourceFromState(state));
useEffect(() => {
function update(payload: CustomEvent<VideoSourceEvent>) {
setData(payload.detail);
}
listenEvent(descriptor, "source", update);
return () => {
unlistenEvent(descriptor, "source", update);
};
}, [descriptor]);
return data;
}

View File

@ -1,6 +1,17 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
type VideoPlayerSource = {
source: string;
type: MWStreamType;
quality: MWStreamQuality;
} | null;
export type VideoPlayerStateController = { export type VideoPlayerStateController = {
pause: () => void; pause: () => void;
play: () => void; play: () => void;
setSource: (source: VideoPlayerSource) => void;
setTime(time: number): void;
setSeeking(active: boolean): void;
}; };
export type VideoPlayerStateProvider = VideoPlayerStateController & { export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@ -16,3 +16,12 @@ export function unsetStateProvider(descriptor: string) {
const state = getPlayerState(descriptor); const state = getPlayerState(descriptor);
state.stateProvider = null; state.stateProvider = null;
} }
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,11 +1,16 @@
import Hls from "hls.js";
import { MWStreamType } from "@/backend/helpers/streams";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
import { updateProgress } from "../logic/progress";
import { handleBuffered } from "./utils";
export function createVideoStateProvider( export function createVideoStateProvider(
descriptor: string, descriptor: string,
player: HTMLVideoElement playerEl: HTMLVideoElement
): VideoPlayerStateProvider { ): VideoPlayerStateProvider {
const player = playerEl;
const state = getPlayerState(descriptor); const state = getPlayerState(descriptor);
return { return {
@ -15,7 +20,72 @@ export function createVideoStateProvider(
pause() { pause() {
player.pause(); player.pause();
}, },
setTime(t) {
// clamp time between 0 and max duration
let time = Math.min(t, player.duration);
time = Math.max(0, time);
if (Number.isNaN(time)) return;
// update state
player.currentTime = time;
state.time = time;
updateProgress(descriptor, state);
},
setSeeking(active) {
// if it was playing when starting to seek, play again
if (!active) {
if (!state.pausedWhenSeeking) this.play();
return;
}
// when seeking we pause the video
// this variables isnt reactive, just used so the state can be remembered next unseek
state.pausedWhenSeeking = state.isPaused;
this.pause();
},
setSource(source) {
if (!source) {
player.src = "";
return;
}
if (source?.type === MWStreamType.HLS) {
if (player.canPlayType("application/vnd.apple.mpegurl")) {
player.src = source.source;
} else {
// HLS support
if (!Hls.isSupported()) {
state.error = {
name: `Not supported`,
description: "Your browser does not support HLS video",
};
// TODO dispatch error
return;
}
const hls = new Hls({ enableWorker: false });
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
state.error = {
name: `error ${data.details}`,
description: data.error?.message ?? "Something went wrong",
};
// TODO dispatch error
}
console.error("HLS error", data);
});
hls.attachMedia(player);
hls.loadSource(source.source);
}
} else if (source.type === MWStreamType.MP4) {
player.src = source.source;
}
},
providerStart() { providerStart() {
// TODO stored volume
const pause = () => { const pause = () => {
state.isPaused = true; state.isPaused = true;
state.isPlaying = false; state.isPlaying = false;
@ -28,13 +98,56 @@ export function createVideoStateProvider(
state.hasPlayedOnce = true; state.hasPlayedOnce = true;
updateMediaPlaying(descriptor, state); updateMediaPlaying(descriptor, state);
}; };
const waiting = () => {
state.isLoading = true;
updateMediaPlaying(descriptor, state);
};
const seeking = () => {
state.isSeeking = true;
updateMediaPlaying(descriptor, state);
};
const seeked = () => {
state.isSeeking = false;
updateMediaPlaying(descriptor, state);
};
const loadedmetadata = () => {
state.duration = player.duration;
updateProgress(descriptor, state);
};
const timeupdate = () => {
state.duration = player.duration;
state.time = player.currentTime;
updateProgress(descriptor, state);
};
const progress = () => {
state.buffered = handleBuffered(player.currentTime, player.buffered);
updateProgress(descriptor, state);
};
const canplay = () => {
state.isFirstLoading = false;
updateMediaPlaying(descriptor, state);
};
player.addEventListener("pause", pause); player.addEventListener("pause", pause);
player.addEventListener("playing", playing); player.addEventListener("playing", playing);
player.addEventListener("seeking", seeking);
player.addEventListener("seeked", seeked);
player.addEventListener("progress", progress);
player.addEventListener("waiting", waiting);
player.addEventListener("timeupdate", timeupdate);
player.addEventListener("loadedmetadata", loadedmetadata);
player.addEventListener("canplay", canplay);
return { return {
destroy: () => { destroy: () => {
player.removeEventListener("pause", pause); player.removeEventListener("pause", pause);
player.removeEventListener("playing", playing); player.removeEventListener("playing", playing);
player.removeEventListener("seeking", seeking);
player.removeEventListener("seeked", seeked);
player.removeEventListener("timeupdate", timeupdate);
player.removeEventListener("loadedmetadata", loadedmetadata);
player.removeEventListener("progress", progress);
player.removeEventListener("waiting", waiting);
player.removeEventListener("canplay", canplay);
}, },
}; };
}, },

View File

@ -1,3 +1,4 @@
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { VideoPlayerStateProvider } from "./providers/providerTypes"; import { VideoPlayerStateProvider } from "./providers/providerTypes";
export type VideoPlayerState = { export type VideoPlayerState = {
@ -30,10 +31,16 @@ export type VideoPlayerState = {
episodes?: { id: string; number: number; title: string }[]; episodes?: { id: string; number: number; title: string }[];
}[]; }[];
}; };
error: null | { error: null | {
name: string; name: string;
description: string; description: string;
}; };
canAirplay: boolean; canAirplay: boolean;
stateProvider: VideoPlayerStateProvider | null; stateProvider: VideoPlayerStateProvider | null;
source: null | {
quality: MWStreamQuality;
url: string;
type: MWStreamType;
};
}; };

View File

@ -4,7 +4,14 @@
// } from "@/hooks/useChromecastAvailable"; // } from "@/hooks/useChromecastAvailable";
// import { useEffect, useRef } from "react"; // import { useEffect, useRef } from "react";
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { LoadingAction } from "@/video/components/actions/LoadingAction";
import { MiddlePauseAction } from "@/video/components/actions/MiddlePauseAction";
import { PauseAction } from "@/video/components/actions/PauseAction"; import { PauseAction } from "@/video/components/actions/PauseAction";
import { ProgressAction } from "@/video/components/actions/ProgressAction";
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
import { TimeAction } from "@/video/components/actions/TimeAction";
import { SourceController } from "@/video/components/controllers/SourceController";
import { VideoPlayerBase } from "@/video/components/VideoPlayerBase"; import { VideoPlayerBase } from "@/video/components/VideoPlayerBase";
// function ChromeCastButton() { // function ChromeCastButton() {
@ -25,6 +32,16 @@ export function TestView() {
return ( return (
<VideoPlayerBase> <VideoPlayerBase>
<PauseAction /> <PauseAction />
<SourceController
quality={MWStreamQuality.QUNKNOWN}
source="http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4"
type={MWStreamType.MP4}
/>
<MiddlePauseAction />
<ProgressAction />
<LoadingAction />
<TimeAction />
<SkipTimeAction />
</VideoPlayerBase> </VideoPlayerBase>
); );
} }