error handling video player

This commit is contained in:
Jelle van Snik 2023-01-15 16:51:55 +01:00
parent 52b063b10a
commit ca169769bb
9 changed files with 233 additions and 64 deletions

View File

@ -30,17 +30,22 @@ interface ErrorMessageProps {
description: string; description: string;
path: string; path: string;
}; };
localSize?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
export function ErrorMessage(props: ErrorMessageProps) { export function ErrorMessage(props: ErrorMessageProps) {
return ( return (
<div className="flex min-h-screen w-full flex-col items-center justify-center px-4 py-12"> <div
className={`${
props.localSize ? "h-full" : "min-h-screen"
} flex w-full flex-col items-center justify-center px-4 py-12`}
>
<div className="flex flex-col items-center justify-start text-center"> <div className="flex flex-col items-center justify-start text-center">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" /> <IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Whoops, it broke</Title> <Title>Whoops, it broke</Title>
{props.children ? ( {props.children ? (
props.children <p className="my-6 max-w-lg">{props.children}</p>
) : ( ) : (
<p className="my-6 max-w-lg"> <p className="my-6 max-w-lg">
The app encountered an error and wasn&apos;t able to recover, please The app encountered an error and wasn&apos;t able to recover, please

View File

@ -8,6 +8,7 @@ import { PauseControl } from "./controls/PauseControl";
import { ProgressControl } from "./controls/ProgressControl"; import { ProgressControl } from "./controls/ProgressControl";
import { TimeControl } from "./controls/TimeControl"; import { TimeControl } from "./controls/TimeControl";
import { VolumeControl } from "./controls/VolumeControl"; import { VolumeControl } from "./controls/VolumeControl";
import { VideoPlayerError } from "./parts/VideoPlayerError";
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader"; import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
import { useVideoPlayerState } from "./VideoContext"; import { useVideoPlayerState } from "./VideoContext";
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer"; import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
@ -56,60 +57,62 @@ export function DecoratedVideoPlayer(
return ( return (
<VideoPlayer autoPlay={props.autoPlay}> <VideoPlayer autoPlay={props.autoPlay}>
<BackdropControl onBackdropChange={onBackdropChange}> <VideoPlayerError title={props.title} onGoBack={props.onGoBack}>
<div className="absolute inset-0 flex items-center justify-center"> <BackdropControl onBackdropChange={onBackdropChange}>
<LoadingControl /> <div className="absolute inset-0 flex items-center justify-center">
</div> <LoadingControl />
<div className="absolute inset-0 flex items-center justify-center"> </div>
<MiddlePauseControl /> <div className="absolute inset-0 flex items-center justify-center">
</div> <MiddlePauseControl />
<CSSTransition </div>
nodeRef={bottom} <CSSTransition
in={show} nodeRef={bottom}
timeout={200} in={show}
classNames={{ timeout={200}
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", classNames={{
exitActive: "!translate-y-4 !opacity-0", exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
exitDone: "hidden", exitActive: "!translate-y-4 !opacity-0",
enter: exitDone: "hidden",
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0", enter:
enterActive: "!translate-y-0 !opacity-100", "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
<div className="flex items-center"> ref={bottom}
<LeftSideControls /> className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2"
<div className="flex-1" /> >
<FullscreenControl /> <ProgressControl />
<div className="flex items-center">
<LeftSideControls />
<div className="flex-1" />
<FullscreenControl />
</div>
</div> </div>
</div> </CSSTransition>
</CSSTransition> <CSSTransition
<CSSTransition nodeRef={top}
nodeRef={top} in={show}
in={show} timeout={200}
timeout={200} classNames={{
classNames={{ exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100",
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", exitActive: "!-translate-y-4 !opacity-0",
exitActive: "!-translate-y-4 !opacity-0", exitDone: "hidden",
exitDone: "hidden", enter:
enter: "transition-[transform,opacity] -translate-y-4 duration-200 opacity-0",
"transition-[transform,opacity] -translate-y-4 duration-200 opacity-0", enterActive: "!translate-y-0 !opacity-100",
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={props.title} onClick={props.onGoBack} /> <div
</div> ref={top}
</CSSTransition> className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
</BackdropControl> >
{props.children} <VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
</div>
</CSSTransition>
</BackdropControl>
{props.children}
</VideoPlayerError>
</VideoPlayer> </VideoPlayer>
); );
} }

View File

@ -1,4 +1,6 @@
import { useGoBack } from "@/hooks/useGoBack";
import { forwardRef, useContext, useEffect, useRef } from "react"; import { forwardRef, useContext, useEffect, useRef } from "react";
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary";
import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext"; import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext";
export interface VideoPlayerProps { export interface VideoPlayerProps {
@ -35,6 +37,9 @@ const VideoPlayerInternals = forwardRef<
export function VideoPlayer(props: VideoPlayerProps) { export function VideoPlayer(props: VideoPlayerProps) {
const playerRef = useRef<HTMLVideoElement | null>(null); const playerRef = useRef<HTMLVideoElement | null>(null);
const playerWrapperRef = useRef<HTMLDivElement | null>(null); const playerWrapperRef = useRef<HTMLDivElement | null>(null);
const goBack = useGoBack();
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
return ( return (
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}> <VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}>
@ -42,11 +47,13 @@ export function VideoPlayer(props: VideoPlayerProps) {
className="relative h-full w-full select-none overflow-hidden bg-black" className="relative h-full w-full select-none overflow-hidden bg-black"
ref={playerWrapperRef} ref={playerWrapperRef}
> >
<VideoPlayerInternals <VideoErrorBoundary onGoBack={goBack}>
autoPlay={props.autoPlay ?? false} <VideoPlayerInternals
ref={playerRef} autoPlay={props.autoPlay ?? false}
/> ref={playerRef}
<div className="absolute inset-0">{props.children}</div> />
<div className="absolute inset-0">{props.children}</div>
</VideoErrorBoundary>
</div> </div>
</VideoPlayerContextProvider> </VideoPlayerContextProvider>
); );

View File

@ -108,19 +108,36 @@ export function populateControls(
initPlayer(sourceUrl: string, sourceType: MWStreamType) { initPlayer(sourceUrl: string, sourceType: MWStreamType) {
this.setVolume(getStoredVolume()); this.setVolume(getStoredVolume());
// TODO test HLS errors
if (sourceType === MWStreamType.HLS) { if (sourceType === MWStreamType.HLS) {
if (player.canPlayType("application/vnd.apple.mpegurl")) { if (player.canPlayType("application/vnd.apple.mpegurl")) {
player.src = sourceUrl; player.src = sourceUrl;
} else { } else {
// HLS support // HLS support
if (!Hls.isSupported()) throw new Error("HLS not supported"); // TODO handle errors if (!Hls.isSupported()) {
update((s) => ({
...s,
error: {
name: `Not supported`,
description: "Your browser does not support HLS video",
},
}));
return;
}
const hls = new Hls(); const hls = new Hls();
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
// eslint-disable-next-line no-alert if (data.fatal) {
if (data.fatal) alert("HLS fatal error"); update((s) => ({
console.error("HLS error", data); // TODO handle errors ...s,
error: {
name: `error ${data.details}`,
description: data.error?.message ?? "Something went wrong",
},
}));
}
console.error("HLS error", data);
}); });
hls.attachMedia(player); hls.attachMedia(player);

View File

@ -23,6 +23,10 @@ export type PlayerState = {
hasInitialized: boolean; hasInitialized: boolean;
leftControlHovering: boolean; leftControlHovering: boolean;
hasPlayedOnce: boolean; hasPlayedOnce: boolean;
error: null | {
name: string;
description: string;
};
}; };
export type PlayerContext = PlayerState & PlayerControls; export type PlayerContext = PlayerState & PlayerControls;
@ -42,6 +46,7 @@ export const initialPlayerState: PlayerContext = {
hasInitialized: false, hasInitialized: false,
leftControlHovering: false, leftControlHovering: false,
hasPlayedOnce: false, hasPlayedOnce: false,
error: null,
...initialControls, ...initialControls,
}; };
@ -61,6 +66,7 @@ function readState(player: HTMLVideoElement, update: SetPlayer) {
state.buffered = handleBuffered(player.currentTime, player.buffered); state.buffered = handleBuffered(player.currentTime, player.buffered);
state.isLoading = false; state.isLoading = false;
state.hasInitialized = true; state.hasInitialized = true;
state.error = null;
update((s) => ({ update((s) => ({
...state, ...state,
@ -131,6 +137,19 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
isFirstLoading: false, isFirstLoading: false,
})); }));
}; };
const error = () => {
console.error("Native video player threw error", player.error);
// TODO check if these errors are actually fatal
update((s) => ({
...s,
error: player.error
? {
description: player.error.message,
name: `Error ${player.error.code}`,
}
: null,
}));
};
player.addEventListener("pause", pause); player.addEventListener("pause", pause);
player.addEventListener("playing", playing); player.addEventListener("playing", playing);
@ -143,6 +162,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
player.addEventListener("progress", progress); player.addEventListener("progress", progress);
player.addEventListener("waiting", waiting); player.addEventListener("waiting", waiting);
player.addEventListener("canplay", canplay); player.addEventListener("canplay", canplay);
player.addEventListener("error", error);
return () => { return () => {
player.removeEventListener("pause", pause); player.removeEventListener("pause", pause);
@ -156,6 +176,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
player.removeEventListener("progress", progress); player.removeEventListener("progress", progress);
player.removeEventListener("waiting", waiting); player.removeEventListener("waiting", waiting);
player.removeEventListener("canplay", canplay); player.removeEventListener("canplay", canplay);
player.removeEventListener("error", error);
}; };
} }

View File

@ -0,0 +1,82 @@
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
import { Link } from "@/components/text/Link";
import { conf } from "@/setup/config";
import { Component, ReactNode } from "react";
import { VideoPlayerHeader } from "./VideoPlayerHeader";
interface ErrorBoundaryState {
hasError: boolean;
error?: {
name: string;
description: string;
path: string;
};
}
interface VideoErrorBoundaryProps {
children?: ReactNode;
title?: string;
onGoBack?: () => void;
}
export class VideoErrorBoundary extends Component<
VideoErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: VideoErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
};
}
static getDerivedStateFromError() {
return {
hasError: true,
};
}
componentDidCatch(error: any, errorInfo: any) {
console.error("Render error caught", error, errorInfo);
if (error instanceof Error) {
const realError: Error = error as Error;
this.setState((s) => ({
...s,
hasError: true,
error: {
name: realError.name,
description: realError.message,
path: errorInfo.componentStack.split("\n")[1],
},
}));
}
}
render() {
if (!this.state.hasError) return this.props.children;
// TODO make responsive, needs to work in tiny player
return (
<div className="absolute inset-0 bg-denim-100">
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
<VideoPlayerHeader
title={this.props.title}
onClick={this.props.onGoBack}
/>
</div>
<ErrorMessage error={this.state.error} localSize>
The video player encounted a fatal error, please report it to the{" "}
<Link url={conf().DISCORD_LINK} newTab>
Discord server
</Link>{" "}
or on{" "}
<Link url={conf().GITHUB_LINK} newTab>
GitHub
</Link>
.
</ErrorMessage>
</div>
);
}
}

View File

@ -0,0 +1,35 @@
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Title } from "@/components/text/Title";
import { ReactNode } from "react";
import { useVideoPlayerState } from "../VideoContext";
import { VideoPlayerHeader } from "./VideoPlayerHeader";
interface VideoPlayerErrorProps {
title?: string;
onGoBack?: () => void;
children?: ReactNode;
}
export function VideoPlayerError(props: VideoPlayerErrorProps) {
const { videoState } = useVideoPlayerState();
const err = videoState.error;
if (!err) return props.children as any;
return (
<div>
<div className="absolute inset-0 flex flex-col items-center justify-center bg-denim-100">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Failed to load media</Title>
<p className="my-6 max-w-lg">
{err.name}: {err.description}
</p>
</div>
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
</div>
</div>
);
}

View File

@ -17,7 +17,6 @@ if (key) {
} }
// TODO video todos: // TODO video todos:
// - error handling
// - captions // - captions
// - mobile UI // - mobile UI
// - safari fullscreen will make video overlap player controls // - safari fullscreen will make video overlap player controls
@ -35,6 +34,7 @@ if (key) {
// TODO general todos: // TODO general todos:
// - localize everything // - localize everything
// - add titles to pages
ReactDOM.render( ReactDOM.render(
<React.StrictMode> <React.StrictMode>

View File

@ -116,7 +116,6 @@ export function MediaView() {
}, [exec, params.media]); }, [exec, params.media]);
// TODO watched store // TODO watched store
// TODO error page with video header
if (loading) return <MediaViewLoading onGoBack={goBack} />; if (loading) return <MediaViewLoading onGoBack={goBack} />;
if (error) return <MediaFetchErrorView />; if (error) return <MediaFetchErrorView />;