Delete old video components, delete popout system, delete old hooks and implement new volume store

This commit is contained in:
mrjvs 2023-10-14 22:28:13 +02:00
parent e7de27e33b
commit fa1ad06968
89 changed files with 10 additions and 6101 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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