mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 00:03:42 +08:00
feat: Add Google Cast functionality and controls to the desktop player.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user