mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 18:51:53 +01:00
remove old
This commit is contained in:
parent
f97b84516b
commit
f14606e579
@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
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
Block a user