From 4a0392d1f004295ca5cfbfc1929e08120d61f9e4 Mon Sep 17 00:00:00 2001 From: Jelle van Snik Date: Sun, 12 Feb 2023 15:58:11 +0100 Subject: [PATCH] chromecasting humble beginnings Co-authored-by: James Hawkins --- .eslintrc.js | 1 + src/components/Icon.tsx | 20 +- src/components/layout/Spinner.tsx | 4 +- src/components/media/MediaCard.tsx | 2 +- src/video/components/VideoPlayer.tsx | 6 +- src/video/components/VideoPlayerBase.tsx | 2 + .../components/actions/ChromecastAction.tsx | 12 + .../components/actions/LoadingAction.tsx | 5 +- .../components/internal/CastingInternal.tsx | 65 +++++ .../internal/VideoElementInternal.tsx | 16 +- .../components/parts/VideoPlayerHeader.tsx | 7 +- src/video/state/init.ts | 8 + src/video/state/logic/controls.ts | 3 + src/video/state/logic/misc.ts | 4 + .../state/providers/castingStateProvider.ts | 236 ++++++++++++++++++ src/video/state/providers/providerTypes.ts | 1 + src/video/state/providers/utils.ts | 15 +- .../state/providers/videoStateProvider.ts | 13 +- src/video/state/types.ts | 9 + 19 files changed, 414 insertions(+), 15 deletions(-) create mode 100644 src/video/components/actions/ChromecastAction.tsx create mode 100644 src/video/components/internal/CastingInternal.tsx create mode 100644 src/video/state/providers/castingStateProvider.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5feb5ad4..1556850a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -49,6 +49,7 @@ module.exports = { "no-eval": "off", "no-await-in-loop": "off", "no-nested-ternary": "off", + "prefer-destructuring": "off", "react/jsx-filename-extension": [ "error", { extensions: [".js", ".tsx", ".jsx"] } diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 9b88a83a..bf0a0ae2 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,4 +1,4 @@ -import { memo } from "react"; +import { memo, useEffect, useRef } from "react"; export enum Icons { SEARCH = "search", @@ -33,6 +33,7 @@ export enum Icons { FILE = "file", CAPTIONS = "captions", LINK = "link", + CASTING = "casting", } export interface IconProps { @@ -73,9 +74,26 @@ const iconList: Record = { file: ``, captions: ``, link: ``, + casting: "", }; +function ChromeCastButton() { + const ref = useRef(null); + + useEffect(() => { + const tag = document.createElement("google-cast-launcher"); + tag.setAttribute("id", "castbutton"); + ref.current?.appendChild(tag); + }, []); + + return
; +} + export const Icon = memo((props: IconProps) => { + if (props.icon === Icons.CASTING) { + return ; + } + return ( ; + return
; } diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 6a19d88b..42d76eb9 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -43,7 +43,7 @@ function MediaCardContent({ }`} >
- {/* */} +
@@ -149,9 +150,8 @@ export function VideoPlayer(props: Props) { - {/* */}
- {/* */} + diff --git a/src/video/components/VideoPlayerBase.tsx b/src/video/components/VideoPlayerBase.tsx index d0656e40..1f1fbba7 100644 --- a/src/video/components/VideoPlayerBase.tsx +++ b/src/video/components/VideoPlayerBase.tsx @@ -1,3 +1,4 @@ +import { CastingInternal } from "@/video/components/internal/CastingInternal"; import { WrapperRegisterInternal } from "@/video/components/internal/WrapperRegisterInternal"; import { VideoErrorBoundary } from "@/video/components/parts/VideoErrorBoundary"; import { useInterface } from "@/video/state/logic/interface"; @@ -42,6 +43,7 @@ function VideoPlayerBaseWithState(props: VideoPlayerBaseProps) { ].join(" ")} > +
{children}
diff --git a/src/video/components/actions/ChromecastAction.tsx b/src/video/components/actions/ChromecastAction.tsx new file mode 100644 index 00000000..123a6130 --- /dev/null +++ b/src/video/components/actions/ChromecastAction.tsx @@ -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 ( + + ); +} diff --git a/src/video/components/actions/LoadingAction.tsx b/src/video/components/actions/LoadingAction.tsx index 0bbe6072..ce46356d 100644 --- a/src/video/components/actions/LoadingAction.tsx +++ b/src/video/components/actions/LoadingAction.tsx @@ -1,14 +1,17 @@ import { Spinner } from "@/components/layout/Spinner"; import { useVideoPlayerDescriptor } from "@/video/state/hooks"; import { useMediaPlaying } from "@/video/state/logic/mediaplaying"; +import { useMisc } from "@/video/state/logic/misc"; 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) return null; + if (!isLoading || !shouldShow) return null; return ; } diff --git a/src/video/components/internal/CastingInternal.tsx b/src/video/components/internal/CastingInternal.tsx new file mode 100644 index 00000000..72979083 --- /dev/null +++ b/src/video/components/internal/CastingInternal.tsx @@ -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(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; +} diff --git a/src/video/components/internal/VideoElementInternal.tsx b/src/video/components/internal/VideoElementInternal.tsx index 2236db04..f819bf8f 100644 --- a/src/video/components/internal/VideoElementInternal.tsx +++ b/src/video/components/internal/VideoElementInternal.tsx @@ -10,7 +10,7 @@ interface Props { autoPlay?: boolean; } -export function VideoElementInternal(props: Props) { +function VideoElement(props: Props) { const descriptor = useVideoPlayerDescriptor(); const mediaPlaying = useMediaPlaying(descriptor); const source = useSource(descriptor); @@ -18,6 +18,7 @@ export function VideoElementInternal(props: Props) { const ref = useRef(null); const initalized = useMemo(() => !!misc.wrapperInitialized, [misc]); + const stateProviderId = useMemo(() => misc.stateProviderId, [misc]); useEffect(() => { if (!initalized) return; @@ -26,10 +27,10 @@ export function VideoElementInternal(props: Props) { setProvider(descriptor, provider); const { destroy } = provider.providerStart(); return () => { - unsetStateProvider(descriptor); + unsetStateProvider(descriptor, provider.getId()); destroy(); }; - }, [descriptor, initalized]); + }, [descriptor, initalized, stateProviderId]); // this element is remotely controlled by a state provider return ( @@ -46,3 +47,12 @@ export function VideoElementInternal(props: Props) { ); } + +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 ; +} diff --git a/src/video/components/parts/VideoPlayerHeader.tsx b/src/video/components/parts/VideoPlayerHeader.tsx index 3eece234..771be45a 100644 --- a/src/video/components/parts/VideoPlayerHeader.tsx +++ b/src/video/components/parts/VideoPlayerHeader.tsx @@ -7,6 +7,7 @@ import { useBookmarkContext, } from "@/state/bookmark"; import { AirplayAction } from "@/video/components/actions/AirplayAction"; +import { ChromecastAction } from "@/video/components/actions/ChromecastAction"; interface VideoPlayerHeaderProps { media?: MWMediaMeta; @@ -55,9 +56,11 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { )}
{props.showControls ? ( - + <> + + + ) : ( - // chromecontrol )} diff --git a/src/video/state/init.ts b/src/video/state/init.ts index c13021b7..13118a1d 100644 --- a/src/video/state/init.ts +++ b/src/video/state/init.ts @@ -51,12 +51,20 @@ function initPlayer(): VideoPlayerState { 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, diff --git a/src/video/state/logic/controls.ts b/src/video/state/logic/controls.ts index b4367526..56c89a10 100644 --- a/src/video/state/logic/controls.ts +++ b/src/video/state/logic/controls.ts @@ -22,6 +22,9 @@ export function useControls( return { // state provider controls + getId() { + return state.stateProvider?.getId() ?? ""; + }, pause() { state.stateProvider?.pause(); }, diff --git a/src/video/state/logic/misc.ts b/src/video/state/logic/misc.ts index 97c26855..a30a146a 100644 --- a/src/video/state/logic/misc.ts +++ b/src/video/state/logic/misc.ts @@ -7,6 +7,8 @@ export type VideoMiscError = { canAirplay: boolean; wrapperInitialized: boolean; initalized: boolean; + isCasting: boolean; + stateProviderId: string; }; function getMiscFromState(state: VideoPlayerState): VideoMiscError { @@ -14,6 +16,8 @@ function getMiscFromState(state: VideoPlayerState): VideoMiscError { canAirplay: state.canAirplay, wrapperInitialized: !!state.wrapperElement, initalized: state.initalized, + isCasting: state.casting.isCasting, + stateProviderId: state.stateProviderId, }; } diff --git a/src/video/state/providers/castingStateProvider.ts b/src/video/state/providers/castingStateProvider.ts new file mode 100644 index 00000000..69806a39 --- /dev/null +++ b/src/video/state/providers/castingStateProvider.ts @@ -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); + }, + }; + }, + }; +} diff --git a/src/video/state/providers/providerTypes.ts b/src/video/state/providers/providerTypes.ts index c3024e7c..e34b950d 100644 --- a/src/video/state/providers/providerTypes.ts +++ b/src/video/state/providers/providerTypes.ts @@ -18,6 +18,7 @@ export type VideoPlayerStateController = { startAirplay(): void; setCaption(id: string, url: string): void; clearCaption(): void; + getId(): string; }; export type VideoPlayerStateProvider = VideoPlayerStateController & { diff --git a/src/video/state/providers/utils.ts b/src/video/state/providers/utils.ts index 51c41a65..273d9fff 100644 --- a/src/video/state/providers/utils.ts +++ b/src/video/state/providers/utils.ts @@ -9,15 +9,28 @@ export function setProvider( 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) { +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 + ) { + state.stateProviderId = "video"; // go back to video when casting stops + return; + } state.stateProvider = null; + state.stateProviderId = "video"; // go back to video when casting stops } export function handleBuffered(time: number, buffered: TimeRanges): number { diff --git a/src/video/state/providers/videoStateProvider.ts b/src/video/state/providers/videoStateProvider.ts index 9791f53f..9788bda7 100644 --- a/src/video/state/providers/videoStateProvider.ts +++ b/src/video/state/providers/videoStateProvider.ts @@ -60,6 +60,9 @@ export function createVideoStateProvider( const state = getPlayerState(descriptor); return { + getId() { + return "video"; + }, play() { player.play(); }, @@ -130,7 +133,8 @@ export function createVideoStateProvider( setSource(source) { if (!source) { resetStateForSource(descriptor, state); - player.src = ""; + player.removeAttribute("src"); + player.load(); state.source = null; updateSource(descriptor, state); return; @@ -302,6 +306,13 @@ export function createVideoStateProvider( canAirplay ); + if (state.source) + this.setSource({ + quality: state.source.quality, + source: state.source.url, + type: state.source.type, + }); + return { destroy: () => { player.removeEventListener("pause", pause); diff --git a/src/video/state/types.ts b/src/video/state/types.ts index 3ca48aa9..ff378c52 100644 --- a/src/video/state/types.ts +++ b/src/video/state/types.ts @@ -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 canAirplay: boolean; initalized: boolean; + stateProviderId: string; error: null | { name: string; description: string;