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