diff --git a/biome.json b/biome.json index 51b31e2..b3d45f3 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": false }, "files": { - "include": ["src/**/*.{js,ts,tsx}", "example/src/**/*.{js,ts,tsx}"], + "include": ["src/**/*", "example/src/**/*"], "ignoreUnknown": false, "ignore": ["lib", "node_modules", "ios", "android"] }, @@ -20,7 +20,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + } } }, "javascript": { diff --git a/example/src/App.tsx b/example/src/App.tsx index ab064d0..6657a37 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Alert, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { type PlayerControls, @@ -20,7 +20,7 @@ function App() { const [isMuted, setIsMuted] = useState(false); const [videoId, setVideoId] = useState('AbZH7XWDW_k'); - const handleReady = async () => { + const handleReady = useCallback(async () => { console.log('Player is ready!'); Alert.alert('알림', 'YouTube 플레이어가 준비되었습니다!'); @@ -36,7 +36,7 @@ function App() { } catch (error) { console.error('Error getting player info:', error); } - }; + }, []); const handleStateChange = (state: PlayerState) => { console.log('Player state changed:', state); @@ -64,31 +64,31 @@ function App() { } }; - const handleProgress = (progress: ProgressData) => { + const handleProgress = useCallback((progress: ProgressData) => { setCurrentTime(progress.currentTime); setDuration(progress.duration); setLoadedFraction(progress.loadedFraction); - }; + }, []); - const handleError = (error: YouTubeError) => { + const handleError = useCallback((error: YouTubeError) => { console.error('Player error:', error); Alert.alert('에러', `플레이어 오류 (${error.code}): ${error.message}`); - }; + }, []); - const handlePlaybackRateChange = (rate: number) => { + const handlePlaybackRateChange = useCallback((rate: number) => { console.log('Playback rate changed:', rate); setPlaybackRate(rate); - }; + }, []); - const handlePlaybackQualityChange = (quality: string) => { + const handlePlaybackQualityChange = useCallback((quality: string) => { console.log('Playback quality changed:', quality); Alert.alert('품질 변경', `재생 품질이 ${quality}로 변경되었습니다`); - }; + }, []); - const handleAutoplayBlocked = () => { + const handleAutoplayBlocked = useCallback(() => { console.log('Autoplay was blocked'); Alert.alert('알림', '자동재생이 브라우저에 의해 차단되었습니다'); - }; + }, []); const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60); @@ -157,7 +157,6 @@ function App() { controls: true, playsinline: true, rel: false, - modestbranding: true, }} onReady={handleReady} onStateChange={handleStateChange} diff --git a/src/YoutubePlayer.tsx b/src/YoutubePlayer.tsx index 00bbc37..28f8548 100644 --- a/src/YoutubePlayer.tsx +++ b/src/YoutubePlayer.tsx @@ -1,9 +1,10 @@ -import useCreateLocalPlayerHtml from './hooks/useCreateLocalPlayerHtml'; -import type { MessageData } from './types/message'; -import { type PlayerControls, PlayerState, type YoutubePlayerProps } from './types/youtube'; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; -import { Dimensions, StyleSheet, View } from 'react-native'; +import { Dimensions, StyleSheet } from 'react-native'; import WebView, { type WebViewMessageEvent } from 'react-native-webview'; +import YoutubePlayerWrapper from './YoutubePlayerWrapper'; +import useCreateLocalPlayerHtml from './hooks/useCreateLocalPlayerHtml'; +import type { MessageData } from './types/message'; +import type { PlayerControls, YoutubePlayerProps } from './types/youtube'; const { width: screenWidth } = Dimensions.get('window'); @@ -29,7 +30,6 @@ const YoutubePlayer = forwardRef( muted: false, playsinline: true, rel: false, - modestbranding: true, }, }, ref, @@ -38,7 +38,6 @@ const YoutubePlayer = forwardRef( const webViewRef = useRef(null); const [isReady, setIsReady] = useState(false); - const [_, setCurrentState] = useState(PlayerState.UNSTARTED); const commandIdRef = useRef(0); const pendingCommandsRef = useRef void>>(new Map()); @@ -59,7 +58,6 @@ const YoutubePlayer = forwardRef( if (data.type === 'stateChange') { const state = data.state; - setCurrentState(state); onStateChange?.(state); return; } @@ -203,7 +201,7 @@ const YoutubePlayer = forwardRef( }, [isReady, sendCommand]); return ( - + ( mixedContentMode="compatibility" thirdPartyCookiesEnabled={false} /> - + ); }, ); const styles = StyleSheet.create({ - container: { - backgroundColor: '#000', - overflow: 'hidden', - }, webView: { flex: 1, backgroundColor: 'transparent', diff --git a/src/YoutubePlayer.web.tsx b/src/YoutubePlayer.web.tsx new file mode 100644 index 0000000..9d05a3f --- /dev/null +++ b/src/YoutubePlayer.web.tsx @@ -0,0 +1,387 @@ +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +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'; + +const YoutubePlayer = forwardRef( + ( + { + videoId, + width, + height = 200, + onReady, + onStateChange, + onError, + onProgress, + onPlaybackRateChange, + onPlaybackQualityChange, + onAutoplayBlocked, + style, + playerVars = { + startTime: 0, + autoplay: false, + controls: true, + loop: false, + muted: false, + playsinline: true, + rel: false, + }, + }, + ref, + ) => { + const { startTime = 0, endTime, autoplay, controls, loop, playsinline, rel } = playerVars; + + const { width: screenWidth } = useWindowDimensions(); + + const playerRef = useRef(null); + const containerRef = useRef(null); + const createPlayerRef = useRef<() => void>(null); + const progressInterval = useRef(null); + + const stopProgressTracking = useCallback(() => { + if (progressInterval.current) { + clearInterval(progressInterval.current); + progressInterval.current = null; + } + }, []); + + const startProgressTracking = useCallback(() => { + if (progressInterval.current) { + clearInterval(progressInterval.current); + } + + progressInterval.current = setInterval(async () => { + if (!playerRef.current || !playerRef.current.getCurrentTime) { + stopProgressTracking(); + return; + } + + try { + 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, + }); + } catch (error) { + console.error('Progress tracking error:', error); + stopProgressTracking(); + } + }, 1000); + }, [onProgress, stopProgressTracking]); + + const loadYouTubeAPI = useCallback(() => { + if (window.YT?.Player) { + return Promise.resolve(); + } + + if (window._ytApiPromise) { + return window._ytApiPromise; + } + + window._ytApiPromise = new Promise((resolve) => { + if (document.querySelector('script[src*="youtube.com/iframe_api"]')) { + const checkAPI = () => { + if (window.YT?.Player) { + resolve(); + return; + } + + setTimeout(checkAPI, 100); + }; + + checkAPI(); + return; + } + + window.onYouTubeIframeAPIReady = () => { + resolve(); + }; + + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.async = true; + document.head.appendChild(script); + }); + + return window._ytApiPromise; + }, []); + + createPlayerRef.current = () => { + if (!containerRef.current || !window.YT?.Player || !videoId) { + return; + } + + if (playerRef.current) { + try { + playerRef.current.destroy(); + } catch (error) { + console.warn('Error destroying YouTube player:', error); + } + } + + const playerId = `youtube-player-${videoId}`; + containerRef.current.id = playerId; + + playerRef.current = new window.YT.Player(playerId, { + width: '100%', + height: '100%', + videoId, + playerVars: { + autoplay: autoplay ? 1 : 0, + controls: controls ? 1 : 0, + loop: loop ? 1 : 0, + start: startTime, + end: endTime, + playsinline: playsinline ? 1 : 0, + rel: rel ? 1 : 0, + enablejsapi: 1, + }, + events: { + onReady: () => { + onReady?.(); + startProgressTracking(); + }, + onStateChange: (event) => { + const state = event.data; + console.log('YouTube player state changed:', state); + onStateChange?.(state); + if (state === PlayerState.PLAYING) { + startProgressTracking(); + return; + } + stopProgressTracking(); + }, + onError: (event) => { + console.error('YouTube player error:', event.data); + const errorCode = event.data; + onError?.({ + code: errorCode, + message: ERROR_CODES[errorCode] || `Unknown error: ${errorCode}`, + }); + }, + onPlaybackQualityChange: (event) => { + onPlaybackQualityChange?.(event.data); + }, + onPlaybackRateChange: (event) => { + onPlaybackRateChange?.(event.data); + }, + onAutoplayBlocked, + }, + }); + }; + + const createPlayer = useCallback(async () => { + try { + await loadYouTubeAPI(); + createPlayerRef.current?.(); + } catch (error) { + console.error('Failed to create YouTube player:', error); + onError?.({ + code: -1, + message: 'Failed to load YouTube API', + }); + } + }, [loadYouTubeAPI, onError]); + + useEffect(() => { + createPlayer(); + + return () => { + stopProgressTracking(); + if (playerRef.current) { + try { + playerRef.current.destroy(); + } catch (error) { + console.warn('Error destroying YouTube player on unmount:', error); + } + } + }; + }, [createPlayer, stopProgressTracking]); + + useEffect(() => { + if (playerRef.current && videoId) { + try { + playerRef.current.loadVideoById(videoId); + } catch (error) { + console.warn('Error loading new video:', error); + createPlayer(); + } + } + }, [videoId, createPlayer]); + + const play = useCallback(() => { + playerRef.current?.playVideo(); + }, []); + + const pause = useCallback(() => { + playerRef.current?.pauseVideo(); + }, []); + + const stop = useCallback(() => { + playerRef.current?.stopVideo(); + }, []); + + const seekTo = useCallback((seconds: number, allowSeekAhead = true) => { + playerRef.current?.seekTo(seconds, allowSeekAhead); + }, []); + + const setVolume = useCallback((volume: number) => { + playerRef.current?.setVolume(volume); + }, []); + + const getVolume = useCallback(async (): Promise => { + const volume = await playerRef.current?.getVolume(); + + return volume ?? 0; + }, []); + + const mute = useCallback(() => { + playerRef.current?.mute(); + }, []); + + const unMute = useCallback(() => { + playerRef.current?.unMute(); + }, []); + + const isMuted = useCallback(async (): Promise => { + const isMuted = await playerRef.current?.isMuted(); + + return isMuted ?? false; + }, []); + + const getCurrentTime = useCallback(async (): Promise => { + const currentTime = await playerRef.current?.getCurrentTime(); + + return currentTime ?? 0; + }, []); + + const getDuration = useCallback(async (): Promise => { + const duration = await playerRef.current?.getDuration(); + + return duration ?? 0; + }, []); + + const getVideoUrl = useCallback(async (): Promise => { + const videoUrl = await playerRef.current?.getVideoUrl(); + + return videoUrl ?? ''; + }, []); + + const getVideoEmbedCode = useCallback(async (): Promise => { + const videoEmbedCode = await playerRef.current?.getVideoEmbedCode(); + + return videoEmbedCode ?? ''; + }, []); + + const getPlaybackRate = useCallback(async (): Promise => { + const playbackRate = await playerRef.current?.getPlaybackRate(); + + return playbackRate ?? 1; + }, []); + + const getAvailablePlaybackRates = useCallback(async (): Promise => { + const availablePlaybackRates = await playerRef.current?.getAvailablePlaybackRates(); + + return availablePlaybackRates ?? [1]; + }, []); + + const getPlayerState = useCallback(async (): Promise => { + const playerState = await playerRef.current?.getPlayerState(); + + return playerState ?? PlayerState.UNSTARTED; + }, []); + + const setPlaybackRate = useCallback((suggestedRate: number) => { + playerRef.current?.setPlaybackRate(suggestedRate); + }, []); + + const getVideoLoadedFraction = useCallback(async (): Promise => { + const videoLoadedFraction = await playerRef.current?.getVideoLoadedFraction(); + + return videoLoadedFraction ?? 0; + }, []); + + const loadVideoById = useCallback((videoId: string, startSeconds?: number, endSeconds?: number) => { + playerRef.current?.loadVideoById(videoId, startSeconds, endSeconds); + }, []); + + const cueVideoById = useCallback((videoId: string, startSeconds?: number, endSeconds?: number) => { + playerRef.current?.cueVideoById(videoId, startSeconds, endSeconds); + }, []); + + const setSize = useCallback((width: number, height: number) => { + playerRef.current?.setSize(width, height); + }, []); + + useImperativeHandle( + ref, + (): PlayerControls => ({ + play, + pause, + stop, + getCurrentTime, + getDuration, + seekTo, + setVolume, + getVolume, + mute, + unMute, + isMuted, + getVideoUrl, + getVideoEmbedCode, + getPlaybackRate, + setPlaybackRate, + getAvailablePlaybackRates, + getPlayerState, + getVideoLoadedFraction, + loadVideoById, + cueVideoById, + setSize, + }), + [ + play, + pause, + stop, + getCurrentTime, + getDuration, + seekTo, + setVolume, + getVolume, + mute, + unMute, + isMuted, + getVideoUrl, + getVideoEmbedCode, + getPlaybackRate, + setPlaybackRate, + getAvailablePlaybackRates, + getPlayerState, + getVideoLoadedFraction, + loadVideoById, + cueVideoById, + setSize, + ], + ); + + return ( + +
+ + ); + }, +); + +export default YoutubePlayer; diff --git a/src/YoutubePlayerWrapper.tsx b/src/YoutubePlayerWrapper.tsx new file mode 100644 index 0000000..1a5ecd1 --- /dev/null +++ b/src/YoutubePlayerWrapper.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; +import { type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; + +type YoutubePlayerWrapperProps = { + children: ReactNode; + width?: number | `${number}%`; + height?: number | `${number}%`; + style?: StyleProp; +}; + +function YoutubePlayerWrapper({ children, width, height, style }: YoutubePlayerWrapperProps) { + return {children}; +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#000', + overflow: 'hidden', + }, +}); + +export default YoutubePlayerWrapper; diff --git a/src/hooks/useCreateLocalPlayerHtml.ts b/src/hooks/useCreateLocalPlayerHtml.ts index a515e6b..fe4b663 100644 --- a/src/hooks/useCreateLocalPlayerHtml.ts +++ b/src/hooks/useCreateLocalPlayerHtml.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; -import { escapeHtml, safeNumber, validateVideoId } from '../utils/validate'; import { youtubeIframeScripts } from '../hooks/youtubeIframeScripts'; import type { YoutubePlayerVars } from '../types/youtube'; +import { escapeHtml, safeNumber, validateVideoId } from '../utils/validate'; const useCreateLocalPlayerHtml = ({ videoId, @@ -14,7 +14,6 @@ const useCreateLocalPlayerHtml = ({ muted, playsinline, rel, - modestbranding, }: YoutubePlayerVars & { videoId: string }) => { const createPlayerHTML = useCallback(() => { if (!validateVideoId(videoId)) { @@ -101,7 +100,6 @@ const useCreateLocalPlayerHtml = ({ playsinline: ${playsinline ? 1 : 0}, rel: ${rel ? 1 : 0}, ${safeOrigin ? `origin: '${safeOrigin}',` : ''} - modestbranding: ${modestbranding ? 1 : 0}, enablejsapi: 1 }, events: { @@ -196,7 +194,7 @@ const useCreateLocalPlayerHtml = ({ `; - }, [videoId, origin, startTime, endTime, autoplay, controls, loop, muted, playsinline, rel, modestbranding]); + }, [videoId, origin, startTime, endTime, autoplay, controls, loop, muted, playsinline, rel]); return createPlayerHTML; }; diff --git a/src/types/iframe.d.ts b/src/types/iframe.d.ts new file mode 100644 index 0000000..5417ab1 --- /dev/null +++ b/src/types/iframe.d.ts @@ -0,0 +1,160 @@ +import type { ERROR_CODES, PlaybackQuality, PlayerState, YouTubeError } from './youtube'; + +declare global { + interface Window { + YT: IframeApiType; + onYouTubeIframeAPIReady: () => void; + _ytApiPromise: Promise; + } +} + +export type EventType = + | 'onReady' + | 'onStateChange' + | 'onPlaybackQualityChange' + | 'onPlaybackRateChange' + | 'onError' + | 'onApiChange' + | 'onAutoplayBlocked'; + +export interface Options { + width?: number | string | undefined; + height?: number | string | undefined; + videoId?: string | undefined; + host?: string | undefined; + playerVars?: + | { + autoplay?: 0 | 1 | undefined; + cc_lang_pref?: string | undefined; + cc_load_policy?: 1 | undefined; + color?: 'red' | 'white' | undefined; + controls?: 0 | 1 | undefined; + disablekb?: 0 | 1 | undefined; + enablejsapi?: 0 | 1 | undefined; + end?: number | undefined; + fs?: 0 | 1 | undefined; + hl?: string | undefined; + iv_load_policy?: 1 | 3 | undefined; + list?: string | undefined; + listType?: 'playlist' | 'search' | 'user_uploads' | undefined; + loop?: 0 | 1 | undefined; + origin?: string | undefined; + playlist?: string | undefined; + playsinline?: 0 | 1 | undefined; + rel?: 0 | 1 | undefined; + start?: number | undefined; + widget_referrer?: string | undefined; + } + | undefined; + events?: + | { + onReady?: (event: CustomEvent) => void; + onStateChange?: (event: { data: PlayerState }) => void; + onPlaybackQualityChange?: (event: { data: PlaybackQuality }) => void; + onPlaybackRateChange?: (event: { data: number }) => void; + onError?: (event: { data: keyof typeof ERROR_CODES }) => void; + onAutoplayBlocked?: (event: CustomEvent) => void; + } + | undefined; +} + +/** + * @see https://developers.google.com/youtube/iframe_api_reference + */ +export interface YouTubePlayer { + addEventListener(event: string, listener: (event: CustomEvent) => void): Promise; + destroy(): Promise; + getAvailablePlaybackRates(): Promise; + getCurrentTime(): Promise; + getDuration(): Promise; + getIframe(): Promise; + getOption(module: string, option: string): Promise; + getOptions(): Promise; + getOptions(module: string): Promise; + setOption(module: string, option: string, value: unknown): Promise; + setOptions(): Promise; + cuePlaylist( + playlist: string | readonly string[], + index?: number, + startSeconds?: number, + suggestedQuality?: string, + ): Promise; + cuePlaylist(playlist: { + listType: string; + list?: string | undefined; + index?: number | undefined; + startSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + loadPlaylist( + playlist: string | readonly string[], + index?: number, + startSeconds?: number, + suggestedQuality?: string, + ): Promise; + loadPlaylist(playlist: { + listType: string; + list?: string | undefined; + index?: number | undefined; + startSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + getPlaylist(): Promise; + getPlaylistIndex(): Promise; + getPlaybackRate(): Promise; + getPlayerState(): Promise; + getVideoEmbedCode(): Promise; + getVideoLoadedFraction(): Promise; + getVideoUrl(): Promise; + getVolume(): Promise; + cueVideoById(videoId: string, startSeconds?: number, endSeconds?: number): Promise; + cueVideoById(video: { + videoId: string; + startSeconds?: number | undefined; + endSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + cueVideoByUrl(mediaContentUrl: string, startSeconds?: number, suggestedQuality?: string): Promise; + cueVideoByUrl(video: { + mediaContentUrl: string; + startSeconds?: number | undefined; + endSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + loadVideoByUrl(mediaContentUrl: string, startSeconds?: number, suggestedQuality?: string): Promise; + loadVideoByUrl(video: { + mediaContentUrl: string; + startSeconds?: number | undefined; + endSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + loadVideoById(videoId: string, startSeconds?: number, endSeconds?: number): Promise; + loadVideoById(video: { + videoId: string; + startSeconds?: number | undefined; + endSeconds?: number | undefined; + suggestedQuality?: string | undefined; + }): Promise; + isMuted(): Promise; + mute(): Promise; + nextVideo(): Promise; + pauseVideo(): Promise; + playVideo(): Promise; + playVideoAt(index: number): Promise; + previousVideo(): Promise; + removeEventListener(event: string, listener: (event: CustomEvent) => void): Promise; + seekTo(seconds: number, allowSeekAhead: boolean): Promise; + setLoop(loopPlaylists: boolean): Promise; + setPlaybackRate(suggestedRate: number): Promise; + setShuffle(shufflePlaylist: boolean): Promise; + setSize(width: number, height: number): Promise<{ width: number; height: number }>; + setVolume(volume: number): Promise; + stopVideo(): Promise; + unMute(): Promise; + on(eventType: 'onStateChange', listener: (event: CustomEvent & { data: number }) => void): void; + on(eventType: EventType, listener: (event: CustomEvent) => void): void; +} + +export interface IframeApiType { + Player: { new (elementId: string, options: Options): YouTubePlayer }; +} diff --git a/src/types/youtube.ts b/src/types/youtube.ts index eee24f9..4ebba34 100644 --- a/src/types/youtube.ts +++ b/src/types/youtube.ts @@ -9,7 +9,6 @@ export type YoutubePlayerVars = { endTime?: number; playsinline?: boolean; rel?: boolean; // 관련 동영상 표시 - modestbranding?: boolean; // YouTube 로고 숨김 origin?: string; // 보안을 위한 origin 설정 }; diff --git a/tsconfig.json b/tsconfig.json index 060c6e9..a5913af 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "react-jsx", - "lib": ["ESNext"], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "moduleResolution": "bundler", "noEmit": true,