mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-11 02:25:08 +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