diff --git a/.changeset/gentle-houses-attack.md b/.changeset/gentle-houses-attack.md new file mode 100644 index 0000000..96cafd9 --- /dev/null +++ b/.changeset/gentle-houses-attack.md @@ -0,0 +1,8 @@ +--- +"react-native-youtube-bridge": patch +--- + +fix: where onProgress is not called when seekTo is invoked +- add TSDoc documentation +- add defensive logic for cases without videoId +- fix issue where seekTo doesn't work properly when paused without interval diff --git a/src/YoutubePlayer.web.tsx b/src/YoutubePlayer.web.tsx index 1c42290..19323c2 100644 --- a/src/YoutubePlayer.web.tsx +++ b/src/YoutubePlayer.web.tsx @@ -3,6 +3,7 @@ import { useWindowDimensions } from 'react-native'; import YoutubePlayerWrapper from './YoutubePlayerWrapper'; import type { YouTubePlayer } from './types/iframe'; import { ERROR_CODES, type PlayerControls, PlayerState, type YoutubePlayerProps } from './types/youtube'; +import { validateVideoId } from './utils/validate'; const YoutubePlayer = forwardRef( ( @@ -40,6 +41,7 @@ const YoutubePlayer = forwardRef( const createPlayerRef = useRef<() => void>(null); const progressInterval = useRef(null); const intervalRef = useRef(interval); + const seekTimeoutRef = useRef(null); const stopProgressTracking = useCallback(() => { if (progressInterval.current) { @@ -48,6 +50,24 @@ const YoutubePlayer = forwardRef( } }, []); + const sendProgress = useCallback(async () => { + if (!playerRef.current || !playerRef.current.getCurrentTime) { + return; + } + + const currentTime = await playerRef.current.getCurrentTime(); + const duration = await playerRef.current.getDuration(); + const percentage = duration > 0 ? (currentTime / duration) * 100 : 0; + const loadedFraction = await playerRef.current.getVideoLoadedFraction(); + + onProgress?.({ + currentTime, + duration, + percentage, + loadedFraction, + }); + }, [onProgress]); + const startProgressTracking = useCallback(() => { if (!intervalRef.current) { return; @@ -120,7 +140,12 @@ const YoutubePlayer = forwardRef( }, []); createPlayerRef.current = () => { - if (!containerRef.current || !window.YT?.Player || !videoId) { + if (!containerRef.current || !window.YT?.Player) { + return; + } + + if (!validateVideoId(videoId)) { + onError?.({ code: -2, message: 'Invalid YouTube videoId supplied' }); return; } @@ -171,10 +196,35 @@ const YoutubePlayer = forwardRef( const state = event.data; console.log('YouTube player state changed:', state); onStateChange?.(state); + + if (state === PlayerState.ENDED) { + stopProgressTracking(); + sendProgress(); + return; + } + if (state === PlayerState.PLAYING) { startProgressTracking(); return; } + + if (state === PlayerState.PAUSED) { + stopProgressTracking(); + sendProgress(); + return; + } + + if (state === PlayerState.BUFFERING) { + startProgressTracking(); + return; + } + + if (state === PlayerState.CUED) { + stopProgressTracking(); + sendProgress(); + return; + } + stopProgressTracking(); }, onError: (event) => { @@ -225,7 +275,7 @@ const YoutubePlayer = forwardRef( }, [createPlayer, stopProgressTracking]); useEffect(() => { - if (playerRef.current && videoId) { + if (playerRef.current && validateVideoId(videoId)) { try { playerRef.current.loadVideoById(videoId); } catch (error) { @@ -258,9 +308,20 @@ const YoutubePlayer = forwardRef( playerRef.current?.stopVideo(); }, []); - const seekTo = useCallback((seconds: number, allowSeekAhead = true) => { - playerRef.current?.seekTo(seconds, allowSeekAhead); - }, []); + const seekTo = useCallback( + (seconds: number, allowSeekAhead = true) => { + playerRef.current?.seekTo(seconds, allowSeekAhead); + + if (seekTimeoutRef.current) { + clearTimeout(seekTimeoutRef.current); + } + + seekTimeoutRef.current = setTimeout(() => { + sendProgress(); + }, 200); + }, + [sendProgress], + ); const setVolume = useCallback((volume: number) => { playerRef.current?.setVolume(volume); diff --git a/src/hooks/useCreateLocalPlayerHtml.ts b/src/hooks/useCreateLocalPlayerHtml.ts index 56a8c41..0510df0 100644 --- a/src/hooks/useCreateLocalPlayerHtml.ts +++ b/src/hooks/useCreateLocalPlayerHtml.ts @@ -73,6 +73,7 @@ const useCreateLocalPlayerHtml = ({ var isDestroyed = false; ${youtubeIframeScripts.receiveMessage} + ${youtubeIframeScripts.sendProgress} function cleanup() { isDestroyed = true; @@ -133,7 +134,17 @@ const useCreateLocalPlayerHtml = ({ play: () => player && player.playVideo(), pause: () => player && player.pauseVideo(), stop: () => player && player.stopVideo(), - seekTo: (seconds, allowSeekAhead) => player && player.seekTo(seconds, allowSeekAhead !== false), + seekTo: (seconds, allowSeekAhead) => { + if (!player) { + return; + } + + player.seekTo(seconds, allowSeekAhead !== false); + + setTimeout(() => { + sendProgress(); + }, 200); + }, setVolume: (volume) => player && player.setVolume(volume), getVolume: () => player ? player.getVolume() : 0, diff --git a/src/hooks/youtubeIframeScripts.ts b/src/hooks/youtubeIframeScripts.ts index 0becb1a..64774ec 100644 --- a/src/hooks/youtubeIframeScripts.ts +++ b/src/hooks/youtubeIframeScripts.ts @@ -44,6 +44,29 @@ const stopProgressTracking = /* js */ ` } `; +const sendProgress = /* js */ ` + function sendProgress() { + if (player && player.getCurrentTime) { + try { + const currentTime = player.getCurrentTime(); + const duration = player.getDuration(); + const percentage = duration > 0 ? (currentTime / duration) * 100 : 0; + const loadedFraction = player.getVideoLoadedFraction(); + + window.ReactNativeWebView.postMessage(JSON.stringify({ + type: 'progress', + currentTime, + duration, + percentage, + loadedFraction, + })); + } catch (error) { + console.error('Final progress error:', error); + } + } + } +`; + const onPlayerReady = /* js */ ` function onPlayerReady(event) { if (isDestroyed) { @@ -62,7 +85,7 @@ const onPlayerReady = /* js */ ` } `; -const onPlayerStateChange = /* js */ ` +const onPlayerStateChange = /* js */ ` function onPlayerStateChange(event) { if (isDestroyed) { return; @@ -74,11 +97,35 @@ const onPlayerStateChange = /* js */ ` state: event.data })); + if (event.data === YT.PlayerState.ENDED) { + stopProgressTracking(); + sendProgress(); + return; + } + if (event.data === YT.PlayerState.PLAYING) { startProgressTracking(); - } else { + return; + } + + if (event.data === YT.PlayerState.PAUSED) { + stopProgressTracking(); + sendProgress(); + return; + } + + if (event.data === YT.PlayerState.BUFFERING) { + startProgressTracking(); + return; + } + + if (event.data === YT.PlayerState.CUED) { stopProgressTracking(); + sendProgress(); + return; } + + stopProgressTracking(); } catch (error) { console.error('onPlayerStateChange error:', error); } @@ -229,6 +276,7 @@ export const youtubeIframeScripts = { startProgressTracking, stopProgressTracking, receiveMessage, + sendProgress, onPlayerReady, onPlayerStateChange, onPlayerError, diff --git a/src/types/youtube.ts b/src/types/youtube.ts index c609221..8d75abf 100644 --- a/src/types/youtube.ts +++ b/src/types/youtube.ts @@ -4,17 +4,33 @@ import type { WebViewProps } from 'react-native-webview'; export type YoutubePlayerVars = { /** - * @description If the `muted` is not set to true when activating the `autoplay`, it may not work properly depending on browser policy. (https://developer.chrome.com/blog/autoplay) + * @description If the `muted` is not set to true when activating the `autoplay`, + * it may not work properly depending on browser policy. (https://developer.chrome.com/blog/autoplay) */ autoplay?: boolean; + /** + * @description If the `controls` is set to true, the player will display the controls. + */ controls?: boolean; + /** + * @description If the `loop` is set to true, the player will loop the video. + */ loop?: boolean; + /** + * @description If the `muted` is set to true, the player will be muted. + */ muted?: boolean; startTime?: number; endTime?: number; playsinline?: boolean; - rel?: boolean; // 관련 동영상 표시 - origin?: string; // 보안을 위한 origin 설정 + /** + * @description If the `rel` is set to true, the related videos will be displayed. + */ + rel?: boolean; + /** + * @description The origin of the player. + */ + origin?: string; }; // YouTube IFrame API official documentation based @@ -24,8 +40,6 @@ export type YoutubePlayerProps = { height?: DimensionValue; /** * @description The interval (in milliseconds) at which `onProgress` callback is called. - * Must be a positive number to enable progress tracking. - * If not provided or set to 0/falsy value, progress tracking is disabled. */ progressInterval?: number; style?: StyleProp; @@ -43,12 +57,16 @@ export type YoutubePlayerProps = { iframeStyle?: CSSProperties; // Events + /** + * @description Callback function called when the player is ready. + */ onReady?: (playerInfo: PlayerInfo) => void; onStateChange?: (state: PlayerState) => void; onError?: (error: YouTubeError) => void; /** - * @description Callback function called at the specified `progressInterval`. - * Only invoked when `progressInterval` is provided as a positive number. + * @description Callback function called at the specified `progressInterval` + * or when `seekTo` is invoked. Only triggered when `progressInterval` is + * provided as a positive number. */ onProgress?: (progress: ProgressData) => void; onPlaybackRateChange?: (playbackRate: number) => void; diff --git a/src/utils/validate.ts b/src/utils/validate.ts index e065e40..9aa83f4 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -1,7 +1,7 @@ -export const validateVideoId = (videoId: string): boolean => { +export const validateVideoId = (videoId?: string): boolean => { // YouTube video ID is 11 characters of alphanumeric and hyphen, underscore const videoIdRegex = /^[a-zA-Z0-9_-]{11}$/; - return videoIdRegex.test(videoId); + return videoIdRegex.test(videoId ?? ''); }; export const extractVideoIdFromUrl = (url: string): string | null => {