23 changed files with 510 additions and 173 deletions
@ -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> |
||||||
); |
); |
||||||
} |
} |
||||||
|
@ -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} |
||||||
|
/> |
||||||
|
); |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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, |
||||||
|
}; |
||||||
|
} |
@ -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> |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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; |
||||||
|
} |
Loading…
Reference in new issue