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:
Jelle van Snik 2023-02-07 16:01:05 +01:00
parent 487ba39bbf
commit 76e4bc5851
23 changed files with 510 additions and 173 deletions

View File

@ -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

View File

@ -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)
);

View File

@ -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>
);
}

View File

@ -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>
);
}

View 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}
/>
);
}

View 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;
}

View File

@ -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;
}

View 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,
};
}

View File

@ -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 (

View File

@ -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;

View 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>
);
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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}`;

View File

@ -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,
};

View File

@ -44,6 +44,9 @@ export function useControls(
setVolume(volume) {
state.stateProvider?.setVolume(volume);
},
startAirplay() {
state.stateProvider?.startAirplay();
},
// other controls
setLeftControlsHover(hovering) {

View 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;
}

View 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;
}

View File

@ -15,6 +15,7 @@ export type VideoPlayerStateController = {
exitFullscreen(): void;
enterFullscreen(): void;
setVolume(volume: number): void;
startAirplay(): void;
};
export type VideoPlayerStateProvider = VideoPlayerStateController & {

View File

@ -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);
}
/**

View File

@ -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);

View File

@ -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;
};

View File

@ -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}