mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 23:21:51 +01:00
Delete old video components, delete popout system, delete old hooks and implement new volume store
This commit is contained in:
parent
e7de27e33b
commit
fa1ad06968
@ -1,180 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useState } from "react";
|
|
||||||
|
|
||||||
import { AirplayAction } from "@/_oldvideo/components/actions/AirplayAction";
|
|
||||||
import { BackdropAction } from "@/_oldvideo/components/actions/BackdropAction";
|
|
||||||
import { CastingTextAction } from "@/_oldvideo/components/actions/CastingTextAction";
|
|
||||||
import { ChromecastAction } from "@/_oldvideo/components/actions/ChromecastAction";
|
|
||||||
import { FullscreenAction } from "@/_oldvideo/components/actions/FullscreenAction";
|
|
||||||
import { HeaderAction } from "@/_oldvideo/components/actions/HeaderAction";
|
|
||||||
import { KeyboardShortcutsAction } from "@/_oldvideo/components/actions/KeyboardShortcutsAction";
|
|
||||||
import { LoadingAction } from "@/_oldvideo/components/actions/LoadingAction";
|
|
||||||
import { MiddlePauseAction } from "@/_oldvideo/components/actions/MiddlePauseAction";
|
|
||||||
import { MobileCenterAction } from "@/_oldvideo/components/actions/MobileCenterAction";
|
|
||||||
import { PageTitleAction } from "@/_oldvideo/components/actions/PageTitleAction";
|
|
||||||
import { PauseAction } from "@/_oldvideo/components/actions/PauseAction";
|
|
||||||
import { PictureInPictureAction } from "@/_oldvideo/components/actions/PictureInPictureAction";
|
|
||||||
import { ProgressAction } from "@/_oldvideo/components/actions/ProgressAction";
|
|
||||||
import { SeriesSelectionAction } from "@/_oldvideo/components/actions/SeriesSelectionAction";
|
|
||||||
import { ShowTitleAction } from "@/_oldvideo/components/actions/ShowTitleAction";
|
|
||||||
import { SkipTimeAction } from "@/_oldvideo/components/actions/SkipTimeAction";
|
|
||||||
import { TimeAction } from "@/_oldvideo/components/actions/TimeAction";
|
|
||||||
import { VolumeAction } from "@/_oldvideo/components/actions/VolumeAction";
|
|
||||||
import { VideoPlayerError } from "@/_oldvideo/components/parts/VideoPlayerError";
|
|
||||||
import { PopoutProviderAction } from "@/_oldvideo/components/popouts/PopoutProviderAction";
|
|
||||||
import {
|
|
||||||
VideoPlayerBase,
|
|
||||||
VideoPlayerBaseProps,
|
|
||||||
} from "@/_oldvideo/components/VideoPlayerBase";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
import { CaptionRendererAction } from "./actions/CaptionRendererAction";
|
|
||||||
import { DividerAction } from "./actions/DividerAction";
|
|
||||||
import { SettingsAction } from "./actions/SettingsAction";
|
|
||||||
import { VolumeAdjustedAction } from "./actions/VolumeAdjustedAction";
|
|
||||||
|
|
||||||
type Props = VideoPlayerBaseProps;
|
|
||||||
|
|
||||||
function CenterPosition(props: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LeftSideControls() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
|
||||||
controls.setLeftControlsHover(true);
|
|
||||||
}, [controls]);
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
controls.setLeftControlsHover(false);
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="flex items-center px-2"
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
>
|
|
||||||
<PauseAction />
|
|
||||||
<SkipTimeAction />
|
|
||||||
<VolumeAction className="mr-2" />
|
|
||||||
<TimeAction />
|
|
||||||
</div>
|
|
||||||
<ShowTitleAction />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPlayer(props: Props) {
|
|
||||||
const [show, setShow] = useState(false);
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
|
|
||||||
const onBackdropChange = useCallback(
|
|
||||||
(showing: boolean) => {
|
|
||||||
setShow(showing);
|
|
||||||
},
|
|
||||||
[setShow]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerBase
|
|
||||||
autoPlay={props.autoPlay}
|
|
||||||
includeSafeArea={props.includeSafeArea}
|
|
||||||
onGoBack={props.onGoBack}
|
|
||||||
>
|
|
||||||
{({ isFullscreen }) => (
|
|
||||||
<>
|
|
||||||
<KeyboardShortcutsAction />
|
|
||||||
<PageTitleAction />
|
|
||||||
<VolumeAdjustedAction />
|
|
||||||
<VideoPlayerError onGoBack={props.onGoBack}>
|
|
||||||
<BackdropAction onBackdropChange={onBackdropChange}>
|
|
||||||
<CenterPosition>
|
|
||||||
<LoadingAction />
|
|
||||||
</CenterPosition>
|
|
||||||
<CenterPosition>
|
|
||||||
<CastingTextAction />
|
|
||||||
</CenterPosition>
|
|
||||||
<CenterPosition>
|
|
||||||
<MiddlePauseAction />
|
|
||||||
</CenterPosition>
|
|
||||||
{isMobile ? (
|
|
||||||
<Transition
|
|
||||||
animation="fade"
|
|
||||||
show={show}
|
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<MobileCenterAction />
|
|
||||||
</Transition>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
<Transition
|
|
||||||
animation="slide-down"
|
|
||||||
show={show}
|
|
||||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2"
|
|
||||||
>
|
|
||||||
<HeaderAction
|
|
||||||
showControls={isMobile}
|
|
||||||
onClick={props.onGoBack}
|
|
||||||
isFullScreen
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
<Transition
|
|
||||||
animation="slide-up"
|
|
||||||
show={show}
|
|
||||||
className={[
|
|
||||||
"pointer-events-auto absolute inset-x-0 bottom-0 flex flex-col px-4 pb-2",
|
|
||||||
props.includeSafeArea || isFullscreen
|
|
||||||
? "[margin-bottom:env(safe-area-inset-bottom)]"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center space-x-3">
|
|
||||||
{isMobile && <TimeAction noDuration />}
|
|
||||||
<ProgressAction />
|
|
||||||
</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">
|
|
||||||
<SeriesSelectionAction />
|
|
||||||
<PictureInPictureAction />
|
|
||||||
<SettingsAction />
|
|
||||||
</div>
|
|
||||||
<FullscreenAction />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LeftSideControls />
|
|
||||||
<div className="flex-1" />
|
|
||||||
<SeriesSelectionAction />
|
|
||||||
<DividerAction />
|
|
||||||
<SettingsAction />
|
|
||||||
<ChromecastAction />
|
|
||||||
<AirplayAction />
|
|
||||||
<PictureInPictureAction />
|
|
||||||
<FullscreenAction />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
{show ? <PopoutProviderAction /> : null}
|
|
||||||
</BackdropAction>
|
|
||||||
<CaptionRendererAction isControlsShown={show} />
|
|
||||||
{props.children}
|
|
||||||
</VideoPlayerError>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</VideoPlayerBase>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,68 +0,0 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
|
|
||||||
import { CastingInternal } from "@/_oldvideo/components/internal/CastingInternal";
|
|
||||||
import { WrapperRegisterInternal } from "@/_oldvideo/components/internal/WrapperRegisterInternal";
|
|
||||||
import { VideoErrorBoundary } from "@/_oldvideo/components/parts/VideoErrorBoundary";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
|
|
||||||
import { MetaAction } from "./actions/MetaAction";
|
|
||||||
import ThumbnailGeneratorInternal from "./internal/ThumbnailGeneratorInternal";
|
|
||||||
import { VideoElementInternal } from "./internal/VideoElementInternal";
|
|
||||||
import {
|
|
||||||
VideoPlayerContextProvider,
|
|
||||||
useVideoPlayerDescriptor,
|
|
||||||
} from "../state/hooks";
|
|
||||||
|
|
||||||
export interface VideoPlayerBaseProps {
|
|
||||||
children?:
|
|
||||||
| React.ReactNode
|
|
||||||
| ((data: { isFullscreen: boolean }) => React.ReactNode);
|
|
||||||
autoPlay?: boolean;
|
|
||||||
includeSafeArea?: boolean;
|
|
||||||
onGoBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const media = useMeta(descriptor);
|
|
||||||
|
|
||||||
const children =
|
|
||||||
typeof props.children === "function"
|
|
||||||
? props.children({
|
|
||||||
isFullscreen: videoInterface.isFullscreen,
|
|
||||||
})
|
|
||||||
: props.children;
|
|
||||||
|
|
||||||
// TODO move error boundary to only decorated, <VideoPlayer /> shouldn't have styling
|
|
||||||
return (
|
|
||||||
<VideoErrorBoundary onGoBack={props.onGoBack} media={media?.meta.meta}>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={[
|
|
||||||
"is-video-player popout-location relative h-full w-full select-none overflow-hidden bg-black",
|
|
||||||
props.includeSafeArea || videoInterface.isFullscreen
|
|
||||||
? "[border-left:env(safe-area-inset-left)_solid_transparent] [border-right:env(safe-area-inset-right)_solid_transparent]"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<MetaAction />
|
|
||||||
<ThumbnailGeneratorInternal />
|
|
||||||
<VideoElementInternal autoPlay={props.autoPlay} />
|
|
||||||
<CastingInternal />
|
|
||||||
<WrapperRegisterInternal wrapper={ref.current} />
|
|
||||||
<div className="absolute inset-0">{children}</div>
|
|
||||||
</div>
|
|
||||||
</VideoErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPlayerBase(props: VideoPlayerBaseProps) {
|
|
||||||
return (
|
|
||||||
<VideoPlayerContextProvider>
|
|
||||||
<VideoPlayerBaseWithState {...props} />
|
|
||||||
</VideoPlayerContextProvider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AirplayAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
controls.startAirplay();
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
if (!misc.canAirplay) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
className={props.className}
|
|
||||||
onClick={handleClick}
|
|
||||||
icon={Icons.AIRPLAY}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,131 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
|
|
||||||
interface BackdropActionProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onBackdropChange?: (showing: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BackdropAction(props: BackdropActionProps) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
|
|
||||||
const [moved, setMoved] = useState(false);
|
|
||||||
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const clickareaRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const lastTouchEnd = useRef<number>(0);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
|
||||||
(e) => {
|
|
||||||
// to enable thumbnail on mouse hover
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!moved) {
|
|
||||||
setTimeout(() => {
|
|
||||||
// If NOT a touch, set moved to true
|
|
||||||
const isTouch = Date.now() - lastTouchEnd.current < 200;
|
|
||||||
if (!isTouch) setMoved(true);
|
|
||||||
}, 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove after all
|
|
||||||
if (timeout.current) clearTimeout(timeout.current);
|
|
||||||
timeout.current = setTimeout(() => {
|
|
||||||
setMoved(false);
|
|
||||||
timeout.current = null;
|
|
||||||
}, 3000);
|
|
||||||
},
|
|
||||||
[setMoved, moved]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
|
||||||
setMoved(false);
|
|
||||||
}, [setMoved]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
|
||||||
(
|
|
||||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
|
||||||
) => {
|
|
||||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return;
|
|
||||||
|
|
||||||
if (videoInterface.popout !== null) return;
|
|
||||||
|
|
||||||
if ((e as React.TouchEvent).type === "touchend") {
|
|
||||||
lastTouchEnd.current = Date.now();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((e as React.MouseEvent<HTMLDivElement>).button !== 0) {
|
|
||||||
return; // not main button (left click), exit event
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (Date.now() - lastTouchEnd.current < 200) {
|
|
||||||
setMoved((v) => !v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaPlaying.isPlaying) controls.pause();
|
|
||||||
else controls.play();
|
|
||||||
}, 20);
|
|
||||||
},
|
|
||||||
[controls, mediaPlaying, videoInterface]
|
|
||||||
);
|
|
||||||
const handleDoubleClick = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!clickareaRef.current || clickareaRef.current !== e.target) return;
|
|
||||||
|
|
||||||
if (!videoInterface.isFullscreen) controls.enterFullscreen();
|
|
||||||
else controls.exitFullscreen();
|
|
||||||
},
|
|
||||||
[controls, videoInterface]
|
|
||||||
);
|
|
||||||
|
|
||||||
const lastBackdropValue = useRef<boolean | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const currentValue =
|
|
||||||
moved || mediaPlaying.isPaused || !!videoInterface.popout;
|
|
||||||
if (currentValue !== lastBackdropValue.current) {
|
|
||||||
lastBackdropValue.current = currentValue;
|
|
||||||
props.onBackdropChange?.(currentValue);
|
|
||||||
}
|
|
||||||
}, [moved, mediaPlaying, props, videoInterface]);
|
|
||||||
const showUI = moved || mediaPlaying.isPaused || !!videoInterface.popout;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 ${!showUI ? "cursor-none" : ""}`}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
ref={clickareaRef}
|
|
||||||
onMouseUp={handleClick}
|
|
||||||
onTouchEnd={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,120 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import { useAsync } from "react-use";
|
|
||||||
import { ContentCaption } from "subsrt-ts/dist/types/handler";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { parseSubtitles, sanitize } from "@/backend/helpers/captions";
|
|
||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { useSettings } from "@/state/settings";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "../../state/hooks";
|
|
||||||
import { useProgress } from "../../state/logic/progress";
|
|
||||||
import { useSource } from "../../state/logic/source";
|
|
||||||
|
|
||||||
export function CaptionCue({ text, scale }: { text?: string; scale?: number }) {
|
|
||||||
const { captionSettings } = useSettings();
|
|
||||||
const textWithNewlines = (text || "").replaceAll(/\r?\n/g, "<br />");
|
|
||||||
|
|
||||||
// https://www.w3.org/TR/webvtt1/#dom-construction-rules
|
|
||||||
// added a <br /> for newlines
|
|
||||||
const html = sanitize(textWithNewlines, {
|
|
||||||
ALLOWED_TAGS: ["c", "b", "i", "u", "span", "ruby", "rt", "br"],
|
|
||||||
ADD_TAGS: ["v", "lang"],
|
|
||||||
ALLOWED_ATTR: ["title", "lang"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
className="pointer-events-none mb-1 select-none rounded px-4 py-1 text-center [text-shadow:0_2px_4px_rgba(0,0,0,0.5)]"
|
|
||||||
style={{
|
|
||||||
...captionSettings.style,
|
|
||||||
fontSize: captionSettings.style.fontSize * (scale ?? 1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
// its sanitised a few lines up
|
|
||||||
// eslint-disable-next-line react/no-danger
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: html,
|
|
||||||
}}
|
|
||||||
dir="auto"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CaptionRendererAction({
|
|
||||||
isControlsShown,
|
|
||||||
}: {
|
|
||||||
isControlsShown: boolean;
|
|
||||||
}) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const source = useSource(descriptor).source;
|
|
||||||
const videoTime = useProgress(descriptor).time;
|
|
||||||
const { captionSettings, setCaptionDelay } = useSettings();
|
|
||||||
const captions = useRef<ContentCaption[]>([]);
|
|
||||||
const isCasting = getPlayerState(descriptor).casting.isCasting;
|
|
||||||
|
|
||||||
const captionSetRef = useRef<(delay: number) => void>(setCaptionDelay);
|
|
||||||
useEffect(() => {
|
|
||||||
captionSetRef.current = setCaptionDelay;
|
|
||||||
}, [setCaptionDelay]);
|
|
||||||
|
|
||||||
useAsync(async () => {
|
|
||||||
const blobUrl = source?.caption?.url;
|
|
||||||
if (blobUrl) {
|
|
||||||
const result = await fetch(blobUrl);
|
|
||||||
const text = await result.text();
|
|
||||||
try {
|
|
||||||
captions.current = parseSubtitles(text);
|
|
||||||
} catch (error) {
|
|
||||||
captions.current = [];
|
|
||||||
}
|
|
||||||
// reset delay on every subtitle change
|
|
||||||
setCaptionDelay(0);
|
|
||||||
} else {
|
|
||||||
captions.current = [];
|
|
||||||
}
|
|
||||||
}, [source?.caption?.url]);
|
|
||||||
|
|
||||||
// reset delay when loading new source url
|
|
||||||
useEffect(() => {
|
|
||||||
captionSetRef.current(0);
|
|
||||||
}, [source?.caption?.url]);
|
|
||||||
|
|
||||||
const isVisible = useCallback(
|
|
||||||
(
|
|
||||||
start: number,
|
|
||||||
end: number,
|
|
||||||
delay: number,
|
|
||||||
currentTime: number
|
|
||||||
): boolean => {
|
|
||||||
const delayedStart = start / 1000 + delay;
|
|
||||||
const delayedEnd = end / 1000 + delay;
|
|
||||||
return (
|
|
||||||
Math.max(0, delayedStart) <= currentTime &&
|
|
||||||
Math.max(0, delayedEnd) >= currentTime
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
if (isCasting) return null;
|
|
||||||
if (!captions.current.length) return null;
|
|
||||||
const visibileCaptions = captions.current.filter(({ start, end }) =>
|
|
||||||
isVisible(start, end, captionSettings.delay, videoTime)
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
className={[
|
|
||||||
"pointer-events-none absolute flex w-full flex-col items-center transition-[bottom]",
|
|
||||||
isControlsShown ? "bottom-24" : "bottom-12",
|
|
||||||
].join(" ")}
|
|
||||||
animation="slide-up"
|
|
||||||
show
|
|
||||||
>
|
|
||||||
{visibileCaptions.map(({ start, end, content }) => (
|
|
||||||
<CaptionCue key={`${start}-${end}`} text={content} />
|
|
||||||
))}
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
export function CastingTextAction() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
|
|
||||||
if (!misc.isCasting) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
|
||||||
<div className="rounded-full bg-denim-200 p-3 brightness-100 grayscale">
|
|
||||||
<Icon icon={Icons.CASTING} />
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-gray-300">{t("casting.casting")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "@/_oldvideo/components/parts/VideoPlayerIconButton";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChromecastAction(props: Props) {
|
|
||||||
const [hidden, setHidden] = useState(false);
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
const isCasting = misc.isCasting;
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const setButtonVisibility = useCallback(
|
|
||||||
(tag: HTMLElement) => {
|
|
||||||
const isVisible = (tag.getAttribute("style") ?? "").includes("inline");
|
|
||||||
setHidden(!isVisible);
|
|
||||||
},
|
|
||||||
[setHidden]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const tag = ref.current?.querySelector<HTMLElement>("google-cast-launcher");
|
|
||||||
if (!tag) return;
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
setButtonVisibility(tag);
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(tag, { attributes: true, attributeFilter: ["style"] });
|
|
||||||
setButtonVisibility(tag);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [setButtonVisibility]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
ref={ref}
|
|
||||||
className={[
|
|
||||||
props.className ?? "",
|
|
||||||
"google-cast-button",
|
|
||||||
isCasting ? "casting" : "",
|
|
||||||
hidden ? "hidden" : "",
|
|
||||||
].join(" ")}
|
|
||||||
icon={Icons.CASTING}
|
|
||||||
onClick={(e) => {
|
|
||||||
const castButton = e.currentTarget.querySelector(
|
|
||||||
"google-cast-launcher"
|
|
||||||
);
|
|
||||||
if (castButton) (castButton as HTMLDivElement).click();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,12 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
|
||||||
|
|
||||||
export function DividerAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
|
|
||||||
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null;
|
|
||||||
|
|
||||||
return <div className="mx-2 h-6 w-px bg-white opacity-50" />;
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { canFullscreen } from "@/utils/detectFeatures";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FullscreenAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (videoInterface.isFullscreen) controls.exitFullscreen();
|
|
||||||
else controls.enterFullscreen();
|
|
||||||
}, [controls, videoInterface]);
|
|
||||||
|
|
||||||
if (!canFullscreen()) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
className={props.className}
|
|
||||||
onClick={handleClick}
|
|
||||||
icon={videoInterface.isFullscreen ? Icons.COMPRESS : Icons.EXPAND}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,16 +0,0 @@
|
|||||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClick?: () => void;
|
|
||||||
showControls?: boolean;
|
|
||||||
isFullScreen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HeaderAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
|
|
||||||
return <VideoPlayerHeader media={meta?.meta.meta} {...props} />;
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
|
||||||
|
|
||||||
export function KeyboardShortcutsAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const progress = useProgress(descriptor);
|
|
||||||
const { toggleVolume } = useVolumeControl(descriptor);
|
|
||||||
|
|
||||||
const curTime = useRef<number>(0);
|
|
||||||
useEffect(() => {
|
|
||||||
curTime.current = progress.time;
|
|
||||||
}, [progress]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const el = state.wrapperElement;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
let isRolling = false;
|
|
||||||
const onKeyDown = (evt: KeyboardEvent) => {
|
|
||||||
if (!videoInterface.isFocused) return;
|
|
||||||
|
|
||||||
switch (evt.key.toLowerCase()) {
|
|
||||||
// Toggle fullscreen
|
|
||||||
case "f":
|
|
||||||
if (videoInterface.isFullscreen) {
|
|
||||||
controls.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
controls.enterFullscreen();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Skip backwards
|
|
||||||
case "arrowleft":
|
|
||||||
controls.setTime(curTime.current - 5);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Skip forward
|
|
||||||
case "arrowright":
|
|
||||||
controls.setTime(curTime.current + 5);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Pause / play
|
|
||||||
case " ":
|
|
||||||
if (mediaPlaying.isPaused) {
|
|
||||||
controls.play();
|
|
||||||
} else {
|
|
||||||
controls.pause();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Mute
|
|
||||||
case "m":
|
|
||||||
toggleVolume(true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Decrease volume
|
|
||||||
case "arrowdown":
|
|
||||||
controls.setVolume(Math.max(mediaPlaying.volume - 0.1, 0), true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Increase volume
|
|
||||||
case "arrowup":
|
|
||||||
controls.setVolume(Math.min(mediaPlaying.volume + 0.1, 1), true);
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Do a barrel Roll!
|
|
||||||
case "r":
|
|
||||||
if (isRolling || evt.ctrlKey || evt.metaKey) return;
|
|
||||||
isRolling = true;
|
|
||||||
el.classList.add("roll");
|
|
||||||
setTimeout(() => {
|
|
||||||
isRolling = false;
|
|
||||||
el.classList.remove("roll");
|
|
||||||
}, 1000);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, [controls, descriptor, mediaPlaying, videoInterface, toggleVolume]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { Spinner } from "@/components/layout/Spinner";
|
|
||||||
|
|
||||||
export function LoadingAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
|
|
||||||
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
|
|
||||||
const shouldShow = !misc.isCasting;
|
|
||||||
|
|
||||||
if (!isLoading || !shouldShow) return null;
|
|
||||||
|
|
||||||
return <Spinner />;
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import {
|
|
||||||
VideoMediaPlayingEvent,
|
|
||||||
useMediaPlaying,
|
|
||||||
} from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import {
|
|
||||||
VideoProgressEvent,
|
|
||||||
useProgress,
|
|
||||||
} from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { VideoPlayerMeta } from "@/_oldvideo/state/types";
|
|
||||||
|
|
||||||
export type WindowMeta = {
|
|
||||||
media: VideoPlayerMeta;
|
|
||||||
state: {
|
|
||||||
mediaPlaying: VideoMediaPlayingEvent;
|
|
||||||
progress: VideoProgressEvent;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
meta?: Record<string, WindowMeta>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetaAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const progress = useProgress(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!window.meta) window.meta = {};
|
|
||||||
if (meta) {
|
|
||||||
window.meta[descriptor] = {
|
|
||||||
media: meta,
|
|
||||||
state: {
|
|
||||||
mediaPlaying,
|
|
||||||
progress,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (window.meta) delete window.meta[descriptor];
|
|
||||||
};
|
|
||||||
}, [meta, descriptor, mediaPlaying, progress]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
export function MiddlePauseAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (mediaPlaying?.isPlaying) controls.pause();
|
|
||||||
else controls.play();
|
|
||||||
}, [controls, mediaPlaying]);
|
|
||||||
|
|
||||||
if (mediaPlaying.hasPlayedOnce) return null;
|
|
||||||
if (mediaPlaying.isPlaying) return null;
|
|
||||||
if (mediaPlaying.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,25 +0,0 @@
|
|||||||
import { PauseAction } from "@/_oldvideo/components/actions/PauseAction";
|
|
||||||
import {
|
|
||||||
SkipTimeBackwardAction,
|
|
||||||
SkipTimeForwardAction,
|
|
||||||
} from "@/_oldvideo/components/actions/SkipTimeAction";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
|
|
||||||
export function MobileCenterAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-8">
|
|
||||||
<SkipTimeBackwardAction />
|
|
||||||
<PauseAction
|
|
||||||
iconSize="text-5xl"
|
|
||||||
className={isLoading ? "pointer-events-none opacity-0" : ""}
|
|
||||||
/>
|
|
||||||
<SkipTimeForwardAction />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
|
|
||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
|
||||||
|
|
||||||
export function PageTitleAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const { isSeries, humanizedEpisodeId, meta } =
|
|
||||||
useCurrentSeriesEpisodeInfo(descriptor);
|
|
||||||
|
|
||||||
if (!meta) return null;
|
|
||||||
|
|
||||||
const title = isSeries
|
|
||||||
? `${meta.meta.title} - ${humanizedEpisodeId}`
|
|
||||||
: meta.meta.title;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Helmet>
|
|
||||||
<title>{title}</title>
|
|
||||||
</Helmet>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
iconSize?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PauseAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (mediaPlaying.isPlaying) controls.pause();
|
|
||||||
else controls.play();
|
|
||||||
}, [mediaPlaying, controls]);
|
|
||||||
|
|
||||||
const icon =
|
|
||||||
mediaPlaying.isPlaying || mediaPlaying.isSeeking ? Icons.PAUSE : Icons.PLAY;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
iconSize={props.iconSize}
|
|
||||||
className={props.className}
|
|
||||||
icon={icon}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
import {
|
|
||||||
canPictureInPicture,
|
|
||||||
canWebkitPictureInPicture,
|
|
||||||
} from "@/utils/detectFeatures";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PictureInPictureAction(props: Props) {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
controls.togglePictureInPicture();
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
className={props.className}
|
|
||||||
icon={Icons.PICTURE_IN_PICTURE}
|
|
||||||
onClick={handleClick}
|
|
||||||
text={
|
|
||||||
isMobile ? (t("videoPlayer.buttons.pictureInPicture") as string) : ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,119 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import {
|
|
||||||
MouseActivity,
|
|
||||||
makePercentage,
|
|
||||||
makePercentageString,
|
|
||||||
useProgressBar,
|
|
||||||
} from "@/hooks/useProgressBar";
|
|
||||||
|
|
||||||
import ThumbnailAction from "./ThumbnailAction";
|
|
||||||
|
|
||||||
export function ProgressAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const videoTime = useProgress(descriptor);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const dragRef = useRef<boolean>(false);
|
|
||||||
const controlRef = useRef<typeof controls>(controls);
|
|
||||||
const [hoverPosition, setHoverPosition] = useState<number>(0);
|
|
||||||
const [isThumbnailVisible, setIsThumbnailVisible] = useState<boolean>(false);
|
|
||||||
const isCasting = getPlayerState(descriptor).casting.isCasting;
|
|
||||||
const onMouseOver = useCallback((e: MouseActivity) => {
|
|
||||||
setHoverPosition(e.clientX);
|
|
||||||
setIsThumbnailVisible(true);
|
|
||||||
}, []);
|
|
||||||
const onMouseLeave = useCallback(() => {
|
|
||||||
setIsThumbnailVisible(false);
|
|
||||||
}, []);
|
|
||||||
useEffect(() => {
|
|
||||||
controlRef.current = controls;
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
const commitTime = useCallback(
|
|
||||||
(percentage) => {
|
|
||||||
controls.setTime(percentage * videoTime.duration);
|
|
||||||
},
|
|
||||||
[controls, videoTime]
|
|
||||||
);
|
|
||||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
|
|
||||||
ref,
|
|
||||||
commitTime
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dragRef.current === dragging) return;
|
|
||||||
dragRef.current = dragging;
|
|
||||||
controls.setSeeking(dragging);
|
|
||||||
}, [dragRef, dragging, controls]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dragging) {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
controlRef.current.setDraggingTime(
|
|
||||||
state.progress.duration * (dragPercentage / 100)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [descriptor, dragging, dragPercentage]);
|
|
||||||
|
|
||||||
let watchProgress = makePercentageString(
|
|
||||||
makePercentage((videoTime.time / videoTime.duration) * 100)
|
|
||||||
);
|
|
||||||
if (dragging)
|
|
||||||
watchProgress = makePercentageString(makePercentage(dragPercentage));
|
|
||||||
|
|
||||||
const bufferProgress = makePercentageString(
|
|
||||||
makePercentage((videoTime.buffered / videoTime.duration) * 100)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="group pointer-events-auto w-full cursor-pointer rounded-full px-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="-my-3 flex h-8 items-center"
|
|
||||||
onMouseDown={dragMouseDown}
|
|
||||||
onTouchStart={dragMouseDown}
|
|
||||||
onMouseMove={onMouseOver}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
{isThumbnailVisible && !isCasting ? (
|
|
||||||
<ThumbnailAction
|
|
||||||
parentRef={ref}
|
|
||||||
videoTime={videoTime}
|
|
||||||
hoverPosition={hoverPosition}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "@/_oldvideo/components/parts/VideoPlayerIconButton";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SeriesSelectionAction(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
if (meta?.meta.meta.type !== MWMediaType.SERIES) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={props.className}>
|
|
||||||
<div className="relative">
|
|
||||||
<FloatingAnchor id="episodes">
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
active={videoInterface.popout === "episodes"}
|
|
||||||
icon={Icons.EPISODES}
|
|
||||||
text={t("videoPlayer.buttons.episodes") as string}
|
|
||||||
wide
|
|
||||||
onClick={() => controls.openPopout("episodes")}
|
|
||||||
/>
|
|
||||||
</FloatingAnchor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "@/_oldvideo/components/parts/VideoPlayerIconButton";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { FloatingAnchor } from "@/components/popout/FloatingAnchor";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsAction(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const { isMobile } = useIsMobile(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={props.className}>
|
|
||||||
<div className="relative">
|
|
||||||
<FloatingAnchor id="settings">
|
|
||||||
<VideoPlayerIconButton
|
|
||||||
active={videoInterface.popout === "settings"}
|
|
||||||
className={props.className}
|
|
||||||
onClick={() => controls.openPopout("settings")}
|
|
||||||
text={
|
|
||||||
isMobile
|
|
||||||
? (t("videoPlayer.buttons.settings") as string)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
icon={Icons.GEAR}
|
|
||||||
/>
|
|
||||||
</FloatingAnchor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,18 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
|
|
||||||
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo";
|
|
||||||
|
|
||||||
export function ShowTitleAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const { isSeries, currentEpisodeInfo, humanizedEpisodeId } =
|
|
||||||
useCurrentSeriesEpisodeInfo(descriptor);
|
|
||||||
|
|
||||||
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,49 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { VideoPlayerIconButton } from "../parts/VideoPlayerIconButton";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkipTimeBackwardAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const videoTime = useProgress(descriptor);
|
|
||||||
|
|
||||||
const skipBackward = () => {
|
|
||||||
controls.setTime(videoTime.time - 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton icon={Icons.SKIP_BACKWARD} onClick={skipBackward} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkipTimeForwardAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const videoTime = useProgress(descriptor);
|
|
||||||
|
|
||||||
const skipForward = () => {
|
|
||||||
controls.setTime(videoTime.time + 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerIconButton icon={Icons.SKIP_FORWARD} onClick={skipForward} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkipTimeAction(props: Props) {
|
|
||||||
return (
|
|
||||||
<div className={props.className}>
|
|
||||||
<div className="flex select-none items-center text-white">
|
|
||||||
<SkipTimeBackwardAction />
|
|
||||||
<SkipTimeForwardAction />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,121 +0,0 @@
|
|||||||
import { RefObject, useMemo } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { VideoProgressEvent } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { formatSeconds } from "@/utils/formatSeconds";
|
|
||||||
|
|
||||||
const THUMBNAIL_HEIGHT = 100;
|
|
||||||
function position(
|
|
||||||
rectLeft: number,
|
|
||||||
rectWidth: number,
|
|
||||||
thumbnailWidth: number,
|
|
||||||
hoverPos: number
|
|
||||||
): number {
|
|
||||||
const relativePosition = hoverPos - rectLeft;
|
|
||||||
if (relativePosition <= thumbnailWidth / 2) {
|
|
||||||
return rectLeft;
|
|
||||||
}
|
|
||||||
if (relativePosition >= rectWidth - thumbnailWidth / 2) {
|
|
||||||
return rectWidth + rectLeft - thumbnailWidth;
|
|
||||||
}
|
|
||||||
return relativePosition + rectLeft - thumbnailWidth / 2;
|
|
||||||
}
|
|
||||||
function useThumbnailWidth() {
|
|
||||||
const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []);
|
|
||||||
const aspectRatio = videoEl.videoWidth / videoEl.videoHeight;
|
|
||||||
return THUMBNAIL_HEIGHT * aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingThumbnail({ pos }: { pos: number }) {
|
|
||||||
const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []);
|
|
||||||
const aspectRatio = videoEl.videoWidth / videoEl.videoHeight;
|
|
||||||
const thumbnailWidth = THUMBNAIL_HEIGHT * aspectRatio;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-32 flex items-center justify-center rounded bg-black"
|
|
||||||
style={{
|
|
||||||
left: `${pos}px`,
|
|
||||||
width: `${thumbnailWidth}px`,
|
|
||||||
height: `${THUMBNAIL_HEIGHT}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="roll-infinite text-6xl text-bink-600"
|
|
||||||
icon={Icons.MOVIE_WEB}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThumbnailTime({ hoverTime, pos }: { hoverTime: number; pos: number }) {
|
|
||||||
const videoEl = useMemo(() => document.getElementsByTagName("video")[0], []);
|
|
||||||
const thumbnailWidth = useThumbnailWidth();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-24 text-white"
|
|
||||||
style={{
|
|
||||||
left: `${pos + thumbnailWidth / 2 - 18}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatSeconds(hoverTime, videoEl.duration > 60 * 60)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThumbnailImage({ src, pos }: { src: string; pos: number }) {
|
|
||||||
const thumbnailWidth = useThumbnailWidth();
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
height={THUMBNAIL_HEIGHT}
|
|
||||||
width={thumbnailWidth}
|
|
||||||
className="absolute bottom-32 rounded"
|
|
||||||
src={src}
|
|
||||||
style={{
|
|
||||||
left: `${pos}px`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default function ThumbnailAction({
|
|
||||||
parentRef,
|
|
||||||
hoverPosition,
|
|
||||||
videoTime,
|
|
||||||
}: {
|
|
||||||
parentRef: RefObject<HTMLDivElement>;
|
|
||||||
hoverPosition: number;
|
|
||||||
videoTime: VideoProgressEvent;
|
|
||||||
}) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const source = useSource(descriptor);
|
|
||||||
const thumbnailWidth = useThumbnailWidth();
|
|
||||||
if (!parentRef.current) return null;
|
|
||||||
const rect = parentRef.current.getBoundingClientRect();
|
|
||||||
if (!rect.width) return null;
|
|
||||||
|
|
||||||
const hoverPercent = (hoverPosition - rect.left) / rect.width;
|
|
||||||
const hoverTime = videoTime.duration * hoverPercent;
|
|
||||||
const src = source.source?.thumbnails.find(
|
|
||||||
(x) => x.from < hoverTime && x.to > hoverTime
|
|
||||||
)?.imgUrl;
|
|
||||||
if (!source.source?.thumbnails.length) return null;
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none">
|
|
||||||
{!src ? (
|
|
||||||
<LoadingThumbnail
|
|
||||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ThumbnailImage
|
|
||||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)}
|
|
||||||
src={src}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ThumbnailTime
|
|
||||||
hoverTime={hoverTime}
|
|
||||||
pos={position(rect.left, rect.width, thumbnailWidth, hoverPosition)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,92 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { VideoPlayerTimeFormat } from "@/_oldvideo/state/types";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
import { formatSeconds } from "@/utils/formatSeconds";
|
|
||||||
|
|
||||||
function durationExceedsHour(secs: number): boolean {
|
|
||||||
return secs > 60 * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
noDuration?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimeAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoTime = useProgress(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const { setTimeFormat } = useControls(descriptor);
|
|
||||||
const { timeFormat } = useInterface(descriptor);
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const hasHours = durationExceedsHour(videoTime.duration);
|
|
||||||
|
|
||||||
const currentTime = formatSeconds(
|
|
||||||
mediaPlaying.isDragSeeking ? videoTime.draggingTime : videoTime.time,
|
|
||||||
hasHours
|
|
||||||
);
|
|
||||||
const duration = formatSeconds(videoTime.duration, hasHours);
|
|
||||||
const remaining = formatSeconds(
|
|
||||||
(videoTime.duration - videoTime.time) / mediaPlaying.playbackSpeed,
|
|
||||||
hasHours
|
|
||||||
);
|
|
||||||
const timeFinished = new Date(
|
|
||||||
new Date().getTime() +
|
|
||||||
((videoTime.duration - videoTime.time) * 1000) /
|
|
||||||
mediaPlaying.playbackSpeed
|
|
||||||
);
|
|
||||||
const formattedTimeFinished = ` - ${t("videoPlayer.finishAt", {
|
|
||||||
timeFinished,
|
|
||||||
formatParams: {
|
|
||||||
timeFinished: { hour: "numeric", minute: "numeric" },
|
|
||||||
},
|
|
||||||
})}`;
|
|
||||||
|
|
||||||
let formattedTime: string;
|
|
||||||
|
|
||||||
if (timeFormat === VideoPlayerTimeFormat.REGULAR) {
|
|
||||||
formattedTime = `${currentTime} ${props.noDuration ? "" : `/ ${duration}`}`;
|
|
||||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && !isMobile) {
|
|
||||||
formattedTime = `${t("videoPlayer.timeLeft", {
|
|
||||||
timeLeft: remaining,
|
|
||||||
})}${videoTime.time === videoTime.duration ? "" : formattedTimeFinished} `;
|
|
||||||
} else if (timeFormat === VideoPlayerTimeFormat.REMAINING && isMobile) {
|
|
||||||
formattedTime = `-${remaining}`;
|
|
||||||
} else {
|
|
||||||
formattedTime = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={[
|
|
||||||
"group pointer-events-auto text-white transition-transform duration-100 active:scale-110",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={() => {
|
|
||||||
setTimeFormat(
|
|
||||||
timeFormat === VideoPlayerTimeFormat.REGULAR
|
|
||||||
? VideoPlayerTimeFormat.REMAINING
|
|
||||||
: VideoPlayerTimeFormat.REGULAR
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 p-2 transition-colors duration-100 group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100 sm:px-4",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className={props.className}>
|
|
||||||
<p className="select-none text-white">{formattedTime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,93 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import {
|
|
||||||
makePercentage,
|
|
||||||
makePercentageString,
|
|
||||||
useProgressBar,
|
|
||||||
} from "@/hooks/useProgressBar";
|
|
||||||
import { useVolumeControl } from "@/hooks/useVolumeToggle";
|
|
||||||
import { canChangeVolume } from "@/utils/detectFeatures";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VolumeAction(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const { setStoredVolume, toggleVolume } = useVolumeControl(descriptor);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [hoveredOnce, setHoveredOnce] = useState(false);
|
|
||||||
|
|
||||||
const commitVolume = useCallback(
|
|
||||||
(percentage) => {
|
|
||||||
controls.setVolume(percentage);
|
|
||||||
setStoredVolume(percentage);
|
|
||||||
},
|
|
||||||
[controls, setStoredVolume]
|
|
||||||
);
|
|
||||||
const { dragging, dragPercentage, dragMouseDown } = useProgressBar(
|
|
||||||
ref,
|
|
||||||
commitVolume,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoInterface.leftControlHovering) setHoveredOnce(false);
|
|
||||||
}, [videoInterface]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
toggleVolume();
|
|
||||||
}, [toggleVolume]);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(async () => {
|
|
||||||
if (await canChangeVolume()) setHoveredOnce(true);
|
|
||||||
}, [setHoveredOnce]);
|
|
||||||
|
|
||||||
let percentage = makePercentage(mediaPlaying.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,32 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
export function VolumeAdjustedAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
videoInterface.volumeChangedWithKeybind
|
|
||||||
? "mt-10 scale-100 opacity-100"
|
|
||||||
: "mt-5 scale-75 opacity-0",
|
|
||||||
"absolute left-1/2 z-[100] flex -translate-x-1/2 items-center space-x-4 rounded-full bg-bink-300 bg-opacity-50 px-5 py-2 transition-all duration-100",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={mediaPlaying.volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
|
|
||||||
className="text-xl text-white"
|
|
||||||
/>
|
|
||||||
<div className="h-2 w-44 overflow-hidden rounded-full bg-denim-100">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-r-full bg-bink-500 transition-[width] duration-100"
|
|
||||||
style={{ width: `${mediaPlaying.volume * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClick: () => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CaptionsSelectionAction(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopoutListAction icon={Icons.CAPTIONS} onClick={props.onClick}>
|
|
||||||
{t("videoPlayer.buttons.captions")}
|
|
||||||
</PopoutListAction>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { normalizeTitle } from "@/utils/normalizeTitle";
|
|
||||||
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
|
||||||
|
|
||||||
export function DownloadAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const sourceInterface = useSource(descriptor);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
|
|
||||||
const isHLS = sourceInterface.source?.type === MWStreamType.HLS;
|
|
||||||
|
|
||||||
if (isHLS) return null;
|
|
||||||
|
|
||||||
const title = meta?.meta.meta.title;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopoutListAction
|
|
||||||
href={isHLS ? undefined : sourceInterface.source?.url}
|
|
||||||
download={title ? `${normalizeTitle(title)}.mp4` : undefined}
|
|
||||||
icon={Icons.DOWNLOAD}
|
|
||||||
>
|
|
||||||
{t("videoPlayer.buttons.download")}
|
|
||||||
</PopoutListAction>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClick: () => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaybackSpeedSelectionAction(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopoutListAction icon={Icons.TACHOMETER} onClick={props.onClick}>
|
|
||||||
{t("videoPlayer.buttons.playbackSpeed")}
|
|
||||||
</PopoutListAction>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
|
|
||||||
export function QualityDisplayAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const source = useSource(descriptor);
|
|
||||||
|
|
||||||
if (!source.source) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md bg-denim-300 px-2 py-1 transition-colors">
|
|
||||||
<p className="text-center text-xs font-bold text-slate-300 transition-colors">
|
|
||||||
{source.source.quality}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
import { QualityDisplayAction } from "./QualityDisplayAction";
|
|
||||||
import { PopoutListAction } from "../../popouts/PopoutUtils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onClick?: () => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceSelectionAction(props: Props) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopoutListAction
|
|
||||||
icon={Icons.CLAPPER_BOARD}
|
|
||||||
onClick={props.onClick}
|
|
||||||
right={<QualityDisplayAction />}
|
|
||||||
noChevron
|
|
||||||
>
|
|
||||||
{t("videoPlayer.buttons.source")}
|
|
||||||
</PopoutListAction>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { VideoPlayerMeta } from "@/_oldvideo/state/types";
|
|
||||||
import { MWCaption } from "@/backend/helpers/streams";
|
|
||||||
import { MWSeasonWithEpisodeMeta } from "@/backend/metadata/types/mw";
|
|
||||||
|
|
||||||
interface MetaControllerProps {
|
|
||||||
data?: VideoPlayerMeta;
|
|
||||||
seasonData?: MWSeasonWithEpisodeMeta;
|
|
||||||
linkedCaptions?: MWCaption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMetadata(
|
|
||||||
props: MetaControllerProps
|
|
||||||
): VideoPlayerMeta | undefined {
|
|
||||||
const seasonsWithEpisodes = props.data?.seasons?.map((v) => {
|
|
||||||
if (v.id === props.seasonData?.id)
|
|
||||||
return {
|
|
||||||
...v,
|
|
||||||
episodes: props.seasonData.episodes,
|
|
||||||
};
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!props.data) return undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
meta: props.data.meta,
|
|
||||||
episode: props.data.episode,
|
|
||||||
seasons: seasonsWithEpisodes,
|
|
||||||
captions: props.linkedCaptions ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetaController(props: MetaControllerProps) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
controls.setMeta(formatMetadata(props));
|
|
||||||
}, [props, controls]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
import throttle from "lodash.throttle";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { useProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { useQueryParams } from "@/hooks/useQueryParams";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
startAt?: number;
|
|
||||||
onProgress?: (time: number, duration: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProgressListenerController(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const progress = useProgress(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
const didInitialize = useRef<true | null>(null);
|
|
||||||
const lastTime = useRef<number>(props.startAt ?? 0);
|
|
||||||
const queryParams = useQueryParams();
|
|
||||||
|
|
||||||
// time updates (throttled)
|
|
||||||
const updateTime = useMemo(
|
|
||||||
() =>
|
|
||||||
throttle((a: number, b: number) => {
|
|
||||||
lastTime.current = a;
|
|
||||||
props.onProgress?.(a, b);
|
|
||||||
}, 1000),
|
|
||||||
[props]
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mediaPlaying.isPlaying) return;
|
|
||||||
if (progress.duration === 0 || progress.time === 0) return;
|
|
||||||
updateTime(progress.time, progress.duration);
|
|
||||||
}, [progress, mediaPlaying, updateTime]);
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
updateTime.cancel();
|
|
||||||
};
|
|
||||||
}, [updateTime]);
|
|
||||||
|
|
||||||
// initialize
|
|
||||||
useEffect(() => {
|
|
||||||
if (didInitialize.current) return;
|
|
||||||
if (mediaPlaying.isFirstLoading || Number.isNaN(progress.duration)) return;
|
|
||||||
controls.setTime(lastTime.current);
|
|
||||||
didInitialize.current = true;
|
|
||||||
}, [didInitialize, props, progress, mediaPlaying, controls]);
|
|
||||||
|
|
||||||
// when switching state providers
|
|
||||||
// TODO stateProviderId is somehow ALWAYS "video"
|
|
||||||
const lastStateProviderId = useRef<string | null>(null);
|
|
||||||
const stateProviderId = useMemo(() => misc.stateProviderId, [misc]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastStateProviderId.current === stateProviderId) return;
|
|
||||||
if (mediaPlaying.isFirstLoading) return;
|
|
||||||
|
|
||||||
lastStateProviderId.current = stateProviderId;
|
|
||||||
|
|
||||||
if ((queryParams.t ?? null) !== null) {
|
|
||||||
// Convert `t` param to time. Supports having only seconds (like `?t=192`), but also `3:30` or `1:30:02`
|
|
||||||
|
|
||||||
const timeArr = queryParams.t.toString().split(":").map(Number).reverse(); // This is an array of [seconds, ?minutes, ?hours] as ints.
|
|
||||||
|
|
||||||
const hours = timeArr[2] ?? 0;
|
|
||||||
const minutes = Math.min(timeArr[1] ?? 0, 59);
|
|
||||||
const seconds = Math.min(timeArr[0] ?? 0, minutes > 0 ? 59 : Infinity);
|
|
||||||
|
|
||||||
const timeInSeconds = hours * 60 * 60 + minutes * 60 + seconds;
|
|
||||||
|
|
||||||
controls.setTime(timeInSeconds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.setTime(lastTime.current);
|
|
||||||
}, [controls, mediaPlaying, stateProviderId, queryParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// if it initialized, but media starts loading for the first time again.
|
|
||||||
// reset initalized so it will restore time again
|
|
||||||
if (didInitialize.current && mediaPlaying.isFirstLoading)
|
|
||||||
didInitialize.current = null;
|
|
||||||
}, [mediaPlaying]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useHistory } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
|
|
||||||
interface SeriesControllerProps {
|
|
||||||
onSelect?: (state: { episodeId?: string; seasonId?: string }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SeriesController(props: SeriesControllerProps) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
const lastState = useRef<{
|
|
||||||
episodeId?: string;
|
|
||||||
seasonId?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentState = {
|
|
||||||
episodeId: meta?.episode?.episodeId,
|
|
||||||
seasonId: meta?.episode?.seasonId,
|
|
||||||
};
|
|
||||||
if (lastState.current === null) {
|
|
||||||
if (!meta) return;
|
|
||||||
lastState.current = currentState;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// when changes are detected, trigger event handler
|
|
||||||
if (
|
|
||||||
currentState.episodeId !== lastState.current?.episodeId ||
|
|
||||||
currentState.seasonId !== lastState.current?.seasonId
|
|
||||||
) {
|
|
||||||
lastState.current = currentState;
|
|
||||||
props.onSelect?.(currentState);
|
|
||||||
}
|
|
||||||
}, [meta, props, history]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { useInitialized } from "@/_oldvideo/components/hooks/useInitialized";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { getCaptionUrl, makeCaptionId } from "@/backend/helpers/captions";
|
|
||||||
import {
|
|
||||||
MWCaption,
|
|
||||||
MWStreamQuality,
|
|
||||||
MWStreamType,
|
|
||||||
} from "@/backend/helpers/streams";
|
|
||||||
import { captionLanguages } from "@/setup/iso6391";
|
|
||||||
import { useSettings } from "@/state/settings";
|
|
||||||
|
|
||||||
interface SourceControllerProps {
|
|
||||||
source: string;
|
|
||||||
type: MWStreamType;
|
|
||||||
quality: MWStreamQuality;
|
|
||||||
providerId?: string;
|
|
||||||
embedId?: string;
|
|
||||||
captions: MWCaption[];
|
|
||||||
}
|
|
||||||
async function tryFetch(captions: MWCaption[]) {
|
|
||||||
for (let i = 0; i < captions.length; i += 1) {
|
|
||||||
const caption = captions[i];
|
|
||||||
try {
|
|
||||||
const blobUrl = await getCaptionUrl(caption);
|
|
||||||
return { caption, blobUrl };
|
|
||||||
} catch (error) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceController(props: SourceControllerProps) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const { initialized } = useInitialized(descriptor);
|
|
||||||
const didInitialize = useRef<boolean>(false);
|
|
||||||
const { captionSettings } = useSettings();
|
|
||||||
useEffect(() => {
|
|
||||||
if (didInitialize.current) return;
|
|
||||||
if (!initialized) return;
|
|
||||||
controls.setSource(props);
|
|
||||||
// get preferred language
|
|
||||||
const preferredLanguage = captionLanguages.find(
|
|
||||||
(v) => v.id === captionSettings.language
|
|
||||||
);
|
|
||||||
if (!preferredLanguage) return;
|
|
||||||
const captions = props.captions.filter(
|
|
||||||
(v) =>
|
|
||||||
// langIso may contain the English name or the native name of the language
|
|
||||||
v.langIso.indexOf(preferredLanguage.englishName) !== -1 ||
|
|
||||||
v.langIso.indexOf(preferredLanguage.nativeName) !== -1
|
|
||||||
);
|
|
||||||
if (!captions) return;
|
|
||||||
// caption url can return a response other than 200
|
|
||||||
// that's why we fetch until we get a 200 response
|
|
||||||
tryFetch(captions).then((response) => {
|
|
||||||
// none of them were successful
|
|
||||||
if (!response) return;
|
|
||||||
// set the preferred language
|
|
||||||
const id = makeCaptionId(response.caption, true);
|
|
||||||
controls.setCaption(id, response.blobUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
didInitialize.current = true;
|
|
||||||
}, [props, controls, initialized, captionSettings.language]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
|
||||||
|
|
||||||
export function useCurrentSeriesEpisodeInfo(descriptor: string) {
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const currentSeasonInfo = useMemo(() => {
|
|
||||||
return meta?.seasons?.find(
|
|
||||||
(season) => season.id === meta?.episode?.seasonId
|
|
||||||
);
|
|
||||||
}, [meta]);
|
|
||||||
|
|
||||||
const currentEpisodeInfo = useMemo(() => {
|
|
||||||
return currentSeasonInfo?.episodes?.find(
|
|
||||||
(episode) => episode.id === meta?.episode?.episodeId
|
|
||||||
);
|
|
||||||
}, [currentSeasonInfo, meta]);
|
|
||||||
|
|
||||||
const isSeries = Boolean(
|
|
||||||
meta?.meta.meta.type === MWMediaType.SERIES && meta?.episode
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isSeries) return { isSeries: false };
|
|
||||||
|
|
||||||
const humanizedEpisodeId = t("videoPlayer.seasonAndEpisode", {
|
|
||||||
season: currentSeasonInfo?.number,
|
|
||||||
episode: currentEpisodeInfo?.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSeries: true,
|
|
||||||
humanizedEpisodeId,
|
|
||||||
currentSeasonInfo,
|
|
||||||
currentEpisodeInfo,
|
|
||||||
meta: meta?.meta,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
|
|
||||||
export function useInitialized(descriptor: string): { initialized: boolean } {
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
const initialized = useMemo(() => !!misc.initalized, [misc]);
|
|
||||||
return {
|
|
||||||
initialized,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useHistory, useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
import { ControlMethods, useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
|
|
||||||
function syncRouteToPopout(
|
|
||||||
location: ReturnType<typeof useLocation>,
|
|
||||||
controls: ControlMethods
|
|
||||||
) {
|
|
||||||
const parsed = new URLSearchParams(location.search);
|
|
||||||
const value = parsed.get("modal");
|
|
||||||
if (value) controls.openPopout(value);
|
|
||||||
else controls.closePopout();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO when opening with an open modal url, closing popout will close tab
|
|
||||||
export function useSyncPopouts(descriptor: string) {
|
|
||||||
const history = useHistory();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const loc = useLocation();
|
|
||||||
|
|
||||||
const lastKnownValue = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const controlsRef = useRef<typeof controls>(controls);
|
|
||||||
useEffect(() => {
|
|
||||||
controlsRef.current = controls;
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
// sync current popout to router
|
|
||||||
useEffect(() => {
|
|
||||||
const popoutId = videoInterface.popout;
|
|
||||||
if (lastKnownValue.current === popoutId) return;
|
|
||||||
lastKnownValue.current = popoutId;
|
|
||||||
// rest only triggers with changes
|
|
||||||
|
|
||||||
if (popoutId) {
|
|
||||||
const params = new URLSearchParams([["modal", popoutId]]).toString();
|
|
||||||
history.push({
|
|
||||||
search: params,
|
|
||||||
state: "popout",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// dont do anything if no modal is even open
|
|
||||||
if (!new URLSearchParams(history.location.search).has("modal")) return;
|
|
||||||
if (history.length > 0) history.goBack();
|
|
||||||
else
|
|
||||||
history.replace({
|
|
||||||
search: "",
|
|
||||||
state: "popout",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [videoInterface, history]);
|
|
||||||
|
|
||||||
// sync router to popout state (but only if its not done by block of code above)
|
|
||||||
useEffect(() => {
|
|
||||||
// if location update a push from the block above
|
|
||||||
if (loc.state === "popout") return;
|
|
||||||
|
|
||||||
// sync popout state
|
|
||||||
syncRouteToPopout(loc, controlsRef.current);
|
|
||||||
}, [loc]);
|
|
||||||
|
|
||||||
// mount hook
|
|
||||||
const routerInitialized = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (routerInitialized.current) return;
|
|
||||||
syncRouteToPopout(loc, controlsRef.current);
|
|
||||||
routerInitialized.current = true;
|
|
||||||
}, [loc]);
|
|
||||||
}
|
|
@ -1,28 +0,0 @@
|
|||||||
import { createVersionedStore } from "@/utils/storage";
|
|
||||||
|
|
||||||
interface VolumeStoreData {
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const volumeStore = createVersionedStore<VolumeStoreData>()
|
|
||||||
.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) {
|
|
||||||
volumeStore.save({
|
|
||||||
volume,
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,73 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { updateMisc, useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { createCastingStateProvider } from "@/_oldvideo/state/providers/castingStateProvider";
|
|
||||||
import {
|
|
||||||
setProvider,
|
|
||||||
unsetStateProvider,
|
|
||||||
} from "@/_oldvideo/state/providers/utils";
|
|
||||||
import { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
|
|
||||||
|
|
||||||
export function CastingInternal() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
const lastValue = useRef<boolean>(false);
|
|
||||||
const available = useChromecastAvailable();
|
|
||||||
|
|
||||||
const isCasting = useMemo(() => misc.isCasting, [misc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastValue.current === isCasting) return;
|
|
||||||
lastValue.current = isCasting;
|
|
||||||
if (!isCasting) return;
|
|
||||||
const provider = createCastingStateProvider(descriptor);
|
|
||||||
setProvider(descriptor, provider);
|
|
||||||
const { destroy } = provider.providerStart();
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
unsetStateProvider(descriptor, provider.getId());
|
|
||||||
} catch {
|
|
||||||
// ignore errors from missing player state, we need to run destroy()!
|
|
||||||
}
|
|
||||||
destroy();
|
|
||||||
};
|
|
||||||
}, [descriptor, isCasting]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
if (!available) return;
|
|
||||||
|
|
||||||
state.casting.instance = cast.framework.CastContext.getInstance();
|
|
||||||
state.casting.instance.setOptions({
|
|
||||||
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
|
||||||
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
|
|
||||||
});
|
|
||||||
|
|
||||||
state.casting.player = new cast.framework.RemotePlayer();
|
|
||||||
state.casting.controller = new cast.framework.RemotePlayerController(
|
|
||||||
state.casting.player
|
|
||||||
);
|
|
||||||
|
|
||||||
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
|
|
||||||
if (e.field === "isConnected") {
|
|
||||||
state.casting.isCasting = e.value;
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.casting.controller.addEventListener(
|
|
||||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
|
||||||
connectionChanged
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
state.casting.controller?.removeEventListener(
|
|
||||||
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
|
|
||||||
connectionChanged
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}, [available, descriptor]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,111 +0,0 @@
|
|||||||
import Hls from "hls.js";
|
|
||||||
import { RefObject, useCallback, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { updateSource, useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { Thumbnail } from "@/_oldvideo/state/types";
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
|
|
||||||
async function* generate(
|
|
||||||
videoRef: RefObject<HTMLVideoElement>,
|
|
||||||
canvasRef: RefObject<HTMLCanvasElement>,
|
|
||||||
index = 0,
|
|
||||||
numThumbnails = 20
|
|
||||||
): AsyncGenerator<Thumbnail, Thumbnail> {
|
|
||||||
const video = videoRef.current;
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!video) return { from: -1, to: -1, imgUrl: "" };
|
|
||||||
if (!canvas) return { from: -1, to: -1, imgUrl: "" };
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
video.addEventListener("loadedmetadata", resolve);
|
|
||||||
video.addEventListener("error", reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
canvas.height = video.videoHeight;
|
|
||||||
canvas.width = video.videoWidth;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (!ctx) return { from: -1, to: -1, imgUrl: "" };
|
|
||||||
let i = index;
|
|
||||||
const limit = numThumbnails - 1;
|
|
||||||
const step = video.duration / limit;
|
|
||||||
while (i < limit && !Number.isNaN(video.duration)) {
|
|
||||||
const from = i * step;
|
|
||||||
const to = (i + 1) * step;
|
|
||||||
video.currentTime = from;
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
video.addEventListener("seeked", resolve);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
||||||
const imgUrl = canvas.toDataURL();
|
|
||||||
i += 1;
|
|
||||||
yield {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
imgUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { from: -1, to: -1, imgUrl: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ThumbnailGeneratorInternal() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const source = useSource(descriptor);
|
|
||||||
|
|
||||||
// TODO fix memory leak
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(document.createElement("video"));
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(document.createElement("canvas"));
|
|
||||||
const hlsRef = useRef<Hls>(new Hls());
|
|
||||||
const thumbnails = useRef<Thumbnail[]>([]);
|
|
||||||
const abortController = useRef<AbortController>(new AbortController());
|
|
||||||
|
|
||||||
const generator = useCallback(
|
|
||||||
async (videoUrl: string, streamType: MWStreamType) => {
|
|
||||||
const prevIndex = thumbnails.current.length;
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (streamType === MWStreamType.HLS) {
|
|
||||||
hlsRef.current.attachMedia(video);
|
|
||||||
hlsRef.current.loadSource(videoUrl);
|
|
||||||
} else {
|
|
||||||
video.crossOrigin = "anonymous";
|
|
||||||
video.src = videoUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
for await (const thumbnail of generate(videoRef, canvasRef, prevIndex)) {
|
|
||||||
if (abortController.current.signal.aborted) {
|
|
||||||
if (streamType === MWStreamType.HLS) hlsRef.current.detachMedia();
|
|
||||||
abortController.current = new AbortController();
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
if (!state.source) return;
|
|
||||||
const { url, type } = state.source;
|
|
||||||
generator(url, type);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thumbnail.from === -1) continue;
|
|
||||||
thumbnails.current = [...thumbnails.current, thumbnail];
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
if (!state.source) return;
|
|
||||||
state.source.thumbnails = thumbnails.current;
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[descriptor]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const controller = abortController.current;
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
if (!state.source) return;
|
|
||||||
const { url, type } = state.source;
|
|
||||||
generator(url, type);
|
|
||||||
return () => {
|
|
||||||
if (!source.source?.url) return;
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [descriptor, generator, source.source?.url]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { useMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import {
|
|
||||||
setProvider,
|
|
||||||
unsetStateProvider,
|
|
||||||
} from "@/_oldvideo/state/providers/utils";
|
|
||||||
import { createVideoStateProvider } from "@/_oldvideo/state/providers/videoStateProvider";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
autoPlay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function VideoElement(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
|
||||||
|
|
||||||
const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]);
|
|
||||||
const stateProviderId = useMemo(() => misc.stateProviderId, [misc]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initalized) return;
|
|
||||||
if (!ref.current) return;
|
|
||||||
const provider = createVideoStateProvider(descriptor, ref.current);
|
|
||||||
setProvider(descriptor, provider);
|
|
||||||
const { destroy } = provider.providerStart();
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
unsetStateProvider(descriptor, provider.getId());
|
|
||||||
} catch {
|
|
||||||
// ignore errors from missing player state, we need to run destroy()!
|
|
||||||
}
|
|
||||||
destroy();
|
|
||||||
};
|
|
||||||
}, [descriptor, initalized, stateProviderId]);
|
|
||||||
|
|
||||||
// this element is remotely controlled by a state provider
|
|
||||||
return (
|
|
||||||
<video
|
|
||||||
ref={ref}
|
|
||||||
autoPlay={props.autoPlay}
|
|
||||||
muted={mediaPlaying.volume === 0}
|
|
||||||
playsInline
|
|
||||||
className="z-0 h-full w-full"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoElementInternal(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const misc = useMisc(descriptor);
|
|
||||||
|
|
||||||
// this element is remotely controlled by a state provider
|
|
||||||
if (misc.stateProviderId !== "video") return null;
|
|
||||||
return <VideoElement {...props} />;
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { updateMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
|
|
||||||
export function WrapperRegisterInternal(props: {
|
|
||||||
wrapper: HTMLDivElement | null;
|
|
||||||
}) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
state.wrapperElement = props.wrapper;
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}, [props.wrapper, descriptor]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
@ -1,82 +0,0 @@
|
|||||||
import { Component } from "react";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
|
||||||
import { Link } from "@/components/text/Link";
|
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
|
|
||||||
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 px-8 py-6 pb-2">
|
|
||||||
<VideoPlayerHeader
|
|
||||||
media={this.props.media}
|
|
||||||
onClick={this.props.onGoBack}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ErrorMessage error={this.state.error} localSize>
|
|
||||||
<Trans i18nKey="videoPlayer.errors.fatalError">
|
|
||||||
<Link url={conf().DISCORD_LINK} newTab />
|
|
||||||
<Link url={conf().GITHUB_LINK} newTab />
|
|
||||||
</Trans>
|
|
||||||
</ErrorMessage>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useError } from "@/_oldvideo/state/logic/error";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { Title } from "@/components/text/Title";
|
|
||||||
|
|
||||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
|
||||||
|
|
||||||
interface VideoPlayerErrorProps {
|
|
||||||
onGoBack?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const errorData = useError(descriptor);
|
|
||||||
|
|
||||||
const err = errorData.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 text-center">
|
|
||||||
{err?.name}: {err?.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col px-8 py-6 pb-2">
|
|
||||||
<VideoPlayerHeader media={meta?.meta.meta} onClick={props.onGoBack} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { AirplayAction } from "@/_oldvideo/components/actions/AirplayAction";
|
|
||||||
import { ChromecastAction } from "@/_oldvideo/components/actions/ChromecastAction";
|
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
|
||||||
import { useBannerSize } from "@/hooks/useBanner";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
import {
|
|
||||||
getIfBookmarkedFromPortable,
|
|
||||||
useBookmarkContext,
|
|
||||||
} from "@/state/bookmark";
|
|
||||||
|
|
||||||
interface VideoPlayerHeaderProps {
|
|
||||||
media?: MWMediaMeta;
|
|
||||||
onClick?: () => void;
|
|
||||||
showControls?: boolean;
|
|
||||||
isFullScreen?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
|
||||||
const isBookmarked = props.media
|
|
||||||
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
|
||||||
: false;
|
|
||||||
const showDivider = props.media && props.onClick;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const bannerHeight = useBannerSize();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center"
|
|
||||||
style={{
|
|
||||||
paddingTop: props.isFullScreen ? `${bannerHeight}px` : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
|
||||||
<p className="flex items-center truncate">
|
|
||||||
{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} />
|
|
||||||
{isMobile ? (
|
|
||||||
<span>{t("videoPlayer.backToHomeShort")}</span>
|
|
||||||
) : (
|
|
||||||
<span>{t("videoPlayer.backToHome")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{showDivider ? (
|
|
||||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
|
||||||
) : null}
|
|
||||||
{props.media ? (
|
|
||||||
<span className="truncate text-white">{props.media.title}</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.showControls ? (
|
|
||||||
<>
|
|
||||||
<AirplayAction />
|
|
||||||
<ChromecastAction />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<BrandPill hideTextOnMobile />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import React, { forwardRef } from "react";
|
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
export interface VideoPlayerIconButtonProps {
|
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
||||||
icon: Icons;
|
|
||||||
text?: string;
|
|
||||||
className?: string;
|
|
||||||
iconSize?: string;
|
|
||||||
active?: boolean;
|
|
||||||
wide?: boolean;
|
|
||||||
noPadding?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoPlayerIconButton = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
VideoPlayerIconButtonProps
|
|
||||||
>((props, ref) => {
|
|
||||||
return (
|
|
||||||
<div className={props.className} ref={ref}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={props.onClick}
|
|
||||||
className={[
|
|
||||||
"group pointer-events-auto p-2 text-white transition-transform duration-100 active:scale-110",
|
|
||||||
props.disabled
|
|
||||||
? "pointer-events-none cursor-not-allowed opacity-50"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"flex items-center justify-center rounded-full bg-denim-600 bg-opacity-0 transition-colors duration-100",
|
|
||||||
props.active ? "!bg-denim-500 !bg-opacity-100" : "",
|
|
||||||
!props.noPadding ? (props.wide ? "p-2 sm:px-4" : "p-2") : "",
|
|
||||||
!props.disabled
|
|
||||||
? "group-hover:bg-opacity-50 group-active:bg-denim-500 group-active:bg-opacity-100"
|
|
||||||
: "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<Icon icon={props.icon} className={props.iconSize ?? "text-2xl"} />
|
|
||||||
<p className="hidden sm:block">
|
|
||||||
{props.text ? <span className="ml-2">{props.text}</span> : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,64 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VideoPopout(props: Props) {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
const popoutRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isOpen = videoInterface.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controls.closePopout();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("click", windowClick);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("click", windowClick);
|
|
||||||
};
|
|
||||||
}, [isOpen, controls]);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,146 +0,0 @@
|
|||||||
import { useMemo, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import {
|
|
||||||
customCaption,
|
|
||||||
getCaptionUrl,
|
|
||||||
makeCaptionId,
|
|
||||||
parseSubtitles,
|
|
||||||
subtitleTypeList,
|
|
||||||
} from "@/backend/helpers/captions";
|
|
||||||
import { MWCaption, MWCaptionType } from "@/backend/helpers/streams";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
|
|
||||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
|
||||||
|
|
||||||
export function CaptionSelectionPopout(props: {
|
|
||||||
router: ReturnType<typeof useFloatingRouter>;
|
|
||||||
prefix: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const source = useSource(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const linkedCaptions = useMemo(
|
|
||||||
() =>
|
|
||||||
meta?.captions.map((v) => ({ ...v, id: makeCaptionId(v, true) })) ?? [],
|
|
||||||
[meta]
|
|
||||||
);
|
|
||||||
const loadingId = useRef<string>("");
|
|
||||||
const [setCaption, loading, error] = useLoading(
|
|
||||||
async (caption: MWCaption, isLinked: boolean) => {
|
|
||||||
const id = makeCaptionId(caption, isLinked);
|
|
||||||
loadingId.current = id;
|
|
||||||
const blobUrl = await getCaptionUrl(caption);
|
|
||||||
const result = await fetch(blobUrl);
|
|
||||||
const text = await result.text();
|
|
||||||
parseSubtitles(text); // This will throw if the file is invalid
|
|
||||||
controls.setCaption(id, blobUrl);
|
|
||||||
// sometimes this doesn't work, so we add a small delay
|
|
||||||
setTimeout(() => {
|
|
||||||
controls.closePopout();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentCaption = source.source?.caption?.id;
|
|
||||||
const customCaptionUploadElement = useRef<HTMLInputElement>(null);
|
|
||||||
return (
|
|
||||||
<FloatingView
|
|
||||||
{...props.router.pageProps(props.prefix)}
|
|
||||||
width={320}
|
|
||||||
height={500}
|
|
||||||
>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={t("videoPlayer.popouts.captions")}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.captions")}
|
|
||||||
goBack={() => props.router.navigate("/")}
|
|
||||||
action={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
props.router.navigate(`${props.prefix}/caption-settings`)
|
|
||||||
}
|
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
|
||||||
>
|
|
||||||
<span>{t("videoPlayer.popouts.captionPreferences.title")}</span>
|
|
||||||
<Icon icon={Icons.GEAR} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content noSection>
|
|
||||||
<PopoutSection>
|
|
||||||
<PopoutListEntry
|
|
||||||
active={!currentCaption}
|
|
||||||
onClick={() => {
|
|
||||||
controls.clearCaption();
|
|
||||||
controls.closePopout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("videoPlayer.popouts.noCaptions")}
|
|
||||||
</PopoutListEntry>
|
|
||||||
<PopoutListEntry
|
|
||||||
key={customCaption}
|
|
||||||
active={currentCaption === customCaption}
|
|
||||||
loading={loading && loadingId.current === customCaption}
|
|
||||||
errored={error && loadingId.current === customCaption}
|
|
||||||
onClick={() => customCaptionUploadElement.current?.click()}
|
|
||||||
>
|
|
||||||
{currentCaption === customCaption
|
|
||||||
? t("videoPlayer.popouts.customCaption")
|
|
||||||
: t("videoPlayer.popouts.uploadCustomCaption")}
|
|
||||||
<input
|
|
||||||
className="hidden"
|
|
||||||
ref={customCaptionUploadElement}
|
|
||||||
accept={subtitleTypeList.join(",")}
|
|
||||||
type="file"
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.files) return;
|
|
||||||
const customSubtitle = {
|
|
||||||
langIso: "custom",
|
|
||||||
url: URL.createObjectURL(e.target.files[0]),
|
|
||||||
type: MWCaptionType.UNKNOWN,
|
|
||||||
};
|
|
||||||
setCaption(customSubtitle, false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoutListEntry>
|
|
||||||
</PopoutSection>
|
|
||||||
|
|
||||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
|
|
||||||
<Icon className="text-base" icon={Icons.LINK} />
|
|
||||||
<span>{t("videoPlayer.popouts.linkedCaptions")}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PopoutSection className="pt-0">
|
|
||||||
<div>
|
|
||||||
{linkedCaptions.map((link) => (
|
|
||||||
<PopoutListEntry
|
|
||||||
key={link.langIso}
|
|
||||||
active={link.id === currentCaption}
|
|
||||||
loading={loading && link.id === loadingId.current}
|
|
||||||
errored={error && link.id === loadingId.current}
|
|
||||||
onClick={() => {
|
|
||||||
loadingId.current = link.id;
|
|
||||||
setCaption(link, true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{link.langIso}
|
|
||||||
</PopoutListEntry>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoutSection>
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import CaptionColorSelector, {
|
|
||||||
colors,
|
|
||||||
} from "@/components/CaptionColorSelector";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { Slider } from "@/components/Slider";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
import { useSettings } from "@/state/settings";
|
|
||||||
|
|
||||||
export function CaptionSettingsPopout(props: {
|
|
||||||
router: ReturnType<typeof useFloatingRouter>;
|
|
||||||
prefix: string;
|
|
||||||
}) {
|
|
||||||
// For now, won't add label texts to language files since options are prone to change
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const {
|
|
||||||
captionSettings,
|
|
||||||
setCaptionBackgroundColor,
|
|
||||||
setCaptionDelay,
|
|
||||||
setCaptionFontSize,
|
|
||||||
} = useSettings();
|
|
||||||
return (
|
|
||||||
<FloatingView {...props.router.pageProps(props.prefix)} width={375}>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={t("videoPlayer.popouts.captionPreferences.title")}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.captionPreferences")}
|
|
||||||
goBack={() => props.router.navigate("/captions")}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
<Slider
|
|
||||||
label={t("videoPlayer.popouts.captionPreferences.delay") as string}
|
|
||||||
max={10}
|
|
||||||
min={-10}
|
|
||||||
step={0.1}
|
|
||||||
valueDisplay={`${captionSettings.delay.toFixed(1)}s`}
|
|
||||||
value={captionSettings.delay}
|
|
||||||
onChange={(e) => setCaptionDelay(e.target.valueAsNumber)}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("videoPlayer.popouts.captionPreferences.fontSize") as string}
|
|
||||||
min={14}
|
|
||||||
step={1}
|
|
||||||
max={60}
|
|
||||||
value={captionSettings.style.fontSize}
|
|
||||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)}
|
|
||||||
/>
|
|
||||||
<Slider
|
|
||||||
label={t("videoPlayer.popouts.captionPreferences.opacity") as string}
|
|
||||||
step={1}
|
|
||||||
min={0}
|
|
||||||
max={255}
|
|
||||||
valueDisplay={`${(
|
|
||||||
(parseInt(
|
|
||||||
captionSettings.style.backgroundColor.substring(7, 9),
|
|
||||||
16
|
|
||||||
) /
|
|
||||||
255) *
|
|
||||||
100
|
|
||||||
).toFixed(0)}%`}
|
|
||||||
value={parseInt(
|
|
||||||
captionSettings.style.backgroundColor.substring(7, 9),
|
|
||||||
16
|
|
||||||
)}
|
|
||||||
onChange={(e) => setCaptionBackgroundColor(e.target.valueAsNumber)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row justify-between">
|
|
||||||
<label className="font-bold" htmlFor="color">
|
|
||||||
{t("videoPlayer.popouts.captionPreferences.color")}
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-row gap-2">
|
|
||||||
{colors.map((color) => (
|
|
||||||
<CaptionColorSelector key={color} color={color} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,197 +0,0 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { getMetaFromId } from "@/backend/metadata/getmeta";
|
|
||||||
import { decodeTMDBId } from "@/backend/metadata/tmdb";
|
|
||||||
import {
|
|
||||||
MWMediaType,
|
|
||||||
MWSeasonWithEpisodeMeta,
|
|
||||||
} from "@/backend/metadata/types/mw";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { Loading } from "@/components/layout/Loading";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
import { useWatchedContext } from "@/state/watched";
|
|
||||||
|
|
||||||
import { PopoutListEntry } from "./PopoutUtils";
|
|
||||||
|
|
||||||
export function EpisodeSelectionPopout() {
|
|
||||||
const params = useParams<{
|
|
||||||
media: string;
|
|
||||||
}>();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { pageProps, navigate } = useFloatingRouter("/episodes");
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
reqSeasonMeta(decodeTMDBId(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 ?? meta?.episode?.seasonId;
|
|
||||||
|
|
||||||
const setCurrent = useCallback(
|
|
||||||
(seasonId: string, episodeId: string) => {
|
|
||||||
controls.closePopout();
|
|
||||||
// race condition, jank solution but it works.
|
|
||||||
setTimeout(() => {
|
|
||||||
controls.setCurrentEpisode(seasonId, episodeId);
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
[controls]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentSeasonInfo = useMemo(() => {
|
|
||||||
return meta?.seasons?.find((season) => season.id === currentSeasonId);
|
|
||||||
}, [meta, currentSeasonId]);
|
|
||||||
|
|
||||||
const currentSeasonEpisodes = useMemo(() => {
|
|
||||||
if (currentVisibleSeason?.season) {
|
|
||||||
return currentVisibleSeason?.season?.episodes;
|
|
||||||
}
|
|
||||||
return meta?.seasons?.find?.(
|
|
||||||
(season) => season && season.id === currentSeasonId
|
|
||||||
)?.episodes;
|
|
||||||
}, [meta, currentSeasonId, currentVisibleSeason]);
|
|
||||||
|
|
||||||
const setSeason = (id: string) => {
|
|
||||||
requestSeason(id);
|
|
||||||
setCurrentVisibleSeason({ seasonId: id });
|
|
||||||
navigate("/episodes");
|
|
||||||
};
|
|
||||||
|
|
||||||
const { watched } = useWatchedContext();
|
|
||||||
|
|
||||||
const closePopout = () => {
|
|
||||||
controls.closePopout();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FloatingView {...pageProps("seasons")} height={600} width={375}>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={t("videoPlayer.popouts.seasons.title")}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.seasons")}
|
|
||||||
goBack={() => navigate("/episodes")}
|
|
||||||
backText={currentSeasonInfo?.title}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
{currentSeasonInfo
|
|
||||||
? meta?.seasons?.map?.((season) => (
|
|
||||||
<PopoutListEntry
|
|
||||||
key={season.id}
|
|
||||||
active={meta?.episode?.seasonId === season.id}
|
|
||||||
onClick={() => setSeason(season.id)}
|
|
||||||
>
|
|
||||||
{season.title}
|
|
||||||
</PopoutListEntry>
|
|
||||||
))
|
|
||||||
: t("videoPlayer.popouts.seasons.noSeason")}
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
<FloatingView {...pageProps("episodes")} height={600} width={375}>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={
|
|
||||||
currentSeasonInfo?.title ??
|
|
||||||
t("videoPlayer.popouts.episodes.unknown")
|
|
||||||
}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.episode")}
|
|
||||||
goBack={closePopout}
|
|
||||||
close
|
|
||||||
action={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate("/episodes/seasons")}
|
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
|
||||||
>
|
|
||||||
<span>{t("videoPlayer.popouts.seasons.other")}</span>
|
|
||||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
{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">
|
|
||||||
{t("videoPlayer.popouts.errors.loadingWentWrong", {
|
|
||||||
seasonTitle: currentSeasonInfo?.title?.toLowerCase(),
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{currentSeasonEpisodes && currentSeasonInfo
|
|
||||||
? currentSeasonEpisodes.map((e) => (
|
|
||||||
<PopoutListEntry
|
|
||||||
key={e.id}
|
|
||||||
active={e.id === meta?.episode?.episodeId}
|
|
||||||
onClick={() => {
|
|
||||||
if (e.id === meta?.episode?.episodeId)
|
|
||||||
controls.closePopout();
|
|
||||||
else setCurrent(currentSeasonInfo.id, e.id);
|
|
||||||
}}
|
|
||||||
percentageCompleted={
|
|
||||||
watched.items.find(
|
|
||||||
(item) =>
|
|
||||||
item.item?.series?.seasonId ===
|
|
||||||
currentSeasonInfo.id &&
|
|
||||||
item.item?.series?.episodeId === e.id
|
|
||||||
)?.percentage
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("videoPlayer.popouts.episode", {
|
|
||||||
index: e.number,
|
|
||||||
title: e.title,
|
|
||||||
})}
|
|
||||||
</PopoutListEntry>
|
|
||||||
))
|
|
||||||
: t("videoPlayer.popouts.episodes.noEpisode")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { Slider } from "@/components/Slider";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
|
|
||||||
import { PopoutListEntry, PopoutSection } from "./PopoutUtils";
|
|
||||||
|
|
||||||
const speedSelectionOptions = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
|
||||||
|
|
||||||
export function PlaybackSpeedPopout(props: {
|
|
||||||
router: ReturnType<typeof useFloatingRouter>;
|
|
||||||
prefix: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FloatingView
|
|
||||||
{...props.router.pageProps(props.prefix)}
|
|
||||||
width={320}
|
|
||||||
height={500}
|
|
||||||
>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={t("videoPlayer.popouts.playbackSpeed")}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.playbackSpeed")}
|
|
||||||
goBack={() => props.router.navigate("/")}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content noSection>
|
|
||||||
<PopoutSection>
|
|
||||||
{speedSelectionOptions.map((speed) => (
|
|
||||||
<PopoutListEntry
|
|
||||||
key={speed}
|
|
||||||
active={mediaPlaying.playbackSpeed === speed}
|
|
||||||
onClick={() => {
|
|
||||||
controls.setPlaybackSpeed(speed);
|
|
||||||
controls.closePopout();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{speed}x
|
|
||||||
</PopoutListEntry>
|
|
||||||
))}
|
|
||||||
</PopoutSection>
|
|
||||||
|
|
||||||
<p className="sticky top-0 z-10 flex items-center space-x-1 bg-ash-300 px-5 py-3 text-xs font-bold uppercase">
|
|
||||||
<Icon className="text-base" icon={Icons.TACHOMETER} />
|
|
||||||
<span>{t("videoPlayer.popouts.customPlaybackSpeed")}</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PopoutSection className="pt-0">
|
|
||||||
<div>
|
|
||||||
<Slider
|
|
||||||
min={0.1}
|
|
||||||
max={10}
|
|
||||||
step={0.1}
|
|
||||||
value={mediaPlaying.playbackSpeed}
|
|
||||||
valueDisplay={`${mediaPlaying.playbackSpeed}x`}
|
|
||||||
onChange={(e: { target: { valueAsNumber: number } }) =>
|
|
||||||
controls.setPlaybackSpeed(e.target.valueAsNumber)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PopoutSection>
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import { ReactNode, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "@/_oldvideo/state/cache";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { updateInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
for: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutAnchor(props: Props) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
|
|
||||||
if (state.interface.popout !== props.for) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
function render() {
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (ref.current) {
|
|
||||||
const current = JSON.stringify(state.interface.popoutBounds);
|
|
||||||
const newer = ref.current.getBoundingClientRect();
|
|
||||||
if (current !== JSON.stringify(newer)) {
|
|
||||||
state.interface.popoutBounds = newer;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.requestAnimationFrame(render);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(render);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [descriptor, props]);
|
|
||||||
|
|
||||||
return <div ref={ref}>{props.children}</div>;
|
|
||||||
}
|
|
@ -1,46 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
|
|
||||||
import { useSyncPopouts } from "@/_oldvideo/components/hooks/useSyncPopouts";
|
|
||||||
import { EpisodeSelectionPopout } from "@/_oldvideo/components/popouts/EpisodeSelectionPopout";
|
|
||||||
import { SettingsPopout } from "@/_oldvideo/components/popouts/SettingsPopout";
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { PopoutFloatingCard } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingContainer } from "@/components/popout/FloatingContainer";
|
|
||||||
|
|
||||||
function ShowPopout(props: { popoutId: string | null; onClose: () => void }) {
|
|
||||||
const popoutMap = {
|
|
||||||
settings: <SettingsPopout />,
|
|
||||||
episodes: <EpisodeSelectionPopout />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{Object.entries(popoutMap).map(([id, el]) => (
|
|
||||||
<FloatingContainer
|
|
||||||
key={id}
|
|
||||||
show={props.popoutId === id}
|
|
||||||
onClose={props.onClose}
|
|
||||||
>
|
|
||||||
<PopoutFloatingCard for={id} onClose={props.onClose}>
|
|
||||||
{el}
|
|
||||||
</PopoutFloatingCard>
|
|
||||||
</FloatingContainer>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutProviderAction() {
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const videoInterface = useInterface(descriptor);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
useSyncPopouts(descriptor);
|
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
|
||||||
controls.closePopout();
|
|
||||||
}, [controls]);
|
|
||||||
|
|
||||||
return <ShowPopout popoutId={videoInterface.popout} onClose={onClose} />;
|
|
||||||
}
|
|
@ -1,213 +0,0 @@
|
|||||||
import { createRef, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { ProgressRing } from "@/components/layout/ProgressRing";
|
|
||||||
import { Spinner } from "@/components/layout/Spinner";
|
|
||||||
|
|
||||||
interface PopoutListEntryBaseTypes {
|
|
||||||
active?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
isOnDarkBackground?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopoutListEntryTypes extends PopoutListEntryBaseTypes {
|
|
||||||
percentageCompleted?: number;
|
|
||||||
loading?: boolean;
|
|
||||||
errored?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopoutListEntryRootTypes extends PopoutListEntryBaseTypes {
|
|
||||||
right?: React.ReactNode;
|
|
||||||
noChevron?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopoutListActionTypes extends PopoutListEntryBaseTypes {
|
|
||||||
icon?: Icons;
|
|
||||||
right?: React.ReactNode;
|
|
||||||
download?: string;
|
|
||||||
href?: string;
|
|
||||||
noChevron?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ScrollToActiveProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopoutSectionProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScrollToActive(props: ScrollToActiveProps) {
|
|
||||||
const ref = createRef<HTMLDivElement>();
|
|
||||||
const inited = useRef<boolean>(false);
|
|
||||||
|
|
||||||
const SAFE_OFFSET = 30;
|
|
||||||
|
|
||||||
// Scroll to "active" child on first load (AKA mount except React dumb)
|
|
||||||
useEffect(() => {
|
|
||||||
if (inited.current) return;
|
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
const el = ref.current as HTMLDivElement;
|
|
||||||
|
|
||||||
// Find nearest scroll container, or self
|
|
||||||
const wrapper: HTMLDivElement | null = el.classList.contains(
|
|
||||||
"overflow-y-auto"
|
|
||||||
)
|
|
||||||
? el
|
|
||||||
: el.closest(".overflow-y-auto");
|
|
||||||
|
|
||||||
const active: HTMLDivElement | null | undefined =
|
|
||||||
wrapper?.querySelector(".active");
|
|
||||||
|
|
||||||
if (wrapper && active) {
|
|
||||||
let wrapperHeight = 0;
|
|
||||||
let activePos = 0;
|
|
||||||
let activeHeight = 0;
|
|
||||||
let wrapperScroll = 0;
|
|
||||||
|
|
||||||
const getCoords = () => {
|
|
||||||
const activeRect = active.getBoundingClientRect();
|
|
||||||
const wrapperRect = wrapper.getBoundingClientRect();
|
|
||||||
wrapperHeight = wrapperRect.height;
|
|
||||||
activeHeight = activeRect.height;
|
|
||||||
activePos = activeRect.top - wrapperRect.top + wrapper.scrollTop;
|
|
||||||
wrapperScroll = wrapper.scrollTop;
|
|
||||||
};
|
|
||||||
getCoords();
|
|
||||||
|
|
||||||
const isVisible =
|
|
||||||
activePos + activeHeight <
|
|
||||||
wrapperScroll + wrapperHeight - SAFE_OFFSET ||
|
|
||||||
activePos > wrapperScroll + SAFE_OFFSET;
|
|
||||||
if (isVisible) {
|
|
||||||
const activeMiddlePos = activePos + activeHeight / 2; // pos of middle of active element
|
|
||||||
const viewMiddle = wrapperHeight / 2; // half of the available height
|
|
||||||
const pos = activeMiddlePos - viewMiddle;
|
|
||||||
wrapper.scrollTo({
|
|
||||||
top: pos,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inited.current = true;
|
|
||||||
}, [ref]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={props.className} ref={ref}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutSection(props: PopoutSectionProps) {
|
|
||||||
return (
|
|
||||||
<ScrollToActive className={["p-5", props.className || ""].join(" ")}>
|
|
||||||
{props.children}
|
|
||||||
</ScrollToActive>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutListEntryBase(props: PopoutListEntryRootTypes) {
|
|
||||||
const bg = props.isOnDarkBackground ? "bg-ash-200" : "bg-ash-400";
|
|
||||||
const hover = props.isOnDarkBackground
|
|
||||||
? "hover:bg-ash-200"
|
|
||||||
: "hover:bg-ash-400";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"group -mx-2 flex cursor-pointer items-center justify-between space-x-1 rounded p-2 font-semibold transition-[background-color,color] duration-150",
|
|
||||||
hover,
|
|
||||||
props.active
|
|
||||||
? `${bg} active text-white outline-denim-700`
|
|
||||||
: "text-denim-700 hover:text-white",
|
|
||||||
].join(" ")}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
{props.active && (
|
|
||||||
<div className="absolute left-0 h-8 w-0.5 bg-bink-500" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{props.children}</span>
|
|
||||||
<div className="relative min-h-[1rem] min-w-[1rem]">
|
|
||||||
{!props.noChevron && (
|
|
||||||
<Icon
|
|
||||||
className="absolute inset-0 translate-x-2 text-white opacity-0 transition-[opacity,transform] duration-100 group-hover:translate-x-0 group-hover:opacity-100"
|
|
||||||
icon={Icons.CHEVRON_RIGHT}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.right}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutListEntry(props: PopoutListEntryTypes) {
|
|
||||||
return (
|
|
||||||
<PopoutListEntryBase
|
|
||||||
isOnDarkBackground={props.isOnDarkBackground}
|
|
||||||
active={props.active}
|
|
||||||
onClick={props.onClick}
|
|
||||||
noChevron={props.loading || props.errored}
|
|
||||||
right={
|
|
||||||
<>
|
|
||||||
{props.errored && (
|
|
||||||
<Icon
|
|
||||||
icon={Icons.WARNING}
|
|
||||||
className="absolute inset-0 text-rose-400"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.loading && !props.errored && (
|
|
||||||
<Spinner className="absolute inset-0 text-base [--color:#9C93B5]" />
|
|
||||||
)}
|
|
||||||
{props.percentageCompleted && !props.loading && !props.errored ? (
|
|
||||||
<ProgressRing
|
|
||||||
className="absolute inset-0 text-bink-600 opacity-100 transition-[opacity] group-hover:opacity-0"
|
|
||||||
backingRingClassname="stroke-ash-500"
|
|
||||||
percentage={
|
|
||||||
props.percentageCompleted > 90 ? 100 : props.percentageCompleted
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</PopoutListEntryBase>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutListAction(props: PopoutListActionTypes) {
|
|
||||||
const entry = (
|
|
||||||
<PopoutListEntryBase
|
|
||||||
active={props.active}
|
|
||||||
isOnDarkBackground={props.isOnDarkBackground}
|
|
||||||
right={props.right}
|
|
||||||
onClick={props.href ? undefined : props.onClick}
|
|
||||||
noChevron={props.noChevron}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
{props.icon ? <Icon className="text-xl" icon={props.icon} /> : null}
|
|
||||||
<div>{props.children}</div>
|
|
||||||
</div>
|
|
||||||
</PopoutListEntryBase>
|
|
||||||
);
|
|
||||||
|
|
||||||
return props.href ? (
|
|
||||||
<a
|
|
||||||
href={props.href ? props.href : undefined}
|
|
||||||
rel="noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
download={props.download ? props.download : undefined}
|
|
||||||
onClick={props.onClick}
|
|
||||||
>
|
|
||||||
{entry}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
entry
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { CaptionsSelectionAction } from "@/_oldvideo/components/actions/list-entries/CaptionsSelectionAction";
|
|
||||||
import { DownloadAction } from "@/_oldvideo/components/actions/list-entries/DownloadAction";
|
|
||||||
import { PlaybackSpeedSelectionAction } from "@/_oldvideo/components/actions/list-entries/PlaybackSpeedSelectionAction";
|
|
||||||
import { SourceSelectionAction } from "@/_oldvideo/components/actions/list-entries/SourceSelectionAction";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingDragHandle } from "@/components/popout/FloatingDragHandle";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
|
|
||||||
import { CaptionSelectionPopout } from "./CaptionSelectionPopout";
|
|
||||||
import { CaptionSettingsPopout } from "./CaptionSettingsPopout";
|
|
||||||
import { PlaybackSpeedPopout } from "./PlaybackSpeedPopout";
|
|
||||||
import { SourceSelectionPopout } from "./SourceSelectionPopout";
|
|
||||||
|
|
||||||
export function SettingsPopout() {
|
|
||||||
const floatingRouter = useFloatingRouter();
|
|
||||||
const { pageProps, navigate } = floatingRouter;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FloatingView {...pageProps("/")} width={320}>
|
|
||||||
<FloatingDragHandle />
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
<DownloadAction />
|
|
||||||
<SourceSelectionAction onClick={() => navigate("/source")} />
|
|
||||||
<CaptionsSelectionAction onClick={() => navigate("/captions")} />
|
|
||||||
<PlaybackSpeedSelectionAction
|
|
||||||
onClick={() => navigate("/playback-speed")}
|
|
||||||
/>
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
<SourceSelectionPopout router={floatingRouter} prefix="source" />
|
|
||||||
<CaptionSelectionPopout router={floatingRouter} prefix="captions" />
|
|
||||||
<CaptionSettingsPopout
|
|
||||||
router={floatingRouter}
|
|
||||||
prefix="caption-settings"
|
|
||||||
/>
|
|
||||||
<PlaybackSpeedPopout router={floatingRouter} prefix="playback-speed" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,287 +0,0 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useVideoPlayerDescriptor } from "@/_oldvideo/state/hooks";
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { useSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { MWEmbed, MWEmbedType } from "@/backend/helpers/embed";
|
|
||||||
import { MWProviderScrapeResult } from "@/backend/helpers/provider";
|
|
||||||
import {
|
|
||||||
getEmbedScraperByType,
|
|
||||||
getProviders,
|
|
||||||
} from "@/backend/helpers/register";
|
|
||||||
import { runEmbedScraper, runProvider } from "@/backend/helpers/run";
|
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
|
||||||
import { Icons } from "@/components/Icon";
|
|
||||||
import { Loading } from "@/components/layout/Loading";
|
|
||||||
import { FloatingCardView } from "@/components/popout/FloatingCard";
|
|
||||||
import { FloatingView } from "@/components/popout/FloatingView";
|
|
||||||
import { useFloatingRouter } from "@/hooks/useFloatingRouter";
|
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
|
||||||
|
|
||||||
import { PopoutListEntry } from "./PopoutUtils";
|
|
||||||
|
|
||||||
interface EmbedEntryProps {
|
|
||||||
name: string;
|
|
||||||
type: MWEmbedType;
|
|
||||||
url: string;
|
|
||||||
active: boolean;
|
|
||||||
onSelect: (stream: MWStream) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmbedEntry(props: EmbedEntryProps) {
|
|
||||||
const [scrapeEmbed, loading, error] = useLoading(async () => {
|
|
||||||
const scraper = getEmbedScraperByType(props.type);
|
|
||||||
if (!scraper) throw new Error("Embed scraper not found");
|
|
||||||
const stream = await runEmbedScraper(scraper, {
|
|
||||||
progress: () => {}, // no progress tracking for inline scraping
|
|
||||||
url: props.url,
|
|
||||||
});
|
|
||||||
props.onSelect(stream);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PopoutListEntry
|
|
||||||
isOnDarkBackground
|
|
||||||
loading={loading}
|
|
||||||
errored={!!error}
|
|
||||||
active={props.active}
|
|
||||||
onClick={() => {
|
|
||||||
scrapeEmbed();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.name}
|
|
||||||
</PopoutListEntry>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SourceSelectionPopout(props: {
|
|
||||||
router: ReturnType<typeof useFloatingRouter>;
|
|
||||||
prefix: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const meta = useMeta(descriptor);
|
|
||||||
const { source } = useSource(descriptor);
|
|
||||||
const providerRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const providers = useMemo(
|
|
||||||
() =>
|
|
||||||
meta
|
|
||||||
? getProviders().filter((v) => v.type.includes(meta.meta.meta.type))
|
|
||||||
: [],
|
|
||||||
[meta]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
|
||||||
const [scrapeResult, setScrapeResult] =
|
|
||||||
useState<MWProviderScrapeResult | null>(null);
|
|
||||||
const selectedProviderPopulated = useMemo(
|
|
||||||
() => providers.find((v) => v.id === selectedProvider) ?? null,
|
|
||||||
[providers, selectedProvider]
|
|
||||||
);
|
|
||||||
const [runScraper, loading, error] = useLoading(
|
|
||||||
async (providerId: string) => {
|
|
||||||
const theProvider = providers.find((v) => v.id === providerId);
|
|
||||||
if (!theProvider) throw new Error("Invalid provider");
|
|
||||||
if (!meta) throw new Error("need meta");
|
|
||||||
return runProvider(theProvider, {
|
|
||||||
media: meta.meta,
|
|
||||||
progress: () => {},
|
|
||||||
type: meta.meta.meta.type,
|
|
||||||
episode: meta.episode?.episodeId as any,
|
|
||||||
season: meta.episode?.seasonId as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function selectSource(stream: MWStream) {
|
|
||||||
controls.setSource({
|
|
||||||
quality: stream.quality,
|
|
||||||
source: stream.streamUrl,
|
|
||||||
type: stream.type,
|
|
||||||
embedId: stream.embedId,
|
|
||||||
providerId: providerRef.current ?? undefined,
|
|
||||||
});
|
|
||||||
if (meta) {
|
|
||||||
controls.setMeta({
|
|
||||||
...meta,
|
|
||||||
captions: stream.captions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
controls.closePopout();
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectProvider = (providerId?: string) => {
|
|
||||||
if (!providerId) {
|
|
||||||
providerRef.current = null;
|
|
||||||
setSelectedProvider(null);
|
|
||||||
props.router.navigate(`/${props.prefix}/source`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
runScraper(providerId).then(async (v) => {
|
|
||||||
if (!providerRef.current) return;
|
|
||||||
if (v) {
|
|
||||||
const len = v.embeds.length + (v.stream ? 1 : 0);
|
|
||||||
if (len === 1) {
|
|
||||||
const realStream = v.stream;
|
|
||||||
if (!realStream) {
|
|
||||||
const embed = v?.embeds[0];
|
|
||||||
if (!embed) throw new Error("Embed scraper not found");
|
|
||||||
const scraper = getEmbedScraperByType(embed.type);
|
|
||||||
if (!scraper) throw new Error("Embed scraper not found");
|
|
||||||
const stream = await runEmbedScraper(scraper, {
|
|
||||||
progress: () => {}, // no progress tracking for inline scraping
|
|
||||||
url: embed.url,
|
|
||||||
});
|
|
||||||
selectSource(stream);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
selectSource(realStream);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setScrapeResult(v ?? null);
|
|
||||||
});
|
|
||||||
providerRef.current = providerId;
|
|
||||||
setSelectedProvider(providerId);
|
|
||||||
props.router.navigate(`/${props.prefix}/source/embeds`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleEmbeds = useMemo(() => {
|
|
||||||
const embeds = scrapeResult?.embeds || [];
|
|
||||||
|
|
||||||
// Count embed types to determine if it should show a number behind the name
|
|
||||||
const embedsPerType: Record<string, (MWEmbed & { displayName: string })[]> =
|
|
||||||
{};
|
|
||||||
for (const embed of embeds) {
|
|
||||||
if (!embed.type) continue;
|
|
||||||
if (!embedsPerType[embed.type]) embedsPerType[embed.type] = [];
|
|
||||||
embedsPerType[embed.type].push({
|
|
||||||
...embed,
|
|
||||||
displayName: embed.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const embedsRes = Object.entries(embedsPerType).flatMap(([_, entries]) => {
|
|
||||||
if (entries.length > 1)
|
|
||||||
return entries.map((embed, i) => ({
|
|
||||||
...embed,
|
|
||||||
displayName: `${embed.type} ${i + 1}`,
|
|
||||||
}));
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
|
|
||||||
return embedsRes;
|
|
||||||
}, [scrapeResult?.embeds]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* List providers */}
|
|
||||||
<FloatingView
|
|
||||||
{...props.router.pageProps(props.prefix)}
|
|
||||||
width={320}
|
|
||||||
height={500}
|
|
||||||
>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={t("videoPlayer.popouts.sources")}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.sources")}
|
|
||||||
goBack={() => props.router.navigate("/")}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
{providers.map((v) => (
|
|
||||||
<PopoutListEntry
|
|
||||||
key={v.id}
|
|
||||||
active={v.id === source?.providerId}
|
|
||||||
onClick={() => {
|
|
||||||
selectProvider(v.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v.displayName}
|
|
||||||
</PopoutListEntry>
|
|
||||||
))}
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
|
|
||||||
{/* List embeds */}
|
|
||||||
<FloatingView
|
|
||||||
{...props.router.pageProps(`embeds`)}
|
|
||||||
width={320}
|
|
||||||
height={500}
|
|
||||||
>
|
|
||||||
<FloatingCardView.Header
|
|
||||||
title={selectedProviderPopulated?.displayName ?? ""}
|
|
||||||
description={t("videoPlayer.popouts.descriptions.embeds")}
|
|
||||||
goBack={() => props.router.navigate(`/${props.prefix}`)}
|
|
||||||
/>
|
|
||||||
<FloatingCardView.Content>
|
|
||||||
{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">
|
|
||||||
{t("videoPlayer.popouts.errors.embedsError")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{scrapeResult?.stream ? (
|
|
||||||
<PopoutListEntry
|
|
||||||
isOnDarkBackground
|
|
||||||
onClick={() => {
|
|
||||||
if (scrapeResult.stream) selectSource(scrapeResult.stream);
|
|
||||||
}}
|
|
||||||
active={
|
|
||||||
selectedProviderPopulated?.id === source?.providerId &&
|
|
||||||
selectedProviderPopulated?.id === source?.embedId
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Native source
|
|
||||||
</PopoutListEntry>
|
|
||||||
) : null}
|
|
||||||
{(visibleEmbeds?.length || 0) > 0 ? (
|
|
||||||
visibleEmbeds?.map((v) => (
|
|
||||||
<EmbedEntry
|
|
||||||
type={v.type}
|
|
||||||
name={v.displayName ?? ""}
|
|
||||||
key={v.url}
|
|
||||||
url={v.url}
|
|
||||||
active={false} // TODO add embed id extractor
|
|
||||||
onSelect={(stream) => {
|
|
||||||
selectSource(stream);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<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">
|
|
||||||
{t("videoPlayer.popouts.noEmbeds")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FloatingCardView.Content>
|
|
||||||
</FloatingView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
import { VideoPlayerState } from "./types";
|
|
||||||
|
|
||||||
export const _players: Map<string, VideoPlayerState> = new Map();
|
|
||||||
|
|
||||||
export function getPlayerState(descriptor: string): VideoPlayerState {
|
|
||||||
const state = _players.get(descriptor);
|
|
||||||
if (!state) throw new Error("invalid descriptor or has been unregistered");
|
|
||||||
return state;
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
export type VideoPlayerEvent =
|
|
||||||
| "mediaplaying"
|
|
||||||
| "source"
|
|
||||||
| "progress"
|
|
||||||
| "interface"
|
|
||||||
| "meta"
|
|
||||||
| "error"
|
|
||||||
| "misc";
|
|
||||||
|
|
||||||
function createEventString(id: string, event: VideoPlayerEvent): string {
|
|
||||||
return `_vid:::${id}:::${event}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sendEvent<T>(id: string, event: VideoPlayerEvent, data: T) {
|
|
||||||
const evObj = new CustomEvent(createEventString(id, event), {
|
|
||||||
detail: data,
|
|
||||||
});
|
|
||||||
document.dispatchEvent(evObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listenEvent<T>(
|
|
||||||
id: string,
|
|
||||||
event: VideoPlayerEvent,
|
|
||||||
cb: (data: T) => void
|
|
||||||
) {
|
|
||||||
document.addEventListener<any>(createEventString(id, event), cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unlistenEvent<T>(
|
|
||||||
id: string,
|
|
||||||
event: VideoPlayerEvent,
|
|
||||||
cb: (data: T) => void
|
|
||||||
) {
|
|
||||||
document.removeEventListener<any>(createEventString(id, event), cb);
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
import {
|
|
||||||
ReactNode,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { registerVideoPlayer, unregisterVideoPlayer } from "./init";
|
|
||||||
|
|
||||||
const VideoPlayerContext = createContext<string>("");
|
|
||||||
|
|
||||||
export function VideoPlayerContextProvider(props: { children: ReactNode }) {
|
|
||||||
const [id, setId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const vidId = registerVideoPlayer();
|
|
||||||
setId(vidId);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unregisterVideoPlayer(vidId);
|
|
||||||
};
|
|
||||||
}, [setId]);
|
|
||||||
|
|
||||||
if (!id) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VideoPlayerContext.Provider value={id}>
|
|
||||||
{props.children}
|
|
||||||
</VideoPlayerContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoPlayerDescriptor(): string {
|
|
||||||
const id = useContext(VideoPlayerContext);
|
|
||||||
return id;
|
|
||||||
}
|
|
@ -1,95 +0,0 @@
|
|||||||
import { nanoid } from "nanoid";
|
|
||||||
|
|
||||||
import { _players } from "./cache";
|
|
||||||
import { VideoPlayerState } from "./types";
|
|
||||||
|
|
||||||
export function resetForSource(s: VideoPlayerState) {
|
|
||||||
const state = s;
|
|
||||||
state.mediaPlaying = {
|
|
||||||
isPlaying: false,
|
|
||||||
isPaused: true,
|
|
||||||
isLoading: false,
|
|
||||||
isSeeking: false,
|
|
||||||
isDragSeeking: false,
|
|
||||||
isFirstLoading: true,
|
|
||||||
hasPlayedOnce: false,
|
|
||||||
volume: state.mediaPlaying.volume, // volume settings needs to persist through resets
|
|
||||||
playbackSpeed: 1,
|
|
||||||
};
|
|
||||||
state.progress = {
|
|
||||||
time: 0,
|
|
||||||
duration: 0,
|
|
||||||
buffered: 0,
|
|
||||||
draggingTime: 0,
|
|
||||||
};
|
|
||||||
state.initalized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPlayer(): VideoPlayerState {
|
|
||||||
return {
|
|
||||||
interface: {
|
|
||||||
popout: null,
|
|
||||||
isFullscreen: false,
|
|
||||||
isFocused: false,
|
|
||||||
leftControlHovering: false,
|
|
||||||
popoutBounds: null,
|
|
||||||
volumeChangedWithKeybind: false,
|
|
||||||
volumeChangedWithKeybindDebounce: null,
|
|
||||||
timeFormat: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
mediaPlaying: {
|
|
||||||
isPlaying: false,
|
|
||||||
isPaused: true,
|
|
||||||
isLoading: false,
|
|
||||||
isSeeking: false,
|
|
||||||
isDragSeeking: false,
|
|
||||||
isFirstLoading: true,
|
|
||||||
hasPlayedOnce: false,
|
|
||||||
volume: 0,
|
|
||||||
playbackSpeed: 1,
|
|
||||||
},
|
|
||||||
|
|
||||||
progress: {
|
|
||||||
time: 0,
|
|
||||||
duration: 0,
|
|
||||||
buffered: 0,
|
|
||||||
draggingTime: 0,
|
|
||||||
},
|
|
||||||
|
|
||||||
casting: {
|
|
||||||
isCasting: false,
|
|
||||||
controller: null,
|
|
||||||
instance: null,
|
|
||||||
player: null,
|
|
||||||
},
|
|
||||||
|
|
||||||
meta: null,
|
|
||||||
source: null,
|
|
||||||
|
|
||||||
error: null,
|
|
||||||
canAirplay: false,
|
|
||||||
initalized: false,
|
|
||||||
stateProviderId: "video",
|
|
||||||
|
|
||||||
pausedWhenSeeking: false,
|
|
||||||
hlsInstance: null,
|
|
||||||
stateProvider: null,
|
|
||||||
wrapperElement: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerVideoPlayer(): string {
|
|
||||||
const id = nanoid();
|
|
||||||
|
|
||||||
if (_players.has(id)) {
|
|
||||||
throw new Error("duplicate id");
|
|
||||||
}
|
|
||||||
|
|
||||||
_players.set(id, initPlayer());
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unregisterVideoPlayer(id: string) {
|
|
||||||
if (_players.has(id)) _players.delete(id);
|
|
||||||
}
|
|
@ -1,135 +0,0 @@
|
|||||||
import { updateInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { updateMeta } from "@/_oldvideo/state/logic/meta";
|
|
||||||
import { updateProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import {
|
|
||||||
VideoPlayerMeta,
|
|
||||||
VideoPlayerTimeFormat,
|
|
||||||
} from "@/_oldvideo/state/types";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { VideoPlayerStateController } from "../providers/providerTypes";
|
|
||||||
|
|
||||||
export type ControlMethods = {
|
|
||||||
openPopout(id: string): void;
|
|
||||||
closePopout(): void;
|
|
||||||
setLeftControlsHover(hovering: boolean): void;
|
|
||||||
setFocused(focused: boolean): void;
|
|
||||||
setMeta(data?: VideoPlayerMeta): void;
|
|
||||||
setCurrentEpisode(sId: string, eId: string): void;
|
|
||||||
setDraggingTime(num: number): void;
|
|
||||||
togglePictureInPicture(): void;
|
|
||||||
setPlaybackSpeed(num: number): void;
|
|
||||||
setTimeFormat(num: VideoPlayerTimeFormat): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useControls(
|
|
||||||
descriptor: string
|
|
||||||
): VideoPlayerStateController & ControlMethods {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// state provider controls
|
|
||||||
getId() {
|
|
||||||
return state.stateProvider?.getId() ?? "";
|
|
||||||
},
|
|
||||||
pause() {
|
|
||||||
state.stateProvider?.pause();
|
|
||||||
},
|
|
||||||
play() {
|
|
||||||
state.stateProvider?.play();
|
|
||||||
},
|
|
||||||
setSource(source) {
|
|
||||||
state.stateProvider?.setSource(source);
|
|
||||||
},
|
|
||||||
setSeeking(active) {
|
|
||||||
state.stateProvider?.setSeeking(active);
|
|
||||||
},
|
|
||||||
setTime(time) {
|
|
||||||
state.stateProvider?.setTime(time);
|
|
||||||
},
|
|
||||||
exitFullscreen() {
|
|
||||||
state.stateProvider?.exitFullscreen();
|
|
||||||
},
|
|
||||||
enterFullscreen() {
|
|
||||||
state.stateProvider?.enterFullscreen();
|
|
||||||
},
|
|
||||||
setVolume(volume, isKeyboardEvent = false) {
|
|
||||||
if (isKeyboardEvent) {
|
|
||||||
if (state.interface.volumeChangedWithKeybindDebounce)
|
|
||||||
clearTimeout(state.interface.volumeChangedWithKeybindDebounce);
|
|
||||||
|
|
||||||
state.interface.volumeChangedWithKeybind = true;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
|
|
||||||
state.interface.volumeChangedWithKeybindDebounce = setTimeout(() => {
|
|
||||||
state.interface.volumeChangedWithKeybind = false;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
}, 3e3);
|
|
||||||
}
|
|
||||||
state.stateProvider?.setVolume(volume, isKeyboardEvent);
|
|
||||||
},
|
|
||||||
startAirplay() {
|
|
||||||
state.stateProvider?.startAirplay();
|
|
||||||
},
|
|
||||||
setCaption(id, url) {
|
|
||||||
state.stateProvider?.setCaption(id, url);
|
|
||||||
},
|
|
||||||
clearCaption() {
|
|
||||||
state.stateProvider?.clearCaption();
|
|
||||||
},
|
|
||||||
|
|
||||||
// other controls
|
|
||||||
setLeftControlsHover(hovering) {
|
|
||||||
state.interface.leftControlHovering = hovering;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
setDraggingTime(num) {
|
|
||||||
state.progress.draggingTime = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(state.progress.duration, num)
|
|
||||||
);
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
},
|
|
||||||
openPopout(id: string) {
|
|
||||||
state.interface.popout = id;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
closePopout() {
|
|
||||||
state.interface.popout = null;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
setFocused(focused) {
|
|
||||||
state.interface.isFocused = focused;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
setMeta(meta) {
|
|
||||||
if (!meta) {
|
|
||||||
state.meta = null;
|
|
||||||
} else {
|
|
||||||
state.meta = meta;
|
|
||||||
}
|
|
||||||
updateMeta(descriptor, state);
|
|
||||||
},
|
|
||||||
setCurrentEpisode(sId, eId) {
|
|
||||||
if (state.meta) {
|
|
||||||
state.meta.episode = {
|
|
||||||
seasonId: sId,
|
|
||||||
episodeId: eId,
|
|
||||||
};
|
|
||||||
updateMeta(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
togglePictureInPicture() {
|
|
||||||
state.stateProvider?.togglePictureInPicture();
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
setPlaybackSpeed(num) {
|
|
||||||
state.stateProvider?.setPlaybackSpeed(num);
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
setTimeFormat(format) {
|
|
||||||
state.interface.timeFormat = format;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoErrorEvent = {
|
|
||||||
error: null | {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function getErrorFromState(state: VideoPlayerState): VideoErrorEvent {
|
|
||||||
return {
|
|
||||||
error: state.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateError(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoErrorEvent>(descriptor, "error", getErrorFromState(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useError(descriptor: string): VideoErrorEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoErrorEvent>(getErrorFromState(state));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoErrorEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "error", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "error", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerState, VideoPlayerTimeFormat } from "../types";
|
|
||||||
|
|
||||||
export type VideoInterfaceEvent = {
|
|
||||||
popout: string | null;
|
|
||||||
leftControlHovering: boolean;
|
|
||||||
isFocused: boolean;
|
|
||||||
isFullscreen: boolean;
|
|
||||||
popoutBounds: null | DOMRect;
|
|
||||||
volumeChangedWithKeybind: boolean;
|
|
||||||
timeFormat: VideoPlayerTimeFormat;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getInterfaceFromState(state: VideoPlayerState): VideoInterfaceEvent {
|
|
||||||
return {
|
|
||||||
popout: state.interface.popout,
|
|
||||||
leftControlHovering: state.interface.leftControlHovering,
|
|
||||||
isFocused: state.interface.isFocused,
|
|
||||||
isFullscreen: state.interface.isFullscreen,
|
|
||||||
popoutBounds: state.interface.popoutBounds,
|
|
||||||
volumeChangedWithKeybind: state.interface.volumeChangedWithKeybind,
|
|
||||||
timeFormat: state.interface.timeFormat,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateInterface(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoInterfaceEvent>(
|
|
||||||
descriptor,
|
|
||||||
"interface",
|
|
||||||
getInterfaceFromState(state)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInterface(descriptor: string): VideoInterfaceEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoInterfaceEvent>(
|
|
||||||
getInterfaceFromState(state)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoInterfaceEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "interface", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "interface", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,63 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoMediaPlayingEvent = {
|
|
||||||
isPlaying: boolean;
|
|
||||||
isPaused: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
isSeeking: boolean;
|
|
||||||
isDragSeeking: boolean;
|
|
||||||
hasPlayedOnce: boolean;
|
|
||||||
isFirstLoading: boolean;
|
|
||||||
volume: number;
|
|
||||||
playbackSpeed: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMediaPlayingFromState(
|
|
||||||
state: VideoPlayerState
|
|
||||||
): VideoMediaPlayingEvent {
|
|
||||||
return {
|
|
||||||
hasPlayedOnce: state.mediaPlaying.hasPlayedOnce,
|
|
||||||
isLoading: state.mediaPlaying.isLoading,
|
|
||||||
isPaused: state.mediaPlaying.isPaused,
|
|
||||||
isPlaying: state.mediaPlaying.isPlaying,
|
|
||||||
isSeeking: state.mediaPlaying.isSeeking,
|
|
||||||
isDragSeeking: state.mediaPlaying.isDragSeeking,
|
|
||||||
isFirstLoading: state.mediaPlaying.isFirstLoading,
|
|
||||||
volume: state.mediaPlaying.volume,
|
|
||||||
playbackSpeed: state.mediaPlaying.playbackSpeed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateMediaPlaying(
|
|
||||||
descriptor: string,
|
|
||||||
state: VideoPlayerState
|
|
||||||
) {
|
|
||||||
sendEvent<VideoMediaPlayingEvent>(
|
|
||||||
descriptor,
|
|
||||||
"mediaplaying",
|
|
||||||
getMediaPlayingFromState(state)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMediaPlaying(descriptor: string): VideoMediaPlayingEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoMediaPlayingEvent>(
|
|
||||||
getMediaPlayingFromState(state)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoMediaPlayingEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "mediaplaying", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "mediaplaying", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,36 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerMeta, VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoMetaEvent = VideoPlayerMeta | null;
|
|
||||||
|
|
||||||
function getMetaFromState(state: VideoPlayerState): VideoMetaEvent {
|
|
||||||
return state.meta
|
|
||||||
? {
|
|
||||||
...state.meta,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateMeta(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoMetaEvent>(descriptor, "meta", getMetaFromState(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMeta(descriptor: string): VideoMetaEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoMetaEvent>(getMetaFromState(state));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoMetaEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "meta", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "meta", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,44 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoMiscError = {
|
|
||||||
canAirplay: boolean;
|
|
||||||
wrapperInitialized: boolean;
|
|
||||||
initalized: boolean;
|
|
||||||
isCasting: boolean;
|
|
||||||
stateProviderId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getMiscFromState(state: VideoPlayerState): VideoMiscError {
|
|
||||||
return {
|
|
||||||
canAirplay: state.canAirplay,
|
|
||||||
wrapperInitialized: !!state.wrapperElement,
|
|
||||||
initalized: state.initalized,
|
|
||||||
isCasting: state.casting.isCasting,
|
|
||||||
stateProviderId: state.stateProviderId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateMisc(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoMiscError>(descriptor, "misc", getMiscFromState(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMisc(descriptor: string): VideoMiscError {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoMiscError>(getMiscFromState(state));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoMiscError>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "misc", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "misc", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoProgressEvent = {
|
|
||||||
time: number;
|
|
||||||
duration: number;
|
|
||||||
buffered: number;
|
|
||||||
draggingTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getProgressFromState(state: VideoPlayerState): VideoProgressEvent {
|
|
||||||
return {
|
|
||||||
time: state.progress.time,
|
|
||||||
duration: state.progress.duration,
|
|
||||||
buffered: state.progress.buffered,
|
|
||||||
draggingTime: state.progress.draggingTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProgress(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoProgressEvent>(
|
|
||||||
descriptor,
|
|
||||||
"progress",
|
|
||||||
getProgressFromState(state)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProgress(descriptor: string): VideoProgressEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoProgressEvent>(
|
|
||||||
getProgressFromState(state)
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoProgressEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "progress", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "progress", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { listenEvent, sendEvent, unlistenEvent } from "../events";
|
|
||||||
import { Thumbnail, VideoPlayerState } from "../types";
|
|
||||||
|
|
||||||
export type VideoSourceEvent = {
|
|
||||||
source: null | {
|
|
||||||
quality: MWStreamQuality;
|
|
||||||
url: string;
|
|
||||||
type: MWStreamType;
|
|
||||||
providerId?: string;
|
|
||||||
embedId?: string;
|
|
||||||
caption: null | {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
thumbnails: Thumbnail[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSourceFromState(state: VideoPlayerState): VideoSourceEvent {
|
|
||||||
return {
|
|
||||||
source: state.source ? { ...state.source } : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSource(descriptor: string, state: VideoPlayerState) {
|
|
||||||
sendEvent<VideoSourceEvent>(descriptor, "source", getSourceFromState(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSource(descriptor: string): VideoSourceEvent {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const [data, setData] = useState<VideoSourceEvent>(getSourceFromState(state));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function update(payload: CustomEvent<VideoSourceEvent>) {
|
|
||||||
setData(payload.detail);
|
|
||||||
}
|
|
||||||
listenEvent(descriptor, "source", update);
|
|
||||||
return () => {
|
|
||||||
unlistenEvent(descriptor, "source", update);
|
|
||||||
};
|
|
||||||
}, [descriptor]);
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
@ -1,340 +0,0 @@
|
|||||||
import fscreen from "fscreen";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getStoredVolume,
|
|
||||||
setStoredVolume,
|
|
||||||
} from "@/_oldvideo/components/hooks/volumeStore";
|
|
||||||
import { updateInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { updateSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { resetStateForSource } from "@/_oldvideo/state/providers/helpers";
|
|
||||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
|
||||||
import {
|
|
||||||
canChangeVolume,
|
|
||||||
canFullscreen,
|
|
||||||
canFullscreenAnyElement,
|
|
||||||
canWebkitFullscreen,
|
|
||||||
} from "@/utils/detectFeatures";
|
|
||||||
|
|
||||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
|
||||||
import { SettingsStore } from "../../../state/settings/store";
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
|
||||||
import { updateProgress } from "../logic/progress";
|
|
||||||
|
|
||||||
// TODO HLS for casting?
|
|
||||||
export function createCastingStateProvider(
|
|
||||||
descriptor: string
|
|
||||||
): VideoPlayerStateProvider {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
const ins = state.casting.instance;
|
|
||||||
const player = state.casting.player;
|
|
||||||
const controller = state.casting.controller;
|
|
||||||
|
|
||||||
return {
|
|
||||||
getId() {
|
|
||||||
return "casting";
|
|
||||||
},
|
|
||||||
play() {
|
|
||||||
if (state.mediaPlaying.isPaused) controller?.playOrPause();
|
|
||||||
},
|
|
||||||
pause() {
|
|
||||||
if (state.mediaPlaying.isPlaying) controller?.playOrPause();
|
|
||||||
},
|
|
||||||
exitFullscreen() {
|
|
||||||
if (!fscreen.fullscreenElement) return;
|
|
||||||
fscreen.exitFullscreen();
|
|
||||||
},
|
|
||||||
enterFullscreen() {
|
|
||||||
if (!canFullscreen() || fscreen.fullscreenElement) return;
|
|
||||||
if (canFullscreenAnyElement()) {
|
|
||||||
if (state.wrapperElement)
|
|
||||||
fscreen.requestFullscreen(state.wrapperElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canWebkitFullscreen()) {
|
|
||||||
(player as any).webkitEnterFullscreen();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
startAirplay() {
|
|
||||||
// no airplay while casting
|
|
||||||
},
|
|
||||||
setTime(t) {
|
|
||||||
// clamp time between 0 and max duration
|
|
||||||
let time = Math.min(t, player?.duration ?? 0);
|
|
||||||
time = Math.max(0, time);
|
|
||||||
|
|
||||||
if (Number.isNaN(time)) return;
|
|
||||||
|
|
||||||
// update state
|
|
||||||
if (player) player.currentTime = time;
|
|
||||||
state.progress.time = time;
|
|
||||||
controller?.seek();
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
},
|
|
||||||
setSeeking(active) {
|
|
||||||
state.mediaPlaying.isSeeking = active;
|
|
||||||
state.mediaPlaying.isDragSeeking = active;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
|
|
||||||
// if it was playing when starting to seek, play again
|
|
||||||
if (!active) {
|
|
||||||
if (!state.pausedWhenSeeking) this.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// when seeking we pause the video
|
|
||||||
// this variables isnt reactive, just used so the state can be remembered next unseek
|
|
||||||
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
|
|
||||||
this.pause();
|
|
||||||
},
|
|
||||||
togglePictureInPicture() {
|
|
||||||
// no picture in picture while casting
|
|
||||||
},
|
|
||||||
setPlaybackSpeed(num) {
|
|
||||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
|
||||||
state.meta?.meta.meta.id ?? "video",
|
|
||||||
"video/mp4"
|
|
||||||
);
|
|
||||||
(mediaInfo as any).contentUrl = state.source?.url;
|
|
||||||
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
|
||||||
mediaInfo.metadata = new chrome.cast.media.MovieMediaMetadata();
|
|
||||||
mediaInfo.metadata.title = state.meta?.meta.meta.title ?? "";
|
|
||||||
mediaInfo.customData = {
|
|
||||||
playbackRate: num,
|
|
||||||
};
|
|
||||||
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
|
||||||
request.autoplay = true;
|
|
||||||
const session = ins?.getCurrentSession();
|
|
||||||
session?.loadMedia(request);
|
|
||||||
},
|
|
||||||
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) player.volumeLevel = volume;
|
|
||||||
state.mediaPlaying.volume = volume;
|
|
||||||
controller?.setVolumeLevel();
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
|
|
||||||
// update localstorage
|
|
||||||
setStoredVolume(volume);
|
|
||||||
},
|
|
||||||
setSource(source) {
|
|
||||||
if (!source) {
|
|
||||||
resetStateForSource(descriptor, state);
|
|
||||||
controller?.stop();
|
|
||||||
state.source = null;
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const movieMeta = new chrome.cast.media.MovieMediaMetadata();
|
|
||||||
movieMeta.title = state.meta?.meta.meta.title ?? "";
|
|
||||||
|
|
||||||
const mediaInfo = new chrome.cast.media.MediaInfo(
|
|
||||||
state.meta?.meta.meta.id ?? "video",
|
|
||||||
"video/mp4"
|
|
||||||
);
|
|
||||||
(mediaInfo as any).contentUrl = source?.source;
|
|
||||||
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
|
||||||
mediaInfo.metadata = movieMeta;
|
|
||||||
|
|
||||||
const loadRequest = new chrome.cast.media.LoadRequest(mediaInfo);
|
|
||||||
loadRequest.autoplay = true;
|
|
||||||
// start where video left off before cast
|
|
||||||
loadRequest.currentTime = state.progress.time;
|
|
||||||
|
|
||||||
let captions = null;
|
|
||||||
|
|
||||||
if (state.source?.caption?.id) {
|
|
||||||
let captionIndex: number | undefined;
|
|
||||||
const linkedCaptions = state.meta?.captions;
|
|
||||||
const captionLangIso = state.source?.caption?.id.slice(7);
|
|
||||||
let trackContentId = "";
|
|
||||||
|
|
||||||
if (linkedCaptions) {
|
|
||||||
for (let index = 0; index < linkedCaptions.length; index += 1) {
|
|
||||||
if (captionLangIso === linkedCaptions[index].langIso) {
|
|
||||||
captionIndex = index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (captionIndex) {
|
|
||||||
trackContentId = linkedCaptions[captionIndex].url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const subtitles = new chrome.cast.media.Track(
|
|
||||||
1,
|
|
||||||
chrome.cast.media.TrackType.TEXT
|
|
||||||
);
|
|
||||||
subtitles.trackContentId = trackContentId;
|
|
||||||
subtitles.trackContentType = "text/vtt";
|
|
||||||
subtitles.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
|
|
||||||
subtitles.name = "Subtitles";
|
|
||||||
subtitles.language = "en";
|
|
||||||
|
|
||||||
const tracks = [subtitles];
|
|
||||||
|
|
||||||
mediaInfo.tracks = tracks;
|
|
||||||
mediaInfo.textTrackStyle = new chrome.cast.media.TextTrackStyle();
|
|
||||||
mediaInfo.textTrackStyle.backgroundColor =
|
|
||||||
SettingsStore.get().captionSettings.style.backgroundColor;
|
|
||||||
mediaInfo.textTrackStyle.foregroundColor =
|
|
||||||
SettingsStore.get().captionSettings.style.color.concat("ff"); // needs to be in RGBA format
|
|
||||||
mediaInfo.textTrackStyle.fontScale =
|
|
||||||
SettingsStore.get().captionSettings.style.fontSize / 40; // scale factor way smaller than fortSize
|
|
||||||
|
|
||||||
loadRequest.activeTrackIds = [1];
|
|
||||||
|
|
||||||
captions = {
|
|
||||||
url: state.source.caption.url,
|
|
||||||
id: state.source.caption.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = ins?.getCurrentSession();
|
|
||||||
session?.loadMedia(loadRequest);
|
|
||||||
|
|
||||||
// update state
|
|
||||||
state.source = {
|
|
||||||
quality: source.quality,
|
|
||||||
type: source.type,
|
|
||||||
url: source.source,
|
|
||||||
caption: captions,
|
|
||||||
embedId: source.embedId,
|
|
||||||
providerId: source.providerId,
|
|
||||||
thumbnails: [],
|
|
||||||
};
|
|
||||||
resetStateForSource(descriptor, state);
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
},
|
|
||||||
setCaption(id, url) {
|
|
||||||
if (state.source) {
|
|
||||||
revokeCaptionBlob(state.source.caption?.url);
|
|
||||||
state.source.caption = {
|
|
||||||
id,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
|
|
||||||
// media has to be loaded again to use the new captions
|
|
||||||
this.setSource({
|
|
||||||
quality: state.source.quality,
|
|
||||||
source: state.source.url,
|
|
||||||
type: state.source.type,
|
|
||||||
embedId: state.source.embedId,
|
|
||||||
providerId: state.source.providerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clearCaption() {
|
|
||||||
if (state.source) {
|
|
||||||
revokeCaptionBlob(state.source.caption?.url);
|
|
||||||
state.source.caption = null;
|
|
||||||
|
|
||||||
const tracksInfoRequest = new chrome.cast.media.EditTracksInfoRequest(
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const session = ins?.getCurrentSession();
|
|
||||||
session?.getMediaSession()?.editTracksInfo(
|
|
||||||
tracksInfoRequest,
|
|
||||||
() => console.log("Captions cleared"),
|
|
||||||
(error) => console.log(error)
|
|
||||||
);
|
|
||||||
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
providerStart() {
|
|
||||||
this.setVolume(getStoredVolume());
|
|
||||||
|
|
||||||
const listenToEvents = async (
|
|
||||||
e: cast.framework.RemotePlayerChangedEvent
|
|
||||||
) => {
|
|
||||||
switch (e.field) {
|
|
||||||
case "volumeLevel":
|
|
||||||
if (await canChangeVolume()) {
|
|
||||||
state.mediaPlaying.volume = e.value;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "currentTime":
|
|
||||||
state.progress.time = e.value;
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
break;
|
|
||||||
case "mediaInfo":
|
|
||||||
if (e.value) {
|
|
||||||
state.progress.duration = e.value.duration;
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "playerState":
|
|
||||||
state.mediaPlaying.isLoading = e.value === "BUFFERING";
|
|
||||||
state.mediaPlaying.isPaused = e.value !== "PLAYING";
|
|
||||||
state.mediaPlaying.isPlaying = e.value === "PLAYING";
|
|
||||||
if (e.value === "PLAYING") {
|
|
||||||
state.mediaPlaying.hasPlayedOnce = true;
|
|
||||||
state.mediaPlaying.isFirstLoading = false;
|
|
||||||
}
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
break;
|
|
||||||
case "isMuted":
|
|
||||||
state.mediaPlaying.volume = e.value ? 1 : 0;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
break;
|
|
||||||
case "displayStatus":
|
|
||||||
case "canSeek":
|
|
||||||
case "title":
|
|
||||||
case "isPaused":
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(e.type, e.field, e.value);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const fullscreenchange = () => {
|
|
||||||
state.interface.isFullscreen = !!document.fullscreenElement;
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
};
|
|
||||||
const isFocused = (evt: any) => {
|
|
||||||
state.interface.isFocused = evt.type !== "mouseleave";
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
};
|
|
||||||
|
|
||||||
controller?.addEventListener(
|
|
||||||
cast.framework.RemotePlayerEventType.ANY_CHANGE,
|
|
||||||
listenToEvents
|
|
||||||
);
|
|
||||||
state.wrapperElement?.addEventListener("click", isFocused);
|
|
||||||
state.wrapperElement?.addEventListener("mouseenter", isFocused);
|
|
||||||
state.wrapperElement?.addEventListener("mouseleave", isFocused);
|
|
||||||
fscreen.addEventListener("fullscreenchange", fullscreenchange);
|
|
||||||
|
|
||||||
if (state.source)
|
|
||||||
this.setSource({
|
|
||||||
quality: state.source.quality,
|
|
||||||
source: state.source.url,
|
|
||||||
type: state.source.type,
|
|
||||||
embedId: state.source.embedId,
|
|
||||||
providerId: state.source.providerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
controller?.removeEventListener(
|
|
||||||
cast.framework.RemotePlayerEventType.ANY_CHANGE,
|
|
||||||
listenToEvents
|
|
||||||
);
|
|
||||||
state.wrapperElement?.removeEventListener("click", isFocused);
|
|
||||||
state.wrapperElement?.removeEventListener("mouseenter", isFocused);
|
|
||||||
state.wrapperElement?.removeEventListener("mouseleave", isFocused);
|
|
||||||
fscreen.removeEventListener("fullscreenchange", fullscreenchange);
|
|
||||||
ins?.endCurrentSession(true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import { resetForSource } from "@/_oldvideo/state/init";
|
|
||||||
import { updateMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
import { updateMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { updateProgress } from "@/_oldvideo/state/logic/progress";
|
|
||||||
import { VideoPlayerState } from "@/_oldvideo/state/types";
|
|
||||||
|
|
||||||
export function resetStateForSource(descriptor: string, s: VideoPlayerState) {
|
|
||||||
const state = s;
|
|
||||||
if (state.hlsInstance) {
|
|
||||||
state.hlsInstance.destroy();
|
|
||||||
state.hlsInstance = null;
|
|
||||||
}
|
|
||||||
resetForSource(state);
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
|
|
||||||
type VideoPlayerSource = {
|
|
||||||
source: string;
|
|
||||||
type: MWStreamType;
|
|
||||||
quality: MWStreamQuality;
|
|
||||||
providerId?: string;
|
|
||||||
embedId?: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
export type VideoPlayerStateController = {
|
|
||||||
pause: () => void;
|
|
||||||
play: () => void;
|
|
||||||
setSource: (source: VideoPlayerSource) => void;
|
|
||||||
setTime(time: number): void;
|
|
||||||
setSeeking(active: boolean): void;
|
|
||||||
exitFullscreen(): void;
|
|
||||||
enterFullscreen(): void;
|
|
||||||
setVolume(volume: number, isKeyboardEvent?: boolean): void;
|
|
||||||
startAirplay(): void;
|
|
||||||
setCaption(id: string, url: string): void;
|
|
||||||
clearCaption(): void;
|
|
||||||
getId(): string;
|
|
||||||
togglePictureInPicture(): void;
|
|
||||||
setPlaybackSpeed(num: number): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
|
||||||
providerStart: () => {
|
|
||||||
destroy: () => void;
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,44 +0,0 @@
|
|||||||
import { updateMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
|
|
||||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
|
|
||||||
export function setProvider(
|
|
||||||
descriptor: string,
|
|
||||||
provider: VideoPlayerStateProvider
|
|
||||||
) {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
state.stateProvider = provider;
|
|
||||||
state.initalized = true;
|
|
||||||
state.stateProviderId = provider.getId();
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Note: This only sets the state provider to null. it does not destroy the listener
|
|
||||||
*/
|
|
||||||
export function unsetStateProvider(
|
|
||||||
descriptor: string,
|
|
||||||
stateProviderId: string
|
|
||||||
) {
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
// dont do anything if state provider doesnt match the thing to unset
|
|
||||||
if (
|
|
||||||
!state.stateProvider ||
|
|
||||||
state.stateProvider?.getId() !== stateProviderId
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.stateProvider = null;
|
|
||||||
state.stateProviderId = "video"; // go back to video when casting stops
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
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,382 +0,0 @@
|
|||||||
import fscreen from "fscreen";
|
|
||||||
import Hls from "hls.js";
|
|
||||||
|
|
||||||
import {
|
|
||||||
getStoredVolume,
|
|
||||||
setStoredVolume,
|
|
||||||
} from "@/_oldvideo/components/hooks/volumeStore";
|
|
||||||
import { updateError } from "@/_oldvideo/state/logic/error";
|
|
||||||
import { updateInterface } from "@/_oldvideo/state/logic/interface";
|
|
||||||
import { updateMisc } from "@/_oldvideo/state/logic/misc";
|
|
||||||
import { updateSource } from "@/_oldvideo/state/logic/source";
|
|
||||||
import { resetStateForSource } from "@/_oldvideo/state/providers/helpers";
|
|
||||||
import { revokeCaptionBlob } from "@/backend/helpers/captions";
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
import {
|
|
||||||
canChangeVolume,
|
|
||||||
canFullscreen,
|
|
||||||
canFullscreenAnyElement,
|
|
||||||
canPictureInPicture,
|
|
||||||
canWebkitFullscreen,
|
|
||||||
canWebkitPictureInPicture,
|
|
||||||
} from "@/utils/detectFeatures";
|
|
||||||
|
|
||||||
import { VideoPlayerStateProvider } from "./providerTypes";
|
|
||||||
import { handleBuffered } from "./utils";
|
|
||||||
import { getPlayerState } from "../cache";
|
|
||||||
import { updateMediaPlaying } from "../logic/mediaplaying";
|
|
||||||
import { updateProgress } from "../logic/progress";
|
|
||||||
|
|
||||||
function errorMessage(err: MediaError) {
|
|
||||||
switch (err.code) {
|
|
||||||
case MediaError.MEDIA_ERR_ABORTED:
|
|
||||||
return {
|
|
||||||
code: "ABORTED",
|
|
||||||
description: "Video was aborted",
|
|
||||||
};
|
|
||||||
case MediaError.MEDIA_ERR_NETWORK:
|
|
||||||
return {
|
|
||||||
code: "NETWORK_ERROR",
|
|
||||||
description: "A network error occured, the video failed to stream",
|
|
||||||
};
|
|
||||||
case MediaError.MEDIA_ERR_DECODE:
|
|
||||||
return {
|
|
||||||
code: "DECODE_ERROR",
|
|
||||||
description: "Video stream could not be decoded",
|
|
||||||
};
|
|
||||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
||||||
return {
|
|
||||||
code: "SRC_NOT_SUPPORTED",
|
|
||||||
description: "The video type is not supported by your browser",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
code: "UNKNOWN_ERROR",
|
|
||||||
description: "Unknown media error occured",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVideoStateProvider(
|
|
||||||
descriptor: string,
|
|
||||||
playerEl: HTMLVideoElement
|
|
||||||
): VideoPlayerStateProvider {
|
|
||||||
const player = playerEl;
|
|
||||||
const state = getPlayerState(descriptor);
|
|
||||||
return {
|
|
||||||
getId() {
|
|
||||||
return "video";
|
|
||||||
},
|
|
||||||
play() {
|
|
||||||
player.play();
|
|
||||||
},
|
|
||||||
pause() {
|
|
||||||
player.pause();
|
|
||||||
},
|
|
||||||
exitFullscreen() {
|
|
||||||
if (!fscreen.fullscreenElement) return;
|
|
||||||
fscreen.exitFullscreen();
|
|
||||||
},
|
|
||||||
enterFullscreen() {
|
|
||||||
if (!canFullscreen() || fscreen.fullscreenElement) return;
|
|
||||||
if (canFullscreenAnyElement()) {
|
|
||||||
if (state.wrapperElement)
|
|
||||||
fscreen.requestFullscreen(state.wrapperElement);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canWebkitFullscreen()) {
|
|
||||||
(player as any).webkitEnterFullscreen();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
startAirplay() {
|
|
||||||
const videoPlayer = player as any;
|
|
||||||
if (videoPlayer.webkitShowPlaybackTargetPicker)
|
|
||||||
videoPlayer.webkitShowPlaybackTargetPicker();
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
state.progress.time = time;
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
},
|
|
||||||
setSeeking(active) {
|
|
||||||
state.mediaPlaying.isSeeking = active;
|
|
||||||
state.mediaPlaying.isDragSeeking = active;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
|
|
||||||
// if it was playing when starting to seek, play again
|
|
||||||
if (!active) {
|
|
||||||
if (!state.pausedWhenSeeking) this.play();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// when seeking we pause the video
|
|
||||||
// this variables isnt reactive, just used so the state can be remembered next unseek
|
|
||||||
state.pausedWhenSeeking = state.mediaPlaying.isPaused;
|
|
||||||
this.pause();
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
state.mediaPlaying.volume = volume;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
|
|
||||||
// update localstorage
|
|
||||||
setStoredVolume(volume);
|
|
||||||
},
|
|
||||||
setSource(source) {
|
|
||||||
if (!source) {
|
|
||||||
resetStateForSource(descriptor, state);
|
|
||||||
player.removeAttribute("src");
|
|
||||||
player.load();
|
|
||||||
state.source = null;
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset before assign new one so the old HLS instance gets destroyed
|
|
||||||
resetStateForSource(descriptor, state);
|
|
||||||
// update state
|
|
||||||
state.source = {
|
|
||||||
quality: source.quality,
|
|
||||||
type: source.type,
|
|
||||||
url: source.source,
|
|
||||||
caption: null,
|
|
||||||
embedId: source.embedId,
|
|
||||||
providerId: source.providerId,
|
|
||||||
thumbnails: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (source?.type === MWStreamType.HLS) {
|
|
||||||
if (player.canPlayType("application/vnd.apple.mpegurl")) {
|
|
||||||
// HLS supported natively by browser
|
|
||||||
player.src = source.source;
|
|
||||||
} else {
|
|
||||||
// HLS through HLS.js
|
|
||||||
if (!Hls.isSupported()) {
|
|
||||||
state.error = {
|
|
||||||
name: `Not supported`,
|
|
||||||
description: "Your browser does not support HLS video",
|
|
||||||
};
|
|
||||||
updateError(descriptor, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hls = new Hls({ enableWorker: false });
|
|
||||||
state.hlsInstance = hls;
|
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
||||||
if (data.fatal) {
|
|
||||||
state.error = {
|
|
||||||
name: `error ${data.details}`,
|
|
||||||
description: data.error?.message ?? "Something went wrong",
|
|
||||||
};
|
|
||||||
updateError(descriptor, state);
|
|
||||||
}
|
|
||||||
console.error("HLS error", data);
|
|
||||||
});
|
|
||||||
|
|
||||||
hls.attachMedia(player);
|
|
||||||
hls.loadSource(source.source);
|
|
||||||
}
|
|
||||||
} else if (source.type === MWStreamType.MP4) {
|
|
||||||
// standard MP4 stream
|
|
||||||
player.src = source.source;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
},
|
|
||||||
setCaption(id, url) {
|
|
||||||
if (state.source) {
|
|
||||||
revokeCaptionBlob(state.source.caption?.url);
|
|
||||||
state.source.caption = {
|
|
||||||
id,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
clearCaption() {
|
|
||||||
if (state.source) {
|
|
||||||
revokeCaptionBlob(state.source.caption?.url);
|
|
||||||
state.source.caption = null;
|
|
||||||
updateSource(descriptor, state);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
togglePictureInPicture() {
|
|
||||||
if (canWebkitPictureInPicture()) {
|
|
||||||
const webkitPlayer = player as any;
|
|
||||||
webkitPlayer.webkitSetPresentationMode(
|
|
||||||
webkitPlayer.webkitPresentationMode === "picture-in-picture"
|
|
||||||
? "inline"
|
|
||||||
: "picture-in-picture"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (canPictureInPicture()) {
|
|
||||||
if (player !== document.pictureInPictureElement) {
|
|
||||||
player.requestPictureInPicture();
|
|
||||||
} else {
|
|
||||||
document.exitPictureInPicture();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setPlaybackSpeed(num) {
|
|
||||||
player.playbackRate = num;
|
|
||||||
state.mediaPlaying.playbackSpeed = num;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
},
|
|
||||||
providerStart() {
|
|
||||||
this.setVolume(getStoredVolume());
|
|
||||||
|
|
||||||
const pause = () => {
|
|
||||||
state.mediaPlaying.isPaused = true;
|
|
||||||
state.mediaPlaying.isPlaying = false;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const playing = () => {
|
|
||||||
state.mediaPlaying.isPaused = false;
|
|
||||||
state.mediaPlaying.isPlaying = true;
|
|
||||||
state.mediaPlaying.isLoading = false;
|
|
||||||
state.mediaPlaying.hasPlayedOnce = true;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const waiting = () => {
|
|
||||||
state.mediaPlaying.isLoading = true;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const seeking = () => {
|
|
||||||
state.mediaPlaying.isSeeking = true;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const seeked = () => {
|
|
||||||
state.mediaPlaying.isSeeking = false;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const loadedmetadata = () => {
|
|
||||||
state.progress.duration = player.duration;
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
};
|
|
||||||
const timeupdate = () => {
|
|
||||||
state.progress.duration = player.duration;
|
|
||||||
state.progress.time = player.currentTime;
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
};
|
|
||||||
const progress = () => {
|
|
||||||
state.progress.buffered = handleBuffered(
|
|
||||||
player.currentTime,
|
|
||||||
player.buffered
|
|
||||||
);
|
|
||||||
updateProgress(descriptor, state);
|
|
||||||
};
|
|
||||||
const canplay = () => {
|
|
||||||
state.mediaPlaying.isFirstLoading = false;
|
|
||||||
state.mediaPlaying.isLoading = false;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const ratechange = () => {
|
|
||||||
state.mediaPlaying.playbackSpeed = player.playbackRate;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
};
|
|
||||||
const fullscreenchange = () => {
|
|
||||||
state.interface.isFullscreen =
|
|
||||||
!!document.fullscreenElement || // other browsers
|
|
||||||
!!(document as any).webkitFullscreenElement; // safari
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
};
|
|
||||||
const volumechange = async () => {
|
|
||||||
if (await canChangeVolume()) {
|
|
||||||
state.mediaPlaying.volume = player.volume;
|
|
||||||
updateMediaPlaying(descriptor, state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const isFocused = (evt: any) => {
|
|
||||||
state.interface.isFocused = evt.type !== "mouseleave";
|
|
||||||
updateInterface(descriptor, state);
|
|
||||||
};
|
|
||||||
const canAirplay = (e: any) => {
|
|
||||||
if (e.availability === "available") {
|
|
||||||
state.canAirplay = true;
|
|
||||||
updateMisc(descriptor, state);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const error = () => {
|
|
||||||
if (player.error) {
|
|
||||||
const err = errorMessage(player.error);
|
|
||||||
console.error("Native video player threw error", player.error);
|
|
||||||
state.error = {
|
|
||||||
description: err.description,
|
|
||||||
name: `Error ${err.code}`,
|
|
||||||
};
|
|
||||||
this.pause(); // stop video from playing
|
|
||||||
} else {
|
|
||||||
state.error = null;
|
|
||||||
}
|
|
||||||
updateError(descriptor, state);
|
|
||||||
};
|
|
||||||
|
|
||||||
state.wrapperElement?.addEventListener("click", isFocused);
|
|
||||||
state.wrapperElement?.addEventListener("mouseenter", isFocused);
|
|
||||||
state.wrapperElement?.addEventListener("mouseleave", isFocused);
|
|
||||||
player.addEventListener("volumechange", volumechange);
|
|
||||||
player.addEventListener("pause", pause);
|
|
||||||
player.addEventListener("playing", playing);
|
|
||||||
player.addEventListener("seeking", seeking);
|
|
||||||
player.addEventListener("seeked", seeked);
|
|
||||||
player.addEventListener("progress", progress);
|
|
||||||
player.addEventListener("waiting", waiting);
|
|
||||||
player.addEventListener("timeupdate", timeupdate);
|
|
||||||
player.addEventListener("loadedmetadata", loadedmetadata);
|
|
||||||
player.addEventListener("canplay", canplay);
|
|
||||||
player.addEventListener("ratechange", ratechange);
|
|
||||||
fscreen.addEventListener("fullscreenchange", fullscreenchange);
|
|
||||||
player.addEventListener("error", error);
|
|
||||||
player.addEventListener(
|
|
||||||
"webkitplaybacktargetavailabilitychanged",
|
|
||||||
canAirplay
|
|
||||||
);
|
|
||||||
|
|
||||||
if (state.source)
|
|
||||||
this.setSource({
|
|
||||||
quality: state.source.quality,
|
|
||||||
source: state.source.url,
|
|
||||||
type: state.source.type,
|
|
||||||
embedId: state.source.embedId,
|
|
||||||
providerId: state.source.providerId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
player.removeEventListener("pause", pause);
|
|
||||||
player.removeEventListener("playing", playing);
|
|
||||||
player.removeEventListener("seeking", seeking);
|
|
||||||
player.removeEventListener("volumechange", volumechange);
|
|
||||||
player.removeEventListener("seeked", seeked);
|
|
||||||
player.removeEventListener("timeupdate", timeupdate);
|
|
||||||
player.removeEventListener("loadedmetadata", loadedmetadata);
|
|
||||||
player.removeEventListener("progress", progress);
|
|
||||||
player.removeEventListener("waiting", waiting);
|
|
||||||
player.removeEventListener("error", error);
|
|
||||||
player.removeEventListener("canplay", canplay);
|
|
||||||
fscreen.removeEventListener("fullscreenchange", fullscreenchange);
|
|
||||||
state.wrapperElement?.removeEventListener("click", isFocused);
|
|
||||||
state.wrapperElement?.removeEventListener("mouseenter", isFocused);
|
|
||||||
state.wrapperElement?.removeEventListener("mouseleave", isFocused);
|
|
||||||
player.removeEventListener(
|
|
||||||
"webkitplaybacktargetavailabilitychanged",
|
|
||||||
canAirplay
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,108 +0,0 @@
|
|||||||
import Hls from "hls.js";
|
|
||||||
|
|
||||||
import {
|
|
||||||
MWCaption,
|
|
||||||
MWStreamQuality,
|
|
||||||
MWStreamType,
|
|
||||||
} from "@/backend/helpers/streams";
|
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
|
||||||
|
|
||||||
import { VideoPlayerStateProvider } from "./providers/providerTypes";
|
|
||||||
|
|
||||||
export interface Thumbnail {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
imgUrl: string;
|
|
||||||
}
|
|
||||||
export type VideoPlayerMeta = {
|
|
||||||
meta: DetailedMeta;
|
|
||||||
captions: MWCaption[];
|
|
||||||
episode?: {
|
|
||||||
episodeId: string;
|
|
||||||
seasonId: string;
|
|
||||||
};
|
|
||||||
seasons?: {
|
|
||||||
id: string;
|
|
||||||
number: number;
|
|
||||||
title: string;
|
|
||||||
episodes?: { id: string; number: number; title: string }[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export enum VideoPlayerTimeFormat {
|
|
||||||
REGULAR = 0,
|
|
||||||
REMAINING = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VideoPlayerState = {
|
|
||||||
// state related to the user interface
|
|
||||||
interface: {
|
|
||||||
isFullscreen: boolean;
|
|
||||||
popout: string | null; // id of current popout (eg source select, episode select)
|
|
||||||
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
|
||||||
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
|
|
||||||
volumeChangedWithKeybindDebounce: NodeJS.Timeout | null; // debounce for the duration of the "volume changed thingamajig"
|
|
||||||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
|
||||||
popoutBounds: null | DOMRect; // bounding box of current popout
|
|
||||||
timeFormat: VideoPlayerTimeFormat; // Time format of the video player
|
|
||||||
};
|
|
||||||
|
|
||||||
// state related to the playing state of the media
|
|
||||||
mediaPlaying: {
|
|
||||||
isPlaying: boolean;
|
|
||||||
isPaused: boolean;
|
|
||||||
isSeeking: boolean; // seeking with progress bar
|
|
||||||
isDragSeeking: boolean; // is seeking for our custom progress bar
|
|
||||||
isLoading: boolean; // buffering or not
|
|
||||||
isFirstLoading: boolean; // first buffering of the video, when set to false the video can start playing
|
|
||||||
hasPlayedOnce: boolean; // has the video played at all?
|
|
||||||
volume: number;
|
|
||||||
playbackSpeed: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// state related to video progress
|
|
||||||
progress: {
|
|
||||||
time: number;
|
|
||||||
duration: number;
|
|
||||||
buffered: number;
|
|
||||||
draggingTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// meta data of video
|
|
||||||
meta: null | VideoPlayerMeta;
|
|
||||||
source: null | {
|
|
||||||
quality: MWStreamQuality;
|
|
||||||
url: string;
|
|
||||||
type: MWStreamType;
|
|
||||||
providerId?: string;
|
|
||||||
embedId?: string;
|
|
||||||
caption: null | {
|
|
||||||
url: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
thumbnails: Thumbnail[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// casting state
|
|
||||||
casting: {
|
|
||||||
isCasting: boolean;
|
|
||||||
controller: cast.framework.RemotePlayerController | null;
|
|
||||||
player: cast.framework.RemotePlayer | null;
|
|
||||||
instance: cast.framework.CastContext | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// misc
|
|
||||||
canAirplay: boolean;
|
|
||||||
initalized: boolean;
|
|
||||||
stateProviderId: string;
|
|
||||||
error: null | {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// backing fields
|
|
||||||
pausedWhenSeeking: boolean; // when seeking, used to store if paused when started to seek
|
|
||||||
hlsInstance: null | Hls; // HLS video player instance storage
|
|
||||||
stateProvider: VideoPlayerStateProvider | null;
|
|
||||||
wrapperElement: HTMLDivElement | null;
|
|
||||||
};
|
|
@ -1,17 +1,15 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
|
|
||||||
import { getStoredVolume } from "@/_oldvideo/components/hooks/volumeStore";
|
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { useVolumeStore } from "@/stores/volume";
|
||||||
// TODO use new stored volume
|
|
||||||
|
|
||||||
export function useInitializePlayer() {
|
export function useInitializePlayer() {
|
||||||
const display = usePlayerStore((s) => s.display);
|
const display = usePlayerStore((s) => s.display);
|
||||||
|
const volume = useVolumeStore((s) => s.volume);
|
||||||
|
|
||||||
const init = useCallback(() => {
|
const init = useCallback(() => {
|
||||||
const storedVolume = getStoredVolume();
|
display?.setVolume(volume);
|
||||||
display?.setVolume(storedVolume);
|
}, [display, volume]);
|
||||||
}, [display]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import { setStoredVolume } from "@/_oldvideo/components/hooks/volumeStore";
|
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
import { useVolumeStore } from "@/stores/volume";
|
||||||
// TODO use new stored volume
|
|
||||||
|
|
||||||
export function useVolume() {
|
export function useVolume() {
|
||||||
const volume = usePlayerStore((s) => s.mediaPlaying.volume);
|
const volume = usePlayerStore((s) => s.mediaPlaying.volume);
|
||||||
const lastVolume = usePlayerStore((s) => s.interface.lastVolume);
|
const lastVolume = usePlayerStore((s) => s.interface.lastVolume);
|
||||||
const setLastVolume = usePlayerStore((s) => s.setLastVolume);
|
const setLastVolume = usePlayerStore((s) => s.setLastVolume);
|
||||||
const display = usePlayerStore((s) => s.display);
|
const display = usePlayerStore((s) => s.display);
|
||||||
|
const setStoredVolume = useVolumeStore((s) => s.setVolume);
|
||||||
|
|
||||||
const toggleVolume = (_isKeyboardEvent = false) => {
|
const toggleVolume = (_isKeyboardEvent = false) => {
|
||||||
// TODO use keyboard event
|
// TODO use keyboard event
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
import { ReactNode, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export function createFloatingAnchorEvent(id: string): string {
|
|
||||||
return `__floating::anchor::${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingAnchor(props: Props) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const old = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
function render() {
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
if (ref.current) {
|
|
||||||
const current = old.current;
|
|
||||||
const newer = ref.current.getBoundingClientRect();
|
|
||||||
const newerStr = JSON.stringify(newer);
|
|
||||||
if (current !== newerStr) {
|
|
||||||
old.current = newerStr;
|
|
||||||
const evtStr = createFloatingAnchorEvent(props.id);
|
|
||||||
(window as any)[evtStr] = newer;
|
|
||||||
const evObj = new CustomEvent(createFloatingAnchorEvent(props.id), {
|
|
||||||
detail: newer,
|
|
||||||
});
|
|
||||||
document.dispatchEvent(evObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.requestAnimationFrame(render);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(render);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [props]);
|
|
||||||
|
|
||||||
return <div ref={ref}>{props.children}</div>;
|
|
||||||
}
|
|
@ -1,194 +0,0 @@
|
|||||||
import { animated, easings, useSpringValue } from "@react-spring/web";
|
|
||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { PopoutSection } from "@/_oldvideo/components/popouts/PopoutUtils";
|
|
||||||
import { FloatingCardAnchorPosition } from "@/components/popout/positions/FloatingCardAnchorPosition";
|
|
||||||
import { FloatingCardMobilePosition } from "@/components/popout/positions/FloatingCardMobilePosition";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
import { FloatingDragHandle, MobilePopoutSpacer } from "./FloatingDragHandle";
|
|
||||||
import { Icon, Icons } from "../Icon";
|
|
||||||
|
|
||||||
interface FloatingCardProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
onClose?: () => void;
|
|
||||||
for: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RootFloatingCardProps extends FloatingCardProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardBase(props: { children: ReactNode }) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const height = useSpringValue(0, {
|
|
||||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
|
||||||
});
|
|
||||||
const width = useSpringValue(0, {
|
|
||||||
config: { easing: easings.easeInOutSine, duration: 300 },
|
|
||||||
});
|
|
||||||
const [pages, setPages] = useState<NodeListOf<Element> | null>(null);
|
|
||||||
|
|
||||||
const getNewHeight = useCallback(
|
|
||||||
(updateList = true) => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
const children = ref.current.querySelectorAll(
|
|
||||||
":scope *[data-floating-page='true']"
|
|
||||||
);
|
|
||||||
if (updateList) setPages(children);
|
|
||||||
if (children.length === 0) {
|
|
||||||
height.start(0);
|
|
||||||
width.start(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastChild = children[children.length - 1];
|
|
||||||
const rect = lastChild.getBoundingClientRect();
|
|
||||||
const rectHeight = lastChild.scrollHeight;
|
|
||||||
if (height.get() === 0) {
|
|
||||||
height.set(rectHeight);
|
|
||||||
width.set(rect.width);
|
|
||||||
} else {
|
|
||||||
height.start(rectHeight);
|
|
||||||
width.start(rect.width);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[height, width]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
getNewHeight();
|
|
||||||
const observer = new MutationObserver(() => {
|
|
||||||
getNewHeight();
|
|
||||||
});
|
|
||||||
observer.observe(ref.current, {
|
|
||||||
attributes: false,
|
|
||||||
childList: true,
|
|
||||||
subtree: false,
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [getNewHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
getNewHeight(false);
|
|
||||||
});
|
|
||||||
pages?.forEach((el) => observer.observe(el));
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [pages, getNewHeight]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<animated.div
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
height,
|
|
||||||
width: isMobile ? "100%" : width,
|
|
||||||
}}
|
|
||||||
className="relative flex items-center justify-center overflow-hidden"
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</animated.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingCard(props: RootFloatingCardProps) {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const content = <CardBase>{props.children}</CardBase>;
|
|
||||||
|
|
||||||
if (isMobile)
|
|
||||||
return (
|
|
||||||
<FloatingCardMobilePosition
|
|
||||||
className={props.className}
|
|
||||||
onClose={props.onClose}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</FloatingCardMobilePosition>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FloatingCardAnchorPosition id={props.for} className={props.className}>
|
|
||||||
{content}
|
|
||||||
</FloatingCardAnchorPosition>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PopoutFloatingCard(props: FloatingCardProps) {
|
|
||||||
return (
|
|
||||||
<FloatingCard
|
|
||||||
className="overflow-hidden rounded-md bg-ash-300"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FloatingCardView = {
|
|
||||||
Header(props: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
close?: boolean;
|
|
||||||
goBack: () => any;
|
|
||||||
action?: React.ReactNode;
|
|
||||||
backText?: string;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
let left = (
|
|
||||||
<div
|
|
||||||
onClick={props.goBack}
|
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
|
||||||
>
|
|
||||||
<Icon icon={Icons.ARROW_LEFT} />
|
|
||||||
<span>{props.backText || t("videoPlayer.popouts.back")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
if (props.close)
|
|
||||||
left = (
|
|
||||||
<div
|
|
||||||
onClick={props.goBack}
|
|
||||||
className="flex cursor-pointer items-center space-x-2 transition-colors duration-200 hover:text-white"
|
|
||||||
>
|
|
||||||
<Icon icon={Icons.X} />
|
|
||||||
<span>{t("videoPlayer.popouts.close")}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col bg-[#1C161B]">
|
|
||||||
<FloatingDragHandle />
|
|
||||||
<PopoutSection>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div>{left}</div>
|
|
||||||
<div>{props.action ?? null}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mb-2 mt-8 text-3xl font-bold text-white">
|
|
||||||
{props.title}
|
|
||||||
</h2>
|
|
||||||
<p>{props.description}</p>
|
|
||||||
</PopoutSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
Content(props: { children: React.ReactNode; noSection?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="grid h-full grid-rows-[1fr]">
|
|
||||||
{props.noSection ? (
|
|
||||||
<div className="relative h-full overflow-y-auto bg-ash-300">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<PopoutSection className="relative h-full overflow-y-auto bg-ash-300">
|
|
||||||
{props.children}
|
|
||||||
</PopoutSection>
|
|
||||||
)}
|
|
||||||
<MobilePopoutSpacer />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,76 +0,0 @@
|
|||||||
import React, {
|
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import { Transition } from "@/components/Transition";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children?: ReactNode;
|
|
||||||
onClose?: () => void;
|
|
||||||
show?: boolean;
|
|
||||||
darken?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingContainer(props: Props) {
|
|
||||||
const [portalElement, setPortalElement] = useState<Element | null>(null);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const target = useRef<Element | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function listen(e: MouseEvent) {
|
|
||||||
target.current = e.target as Element;
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", listen);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", listen);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const click = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
const startedTarget = target.current;
|
|
||||||
target.current = null;
|
|
||||||
if (e.currentTarget !== e.target) return;
|
|
||||||
if (!startedTarget) return;
|
|
||||||
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return;
|
|
||||||
if (props.onClose) props.onClose();
|
|
||||||
},
|
|
||||||
[props]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const element = ref.current?.closest(".popout-location");
|
|
||||||
setPortalElement(element ?? document.body);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref}>
|
|
||||||
{portalElement
|
|
||||||
? createPortal(
|
|
||||||
<Transition show={props.show} animation="none">
|
|
||||||
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none">
|
|
||||||
<Transition animation="fade" isChild>
|
|
||||||
<div
|
|
||||||
onClick={click}
|
|
||||||
className={[
|
|
||||||
"absolute inset-0",
|
|
||||||
props.darken ? "bg-black opacity-90" : "",
|
|
||||||
].join(" ")}
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
<Transition animation="slide-up" className="h-0" isChild>
|
|
||||||
{props.children}
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Transition>,
|
|
||||||
portalElement
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
export function FloatingDragHandle() {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
|
|
||||||
if (!isMobile) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative z-50 mx-auto my-3 -mb-3 h-1 w-12 rounded-full bg-ash-500 bg-opacity-30" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobilePopoutSpacer() {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
|
|
||||||
if (!isMobile) return null;
|
|
||||||
|
|
||||||
return <div className="h-[200px]" />;
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
import { Transition } from "@/components/Transition";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children?: ReactNode;
|
|
||||||
show?: boolean;
|
|
||||||
className?: string;
|
|
||||||
height?: number;
|
|
||||||
width?: number;
|
|
||||||
active?: boolean; // true if a child view is loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingView(props: Props) {
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const width = !isMobile ? `${props.width}px` : "100%";
|
|
||||||
return (
|
|
||||||
<Transition
|
|
||||||
animation={props.active ? "slide-full-left" : "slide-full-right"}
|
|
||||||
className="absolute inset-0"
|
|
||||||
durationClass="duration-[400ms]"
|
|
||||||
show={props.show}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
props.className ?? "",
|
|
||||||
"grid grid-rows-[auto,minmax(0,1fr)]",
|
|
||||||
].join(" ")}
|
|
||||||
data-floating-page={props.show ? "true" : undefined}
|
|
||||||
style={{
|
|
||||||
height: props.height ? `${props.height}px` : undefined,
|
|
||||||
maxHeight: "70vh",
|
|
||||||
width: props.width ? width : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,81 +0,0 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { createFloatingAnchorEvent } from "@/components/popout/FloatingAnchor";
|
|
||||||
|
|
||||||
interface AnchorPositionProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
id: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingCardAnchorPosition(props: AnchorPositionProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [left, setLeft] = useState<number>(0);
|
|
||||||
const [top, setTop] = useState<number>(0);
|
|
||||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
|
||||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
||||||
|
|
||||||
const calculateAndSetCoords = useCallback(
|
|
||||||
(anchor: DOMRect, card: DOMRect) => {
|
|
||||||
const buttonCenter = anchor.left + anchor.width / 2;
|
|
||||||
const bottomReal = window.innerHeight - anchor.bottom;
|
|
||||||
|
|
||||||
setTop(
|
|
||||||
window.innerHeight - bottomReal - anchor.height - card.height - 30
|
|
||||||
);
|
|
||||||
setLeft(
|
|
||||||
Math.min(
|
|
||||||
buttonCenter - card.width / 2,
|
|
||||||
window.innerWidth - card.width - 30
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!anchorRect || !cardRect) return;
|
|
||||||
calculateAndSetCoords(anchorRect, cardRect);
|
|
||||||
}, [anchorRect, calculateAndSetCoords, cardRect]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
function checkBox() {
|
|
||||||
const divRect = ref.current?.getBoundingClientRect();
|
|
||||||
setCardRect(divRect ?? null);
|
|
||||||
}
|
|
||||||
checkBox();
|
|
||||||
const observer = new ResizeObserver(checkBox);
|
|
||||||
observer.observe(ref.current);
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const evtStr = createFloatingAnchorEvent(props.id);
|
|
||||||
if ((window as any)[evtStr]) setAnchorRect((window as any)[evtStr]);
|
|
||||||
function listen(ev: CustomEvent<DOMRect>) {
|
|
||||||
setAnchorRect(ev.detail);
|
|
||||||
}
|
|
||||||
document.addEventListener(evtStr, listen as any);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener(evtStr, listen as any);
|
|
||||||
};
|
|
||||||
}, [props.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
style={{
|
|
||||||
transform: `translateX(${left}px) translateY(${top}px)`,
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
"pointer-events-auto z-10 inline-block origin-top-left touch-none overflow-hidden",
|
|
||||||
props.className ?? "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,103 +0,0 @@
|
|||||||
import { animated, config, useSpring } from "@react-spring/web";
|
|
||||||
import { useDrag } from "@use-gesture/react";
|
|
||||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface MobilePositionProps {
|
|
||||||
children?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingCardMobilePosition(props: MobilePositionProps) {
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const closing = useRef<boolean>(false);
|
|
||||||
const [cardRect, setCardRect] = useState<DOMRect | null>(null);
|
|
||||||
const [{ y }, api] = useSpring(() => ({
|
|
||||||
y: 0,
|
|
||||||
onRest() {
|
|
||||||
if (!closing.current) return;
|
|
||||||
if (props.onClose) props.onClose();
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const bind = useDrag(
|
|
||||||
({
|
|
||||||
last,
|
|
||||||
velocity: [, vy],
|
|
||||||
direction: [, dy],
|
|
||||||
movement: [, my],
|
|
||||||
...event
|
|
||||||
}) => {
|
|
||||||
if (closing.current) return;
|
|
||||||
|
|
||||||
const isInScrollable = (event.target as HTMLDivElement).closest(
|
|
||||||
".overflow-y-auto"
|
|
||||||
);
|
|
||||||
if (isInScrollable) return; // Don't attempt to swipe the thing away if it's a scroll area unless the scroll area is at the top and the user is swiping down
|
|
||||||
|
|
||||||
const height = cardRect?.height ?? 0;
|
|
||||||
if (last) {
|
|
||||||
// if past half height downwards
|
|
||||||
// OR Y velocity is past 0.5 AND going down AND 20 pixels below start position
|
|
||||||
if (my > height * 0.5 || (vy > 0.5 && dy > 0 && my > 20)) {
|
|
||||||
api.start({
|
|
||||||
y: height * 1.2,
|
|
||||||
immediate: false,
|
|
||||||
config: { ...config.wobbly, velocity: vy, clamp: true },
|
|
||||||
});
|
|
||||||
closing.current = true;
|
|
||||||
} else {
|
|
||||||
api.start({
|
|
||||||
y: 0,
|
|
||||||
immediate: false,
|
|
||||||
config: config.wobbly,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
api.start({ y: my, immediate: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
from: () => [0, y.get()],
|
|
||||||
filterTaps: true,
|
|
||||||
bounds: { top: 0 },
|
|
||||||
rubberband: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
function checkBox() {
|
|
||||||
const divRect = ref.current?.getBoundingClientRect();
|
|
||||||
setCardRect(divRect ?? null);
|
|
||||||
}
|
|
||||||
checkBox();
|
|
||||||
const observer = new ResizeObserver(checkBox);
|
|
||||||
observer.observe(ref.current);
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="is-mobile-view absolute inset-x-0 mx-auto max-w-[400px] origin-bottom-left touch-none"
|
|
||||||
style={{
|
|
||||||
transform: `translateY(${
|
|
||||||
window.innerHeight - (cardRect?.height ?? 0) + 200
|
|
||||||
}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<animated.div
|
|
||||||
ref={ref}
|
|
||||||
className={[props.className ?? "", "touch-none"].join(" ")}
|
|
||||||
style={{
|
|
||||||
y,
|
|
||||||
}}
|
|
||||||
{...bind()}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</animated.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { findBestStream } from "@/backend/helpers/scrape";
|
|
||||||
import { MWStream } from "@/backend/helpers/streams";
|
|
||||||
import { DetailedMeta } from "@/backend/metadata/getmeta";
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
|
||||||
|
|
||||||
export interface ScrapeEventLog {
|
|
||||||
type: "provider" | "embed";
|
|
||||||
errored: boolean;
|
|
||||||
percentage: number;
|
|
||||||
eventId: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SelectedMediaData =
|
|
||||||
| {
|
|
||||||
type: MWMediaType.SERIES;
|
|
||||||
episode: string;
|
|
||||||
season: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: MWMediaType.MOVIE | MWMediaType.ANIME;
|
|
||||||
episode: undefined;
|
|
||||||
season: undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useScrape(meta: DetailedMeta, selected: SelectedMediaData) {
|
|
||||||
const [eventLog, setEventLog] = useState<ScrapeEventLog[]>([]);
|
|
||||||
const [stream, setStream] = useState<MWStream | null>(null);
|
|
||||||
const [pending, setPending] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPending(true);
|
|
||||||
setStream(null);
|
|
||||||
setEventLog([]);
|
|
||||||
(async () => {
|
|
||||||
const scrapedStream = await findBestStream({
|
|
||||||
media: meta,
|
|
||||||
...selected,
|
|
||||||
onNext(ctx) {
|
|
||||||
setEventLog((arr) => [
|
|
||||||
...arr,
|
|
||||||
{
|
|
||||||
errored: false,
|
|
||||||
id: ctx.id,
|
|
||||||
eventId: ctx.eventId,
|
|
||||||
type: ctx.type,
|
|
||||||
percentage: 0,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onProgress(ctx) {
|
|
||||||
setEventLog((arr) => {
|
|
||||||
const item = arr.reverse().find((v) => v.id === ctx.id);
|
|
||||||
if (item) {
|
|
||||||
item.errored = ctx.errored;
|
|
||||||
item.percentage = ctx.percentage;
|
|
||||||
}
|
|
||||||
return [...arr];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setPending(false);
|
|
||||||
setStream(scrapedStream);
|
|
||||||
})();
|
|
||||||
}, [meta, selected]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
stream,
|
|
||||||
pending,
|
|
||||||
eventLog,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,25 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { useControls } from "@/_oldvideo/state/logic/controls";
|
|
||||||
import { useMediaPlaying } from "@/_oldvideo/state/logic/mediaplaying";
|
|
||||||
|
|
||||||
export function useVolumeControl(descriptor: string) {
|
|
||||||
const [storedVolume, setStoredVolume] = useState(1);
|
|
||||||
const controls = useControls(descriptor);
|
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
|
||||||
|
|
||||||
const toggleVolume = (isKeyboardEvent = false) => {
|
|
||||||
if (mediaPlaying.volume > 0) {
|
|
||||||
setStoredVolume(mediaPlaying.volume);
|
|
||||||
controls.setVolume(0, isKeyboardEvent);
|
|
||||||
} else {
|
|
||||||
controls.setVolume(storedVolume > 0 ? storedVolume : 1, isKeyboardEvent);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
storedVolume,
|
|
||||||
setStoredVolume,
|
|
||||||
toggleVolume,
|
|
||||||
};
|
|
||||||
}
|
|
@ -2,29 +2,17 @@ import { ReactNode } from "react";
|
|||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader";
|
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
import { useGoBack } from "@/hooks/useGoBack";
|
|
||||||
|
|
||||||
export function ErrorWrapperPart(props: {
|
export function ErrorWrapperPart(props: { children?: ReactNode }) {
|
||||||
children?: ReactNode;
|
|
||||||
video?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const goBack = useGoBack();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col">
|
<div className="relative flex flex-1 flex-col">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("notFound.genericTitle")}</title>
|
<title>{t("notFound.genericTitle")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
{props.video ? (
|
|
||||||
<div className="absolute inset-x-0 top-0 px-8 py-6">
|
|
||||||
<VideoPlayerHeader onClick={goBack} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Navigation />
|
<Navigation />
|
||||||
)}
|
|
||||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
|
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { StateCreator, create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
import { persist } from "zustand/middleware";
|
||||||
import { immer } from "zustand/middleware/immer";
|
import { immer } from "zustand/middleware/immer";
|
||||||
|
|
||||||
@ -7,14 +7,8 @@ export interface VolumeStore {
|
|||||||
setVolume(v: number): void;
|
setVolume(v: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VolumeState = StateCreator<
|
|
||||||
VolumeStore,
|
|
||||||
[["zustand/persist", never]],
|
|
||||||
[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
// TODO add migration from previous stored volume
|
// TODO add migration from previous stored volume
|
||||||
export const useVolumeStore: VolumeState = create(
|
export const useVolumeStore = create(
|
||||||
persist(
|
persist(
|
||||||
immer<VolumeStore>((set) => ({
|
immer<VolumeStore>((set) => ({
|
||||||
volume: 1,
|
volume: 1,
|
||||||
|
Loading…
Reference in New Issue
Block a user