mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-26 17:11:50 +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 { getStoredVolume } from "@/_oldvideo/components/hooks/volumeStore";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
// TODO use new stored volume
|
||||
import { useVolumeStore } from "@/stores/volume";
|
||||
|
||||
export function useInitializePlayer() {
|
||||
const display = usePlayerStore((s) => s.display);
|
||||
const volume = useVolumeStore((s) => s.volume);
|
||||
|
||||
const init = useCallback(() => {
|
||||
const storedVolume = getStoredVolume();
|
||||
display?.setVolume(storedVolume);
|
||||
}, [display]);
|
||||
display?.setVolume(volume);
|
||||
}, [display, volume]);
|
||||
|
||||
return {
|
||||
init,
|
||||
|
@ -1,13 +1,12 @@
|
||||
import { setStoredVolume } from "@/_oldvideo/components/hooks/volumeStore";
|
||||
import { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
// TODO use new stored volume
|
||||
import { useVolumeStore } from "@/stores/volume";
|
||||
|
||||
export function useVolume() {
|
||||
const volume = usePlayerStore((s) => s.mediaPlaying.volume);
|
||||
const lastVolume = usePlayerStore((s) => s.interface.lastVolume);
|
||||
const setLastVolume = usePlayerStore((s) => s.setLastVolume);
|
||||
const display = usePlayerStore((s) => s.display);
|
||||
const setStoredVolume = useVolumeStore((s) => s.setVolume);
|
||||
|
||||
const toggleVolume = (_isKeyboardEvent = false) => {
|
||||
// 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 { useTranslation } from "react-i18next";
|
||||
|
||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { useGoBack } from "@/hooks/useGoBack";
|
||||
|
||||
export function ErrorWrapperPart(props: {
|
||||
children?: ReactNode;
|
||||
video?: boolean;
|
||||
}) {
|
||||
export function ErrorWrapperPart(props: { children?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const goBack = useGoBack();
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<Helmet>
|
||||
<title>{t("notFound.genericTitle")}</title>
|
||||
</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">
|
||||
{props.children}
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StateCreator, create } from "zustand";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
|
||||
@ -7,14 +7,8 @@ export interface VolumeStore {
|
||||
setVolume(v: number): void;
|
||||
}
|
||||
|
||||
export type VolumeState = StateCreator<
|
||||
VolumeStore,
|
||||
[["zustand/persist", never]],
|
||||
[]
|
||||
>;
|
||||
|
||||
// TODO add migration from previous stored volume
|
||||
export const useVolumeStore: VolumeState = create(
|
||||
export const useVolumeStore = create(
|
||||
persist(
|
||||
immer<VolumeStore>((set) => ({
|
||||
volume: 1,
|
||||
|
Loading…
Reference in New Issue
Block a user