mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +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;
|
||||
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't able to recover, please
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
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:
|
||||
// - 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>
|
||||
|
|
|
@ -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 />;
|
||||
|
|
Loading…
Reference in a new issue