mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-21 14:47:41 +01:00
error handling video player
This commit is contained in:
parent
52b063b10a
commit
ca169769bb
9 changed files with 233 additions and 64 deletions
|
@ -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't able to recover, please
|
The app encountered an error and wasn't able to recover, please
|
||||||
|
|
|
@ -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,6 +57,7 @@ export function DecoratedVideoPlayer(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoPlayer autoPlay={props.autoPlay}>
|
<VideoPlayer autoPlay={props.autoPlay}>
|
||||||
|
<VideoPlayerError title={props.title} onGoBack={props.onGoBack}>
|
||||||
<BackdropControl onBackdropChange={onBackdropChange}>
|
<BackdropControl onBackdropChange={onBackdropChange}>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<LoadingControl />
|
<LoadingControl />
|
||||||
|
@ -110,6 +112,7 @@ export function DecoratedVideoPlayer(
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
</BackdropControl>
|
</BackdropControl>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
</VideoPlayerError>
|
||||||
</VideoPlayer>
|
</VideoPlayer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
<VideoErrorBoundary onGoBack={goBack}>
|
||||||
<VideoPlayerInternals
|
<VideoPlayerInternals
|
||||||
autoPlay={props.autoPlay ?? false}
|
autoPlay={props.autoPlay ?? false}
|
||||||
ref={playerRef}
|
ref={playerRef}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0">{props.children}</div>
|
<div className="absolute inset-0">{props.children}</div>
|
||||||
|
</VideoErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</VideoPlayerContextProvider>
|
</VideoPlayerContextProvider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
82
src/components/video/parts/VideoErrorBoundary.tsx
Normal file
82
src/components/video/parts/VideoErrorBoundary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
35
src/components/video/parts/VideoPlayerError.tsx
Normal file
35
src/components/video/parts/VideoPlayerError.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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 />;
|
||||||
|
|
Loading…
Reference in a new issue