1
0
Fork 0
mirror of https://github.com/sussy-code/smov.git synced 2025-01-01 16:37:39 +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:
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.current = value;
}, [didInitialize, video]); }, [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 // muted attribute is required for safari, as they cant change the volume itself
return ( return (
<video <video

View file

@ -14,7 +14,11 @@ import { VideoProgressStore } from "./store";
const FIVETEEN_MINUTES = 15 * 60; const FIVETEEN_MINUTES = 15 * 60;
const FIVE_MINUTES = 5 * 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); const timeFromEnd = Math.max(0, duration - time);
// short movie // short movie
@ -26,7 +30,7 @@ function shouldSave(time: number, duration: number): boolean {
// long movie // long movie
if (time < 30) return false; if (time < 30) return false;
if (timeFromEnd < FIVE_MINUTES) return false; if (timeFromEnd < FIVE_MINUTES && !isSeries) return false;
return true; return true;
} }
@ -126,9 +130,10 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
// update actual item // update actual item
item.progress = progress; item.progress = progress;
item.percentage = Math.round((progress / total) * 100); item.percentage = Math.round((progress / total) * 100);
item.watchedAt = Date.now();
// remove item if shouldnt save // remove item if shouldnt save
if (!shouldSave(progress, total)) { if (!shouldSave(progress, total, !!media.series)) {
newData.items = data.items.filter( newData.items = data.items.filter(
(v) => !isSameEpisode(v.item, media) (v) => !isSameEpisode(v.item, media)
); );

View file

@ -1,5 +1,6 @@
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { AirplayAction } from "@/video/components/actions/AirplayAction";
import { BackdropAction } from "@/video/components/actions/BackdropAction"; import { BackdropAction } from "@/video/components/actions/BackdropAction";
import { FullscreenAction } from "@/video/components/actions/FullscreenAction"; import { FullscreenAction } from "@/video/components/actions/FullscreenAction";
import { HeaderAction } from "@/video/components/actions/HeaderAction"; 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 { QualityDisplayAction } from "@/video/components/actions/QualityDisplayAction";
import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction"; import { SeriesSelectionAction } from "@/video/components/actions/SeriesSelectionAction";
import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction"; import { ShowTitleAction } from "@/video/components/actions/ShowTitleAction";
import { KeyboardShortcutsAction } from "@/video/components/actions/KeyboardShortcutsAction";
import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction"; import { SkipTimeAction } from "@/video/components/actions/SkipTimeAction";
import { TimeAction } from "@/video/components/actions/TimeAction"; import { TimeAction } from "@/video/components/actions/TimeAction";
import { VolumeAction } from "@/video/components/actions/VolumeAction"; 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 { useControls } from "@/video/state/logic/controls";
import { ReactNode, useCallback, useState } from "react"; import { ReactNode, useCallback, useState } from "react";
type Props = VideoPlayerBaseProps & { type Props = VideoPlayerBaseProps;
onGoBack?: () => void;
};
function CenterPosition(props: { children: ReactNode }) { function CenterPosition(props: { children: ReactNode }) {
return ( return (
@ -75,74 +75,89 @@ export function VideoPlayer(props: Props) {
[setShow] [setShow]
); );
// TODO safe area only if full screen or fill screen
// TODO airplay
// TODO source selection // TODO source selection
return ( return (
<VideoPlayerBase autoPlay={props.autoPlay}> <VideoPlayerBase
<PageTitleAction /> autoPlay={props.autoPlay}
<VideoPlayerError onGoBack={props.onGoBack}> includeSafeArea={props.includeSafeArea}
<BackdropAction onBackdropChange={onBackdropChange}> onGoBack={props.onGoBack}
<CenterPosition> >
<LoadingAction /> {({ isFullscreen }) => (
</CenterPosition> <>
<CenterPosition> <KeyboardShortcutsAction />
<MiddlePauseAction /> <PageTitleAction />
</CenterPosition> <VideoPlayerError onGoBack={props.onGoBack}>
{isMobile ? ( <BackdropAction onBackdropChange={onBackdropChange}>
<Transition <CenterPosition>
animation="fade" <LoadingAction />
show={show} </CenterPosition>
className="absolute inset-0 flex items-center justify-center" <CenterPosition>
> <MiddlePauseAction />
<MobileCenterAction /> </CenterPosition>
</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">
{isMobile ? ( {isMobile ? (
<div className="grid w-full grid-cols-[56px,1fr,56px] items-center"> <Transition
<div /> animation="fade"
<div className="flex items-center justify-center"> show={show}
<SeriesSelectionAction /> className="absolute inset-0 flex items-center justify-center"
{/* <SourceSelectionControl media={props.media} /> */} >
</div> <MobileCenterAction />
<FullscreenAction /> </Transition>
</div>
) : ( ) : (
<> ""
<LeftSideControls />
<div className="flex-1" />
<QualityDisplayAction />
<SeriesSelectionAction />
{/* <SourceSelectionControl media={props.media} />
<AirplayControl />
<ChromeCastControl /> */}
<FullscreenAction />
</>
)} )}
</div> <Transition
</Transition> animation="slide-down"
</BackdropAction> show={show}
{props.children} className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
</VideoPlayerError> >
<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> </VideoPlayerBase>
); );
} }

View file

@ -1,28 +1,58 @@
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal"; 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 { useRef } from "react";
import { VideoPlayerContextProvider } from "../state/hooks"; import {
useVideoPlayerDescriptor,
VideoPlayerContextProvider,
} from "../state/hooks";
import { VideoElementInternal } from "./internal/VideoElementInternal"; import { VideoElementInternal } from "./internal/VideoElementInternal";
export interface VideoPlayerBaseProps { export interface VideoPlayerBaseProps {
children?: React.ReactNode; children?:
| React.ReactNode
| ((data: { isFullscreen: boolean }) => React.ReactNode);
autoPlay?: boolean; autoPlay?: boolean;
includeSafeArea?: boolean;
onGoBack?: () => void;
} }
export function VideoPlayerBase(props: VideoPlayerBaseProps) { function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// TODO error boundary const descriptor = useVideoPlayerDescriptor();
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling 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 ( return (
<VideoPlayerContextProvider> <VideoErrorBoundary onGoBack={props.onGoBack} media={media?.meta}>
<div <div
ref={ref} 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} /> <VideoElementInternal autoPlay={props.autoPlay} />
<WrapperRegisterInternal wrapper={ref.current} /> <WrapperRegisterInternal wrapper={ref.current} />
<div className="absolute inset-0">{props.children}</div> <div className="absolute inset-0">{children}</div>
</div> </div>
</VideoErrorBoundary>
);
}
export function VideoPlayerBase(props: VideoPlayerBaseProps) {
return (
<VideoPlayerContextProvider>
<VideoPlayerBaseWithState {...props} />
</VideoPlayerContextProvider> </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 { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
import { useInitialized } from "@/video/components/hooks/useInitialized";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useControls } from "@/video/state/logic/controls"; import { useControls } from "@/video/state/logic/controls";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
@ -12,13 +13,15 @@ interface SourceControllerProps {
export function SourceController(props: SourceControllerProps) { export function SourceController(props: SourceControllerProps) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const controls = useControls(descriptor); const controls = useControls(descriptor);
const { initialized } = useInitialized(descriptor);
const didInitialize = useRef<boolean>(false); const didInitialize = useRef<boolean>(false);
useEffect(() => { useEffect(() => {
if (didInitialize.current) return; if (didInitialize.current) return;
if (!initialized) return;
controls.setSource(props); controls.setSource(props);
didInitialize.current = true; didInitialize.current = true;
}, [props, controls]); }, [props, controls, initialized]);
return null; 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 { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
import { useMisc } from "@/video/state/logic/misc";
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils"; import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider"; import { createVideoStateProvider } from "@/video/state/providers/videoStateProvider";
import { useEffect, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
interface Props { interface Props {
autoPlay?: boolean; autoPlay?: boolean;
@ -11,9 +12,13 @@ interface Props {
export function VideoElementInternal(props: Props) { export function VideoElementInternal(props: Props) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const mediaPlaying = useMediaPlaying(descriptor); const mediaPlaying = useMediaPlaying(descriptor);
const misc = useMisc(descriptor);
const ref = useRef<HTMLVideoElement>(null); const ref = useRef<HTMLVideoElement>(null);
const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]);
useEffect(() => { useEffect(() => {
if (!initalized) return;
if (!ref.current) return; if (!ref.current) return;
const provider = createVideoStateProvider(descriptor, ref.current); const provider = createVideoStateProvider(descriptor, ref.current);
setProvider(descriptor, provider); setProvider(descriptor, provider);
@ -22,9 +27,7 @@ export function VideoElementInternal(props: Props) {
unsetStateProvider(descriptor); unsetStateProvider(descriptor);
destroy(); destroy();
}; };
}, [descriptor]); }, [descriptor, initalized]);
// TODO shortcuts
// this element is remotely controlled by a state provider // this element is remotely controlled by a state provider
return ( return (

View file

@ -1,5 +1,6 @@
import { getPlayerState } from "@/video/state/cache"; import { getPlayerState } from "@/video/state/cache";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { updateMisc } from "@/video/state/logic/misc";
import { useEffect } from "react"; import { useEffect } from "react";
export function WrapperRegisterInternal(props: { export function WrapperRegisterInternal(props: {
@ -10,6 +11,7 @@ export function WrapperRegisterInternal(props: {
useEffect(() => { useEffect(() => {
const state = getPlayerState(descriptor); const state = getPlayerState(descriptor);
state.wrapperElement = props.wrapper; state.wrapperElement = props.wrapper;
updateMisc(descriptor, state);
}, [props.wrapper, descriptor]); }, [props.wrapper, descriptor]);
return null; 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 { Icons } from "@/components/Icon";
import { Title } from "@/components/text/Title"; import { Title } from "@/components/text/Title";
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useVideoPlayerDescriptor } from "@/video/state/hooks";
import { useError } from "@/video/state/logic/error";
import { useMeta } from "@/video/state/logic/meta"; import { useMeta } from "@/video/state/logic/meta";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { VideoPlayerHeader } from "./VideoPlayerHeader"; import { VideoPlayerHeader } from "./VideoPlayerHeader";
@ -14,9 +15,9 @@ interface VideoPlayerErrorProps {
export function VideoPlayerError(props: VideoPlayerErrorProps) { export function VideoPlayerError(props: VideoPlayerErrorProps) {
const descriptor = useVideoPlayerDescriptor(); const descriptor = useVideoPlayerDescriptor();
const meta = useMeta(descriptor); 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; 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"> <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" /> <IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" />
<Title>Failed to load media</Title> <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} {err?.name}: {err?.description}
</p> </p>
</div> </div>

View file

@ -6,6 +6,7 @@ import {
getIfBookmarkedFromPortable, getIfBookmarkedFromPortable,
useBookmarkContext, useBookmarkContext,
} from "@/state/bookmark"; } from "@/state/bookmark";
import { AirplayAction } from "@/video/components/actions/AirplayAction";
interface VideoPlayerHeaderProps { interface VideoPlayerHeaderProps {
media?: MWMediaMeta; media?: MWMediaMeta;
@ -53,11 +54,10 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
/> />
)} )}
</div> </div>
{props.showControls ? null : ( {props.showControls ? (
// <> <AirplayAction />
// <AirplayControl /> ) : (
// <ChromeCastControl /> // chromecontrol
// </>
<BrandPill /> <BrandPill />
)} )}
</div> </div>

View file

@ -3,7 +3,9 @@ export type VideoPlayerEvent =
| "source" | "source"
| "progress" | "progress"
| "interface" | "interface"
| "meta"; | "meta"
| "error"
| "misc";
function createEventString(id: string, event: VideoPlayerEvent): string { function createEventString(id: string, event: VideoPlayerEvent): string {
return `_vid:::${id}:::${event}`; return `_vid:::${id}:::${event}`;

View file

@ -29,11 +29,12 @@ function initPlayer(): VideoPlayerState {
meta: null, meta: null,
source: null, source: null,
error: null, error: null,
canAirplay: false,
initalized: false,
pausedWhenSeeking: false, pausedWhenSeeking: false,
canAirplay: false,
stateProvider: null, stateProvider: null,
wrapperElement: null, wrapperElement: null,
}; };

View file

@ -44,6 +44,9 @@ export function useControls(
setVolume(volume) { setVolume(volume) {
state.stateProvider?.setVolume(volume); state.stateProvider?.setVolume(volume);
}, },
startAirplay() {
state.stateProvider?.startAirplay();
},
// other controls // other controls
setLeftControlsHover(hovering) { 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; exitFullscreen(): void;
enterFullscreen(): void; enterFullscreen(): void;
setVolume(volume: number): void; setVolume(volume: number): void;
startAirplay(): void;
}; };
export type VideoPlayerStateProvider = VideoPlayerStateController & { export type VideoPlayerStateProvider = VideoPlayerStateController & {

View file

@ -1,3 +1,4 @@
import { updateMisc } from "@/video/state/logic/misc";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
@ -7,6 +8,8 @@ export function setProvider(
) { ) {
const state = getPlayerState(descriptor); const state = getPlayerState(descriptor);
state.stateProvider = provider; state.stateProvider = provider;
state.initalized = true;
updateMisc(descriptor, state);
} }
/** /**

View file

@ -13,12 +13,44 @@ import {
getStoredVolume, getStoredVolume,
setStoredVolume, setStoredVolume,
} from "@/video/components/hooks/volumeStore"; } from "@/video/components/hooks/volumeStore";
import { updateError } from "@/video/state/logic/error";
import { updateMisc } from "@/video/state/logic/misc";
import { getPlayerState } from "../cache"; import { getPlayerState } from "../cache";
import { updateMediaPlaying } from "../logic/mediaplaying"; import { updateMediaPlaying } from "../logic/mediaplaying";
import { VideoPlayerStateProvider } from "./providerTypes"; import { VideoPlayerStateProvider } from "./providerTypes";
import { updateProgress } from "../logic/progress"; import { updateProgress } from "../logic/progress";
import { handleBuffered } from "./utils"; 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( export function createVideoStateProvider(
descriptor: string, descriptor: string,
playerEl: HTMLVideoElement playerEl: HTMLVideoElement
@ -48,6 +80,11 @@ export function createVideoStateProvider(
(player as any).webkitEnterFullscreen(); (player as any).webkitEnterFullscreen();
} }
}, },
startAirplay() {
const videoPlayer = player as any;
if (videoPlayer.webkitShowPlaybackTargetPicker)
videoPlayer.webkitShowPlaybackTargetPicker();
},
setTime(t) { setTime(t) {
// clamp time between 0 and max duration // clamp time between 0 and max duration
let time = Math.min(t, player.duration); let time = Math.min(t, player.duration);
@ -103,7 +140,7 @@ export function createVideoStateProvider(
name: `Not supported`, name: `Not supported`,
description: "Your browser does not support HLS video", description: "Your browser does not support HLS video",
}; };
// TODO dispatch error updateError(descriptor, state);
return; return;
} }
@ -115,7 +152,7 @@ export function createVideoStateProvider(
name: `error ${data.details}`, name: `error ${data.details}`,
description: data.error?.message ?? "Something went wrong", description: data.error?.message ?? "Something went wrong",
}; };
// TODO dispatch error updateError(descriptor, state);
} }
console.error("HLS error", data); console.error("HLS error", data);
}); });
@ -199,18 +236,22 @@ export function createVideoStateProvider(
const canAirplay = (e: any) => { const canAirplay = (e: any) => {
if (e.availability === "available") { if (e.availability === "available") {
state.canAirplay = true; state.canAirplay = true;
// TODO dispatch airplay updateMisc(descriptor, state);
} }
}; };
const error = () => { const error = () => {
console.error("Native video player threw error", player.error); if (player.error) {
state.error = player.error const err = errorMessage(player.error);
? { console.error("Native video player threw error", player.error);
description: player.error.message, state.error = {
name: `Error ${player.error.code}`, description: err.description,
} name: `Error ${err.code}`,
: null; };
// TODO dispatch error this.pause(); // stop video from playing
} else {
state.error = null;
}
updateError(descriptor, state);
}; };
state.wrapperElement?.addEventListener("click", isFocused); state.wrapperElement?.addEventListener("click", isFocused);

View file

@ -50,16 +50,17 @@ export type VideoPlayerState = {
url: string; url: string;
type: MWStreamType; type: MWStreamType;
}; };
// misc
canAirplay: boolean;
initalized: boolean;
error: null | { error: null | {
name: string; name: string;
description: string; description: string;
}; };
// misc
pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek
canAirplay: boolean;
// backing fields // backing fields
pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek
stateProvider: VideoPlayerStateProvider | null; stateProvider: VideoPlayerStateProvider | null;
wrapperElement: HTMLDivElement | null; wrapperElement: HTMLDivElement | null;
}; };

View file

@ -131,7 +131,7 @@ export function MediaViewPlayer(props: MediaViewPlayerProps) {
<Helmet> <Helmet>
<html data-full="true" /> <html data-full="true" />
</Helmet> </Helmet>
<VideoPlayer autoPlay onGoBack={goBack}> <VideoPlayer includeSafeArea autoPlay onGoBack={goBack}>
<MetaController data={metaProps} seasonData={metaSeasonData} /> <MetaController data={metaProps} seasonData={metaSeasonData} />
<SourceController <SourceController
source={props.stream.streamUrl} source={props.stream.streamUrl}