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