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;
path: string;
};
localSize?: boolean;
children?: React.ReactNode;
}
export function ErrorMessage(props: ErrorMessageProps) {
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">
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Whoops, it broke</Title>
{props.children ? (
props.children
<p className="my-6 max-w-lg">{props.children}</p>
) : (
<p className="my-6 max-w-lg">
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 { TimeControl } from "./controls/TimeControl";
import { VolumeControl } from "./controls/VolumeControl";
import { VideoPlayerError } from "./parts/VideoPlayerError";
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader";
import { useVideoPlayerState } from "./VideoContext";
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
@ -56,60 +57,62 @@ export function DecoratedVideoPlayer(
return (
<VideoPlayer autoPlay={props.autoPlay}>
<BackdropControl onBackdropChange={onBackdropChange}>
<div className="absolute inset-0 flex items-center justify-center">
<LoadingControl />
</div>
<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"
<VideoPlayerError title={props.title} onGoBack={props.onGoBack}>
<BackdropControl onBackdropChange={onBackdropChange}>
<div className="absolute inset-0 flex items-center justify-center">
<LoadingControl />
</div>
<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",
}}
>
<ProgressControl />
<div className="flex items-center">
<LeftSideControls />
<div className="flex-1" />
<FullscreenControl />
<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>
</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"
</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",
}}
>
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
</div>
</CSSTransition>
</BackdropControl>
{props.children}
<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>
</CSSTransition>
</BackdropControl>
{props.children}
</VideoPlayerError>
</VideoPlayer>
);
}

View File

@ -1,4 +1,6 @@
import { useGoBack } from "@/hooks/useGoBack";
import { forwardRef, useContext, useEffect, useRef } from "react";
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary";
import { VideoPlayerContext, VideoPlayerContextProvider } from "./VideoContext";
export interface VideoPlayerProps {
@ -35,6 +37,9 @@ const VideoPlayerInternals = forwardRef<
export function VideoPlayer(props: VideoPlayerProps) {
const playerRef = useRef<HTMLVideoElement | null>(null);
const playerWrapperRef = useRef<HTMLDivElement | null>(null);
const goBack = useGoBack();
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
return (
<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"
ref={playerWrapperRef}
>
<VideoPlayerInternals
autoPlay={props.autoPlay ?? false}
ref={playerRef}
/>
<div className="absolute inset-0">{props.children}</div>
<VideoErrorBoundary onGoBack={goBack}>
<VideoPlayerInternals
autoPlay={props.autoPlay ?? false}
ref={playerRef}
/>
<div className="absolute inset-0">{props.children}</div>
</VideoErrorBoundary>
</div>
</VideoPlayerContextProvider>
);

View File

@ -108,19 +108,36 @@ export function populateControls(
initPlayer(sourceUrl: string, sourceType: MWStreamType) {
this.setVolume(getStoredVolume());
// TODO test HLS errors
if (sourceType === MWStreamType.HLS) {
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
if (!Hls.isSupported()) {
update((s) => ({
...s,
error: {
name: `Not supported`,
description: "Your browser does not support HLS video",
},
}));
return;
}
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
if (data.fatal) {
update((s) => ({
...s,
error: {
name: `error ${data.details}`,
description: data.error?.message ?? "Something went wrong",
},
}));
}
console.error("HLS error", data);
});
hls.attachMedia(player);

View File

@ -23,6 +23,10 @@ export type PlayerState = {
hasInitialized: boolean;
leftControlHovering: boolean;
hasPlayedOnce: boolean;
error: null | {
name: string;
description: string;
};
};
export type PlayerContext = PlayerState & PlayerControls;
@ -42,6 +46,7 @@ export const initialPlayerState: PlayerContext = {
hasInitialized: false,
leftControlHovering: false,
hasPlayedOnce: false,
error: null,
...initialControls,
};
@ -61,6 +66,7 @@ function readState(player: HTMLVideoElement, update: SetPlayer) {
state.buffered = handleBuffered(player.currentTime, player.buffered);
state.isLoading = false;
state.hasInitialized = true;
state.error = null;
update((s) => ({
...state,
@ -131,6 +137,19 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
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("playing", playing);
@ -143,6 +162,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
player.addEventListener("progress", progress);
player.addEventListener("waiting", waiting);
player.addEventListener("canplay", canplay);
player.addEventListener("error", error);
return () => {
player.removeEventListener("pause", pause);
@ -156,6 +176,7 @@ function registerListeners(player: HTMLVideoElement, update: SetPlayer) {
player.removeEventListener("progress", progress);
player.removeEventListener("waiting", waiting);
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:
// - error handling
// - captions
// - mobile UI
// - safari fullscreen will make video overlap player controls
@ -35,6 +34,7 @@ if (key) {
// TODO general todos:
// - localize everything
// - add titles to pages
ReactDOM.render(
<React.StrictMode>

View File

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