feat: Add Google Cast functionality and controls to the desktop player.

This commit is contained in:
kuekhaoyang
2025-12-26 11:51:56 +08:00
parent 1d5a8b8d47
commit 8e985e2268
8 changed files with 167 additions and 2 deletions
+3
View File
@@ -53,6 +53,9 @@ export default function RootLayout({
className="sr-only"
/>
{/* Google Cast SDK */}
<script src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1" async />
{/* Scroll Performance Optimization Script */}
<script
dangerouslySetInnerHTML={{
@@ -17,6 +17,7 @@ interface DesktopControlsProps {
showVolumeBar: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isCastAvailable: boolean;
isProxied?: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
@@ -29,6 +30,7 @@ interface DesktopControlsProps {
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onShowCastMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
@@ -25,6 +25,7 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
showVolumeBar,
isPiPSupported,
isAirPlaySupported,
isCastAvailable,
setShowSpeedMenu,
setShowMoreMenu,
} = state;
@@ -39,6 +40,7 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
toggleFullscreen,
togglePictureInPicture,
showAirPlayMenu,
showCastMenu,
changePlaybackSpeed,
handleCopyLink,
handleProgressClick,
@@ -72,6 +74,7 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
showVolumeBar={showVolumeBar}
isPiPSupported={isPiPSupported}
isAirPlaySupported={isAirPlaySupported}
isCastAvailable={isCastAvailable}
isProxied={isProxied}
progressBarRef={progressBarRef}
volumeBarRef={volumeBarRef}
@@ -81,9 +84,9 @@ export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopContr
onToggleMute={toggleMute}
onVolumeChange={handleVolumeChange}
onVolumeMouseDown={handleVolumeMouseDown}
onToggleFullscreen={toggleFullscreen}
onTogglePictureInPicture={togglePictureInPicture}
onShowAirPlayMenu={showAirPlayMenu}
onShowCastMenu={showCastMenu}
onToggleSpeedMenu={() => setShowSpeedMenu(!showSpeedMenu)}
onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)}
onSpeedChange={changePlaybackSpeed}
@@ -10,10 +10,12 @@ interface DesktopRightControlsProps {
showMoreMenu: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isCastAvailable: boolean;
isProxied?: boolean;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onShowCastMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
@@ -32,10 +34,12 @@ export function DesktopRightControls({
showMoreMenu,
isPiPSupported,
isAirPlaySupported,
isCastAvailable,
isProxied,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
onShowCastMenu,
onToggleSpeedMenu,
onToggleMoreMenu,
onSpeedChange,
@@ -83,6 +87,18 @@ export function DesktopRightControls({
</button>
)}
{/* Google Cast */}
{isCastAvailable && (
<button
onClick={onShowCastMenu}
className="btn-icon"
aria-label="Google Cast"
title="Google Cast"
>
<Icons.Cast size={20} />
</button>
)}
{/* More Menu */}
<DesktopMoreMenu
showMoreMenu={showMoreMenu}
@@ -0,0 +1,120 @@
'use client';
import { useCallback, useEffect, useRef } from 'react';
interface UseCastControlsProps {
src: string;
videoRef: React.RefObject<HTMLVideoElement | null>;
setIsCastAvailable: (available: boolean) => void;
setIsCasting: (casting: boolean) => void;
}
declare global {
interface Window {
chrome: any;
cast: any;
__onGCastApiAvailable: (isAvailable: boolean) => void;
}
}
export function useCastControls({
src,
videoRef,
setIsCastAvailable,
setIsCasting
}: UseCastControlsProps) {
const castContextRef = useRef<any>(null);
useEffect(() => {
// Function to initialize Cast
const initializeCastApi = () => {
if (!window.cast || !window.cast.framework) return;
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
});
// Monitor cast state
castContext.addEventListener(
window.cast.framework.CastContextEventType.CAST_STATE_CHANGED,
(event: any) => {
const state = event.castState;
setIsCastAvailable(state !== window.cast.framework.CastState.NO_DEVICES_AVAILABLE);
}
);
// Initial state check
const initialState = castContext.getCastState();
setIsCastAvailable(initialState !== window.cast.framework.CastState.NO_DEVICES_AVAILABLE);
// 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]);
const loadMedia = useCallback(() => {
if (!castContextRef.current || !src) return;
const castContext = castContextRef.current;
const session = castContext.getCurrentSession();
if (!session) return;
const mediaInfo = new window.chrome.cast.media.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);
// Sync current time
if (videoRef.current) {
request.currentTime = videoRef.current.currentTime;
}
session.loadMedia(request).then(
() => console.log('Cast: Media loaded successfully'),
(error: any) => console.error('Cast: Media load failed', error)
);
}, [src, videoRef]);
const showCastMenu = useCallback(() => {
if (window.cast && window.cast.framework) {
window.cast.framework.CastContext.getInstance().requestSession();
}
}, []);
return {
showCastMenu
};
}
@@ -8,6 +8,7 @@ import { useUtilities } from './desktop/useUtilities';
import { useDesktopShortcuts } from './desktop/useDesktopShortcuts';
import { useDesktopPlayerState } from './useDesktopPlayerState';
import { getCopyUrl } from '../utils/urlUtils';
import { useCastControls } from './desktop/useCastControls';
type DesktopPlayerState = ReturnType<typeof useDesktopPlayerState>;
@@ -55,7 +56,9 @@ export function useDesktopPlayerLogic({
showSkipForwardIndicator, setShowSkipForwardIndicator,
showSkipBackwardIndicator, setShowSkipBackwardIndicator,
setIsSkipForwardAnimatingOut, setIsSkipBackwardAnimatingOut,
setShowVolumeBar, setToastMessage, setShowToast
setShowVolumeBar, setToastMessage, setShowToast,
isCastAvailable, setIsCastAvailable,
isCasting, setIsCasting
} = state;
const playbackControls = usePlaybackControls({
@@ -100,6 +103,10 @@ export function useDesktopPlayerLogic({
src, setToastMessage, setShowToast, toastTimeoutRef
});
const castControls = useCastControls({
src, videoRef, setIsCastAvailable, setIsCasting
});
useDesktopShortcuts({
videoRef, isPlaying, volume, isPiPSupported,
togglePlay: playbackControls.togglePlay,
@@ -129,6 +136,7 @@ export function useDesktopPlayerLogic({
toggleFullscreen: fullscreenControls.toggleFullscreen,
togglePictureInPicture: fullscreenControls.togglePictureInPicture,
showAirPlayMenu: fullscreenControls.showAirPlayMenu,
showCastMenu: castControls.showCastMenu,
skipForward: skipControls.skipForward,
skipBackward: skipControls.skipBackward,
changePlaybackSpeed: playbackControls.changePlaybackSpeed,
@@ -31,6 +31,8 @@ export function useDesktopPlayerState() {
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
const [isPiPSupported, setIsPiPSupported] = useState(false);
const [isAirPlaySupported, setIsAirPlaySupported] = useState(false);
const [isCastAvailable, setIsCastAvailable] = useState(false);
const [isCasting, setIsCasting] = useState(false);
const [skipForwardAmount, setSkipForwardAmount] = useState(0);
const [skipBackwardAmount, setSkipBackwardAmount] = useState(0);
const [showSkipForwardIndicator, setShowSkipForwardIndicator] = useState(false);
@@ -72,6 +74,8 @@ export function useDesktopPlayerState() {
showSpeedMenu, setShowSpeedMenu,
isPiPSupported, setIsPiPSupported,
isAirPlaySupported, setIsAirPlaySupported,
isCastAvailable, setIsCastAvailable,
isCasting, setIsCasting,
skipForwardAmount, setSkipForwardAmount,
skipBackwardAmount, setSkipBackwardAmount,
showSkipForwardIndicator, setShowSkipForwardIndicator,
+9
View File
@@ -110,4 +110,13 @@ export const MediaIcons = {
<polygon points="12 15 17 21 7 21 12 15" />
</svg>
),
Cast: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6" />
<path d="M2 12a9 9 0 0 1 8 8" />
<path d="M2 16a5 5 0 0 1 4 4" />
<line x1="2" y1="20" x2="2.01" y2="20" />
</svg>
),
};