mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
chromecasting humble beginnings
Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
parent
e569f15661
commit
4a0392d1f0
19 changed files with 414 additions and 15 deletions
|
@ -49,6 +49,7 @@ module.exports = {
|
||||||
"no-eval": "off",
|
"no-eval": "off",
|
||||||
"no-await-in-loop": "off",
|
"no-await-in-loop": "off",
|
||||||
"no-nested-ternary": "off",
|
"no-nested-ternary": "off",
|
||||||
|
"prefer-destructuring": "off",
|
||||||
"react/jsx-filename-extension": [
|
"react/jsx-filename-extension": [
|
||||||
"error",
|
"error",
|
||||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export enum Icons {
|
export enum Icons {
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
|
@ -33,6 +33,7 @@ export enum Icons {
|
||||||
FILE = "file",
|
FILE = "file",
|
||||||
CAPTIONS = "captions",
|
CAPTIONS = "captions",
|
||||||
LINK = "link",
|
LINK = "link",
|
||||||
|
CASTING = "casting",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
@ -73,9 +74,26 @@ const iconList: Record<Icons, string> = {
|
||||||
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
file: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||||
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path fill="currentColor" d="M0 96C0 60.7 28.7 32 64 32H512c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM200 208c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48s21.5-48 48-48zm144 48c0-26.5 21.5-48 48-48c14.2 0 27 6.1 35.8 16c8.8 9.9 24 10.7 33.9 1.9s10.7-24 1.9-33.9c-17.5-19.6-43.1-32-71.5-32c-53 0-96 43-96 96s43 96 96 96c28.4 0 54-12.4 71.5-32c8.8-9.9 8-25-1.9-33.9s-25-8-33.9 1.9c-8.8 9.9-21.6 16-35.8 16c-26.5 0-48-21.5-48-48z"/></svg>`,
|
||||||
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
link: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>`,
|
||||||
|
casting: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ChromeCastButton() {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tag = document.createElement("google-cast-launcher");
|
||||||
|
tag.setAttribute("id", "castbutton");
|
||||||
|
ref.current?.appendChild(tag);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <div ref={ref} className="h-6" />;
|
||||||
|
}
|
||||||
|
|
||||||
export const Icon = memo((props: IconProps) => {
|
export const Icon = memo((props: IconProps) => {
|
||||||
|
if (props.icon === Icons.CASTING) {
|
||||||
|
return <ChromeCastButton />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
dangerouslySetInnerHTML={{ __html: iconList[props.icon] }} // eslint-disable-line react/no-danger
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import "./Spinner.css";
|
import "./Spinner.css";
|
||||||
|
|
||||||
interface SpinnerProps {
|
interface SpinnerProps {
|
||||||
className: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Spinner(props: SpinnerProps) {
|
export function Spinner(props: SpinnerProps) {
|
||||||
return <div className={["spinner", props.className].join(" ")} />;
|
return <div className={["spinner", props.className ?? ""].join(" ")} />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@ function MediaCardContent({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover transition-[border-radius] duration-100 group-hover:rounded-lg"
|
className="relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100 group-hover:rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
backgroundImage: media.poster ? `url(${media.poster})` : undefined,
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useControls } from "@/video/state/logic/controls";
|
import { useControls } from "@/video/state/logic/controls";
|
||||||
import { ReactNode, useCallback, useState } from "react";
|
import { ReactNode, useCallback, useState } from "react";
|
||||||
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
import { PopoutProviderAction } from "@/video/components/popouts/PopoutProviderAction";
|
||||||
|
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||||
|
|
||||||
type Props = VideoPlayerBaseProps;
|
type Props = VideoPlayerBaseProps;
|
||||||
|
|
||||||
|
@ -138,7 +139,7 @@ export function VideoPlayer(props: Props) {
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<CaptionsSelectionAction />
|
<CaptionsSelectionAction />
|
||||||
<SeriesSelectionAction />
|
<SeriesSelectionAction />
|
||||||
{/* <SourceSelectionControl media={props.media} /> */}
|
<SourceSelectionAction />
|
||||||
</div>
|
</div>
|
||||||
<FullscreenAction />
|
<FullscreenAction />
|
||||||
</div>
|
</div>
|
||||||
|
@ -149,9 +150,8 @@ export function VideoPlayer(props: Props) {
|
||||||
<QualityDisplayAction />
|
<QualityDisplayAction />
|
||||||
<SeriesSelectionAction />
|
<SeriesSelectionAction />
|
||||||
<SourceSelectionAction />
|
<SourceSelectionAction />
|
||||||
{/* <SourceSelectionControl media={props.media} /> */}
|
|
||||||
<div className="mx-2 h-6 w-px bg-white opacity-50" />
|
<div className="mx-2 h-6 w-px bg-white opacity-50" />
|
||||||
{/* <ChromeCastControl /> */}
|
<ChromecastAction />
|
||||||
<AirplayAction />
|
<AirplayAction />
|
||||||
<CaptionsSelectionAction />
|
<CaptionsSelectionAction />
|
||||||
<FullscreenAction />
|
<FullscreenAction />
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { CastingInternal } from "@/video/components/internal/CastingInternal";
|
||||||
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
|
import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal";
|
||||||
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
|
import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary";
|
||||||
import { useInterface } from "@/video/state/logic/interface";
|
import { useInterface } from "@/video/state/logic/interface";
|
||||||
|
@ -42,6 +43,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) {
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<VideoElementInternal autoPlay={props.autoPlay} />
|
<VideoElementInternal autoPlay={props.autoPlay} />
|
||||||
|
<CastingInternal />
|
||||||
<WrapperRegisterInternal wrapper={ref.current} />
|
<WrapperRegisterInternal wrapper={ref.current} />
|
||||||
<div className="absolute inset-0">{children}</div>
|
<div className="absolute inset-0">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
12
src/video/components/actions/ChromecastAction.tsx
Normal file
12
src/video/components/actions/ChromecastAction.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { VideoPlayerIconButton } from "@/video/components/parts/VideoPlayerIconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChromecastAction(props: Props) {
|
||||||
|
return (
|
||||||
|
<VideoPlayerIconButton className={props.className} icon={Icons.CASTING} />
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,14 +1,17 @@
|
||||||
import { Spinner } from "@/components/layout/Spinner";
|
import { Spinner } from "@/components/layout/Spinner";
|
||||||
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
import { useMediaPlaying } from "@/video/state/logic/mediaplaying";
|
||||||
|
import { useMisc } from "@/video/state/logic/misc";
|
||||||
|
|
||||||
export function LoadingAction() {
|
export function LoadingAction() {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
const mediaPlaying = useMediaPlaying(descriptor);
|
||||||
|
const misc = useMisc(descriptor);
|
||||||
|
|
||||||
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
|
const isLoading = mediaPlaying.isFirstLoading || mediaPlaying.isLoading;
|
||||||
|
const shouldShow = !misc.isCasting;
|
||||||
|
|
||||||
if (!isLoading) return null;
|
if (!isLoading || !shouldShow) return null;
|
||||||
|
|
||||||
return <Spinner />;
|
return <Spinner />;
|
||||||
}
|
}
|
||||||
|
|
65
src/video/components/internal/CastingInternal.tsx
Normal file
65
src/video/components/internal/CastingInternal.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
|
||||||
|
import { getPlayerState } from "@/video/state/cache";
|
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks";
|
||||||
|
import { updateMisc, useMisc } from "@/video/state/logic/misc";
|
||||||
|
import { createCastingStateProvider } from "@/video/state/providers/castingStateProvider";
|
||||||
|
import { setProvider, unsetStateProvider } from "@/video/state/providers/utils";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!isCasting) return;
|
||||||
|
lastValue.current = isCasting;
|
||||||
|
const provider = createCastingStateProvider(descriptor);
|
||||||
|
setProvider(descriptor, provider);
|
||||||
|
const { destroy } = provider.providerStart();
|
||||||
|
return () => {
|
||||||
|
unsetStateProvider(descriptor, provider.getId());
|
||||||
|
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;
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ interface Props {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoElementInternal(props: Props) {
|
function VideoElement(props: Props) {
|
||||||
const descriptor = useVideoPlayerDescriptor();
|
const descriptor = useVideoPlayerDescriptor();
|
||||||
const mediaPlaying = useMediaPlaying(descriptor);
|
const mediaPlaying = useMediaPlaying(descriptor);
|
||||||
const source = useSource(descriptor);
|
const source = useSource(descriptor);
|
||||||
|
@ -18,6 +18,7 @@ export function VideoElementInternal(props: Props) {
|
||||||
const ref = useRef<HTMLVideoElement>(null);
|
const ref = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]);
|
const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]);
|
||||||
|
const stateProviderId = useMemo(() => misc.stateProviderId, [misc]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initalized) return;
|
if (!initalized) return;
|
||||||
|
@ -26,10 +27,10 @@ export function VideoElementInternal(props: Props) {
|
||||||
setProvider(descriptor, provider);
|
setProvider(descriptor, provider);
|
||||||
const { destroy } = provider.providerStart();
|
const { destroy } = provider.providerStart();
|
||||||
return () => {
|
return () => {
|
||||||
unsetStateProvider(descriptor);
|
unsetStateProvider(descriptor, provider.getId());
|
||||||
destroy();
|
destroy();
|
||||||
};
|
};
|
||||||
}, [descriptor, initalized]);
|
}, [descriptor, initalized, stateProviderId]);
|
||||||
|
|
||||||
// this element is remotely controlled by a state provider
|
// this element is remotely controlled by a state provider
|
||||||
return (
|
return (
|
||||||
|
@ -46,3 +47,12 @@ export function VideoElementInternal(props: Props) {
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
useBookmarkContext,
|
useBookmarkContext,
|
||||||
} from "@/state/bookmark";
|
} from "@/state/bookmark";
|
||||||
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
import { AirplayAction } from "@/video/components/actions/AirplayAction";
|
||||||
|
import { ChromecastAction } from "@/video/components/actions/ChromecastAction";
|
||||||
|
|
||||||
interface VideoPlayerHeaderProps {
|
interface VideoPlayerHeaderProps {
|
||||||
media?: MWMediaMeta;
|
media?: MWMediaMeta;
|
||||||
|
@ -55,9 +56,11 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{props.showControls ? (
|
{props.showControls ? (
|
||||||
<AirplayAction />
|
<>
|
||||||
|
<AirplayAction />
|
||||||
|
<ChromecastAction />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
// chromecontrol
|
|
||||||
<BrandPill />
|
<BrandPill />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -51,12 +51,20 @@ function initPlayer(): VideoPlayerState {
|
||||||
draggingTime: 0,
|
draggingTime: 0,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
casting: {
|
||||||
|
isCasting: false,
|
||||||
|
controller: null,
|
||||||
|
instance: null,
|
||||||
|
player: null,
|
||||||
|
},
|
||||||
|
|
||||||
meta: null,
|
meta: null,
|
||||||
source: null,
|
source: null,
|
||||||
|
|
||||||
error: null,
|
error: null,
|
||||||
canAirplay: false,
|
canAirplay: false,
|
||||||
initalized: false,
|
initalized: false,
|
||||||
|
stateProviderId: "video",
|
||||||
|
|
||||||
pausedWhenSeeking: false,
|
pausedWhenSeeking: false,
|
||||||
hlsInstance: null,
|
hlsInstance: null,
|
||||||
|
|
|
@ -22,6 +22,9 @@ export function useControls(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state provider controls
|
// state provider controls
|
||||||
|
getId() {
|
||||||
|
return state.stateProvider?.getId() ?? "";
|
||||||
|
},
|
||||||
pause() {
|
pause() {
|
||||||
state.stateProvider?.pause();
|
state.stateProvider?.pause();
|
||||||
},
|
},
|
||||||
|
|
|
@ -7,6 +7,8 @@ export type VideoMiscError = {
|
||||||
canAirplay: boolean;
|
canAirplay: boolean;
|
||||||
wrapperInitialized: boolean;
|
wrapperInitialized: boolean;
|
||||||
initalized: boolean;
|
initalized: boolean;
|
||||||
|
isCasting: boolean;
|
||||||
|
stateProviderId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getMiscFromState(state: VideoPlayerState): VideoMiscError {
|
function getMiscFromState(state: VideoPlayerState): VideoMiscError {
|
||||||
|
@ -14,6 +16,8 @@ function getMiscFromState(state: VideoPlayerState): VideoMiscError {
|
||||||
canAirplay: state.canAirplay,
|
canAirplay: state.canAirplay,
|
||||||
wrapperInitialized: !!state.wrapperElement,
|
wrapperInitialized: !!state.wrapperElement,
|
||||||
initalized: state.initalized,
|
initalized: state.initalized,
|
||||||
|
isCasting: state.casting.isCasting,
|
||||||
|
stateProviderId: state.stateProviderId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
236
src/video/state/providers/castingStateProvider.ts
Normal file
236
src/video/state/providers/castingStateProvider.ts
Normal file
|
@ -0,0 +1,236 @@
|
||||||
|
import fscreen from "fscreen";
|
||||||
|
import {
|
||||||
|
canChangeVolume,
|
||||||
|
canFullscreen,
|
||||||
|
canFullscreenAnyElement,
|
||||||
|
canWebkitFullscreen,
|
||||||
|
} from "@/utils/detectFeatures";
|
||||||
|
import { updateSource } from "@/video/state/logic/source";
|
||||||
|
import {
|
||||||
|
getStoredVolume,
|
||||||
|
setStoredVolume,
|
||||||
|
} from "@/video/components/hooks/volumeStore";
|
||||||
|
import { resetStateForSource } from "@/video/state/providers/helpers";
|
||||||
|
import { updateInterface } from "@/video/state/logic/interface";
|
||||||
|
import { getPlayerState } from "../cache";
|
||||||
|
import { updateMediaPlaying } from "../logic/mediaplaying";
|
||||||
|
import { VideoPlayerStateProvider } from "./providerTypes";
|
||||||
|
import { updateProgress } from "../logic/progress";
|
||||||
|
|
||||||
|
// TODO startAt when switching state providers
|
||||||
|
// TODO cast -> uncast -> cast will break
|
||||||
|
// TODO chromecast button has incorrect hitbox and badly styled
|
||||||
|
// TODO casting text middle of screen
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
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.title ?? "";
|
||||||
|
|
||||||
|
// TODO contentId?
|
||||||
|
const mediaInfo = new chrome.cast.media.MediaInfo("hello", "video/mp4");
|
||||||
|
(mediaInfo as any).contentUrl = source?.source;
|
||||||
|
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
|
||||||
|
mediaInfo.metadata = movieMeta;
|
||||||
|
|
||||||
|
const request = new chrome.cast.media.LoadRequest(mediaInfo);
|
||||||
|
request.autoplay = true;
|
||||||
|
|
||||||
|
const session = ins?.getCurrentSession();
|
||||||
|
session?.loadMedia(request);
|
||||||
|
|
||||||
|
// update state
|
||||||
|
state.source = {
|
||||||
|
quality: source.quality,
|
||||||
|
type: source.type,
|
||||||
|
url: source.source,
|
||||||
|
caption: null,
|
||||||
|
};
|
||||||
|
resetStateForSource(descriptor, state);
|
||||||
|
updateSource(descriptor, state);
|
||||||
|
},
|
||||||
|
setCaption(id, url) {
|
||||||
|
if (state.source) {
|
||||||
|
state.source.caption = {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
updateSource(descriptor, state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearCaption() {
|
||||||
|
if (state.source) {
|
||||||
|
state.source.caption = null;
|
||||||
|
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":
|
||||||
|
state.progress.duration = e.value.duration;
|
||||||
|
updateProgress(descriptor, state);
|
||||||
|
break;
|
||||||
|
case "playerState":
|
||||||
|
state.mediaPlaying.isLoading = e.value === "BUFFERING";
|
||||||
|
updateMediaPlaying(descriptor, state);
|
||||||
|
break;
|
||||||
|
case "isPaused":
|
||||||
|
state.mediaPlaying.isPaused = e.value;
|
||||||
|
state.mediaPlaying.isPlaying = !e.value;
|
||||||
|
if (!e.value) state.mediaPlaying.hasPlayedOnce = true;
|
||||||
|
updateMediaPlaying(descriptor, state);
|
||||||
|
break;
|
||||||
|
case "isMuted":
|
||||||
|
state.mediaPlaying.volume = e.value ? 1 : 0;
|
||||||
|
// TODO better mute handling
|
||||||
|
updateMediaPlaying(descriptor, state);
|
||||||
|
break;
|
||||||
|
case "displayStatus":
|
||||||
|
case "canSeek":
|
||||||
|
case "title":
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ export type VideoPlayerStateController = {
|
||||||
startAirplay(): void;
|
startAirplay(): void;
|
||||||
setCaption(id: string, url: string): void;
|
setCaption(id: string, url: string): void;
|
||||||
clearCaption(): void;
|
clearCaption(): void;
|
||||||
|
getId(): string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
export type VideoPlayerStateProvider = VideoPlayerStateController & {
|
||||||
|
|
|
@ -9,15 +9,28 @@ export function setProvider(
|
||||||
const state = getPlayerState(descriptor);
|
const state = getPlayerState(descriptor);
|
||||||
state.stateProvider = provider;
|
state.stateProvider = provider;
|
||||||
state.initalized = true;
|
state.initalized = true;
|
||||||
|
state.stateProviderId = provider.getId();
|
||||||
updateMisc(descriptor, state);
|
updateMisc(descriptor, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note: This only sets the state provider to null. it does not destroy the listener
|
* Note: This only sets the state provider to null. it does not destroy the listener
|
||||||
*/
|
*/
|
||||||
export function unsetStateProvider(descriptor: string) {
|
export function unsetStateProvider(
|
||||||
|
descriptor: string,
|
||||||
|
stateProviderId: string
|
||||||
|
) {
|
||||||
const state = getPlayerState(descriptor);
|
const state = getPlayerState(descriptor);
|
||||||
|
// dont do anything if state provider doesnt match the thing to unset
|
||||||
|
if (
|
||||||
|
!state.stateProvider ||
|
||||||
|
state.stateProvider?.getId() !== stateProviderId
|
||||||
|
) {
|
||||||
|
state.stateProviderId = "video"; // go back to video when casting stops
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.stateProvider = null;
|
state.stateProvider = null;
|
||||||
|
state.stateProviderId = "video"; // go back to video when casting stops
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleBuffered(time: number, buffered: TimeRanges): number {
|
export function handleBuffered(time: number, buffered: TimeRanges): number {
|
||||||
|
|
|
@ -60,6 +60,9 @@ export function createVideoStateProvider(
|
||||||
const state = getPlayerState(descriptor);
|
const state = getPlayerState(descriptor);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getId() {
|
||||||
|
return "video";
|
||||||
|
},
|
||||||
play() {
|
play() {
|
||||||
player.play();
|
player.play();
|
||||||
},
|
},
|
||||||
|
@ -130,7 +133,8 @@ export function createVideoStateProvider(
|
||||||
setSource(source) {
|
setSource(source) {
|
||||||
if (!source) {
|
if (!source) {
|
||||||
resetStateForSource(descriptor, state);
|
resetStateForSource(descriptor, state);
|
||||||
player.src = "";
|
player.removeAttribute("src");
|
||||||
|
player.load();
|
||||||
state.source = null;
|
state.source = null;
|
||||||
updateSource(descriptor, state);
|
updateSource(descriptor, state);
|
||||||
return;
|
return;
|
||||||
|
@ -302,6 +306,13 @@ export function createVideoStateProvider(
|
||||||
canAirplay
|
canAirplay
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (state.source)
|
||||||
|
this.setSource({
|
||||||
|
quality: state.source.quality,
|
||||||
|
source: state.source.url,
|
||||||
|
type: state.source.type,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
destroy: () => {
|
destroy: () => {
|
||||||
player.removeEventListener("pause", pause);
|
player.removeEventListener("pause", pause);
|
||||||
|
|
|
@ -64,9 +64,18 @@ export type VideoPlayerState = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// casting state
|
||||||
|
casting: {
|
||||||
|
isCasting: boolean;
|
||||||
|
controller: cast.framework.RemotePlayerController | null;
|
||||||
|
player: cast.framework.RemotePlayer | null;
|
||||||
|
instance: cast.framework.CastContext | null;
|
||||||
|
};
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
canAirplay: boolean;
|
canAirplay: boolean;
|
||||||
initalized: boolean;
|
initalized: boolean;
|
||||||
|
stateProviderId: string;
|
||||||
error: null | {
|
error: null | {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
Loading…
Reference in a new issue