23 changed files with 510 additions and 173 deletions
@ -1,28 +1,58 @@
@@ -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> |
||||
); |
||||
} |
||||
|
@ -0,0 +1,30 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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