33 changed files with 0 additions and 2225 deletions
@ -1,184 +0,0 @@
@@ -1,184 +0,0 @@
|
||||
import { DetailedMeta } from "@/backend/metadata/getmeta"; |
||||
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||
import { useCallback, useRef, useState } from "react"; |
||||
import { CSSTransition } from "react-transition-group"; |
||||
import { AirplayControl } from "./controls/AirplayControl"; |
||||
import { BackdropControl } from "./controls/BackdropControl"; |
||||
import { ChromeCastControl } from "./controls/ChromeCastControl"; |
||||
import { FullscreenControl } from "./controls/FullscreenControl"; |
||||
import { LoadingControl } from "./controls/LoadingControl"; |
||||
import { MiddlePauseControl } from "./controls/MiddlePauseControl"; |
||||
import { MobileCenterControl } from "./controls/MobileCenterControl"; |
||||
import { PageTitleControl } from "./controls/PageTitleControl"; |
||||
import { PauseControl } from "./controls/PauseControl"; |
||||
import { ProgressControl } from "./controls/ProgressControl"; |
||||
import { QualityDisplayControl } from "./controls/QualityDisplayControl"; |
||||
import { SeriesSelectionControl } from "./controls/SeriesSelectionControl"; |
||||
import { ShowTitleControl } from "./controls/ShowTitleControl"; |
||||
import { SkipTime } from "./controls/SkipTime"; |
||||
import { SourceSelectionControl } from "./controls/SourceSelectionControl"; |
||||
import { TimeControl } from "./controls/TimeControl"; |
||||
import { VolumeControl } from "./controls/VolumeControl"; |
||||
import { VideoPlayerError } from "./parts/VideoPlayerError"; |
||||
import { VideoPlayerHeader } from "./parts/VideoPlayerHeader"; |
||||
import { useVideoPlayerState } from "./VideoContext"; |
||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer"; |
||||
|
||||
interface DecoratedVideoPlayerProps { |
||||
media?: DetailedMeta; |
||||
onGoBack?: () => void; |
||||
} |
||||
|
||||
function LeftSideControls() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const handleMouseEnter = useCallback(() => { |
||||
videoState.setLeftControlsHover(true); |
||||
}, [videoState]); |
||||
const handleMouseLeave = useCallback(() => { |
||||
videoState.setLeftControlsHover(false); |
||||
}, [videoState]); |
||||
|
||||
return ( |
||||
<> |
||||
<div |
||||
className="flex items-center px-2" |
||||
onMouseLeave={handleMouseLeave} |
||||
onMouseEnter={handleMouseEnter} |
||||
> |
||||
<PauseControl /> |
||||
<TimeControl /> |
||||
<VolumeControl className="mr-2" /> |
||||
<SkipTime /> |
||||
</div> |
||||
<ShowTitleControl /> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
export function DecoratedVideoPlayer( |
||||
props: VideoPlayerProps & DecoratedVideoPlayerProps |
||||
) { |
||||
const top = useRef<HTMLDivElement>(null); |
||||
const center = useRef<HTMLDivElement>(null); |
||||
const bottom = useRef<HTMLDivElement>(null); |
||||
const [show, setShow] = useState(false); |
||||
const { isMobile } = useIsMobile(); |
||||
|
||||
const onBackdropChange = useCallback( |
||||
(showing: boolean) => { |
||||
setShow(showing); |
||||
}, |
||||
[setShow] |
||||
); |
||||
|
||||
return ( |
||||
<VideoPlayer autoPlay={props.autoPlay}> |
||||
<PageTitleControl media={props.media?.meta} /> |
||||
<VideoPlayerError media={props.media?.meta} onGoBack={props.onGoBack}> |
||||
<BackdropControl onBackdropChange={onBackdropChange}> |
||||
<div className="absolute inset-0 flex items-center justify-center"> |
||||
<LoadingControl /> |
||||
</div> |
||||
<div className="absolute inset-0 flex items-center justify-center"> |
||||
<MiddlePauseControl /> |
||||
</div> |
||||
{isMobile ? ( |
||||
<CSSTransition |
||||
nodeRef={center} |
||||
in={show} |
||||
timeout={200} |
||||
classNames={{ |
||||
exit: "transition-[transform,opacity] duration-200 opacity-100", |
||||
exitActive: "!opacity-0", |
||||
exitDone: "hidden", |
||||
enter: "transition-[transform,opacity] duration-200 opacity-0", |
||||
enterActive: "!opacity-100", |
||||
}} |
||||
> |
||||
<div |
||||
ref={center} |
||||
className="absolute inset-0 flex items-center justify-center" |
||||
> |
||||
<MobileCenterControl /> |
||||
</div> |
||||
</CSSTransition> |
||||
) : ( |
||||
"" |
||||
)} |
||||
<CSSTransition |
||||
nodeRef={top} |
||||
in={show} |
||||
timeout={200} |
||||
classNames={{ |
||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", |
||||
exitActive: "!-translate-y-4 !opacity-0", |
||||
exitDone: "hidden", |
||||
enter: |
||||
"transition-[transform,opacity] -translate-y-4 duration-200 opacity-0", |
||||
enterActive: "!translate-y-0 !opacity-100", |
||||
}} |
||||
> |
||||
<div |
||||
ref={top} |
||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2" |
||||
> |
||||
<VideoPlayerHeader |
||||
media={props.media?.meta} |
||||
onClick={props.onGoBack} |
||||
isMobile={isMobile} |
||||
/> |
||||
</div> |
||||
</CSSTransition> |
||||
<CSSTransition |
||||
nodeRef={bottom} |
||||
in={show} |
||||
timeout={200} |
||||
classNames={{ |
||||
exit: "transition-[transform,opacity] translate-y-0 duration-200 opacity-100", |
||||
exitActive: "!translate-y-4 !opacity-0", |
||||
exitDone: "hidden", |
||||
enter: |
||||
"transition-[transform,opacity] translate-y-4 duration-200 opacity-0", |
||||
enterActive: "!translate-y-0 !opacity-100", |
||||
}} |
||||
> |
||||
<div |
||||
ref={bottom} |
||||
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 && <SkipTime noDuration />} |
||||
<ProgressControl /> |
||||
</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"> |
||||
<SeriesSelectionControl /> |
||||
<SourceSelectionControl media={props.media} /> |
||||
</div> |
||||
<FullscreenControl /> |
||||
</div> |
||||
) : ( |
||||
<> |
||||
<LeftSideControls /> |
||||
<div className="flex-1" /> |
||||
<QualityDisplayControl /> |
||||
<SeriesSelectionControl /> |
||||
<SourceSelectionControl media={props.media} /> |
||||
<AirplayControl /> |
||||
<ChromeCastControl /> |
||||
<FullscreenControl /> |
||||
</> |
||||
)} |
||||
</div> |
||||
</div> |
||||
</CSSTransition> |
||||
</BackdropControl> |
||||
{props.children} |
||||
</VideoPlayerError> |
||||
</VideoPlayer> |
||||
); |
||||
} |
||||
@ -1,98 +0,0 @@
@@ -1,98 +0,0 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||
import React, { |
||||
createContext, |
||||
MutableRefObject, |
||||
useContext, |
||||
useEffect, |
||||
useReducer, |
||||
} from "react"; |
||||
import { |
||||
initialPlayerState, |
||||
PlayerContext, |
||||
useVideoPlayer, |
||||
} from "./hooks/useVideoPlayer"; |
||||
|
||||
interface VideoPlayerContextType { |
||||
source: string | null; |
||||
sourceType: MWStreamType; |
||||
quality: MWStreamQuality; |
||||
state: PlayerContext; |
||||
} |
||||
const initial: VideoPlayerContextType = { |
||||
source: null, |
||||
sourceType: MWStreamType.MP4, |
||||
quality: MWStreamQuality.QUNKNOWN, |
||||
state: initialPlayerState, |
||||
}; |
||||
|
||||
type VideoPlayerContextAction = |
||||
| { |
||||
type: "SET_SOURCE"; |
||||
url: string; |
||||
sourceType: MWStreamType; |
||||
quality: MWStreamQuality; |
||||
} |
||||
| { |
||||
type: "UPDATE_PLAYER"; |
||||
state: PlayerContext; |
||||
}; |
||||
|
||||
function videoPlayerContextReducer( |
||||
original: VideoPlayerContextType, |
||||
action: VideoPlayerContextAction |
||||
): VideoPlayerContextType { |
||||
const video = { ...original }; |
||||
if (action.type === "SET_SOURCE") { |
||||
video.source = action.url; |
||||
video.sourceType = action.sourceType; |
||||
video.quality = action.quality; |
||||
return video; |
||||
} |
||||
if (action.type === "UPDATE_PLAYER") { |
||||
video.state = action.state; |
||||
return video; |
||||
} |
||||
|
||||
return original; |
||||
} |
||||
|
||||
export const VideoPlayerContext = |
||||
createContext<VideoPlayerContextType>(initial); |
||||
export const VideoPlayerDispatchContext = createContext< |
||||
React.Dispatch<VideoPlayerContextAction> |
||||
>(null as any); |
||||
|
||||
export function VideoPlayerContextProvider(props: { |
||||
children: React.ReactNode; |
||||
player: MutableRefObject<HTMLVideoElement | null>; |
||||
wrapper: MutableRefObject<HTMLDivElement | null>; |
||||
}) { |
||||
const { playerState } = useVideoPlayer(props.player, props.wrapper); |
||||
const [videoData, dispatch] = useReducer<typeof videoPlayerContextReducer>( |
||||
videoPlayerContextReducer, |
||||
initial |
||||
); |
||||
|
||||
useEffect(() => { |
||||
dispatch({ |
||||
type: "UPDATE_PLAYER", |
||||
state: playerState, |
||||
}); |
||||
}, [playerState]); |
||||
|
||||
return ( |
||||
<VideoPlayerContext.Provider value={videoData}> |
||||
<VideoPlayerDispatchContext.Provider value={dispatch}> |
||||
{props.children} |
||||
</VideoPlayerDispatchContext.Provider> |
||||
</VideoPlayerContext.Provider> |
||||
); |
||||
} |
||||
|
||||
export function useVideoPlayerState() { |
||||
const { state } = useContext(VideoPlayerContext); |
||||
|
||||
return { |
||||
videoState: state, |
||||
}; |
||||
} |
||||
@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
|
||||
import { useGoBack } from "@/hooks/useGoBack"; |
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle"; |
||||
import { forwardRef, useContext, useEffect, useRef } from "react"; |
||||
import { VideoErrorBoundary } from "./parts/VideoErrorBoundary"; |
||||
import { |
||||
useVideoPlayerState, |
||||
VideoPlayerContext, |
||||
VideoPlayerContextProvider, |
||||
} from "./VideoContext"; |
||||
|
||||
export interface VideoPlayerProps { |
||||
autoPlay?: boolean; |
||||
children?: React.ReactNode; |
||||
} |
||||
|
||||
const VideoPlayerInternals = forwardRef< |
||||
HTMLVideoElement, |
||||
{ autoPlay: boolean } |
||||
>((props, ref) => { |
||||
const video = useContext(VideoPlayerContext); |
||||
const didInitialize = useRef<{ source: string | null } | null>(null); |
||||
const { videoState } = useVideoPlayerState(); |
||||
const { toggleVolume } = useVolumeControl(); |
||||
|
||||
useEffect(() => { |
||||
const value = { source: video.source }; |
||||
const hasChanged = value.source !== didInitialize.current?.source; |
||||
if (!hasChanged) return; |
||||
if (!video.state.hasInitialized || !video.source) return; |
||||
video.state.initPlayer(video.source, video.sourceType); |
||||
didInitialize.current = value; |
||||
}, [didInitialize, video]); |
||||
|
||||
// muted attribute is required for safari, as they cant change the volume itself
|
||||
return ( |
||||
<video |
||||
ref={ref} |
||||
autoPlay={props.autoPlay} |
||||
muted={video.state.volume === 0} |
||||
playsInline |
||||
className="h-full w-full" |
||||
/> |
||||
); |
||||
}); |
||||
|
||||
export function VideoPlayer(props: VideoPlayerProps) { |
||||
const playerRef = useRef<HTMLVideoElement | null>(null); |
||||
const playerWrapperRef = useRef<HTMLDivElement | null>(null); |
||||
const goBack = useGoBack(); |
||||
|
||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
||||
|
||||
return ( |
||||
<VideoPlayerContextProvider player={playerRef} wrapper={playerWrapperRef}> |
||||
<div |
||||
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]" |
||||
ref={playerWrapperRef} |
||||
> |
||||
<VideoErrorBoundary onGoBack={goBack}> |
||||
<VideoPlayerInternals |
||||
autoPlay={props.autoPlay ?? false} |
||||
ref={playerRef} |
||||
/> |
||||
<div className="absolute inset-0">{props.children}</div> |
||||
</VideoErrorBoundary> |
||||
</div> |
||||
</VideoPlayerContextProvider> |
||||
); |
||||
} |
||||
@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
|
||||
import { Icons } from "@/components/Icon"; |
||||
import { useCallback } from "react"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
} |
||||
|
||||
export function AirplayControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const handleClick = useCallback(() => { |
||||
videoState.startAirplay(); |
||||
}, [videoState]); |
||||
|
||||
if (!videoState.canAirplay) return null; |
||||
|
||||
return ( |
||||
<VideoPlayerIconButton |
||||
className={props.className} |
||||
onClick={handleClick} |
||||
icon={Icons.AIRPLAY} |
||||
/> |
||||
); |
||||
} |
||||
@ -1,89 +0,0 @@
@@ -1,89 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface BackdropControlProps { |
||||
children?: React.ReactNode; |
||||
onBackdropChange?: (showing: boolean) => void; |
||||
} |
||||
|
||||
export function BackdropControl(props: BackdropControlProps) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const [moved, setMoved] = useState(false); |
||||
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null); |
||||
const clickareaRef = useRef<HTMLDivElement>(null); |
||||
|
||||
const handleMouseMove = useCallback(() => { |
||||
if (!moved) setMoved(true); |
||||
if (timeout.current) clearTimeout(timeout.current); |
||||
timeout.current = setTimeout(() => { |
||||
if (moved) setMoved(false); |
||||
timeout.current = null; |
||||
}, 3000); |
||||
}, [setMoved, moved]); |
||||
|
||||
const handleMouseLeave = useCallback(() => { |
||||
setMoved(false); |
||||
}, [setMoved]); |
||||
|
||||
const handleClick = useCallback( |
||||
(e: React.MouseEvent<HTMLDivElement>) => { |
||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return; |
||||
|
||||
if (videoState.popout !== null) return; |
||||
|
||||
if (videoState.isPlaying) videoState.pause(); |
||||
else videoState.play(); |
||||
}, |
||||
[videoState, clickareaRef] |
||||
); |
||||
const handleDoubleClick = useCallback( |
||||
(e: React.MouseEvent<HTMLDivElement>) => { |
||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return; |
||||
|
||||
if (!videoState.isFullscreen) videoState.enterFullscreen(); |
||||
else videoState.exitFullscreen(); |
||||
}, |
||||
[videoState, clickareaRef] |
||||
); |
||||
|
||||
const lastBackdropValue = useRef<boolean | null>(null); |
||||
useEffect(() => { |
||||
const currentValue = moved || videoState.isPaused; |
||||
if (currentValue !== lastBackdropValue.current) { |
||||
lastBackdropValue.current = currentValue; |
||||
if (!currentValue) videoState.closePopout(); |
||||
props.onBackdropChange?.(currentValue); |
||||
} |
||||
}, [videoState, moved, props]); |
||||
const showUI = moved || videoState.isPaused; |
||||
|
||||
return ( |
||||
<div |
||||
className={`absolute inset-0 ${!showUI ? "cursor-none" : ""}`} |
||||
onMouseMove={handleMouseMove} |
||||
onMouseLeave={handleMouseLeave} |
||||
ref={clickareaRef} |
||||
onClick={handleClick} |
||||
onDoubleClick={handleDoubleClick} |
||||
> |
||||
<div |
||||
className={`pointer-events-none absolute inset-0 bg-black bg-opacity-20 transition-opacity duration-200 ${ |
||||
!showUI ? "!opacity-0" : "" |
||||
}`}
|
||||
/> |
||||
<div |
||||
className={`pointer-events-none absolute inset-x-0 bottom-0 h-[20%] bg-gradient-to-t from-black to-transparent transition-opacity duration-200 ${ |
||||
!showUI ? "!opacity-0" : "" |
||||
}`}
|
||||
/> |
||||
<div |
||||
className={`pointer-events-none absolute inset-x-0 top-0 h-[20%] bg-gradient-to-b from-black to-transparent transition-opacity duration-200 ${ |
||||
!showUI ? "!opacity-0" : "" |
||||
}`}
|
||||
/> |
||||
<div className="pointer-events-none absolute inset-0"> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,15 +0,0 @@
@@ -1,15 +0,0 @@
|
||||
declare global { |
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace JSX { |
||||
interface IntrinsicElements { |
||||
"google-cast-launcher": React.DetailedHTMLProps< |
||||
React.HTMLAttributes<HTMLElement>, |
||||
HTMLElement |
||||
>; |
||||
} |
||||
} |
||||
} |
||||
|
||||
export function ChromeCastControl() { |
||||
return <google-cast-launcher />; |
||||
} |
||||
@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
|
||||
import { Icons } from "@/components/Icon"; |
||||
import { canFullscreen } from "@/utils/detectFeatures"; |
||||
import { useCallback } from "react"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
} |
||||
|
||||
export function FullscreenControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const handleClick = useCallback(() => { |
||||
if (videoState.isFullscreen) videoState.exitFullscreen(); |
||||
else videoState.enterFullscreen(); |
||||
}, [videoState]); |
||||
|
||||
if (!canFullscreen()) return null; |
||||
|
||||
return ( |
||||
<VideoPlayerIconButton |
||||
className={props.className} |
||||
onClick={handleClick} |
||||
icon={videoState.isFullscreen ? Icons.COMPRESS : Icons.EXPAND} |
||||
/> |
||||
); |
||||
} |
||||
@ -1,12 +0,0 @@
@@ -1,12 +0,0 @@
|
||||
import { Spinner } from "@/components/layout/Spinner"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
export function LoadingControl() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const isLoading = videoState.isFirstLoading || videoState.isLoading; |
||||
|
||||
if (!isLoading) return null; |
||||
|
||||
return <Spinner />; |
||||
} |
||||
@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { useCallback } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
export function MiddlePauseControl() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const handleClick = useCallback(() => { |
||||
if (videoState?.isPlaying) videoState.pause(); |
||||
else videoState.play(); |
||||
}, [videoState]); |
||||
|
||||
if (videoState.hasPlayedOnce) return null; |
||||
if (videoState.isPlaying) return null; |
||||
if (videoState.isFirstLoading) return null; |
||||
|
||||
return ( |
||||
<div |
||||
onClick={handleClick} |
||||
className="group pointer-events-auto flex h-16 w-16 items-center justify-center rounded-full bg-denim-400 text-white transition-[background-color,transform] hover:scale-125 hover:bg-denim-500 active:scale-100" |
||||
> |
||||
<Icon |
||||
icon={Icons.PLAY} |
||||
className="text-2xl transition-transform group-hover:scale-125" |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
|
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
import { PauseControl } from "./PauseControl"; |
||||
import { SkipTimeBackward, SkipTimeForward } from "./TimeControl"; |
||||
|
||||
export function MobileCenterControl() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const isLoading = videoState.isFirstLoading || videoState.isLoading; |
||||
|
||||
return ( |
||||
<div className="flex items-center space-x-8"> |
||||
<SkipTimeBackward /> |
||||
<PauseControl |
||||
iconSize="text-5xl" |
||||
className={isLoading ? "pointer-events-none opacity-0" : ""} |
||||
/> |
||||
<SkipTimeForward /> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
||||
|
||||
interface PageTitleControlProps { |
||||
media?: MWMediaMeta; |
||||
} |
||||
|
||||
export function PageTitleControl(props: PageTitleControlProps) { |
||||
const { isSeries, humanizedEpisodeId } = useCurrentSeriesEpisodeInfo(); |
||||
|
||||
if (!props.media) return null; |
||||
|
||||
const title = isSeries |
||||
? `${props.media.title} - ${humanizedEpisodeId}` |
||||
: props.media.title; |
||||
|
||||
return ( |
||||
<Helmet> |
||||
<title>{title}</title> |
||||
</Helmet> |
||||
); |
||||
} |
||||
@ -1,30 +0,0 @@
@@ -1,30 +0,0 @@
|
||||
import { Icons } from "@/components/Icon"; |
||||
import { useCallback } from "react"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
iconSize?: string; |
||||
} |
||||
|
||||
export function PauseControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const handleClick = useCallback(() => { |
||||
if (videoState?.isPlaying) videoState.pause(); |
||||
else videoState.play(); |
||||
}, [videoState]); |
||||
|
||||
const icon = |
||||
videoState.isPlaying || videoState.isSeeking ? Icons.PAUSE : Icons.PLAY; |
||||
|
||||
return ( |
||||
<VideoPlayerIconButton |
||||
iconSize={props.iconSize} |
||||
className={props.className} |
||||
icon={icon} |
||||
onClick={handleClick} |
||||
/> |
||||
); |
||||
} |
||||
@ -1,77 +0,0 @@
@@ -1,77 +0,0 @@
|
||||
import { |
||||
makePercentage, |
||||
makePercentageString, |
||||
useProgressBar, |
||||
} from "@/hooks/useProgressBar"; |
||||
import { useCallback, useEffect, useRef } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
export function ProgressControl() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const dragRef = useRef<boolean>(false); |
||||
|
||||
const commitTime = useCallback( |
||||
(percentage) => { |
||||
videoState.setTime(percentage * videoState.duration); |
||||
}, |
||||
[videoState] |
||||
); |
||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar( |
||||
ref, |
||||
commitTime |
||||
); |
||||
|
||||
// TODO make dragging update timer
|
||||
useEffect(() => { |
||||
if (dragRef.current === dragging) return; |
||||
dragRef.current = dragging; |
||||
videoState.setSeeking(dragging); |
||||
}, [dragRef, dragging, videoState]); |
||||
|
||||
let watchProgress = makePercentageString( |
||||
makePercentage((videoState.time / videoState.duration) * 100) |
||||
); |
||||
if (dragging) |
||||
watchProgress = makePercentageString(makePercentage(dragPercentage)); |
||||
|
||||
const bufferProgress = makePercentageString( |
||||
makePercentage((videoState.buffered / videoState.duration) * 100) |
||||
); |
||||
|
||||
return ( |
||||
<div className="group pointer-events-auto w-full cursor-pointer rounded-full px-2"> |
||||
<div |
||||
ref={ref} |
||||
className="-my-3 flex h-8 items-center" |
||||
onMouseDown={dragMouseDown} |
||||
onTouchStart={dragMouseDown} |
||||
> |
||||
<div |
||||
className={`relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50 transition-[height] duration-100 group-hover:h-2 ${ |
||||
dragging ? "!h-2" : "" |
||||
}`}
|
||||
> |
||||
<div |
||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-gray-300 bg-opacity-20" |
||||
style={{ |
||||
width: bufferProgress, |
||||
}} |
||||
/> |
||||
<div |
||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-600" |
||||
style={{ |
||||
width: watchProgress, |
||||
}} |
||||
> |
||||
<div |
||||
className={`absolute h-1 w-1 translate-x-1/2 rounded-full bg-white opacity-0 transition-[transform,opacity] group-hover:scale-[400%] group-hover:opacity-100 ${ |
||||
dragging ? "!scale-[400%] !opacity-100" : "" |
||||
}`}
|
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,41 +0,0 @@
@@ -1,41 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from "react"; |
||||
import throttle from "lodash.throttle"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
startAt?: number; |
||||
onProgress?: (time: number, duration: number) => void; |
||||
} |
||||
|
||||
export function ProgressListenerControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const didInitialize = useRef<true | null>(null); |
||||
|
||||
// time updates (throttled)
|
||||
const updateTime = useMemo( |
||||
() => throttle((a: number, b: number) => props.onProgress?.(a, b), 1000), |
||||
[props] |
||||
); |
||||
useEffect(() => { |
||||
if (!videoState.isPlaying) return; |
||||
if (videoState.duration === 0 || videoState.time === 0) return; |
||||
updateTime(videoState.time, videoState.duration); |
||||
}, [videoState, updateTime]); |
||||
useEffect(() => { |
||||
return () => { |
||||
updateTime.cancel(); |
||||
}; |
||||
}, [updateTime]); |
||||
|
||||
// initialize
|
||||
useEffect(() => { |
||||
if (didInitialize.current) return; |
||||
if (!videoState.hasInitialized || Number.isNaN(videoState.duration)) return; |
||||
if (props.startAt !== undefined) { |
||||
videoState.setTime(props.startAt); |
||||
} |
||||
didInitialize.current = true; |
||||
}, [didInitialize, props, videoState]); |
||||
|
||||
return null; |
||||
} |
||||
@ -1,14 +0,0 @@
@@ -1,14 +0,0 @@
|
||||
import { useContext } from "react"; |
||||
import { VideoPlayerContext } from "../VideoContext"; |
||||
|
||||
export function QualityDisplayControl() { |
||||
const videoPlayerContext = useContext(VideoPlayerContext); |
||||
|
||||
return ( |
||||
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors"> |
||||
<p className="text-center text-xs font-bold text-slate-300 transition-colors"> |
||||
{videoPlayerContext.quality} |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,194 +0,0 @@
@@ -1,194 +0,0 @@
|
||||
import React, { useCallback, useMemo, useState } from "react"; |
||||
import { useParams } from "react-router-dom"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { MWMediaType, MWSeasonWithEpisodeMeta } from "@/backend/metadata/types"; |
||||
import { getMetaFromId } from "@/backend/metadata/getmeta"; |
||||
import { decodeJWId } from "@/backend/metadata/justwatch"; |
||||
import { Loading } from "@/components/layout/Loading"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { VideoPopout } from "../parts/VideoPopout"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
} |
||||
|
||||
function PopupSection(props: { |
||||
children?: React.ReactNode; |
||||
className?: string; |
||||
}) { |
||||
return ( |
||||
<div className={["p-4", props.className || ""].join(" ")}> |
||||
{props.children} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function PopupEpisodeSelect() { |
||||
const params = useParams<{ |
||||
media: string; |
||||
}>(); |
||||
const { videoState } = useVideoPlayerState(); |
||||
const [isPickingSeason, setIsPickingSeason] = useState<boolean>(false); |
||||
const { current, seasons } = videoState.seasonData; |
||||
const [currentVisibleSeason, setCurrentVisibleSeason] = useState<{ |
||||
seasonId: string; |
||||
season?: MWSeasonWithEpisodeMeta; |
||||
} | null>(null); |
||||
const [reqSeasonMeta, loading, error] = useLoading( |
||||
(id: string, seasonId: string) => { |
||||
return getMetaFromId(MWMediaType.SERIES, id, seasonId); |
||||
} |
||||
); |
||||
const requestSeason = useCallback( |
||||
(sId: string) => { |
||||
setCurrentVisibleSeason({ |
||||
seasonId: sId, |
||||
season: undefined, |
||||
}); |
||||
setIsPickingSeason(false); |
||||
reqSeasonMeta(decodeJWId(params.media)?.id as string, sId).then((v) => { |
||||
if (v?.meta.type !== MWMediaType.SERIES) return; |
||||
setCurrentVisibleSeason({ |
||||
seasonId: sId, |
||||
season: v?.meta.seasonData, |
||||
}); |
||||
}); |
||||
}, |
||||
[reqSeasonMeta, params.media] |
||||
); |
||||
|
||||
const currentSeasonId = currentVisibleSeason?.seasonId ?? current?.seasonId; |
||||
|
||||
const setCurrent = useCallback( |
||||
(seasonId: string, episodeId: string) => { |
||||
videoState.setCurrentEpisode(seasonId, episodeId); |
||||
}, |
||||
[videoState] |
||||
); |
||||
|
||||
const currentSeasonInfo = useMemo(() => { |
||||
return seasons?.find((season) => season.id === currentSeasonId); |
||||
}, [seasons, currentSeasonId]); |
||||
|
||||
const currentSeasonEpisodes = useMemo(() => { |
||||
if (currentVisibleSeason?.season) { |
||||
return currentVisibleSeason?.season?.episodes; |
||||
} |
||||
return videoState?.seasonData.seasons?.find?.( |
||||
(season) => season && season.id === currentSeasonId |
||||
)?.episodes; |
||||
}, [videoState, currentSeasonId, currentVisibleSeason]); |
||||
|
||||
const toggleIsPickingSeason = () => { |
||||
setIsPickingSeason(!isPickingSeason); |
||||
}; |
||||
|
||||
const setSeason = (id: string) => { |
||||
requestSeason(id); |
||||
setCurrentVisibleSeason({ seasonId: id }); |
||||
}; |
||||
|
||||
if (isPickingSeason) |
||||
return ( |
||||
<> |
||||
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> |
||||
Pick a season |
||||
</PopupSection> |
||||
<PopupSection className="overflow-y-auto"> |
||||
<div className="space-y-1"> |
||||
{currentSeasonInfo |
||||
? videoState?.seasonData?.seasons?.map?.((season) => ( |
||||
<div |
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
||||
key={season.id} |
||||
onClick={() => setSeason(season.id)} |
||||
> |
||||
{season.title} |
||||
</div> |
||||
)) |
||||
: "No season"} |
||||
</div> |
||||
</PopupSection> |
||||
</> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<PopupSection className="flex items-center space-x-3 border-b border-denim-500 font-bold text-white"> |
||||
<button |
||||
className="-m-1.5 rounded p-1.5 hover:bg-denim-600" |
||||
onClick={toggleIsPickingSeason} |
||||
type="button" |
||||
> |
||||
<Icon icon={Icons.CHEVRON_LEFT} /> |
||||
</button> |
||||
<span>{currentSeasonInfo?.title || ""}</span> |
||||
</PopupSection> |
||||
<PopupSection className="overflow-y-auto"> |
||||
{loading ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<Loading /> |
||||
</div> |
||||
) : error ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<div className="flex flex-col flex-wrap items-center text-slate-400"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="text-xl text-bink-600" |
||||
/> |
||||
<p className="mt-6 w-full text-center"> |
||||
Something went wrong loading the episodes for{" "} |
||||
{currentSeasonInfo?.title?.toLowerCase()} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<div className="space-y-1"> |
||||
{currentSeasonEpisodes && currentSeasonInfo |
||||
? currentSeasonEpisodes.map((e) => ( |
||||
<div |
||||
className={[ |
||||
"text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600", |
||||
current?.episodeId === e.id && |
||||
"outline outline-2 outline-denim-700", |
||||
].join(" ")} |
||||
onClick={() => setCurrent(currentSeasonInfo.id, e.id)} |
||||
key={e.id} |
||||
> |
||||
{e.number}. {e.title} |
||||
</div> |
||||
)) |
||||
: "No episodes"} |
||||
</div> |
||||
)} |
||||
</PopupSection> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
export function SeriesSelectionControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
if (!videoState.seasonData.isSeries) return null; |
||||
|
||||
return ( |
||||
<div className={props.className}> |
||||
<div className="relative"> |
||||
<VideoPopout |
||||
id="episodes" |
||||
className="grid grid-rows-[auto,minmax(0,1fr)]" |
||||
> |
||||
<PopupEpisodeSelect /> |
||||
</VideoPopout> |
||||
<VideoPlayerIconButton |
||||
icon={Icons.EPISODES} |
||||
text="Episodes" |
||||
onClick={() => videoState.openPopout("episodes")} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,69 +0,0 @@
@@ -1,69 +0,0 @@
|
||||
import { |
||||
MWSeasonMeta, |
||||
MWSeasonWithEpisodeMeta, |
||||
} from "@/backend/metadata/types"; |
||||
import { useEffect, useRef } from "react"; |
||||
import { PlayerContext } from "../hooks/useVideoPlayer"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface ShowControlProps { |
||||
series?: { |
||||
episodeId: string; |
||||
seasonId: string; |
||||
}; |
||||
seasons: MWSeasonMeta[]; |
||||
seasonData: MWSeasonWithEpisodeMeta; |
||||
onSelect?: (state: { episodeId?: string; seasonId?: string }) => void; |
||||
} |
||||
|
||||
function setVideoShowState(videoState: PlayerContext, props: ShowControlProps) { |
||||
const seasonsWithEpisodes = props.seasons.map((v) => { |
||||
if (v.id === props.seasonData.id) |
||||
return { |
||||
...v, |
||||
episodes: props.seasonData.episodes, |
||||
}; |
||||
return v; |
||||
}); |
||||
|
||||
videoState.setShowData({ |
||||
current: props.series, |
||||
isSeries: !!props.series, |
||||
seasons: seasonsWithEpisodes, |
||||
}); |
||||
} |
||||
|
||||
export function ShowControl(props: ShowControlProps) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const lastState = useRef<{ |
||||
episodeId?: string; |
||||
seasonId?: string; |
||||
} | null>({ |
||||
episodeId: props.series?.episodeId, |
||||
seasonId: props.series?.seasonId, |
||||
}); |
||||
|
||||
const hasInitialized = useRef(false); |
||||
useEffect(() => { |
||||
if (hasInitialized.current) return; |
||||
if (!videoState.hasInitialized) return; |
||||
setVideoShowState(videoState, props); |
||||
hasInitialized.current = true; |
||||
}, [props, videoState]); |
||||
|
||||
useEffect(() => { |
||||
const currentState = { |
||||
episodeId: videoState.seasonData.current?.episodeId, |
||||
seasonId: videoState.seasonData.current?.seasonId, |
||||
}; |
||||
if ( |
||||
currentState.episodeId !== lastState.current?.episodeId || |
||||
currentState.seasonId !== lastState.current?.seasonId |
||||
) { |
||||
lastState.current = currentState; |
||||
props.onSelect?.(currentState); |
||||
} |
||||
}, [videoState, props]); |
||||
|
||||
return null; |
||||
} |
||||
@ -1,15 +0,0 @@
@@ -1,15 +0,0 @@
|
||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
||||
|
||||
export function ShowTitleControl() { |
||||
const { isSeries, currentEpisodeInfo, humanizedEpisodeId } = |
||||
useCurrentSeriesEpisodeInfo(); |
||||
|
||||
if (!isSeries) return null; |
||||
|
||||
return ( |
||||
<p className="ml-8 select-none space-x-2 text-white"> |
||||
<span>{humanizedEpisodeId}</span> |
||||
<span className="opacity-50">{currentEpisodeInfo?.title}</span> |
||||
</p> |
||||
); |
||||
} |
||||
@ -1,47 +0,0 @@
@@ -1,47 +0,0 @@
|
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
function durationExceedsHour(secs: number): boolean { |
||||
return secs > 60 * 60; |
||||
} |
||||
|
||||
function formatSeconds(secs: number, showHours = false): string { |
||||
if (Number.isNaN(secs)) { |
||||
if (showHours) return "0:00:00"; |
||||
return "0:00"; |
||||
} |
||||
|
||||
let time = secs; |
||||
const seconds = Math.floor(time % 60); |
||||
|
||||
time /= 60; |
||||
const minutes = Math.floor(time % 60); |
||||
|
||||
time /= 60; |
||||
const hours = Math.floor(time); |
||||
|
||||
const paddedSecs = seconds.toString().padStart(2, "0"); |
||||
const paddedMins = minutes.toString().padStart(2, "0"); |
||||
|
||||
if (!showHours) return [paddedMins, paddedSecs].join(":"); |
||||
return [hours, paddedMins, paddedSecs].join(":"); |
||||
} |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
noDuration?: boolean; |
||||
} |
||||
|
||||
export function SkipTime(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const hasHours = durationExceedsHour(videoState.duration); |
||||
const time = formatSeconds(videoState.time, hasHours); |
||||
const duration = formatSeconds(videoState.duration, hasHours); |
||||
|
||||
return ( |
||||
<div className={props.className}> |
||||
<p className="select-none text-white"> |
||||
{time} {props.noDuration ? "" : `/ ${duration}`} |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,27 +0,0 @@
@@ -1,27 +0,0 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||
import { useContext, useEffect, useRef } from "react"; |
||||
import { VideoPlayerDispatchContext } from "../VideoContext"; |
||||
|
||||
interface SourceControlProps { |
||||
source: string; |
||||
type: MWStreamType; |
||||
quality: MWStreamQuality; |
||||
} |
||||
|
||||
export function SourceControl(props: SourceControlProps) { |
||||
const dispatch = useContext(VideoPlayerDispatchContext); |
||||
const didInitialize = useRef(false); |
||||
|
||||
useEffect(() => { |
||||
if (didInitialize.current) return; |
||||
dispatch({ |
||||
type: "SET_SOURCE", |
||||
url: props.source, |
||||
sourceType: props.type, |
||||
quality: props.quality, |
||||
}); |
||||
didInitialize.current = true; |
||||
}, [props, dispatch]); |
||||
|
||||
return null; |
||||
} |
||||
@ -1,185 +0,0 @@
@@ -1,185 +0,0 @@
|
||||
import { useParams } from "react-router-dom"; |
||||
import { useCallback, useContext, useMemo, useState } from "react"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { getProviders } from "@/backend/helpers/register"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { DetailedMeta } from "@/backend/metadata/getmeta"; |
||||
import { MWMediaType } from "@/backend/metadata/types"; |
||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider"; |
||||
import { runProvider } from "@/backend/helpers/run"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Loading } from "@/components/layout/Loading"; |
||||
import { |
||||
useVideoPlayerState, |
||||
VideoPlayerDispatchContext, |
||||
} from "../VideoContext"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { VideoPopout } from "../parts/VideoPopout"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
media?: DetailedMeta; |
||||
} |
||||
|
||||
function PopoutSourceSelect(props: { media: DetailedMeta }) { |
||||
const dispatch = useContext(VideoPlayerDispatchContext); |
||||
const providers = useMemo( |
||||
() => getProviders().filter((v) => v.type.includes(props.media.meta.type)), |
||||
[props] |
||||
); |
||||
const { episode, season } = useParams<{ episode: string; season: string }>(); |
||||
const [selected, setSelected] = useState<string | null>(null); |
||||
const selectedProvider = useMemo( |
||||
() => providers.find((v) => v.id === selected), |
||||
[selected, providers] |
||||
); |
||||
|
||||
const [scrapeData, setScrapeData] = useState<MWProviderScrapeResult | null>( |
||||
null |
||||
); |
||||
const [scrapeProvider, loadingProvider, errorProvider] = useLoading( |
||||
async (providerId: string) => { |
||||
const theProvider = providers.find((v) => v.id === providerId); |
||||
if (!theProvider) throw new Error("Invalid provider"); |
||||
return runProvider(theProvider, { |
||||
media: props.media, |
||||
progress: () => {}, |
||||
type: props.media.meta.type, |
||||
episode: (props.media.meta.type === MWMediaType.SERIES |
||||
? episode |
||||
: undefined) as any, |
||||
season: (props.media.meta.type === MWMediaType.SERIES |
||||
? season |
||||
: undefined) as any, |
||||
}); |
||||
} |
||||
); |
||||
|
||||
// TODO add embed support
|
||||
// TODO restore startAt when changing source
|
||||
// TODO auto choose when only one option
|
||||
// TODO close when selecting item
|
||||
// TODO show currently selected provider
|
||||
// TODO clear error state when switching
|
||||
// const [scrapeEmbed, embedLoading, embedError] = useLoading(
|
||||
// async (embed: MWEmbed) => {
|
||||
// if (!embed.type) throw new Error("Invalid embed type");
|
||||
// const theScraper = getEmbedScraperByType(embed.type);
|
||||
// if (!theScraper) throw new Error("Invalid scraper");
|
||||
// return runEmbedScraper(theScraper, {
|
||||
// progress: () => {},
|
||||
// url: embed.url,
|
||||
// });
|
||||
// }
|
||||
// );
|
||||
|
||||
const selectProvider = useCallback( |
||||
(id: string) => { |
||||
scrapeProvider(id).then((v) => { |
||||
if (!v) throw new Error("No scrape result"); |
||||
setScrapeData(v); |
||||
}); |
||||
setSelected(id); |
||||
}, |
||||
[setSelected, scrapeProvider] |
||||
); |
||||
|
||||
if (!selectedProvider) |
||||
return ( |
||||
<> |
||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white"> |
||||
<span>Select video source</span> |
||||
</div> |
||||
<div className="overflow-y-auto p-4"> |
||||
<div className="space-y-1"> |
||||
{providers.map((e) => ( |
||||
<div |
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
||||
onClick={() => selectProvider(e.id)} |
||||
key={e.id} |
||||
> |
||||
{e.displayName} |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<div className="flex items-center space-x-3 border-b border-denim-500 p-4 font-bold text-white"> |
||||
<button |
||||
className="-m-1.5 rounded p-1.5 hover:bg-denim-600" |
||||
onClick={() => setSelected(null)} |
||||
type="button" |
||||
> |
||||
<Icon icon={Icons.CHEVRON_LEFT} /> |
||||
</button> |
||||
<span>{selectedProvider.displayName}</span> |
||||
</div> |
||||
<div className="overflow-y-auto p-4 text-white"> |
||||
{loadingProvider ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<Loading /> |
||||
</div> |
||||
) : errorProvider ? ( |
||||
<div className="flex h-full w-full items-center justify-center"> |
||||
<div className="flex flex-col flex-wrap items-center text-slate-400"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="text-xl text-bink-600" |
||||
/> |
||||
<p className="mt-6 w-full text-center"> |
||||
Something went wrong loading streams. |
||||
</p> |
||||
</div> |
||||
</div> |
||||
) : scrapeData ? ( |
||||
<div> |
||||
{scrapeData.stream ? ( |
||||
<div |
||||
className="text-denim-800 -mx-2 flex items-center space-x-1 rounded p-2 text-white hover:bg-denim-600" |
||||
onClick={() => |
||||
scrapeData.stream && |
||||
dispatch({ |
||||
url: scrapeData.stream.streamUrl, |
||||
quality: scrapeData.stream.quality, |
||||
sourceType: scrapeData.stream.type, |
||||
type: "SET_SOURCE", |
||||
}) |
||||
} |
||||
> |
||||
{selectedProvider.displayName} |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
export function SourceSelectionControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
if (!props.media) return null; |
||||
|
||||
return ( |
||||
<div className={props.className}> |
||||
<div className="relative"> |
||||
<VideoPopout |
||||
id="source" |
||||
className="grid grid-rows-[auto,minmax(0,1fr)]" |
||||
> |
||||
<PopoutSourceSelect media={props.media} /> |
||||
</VideoPopout> |
||||
<VideoPlayerIconButton |
||||
icon={Icons.FILE} |
||||
text="Video source" |
||||
onClick={() => videoState.openPopout("source")} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,42 +0,0 @@
@@ -1,42 +0,0 @@
|
||||
import { Icons } from "@/components/Icon"; |
||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
} |
||||
|
||||
export function SkipTimeBackward() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const skipBackward = () => { |
||||
videoState.setTime(videoState.time - 10); |
||||
}; |
||||
|
||||
return ( |
||||
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} /> |
||||
); |
||||
} |
||||
|
||||
export function SkipTimeForward() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const skipForward = () => { |
||||
videoState.setTime(videoState.time + 10); |
||||
}; |
||||
|
||||
return ( |
||||
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} /> |
||||
); |
||||
} |
||||
|
||||
export function TimeControl(props: Props) { |
||||
return ( |
||||
<div className={props.className}> |
||||
<div className="flex select-none items-center text-white"> |
||||
<SkipTimeBackward /> |
||||
<SkipTimeForward /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,86 +0,0 @@
@@ -1,86 +0,0 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { |
||||
makePercentage, |
||||
makePercentageString, |
||||
useProgressBar, |
||||
} from "@/hooks/useProgressBar"; |
||||
import { useVolumeControl } from "@/hooks/useVolumeToggle"; |
||||
import { canChangeVolume } from "@/utils/detectFeatures"; |
||||
import { useCallback, useEffect, useRef, useState } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
className?: string; |
||||
} |
||||
|
||||
export function VolumeControl(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const ref = useRef<HTMLDivElement>(null); |
||||
const { setStoredVolume, toggleVolume } = useVolumeControl(); |
||||
const [hoveredOnce, setHoveredOnce] = useState(false); |
||||
|
||||
const commitVolume = useCallback( |
||||
(percentage) => { |
||||
videoState.setVolume(percentage); |
||||
setStoredVolume(percentage); |
||||
}, |
||||
[videoState, setStoredVolume] |
||||
); |
||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar( |
||||
ref, |
||||
commitVolume, |
||||
true |
||||
); |
||||
|
||||
useEffect(() => { |
||||
if (!videoState.leftControlHovering) setHoveredOnce(false); |
||||
}, [videoState, setHoveredOnce]); |
||||
|
||||
const handleClick = useCallback(() => { |
||||
toggleVolume(); |
||||
}, [toggleVolume]); |
||||
|
||||
const handleMouseEnter = useCallback(async () => { |
||||
if (await canChangeVolume()) setHoveredOnce(true); |
||||
}, [setHoveredOnce]); |
||||
|
||||
let percentage = makePercentage(videoState.volume * 100); |
||||
if (dragging) percentage = makePercentage(dragPercentage); |
||||
const percentageString = makePercentageString(percentage); |
||||
|
||||
return ( |
||||
<div className={props.className}> |
||||
<div |
||||
className="pointer-events-auto flex cursor-pointer items-center" |
||||
onMouseEnter={handleMouseEnter} |
||||
> |
||||
<div className="px-4 text-2xl text-white" onClick={handleClick}> |
||||
<Icon icon={percentage > 0 ? Icons.VOLUME : Icons.VOLUME_X} /> |
||||
</div> |
||||
<div |
||||
className={`linear -ml-2 w-0 overflow-hidden transition-[width,opacity] duration-300 ${ |
||||
hoveredOnce || dragging ? "!w-24 opacity-100" : "w-4 opacity-0" |
||||
}`}
|
||||
> |
||||
<div |
||||
ref={ref} |
||||
className="flex h-10 w-20 items-center px-2" |
||||
onMouseDown={dragMouseDown} |
||||
onTouchStart={dragMouseDown} |
||||
> |
||||
<div className="relative h-1 flex-1 rounded-full bg-gray-500 bg-opacity-50"> |
||||
<div |
||||
className="absolute inset-y-0 left-0 flex items-center justify-end rounded-full bg-bink-500" |
||||
style={{ |
||||
width: percentageString, |
||||
}} |
||||
> |
||||
<div className="absolute h-3 w-3 translate-x-1/2 rounded-full bg-white" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,204 +0,0 @@
@@ -1,204 +0,0 @@
|
||||
import Hls from "hls.js"; |
||||
import { |
||||
canChangeVolume, |
||||
canFullscreen, |
||||
canFullscreenAnyElement, |
||||
canWebkitFullscreen, |
||||
} from "@/utils/detectFeatures"; |
||||
import { MWStreamType } from "@/backend/helpers/streams"; |
||||
import fscreen from "fscreen"; |
||||
import React, { RefObject } from "react"; |
||||
import { PlayerState } from "./useVideoPlayer"; |
||||
import { getStoredVolume, setStoredVolume } from "./volumeStore"; |
||||
|
||||
interface ShowData { |
||||
current?: { |
||||
episodeId: string; |
||||
seasonId: string; |
||||
}; |
||||
isSeries: boolean; |
||||
seasons?: { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
episodes?: { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
}[]; |
||||
}[]; |
||||
} |
||||
|
||||
export interface PlayerControls { |
||||
play(): void; |
||||
pause(): void; |
||||
exitFullscreen(): void; |
||||
enterFullscreen(): void; |
||||
setTime(time: number): void; |
||||
setVolume(volume: number): void; |
||||
setSeeking(active: boolean): void; |
||||
setLeftControlsHover(hovering: boolean): void; |
||||
initPlayer(sourceUrl: string, sourceType: MWStreamType): void; |
||||
setShowData(data: ShowData): void; |
||||
setCurrentEpisode(sId: string, eId: string): void; |
||||
startAirplay(): void; |
||||
openPopout(id: string): void; |
||||
closePopout(): void; |
||||
} |
||||
|
||||
export const initialControls: PlayerControls = { |
||||
play: () => null, |
||||
pause: () => null, |
||||
enterFullscreen: () => null, |
||||
exitFullscreen: () => null, |
||||
setTime: () => null, |
||||
setVolume: () => null, |
||||
setSeeking: () => null, |
||||
setLeftControlsHover: () => null, |
||||
initPlayer: () => null, |
||||
setShowData: () => null, |
||||
startAirplay: () => null, |
||||
setCurrentEpisode: () => null, |
||||
openPopout: () => null, |
||||
closePopout: () => null, |
||||
}; |
||||
|
||||
export function populateControls( |
||||
playerEl: HTMLVideoElement, |
||||
wrapperEl: HTMLDivElement, |
||||
update: (s: React.SetStateAction<PlayerState>) => void, |
||||
state: RefObject<PlayerState> |
||||
): PlayerControls { |
||||
const player = playerEl; |
||||
const wrapper = wrapperEl; |
||||
|
||||
return { |
||||
play() { |
||||
player.play(); |
||||
}, |
||||
pause() { |
||||
player.pause(); |
||||
}, |
||||
enterFullscreen() { |
||||
if (!canFullscreen() || fscreen.fullscreenElement) return; |
||||
if (canFullscreenAnyElement()) { |
||||
fscreen.requestFullscreen(wrapper); |
||||
return; |
||||
} |
||||
if (canWebkitFullscreen()) { |
||||
(player as any).webkitEnterFullscreen(); |
||||
} |
||||
}, |
||||
exitFullscreen() { |
||||
if (!fscreen.fullscreenElement) return; |
||||
fscreen.exitFullscreen(); |
||||
}, |
||||
setTime(t) { |
||||
// clamp time between 0 and max duration
|
||||
let time = Math.min(t, player.duration); |
||||
time = Math.max(0, time); |
||||
|
||||
if (Number.isNaN(time)) return; |
||||
|
||||
// update state
|
||||
player.currentTime = time; |
||||
update((s) => ({ ...s, time })); |
||||
}, |
||||
async setVolume(v) { |
||||
// clamp time between 0 and 1
|
||||
let volume = Math.min(v, 1); |
||||
volume = Math.max(0, volume); |
||||
|
||||
// update state
|
||||
if (await canChangeVolume()) player.volume = volume; |
||||
update((s) => ({ ...s, volume })); |
||||
|
||||
// update localstorage
|
||||
setStoredVolume(volume); |
||||
}, |
||||
setSeeking(active) { |
||||
const currentState = state.current; |
||||
if (!currentState) return; |
||||
|
||||
// if it was playing when starting to seek, play again
|
||||
if (!active) { |
||||
if (!currentState.pausedWhenSeeking) this.play(); |
||||
return; |
||||
} |
||||
|
||||
// when seeking we pause the video
|
||||
update((s) => ({ ...s, pausedWhenSeeking: s.isPaused })); |
||||
this.pause(); |
||||
}, |
||||
setLeftControlsHover(hovering) { |
||||
update((s) => ({ ...s, leftControlHovering: hovering })); |
||||
}, |
||||
openPopout(id: string) { |
||||
update((s) => ({ ...s, popout: id })); |
||||
}, |
||||
closePopout() { |
||||
update((s) => ({ ...s, popout: null })); |
||||
}, |
||||
setShowData(data) { |
||||
update((s) => ({ ...s, seasonData: data })); |
||||
}, |
||||
setCurrentEpisode(sId: string, eId: string) { |
||||
update((s) => ({ |
||||
...s, |
||||
seasonData: { |
||||
...s.seasonData, |
||||
current: { |
||||
seasonId: sId, |
||||
episodeId: eId, |
||||
}, |
||||
}, |
||||
})); |
||||
}, |
||||
startAirplay() { |
||||
const videoPlayer = player as any; |
||||
if (videoPlayer.webkitShowPlaybackTargetPicker) |
||||
videoPlayer.webkitShowPlaybackTargetPicker(); |
||||
}, |
||||
initPlayer(sourceUrl: string, sourceType: MWStreamType) { |
||||
this.setVolume(getStoredVolume()); |
||||
|
||||
if (sourceType === MWStreamType.HLS) { |
||||
if (player.canPlayType("application/vnd.apple.mpegurl")) { |
||||
player.src = sourceUrl; |
||||
} else { |
||||
// HLS support
|
||||
if (!Hls.isSupported()) { |
||||
update((s) => ({ |
||||
...s, |
||||
error: { |
||||
name: `Not supported`, |
||||
description: "Your browser does not support HLS video", |
||||
}, |
||||
})); |
||||
return; |
||||
} |
||||
|
||||
const hls = new Hls({ enableWorker: false }); |
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => { |
||||
if (data.fatal) { |
||||
update((s) => ({ |
||||
...s, |
||||
error: { |
||||
name: `error ${data.details}`, |
||||
description: data.error?.message ?? "Something went wrong", |
||||
}, |
||||
})); |
||||
} |
||||
console.error("HLS error", data); |
||||
}); |
||||
|
||||
hls.attachMedia(player); |
||||
hls.loadSource(sourceUrl); |
||||
} |
||||
} else if (sourceType === MWStreamType.MP4) { |
||||
player.src = sourceUrl; |
||||
} |
||||
}, |
||||
}; |
||||
} |
||||
@ -1,33 +0,0 @@
@@ -1,33 +0,0 @@
|
||||
import { useMemo } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
export function useCurrentSeriesEpisodeInfo() { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const { current, seasons } = videoState.seasonData; |
||||
|
||||
const currentSeasonInfo = useMemo(() => { |
||||
return seasons?.find((season) => season.id === current?.seasonId); |
||||
}, [seasons, current]); |
||||
|
||||
const currentEpisodeInfo = useMemo(() => { |
||||
return currentSeasonInfo?.episodes?.find( |
||||
(episode) => episode.id === current?.episodeId |
||||
); |
||||
}, [currentSeasonInfo, current]); |
||||
|
||||
const isSeries = Boolean( |
||||
videoState.seasonData.isSeries && videoState.seasonData.current |
||||
); |
||||
|
||||
if (!isSeries) return { isSeries: false }; |
||||
|
||||
const humanizedEpisodeId = `S${currentSeasonInfo?.number} E${currentEpisodeInfo?.number}`; |
||||
|
||||
return { |
||||
isSeries: true, |
||||
humanizedEpisodeId, |
||||
currentSeasonInfo, |
||||
currentEpisodeInfo, |
||||
}; |
||||
} |
||||
@ -1,262 +0,0 @@
@@ -1,262 +0,0 @@
|
||||
import { canChangeVolume } from "@/utils/detectFeatures"; |
||||
import fscreen from "fscreen"; |
||||
import React, { MutableRefObject, useEffect, useRef, useState } from "react"; |
||||
import { |
||||
initialControls, |
||||
PlayerControls, |
||||
populateControls, |
||||
} from "./controlVideo"; |
||||
import { handleBuffered } from "./utils"; |
||||
|
||||
export type PlayerState = { |
||||
isPlaying: boolean; |
||||
isPaused: boolean; |
||||
isSeeking: boolean; |
||||
isLoading: boolean; |
||||
isFirstLoading: boolean; |
||||
isFullscreen: boolean; |
||||
time: number; |
||||
duration: number; |
||||
volume: number; |
||||
buffered: number; |
||||
pausedWhenSeeking: boolean; |
||||
hasInitialized: boolean; |
||||
leftControlHovering: boolean; |
||||
hasPlayedOnce: boolean; |
||||
popout: string | null; |
||||
isFocused: boolean; |
||||
seasonData: { |
||||
isSeries: boolean; |
||||
current?: { |
||||
episodeId: string; |
||||
seasonId: string; |
||||
}; |
||||
seasons?: { |
||||
id: string; |
||||
number: number; |
||||
title: string; |
||||
episodes?: { id: string; number: number; title: string }[]; |
||||
}[]; |
||||
}; |
||||
error: null | { |
||||
name: string; |
||||
description: string; |
||||
}; |
||||
canAirplay: boolean; |
||||
}; |
||||
|
||||
export type PlayerContext = PlayerState & PlayerControls; |
||||
|
||||
export const initialPlayerState: PlayerContext = { |
||||
isPlaying: false, |
||||
isPaused: true, |
||||
isFullscreen: false, |
||||
isFocused: false, |
||||
isLoading: false, |
||||
isSeeking: false, |
||||
isFirstLoading: true, |
||||
time: 0, |
||||
duration: 0, |
||||
volume: 0, |
||||
buffered: 0, |
||||
pausedWhenSeeking: false, |
||||
hasInitialized: false, |
||||
leftControlHovering: false, |
||||
hasPlayedOnce: false, |
||||
error: null, |
||||
popout: null, |
||||
seasonData: { |
||||
isSeries: false, |
||||
}, |
||||
canAirplay: false, |
||||
...initialControls, |
||||
}; |
||||
|
||||
type SetPlayer = (s: React.SetStateAction<PlayerContext>) => void; |
||||
|
||||
function readState(player: HTMLVideoElement, update: SetPlayer) { |
||||
const state = { |
||||
...initialPlayerState, |
||||
}; |
||||
state.isPaused = player.paused; |
||||
state.isPlaying = !player.paused; |
||||
state.isFullscreen = !!document.fullscreenElement; |
||||
state.isSeeking = player.seeking; |
||||
state.time = player.currentTime; |
||||
state.duration = player.duration; |
||||
state.volume = player.volume; |
||||
state.buffered = handleBuffered(player.currentTime, player.buffered); |
||||
state.isLoading = false; |
||||
state.hasInitialized = true; |
||||
state.error = null; |
||||
|
||||
update((s) => ({ |
||||
...state, |
||||
pausedWhenSeeking: s.pausedWhenSeeking, |
||||
hasPlayedOnce: s.hasPlayedOnce, |
||||
isFirstLoading: s.isFirstLoading, |
||||
})); |
||||
} |
||||
|
||||
function registerListeners(player: HTMLVideoElement, update: SetPlayer) { |
||||
const pause = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
isPaused: true, |
||||
isPlaying: false, |
||||
})); |
||||
}; |
||||
const playing = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
isPaused: false, |
||||
isPlaying: true, |
||||
isLoading: false, |
||||
hasPlayedOnce: true, |
||||
})); |
||||
}; |
||||
const seeking = () => { |
||||
update((s) => ({ ...s, isSeeking: true })); |
||||
}; |
||||
const seeked = () => { |
||||
update((s) => ({ ...s, isSeeking: false })); |
||||
}; |
||||
const waiting = () => { |
||||
update((s) => ({ ...s, isLoading: true })); |
||||
}; |
||||
const fullscreenchange = () => { |
||||
update((s) => ({ ...s, isFullscreen: !!document.fullscreenElement })); |
||||
}; |
||||
const timeupdate = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
duration: player.duration, |
||||
time: player.currentTime, |
||||
})); |
||||
}; |
||||
const loadedmetadata = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
duration: player.duration, |
||||
})); |
||||
}; |
||||
const volumechange = async () => { |
||||
if (await canChangeVolume()) |
||||
update((s) => ({ |
||||
...s, |
||||
volume: player.volume, |
||||
})); |
||||
}; |
||||
const progress = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
buffered: handleBuffered(player.currentTime, player.buffered), |
||||
})); |
||||
}; |
||||
const canplay = () => { |
||||
update((s) => ({ |
||||
...s, |
||||
isFirstLoading: false, |
||||
})); |
||||
}; |
||||
const error = () => { |
||||
console.error("Native video player threw error", player.error); |
||||
update((s) => ({ |
||||
...s, |
||||
error: player.error |
||||
? { |
||||
description: player.error.message, |
||||
name: `Error ${player.error.code}`, |
||||
} |
||||
: null, |
||||
})); |
||||
}; |
||||
const canAirplay = (e: any) => { |
||||
if (e.availability === "available") { |
||||
update((s) => ({ |
||||
...s, |
||||
canAirplay: true, |
||||
})); |
||||
} |
||||
}; |
||||
const isFocused = (evt: any) => { |
||||
update((s) => ({ |
||||
...s, |
||||
isFocused: evt.type !== "mouseleave", |
||||
})); |
||||
}; |
||||
|
||||
const playerWrapper = player.closest(".is-video-player"); |
||||
if (!playerWrapper) return; |
||||
|
||||
playerWrapper.addEventListener("click", isFocused); |
||||
playerWrapper.addEventListener("mouseenter", isFocused); |
||||
playerWrapper.addEventListener("mouseleave", isFocused); |
||||
player.addEventListener("pause", pause); |
||||
player.addEventListener("playing", playing); |
||||
player.addEventListener("seeking", seeking); |
||||
player.addEventListener("seeked", seeked); |
||||
fscreen.addEventListener("fullscreenchange", fullscreenchange); |
||||
player.addEventListener("timeupdate", timeupdate); |
||||
player.addEventListener("loadedmetadata", loadedmetadata); |
||||
player.addEventListener("volumechange", volumechange); |
||||
player.addEventListener("progress", progress); |
||||
player.addEventListener("waiting", waiting); |
||||
player.addEventListener("canplay", canplay); |
||||
player.addEventListener("error", error); |
||||
player.addEventListener( |
||||
"webkitplaybacktargetavailabilitychanged", |
||||
canAirplay |
||||
); |
||||
|
||||
return () => { |
||||
playerWrapper.removeEventListener("click", isFocused); |
||||
playerWrapper.removeEventListener("mouseenter", isFocused); |
||||
playerWrapper.removeEventListener("mouseleave", isFocused); |
||||
player.removeEventListener("pause", pause); |
||||
player.removeEventListener("playing", playing); |
||||
player.removeEventListener("seeking", seeking); |
||||
player.removeEventListener("seeked", seeked); |
||||
fscreen.removeEventListener("fullscreenchange", fullscreenchange); |
||||
player.removeEventListener("timeupdate", timeupdate); |
||||
player.removeEventListener("loadedmetadata", loadedmetadata); |
||||
player.removeEventListener("volumechange", volumechange); |
||||
player.removeEventListener("progress", progress); |
||||
player.removeEventListener("waiting", waiting); |
||||
player.removeEventListener("canplay", canplay); |
||||
player.removeEventListener("error", error); |
||||
player.removeEventListener( |
||||
"webkitplaybacktargetavailabilitychanged", |
||||
canAirplay |
||||
); |
||||
}; |
||||
} |
||||
|
||||
export function useVideoPlayer( |
||||
ref: MutableRefObject<HTMLVideoElement | null>, |
||||
wrapperRef: MutableRefObject<HTMLDivElement | null> |
||||
) { |
||||
const [state, setState] = useState(initialPlayerState); |
||||
const stateRef = useRef<PlayerState | null>(null); |
||||
|
||||
useEffect(() => { |
||||
const player = ref.current; |
||||
const wrapper = wrapperRef.current; |
||||
if (player && wrapper) { |
||||
readState(player, setState); |
||||
registerListeners(player, setState); |
||||
setState((s) => ({ |
||||
...s, |
||||
...populateControls(player, wrapper, setState as any, stateRef), |
||||
})); |
||||
} |
||||
}, [ref, wrapperRef, stateRef]); |
||||
|
||||
useEffect(() => { |
||||
stateRef.current = state; |
||||
}, [state, stateRef]); |
||||
|
||||
return { |
||||
playerState: state, |
||||
}; |
||||
} |
||||
@ -1,8 +0,0 @@
@@ -1,8 +0,0 @@
|
||||
export function handleBuffered(time: number, buffered: TimeRanges): number { |
||||
for (let i = 0; i < buffered.length; i += 1) { |
||||
if (buffered.start(buffered.length - 1 - i) < time) { |
||||
return buffered.end(buffered.length - 1 - i); |
||||
} |
||||
} |
||||
return 0; |
||||
} |
||||
@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
|
||||
import { versionedStoreBuilder } from "@/utils/storage"; |
||||
|
||||
export const volumeStore = versionedStoreBuilder() |
||||
.setKey("mw-volume") |
||||
.addVersion({ |
||||
version: 0, |
||||
create() { |
||||
return { |
||||
volume: 1, |
||||
}; |
||||
}, |
||||
}) |
||||
.build(); |
||||
|
||||
export function getStoredVolume(): number { |
||||
const store = volumeStore.get(); |
||||
return store.volume; |
||||
} |
||||
|
||||
export function setStoredVolume(volume: number) { |
||||
const store = volumeStore.get(); |
||||
store.save({ |
||||
volume, |
||||
}); |
||||
} |
||||
@ -1,83 +0,0 @@
@@ -1,83 +0,0 @@
|
||||
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> |
||||
); |
||||
} |
||||
} |
||||
@ -1,36 +0,0 @@
@@ -1,36 +0,0 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { ReactNode } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
import { VideoPlayerHeader } from "./VideoPlayerHeader"; |
||||
|
||||
interface VideoPlayerErrorProps { |
||||
media?: MWMediaMeta; |
||||
onGoBack?: () => void; |
||||
children?: ReactNode; |
||||
} |
||||
|
||||
export function VideoPlayerError(props: VideoPlayerErrorProps) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
|
||||
const err = videoState.error; |
||||
|
||||
if (!err) return props.children as any; |
||||
|
||||
return ( |
||||
<div> |
||||
<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"> |
||||
{err.name}: {err.description} |
||||
</p> |
||||
</div> |
||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"> |
||||
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,68 +0,0 @@
@@ -1,68 +0,0 @@
|
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { BrandPill } from "@/components/layout/BrandPill"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark"; |
||||
import { AirplayControl } from "../controls/AirplayControl"; |
||||
import { ChromeCastControl } from "../controls/ChromeCastControl"; |
||||
|
||||
interface VideoPlayerHeaderProps { |
||||
media?: MWMediaMeta; |
||||
onClick?: () => void; |
||||
isMobile?: boolean; |
||||
} |
||||
|
||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { |
||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext(); |
||||
const isBookmarked = props.media |
||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media) |
||||
: false; |
||||
const showDivider = props.media && props.onClick; |
||||
return ( |
||||
<div className="flex items-center"> |
||||
<div className="flex flex-1 items-center"> |
||||
<p className="flex items-center"> |
||||
{props.onClick ? ( |
||||
<span |
||||
onClick={props.onClick} |
||||
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100" |
||||
> |
||||
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> |
||||
<span>Back to home</span> |
||||
</span> |
||||
) : null} |
||||
{showDivider ? ( |
||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> |
||||
) : null} |
||||
{props.media ? ( |
||||
<span className="flex items-center text-white"> |
||||
<span>{props.media.title}</span> |
||||
</span> |
||||
) : null} |
||||
</p> |
||||
{props.media && ( |
||||
<IconPatch |
||||
clickable |
||||
transparent |
||||
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE} |
||||
className="ml-2 text-white" |
||||
onClick={() => |
||||
props.media && setItemBookmark(props.media, !isBookmarked) |
||||
} |
||||
/> |
||||
)} |
||||
</div> |
||||
{props.isMobile ? ( |
||||
<> |
||||
<AirplayControl /> |
||||
<ChromeCastControl /> |
||||
</> |
||||
) : ( |
||||
<BrandPill /> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,27 +0,0 @@
@@ -1,27 +0,0 @@
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import React from "react"; |
||||
|
||||
export interface VideoPlayerIconButtonProps { |
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; |
||||
icon: Icons; |
||||
text?: string; |
||||
className?: string; |
||||
iconSize?: string; |
||||
} |
||||
|
||||
export function VideoPlayerIconButton(props: VideoPlayerIconButtonProps) { |
||||
return ( |
||||
<div className={props.className}> |
||||
<button |
||||
type="button" |
||||
onClick={props.onClick} |
||||
className="group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110" |
||||
> |
||||
<div className="flex items-center justify-center rounded-full bg-white bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-20"> |
||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} /> |
||||
{props.text ? <span className="ml-2">{props.text}</span> : null} |
||||
</div> |
||||
</button> |
||||
</div> |
||||
); |
||||
} |
||||
@ -1,60 +0,0 @@
@@ -1,60 +0,0 @@
|
||||
import { useEffect, useRef } from "react"; |
||||
import { useVideoPlayerState } from "../VideoContext"; |
||||
|
||||
interface Props { |
||||
children?: React.ReactNode; |
||||
id?: string; |
||||
className?: string; |
||||
} |
||||
|
||||
// TODO store popout in router history so you can press back to yeet
|
||||
// TODO add transition
|
||||
export function VideoPopout(props: Props) { |
||||
const { videoState } = useVideoPlayerState(); |
||||
const popoutRef = useRef<HTMLDivElement>(null); |
||||
const isOpen = videoState.popout === props.id; |
||||
|
||||
useEffect(() => { |
||||
if (!isOpen) return; |
||||
const popoutEl = popoutRef.current; |
||||
function windowClick(e: MouseEvent) { |
||||
const rect = popoutEl?.getBoundingClientRect(); |
||||
if (rect) { |
||||
if ( |
||||
e.pageX >= rect.x && |
||||
e.pageX <= rect.x + rect.width && |
||||
e.pageY >= rect.y && |
||||
e.pageY <= rect.y + rect.height |
||||
) { |
||||
// inside bounding box of popout
|
||||
return; |
||||
} |
||||
} |
||||
|
||||
videoState.closePopout(); |
||||
} |
||||
|
||||
window.addEventListener("click", windowClick); |
||||
return () => { |
||||
window.removeEventListener("click", windowClick); |
||||
}; |
||||
}, [isOpen, videoState]); |
||||
|
||||
return ( |
||||
<div |
||||
className={[ |
||||
"is-popout absolute inset-x-0 h-0", |
||||
!isOpen ? "hidden" : "", |
||||
].join(" ")} |
||||
> |
||||
<div className="absolute bottom-10 right-0 h-96 w-72 rounded-lg bg-denim-400"> |
||||
<div |
||||
ref={popoutRef} |
||||
className={["h-full w-full", props.className].join(" ")} |
||||
> |
||||
{isOpen ? props.children : null} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
Loading…
Reference in new issue