From 66610c1d8dfd256578a4bd6fa0e1f93f6adf0c63 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 18:45:37 +0900 Subject: [PATCH 1/2] feat: support optional YouTube source for dynamic loading - Extend YouTubeSource type to accept undefined values - Add defensive logic for undefined source handling - Enable async video ID loading patterns - Maintain backward compatibility with existing usage --- .changeset/eight-wolves-swim.md | 22 +++++++++++++++++ packages/core/src/types/index.ts | 2 +- packages/core/src/utils.ts | 8 +++++-- .../src/YoutubeView.tsx | 24 ++++++++++++------- .../src/YoutubeView.web.tsx | 4 ++++ .../src/hooks/useCreateLocalPlayerHtml.ts | 6 ++--- .../src/modules/YoutubePlayer.ts | 6 ++--- .../src/utils/youtube.ts | 4 ++-- packages/react/src/hooks/useYoutubeOEmbed.ts | 10 ++++---- packages/react/src/hooks/useYoutubeVideoId.ts | 24 +++++++++++++++---- packages/web/src/YoutubePlayer.tsx | 2 +- 11 files changed, 82 insertions(+), 30 deletions(-) create mode 100644 .changeset/eight-wolves-swim.md diff --git a/.changeset/eight-wolves-swim.md b/.changeset/eight-wolves-swim.md new file mode 100644 index 0000000..1fb920b --- /dev/null +++ b/.changeset/eight-wolves-swim.md @@ -0,0 +1,22 @@ +--- +"react-native-youtube-bridge": minor +"@react-native-youtube-bridge/react": minor +"@react-native-youtube-bridge/core": minor +"@react-native-youtube-bridge/web": minor +--- + +feat: support optional YouTube source for dynamic loading + +- Extend YouTubeSource type to accept undefined values +- Add defensive logic for undefined source handling +- Enable async video ID loading patterns +- Maintain backward compatibility with existing usage + +New usage pattern: + +```tsx +type YouTubeSource = string | { videoId: string | undefined } | { url: string | undefined } | undefined; + +const [videoId, setVideoId] = useState(); +const player = useYouTubePlayer(videoId); // Now supports undefined +``` diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 797bccf..5975b4e 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -3,7 +3,7 @@ import type { ERROR_CODES } from '../constants'; type YouTubeErrorCode = keyof typeof ERROR_CODES; type YouTubeErrorMessage = (typeof ERROR_CODES)[YouTubeErrorCode]; -export type YouTubeSource = string | { videoId: string } | { url: string }; +export type YouTubeSource = string | { videoId: string | undefined } | { url: string | undefined } | undefined; export type ProgressData = { currentTime: number; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 4adb9f7..025fa7e 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -10,9 +10,13 @@ export const extractVideoIdFromUrl = (url?: string): string | undefined => { return match ? match[1] : undefined; }; -export const validateVideoId = (videoId?: string): boolean => { +export const validateVideoId = (videoId?: string | null): boolean => { + if (!videoId) { + return false; + } + const videoIdRegex = /^[\w-]{11}$/; - return videoIdRegex.test(videoId ?? ''); + return videoIdRegex.test(videoId); }; export const escapeHtml = (unsafe?: string): string => { diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.tsx index 4497b2a..cac1f1a 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -34,10 +34,20 @@ function YoutubeView({ }, [player]); const createPlayerHTML = useCreateLocalPlayerHtml({ videoId, useInlineHtml, ...playerVars }); - const webViewUrl = useMemo( - () => getYoutubeWebViewUrl(videoId, useInlineHtml, playerVars, webViewBaseUrl), - [videoId, useInlineHtml, playerVars, webViewBaseUrl], - ); + const webViewUrl = getYoutubeWebViewUrl(videoId, useInlineHtml, playerVars, webViewBaseUrl); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const webViewSource = useMemo(() => { + if (useInlineHtml) { + return { html: createPlayerHTML(), ...(webViewBaseUrl ? { baseUrl: webViewBaseUrl } : {}) }; + } + + if (webViewUrl) { + return { ...(webViewProps?.source ?? {}), uri: webViewUrl }; + } + + return undefined; + }, [useInlineHtml, createPlayerHTML, webViewBaseUrl, webViewUrl]); const handleMessage = useCallback( (event: WebViewMessageEvent) => { @@ -149,11 +159,7 @@ function YoutubeView({ {...webViewProps} ref={webViewRef} javaScriptEnabled - source={ - useInlineHtml - ? { html: createPlayerHTML(), baseUrl: webViewBaseUrl } - : { ...(webViewProps?.source ?? {}), uri: webViewUrl } - } + source={webViewSource} onMessage={handleMessage} onError={(error) => { console.error('WebView error:', error); diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx index c4b8994..70d7a81 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -27,6 +27,10 @@ function YoutubeView({ player, height, width, style, iframeStyle }: YoutubeViewP const videoId = player.getVideoId(); + if (!videoId) { + return; + } + const containerId = `youtube-player-${videoId}`; containerRef.current.id = containerId; const options = player.getOptions(); diff --git a/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts b/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts index 0eae4e1..752d134 100644 --- a/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts @@ -15,14 +15,14 @@ const useCreateLocalPlayerHtml = ({ playsinline, rel, useInlineHtml, -}: YoutubePlayerVars & { videoId: string; useInlineHtml: boolean }) => { +}: YoutubePlayerVars & { videoId: string | null | undefined; useInlineHtml: boolean }) => { const createPlayerHTML = useCallback(() => { - if (!useInlineHtml) { + if (!useInlineHtml || videoId === undefined) { return ''; } if (!validateVideoId(videoId)) { - return '
Invalid video ID
'; + return '
Invalid YouTube ID
'; } const safeOrigin = escapeHtml(origin); diff --git a/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts index ecfc8ef..5fcdbe3 100644 --- a/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts +++ b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts @@ -20,15 +20,15 @@ class YoutubePlayer { private progressInterval: number | null = null; - private videoId: string; + private videoId: string | null | undefined; private options: YoutubePlayerVars; - constructor(videoId: string, options?: YoutubePlayerVars) { + constructor(videoId: string | null | undefined, options?: YoutubePlayerVars) { this.videoId = videoId; this.options = options ?? {}; } - getVideoId(): string { + getVideoId(): string | null | undefined { return this.videoId; } diff --git a/packages/react-native-youtube-bridge/src/utils/youtube.ts b/packages/react-native-youtube-bridge/src/utils/youtube.ts index 618c6ad..6dc4b69 100644 --- a/packages/react-native-youtube-bridge/src/utils/youtube.ts +++ b/packages/react-native-youtube-bridge/src/utils/youtube.ts @@ -2,13 +2,13 @@ import type { YoutubePlayerVars } from '@react-native-youtube-bridge/core'; import { DEFAULT_EXTERNAL_WEB_URL } from './constants'; export const getYoutubeWebViewUrl = ( - videoId: string, + videoId: string | null | undefined, useInlineHtml: boolean, playerVars: YoutubePlayerVars, webViewBaseUrl?: string, ) => { if (useInlineHtml || !videoId) { - return ''; + return undefined; } const baseUrl = webViewBaseUrl || DEFAULT_EXTERNAL_WEB_URL; diff --git a/packages/react/src/hooks/useYoutubeOEmbed.ts b/packages/react/src/hooks/useYoutubeOEmbed.ts index 659aea4..7a04fac 100644 --- a/packages/react/src/hooks/useYoutubeOEmbed.ts +++ b/packages/react/src/hooks/useYoutubeOEmbed.ts @@ -21,12 +21,16 @@ type OEmbed = { * @param url - The URL of the YouTube video. * @returns The oEmbed data, loading state, and error. */ -const useYoutubeOEmbed = (url: string) => { +const useYoutubeOEmbed = (url?: string) => { const [oEmbed, setOEmbed] = useState(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { + if (!url) { + return; + } + const controller = new AbortController(); setError(null); @@ -61,9 +65,7 @@ const useYoutubeOEmbed = (url: string) => { } }; - if (url) { - fetchOEmbed(); - } + fetchOEmbed(); return () => { controller.abort(); diff --git a/packages/react/src/hooks/useYoutubeVideoId.ts b/packages/react/src/hooks/useYoutubeVideoId.ts index e7b2b8b..f87be6f 100644 --- a/packages/react/src/hooks/useYoutubeVideoId.ts +++ b/packages/react/src/hooks/useYoutubeVideoId.ts @@ -7,9 +7,13 @@ import { } from '@react-native-youtube-bridge/core'; import { useMemo } from 'react'; -const useYouTubeVideoId = (source: YouTubeSource, onError?: PlayerEvents['onError']): string => { +const useYouTubeVideoId = (source: YouTubeSource, onError?: PlayerEvents['onError']): string | null | undefined => { // biome-ignore lint/correctness/useExhaustiveDependencies: const sourceValue = useMemo(() => { + if (!source) { + return; + } + if (typeof source === 'string') { return source; } @@ -24,17 +28,27 @@ const useYouTubeVideoId = (source: YouTubeSource, onError?: PlayerEvents['onErro return null; }, [ - typeof source === 'string' ? source : 'videoId' in source ? source.videoId : 'url' in source ? source.url : null, + typeof source === 'string' + ? source + : source && 'videoId' in source + ? source.videoId + : source && 'url' in source + ? source.url + : null, ]); const videoId = useMemo(() => { - if (!sourceValue) { + if (sourceValue === null) { console.error('Invalid YouTube source: ', sourceValue); onError?.({ code: 1002, message: ERROR_CODES[1002], }); - return ''; + return null; + } + + if (sourceValue === undefined) { + return undefined; } if (validateVideoId(sourceValue)) { @@ -49,7 +63,7 @@ const useYouTubeVideoId = (source: YouTubeSource, onError?: PlayerEvents['onErro code: 1002, message: ERROR_CODES[1002], }); - return ''; + return null; } return extractedId; diff --git a/packages/web/src/YoutubePlayer.tsx b/packages/web/src/YoutubePlayer.tsx index 57404aa..e8894c6 100644 --- a/packages/web/src/YoutubePlayer.tsx +++ b/packages/web/src/YoutubePlayer.tsx @@ -43,7 +43,7 @@ function YoutubePlayer() { }, []); useEffect(() => { - if (!isInitialized || !containerRef.current) { + if (!isInitialized || !containerRef.current || !youtubeVideoId) { return; } From 1b6d919ef818ae5a12f653003e210ac13f625a2c Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 18:52:55 +0900 Subject: [PATCH 2/2] refactor: suggested review --- packages/react-native-youtube-bridge/src/YoutubeView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.tsx index cac1f1a..2cba476 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -36,7 +36,7 @@ function YoutubeView({ const createPlayerHTML = useCreateLocalPlayerHtml({ videoId, useInlineHtml, ...playerVars }); const webViewUrl = getYoutubeWebViewUrl(videoId, useInlineHtml, playerVars, webViewBaseUrl); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: webViewProps.source is intentionally excluded to prevent unnecessary re-renders const webViewSource = useMemo(() => { if (useInlineHtml) { return { html: createPlayerHTML(), ...(webViewBaseUrl ? { baseUrl: webViewBaseUrl } : {}) };