chromecasting connectivity

This commit is contained in:
mrjvs 2023-10-20 22:39:56 +02:00
parent 5b145e1707
commit 43d4869f7e
10 changed files with 175 additions and 6 deletions

View File

@ -0,0 +1,56 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Icons } from "@/components/Icon";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { usePlayerStore } from "@/stores/player/store";
export interface ChromecastProps {
className?: string;
}
export function Chromecast(props: ChromecastProps) {
const [hidden, setHidden] = useState(false);
const isCasting = usePlayerStore((s) => s.interface.isCasting);
const ref = useRef<HTMLButtonElement>(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 (
<VideoPlayerButton
ref={ref}
className={[
props.className ?? "",
"google-cast-button",
isCasting ? "casting" : "",
hidden ? "hidden" : "",
].join(" ")}
icon={Icons.CASTING}
onClick={(el) => {
const castButton = el.querySelector("google-cast-launcher");
if (castButton) (castButton as HTMLDivElement).click();
}}
/>
);
}

View File

@ -13,3 +13,4 @@ export * from "./Episodes";
export * from "./Airplay"; export * from "./Airplay";
export * from "./VolumeChangedPopout"; export * from "./VolumeChangedPopout";
export * from "./NextEpisodeButton"; export * from "./NextEpisodeButton";
export * from "./Chromecast";

View File

@ -1,6 +1,7 @@
import { ReactNode, RefObject, useEffect, useRef } from "react"; import { ReactNode, RefObject, useEffect, useRef } from "react";
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay"; import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
import { CastingInternal } from "@/components/player/internals/CastingInternal";
import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents"; import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents";
import { ProgressSaver } from "@/components/player/internals/ProgressSaver"; import { ProgressSaver } from "@/components/player/internals/ProgressSaver";
@ -81,6 +82,7 @@ export function Container(props: PlayerProps) {
return ( return (
<div className="relative"> <div className="relative">
<BaseContainer> <BaseContainer>
<CastingInternal />
<VideoContainer /> <VideoContainer />
<ProgressSaver /> <ProgressSaver />
<KeyboardEvents /> <KeyboardEvents />

View File

@ -1,19 +1,26 @@
import classNames from "classnames"; import classNames from "classnames";
import { forwardRef } from "react";
import { Icon, Icons } from "@/components/Icon"; import { Icon, Icons } from "@/components/Icon";
export function VideoPlayerButton(props: { export interface VideoPlayerButtonProps {
children?: React.ReactNode; children?: React.ReactNode;
onClick?: () => void; onClick?: (el: HTMLButtonElement) => void;
icon?: Icons; icon?: Icons;
iconSizeClass?: string; iconSizeClass?: string;
className?: string; className?: string;
activeClass?: string; activeClass?: string;
}) { }
export const VideoPlayerButton = forwardRef<
HTMLButtonElement,
VideoPlayerButtonProps
>((props, ref) => {
return ( return (
<button <button
ref={ref}
type="button" type="button"
onClick={props.onClick} onClick={(e) => props.onClick?.(e.currentTarget as HTMLButtonElement)}
className={classNames([ className={classNames([
"p-2 rounded-full hover:bg-video-buttonBackground hover:bg-opacity-50 transition-transform duration-100 flex items-center", "p-2 rounded-full hover:bg-video-buttonBackground hover:bg-opacity-50 transition-transform duration-100 flex items-center",
props.activeClass ?? props.activeClass ??
@ -33,4 +40,4 @@ export function VideoPlayerButton(props: {
{props.children} {props.children}
</button> </button>
); );
} });

View File

@ -0,0 +1,47 @@
import { useEffect } from "react";
import { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
import { usePlayerStore } from "@/stores/player/store";
export function CastingInternal() {
const setInstance = usePlayerStore((s) => s.casting.setInstance);
const setController = usePlayerStore((s) => s.casting.setController);
const setPlayer = usePlayerStore((s) => s.casting.setPlayer);
const setIsCasting = usePlayerStore((s) => s.casting.setIsCasting);
const available = useChromecastAvailable();
useEffect(() => {
if (!available) return;
const ins = cast.framework.CastContext.getInstance();
setInstance(ins);
ins.setOptions({
receiverApplicationId: chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED,
});
const player = new cast.framework.RemotePlayer();
setPlayer(player);
const controller = new cast.framework.RemotePlayerController(player);
setController(controller);
function connectionChanged(e: cast.framework.RemotePlayerChangedEvent) {
if (e.field === "isConnected") {
setIsCasting(e.value);
}
}
controller.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged
);
return () => {
controller.removeEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged
);
};
}, [available, setPlayer, setController, setInstance, setIsCasting]);
return null;
}

View File

@ -3,6 +3,7 @@ import { ReactNode } from "react";
import { BrandPill } from "@/components/layout/BrandPill"; import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player"; import { Player } from "@/components/player";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useChromecastAvailable } from "@/hooks/useChromecastAvailable";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store"; import { usePlayerStore } from "@/stores/player/store";
@ -59,6 +60,7 @@ export function PlayerPart(props: PlayerPartProps) {
</div> </div>
<div className="flex sm:hidden items-center justify-end"> <div className="flex sm:hidden items-center justify-end">
<Player.Airplay /> <Player.Airplay />
<Player.Chromecast />
</div> </div>
</div> </div>
</Player.TopControls> </Player.TopControls>
@ -79,6 +81,7 @@ export function PlayerPart(props: PlayerPartProps) {
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Player.Episodes /> <Player.Episodes />
<Player.Airplay /> <Player.Airplay />
<Player.Chromecast />
<Player.Settings /> <Player.Settings />
<Player.Fullscreen /> <Player.Fullscreen />
</div> </div>

View File

@ -0,0 +1,47 @@
import { MakeSlice } from "@/stores/player/slices/types";
export interface CastingSlice {
casting: {
instance: cast.framework.CastContext | null;
player: cast.framework.RemotePlayer | null;
controller: cast.framework.RemotePlayerController | null;
setInstance(instance: cast.framework.CastContext): void;
setPlayer(player: cast.framework.RemotePlayer): void;
setController(controller: cast.framework.RemotePlayerController): void;
setIsCasting(isCasting: boolean): void;
clear(): void;
};
}
export const createCastingSlice: MakeSlice<CastingSlice> = (set) => ({
casting: {
instance: null,
player: null,
controller: null,
setInstance(instance) {
set((s) => {
s.casting.instance = instance;
});
},
setPlayer(player) {
set((s) => {
s.casting.player = player;
});
},
setController(controller) {
set((s) => {
s.casting.controller = controller;
});
},
setIsCasting(isCasting) {
set((s) => {
s.interface.isCasting = isCasting;
});
},
clear() {
set((s) => {
s.casting.instance = null;
});
},
},
});

View File

@ -20,6 +20,7 @@ export interface InterfaceSlice {
hovering: PlayerHoverState; hovering: PlayerHoverState;
lastHoveringState: PlayerHoverState; lastHoveringState: PlayerHoverState;
canAirplay: boolean; canAirplay: boolean;
isCasting: boolean;
hideNextEpisodeBtn: boolean; hideNextEpisodeBtn: boolean;
volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently? volumeChangedWithKeybind: boolean; // has the volume recently been adjusted with the up/down arrows recently?
@ -39,6 +40,7 @@ export interface InterfaceSlice {
export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({ export const createInterfaceSlice: MakeSlice<InterfaceSlice> = (set, get) => ({
interface: { interface: {
isCasting: false,
hasOpenOverlay: false, hasOpenOverlay: false,
isFullscreen: false, isFullscreen: false,
isSeeking: false, isSeeking: false,

View File

@ -1,5 +1,6 @@
import { StateCreator } from "zustand"; import { StateCreator } from "zustand";
import { CastingSlice } from "@/stores/player/slices/casting";
import { DisplaySlice } from "@/stores/player/slices/display"; import { DisplaySlice } from "@/stores/player/slices/display";
import { InterfaceSlice } from "@/stores/player/slices/interface"; import { InterfaceSlice } from "@/stores/player/slices/interface";
import { PlayingSlice } from "@/stores/player/slices/playing"; import { PlayingSlice } from "@/stores/player/slices/playing";
@ -10,7 +11,8 @@ export type AllSlices = InterfaceSlice &
PlayingSlice & PlayingSlice &
ProgressSlice & ProgressSlice &
SourceSlice & SourceSlice &
DisplaySlice; DisplaySlice &
CastingSlice;
export type MakeSlice<Slice> = StateCreator< export type MakeSlice<Slice> = StateCreator<
AllSlices, AllSlices,
[["zustand/immer", never]], [["zustand/immer", never]],

View File

@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { immer } from "zustand/middleware/immer"; import { immer } from "zustand/middleware/immer";
import { createCastingSlice } from "@/stores/player/slices/casting";
import { createDisplaySlice } from "@/stores/player/slices/display"; import { createDisplaySlice } from "@/stores/player/slices/display";
import { createInterfaceSlice } from "@/stores/player/slices/interface"; import { createInterfaceSlice } from "@/stores/player/slices/interface";
import { createPlayingSlice } from "@/stores/player/slices/playing"; import { createPlayingSlice } from "@/stores/player/slices/playing";
@ -15,5 +16,6 @@ export const usePlayerStore = create(
...createPlayingSlice(...a), ...createPlayingSlice(...a),
...createSourceSlice(...a), ...createSourceSlice(...a),
...createDisplaySlice(...a), ...createDisplaySlice(...a),
...createCastingSlice(...a),
})) }))
); );