From 15f6e434c81b2f2130e8533ec34bc48955372f18 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Thu, 9 Apr 2026 22:41:15 +0800 Subject: [PATCH] fix(player): guard cast SDK in Android WebView Fixes #147 --- .../player/hooks/desktop/useCastControls.ts | 173 ++++++++++++------ 1 file changed, 119 insertions(+), 54 deletions(-) diff --git a/components/player/hooks/desktop/useCastControls.ts b/components/player/hooks/desktop/useCastControls.ts index e8a8946..0e322f8 100644 --- a/components/player/hooks/desktop/useCastControls.ts +++ b/components/player/hooks/desktop/useCastControls.ts @@ -13,7 +13,7 @@ declare global { interface Window { chrome: any; cast: any; - __onGCastApiAvailable: (isAvailable: boolean) => void; + __onGCastApiAvailable?: (isAvailable: boolean) => void; } } @@ -24,69 +24,37 @@ export function useCastControls({ setIsCasting }: UseCastControlsProps) { const castContextRef = useRef(null); + const loadMediaRef = useRef<() => void>(() => {}); - useEffect(() => { - // Function to initialize Cast - const initializeCastApi = () => { - if (!window.cast || !window.cast.framework) return; + const isCastSdkReady = useCallback(() => { + if (typeof window === 'undefined') return false; - const castContext = window.cast.framework.CastContext.getInstance(); - castContextRef.current = castContext; - - castContext.setOptions({ - receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, - autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED - }); - - // SDK loaded — show cast button immediately. - // requestSession() will re-scan and show Chrome's native device picker. - setIsCastAvailable(true); - - // Monitor session state - castContext.addEventListener( - window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED, - (event: any) => { - const sessionState = event.sessionState; - const isSessionActive = sessionState === window.cast.framework.SessionState.SESSION_STARTED || - sessionState === window.cast.framework.SessionState.SESSION_RESUMED; - - setIsCasting(isSessionActive); - - if (isSessionActive && videoRef.current) { - videoRef.current.pause(); - loadMedia(); - } - } - ); - }; - - // If API is already loaded - if (window.cast && window.cast.framework) { - initializeCastApi(); - } else { - // Wait for API to be available - window.__onGCastApiAvailable = (isAvailable: boolean) => { - if (isAvailable) { - initializeCastApi(); - } - }; - } - }, [setIsCastAvailable, setIsCasting, videoRef]); + return Boolean( + window.cast?.framework?.CastContext?.getInstance && + window.cast?.framework?.CastContextEventType?.SESSION_STATE_CHANGED && + window.cast?.framework?.SessionState && + window.chrome?.cast?.media?.DEFAULT_MEDIA_RECEIVER_APP_ID && + window.chrome?.cast?.media?.MediaInfo && + window.chrome?.cast?.media?.LoadRequest && + window.chrome?.cast?.AutoJoinPolicy?.ORIGIN_SCOPED + ); + }, []); const loadMedia = useCallback(() => { - if (!castContextRef.current || !src) return; + const castMedia = window.chrome?.cast?.media; + if (!castContextRef.current || !src || !castMedia?.MediaInfo || !castMedia?.LoadRequest) return; const castContext = castContextRef.current; const session = castContext.getCurrentSession(); if (!session) return; - const mediaInfo = new window.chrome.cast.media.MediaInfo(src, 'video/mp4'); + const mediaInfo = new castMedia.MediaInfo(src, 'video/mp4'); // Handle HLS specifically if possible, though DEFAULT_MEDIA_RECEIVER supports it if (src.includes('.m3u8')) { mediaInfo.contentType = 'application/x-mpegurl'; } - const request = new window.chrome.cast.media.LoadRequest(mediaInfo); + const request = new castMedia.LoadRequest(mediaInfo); // Sync current time if (videoRef.current) { @@ -99,11 +67,108 @@ export function useCastControls({ ); }, [src, videoRef]); - const showCastMenu = useCallback(() => { - if (window.cast && window.cast.framework) { - window.cast.framework.CastContext.getInstance().requestSession(); + useEffect(() => { + loadMediaRef.current = loadMedia; + }, [loadMedia]); + + useEffect(() => { + let sessionStateListener: ((event: any) => void) | null = null; + let onGCastApiAvailable: ((isAvailable: boolean) => void) | null = null; + + const markCastUnavailable = () => { + castContextRef.current = null; + setIsCastAvailable(false); + setIsCasting(false); + }; + + // Function to initialize Cast + const initializeCastApi = () => { + if (!isCastSdkReady()) { + markCastUnavailable(); + return; + } + + try { + const castContext = window.cast.framework.CastContext.getInstance(); + castContextRef.current = castContext; + + castContext.setOptions({ + receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID, + autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED + }); + + // SDK loaded — show cast button immediately. + // requestSession() will re-scan and show Chrome's native device picker. + setIsCastAvailable(true); + + // Monitor session state + sessionStateListener = (event: any) => { + const sessionState = event.sessionState; + const isSessionActive = sessionState === window.cast.framework.SessionState.SESSION_STARTED || + sessionState === window.cast.framework.SessionState.SESSION_RESUMED; + + setIsCasting(isSessionActive); + + if (isSessionActive && videoRef.current) { + videoRef.current.pause(); + loadMediaRef.current(); + } + }; + + castContext.addEventListener( + window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + sessionStateListener + ); + } catch (error) { + console.warn('Cast SDK is not usable in this browser context.', error); + markCastUnavailable(); + } + }; + + // If API is already loaded + if (isCastSdkReady()) { + initializeCastApi(); + } else { + // Wait for API to be available + onGCastApiAvailable = (isAvailable: boolean) => { + if (isAvailable) { + initializeCastApi(); + } else { + markCastUnavailable(); + } + }; + window.__onGCastApiAvailable = onGCastApiAvailable; } - }, []); + + return () => { + if (onGCastApiAvailable && window.__onGCastApiAvailable === onGCastApiAvailable) { + delete window.__onGCastApiAvailable; + } + + const castContext = castContextRef.current; + if ( + sessionStateListener && + castContext?.removeEventListener && + window.cast?.framework?.CastContextEventType?.SESSION_STATE_CHANGED + ) { + castContext.removeEventListener( + window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED, + sessionStateListener + ); + } + }; + }, [isCastSdkReady, setIsCastAvailable, setIsCasting, videoRef]); + + const showCastMenu = useCallback(() => { + if (!isCastSdkReady()) return; + + try { + window.cast.framework.CastContext.getInstance().requestSession(); + } catch (error) { + console.warn('Cast session request failed.', error); + setIsCastAvailable(false); + } + }, [isCastSdkReady, setIsCastAvailable]); const castActions = useMemo(() => ({ showCastMenu