remove old

This commit is contained in:
Jelle van Snik 2023-02-08 22:57:40 +01:00
parent f97b84516b
commit f14606e579
33 changed files with 0 additions and 2225 deletions

View File

@ -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>
);
}

View File

@ -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,
};
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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 />;
}

View File

@ -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}
/>
);
}

View File

@ -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 />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;
}
},
};
}

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -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,
});
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}