mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-13 08:35:08 +01:00
shortcuts, progress saving fix, error handling, airplay, safe are for full screen only
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
487ba39bbf
commit
76e4bc5851
@ -31,70 +31,6 @@ const VideoPlayerInternals = forwardRef<
|
||||
didInitialize.current = value;
|
||||
}, [didInitialize, video]);
|
||||
|
||||
useEffect(() => {
|
||||
let isRolling = false;
|
||||
const onKeyDown = (evt: KeyboardEvent) => {
|
||||
if (!videoState.isFocused) return;
|
||||
if (!ref || !(ref as any)?.current) return;
|
||||
const el = (ref as any).current as HTMLVideoElement;
|
||||
|
||||
switch (evt.key.toLowerCase()) {
|
||||
// Toggle fullscreen
|
||||
case "f":
|
||||
if (videoState.isFullscreen) {
|
||||
videoState.exitFullscreen();
|
||||
} else {
|
||||
videoState.enterFullscreen();
|
||||
}
|
||||
break;
|
||||
|
||||
// Skip backwards
|
||||
case "arrowleft":
|
||||
videoState.setTime(videoState.time - 5);
|
||||
break;
|
||||
|
||||
// Skip forward
|
||||
case "arrowright":
|
||||
videoState.setTime(videoState.time + 5);
|
||||
break;
|
||||
|
||||
// Pause / play
|
||||
case " ":
|
||||
if (videoState.isPaused) {
|
||||
videoState.play();
|
||||
} else {
|
||||
videoState.pause();
|
||||
}
|
||||
break;
|
||||
|
||||
// Mute
|
||||
case "m":
|
||||
toggleVolume();
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
case "r":
|
||||
if (isRolling) return;
|
||||
isRolling = true;
|
||||
el.classList.add("roll");
|
||||
setTimeout(() => {
|
||||
isRolling = false;
|
||||
el.classList.remove("roll");
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [videoState, toggleVolume, ref]);
|
||||
|
||||
// muted attribute is required for safari, as they cant change the volume itself
|
||||
return (
|
||||
<video
|
||||
|
@ -14,7 +14,11 @@ import { VideoProgressStore } from "./store";
|
||||
const FIVETEEN_MINUTES = 15 * 60;
|
||||
const FIVE_MINUTES = 5 * 60;
|
||||
|
||||
function shouldSave(time: number, duration: number): boolean {
|
||||
function shouldSave(
|
||||
time: number,
|
||||
duration: number,
|
||||
isSeries: boolean
|
||||
): boolean {
|
||||
const timeFromEnd = Math.max(0, duration - time);
|
||||
|
||||
// short movie
|
||||
@ -26,7 +30,7 @@ function shouldSave(time: number, duration: number): boolean {
|
||||
|
||||
// long movie
|
||||
if (time < 30) return false;
|
||||
if (timeFromEnd < FIVE_MINUTES) return false;
|
||||
if (timeFromEnd < FIVE_MINUTES && !isSeries) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -126,9 +130,10 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||
// update actual item
|
||||
item.progress = progress;
|
||||
item.percentage = Math.round((progress / total) * 100);
|
||||
item.watchedAt = Date.now();
|
||||
|
||||
// remove item if shouldnt save
|
||||
if (!shouldSave(progress, total)) {
|
||||
if (!shouldSave(progress, total, !!media.series)) {
|
||||
newData.items = data.items.filter(
|
||||
(v) => !isSameEpisode(v.item, media)
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Transition } from "@/components/Transition";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||
import { BackdropAction } from "@/video/components/actions/BackdropAction";
|
||||
import { FullscreenAction } from "@/video/components/actions/FullscreenAction";
|
||||
import { HeaderAction } from "@/video/components/actions/HeaderAction";
|
||||
@ -12,6 +13,7 @@ import { ProgressAction } from "@/video/components/actions/ProgressAction";
|
||||
import { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction";
|
||||
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
|
||||
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
|
||||
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
|
||||
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
|
||||
import { TimeAction } from "@/video/components/actions/TimeAction";
|
||||
import { VolumeAction } from "@/video/components/actions/VolumeAction";
|
||||
@ -24,9 +26,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { ReactNode, useCallback, useState } from "react";
|
||||
|
||||
type Props = VideoPlayerBaseProps & {
|
||||
onGoBack?: () => void;
|
||||
};
|
||||
type Props = VideoPlayerBaseProps;
|
||||
|
||||
function CenterPosition(props: { children: ReactNode }) {
|
||||
return (
|
||||
@ -75,74 +75,89 @@ export function VideoPlayer(props: Props) {
|
||||
[setShow]
|
||||
);
|
||||
|
||||
// TODO safe area only if full screen or fill screen
|
||||
// TODO airplay
|
||||
// TODO source selection
|
||||
return (
|
||||
<VideoPlayerBase autoPlay={props.autoPlay}>
|
||||
<PageTitleAction />
|
||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||
<CenterPosition>
|
||||
<LoadingAction />
|
||||
</CenterPosition>
|
||||
<CenterPosition>
|
||||
<MiddlePauseAction />
|
||||
</CenterPosition>
|
||||
{isMobile ? (
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={show}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<MobileCenterAction />
|
||||
</Transition>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Transition
|
||||
animation="slide-down"
|
||||
show={show}
|
||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
||||
>
|
||||
<HeaderAction showControls={isMobile} onClick={props.onGoBack} />
|
||||
</Transition>
|
||||
<Transition
|
||||
animation="slide-up"
|
||||
show={show}
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2 [margin-bottom:env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
{isMobile && <TimeAction noDuration />}
|
||||
<ProgressAction />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<VideoPlayerBase
|
||||
autoPlay={props.autoPlay}
|
||||
includeSafeArea={props.includeSafeArea}
|
||||
onGoBack={props.onGoBack}
|
||||
>
|
||||
{({ isFullscreen }) => (
|
||||
<>
|
||||
<KeyboardShortcutsAction />
|
||||
<PageTitleAction />
|
||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
||||
<CenterPosition>
|
||||
<LoadingAction />
|
||||
</CenterPosition>
|
||||
<CenterPosition>
|
||||
<MiddlePauseAction />
|
||||
</CenterPosition>
|
||||
{isMobile ? (
|
||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<SeriesSelectionAction />
|
||||
{/* <SourceSelectionControl media={props.media} /> */}
|
||||
</div>
|
||||
<FullscreenAction />
|
||||
</div>
|
||||
<Transition
|
||||
animation="fade"
|
||||
show={show}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<MobileCenterAction />
|
||||
</Transition>
|
||||
) : (
|
||||
<>
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<QualityDisplayAction />
|
||||
<SeriesSelectionAction />
|
||||
{/* <SourceSelectionControl media={props.media} />
|
||||
<AirplayControl />
|
||||
<ChromeCastControl /> */}
|
||||
<FullscreenAction />
|
||||
</>
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</BackdropAction>
|
||||
{props.children}
|
||||
</VideoPlayerError>
|
||||
<Transition
|
||||
animation="slide-down"
|
||||
show={show}
|
||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
||||
>
|
||||
<HeaderAction
|
||||
showControls={isMobile}
|
||||
onClick={props.onGoBack}
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
animation="slide-up"
|
||||
show={show}
|
||||
className={[
|
||||
"pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2",
|
||||
props.includeSafeArea || isFullscreen
|
||||
? "[margin-bottom:env(safe-area-inset-bottom)]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex w-full items-center space-x-3">
|
||||
{isMobile && <TimeAction noDuration />}
|
||||
<ProgressAction />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isMobile ? (
|
||||
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center">
|
||||
<div />
|
||||
<div className="flex items-center justify-center">
|
||||
<SeriesSelectionAction />
|
||||
{/* <SourceSelectionControl media={props.media} /> */}
|
||||
</div>
|
||||
<FullscreenAction />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<LeftSideControls />
|
||||
<div className="flex-1" />
|
||||
<QualityDisplayAction />
|
||||
<SeriesSelectionAction />
|
||||
{/* <SourceSelectionControl media={props.media} /> */}
|
||||
<AirplayAction />
|
||||
{/* <ChromeCastControl /> */}
|
||||
<FullscreenAction />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Transition>
|
||||
</BackdropAction>
|
||||
{props.children}
|
||||
</VideoPlayerError>
|
||||
</>
|
||||
)}
|
||||
</VideoPlayerBase>
|
||||
);
|
||||
}
|
||||
|
@ -1,28 +1,58 @@
|
||||
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
|
||||
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { useRef } from "react";
|
||||
import { VideoPlayerContextProvider } from "../state/hooks";
|
||||
import {
|
||||
useVideoPlayerDescriptor,
|
||||
VideoPlayerContextProvider,
|
||||
} from "../state/hooks";
|
||||
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
||||
|
||||
export interface VideoPlayerBaseProps {
|
||||
children?: React.ReactNode;
|
||||
children?:
|
||||
| React.ReactNode
|
||||
| ((data: { isFullscreen: boolean }) => React.ReactNode);
|
||||
autoPlay?: boolean;
|
||||
includeSafeArea?: boolean;
|
||||
onGoBack?: () => void;
|
||||
}
|
||||
|
||||
export function VideoPlayerBase(props: VideoPlayerBaseProps) {
|
||||
function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// TODO error boundary
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const media = useMeta(descriptor);
|
||||
|
||||
const children =
|
||||
typeof props.children === "function"
|
||||
? props.children({ isFullscreen: videoInterface.isFullscreen })
|
||||
: props.children;
|
||||
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
return (
|
||||
<VideoPlayerContextProvider>
|
||||
<VideoErrorBoundary onGoBack={props.onGoBack} media={media?.meta}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="is-video-player relative h-full w-full select-none overflow-hidden bg-black [border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
|
||||
className={[
|
||||
"is-video-player relative h-full w-full select-none overflow-hidden bg-black",
|
||||
props.includeSafeArea || videoInterface.isFullscreen
|
||||
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
<VideoElementInternal autoPlay={props.autoPlay} />
|
||||
<WrapperRegisterInternal wrapper={ref.current} />
|
||||
<div className="absolute inset-0">{props.children}</div>
|
||||
<div className="absolute inset-0">{children}</div>
|
||||
</div>
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export function VideoPlayerBase(props: VideoPlayerBaseProps) {
|
||||
return (
|
||||
<VideoPlayerContextProvider>
|
||||
<VideoPlayerBaseWithState {...props} />
|
||||
</VideoPlayerContextProvider>
|
||||
);
|
||||
}
|
||||
|
30
src/video/components/actions/AirplayAction.tsx
Normal file
30
src/video/components/actions/AirplayAction.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
import { useCallback } from "react";
|
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AirplayAction(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const misc = useMisc(descriptor);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
controls.startAirplay();
|
||||
}, [controls]);
|
||||
|
||||
if (!misc.canAirplay) return null;
|
||||
|
||||
return (
|
||||
<VideoPlayerIconButton
|
||||
className={props.className}
|
||||
onClick={handleClick}
|
||||
icon={Icons.AIRPLAY}
|
||||
/>
|
||||
);
|
||||
}
|
90
src/video/components/actions/KeyboardShortcutsAction.tsx
Normal file
90
src/video/components/actions/KeyboardShortcutsAction.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useInterface } from "@/video/state/logic/interface";
|
||||
import { getPlayerState } from "@/video/state/cache";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useProgress } from "@/video/state/logic/progress";
|
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
||||
|
||||
export function KeyboardShortcutsAction() {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const videoInterface = useInterface(descriptor);
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const progress = useProgress(descriptor);
|
||||
const { toggleVolume } = useVolumeControl(descriptor);
|
||||
|
||||
const curTime = useRef<number>(0);
|
||||
useEffect(() => {
|
||||
curTime.current = progress.time;
|
||||
}, [progress]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = getPlayerState(descriptor);
|
||||
const el = state.wrapperElement;
|
||||
if (!el) return;
|
||||
|
||||
let isRolling = false;
|
||||
const onKeyDown = (evt: KeyboardEvent) => {
|
||||
if (!videoInterface.isFocused) return;
|
||||
|
||||
switch (evt.key.toLowerCase()) {
|
||||
// Toggle fullscreen
|
||||
case "f":
|
||||
if (videoInterface.isFullscreen) {
|
||||
controls.exitFullscreen();
|
||||
} else {
|
||||
controls.enterFullscreen();
|
||||
}
|
||||
break;
|
||||
|
||||
// Skip backwards
|
||||
case "arrowleft":
|
||||
controls.setTime(curTime.current - 5);
|
||||
break;
|
||||
|
||||
// Skip forward
|
||||
case "arrowright":
|
||||
controls.setTime(curTime.current + 5);
|
||||
break;
|
||||
|
||||
// Pause / play
|
||||
case " ":
|
||||
if (mediaPlaying.isPaused) {
|
||||
controls.play();
|
||||
} else {
|
||||
controls.pause();
|
||||
}
|
||||
break;
|
||||
|
||||
// Mute
|
||||
case "m":
|
||||
toggleVolume();
|
||||
break;
|
||||
|
||||
// Do a barrel Roll!
|
||||
case "r":
|
||||
if (isRolling || evt.ctrlKey || evt.metaKey) return;
|
||||
isRolling = true;
|
||||
el.classList.add("roll");
|
||||
setTimeout(() => {
|
||||
isRolling = false;
|
||||
el.classList.remove("roll");
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [controls, descriptor, mediaPlaying, videoInterface, toggleVolume]);
|
||||
|
||||
return null;
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
||||
import { useInitialized } from "@/video/components/hooks/useInitialized";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useControls } from "@/video/state/logic/controls";
|
||||
import { useEffect, useRef } from "react";
|
||||
@ -12,13 +13,15 @@ interface SourceControllerProps {
|
||||
export function SourceController(props: SourceControllerProps) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const controls = useControls(descriptor);
|
||||
const { initialized } = useInitialized(descriptor);
|
||||
const didInitialize = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (didInitialize.current) return;
|
||||
if (!initialized) return;
|
||||
controls.setSource(props);
|
||||
didInitialize.current = true;
|
||||
}, [props, controls]);
|
||||
}, [props, controls, initialized]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
10
src/video/components/hooks/useInitialized.ts
Normal file
10
src/video/components/hooks/useInitialized.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useInitialized(descriptor: string): { initialized: boolean } {
|
||||
const misc = useMisc(descriptor);
|
||||
const initialized = useMemo(() => !!misc.initalized, [misc]);
|
||||
return {
|
||||
initialized,
|
||||
};
|
||||
}
|
@ -1,8 +1,9 @@
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||
import { useMisc } from "@/video/state/logic/misc";
|
||||
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
|
||||
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
autoPlay?: boolean;
|
||||
@ -11,9 +12,13 @@ interface Props {
|
||||
export function VideoElementInternal(props: Props) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const mediaPlaying = useMediaPlaying(descriptor);
|
||||
const misc = useMisc(descriptor);
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initalized) return;
|
||||
if (!ref.current) return;
|
||||
const provider = createVideoStateProvider(descriptor, ref.current);
|
||||
setProvider(descriptor, provider);
|
||||
@ -22,9 +27,7 @@ export function VideoElementInternal(props: Props) {
|
||||
unsetStateProvider(descriptor);
|
||||
destroy();
|
||||
};
|
||||
}, [descriptor]);
|
||||
|
||||
// TODO shortcuts
|
||||
}, [descriptor, initalized]);
|
||||
|
||||
// this element is remotely controlled by a state provider
|
||||
return (
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { getPlayerState } from "@/video/state/cache";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { updateMisc } from "@/video/state/logic/misc";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function WrapperRegisterInternal(props: {
|
||||
@ -10,6 +11,7 @@ export function WrapperRegisterInternal(props: {
|
||||
useEffect(() => {
|
||||
const state = getPlayerState(descriptor);
|
||||
state.wrapperElement = props.wrapper;
|
||||
updateMisc(descriptor, state);
|
||||
}, [props.wrapper, descriptor]);
|
||||
|
||||
return null;
|
||||
|
83
src/video/components/parts/VideoErrorBoundary.tsx
Normal file
83
src/video/components/parts/VideoErrorBoundary.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||
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;
|
||||
media?: MWMediaMeta;
|
||||
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
|
||||
media={this.props.media}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||
import { useError } from "@/video/state/logic/error";
|
||||
import { useMeta } from "@/video/state/logic/meta";
|
||||
import { ReactNode } from "react";
|
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||
@ -14,9 +15,9 @@ interface VideoPlayerErrorProps {
|
||||
export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
||||
const descriptor = useVideoPlayerDescriptor();
|
||||
const meta = useMeta(descriptor);
|
||||
// TODO add error state
|
||||
const errorData = useError(descriptor);
|
||||
|
||||
const err = null as any;
|
||||
const err = errorData.error;
|
||||
|
||||
if (!err) return props.children as any;
|
||||
|
||||
@ -25,7 +26,7 @@ export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
||||
<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">
|
||||
<p className="my-6 max-w-lg text-center">
|
||||
{err?.name}: {err?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "@/state/bookmark";
|
||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||
|
||||
interface VideoPlayerHeaderProps {
|
||||
media?: MWMediaMeta;
|
||||
@ -53,11 +54,10 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{props.showControls ? null : (
|
||||
// <>
|
||||
// <AirplayControl />
|
||||
// <ChromeCastControl />
|
||||
// </>
|
||||
{props.showControls ? (
|
||||
<AirplayAction />
|
||||
) : (
|
||||
// chromecontrol
|
||||
<BrandPill />
|
||||
)}
|
||||
</div>
|
||||
|
@ -3,7 +3,9 @@ export type VideoPlayerEvent =
|
||||
| "source"
|
||||
| "progress"
|
||||
| "interface"
|
||||
| "meta";
|
||||
| "meta"
|
||||
| "error"
|
||||
| "misc";
|
||||
|
||||
function createEventString(id: string, event: VideoPlayerEvent): string {
|
||||
return `_vid:::${id}:::${event}`;
|
||||
|
@ -29,11 +29,12 @@ function initPlayer(): VideoPlayerState {
|
||||
|
||||
meta: null,
|
||||
source: null,
|
||||
|
||||
error: null,
|
||||
canAirplay: false,
|
||||
initalized: false,
|
||||
|
||||
pausedWhenSeeking: false,
|
||||
canAirplay: false,
|
||||
|
||||
stateProvider: null,
|
||||
wrapperElement: null,
|
||||
};
|
||||
|
@ -44,6 +44,9 @@ export function useControls(
|
||||
setVolume(volume) {
|
||||
state.stateProvider?.setVolume(volume);
|
||||
},
|
||||
startAirplay() {
|
||||
state.stateProvider?.startAirplay();
|
||||
},
|
||||
|
||||
// other controls
|
||||
setLeftControlsHover(hovering) {
|
||||
|
38
src/video/state/logic/error.ts
Normal file
38
src/video/state/logic/error.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||
import { VideoPlayerState } from "../types";
|
||||
|
||||
export type VideoErrorEvent = {
|
||||
error: null | {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getErrorFromState(state: VideoPlayerState): VideoErrorEvent {
|
||||
return {
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateError(descriptor: string, state: VideoPlayerState) {
|
||||
sendEvent<VideoErrorEvent>(descriptor, "error", getErrorFromState(state));
|
||||
}
|
||||
|
||||
export function useError(descriptor: string): VideoErrorEvent {
|
||||
const state = getPlayerState(descriptor);
|
||||
const [data, setData] = useState<VideoErrorEvent>(getErrorFromState(state));
|
||||
|
||||
useEffect(() => {
|
||||
function update(payload: CustomEvent<VideoErrorEvent>) {
|
||||
setData(payload.detail);
|
||||
}
|
||||
listenEvent(descriptor, "error", update);
|
||||
return () => {
|
||||
unlistenEvent(descriptor, "error", update);
|
||||
};
|
||||
}, [descriptor]);
|
||||
|
||||
return data;
|
||||
}
|
39
src/video/state/logic/misc.ts
Normal file
39
src/video/state/logic/misc.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
||||
import { VideoPlayerState } from "../types";
|
||||
|
||||
export type VideoMiscError = {
|
||||
canAirplay: boolean;
|
||||
wrapperInitialized: boolean;
|
||||
initalized: boolean;
|
||||
};
|
||||
|
||||
function getMiscFromState(state: VideoPlayerState): VideoMiscError {
|
||||
return {
|
||||
canAirplay: state.canAirplay,
|
||||
wrapperInitialized: !!state.wrapperElement,
|
||||
initalized: state.initalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMisc(descriptor: string, state: VideoPlayerState) {
|
||||
sendEvent<VideoMiscError>(descriptor, "misc", getMiscFromState(state));
|
||||
}
|
||||
|
||||
export function useMisc(descriptor: string): VideoMiscError {
|
||||
const state = getPlayerState(descriptor);
|
||||
const [data, setData] = useState<VideoMiscError>(getMiscFromState(state));
|
||||
|
||||
useEffect(() => {
|
||||
function update(payload: CustomEvent<VideoMiscError>) {
|
||||
setData(payload.detail);
|
||||
}
|
||||
listenEvent(descriptor, "misc", update);
|
||||
return () => {
|
||||
unlistenEvent(descriptor, "misc", update);
|
||||
};
|
||||
}, [descriptor]);
|
||||
|
||||
return data;
|
||||
}
|
@ -15,6 +15,7 @@ export type VideoPlayerStateController = {
|
||||
exitFullscreen(): void;
|
||||
enterFullscreen(): void;
|
||||
setVolume(volume: number): void;
|
||||
startAirplay(): void;
|
||||
};
|
||||
|
||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { updateMisc } from "@/video/state/logic/misc";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
|
||||
@ -7,6 +8,8 @@ export function setProvider(
|
||||
) {
|
||||
const state = getPlayerState(descriptor);
|
||||
state.stateProvider = provider;
|
||||
state.initalized = true;
|
||||
updateMisc(descriptor, state);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -13,12 +13,44 @@ import {
|
||||
getStoredVolume,
|
||||
setStoredVolume,
|
||||
} from "@/video/components/hooks/volumeStore";
|
||||
import { updateError } from "@/video/state/logic/error";
|
||||
import { updateMisc } from "@/video/state/logic/misc";
|
||||
import { getPlayerState } from "../cache";
|
||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||
import { updateProgress } from "../logic/progress";
|
||||
import { handleBuffered } from "./utils";
|
||||
|
||||
function errorMessage(err: MediaError) {
|
||||
switch (err.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
return {
|
||||
code: "ABORTED",
|
||||
description: "Video was aborted",
|
||||
};
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
return {
|
||||
code: "NETWORK_ERROR",
|
||||
description: "A network error occured, the video failed to stream",
|
||||
};
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
return {
|
||||
code: "DECODE_ERROR",
|
||||
description: "Video stream could not be decoded",
|
||||
};
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
return {
|
||||
code: "SRC_NOT_SUPPORTED",
|
||||
description: "The video type is not supported by your browser",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
code: "UNKNOWN_ERROR",
|
||||
description: "Unknown media error occured",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createVideoStateProvider(
|
||||
descriptor: string,
|
||||
playerEl: HTMLVideoElement
|
||||
@ -48,6 +80,11 @@ export function createVideoStateProvider(
|
||||
(player as any).webkitEnterFullscreen();
|
||||
}
|
||||
},
|
||||
startAirplay() {
|
||||
const videoPlayer = player as any;
|
||||
if (videoPlayer.webkitShowPlaybackTargetPicker)
|
||||
videoPlayer.webkitShowPlaybackTargetPicker();
|
||||
},
|
||||
setTime(t) {
|
||||
// clamp time between 0 and max duration
|
||||
let time = Math.min(t, player.duration);
|
||||
@ -103,7 +140,7 @@ export function createVideoStateProvider(
|
||||
name: `Not supported`,
|
||||
description: "Your browser does not support HLS video",
|
||||
};
|
||||
// TODO dispatch error
|
||||
updateError(descriptor, state);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -115,7 +152,7 @@ export function createVideoStateProvider(
|
||||
name: `error ${data.details}`,
|
||||
description: data.error?.message ?? "Something went wrong",
|
||||
};
|
||||
// TODO dispatch error
|
||||
updateError(descriptor, state);
|
||||
}
|
||||
console.error("HLS error", data);
|
||||
});
|
||||
@ -199,18 +236,22 @@ export function createVideoStateProvider(
|
||||
const canAirplay = (e: any) => {
|
||||
if (e.availability === "available") {
|
||||
state.canAirplay = true;
|
||||
// TODO dispatch airplay
|
||||
updateMisc(descriptor, state);
|
||||
}
|
||||
};
|
||||
const error = () => {
|
||||
console.error("Native video player threw error", player.error);
|
||||
state.error = player.error
|
||||
? {
|
||||
description: player.error.message,
|
||||
name: `Error ${player.error.code}`,
|
||||
}
|
||||
: null;
|
||||
// TODO dispatch error
|
||||
if (player.error) {
|
||||
const err = errorMessage(player.error);
|
||||
console.error("Native video player threw error", player.error);
|
||||
state.error = {
|
||||
description: err.description,
|
||||
name: `Error ${err.code}`,
|
||||
};
|
||||
this.pause(); // stop video from playing
|
||||
} else {
|
||||
state.error = null;
|
||||
}
|
||||
updateError(descriptor, state);
|
||||
};
|
||||
|
||||
state.wrapperElement?.addEventListener("click", isFocused);
|
||||
|
@ -50,16 +50,17 @@ export type VideoPlayerState = {
|
||||
url: string;
|
||||
type: MWStreamType;
|
||||
};
|
||||
|
||||
// misc
|
||||
canAirplay: boolean;
|
||||
initalized: boolean;
|
||||
error: null | {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
// misc
|
||||
pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek
|
||||
canAirplay: boolean;
|
||||
|
||||
// backing fields
|
||||
pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek
|
||||
stateProvider: VideoPlayerStateProvider | null;
|
||||
wrapperElement: HTMLDivElement | null;
|
||||
};
|
||||
|
@ -131,7 +131,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
|
||||
<Helmet>
|
||||
<html data-full="true" />
|
||||
</Helmet>
|
||||
<VideoPlayer autoPlay onGoBack={goBack}>
|
||||
<VideoPlayer includeSafeArea autoPlay onGoBack={goBack}>
|
||||
<MetaController data={metaProps} seasonData={metaSeasonData} />
|
||||
<SourceController
|
||||
source={props.stream.streamUrl}
|
||||
|
Loading…
Reference in New Issue
Block a user