From ca169769bbe06639b1d3b95672ee6c7c475b8185 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Sun, 15 Jan 2023 16:51:55 +0100 Subject: [PATCH] error handling video player --- src/components/layout/ErrorBoundary.tsx | 9 +- src/components/video/DecoratedVideoPlayer.tsx | 105 +++++++++--------- src/components/video/VideoPlayer.tsx | 17 ++- src/components/video/hooks/controlVideo.ts | 25 ++++- src/components/video/hooks/useVideoPlayer.ts | 21 ++++ .../video/parts/VideoErrorBoundary.tsx | 82 ++++++++++++++ .../video/parts/VideoPlayerError.tsx | 35 ++++++ src/index.tsx | 2 +- src/views/media/MediaView.tsx | 1 - 9 files changed, 233 insertions(+), 64 deletions(-) create mode 100644 src/components/video/parts/VideoErrorBoundary.tsx create mode 100644 src/components/video/parts/VideoPlayerError.tsx diff --git a/src/components/layout/ErrorBoundary.tsx b/src/components/layout/ErrorBoundary.tsx index 125a352b..5f6adacb 100644 --- a/src/components/layout/ErrorBoundary.tsx +++ b/src/components/layout/ErrorBoundary.tsx @@ -30,17 +30,22 @@ interface ErrorMessageProps { description: string; path: string; }; + localSize?: boolean; children?: React.ReactNode; } export function ErrorMessage(props: ErrorMessageProps) { return ( -
+
Whoops, it broke {props.children ? ( - props.children +

{props.children}

) : (

The app encountered an error and wasn't able to recover, please diff --git a/src/components/video/DecoratedVideoPlayer.tsx b/src/components/video/DecoratedVideoPlayer.tsx index e1582d5c..f5a471ee 100644 --- a/src/components/video/DecoratedVideoPlayer.tsx +++ b/src/components/video/DecoratedVideoPlayer.tsx @@ -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 ( - -

- -
-
- -
- -
+ +
+ +
+
+ +
+ - -
- -
- +
+ +
+ +
+ +
-
- - -
+ - -
-
- - {props.children} +
+ +
+ + + {props.children} + ); } diff --git a/src/components/video/VideoPlayer.tsx b/src/components/video/VideoPlayer.tsx index 02d71440..d00b917a 100644 --- a/src/components/video/VideoPlayer.tsx +++ b/src/components/video/VideoPlayer.tsx @@ -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(null); const playerWrapperRef = useRef(null); + const goBack = useGoBack(); + + // TODO move error boundary to only decorated, shouldn't have styling return ( @@ -42,11 +47,13 @@ export function VideoPlayer(props: VideoPlayerProps) { className="relative h-full w-full select-none overflow-hidden bg-black" ref={playerWrapperRef} > - -
{props.children}
+ + +
{props.children}
+
); diff --git a/src/components/video/hooks/controlVideo.ts b/src/components/video/hooks/controlVideo.ts index 50e85fde..d9e8f87b 100644 --- a/src/components/video/hooks/controlVideo.ts +++ b/src/components/video/hooks/controlVideo.ts @@ -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); diff --git a/src/components/video/hooks/useVideoPlayer.ts b/src/components/video/hooks/useVideoPlayer.ts index 4589c07e..937af32b 100644 --- a/src/components/video/hooks/useVideoPlayer.ts +++ b/src/components/video/hooks/useVideoPlayer.ts @@ -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); }; } diff --git a/src/components/video/parts/VideoErrorBoundary.tsx b/src/components/video/parts/VideoErrorBoundary.tsx new file mode 100644 index 00000000..c05bbd5a --- /dev/null +++ b/src/components/video/parts/VideoErrorBoundary.tsx @@ -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 ( +
+
+ +
+ + The video player encounted a fatal error, please report it to the{" "} + + Discord server + {" "} + or on{" "} + + GitHub + + . + +
+ ); + } +} diff --git a/src/components/video/parts/VideoPlayerError.tsx b/src/components/video/parts/VideoPlayerError.tsx new file mode 100644 index 00000000..26ae3dee --- /dev/null +++ b/src/components/video/parts/VideoPlayerError.tsx @@ -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 ( +
+
+ + Failed to load media +

+ {err.name}: {err.description} +

+
+
+ +
+
+ ); +} diff --git a/src/index.tsx b/src/index.tsx index fce8fd42..008b958c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( diff --git a/src/views/media/MediaView.tsx b/src/views/media/MediaView.tsx index f0224953..b019e688 100644 --- a/src/views/media/MediaView.tsx +++ b/src/views/media/MediaView.tsx @@ -116,7 +116,6 @@ export function MediaView() { }, [exec, params.media]); // TODO watched store - // TODO error page with video header if (loading) return ; if (error) return ;