rework chromecast implementation

This commit is contained in:
Vasilis Manetas 2025-08-25 15:15:45 +02:00
parent 4f52cd2ba2
commit d8368e6d40
4 changed files with 152 additions and 70 deletions

View file

@ -4,12 +4,24 @@ 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
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace JSX {
interface IntrinsicElements {
"google-cast-launcher": any;
}
}
}
/* eslint-enable @typescript-eslint/no-namespace */
export interface ChromecastProps {
className?: string;
}
export function Chromecast(props: ChromecastProps) {
const [hidden, setHidden] = useState(false);
const [castHidden, setCastHidden] = useState(false);
const isCasting = usePlayerStore((s) => s.interface.isCasting);
const ref = useRef<HTMLButtonElement>(null);
@ -37,6 +49,33 @@ export function Chromecast(props: ChromecastProps) {
};
}, [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 state = context.getCastState();
setCastHidden(state === cast.framework.CastState.NO_DEVICES_AVAILABLE);
};
const handler = () => update();
context.addEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
handler,
);
update();
return () => {
context.removeEventListener(
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
handler,
);
};
}, []);
return (
<VideoPlayerButton
ref={ref}
@ -44,13 +83,25 @@ export function Chromecast(props: ChromecastProps) {
props.className ?? "",
"google-cast-button",
isCasting ? "casting" : "",
hidden ? "hidden" : "",
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"
/>
</VideoPlayerButton>
);
}

View file

@ -1,3 +1,5 @@
/// <reference types="chromecast-caf-sender"/>
import fscreen from "fscreen";
import { MWMediaType } from "@/backend/metadata/types/mw";
@ -9,11 +11,7 @@ import {
} from "@/components/player/display/displayInterface";
import { LoadableSource } from "@/stores/player/utils/qualities";
import { processCdnLink } from "@/utils/cdn";
import {
canChangeVolume,
canFullscreen,
canFullscreenAnyElement,
} from "@/utils/detectFeatures";
import { canFullscreen, canFullscreenAnyElement } from "@/utils/detectFeatures";
import { makeEmitter } from "@/utils/events";
export interface ChromeCastDisplayInterfaceOptions {
@ -49,10 +47,10 @@ export function makeChromecastDisplayInterface(
let caption: DisplayCaption | null = null;
function listenForEvents() {
const listen = async (e: cast.framework.RemotePlayerChangedEvent) => {
const listen = (e: cast.framework.RemotePlayerChangedEvent) => {
switch (e.field) {
case "volumeLevel":
if (await canChangeVolume()) emit("volumechange", e.value);
emit("volumechange", e.value);
break;
case "currentTime":
emit("time", e.value);
@ -70,7 +68,7 @@ export function makeChromecastDisplayInterface(
isPaused = e.value === "PAUSED";
break;
case "isMuted":
emit("volumechange", e.value ? 1 : 0);
emit("volumechange", e.value ? 0 : ops.player.volumeLevel);
break;
case "displayStatus":
case "canSeek":
@ -112,31 +110,62 @@ export function makeChromecastDisplayInterface(
const metaData = new chrome.cast.media.GenericMediaMetadata();
metaData.title = meta.title;
const mediaInfo = new chrome.cast.media.MediaInfo("video", type);
(mediaInfo as any).contentUrl = processCdnLink(source.url);
const contentUrl = processCdnLink(source.url);
const mediaInfo = new chrome.cast.media.MediaInfo(contentUrl, type);
mediaInfo.streamType = chrome.cast.media.StreamType.BUFFERED;
mediaInfo.metadata = metaData;
mediaInfo.customData = {
playbackRate,
};
// Add basic VTT captions support if a caption URL is provided
if (caption?.url) {
try {
const textTrack = new chrome.cast.media.Track(
1,
chrome.cast.media.TrackType.TEXT,
);
textTrack.trackContentType = "text/vtt";
textTrack.trackContentId = caption.url;
textTrack.language = caption.language;
textTrack.name = caption.language || "Subtitles";
textTrack.subtype = chrome.cast.media.TextTrackType.SUBTITLES;
mediaInfo.tracks = [textTrack];
} catch {
// ignore track creation errors
}
}
const request = new chrome.cast.media.LoadRequest(mediaInfo);
request.autoplay = true;
request.currentTime = startAt;
if (caption?.url) request.activeTrackIds = [1];
if (source.type === "hls") {
const staticMedia = chrome.cast.media as any;
const media = request.media as any;
media.hlsSegmentFormat = staticMedia.HlsSegmentFormat.FMP4;
media.hlsVideoSegmentFormat = staticMedia.HlsVideoSegmentFormat.FMP4;
(mediaInfo as any).hlsSegmentFormat = staticMedia.HlsSegmentFormat.FMP4;
(mediaInfo as any).hlsVideoSegmentFormat =
staticMedia.HlsVideoSegmentFormat.FMP4;
}
const session = ops.instance.getCurrentSession();
session?.loadMedia(request);
session
?.loadMedia(request)
.then(() => {
emit("loading", false);
})
.catch((err: unknown) => {
emit("loading", false);
emit("error", {
type: "global",
errorName: "chromecast_load_failure",
message: (err as any)?.message ?? String(err),
});
});
}
function setSource() {
if (!videoElement || !source) return;
if (!source) return;
setupSource();
}
@ -178,6 +207,19 @@ export function makeChromecastDisplayInterface(
},
setCaption(newCaption) {
caption = newCaption;
// If a session and media exist, toggle active track IDs without reloading
const session = ops.instance.getCurrentSession();
const media = session?.getMediaSession();
try {
if (media) {
const ids = newCaption?.url ? [1] : [];
const req = new chrome.cast.media.EditTracksInfoRequest(ids);
(media as any).editTracksInfo(req);
return;
}
} catch {
// Fallback to reload if needed
}
setSource();
},
@ -195,15 +237,13 @@ export function makeChromecastDisplayInterface(
},
pause() {
if (!isPaused) {
if (!ops.player.isPaused) {
ops.controller.playOrPause();
isPaused = true;
}
},
play() {
if (isPaused) {
if (ops.player.isPaused) {
ops.controller.playOrPause();
isPaused = false;
}
},
setSeeking(active) {
@ -220,10 +260,12 @@ export function makeChromecastDisplayInterface(
this.pause();
},
setTime(t) {
if (!videoElement) return;
// clamp time between 0 and max duration
let time = Math.min(t, ops.player.duration);
time = Math.max(0, time);
// clamp time between 0 and max duration if duration is known
let time = t;
if (!Number.isNaN(ops.player.duration)) {
time = Math.min(t, ops.player.duration);
time = Math.max(0, time);
}
if (Number.isNaN(time)) return;
emit("time", time);
@ -231,20 +273,14 @@ export function makeChromecastDisplayInterface(
ops.controller.seek();
},
async setVolume(v) {
// clamp time between 0 and 1
// clamp volume between 0 and 1
let volume = Math.min(v, 1);
volume = Math.max(0, volume);
// update state
const isChangeable = await canChangeVolume();
if (isChangeable) {
ops.player.volumeLevel = volume;
ops.controller.setVolumeLevel();
emit("volumechange", volume);
} else {
// For browsers where it can't be changed
emit("volumechange", volume === 0 ? 0 : 1);
}
// Always control remote cast volume regardless of local platform restrictions
ops.player.volumeLevel = volume;
ops.controller.setVolumeLevel();
emit("volumechange", volume);
},
toggleFullscreen() {
if (isFullscreen) {
@ -271,8 +307,10 @@ export function makeChromecastDisplayInterface(
// cant airplay while chromecasting
},
setPlaybackRate(rate) {
// Default Media Receiver does not support changing playback rate dynamically.
// Store locally and notify UI without reloading media.
playbackRate = rate;
setSource();
emit("playbackrate", rate);
},
getCaptionList() {
return [];

View file

@ -3,10 +3,13 @@ const CHROMECAST_SENDER_SDK =
const callbacks: ((available: boolean) => void)[] = [];
let _available: boolean | null = null;
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) {
@ -15,9 +18,29 @@ export function isChromecastAvailable(cb: (available: boolean) => void) {
}
export function initializeChromecast() {
window.__onGCastApiAvailable = (isAvailable) => {
init(isAvailable);
};
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) => {
try {
if (isAvailable && w.cast?.framework) {
const context = w.cast.framework.CastContext.getInstance();
context.setOptions({
receiverApplicationId:
w.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID,
autoJoinPolicy: w.cast.framework.AutoJoinPolicy.ORIGIN_SCOPED,
});
}
} catch {
// Swallow errors; availability will still be reported below
} finally {
init(!!isAvailable);
}
};
}
// add script if doesnt exist yet
const exists = !!document.getElementById("chromecast-script");

View file

@ -1,30 +0,0 @@
const CHROMECAST_SENDER_SDK =
"https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1";
const callbacks: ((available: boolean) => void)[] = [];
let _available: boolean | null = null;
function init(available: boolean) {
_available = available;
callbacks.forEach((cb) => cb(available));
}
export function isChromecastAvailable(cb: (available: boolean) => void) {
if (_available !== null) return cb(_available);
callbacks.push(cb);
}
export function initializeChromecast() {
window.__onGCastApiAvailable = (isAvailable) => {
init(isAvailable);
};
// add script if doesnt exist yet
const exists = !!document.getElementById("chromecast-script");
if (!exists) {
const script = document.createElement("script");
script.setAttribute("src", CHROMECAST_SENDER_SDK);
script.setAttribute("id", "chromecast-script");
document.body.appendChild(script);
}
}