Fix casting (#49)

* Tbh i tried adding it and it works but i cant fix the dual casting

* clean up chromecasting

* Apply Chromecast fixes

* Update Icon.tsx

---------

Co-authored-by: Pas <74743263+Pasithea0@users.noreply.github.com>
This commit is contained in:
Chris 2025-10-12 20:16:16 +03:00 committed by GitHub
parent c45004dc11
commit 47653c2906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 105 additions and 135 deletions

View file

@ -594,6 +594,8 @@
"short": "Back"
},
"casting": {
"to": "Casting to {{device}} 📺",
"device": "device",
"enabled": "Casting to device 🎬"
},
"menus": {

View file

@ -1,5 +1,5 @@
import classNames from "classnames";
import { memo, useEffect, useRef } from "react";
import { memo } from "react";
export enum Icons {
SEARCH = "search",
@ -128,7 +128,7 @@ const iconList: Record<Icons, string> = {
captions: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 25 20"><path transform="translate(-3 -6)" d="M25.5,6H5.5A2.507,2.507,0,0,0,3,8.5v15A2.507,2.507,0,0,0,5.5,26h20A2.507,2.507,0,0,0,28,23.5V8.5A2.507,2.507,0,0,0,25.5,6ZM5.5,16h5v2.5h-5ZM18,23.5H5.5V21H18Zm7.5,0h-5V21h5Zm0-5H13V16H25.5Z" fill="currentColor"/></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>`,
circle_exclamation: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24V264c0 13.3-10.7 24-24 24s-24-10.7-24-24V152c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
casting: "",
casting: "", // leave blank because Chrome imports it's own icon
download: `<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-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
gear: `<svg fill="currentColor" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1em" height="1em" viewBox="0 0 48.4 48.4" xml:space="preserve"><g><path d="M48.4,24.2c0-1.8-1.297-3.719-2.896-4.285s-3.149-1.952-3.6-3.045c-0.451-1.093-0.334-3.173,0.396-4.705 c0.729-1.532,0.287-3.807-0.986-5.08c-1.272-1.273-3.547-1.714-5.08-0.985c-1.532,0.729-3.609,0.848-4.699,0.397 s-2.477-2.003-3.045-3.602C27.921,1.296,26,0,24.2,0c-1.8,0-3.721,1.296-4.29,2.895c-0.569,1.599-1.955,3.151-3.045,3.602 c-1.09,0.451-3.168,0.332-4.7-0.397c-1.532-0.729-3.807-0.288-5.08,0.985c-1.273,1.273-1.714,3.547-0.985,5.08 c0.729,1.533,0.845,3.611,0.392,4.703c-0.453,1.092-1.998,2.481-3.597,3.047S0,22.4,0,24.2s1.296,3.721,2.895,4.29 c1.599,0.568,3.146,1.957,3.599,3.047c0.453,1.089,0.335,3.166-0.394,4.698s-0.288,3.807,0.985,5.08 c1.273,1.272,3.547,1.714,5.08,0.985c1.533-0.729,3.61-0.847,4.7-0.395c1.091,0.452,2.476,2.008,3.045,3.604 c0.569,1.596,2.49,2.891,4.29,2.891c1.8,0,3.721-1.295,4.29-2.891c0.568-1.596,1.953-3.15,3.043-3.604 c1.09-0.453,3.17-0.334,4.701,0.396c1.533,0.729,3.808,0.287,5.08-0.985c1.273-1.273,1.715-3.548,0.986-5.08 c-0.729-1.533-0.849-3.61-0.398-4.7c0.451-1.09,2.004-2.477,3.603-3.045C47.104,27.921,48.4,26,48.4,24.2z M24.2,33.08 c-4.91,0-8.88-3.97-8.88-8.87c0-4.91,3.97-8.88,8.88-8.88c4.899,0,8.87,3.97,8.87,8.88C33.07,29.11,29.1,33.08,24.2,33.08z"/></g></svg>`,
watch_party: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.3.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M319.4 372c48.5-31.3 80.6-85.9 80.6-148c0-97.2-78.8-176-176-176S48 126.8 48 224c0 62.1 32.1 116.6 80.6 148c1.2 17.3 4 38 7.2 57.1l.2 1C56 395.8 0 316.5 0 224C0 100.3 100.3 0 224 0S448 100.3 448 224c0 92.5-56 171.9-136 206.1l.2-1.1c3.1-19.2 6-39.8 7.2-57zm-2.3-38.1c-1.6-5.7-3.9-11.1-7-16.2c-5.8-9.7-13.5-17-21.9-22.4c19.5-17.6 31.8-43 31.8-71.3c0-53-43-96-96-96s-96 43-96 96c0 28.3 12.3 53.8 31.8 71.3c-8.4 5.4-16.1 12.7-21.9 22.4c-3.1 5.1-5.4 10.5-7 16.2C99.8 307.5 80 268 80 224c0-79.5 64.5-144 144-144s144 64.5 144 144c0 44-19.8 83.5-50.9 109.9zM224 312c32.9 0 64 8.6 64 43.8c0 33-12.9 104.1-20.6 132.9c-5.1 19-24.5 23.4-43.4 23.4s-38.2-4.4-43.4-23.4c-7.8-28.5-20.6-99.7-20.6-132.8c0-35.1 31.1-43.8 64-43.8zm0-144a56 56 0 1 1 0 112 56 56 0 1 1 0-112z"/></svg>`,
@ -183,23 +183,7 @@ const iconList: Record<Icons, string> = {
repeat: `<svg viewBox="0 0 24 24" width="1em" height="1em" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><polyline points="17 1 21 5 17 9"></polyline><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><polyline points="7 23 3 19 7 15"></polyline><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg>`,
};
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) => {
if (props.icon === Icons.CASTING) {
return <ChromeCastButton />;
}
const flipClass =
props.icon === Icons.ARROW_LEFT ||
props.icon === Icons.ARROW_RIGHT ||

View file

@ -11,3 +11,4 @@ export * from "./base/SubtitleView";
export * from "./internals/BookmarkButton";
export * from "./internals/InfoButton";
export * from "./internals/SkipEpisodeButton";
export * from "./atoms/Chromecast";

View file

@ -8,15 +8,23 @@ export function CastingNotification() {
const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading);
const display = usePlayerStore((s) => s.display);
const isCasting = display?.getType() === "casting";
const remotePlayer = usePlayerStore((s) => s.casting.player);
if (isLoading || !isCasting) return null;
let deviceName = remotePlayer?.displayName || t("player.casting.device");
if (deviceName === "Default Media Receiver") {
deviceName = t("player.casting.device"); // e.g., "your TV"
}
return (
<div className="flex flex-col items-center justify-center gap-4">
<div className="rounded-full bg-opacity-10 bg-video-buttonBackground p-3 brightness-100 grayscale">
<Icon icon={Icons.CASTING} />
</div>
<p className="text-center">{t("player.casting.enabled")}</p>
<p className="text-center">
{t("player.casting.to", { device: deviceName })}
</p>
</div>
);
}

View file

@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react";
/// <reference types="chromecast-caf-sender" />
import { useEffect, useRef, useState } from "react";
import { Icons } from "@/components/Icon";
import { VideoPlayerButton } from "@/components/player/internals/Button";
import { usePlayerStore } from "@/stores/player/store";
// Allow the custom element in TSX without adding a global d.ts file
// Allow the custom element in TSX
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace JSX {
@ -19,89 +20,55 @@ export interface ChromecastProps {
className?: string;
}
export function Chromecast(props: ChromecastProps) {
const [hidden, setHidden] = useState(false);
export function Chromecast({ className }: ChromecastProps) {
const [castHidden, setCastHidden] = 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],
);
const launcherRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const tag = ref.current?.querySelector<HTMLElement>("google-cast-launcher");
if (!tag) return;
const w = window as unknown as { cast?: typeof cast };
const castFramework = w.cast?.framework;
if (!castFramework) return;
const observer = new MutationObserver(() => {
setButtonVisibility(tag);
});
observer.observe(tag, { attributes: true, attributeFilter: ["style"] });
setButtonVisibility(tag);
return () => {
observer.disconnect();
};
}, [setButtonVisibility]);
// Hide the button when there are no cast devices available according to CAF
useEffect(() => {
const w = window as any;
const cast = w?.cast;
if (!cast?.framework) return;
const context = cast.framework.CastContext.getInstance();
const update = () => {
const context = castFramework.CastContext.getInstance();
const updateVisibility = () => {
const state = context.getCastState();
setCastHidden(state === cast.framework.CastState.NO_DEVICES_AVAILABLE);
setCastHidden(state === castFramework.CastState.NO_DEVICES_AVAILABLE);
};
const handler = () => update();
const handler = () => updateVisibility();
context.addEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
castFramework.CastContextEventType.CAST_STATE_CHANGED,
handler,
);
update();
updateVisibility();
return () => {
context.removeEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
castFramework.CastContextEventType.CAST_STATE_CHANGED,
handler,
);
};
}, []);
useEffect(() => {
if (!launcherRef.current || launcherRef.current.children.length > 0) return;
const launcher = document.createElement("google-cast-launcher");
launcherRef.current.appendChild(launcher);
}, []);
return (
<VideoPlayerButton
ref={ref}
className={[
props.className ?? "",
className ?? "",
"google-cast-button",
"cast-button-container",
isCasting ? "casting" : "",
hidden || castHidden ? "hidden" : "",
castHidden ? "hidden" : "",
].join(" ")}
icon={Icons.CASTING}
onClick={(el) => {
const castButton = el.querySelector("google-cast-launcher");
if (castButton) (castButton as HTMLDivElement).click();
}}
>
{/* Render a hidden launcher so programmatic click always works */}
<google-cast-launcher
style={{
width: 0,
height: 0,
opacity: 0,
position: "absolute",
pointerEvents: "none",
}}
aria-hidden="true"
/>
<div ref={launcherRef} />
</VideoPlayerButton>
);
}

View file

@ -90,16 +90,18 @@ export function CastingInternal() {
);
useEffect(() => {
if (!available || !window.cast || !window.chrome || !window.chrome.cast)
return;
if (!chrome.cast || !chrome.cast.media) {
console.error(
"Chrome Cast API not fully initialized: chrome.cast.media is undefined",
);
if (
!available ||
!window.cast?.framework ||
!window.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID ||
!window.chrome.cast.AutoJoinPolicy
) {
return;
}
let newPlayer: cast.framework.RemotePlayer | null = null;
let newController: cast.framework.RemotePlayerController | null = null;
try {
const ins = cast.framework.CastContext.getInstance();
setInstance(ins);
@ -110,37 +112,33 @@ export function CastingInternal() {
ins.setOptions({
receiverApplicationId: receiverAppId,
autoJoinPolicy,
androidReceiverCompatible: false,
resumeSavedSession: false,
});
const newPlayer = new cast.framework.RemotePlayer();
newPlayer = new cast.framework.RemotePlayer();
newController = new cast.framework.RemotePlayerController(newPlayer);
setPlayer(newPlayer);
const newControlller = new cast.framework.RemotePlayerController(
newPlayer,
);
setController(newControlller);
setController(newController);
newControlller.addEventListener(
newController.addEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged,
);
} catch (error) {
console.error("Error initializing Chromecast:", error);
return;
}
return () => {
newControlller.removeEventListener(
return () => {
if (newController) {
newController.removeEventListener(
cast.framework.RemotePlayerEventType.IS_CONNECTED_CHANGED,
connectionChanged,
);
};
} catch (error) {
console.error("Error initializing Chromecast:", error);
}
}, [
available,
setPlayer,
setController,
setInstance,
setIsCasting,
connectionChanged,
]);
}
};
}, [available, setPlayer, setController, setInstance, connectionChanged]);
return null;
}

View file

@ -1,4 +1,4 @@
/// <reference types="chromecast-caf-sender"/>
/// <reference types="chromecast-caf-sender" />
import { useEffect, useState } from "react";
@ -8,7 +8,17 @@ export function useChromecastAvailable() {
const [available, setAvailable] = useState<boolean | null>(null);
useEffect(() => {
isChromecastAvailable((bool) => setAvailable(bool));
let isMounted = true;
isChromecastAvailable((bool) => {
if (isMounted) {
setAvailable(bool);
}
});
return () => {
isMounted = false;
};
}, []);
return available;

View file

@ -85,15 +85,11 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.SubtitleView controlsShown={showTargets} />
{status === playerStatus.PLAYING ? (
<>
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
</Player.CenterControls>
<Player.CenterControls>
<Player.CastingNotification />
</Player.CenterControls>
</>
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
<Player.CastingNotification />
</Player.CenterControls>
) : null}
<Player.CenterMobileControls

View file

@ -1,3 +1,5 @@
/// <reference types="chromecast-caf-sender" />
const CHROMECAST_SENDER_SDK =
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
@ -8,12 +10,14 @@ let _initialized = false;
function init(available: boolean) {
_available = available;
callbacks.forEach((cb) => cb(available));
// Clear callbacks after first resolution to avoid leaks/repeated calls
callbacks.length = 0;
}
export function isChromecastAvailable(cb: (available: boolean) => void) {
if (_available !== null) return cb(_available);
if (_available !== null) {
setTimeout(() => cb(_available!), 0);
return;
}
callbacks.push(cb);
}
@ -21,33 +25,33 @@ export function initializeChromecast() {
if (_initialized) return;
_initialized = true;
const w = window as any;
// Only set the global callback if not already present
if (!w.__onGCastApiAvailable) {
w.__onGCastApiAvailable = (isAvailable: boolean) => {
if (!(window as any).__onGCastApiAvailable) {
(window as any).__onGCastApiAvailable = (isAvailable: boolean) => {
try {
if (isAvailable && w.cast?.framework) {
const context = w.cast.framework.CastContext.getInstance();
if (isAvailable && (window as any).cast?.framework) {
const context = (
window as any
).cast.framework.CastContext.getInstance();
context.setOptions({
receiverApplicationId:
w.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: w.cast.framework.AutoJoinPolicy.ORIGIN_SCOPED,
receiverApplicationId: (window as any).chrome?.cast?.media
?.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: (window as any).cast.framework.AutoJoinPolicy
.ORIGIN_SCOPED,
});
}
} catch {
// Swallow errors; availability will still be reported below
} catch (e) {
console.warn("Chromecast initialization error:", e);
} finally {
init(!!isAvailable);
}
};
}
// add script if doesnt exist yet
const exists = !!document.getElementById("chromecast-script");
if (!exists) {
if (!document.getElementById("chromecast-script")) {
const script = document.createElement("script");
script.setAttribute("src", CHROMECAST_SENDER_SDK);
script.setAttribute("id", "chromecast-script");
script.src = CHROMECAST_SENDER_SDK;
script.id = "chromecast-script";
script.onerror = () => console.warn("Failed to load Chromecast SDK");
document.body.appendChild(script);
}
}