From 86729ca2ed1d63dcc2db7612004cb60b90be731c Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 10:14:51 +0900 Subject: [PATCH 1/5] feat!: introduce hooks-based API for v2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: Complete API redesign with React hooks - Replace YoutubePlayer component with YoutubeView + useYouTubePlayer hook - Add useYouTubeEvent hook for reactive event handling - Remove ref-based imperative API in favor of declarative approach - Simplify component props and reduce coupling between components - Follow expo-audio/expo-video patterns for better DX Migration required from v1: - YoutubePlayer → YoutubeView + useYouTubePlayer() - Event props → useYouTubeEvent() hooks - playerRef.current.method() → player.method() Fixes: Memory leaks, complex state management, tight coupling Improves: Developer experience, maintainability, performance --- .changeset/config.json | 2 +- .changeset/funny-moons-hear.md | 25 ++ example/CHANGELOG.md | 47 --- example/src/App.tsx | 216 ++++++------- ...rCore.ts => WebYoutubePlayerController.ts} | 28 +- .../src/WebviewYoutubePlayerController.ts | 176 +++++++++++ packages/core/src/YoutubePlayer.ts | 155 +++++++++ packages/core/src/YoutubePlayerStore.ts | 160 ---------- packages/core/src/index.ts | 11 +- packages/core/src/types/index.ts | 4 +- packages/core/src/types/webview.ts | 3 +- .../src/YoutubePlayer.tsx | 296 ------------------ .../src/YoutubePlayer.web.tsx | 29 -- .../src/YoutubeView.tsx | 176 +++++++++++ .../src/YoutubeView.web.tsx | 92 ++++++ ...ayerWrapper.tsx => YoutubeViewWrapper.tsx} | 6 +- .../src/hooks/youtubeIframeScripts.ts | 35 ++- .../react-native-youtube-bridge/src/index.tsx | 12 +- .../src/types/youtube.ts | 16 +- .../src/utils/youtube.ts | 2 +- packages/react/src/hooks/index.ts | 1 + packages/react/src/hooks/useYouTubeEvent.ts | 114 +++++++ packages/react/src/hooks/useYoutubePlayer.ts | 190 +++-------- packages/react/src/index.ts | 2 +- packages/web/src/YoutubePlayer.tsx | 192 +++++++----- 25 files changed, 1086 insertions(+), 904 deletions(-) create mode 100644 .changeset/funny-moons-hear.md delete mode 100644 example/CHANGELOG.md rename packages/core/src/{YoutubePlayerCore.ts => WebYoutubePlayerController.ts} (92%) create mode 100644 packages/core/src/WebviewYoutubePlayerController.ts create mode 100644 packages/core/src/YoutubePlayer.ts delete mode 100644 packages/core/src/YoutubePlayerStore.ts delete mode 100644 packages/react-native-youtube-bridge/src/YoutubePlayer.tsx delete mode 100644 packages/react-native-youtube-bridge/src/YoutubePlayer.web.tsx create mode 100644 packages/react-native-youtube-bridge/src/YoutubeView.tsx create mode 100644 packages/react-native-youtube-bridge/src/YoutubeView.web.tsx rename packages/react-native-youtube-bridge/src/{YoutubePlayerWrapper.tsx => YoutubeViewWrapper.tsx} (75%) create mode 100644 packages/react/src/hooks/useYouTubeEvent.ts diff --git a/.changeset/config.json b/.changeset/config.json index 2be13d4..a4e70c8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,5 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": ["example"] } diff --git a/.changeset/funny-moons-hear.md b/.changeset/funny-moons-hear.md new file mode 100644 index 0000000..9aa8d94 --- /dev/null +++ b/.changeset/funny-moons-hear.md @@ -0,0 +1,25 @@ +--- +"react-native-youtube-bridge": major +"@react-native-youtube-bridge/react": major +"@react-native-youtube-bridge/core": major +"@react-native-youtube-bridge/web": major +--- + +feat!: introduce hooks-based API for v2.0 + +BREAKING CHANGE: Complete API redesign with React hooks + +- Replace `YoutubePlayer` component with `YoutubeView` + `useYouTubePlayer` hook +- Add `useYouTubeEvent` hook for reactive event handling +- Remove ref-based imperative API in favor of declarative approach +- Simplify component props and reduce coupling between components +- Follow expo patterns for better DX + +Migration required from v1: + +- `YoutubePlayer` → `YoutubeView` + `useYouTubePlayer()` +- Event props → `useYouTubeEvent()` hooks +- `playerRef.current.method()` → `player.method()` + +Fixes: Memory leaks, complex state management, tight coupling +Improves: Developer experience, maintainability, performance diff --git a/example/CHANGELOG.md b/example/CHANGELOG.md deleted file mode 100644 index 4b85d15..0000000 --- a/example/CHANGELOG.md +++ /dev/null @@ -1,47 +0,0 @@ -# example - -## 1.1.4 - -### Patch Changes - -- Updated dependencies [ba12f3d] - - react-native-youtube-bridge@1.1.4 - -## 1.1.3 - -### Patch Changes - -- Updated dependencies [4037df5] - - react-native-youtube-bridge@1.1.3 - -## 1.1.2 - -### Patch Changes - -- Updated dependencies [e0ae7e0] - - react-native-youtube-bridge@1.1.2 - -## 1.1.1 - -### Patch Changes - -- Updated dependencies [4f1513a] - - react-native-youtube-bridge@1.1.1 - -## 1.1.0 - -### Minor Changes - -- 46ce91a: feat(hook): add useYoutubeOEmbed hook for fetching YouTube video metadata - -### Patch Changes - -- Updated dependencies [46ce91a] - - react-native-youtube-bridge@1.1.0 - -## 1.0.1 - -### Patch Changes - -- Updated dependencies [d2b1b03] - - react-native-youtube-bridge@1.0.2 diff --git a/example/src/App.tsx b/example/src/App.tsx index 0d6454f..67fa660 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,12 +1,10 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Alert, Platform, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { - type PlayerControls, - type PlayerInfo, PlayerState, - type ProgressData, - type YouTubeError, - YoutubePlayer, + YoutubeView, + useYouTubeEvent, + useYouTubePlayer, useYoutubeOEmbed, } from 'react-native-youtube-bridge'; @@ -17,12 +15,7 @@ const formatTime = (seconds: number): string => { }; function App() { - const playerRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [loadedFraction, setLoadedFraction] = useState(0); - const [playbackRate, setPlaybackRate] = useState(1); const [availableRates, setAvailableRates] = useState([1]); const [volume, setVolume] = useState(100); const [isMuted, setIsMuted] = useState(false); @@ -30,9 +23,81 @@ function App() { const [progressInterval, setProgressInterval] = useState(1000); const { oEmbed, isLoading, error } = useYoutubeOEmbed(`https://www.youtube.com/watch?v=${videoId}`); - console.log('oEmbed', oEmbed, isLoading, error); + const player = useYouTubePlayer(videoId, { + autoplay: true, + controls: true, + playsinline: true, + rel: false, + muted: true, + }); - const handleReady = useCallback((playerInfo: PlayerInfo) => { + const changePlaybackRate = (rate: number) => { + player.setPlaybackRate(rate); + }; + + const changeVolume = (newVolume: number) => { + player.setVolume(newVolume); + setVolume(newVolume); + }; + + const toggleMute = useCallback(() => { + if (isMuted) { + player.unMute(); + setIsMuted(false); + return; + } + + player.mute(); + setIsMuted(true); + }, [player, isMuted]); + + const onPlay = useCallback(() => { + if (isPlaying) { + player.pause(); + return; + } + + player.play(); + }, [player, isPlaying]); + + const getPlayerInfo = async () => { + const [currentTime, duration, url, state, loaded] = await Promise.all([ + player.getCurrentTime(), + player.getDuration(), + player.getVideoUrl(), + player.getPlayerState(), + player.getVideoLoadedFraction(), + ]); + + console.log( + ` + currentTime: ${currentTime} + duration: ${duration} + url: ${url} + state: ${state} + loaded: ${loaded} + `, + ); + + Alert.alert( + 'Player info', + `Current time: ${formatTime(currentTime || 0)}\n` + + `duration: ${formatTime(duration || 0)}\n` + + `state: ${state}\n` + + `loaded: ${((loaded || 0) * 100).toFixed(1)}%\n` + + `url: ${url || 'N/A'}`, + ); + }; + + const playbackRate = useYouTubeEvent(player, 'playbackRateChange', 1); + const playbackQuality = useYouTubeEvent(player, 'playbackQualityChange'); + const progress = useYouTubeEvent(player, 'progress', progressInterval); + + const currentTime = progress?.currentTime ?? 0; + const duration = progress?.duration ?? 0; + const loadedFraction = progress?.loadedFraction ?? 0; + + useYouTubeEvent(player, 'ready', (playerInfo) => { console.log('Player is ready!'); Alert.alert('Alert', 'YouTube player is ready!'); @@ -51,9 +116,9 @@ function App() { if (playerInfo?.muted !== undefined) { setIsMuted(playerInfo.muted); } - }, []); + }); - const handleStateChange = useCallback((state: PlayerState) => { + useYouTubeEvent(player, 'stateChange', (state) => { console.log('Player state changed:', state); setIsPlaying(state === PlayerState.PLAYING); @@ -77,93 +142,21 @@ function App() { console.log('Video is cued'); break; } - }, []); + }); - const handleProgress = useCallback((progress: ProgressData) => { - setCurrentTime(progress.currentTime); - setDuration(progress.duration); - setLoadedFraction(progress.loadedFraction); - }, []); + useYouTubeEvent(player, 'autoplayBlocked', () => { + console.log('Autoplay was blocked'); + }); - const handleError = useCallback((error: YouTubeError) => { + useYouTubeEvent(player, 'error', (error) => { console.error('Player error:', error); Alert.alert('Error', `Player error (${error.code}): ${error.message}`); - }, []); + }); - const handlePlaybackRateChange = useCallback((rate: number) => { - console.log('Playback rate changed:', rate); - setPlaybackRate(rate); - }, []); - - const handlePlaybackQualityChange = useCallback((quality: string) => { - console.log('Playback quality changed:', quality); - }, []); - - const handleAutoplayBlocked = useCallback(() => { - console.log('Autoplay was blocked'); - }, []); - - const changePlaybackRate = (rate: number) => { - playerRef.current?.setPlaybackRate(rate); - }; - - const changeVolume = (newVolume: number) => { - playerRef.current?.setVolume(newVolume); - setVolume(newVolume); - }; - - const toggleMute = useCallback(() => { - if (isMuted) { - playerRef.current?.unMute(); - setIsMuted(false); - return; - } - - playerRef.current?.mute(); - setIsMuted(true); - }, [isMuted]); - - const onPlay = useCallback(() => { - if (isPlaying) { - playerRef.current?.pause(); - return; - } - - playerRef.current?.play(); - }, [isPlaying]); - - const getPlayerInfo = async () => { - try { - const [currentTime, duration, url, state, loaded] = await Promise.all([ - playerRef.current?.getCurrentTime(), - playerRef.current?.getDuration(), - playerRef.current?.getVideoUrl(), - playerRef.current?.getPlayerState(), - playerRef.current?.getVideoLoadedFraction(), - ]); - - console.log( - ` - currentTime: ${currentTime} - duration: ${duration} - url: ${url} - state: ${state} - loaded: ${loaded} - `, - ); - - Alert.alert( - 'Player info', - `Current time: ${formatTime(currentTime || 0)}\n` + - `duration: ${formatTime(duration || 0)}\n` + - `state: ${state}\n` + - `loaded: ${((loaded || 0) * 100).toFixed(1)}%\n` + - `url: ${url || 'N/A'}`, - ); - } catch (error) { - console.error('Error getting player info:', error); - } - }; + useEffect(() => { + console.log('oEmbed', oEmbed, isLoading, error); + console.log('playbackQuality', playbackQuality); + }, [oEmbed, isLoading, error, playbackQuality]); return ( @@ -173,30 +166,13 @@ function App() { Video ID: {videoId} Playback rate: {playbackRate}x - - playerRef.current?.seekTo(currentTime > 10 ? currentTime - 10 : 0)} + onPress={() => player.seekTo(currentTime > 10 ? currentTime - 10 : 0)} > ⏪ -10s @@ -244,13 +220,13 @@ function App() { {isPlaying ? '⏸️ Pause' : '▶️ Play'} - playerRef.current?.stop()}> + player.stop()}> ⏹️ Stop playerRef.current?.seekTo(currentTime + 10, true)} + onPress={() => player.seekTo(currentTime + 10, true)} > ⏭️ +10s diff --git a/packages/core/src/YoutubePlayerCore.ts b/packages/core/src/WebYoutubePlayerController.ts similarity index 92% rename from packages/core/src/YoutubePlayerCore.ts rename to packages/core/src/WebYoutubePlayerController.ts index 46e02c5..b646b88 100644 --- a/packages/core/src/YoutubePlayerCore.ts +++ b/packages/core/src/WebYoutubePlayerController.ts @@ -7,18 +7,24 @@ type PlayerConfig = Omit & { videoId: string; }; -class YoutubePlayerCore { +class WebYoutubePlayerController { private player: YouTubePlayer | null = null; private progressInterval: NodeJS.Timeout | null = null; private callbacks: PlayerEvents = {}; private progressIntervalMs = 1000; private seekTimeout: NodeJS.Timeout | null = null; - constructor(callbacks: PlayerEvents = {}) { - this.callbacks = callbacks; + private static instance: WebYoutubePlayerController | null = null; + + static getInstance(): WebYoutubePlayerController { + if (!WebYoutubePlayerController.instance) { + WebYoutubePlayerController.instance = new WebYoutubePlayerController(); + } + + return WebYoutubePlayerController.instance; } - static async loadAPI(): Promise { + static async initialize(): Promise { if (typeof window === 'undefined' || window.YT?.Player) { return Promise.resolve(); } @@ -234,6 +240,10 @@ class YoutubePlayerCore { }); } + getPlayer(): YouTubePlayer | null { + return this.player; + } + play(): void { this.player?.playVideo(); } @@ -314,8 +324,8 @@ class YoutubePlayerCore { return playerState ?? PlayerState.UNSTARTED; } - setPlaybackRate(suggestedRate: number): void { - this.player?.setPlaybackRate(suggestedRate); + async setPlaybackRate(suggestedRate: number): Promise { + await this.player?.setPlaybackRate(suggestedRate); } async getVideoLoadedFraction(): Promise { @@ -335,7 +345,7 @@ class YoutubePlayerCore { this.player?.setSize(width, height); } - setProgressInterval(intervalMs: number): void { + updateProgressInterval(intervalMs: number): void { this.progressIntervalMs = intervalMs; if (this.progressInterval) { this.stopProgressTracking(); @@ -369,7 +379,9 @@ class YoutubePlayerCore { } this.player = null; } + + WebYoutubePlayerController.instance = null; } } -export default YoutubePlayerCore; +export default WebYoutubePlayerController; diff --git a/packages/core/src/WebviewYoutubePlayerController.ts b/packages/core/src/WebviewYoutubePlayerController.ts new file mode 100644 index 0000000..20d34af --- /dev/null +++ b/packages/core/src/WebviewYoutubePlayerController.ts @@ -0,0 +1,176 @@ +import type WebView from 'react-native-webview'; +import type { PlayerEvents } from './types'; + +class WebviewYoutubePlayerController { + private webViewRef: React.RefObject; + private commandId = 0; + private pendingCommands: Map void> = new Map(); + private static instance: WebviewYoutubePlayerController | null = null; + + constructor(webViewRef: React.RefObject) { + this.webViewRef = webViewRef; + } + + static getInstance(webViewRef: React.RefObject): WebviewYoutubePlayerController { + if (!WebviewYoutubePlayerController.instance) { + WebviewYoutubePlayerController.instance = new WebviewYoutubePlayerController(webViewRef); + } else { + WebviewYoutubePlayerController.instance.webViewRef = webViewRef; + } + + return WebviewYoutubePlayerController.instance; + } + + getPendingCommands(): Map void> { + return this.pendingCommands; + } + + async play(): Promise { + await this.executeCommand('play'); + } + + async pause(): Promise { + await this.executeCommand('pause'); + } + + async stop(): Promise { + await this.executeCommand('stop'); + } + + async seekTo(seconds: number, allowSeekAhead = true): Promise { + await this.executeCommand('seekTo', [seconds, allowSeekAhead]); + } + + async setVolume(volume: number): Promise { + await this.executeCommand('setVolume', [volume]); + } + + async getVolume(): Promise { + return await this.executeCommand('getVolume', [], true); + } + + async mute(): Promise { + await this.executeCommand('mute'); + } + + async unMute(): Promise { + await this.executeCommand('unMute'); + } + + async isMuted(): Promise { + return await this.executeCommand('isMuted', [], true); + } + + async getCurrentTime(): Promise { + return await this.executeCommand('getCurrentTime', [], true); + } + + async getDuration(): Promise { + return await this.executeCommand('getDuration', [], true); + } + + async getVideoUrl(): Promise { + return await this.executeCommand('getVideoUrl', [], true); + } + + async getVideoEmbedCode(): Promise { + return await this.executeCommand('getVideoEmbedCode', [], true); + } + + async getPlaybackRate(): Promise { + return await this.executeCommand('getPlaybackRate', [], true); + } + + async getAvailablePlaybackRates(): Promise { + return await this.executeCommand('getAvailablePlaybackRates', [], true); + } + + async getPlayerState(): Promise { + return await this.executeCommand('getPlayerState', [], true); + } + + async setPlaybackRate(suggestedRate: number): Promise { + await this.executeCommand('setPlaybackRate', [suggestedRate]); + } + + async getVideoLoadedFraction(): Promise { + return await this.executeCommand('getVideoLoadedFraction', [], true); + } + + async loadVideoById(videoId: string, startSeconds?: number, endSeconds?: number): Promise { + await this.executeCommand('loadVideoById', [videoId, startSeconds, endSeconds]); + } + + async cueVideoById(videoId: string, startSeconds?: number, endSeconds?: number): Promise { + await this.executeCommand('cueVideoById', [videoId, startSeconds, endSeconds]); + } + + async setSize(width: number, height: number): Promise { + await this.executeCommand('setSize', [width, height]); + } + + async cleanup(): Promise { + await this.executeCommand('cleanup'); + } + + async updateProgressInterval(interval: number): Promise { + await this.executeCommand('updateProgressInterval', [interval]); + } + + private executeCommand( + command: string, + args: (string | number | boolean | undefined)[] = [], + needsResult = false, + ): Promise { + return new Promise((resolve) => { + if (!this.webViewRef.current) { + resolve(null); + return; + } + + const messageId = needsResult ? (++this.commandId).toString() : undefined; + + if (needsResult && messageId) { + const timeout = setTimeout(() => { + this.pendingCommands.delete(messageId); + console.warn('Command timeout:', command, messageId); + resolve(null); + }, 5000); + + this.pendingCommands.set(messageId, (result) => { + clearTimeout(timeout); + resolve(result); + }); + } + + const commandData = { + command, + args, + ...(messageId && { id: messageId }), + }; + + const injectScript = /* js */ ` + window.__execCommand && window.__execCommand(${JSON.stringify(commandData)}); true; + `; + + this.webViewRef.current.injectJavaScript(injectScript); + + if (!needsResult) { + resolve(null); + } + }); + } + + updateCallbacks(_newCallbacks: Partial): void { + // no-op only for web + } + + destroy(): void { + this.pendingCommands.clear(); + this.cleanup(); + + WebviewYoutubePlayerController.instance = null; + } +} + +export default WebviewYoutubePlayerController; diff --git a/packages/core/src/YoutubePlayer.ts b/packages/core/src/YoutubePlayer.ts new file mode 100644 index 0000000..3158719 --- /dev/null +++ b/packages/core/src/YoutubePlayer.ts @@ -0,0 +1,155 @@ +import type { YoutubeEventType, YoutubePlayerEvents, YoutubePlayerVars, EventCallback } from './types'; +import type WebviewYoutubePlayerController from './WebviewYoutubePlayerController'; +import type WebYoutubePlayerController from './WebYoutubePlayerController'; + +export const INTERNAL_SET_CONTROLLER_INSTANCE = Symbol('setControllerInstance'); +export const INTERNAL_UPDATE_PROGRESS_INTERVAL = Symbol('updateProgressInterval'); +export const INTERNAL_SET_PROGRESS_INTERVAL = Symbol('setProgressInterval'); + +class YoutubePlayer { + private listeners = new Map>(); + // private eventStates = new Map(); + private controller: WebviewYoutubePlayerController | WebYoutubePlayerController | null = null; + + private progressInterval: number | null = null; + + private videoId: string; + private options: YoutubePlayerVars; + + constructor(videoId: string, options?: YoutubePlayerVars) { + this.videoId = videoId; + this.options = options ?? {}; + } + + getVideoId(): string { + return this.videoId; + } + + getOptions(): YoutubePlayerVars | undefined { + return this.options; + } + + emit(event: T, data: YoutubePlayerEvents[T]) { + const eventListeners = this.listeners.get(event); + + if (eventListeners && eventListeners.size > 0) { + for (const listener of eventListeners) { + listener(data); + } + } + } + + [INTERNAL_SET_CONTROLLER_INSTANCE]( + controller: WebviewYoutubePlayerController | WebYoutubePlayerController | null, + ): void { + this.controller = controller; + } + + [INTERNAL_SET_PROGRESS_INTERVAL](interval: number): void { + if (this.progressInterval !== interval) { + this.progressInterval = interval; + this.controller?.updateProgressInterval(interval); + } + } + + [INTERNAL_UPDATE_PROGRESS_INTERVAL](): void { + if (this.progressInterval) { + this.controller?.updateProgressInterval(this.progressInterval); + } + } + + subscribe(event: T, callback: EventCallback): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + this.listeners.get(event)?.add(callback); + + return () => { + const eventListeners = this.listeners.get(event); + if (eventListeners) { + eventListeners.delete(callback); + + if (eventListeners.size === 0) { + this.listeners.delete(event); + } + } + }; + } + + // getSnapshot(event: T) { + // return this.eventStates.get(event); + // } + + play() { + return this.controller?.play(); + } + pause() { + return this.controller?.pause(); + } + stop() { + return this.controller?.stop(); + } + seekTo(seconds: number, allowSeekAhead?: boolean) { + return this.controller?.seekTo(seconds, allowSeekAhead); + } + setVolume(volume: number) { + return this.controller?.setVolume(volume); + } + getVolume() { + return this.controller?.getVolume(); + } + mute() { + return this.controller?.mute(); + } + unMute() { + return this.controller?.unMute(); + } + isMuted() { + return this.controller?.isMuted(); + } + getCurrentTime() { + return this.controller?.getCurrentTime(); + } + getDuration() { + return this.controller?.getDuration(); + } + getVideoUrl() { + return this.controller?.getVideoUrl(); + } + getVideoEmbedCode() { + return this.controller?.getVideoEmbedCode(); + } + getPlaybackRate() { + return this.controller?.getPlaybackRate(); + } + setPlaybackRate(suggestedRate: number) { + return this.controller?.setPlaybackRate(suggestedRate); + } + getAvailablePlaybackRates() { + return this.controller?.getAvailablePlaybackRates(); + } + getPlayerState() { + return this.controller?.getPlayerState(); + } + getVideoLoadedFraction() { + return this.controller?.getVideoLoadedFraction(); + } + loadVideoById(videoId: string, startSeconds?: number, endSeconds?: number) { + return this.controller?.loadVideoById(videoId, startSeconds, endSeconds); + } + cueVideoById(videoId: string, startSeconds?: number, endSeconds?: number) { + return this.controller?.cueVideoById(videoId, startSeconds, endSeconds); + } + setSize(width: number, height: number) { + return this.controller?.setSize(width, height); + } + destroy() { + this.controller?.destroy(); + this.progressInterval = null; + this.listeners.clear(); + this.controller = null; + } +} + +export default YoutubePlayer; diff --git a/packages/core/src/YoutubePlayerStore.ts b/packages/core/src/YoutubePlayerStore.ts deleted file mode 100644 index 194b4bc..0000000 --- a/packages/core/src/YoutubePlayerStore.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { - PlaybackQuality, - PlayerState, - PlayerEvents, - PlayerInfo, - ProgressData, - YouTubeError, - YoutubeEventType, - YoutubePlayerEvents, -} from './types'; -import YoutubePlayerCore from './YoutubePlayerCore'; - -class YoutubePlayerStore { - private core: YoutubePlayerCore; - private listeners = new Map void>>(); - private eventStates = new Map(); - private registeredCoreEvents = new Set(); - - constructor() { - this.core = new YoutubePlayerCore({}); - } - - private emit(event: T, data: YoutubePlayerEvents[T]) { - this.eventStates.set(event, data); - - // 해당 이벤트를 구독하는 모든 리스너에게 알림 - const eventListeners = this.listeners.get(event); - if (eventListeners) { - for (const listener of eventListeners) { - listener(); - } - } - } - - private createOnReadyCallback = () => (data: PlayerInfo) => { - this.emit('ready', data); - }; - - private createOnStateChangeCallback = () => (state: PlayerState) => { - this.emit('statechange', state); - }; - - private createOnProgressCallback = () => (progress: ProgressData) => { - this.emit('progress', progress); - }; - - private createOnErrorCallback = () => (error: YouTubeError) => { - this.emit('error', error); - }; - - private createOnPlaybackRateChangeCallback = () => (playbackRate: number) => { - this.emit('playbackRateChange', playbackRate); - }; - - private createOnPlaybackQualityChangeCallback = () => (playbackQuality: PlaybackQuality) => { - this.emit('playbackQualityChange', playbackQuality); - }; - - private createOnAutoplayBlockedCallback = () => () => { - this.emit('autoplayBlocked', undefined); - }; - - private getRequiredCoreEvents(event: YoutubeEventType): keyof PlayerEvents { - const requiredCoreEvents: Record = { - ready: 'onReady', - statechange: 'onStateChange', - progress: 'onProgress', - error: 'onError', - playbackRateChange: 'onPlaybackRateChange', - playbackQualityChange: 'onPlaybackQualityChange', - autoplayBlocked: 'onAutoplayBlocked', - }; - - return requiredCoreEvents[event]; - } - - private shouldEnableCoreEvent(coreEvent: keyof PlayerEvents): boolean { - for (const [event, listeners] of this.listeners.entries()) { - if (listeners.size > 0) { - const requiredCoreEvent = this.getRequiredCoreEvents(event); - if (requiredCoreEvent === coreEvent) { - return true; - } - } - } - return false; - } - - private updateCoreEvents() { - const coreEventCallbacks: PlayerEvents = { - onReady: this.createOnReadyCallback(), - onStateChange: this.createOnStateChangeCallback(), - onProgress: this.createOnProgressCallback(), - onError: this.createOnErrorCallback(), - onPlaybackRateChange: this.createOnPlaybackRateChangeCallback(), - onPlaybackQualityChange: this.createOnPlaybackQualityChangeCallback(), - onAutoplayBlocked: this.createOnAutoplayBlockedCallback(), - }; - - const newCallbacks: Partial = {}; - - for (const coreEvent of Object.keys(coreEventCallbacks) as (keyof PlayerEvents)[]) { - const shouldEnable = this.shouldEnableCoreEvent(coreEvent); - - if (shouldEnable && !this.registeredCoreEvents.has(coreEvent)) { - newCallbacks[coreEvent] = coreEventCallbacks[coreEvent] as any; - this.registeredCoreEvents.add(coreEvent); - } else if (!shouldEnable && this.registeredCoreEvents.has(coreEvent)) { - newCallbacks[coreEvent] = undefined; - this.registeredCoreEvents.delete(coreEvent); - } - } - - if (Object.keys(newCallbacks).length > 0) { - this.core.updateCallbacks(newCallbacks); - } - } - - subscribe(event: YoutubeEventType, listener: () => void) { - if (!this.listeners.has(event)) { - this.listeners.set(event, new Set()); - } - - this.listeners.get(event)?.add(listener); - - this.updateCoreEvents(); - - return () => { - const eventListeners = this.listeners.get(event); - if (eventListeners) { - eventListeners.delete(listener); - - if (eventListeners.size === 0) { - this.listeners.delete(event); - } - } - - this.updateCoreEvents(); - }; - } - - getSnapshot(event: T): YoutubePlayerEvents[T] | undefined { - return this.eventStates.get(event) as YoutubePlayerEvents[T] | undefined; - } - - play() { - return this.core.play(); - } - pause() { - return this.core.pause(); - } - seekTo(seconds: number) { - return this.core.seekTo(seconds); - } - setProgressInterval(interval: number) { - return this.core.setProgressInterval(interval); - } -} - -export default YoutubePlayerStore; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c496946..96ba257 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,17 @@ export { type YoutubePlayerConfig, type YouTubeSource, type PlayerControls, + type YoutubePlayerEvents, + type EventCallback, } from './types'; export type { MessageData } from './types/webview'; -export { default as YoutubePlayerCore } from './YoutubePlayerCore'; +export { default as WebYoutubePlayerController } from './WebYoutubePlayerController'; +export { default as WebviewYoutubePlayerController } from './WebviewYoutubePlayerController'; +export { + default as YoutubePlayer, + INTERNAL_SET_CONTROLLER_INSTANCE, + INTERNAL_UPDATE_PROGRESS_INTERVAL, + INTERNAL_SET_PROGRESS_INTERVAL, +} from './YoutubePlayer'; export { escapeHtml, extractVideoIdFromUrl, safeNumber, validateVideoId } from './utils'; export { ERROR_CODES } from './constants'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 3c56996..10da8fb 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -194,7 +194,7 @@ export type PlayerControls = { export type YoutubePlayerEvents = { ready: PlayerInfo; - statechange: PlayerState; + stateChange: PlayerState; error: YouTubeError; progress: ProgressData; playbackRateChange: number; @@ -203,3 +203,5 @@ export type YoutubePlayerEvents = { }; export type YoutubeEventType = keyof YoutubePlayerEvents; + +export type EventCallback = (data: Data) => any; diff --git a/packages/core/src/types/webview.ts b/packages/core/src/types/webview.ts index 136674c..a100b80 100644 --- a/packages/core/src/types/webview.ts +++ b/packages/core/src/types/webview.ts @@ -15,8 +15,9 @@ interface ErrorMessageData { error: YouTubeError; } -interface ProgressMessageData extends ProgressData { +interface ProgressMessageData { type: 'progress'; + progress: ProgressData; } interface PlaybackRateChangeMessageData { diff --git a/packages/react-native-youtube-bridge/src/YoutubePlayer.tsx b/packages/react-native-youtube-bridge/src/YoutubePlayer.tsx deleted file mode 100644 index 94d180b..0000000 --- a/packages/react-native-youtube-bridge/src/YoutubePlayer.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import { type MessageData, type PlayerControls, safeNumber, validateVideoId } from '@react-native-youtube-bridge/core'; -import { useYouTubeVideoId } from '@react-native-youtube-bridge/react'; -import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { type DataDetectorTypes, Dimensions, StyleSheet } from 'react-native'; -import WebView, { type WebViewMessageEvent } from 'react-native-webview'; -import YoutubePlayerWrapper from './YoutubePlayerWrapper'; -import useCreateLocalPlayerHtml from './hooks/useCreateLocalPlayerHtml'; -import type { CommandType } from './types/message'; -import type { YoutubePlayerProps } from './types/youtube'; -import { getYoutubeWebViewUrl } from './utils/youtube'; - -const { width: screenWidth } = Dimensions.get('window'); - -const YoutubePlayer = forwardRef( - ( - { - source, - webViewUrl: webViewBaseUrl, - width = screenWidth, - height = 200, - progressInterval, - useInlineHtml = true, - onReady, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - style, - playerVars = { - startTime: 0, - autoplay: false, - controls: true, - loop: false, - muted: false, - playsinline: true, - rel: false, - }, - webViewStyle, - webViewProps, - }, - ref, - ) => { - const { startTime, endTime } = playerVars; - - const videoId = useYouTubeVideoId(source); - - const webViewRef = useRef(null); - const [isReady, setIsReady] = useState(false); - const commandIdRef = useRef(0); - const pendingCommandsRef = useRef void>>(new Map()); - - const dataDetectorTypes = useMemo(() => ['none'] as DataDetectorTypes[], []); - - const createPlayerHTML = useCreateLocalPlayerHtml({ videoId, useInlineHtml, ...playerVars }); - const webViewUrl = useMemo( - () => getYoutubeWebViewUrl(videoId, useInlineHtml, playerVars, webViewBaseUrl), - [videoId, useInlineHtml, playerVars, webViewBaseUrl], - ); - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - try { - const data = JSON.parse(event.nativeEvent.data) as MessageData; - - if (data.type === 'ready') { - const { playerInfo } = data; - - setIsReady(true); - onReady?.({ - availablePlaybackRates: playerInfo.availablePlaybackRates, - availableQualityLevels: playerInfo.availableQualityLevels, - currentTime: playerInfo.currentTime, - duration: playerInfo.duration, - muted: playerInfo.muted, - playbackQuality: playerInfo.playbackQuality, - playbackRate: playerInfo.playbackRate, - playerState: playerInfo.playerState, - size: playerInfo.size, - volume: playerInfo.volume, - }); - return; - } - - if (data.type === 'stateChange') { - const state = data.state; - onStateChange?.(state); - return; - } - - if (data.type === 'error') { - onError?.(data.error); - return; - } - - if (data.type === 'progress') { - onProgress?.(data); - return; - } - - if (data.type === 'playbackRateChange') { - onPlaybackRateChange?.(data.playbackRate); - return; - } - - if (data.type === 'playbackQualityChange') { - onPlaybackQualityChange?.(data.quality); - return; - } - - if (data.type === 'autoplayBlocked') { - onAutoplayBlocked?.(); - return; - } - - if (data.type === 'commandResult') { - const resolver = pendingCommandsRef.current.get(data.id); - if (resolver) { - resolver(data.result); - pendingCommandsRef.current.delete(data.id); - } - return; - } - } catch (error) { - console.error('Error parsing WebView message:', error); - onError?.({ code: 1000, message: 'FAILED_TO_PARSE_WEBVIEW_MESSAGE' }); - } - }, - [onReady, onStateChange, onError, onProgress, onPlaybackRateChange, onPlaybackQualityChange, onAutoplayBlocked], - ); - - const sendCommand = useCallback( - ( - command: CommandType, - args: (string | number | boolean | undefined)[] = [], - needsResult = false, - // biome-ignore lint/suspicious/noExplicitAny: - ): Promise => { - return new Promise((resolve) => { - if (!webViewRef.current || !isReady) { - resolve(null); - return; - } - - const messageId = needsResult ? (++commandIdRef.current).toString() : undefined; - - if (needsResult && messageId) { - const timeout = setTimeout(() => { - pendingCommandsRef.current.delete(messageId); - console.warn('Command timeout:', command, messageId); - resolve(null); - }, 5000); - - pendingCommandsRef.current.set(messageId, (result) => { - clearTimeout(timeout); - resolve(result); - }); - } - - const commandData = { - command, - args, - ...(messageId && { id: messageId }), - }; - - const injectedJS = /*js*/ ` - window.__execCommand && window.__execCommand(${JSON.stringify(commandData)}); true; - `; - - webViewRef.current?.injectJavaScript(injectedJS); - - if (!needsResult) { - resolve(null); - } - }); - }, - [isReady], - ); - - useEffect(() => { - if (!isReady) { - return; - } - - if (!validateVideoId(videoId)) { - onError?.({ code: 1002, message: 'INVALID_YOUTUBE_VIDEO_ID' }); - return; - } - - sendCommand('loadVideoById', [videoId, startTime, endTime]); - }, [videoId, startTime, endTime, isReady, sendCommand, onError]); - - useImperativeHandle( - ref, - (): PlayerControls => ({ - play: () => sendCommand('play'), - pause: () => sendCommand('pause'), - stop: () => sendCommand('stop'), - seekTo: (seconds: number, allowSeekAhead = true) => sendCommand('seekTo', [seconds, allowSeekAhead]), - - setVolume: (volume: number) => sendCommand('setVolume', [volume]), - getVolume: () => sendCommand('getVolume', [], true), - mute: () => sendCommand('mute'), - unMute: () => sendCommand('unMute'), - isMuted: () => sendCommand('isMuted', [], true), - - getCurrentTime: () => sendCommand('getCurrentTime', [], true), - getDuration: () => sendCommand('getDuration', [], true), - getVideoUrl: () => sendCommand('getVideoUrl', [], true), - getVideoEmbedCode: () => sendCommand('getVideoEmbedCode', [], true), - - getPlaybackRate: () => sendCommand('getPlaybackRate', [], true), - setPlaybackRate: (rate: number) => { - sendCommand('setPlaybackRate', [rate]); - }, - getAvailablePlaybackRates: () => sendCommand('getAvailablePlaybackRates', [], true), - - getPlayerState: () => sendCommand('getPlayerState', [], true), - getVideoLoadedFraction: () => sendCommand('getVideoLoadedFraction', [], true), - - loadVideoById: (videoId: string, startSeconds?: number, endSeconds?: number) => - sendCommand('loadVideoById', [videoId, startSeconds, endSeconds]), - cueVideoById: (videoId: string, startSeconds?: number, endSeconds?: number) => - sendCommand('cueVideoById', [videoId, startSeconds, endSeconds]), - - setSize: (width: number, height: number) => sendCommand('setSize', [width, height]), - }), - [sendCommand], - ); - - useEffect(() => { - return () => { - pendingCommandsRef.current.clear(); - - if (webViewRef.current && isReady) { - sendCommand('cleanup'); - } - }; - }, [isReady, sendCommand]); - - useEffect(() => { - if (isReady) { - const safeInterval = safeNumber(progressInterval); - - sendCommand('updateProgressInterval', [safeInterval]); - } - }, [progressInterval, isReady, sendCommand]); - - return ( - - { - console.error('WebView error:', error); - onError?.({ code: 1001, message: 'WEBVIEW_LOADING_ERROR' }); - }} - /> - - ); - }, -); - -const styles = StyleSheet.create({ - webView: { - flex: 1, - backgroundColor: 'transparent', - }, -}); - -YoutubePlayer.displayName = 'YoutubePlayer'; - -export default YoutubePlayer; diff --git a/packages/react-native-youtube-bridge/src/YoutubePlayer.web.tsx b/packages/react-native-youtube-bridge/src/YoutubePlayer.web.tsx deleted file mode 100644 index 25001e9..0000000 --- a/packages/react-native-youtube-bridge/src/YoutubePlayer.web.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { PlayerControls } from '@react-native-youtube-bridge/core'; -import { useYouTubePlayer, useYouTubeVideoId } from '@react-native-youtube-bridge/react'; -import { forwardRef, useImperativeHandle } from 'react'; -import { useWindowDimensions } from 'react-native'; -import YoutubePlayerWrapper from './YoutubePlayerWrapper'; -import type { YoutubePlayerProps } from './types/youtube'; - -const YoutubePlayer = forwardRef((props, ref) => { - const videoId = useYouTubeVideoId(props.source); - const { containerRef, controls } = useYouTubePlayer({ ...props, videoId }); - const { width: screenWidth } = useWindowDimensions(); - - useImperativeHandle(ref, () => controls, [controls]); - - return ( - -
- - ); -}); - -export default YoutubePlayer; diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.tsx new file mode 100644 index 0000000..0d49a94 --- /dev/null +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -0,0 +1,176 @@ +import { + INTERNAL_SET_CONTROLLER_INSTANCE, + INTERNAL_UPDATE_PROGRESS_INTERVAL, + type MessageData, + WebviewYoutubePlayerController, +} from '@react-native-youtube-bridge/core'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type DataDetectorTypes, Dimensions, StyleSheet } from 'react-native'; +import WebView, { type WebViewMessageEvent } from 'react-native-webview'; +import YoutubeViewWrapper from './YoutubeViewWrapper'; +import useCreateLocalPlayerHtml from './hooks/useCreateLocalPlayerHtml'; +import type { YoutubeViewProps } from './types/youtube'; +import { getYoutubeWebViewUrl } from './utils/youtube'; + +const { width: screenWidth } = Dimensions.get('window'); + +function YoutubeView({ + player, + webViewUrl: webViewBaseUrl, + width = screenWidth, + height = 200, + useInlineHtml = true, + style, + webViewStyle, + webViewProps, +}: YoutubeViewProps) { + const webViewRef = useRef(null); + const playerRef = useRef(null); + + const [isReady, setIsReady] = useState(false); + + const dataDetectorTypes = useMemo(() => ['none'] as DataDetectorTypes[], []); + + const { videoId, playerVars } = useMemo(() => { + return { videoId: player.getVideoId(), playerVars: player.getOptions() || {} }; + }, [player]); + + const createPlayerHTML = useCreateLocalPlayerHtml({ videoId, useInlineHtml, ...playerVars }); + const webViewUrl = useMemo( + () => getYoutubeWebViewUrl(videoId, useInlineHtml, playerVars, webViewBaseUrl), + [videoId, useInlineHtml, playerVars, webViewBaseUrl], + ); + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + try { + const data = JSON.parse(event.nativeEvent.data) as MessageData | undefined | null; + + if (!data) { + return; + } + + if (data.type === 'commandResult') { + const pendingCommands = playerRef.current?.getPendingCommands(); + + const resolver = pendingCommands?.get(data.id); + + if (resolver) { + resolver(data.result); + pendingCommands?.delete(data.id); + } + return; + } + + if (data.type === 'ready') { + setIsReady(true); + player.emit(data.type, data.playerInfo); + return; + } + + if (data.type === 'stateChange') { + player.emit(data.type, data.state); + return; + } + + if (data.type === 'error') { + player.emit(data.type, data.error); + return; + } + + if (data.type === 'progress') { + player.emit(data.type, data.progress); + return; + } + + if (data.type === 'playbackRateChange') { + player.emit(data.type, data.playbackRate); + return; + } + + if (data.type === 'playbackQualityChange') { + player.emit(data.type, data.quality); + return; + } + + if (data.type === 'autoplayBlocked') { + player.emit(data.type, undefined); + return; + } + } catch (error) { + console.error('Error parsing WebView message:', error); + player.emit('error', { code: 1000, message: 'FAILED_TO_PARSE_WEBVIEW_MESSAGE' }); + } + }, + [player], + ); + + useEffect(() => { + if (isReady && webViewRef.current) { + const controller = WebviewYoutubePlayerController.getInstance(webViewRef); + + playerRef.current = controller; + + player[INTERNAL_SET_CONTROLLER_INSTANCE](controller); + player[INTERNAL_UPDATE_PROGRESS_INTERVAL](); + } + + return () => { + if (isReady) { + setIsReady(false); + } + }; + }, [isReady, player]); + + useEffect(() => { + return () => { + if (playerRef.current) { + playerRef.current = null; + } + }; + }, []); + + return ( + + { + console.error('WebView error:', error); + player.emit('error', { code: 1001, message: 'WEBVIEW_LOADING_ERROR' }); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + webView: { + flex: 1, + backgroundColor: 'transparent', + }, +}); + +export default YoutubeView; diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx new file mode 100644 index 0000000..18654ff --- /dev/null +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -0,0 +1,92 @@ +import { + INTERNAL_SET_CONTROLLER_INSTANCE, + INTERNAL_UPDATE_PROGRESS_INTERVAL, + WebYoutubePlayerController, +} from '@react-native-youtube-bridge/core'; +import { useEffect, useRef, useState } from 'react'; +import { useWindowDimensions } from 'react-native'; +import YoutubeViewWrapper from './YoutubeViewWrapper'; +import type { YoutubeViewProps } from './types/youtube'; + +function YoutubeView({ player, height, width, style, iframeStyle }: YoutubeViewProps) { + const { width: screenWidth } = useWindowDimensions(); + + const containerRef = useRef(null); + const playerRef = useRef(null); + + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + WebYoutubePlayerController.initialize().then(() => { + setIsInitialized(true); + }); + }, []); + + useEffect(() => { + if (!isInitialized || !containerRef.current || !player) { + return; + } + + const videoId = player.getVideoId(); + + const containerId = 'youtube-player-container'; + containerRef.current.id = containerId; + const options = player.getOptions(); + const controller = WebYoutubePlayerController.getInstance(); + playerRef.current = controller; + + player[INTERNAL_SET_CONTROLLER_INSTANCE](playerRef.current); + player[INTERNAL_UPDATE_PROGRESS_INTERVAL](); + + playerRef.current?.updateCallbacks({ + onReady: (playerInfo) => { + player.emit('ready', playerInfo); + }, + onStateChange: (state) => { + player.emit('stateChange', state); + }, + onError: (error) => { + player.emit('error', error); + }, + onPlaybackRateChange: (playbackRate) => { + player.emit('playbackRateChange', playbackRate); + }, + onPlaybackQualityChange: (playbackQuality) => { + player.emit('playbackQualityChange', playbackQuality); + }, + onAutoplayBlocked: () => { + player.emit('autoplayBlocked', undefined); + }, + onProgress: (progress) => { + player.emit('progress', progress); + }, + }); + + playerRef.current?.createPlayer(containerId, { + videoId, + ...options, + }); + + return () => { + if (playerRef.current) { + playerRef.current.destroy(); + playerRef.current = null; + } + }; + }, [isInitialized, player]); + + return ( + +
+ + ); +} + +export default YoutubeView; diff --git a/packages/react-native-youtube-bridge/src/YoutubePlayerWrapper.tsx b/packages/react-native-youtube-bridge/src/YoutubeViewWrapper.tsx similarity index 75% rename from packages/react-native-youtube-bridge/src/YoutubePlayerWrapper.tsx rename to packages/react-native-youtube-bridge/src/YoutubeViewWrapper.tsx index 3f39e5a..77437d7 100644 --- a/packages/react-native-youtube-bridge/src/YoutubePlayerWrapper.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeViewWrapper.tsx @@ -1,14 +1,14 @@ import type { ReactNode } from 'react'; import { type DimensionValue, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; -type YoutubePlayerWrapperProps = { +type YoutubeViewWrapperProps = { children: ReactNode; width?: DimensionValue; height?: DimensionValue; style?: StyleProp; }; -function YoutubePlayerWrapper({ children, width, height, style }: YoutubePlayerWrapperProps) { +function YoutubeViewWrapper({ children, width, height, style }: YoutubeViewWrapperProps) { const safeStyles = StyleSheet.flatten([styles.container, { width, height }, style]); return {children}; @@ -21,4 +21,4 @@ const styles = StyleSheet.create({ }, }); -export default YoutubePlayerWrapper; +export default YoutubeViewWrapper; diff --git a/packages/react-native-youtube-bridge/src/hooks/youtubeIframeScripts.ts b/packages/react-native-youtube-bridge/src/hooks/youtubeIframeScripts.ts index 3bcf0e0..ba122a0 100644 --- a/packages/react-native-youtube-bridge/src/hooks/youtubeIframeScripts.ts +++ b/packages/react-native-youtube-bridge/src/hooks/youtubeIframeScripts.ts @@ -22,10 +22,12 @@ const startProgressTracking = /* js */ ` window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'progress', - currentTime, - duration, - percentage, - loadedFraction, + progress: { + currentTime, + duration, + percentage, + loadedFraction, + }, })); } catch (error) { console.error('Progress tracking error:', error); @@ -55,10 +57,12 @@ const sendProgress = /* js */ ` window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'progress', - currentTime, - duration, - percentage, - loadedFraction, + progress: { + currentTime, + duration, + percentage, + loadedFraction, + }, })); } catch (error) { console.error('Final progress error:', error); @@ -72,11 +76,24 @@ const onPlayerReady = /* js */ ` if (isDestroyed) { return; } + + const playerInfo = event.target.playerInfo; try { window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'ready', - playerInfo: event.target.playerInfo + playerInfo: { + availablePlaybackRates: playerInfo.availablePlaybackRates, + availableQualityLevels: playerInfo.availableQualityLevels, + currentTime: playerInfo.currentTime, + duration: playerInfo.duration, + muted: playerInfo.muted, + playbackQuality: playerInfo.playbackQuality, + playbackRate: playerInfo.playbackRate, + playerState: playerInfo.playerState, + size: playerInfo.size, + volume: playerInfo.volume, + } })); startProgressTracking(); } catch (error) { diff --git a/packages/react-native-youtube-bridge/src/index.tsx b/packages/react-native-youtube-bridge/src/index.tsx index a58b125..a28cff0 100644 --- a/packages/react-native-youtube-bridge/src/index.tsx +++ b/packages/react-native-youtube-bridge/src/index.tsx @@ -1,6 +1,4 @@ -import type { PlayerControls } from '@react-native-youtube-bridge/core'; -import type { YoutubePlayerProps } from './types/youtube'; -import YoutubePlayerComponent from './YoutubePlayer'; +import type { YoutubeViewProps } from './types/youtube'; export { ERROR_CODES, @@ -12,10 +10,10 @@ export { type PlayerInfo, type PlayerEvents, type PlayerControls, + type YoutubePlayer, } from '@react-native-youtube-bridge/core'; -export { useYoutubeOEmbed } from '@react-native-youtube-bridge/react'; +export { useYoutubeOEmbed, useYouTubeEvent, useYouTubePlayer } from '@react-native-youtube-bridge/react'; -export const YoutubePlayer: React.ForwardRefExoticComponent> = - YoutubePlayerComponent; +export { default as YoutubeView } from './YoutubeView'; -export type { YoutubePlayerProps }; +export type { YoutubeViewProps }; diff --git a/packages/react-native-youtube-bridge/src/types/youtube.ts b/packages/react-native-youtube-bridge/src/types/youtube.ts index 0e873a2..f8217f6 100644 --- a/packages/react-native-youtube-bridge/src/types/youtube.ts +++ b/packages/react-native-youtube-bridge/src/types/youtube.ts @@ -1,4 +1,4 @@ -import type { PlayerEvents, YouTubeSource, YoutubePlayerVars } from '@react-native-youtube-bridge/core'; +import type { PlayerEvents, YouTubeSource, YoutubePlayer, YoutubePlayerVars } from '@react-native-youtube-bridge/core'; import type { CSSProperties } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import type { WebViewProps } from 'react-native-webview'; @@ -66,3 +66,17 @@ export type YoutubePlayerProps = { */ playerVars?: YoutubePlayerVars; } & PlayerEvents; + +export type YoutubeViewProps = { + player: YoutubePlayer; + width?: number | 'auto' | `${number}%`; + height?: number | 'auto' | `${number}%`; + style?: StyleProp; + iframeStyle?: CSSProperties; + useInlineHtml?: boolean; + webViewUrl?: string; + webViewStyle?: StyleProp; + webViewProps?: Omit & { + source?: Omit; + }; +}; diff --git a/packages/react-native-youtube-bridge/src/utils/youtube.ts b/packages/react-native-youtube-bridge/src/utils/youtube.ts index 619d1a8..618c6ad 100644 --- a/packages/react-native-youtube-bridge/src/utils/youtube.ts +++ b/packages/react-native-youtube-bridge/src/utils/youtube.ts @@ -7,7 +7,7 @@ export const getYoutubeWebViewUrl = ( playerVars: YoutubePlayerVars, webViewBaseUrl?: string, ) => { - if (useInlineHtml) { + if (useInlineHtml || !videoId) { return ''; } diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 73ece6e..172b788 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,3 +1,4 @@ export { default as useYouTubePlayer } from './useYoutubePlayer'; export { default as useYouTubeVideoId } from './useYoutubeVideoId'; export { default as useYoutubeOEmbed } from './useYoutubeOEmbed'; +export { default as useYouTubeEvent } from './useYouTubeEvent'; diff --git a/packages/react/src/hooks/useYouTubeEvent.ts b/packages/react/src/hooks/useYouTubeEvent.ts new file mode 100644 index 0000000..75e60a3 --- /dev/null +++ b/packages/react/src/hooks/useYouTubeEvent.ts @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react'; +import { + INTERNAL_SET_PROGRESS_INTERVAL, + type EventCallback, + type YoutubePlayer, + type YoutubePlayerEvents, +} from '@react-native-youtube-bridge/core'; + +const DEFAULT_PROGRESS_INTERVAL = 1000; + +/** + * @param player - The Youtube player instance. + * @param eventType - The type of event to subscribe to. `progress` event is not supported. + * @returns The event data. + */ +function useYouTubeEvent>( + player: YoutubePlayer, + eventType: T, + defaultValue?: YoutubePlayerEvents[T], +): YoutubePlayerEvents[T] | null; + +/** + * @param player - The Youtube player instance. + * @param eventType - The type of event to subscribe to. + * @param callback - The callback to call when the event is triggered. + * @param deps - The dependencies to watch for changes. + * @returns void + */ +function useYouTubeEvent( + player: YoutubePlayer, + eventType: T, + callback: EventCallback, + deps?: React.DependencyList, +): void; + +/** + * @param player - The Youtube player instance. + * @param eventType - `progress` event only. + * @param throttleMs - The throttle time in milliseconds (default 1000ms). + * @returns The event data. + */ +function useYouTubeEvent( + player: YoutubePlayer, + eventType: 'progress', + throttleMs?: number, +): YoutubePlayerEvents['progress'] | null; + +/** + * @param player - The Youtube player instance. + * @param eventType - The type of event to subscribe to. + * @param callbackOrThrottleOrDefaultValue - The callback to call when the event is triggered. If it is a number, it will be used as the throttle time in milliseconds for `progress` event. + * @param deps - The dependencies to watch for changes. + * @returns The event data. If it is a callback, it will return void. + */ +function useYouTubeEvent( + player: YoutubePlayer, + eventType: T, + callbackOrThrottleOrDefaultValue?: EventCallback | YoutubePlayerEvents[T] | null, + deps?: React.DependencyList, +): YoutubePlayerEvents[T] | null | undefined { + const isProgress = eventType === 'progress'; + const isCallback = typeof callbackOrThrottleOrDefaultValue === 'function'; + const throttleMs = isProgress + ? typeof callbackOrThrottleOrDefaultValue === 'number' + ? callbackOrThrottleOrDefaultValue + : DEFAULT_PROGRESS_INTERVAL + : undefined; + const defaultValue = isCallback || isProgress ? null : (callbackOrThrottleOrDefaultValue ?? null); + + const callbackRef = useRef | null>( + isCallback ? callbackOrThrottleOrDefaultValue : null, + ); + + const [data, setData] = useState(defaultValue); + + useEffect(() => { + if (isCallback) { + callbackRef.current = callbackOrThrottleOrDefaultValue; + } + }, [callbackOrThrottleOrDefaultValue, isCallback, ...(deps ?? [])]); + + useEffect(() => { + if (typeof throttleMs === 'number' && player) { + player[INTERNAL_SET_PROGRESS_INTERVAL](throttleMs); + } + }, [throttleMs, player]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (!player) { + return; + } + + const unsubscribe = player.subscribe(eventType, (eventData) => { + if (isCallback && callbackRef.current) { + callbackRef.current(eventData); + return; + } + + if (!isCallback) { + setData(eventData); + } + }); + + return () => { + setData(defaultValue ?? null); + unsubscribe(); + }; + }, [player, eventType, isCallback]); + + return isCallback ? undefined : data; +} + +export default useYouTubeEvent; diff --git a/packages/react/src/hooks/useYoutubePlayer.ts b/packages/react/src/hooks/useYoutubePlayer.ts index 2da395b..1de1b75 100644 --- a/packages/react/src/hooks/useYoutubePlayer.ts +++ b/packages/react/src/hooks/useYoutubePlayer.ts @@ -1,161 +1,61 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { - ERROR_CODES, - type PlayerControls, - PlayerState, - type YoutubePlayerConfig, - YoutubePlayerCore, + YoutubePlayer, + type YouTubeError, + type YoutubePlayerVars, + type YouTubeSource, } from '@react-native-youtube-bridge/core'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -const useYouTubePlayer = (config: YoutubePlayerConfig) => { - const coreRef = useRef(null); - const containerRef = useRef(null); - const [isReady, setIsReady] = useState(false); - - const { - videoId, - progressInterval = 0, - playerVars = {}, - onReady, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - } = config; - - const { startTime, endTime, autoplay, controls, loop, muted, playsinline, rel, origin } = playerVars; - - const cleanup = useCallback(() => { - coreRef.current?.destroy(); - coreRef.current = null; +import useYouTubeVideoId from './useYoutubeVideoId'; + +/** + * @param source - The source of the Youtube video. + * @param config - The config for the Youtube player. + * @returns The Youtube player instance. + */ +const useYouTubePlayer = (source: YouTubeSource, config?: YoutubePlayerVars): YoutubePlayer => { + const playerRef = useRef(null); + const previousVideoId = useRef(undefined); + const isFastRefresh = useRef(false); + + const onError = useCallback((error: YouTubeError) => { + console.error('Invalid YouTube source: ', error); + playerRef.current?.emit('error', error); }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: initialize only once - useEffect(() => { - if (!coreRef.current) { - coreRef.current = new YoutubePlayerCore({ - onReady: (playerInfo) => { - setIsReady(true); - onReady?.(playerInfo); - }, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - }); - } - return cleanup; - }, []); + const videoId = useYouTubeVideoId(source, onError); - // biome-ignore lint/correctness/useExhaustiveDependencies: update only playerVars - useEffect(() => { - const initialize = async () => { - if (!containerRef.current) { - return; - } + if (playerRef.current == null) { + playerRef.current = new YoutubePlayer(videoId, config); + } - try { - await YoutubePlayerCore.loadAPI(); - const containerId = `youtube-player-${videoId}`; - containerRef.current.id = containerId; + // biome-ignore lint/correctness/useExhaustiveDependencies: only once + const player = useMemo(() => { + let newPlayer = playerRef.current; - coreRef.current?.createPlayer(containerId, { - videoId, - playerVars: { - autoplay, - controls, - loop, - muted, - playsinline, - rel, - startTime, - endTime, - origin, - }, - }); - } catch (error) { - console.error('Failed to create YouTube player:', error); - onError?.({ - code: 1003, - message: ERROR_CODES[1003], - }); - } - }; - - initialize(); - }, [videoId, startTime, endTime, autoplay, controls, loop, muted, playsinline, rel, origin]); - - useEffect(() => { - if (isReady && videoId && coreRef.current) { - try { - coreRef.current.loadVideoById(videoId, startTime, endTime); - } catch (error) { - console.warn('Error loading new video:', error); - } + if (!newPlayer || previousVideoId.current !== videoId) { + playerRef.current?.destroy(); + newPlayer = new YoutubePlayer(videoId, config); + playerRef.current = newPlayer; + previousVideoId.current = videoId; + return newPlayer; } - }, [videoId, isReady, startTime, endTime]); - useEffect(() => { - if (coreRef.current) { - coreRef.current.setProgressInterval(progressInterval); - } - }, [progressInterval]); + isFastRefresh.current = true; + + return newPlayer; + }, [videoId, JSON.stringify(config)]); useEffect(() => { - coreRef.current?.updateCallbacks({ - onReady: (playerInfo) => { - setIsReady(true); - onReady?.(playerInfo); - }, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - }); - }, [onReady, onStateChange, onError, onProgress, onPlaybackRateChange, onPlaybackQualityChange, onAutoplayBlocked]); + isFastRefresh.current = false; - const playerControls = useMemo( - (): PlayerControls => ({ - play: () => coreRef.current?.play(), - pause: () => coreRef.current?.pause(), - stop: () => coreRef.current?.stop(), - seekTo: (seconds: number, allowSeekAhead?: boolean) => - coreRef.current?.seekTo(seconds, allowSeekAhead) ?? Promise.resolve(), - getCurrentTime: () => coreRef.current?.getCurrentTime() ?? Promise.resolve(0), - getDuration: () => coreRef.current?.getDuration() ?? Promise.resolve(0), - setVolume: (volume: number) => coreRef.current?.setVolume(volume), - getVolume: () => coreRef.current?.getVolume() ?? Promise.resolve(0), - mute: () => coreRef.current?.mute(), - unMute: () => coreRef.current?.unMute(), - isMuted: () => coreRef.current?.isMuted() ?? Promise.resolve(false), - getVideoUrl: () => coreRef.current?.getVideoUrl() ?? Promise.resolve(''), - getVideoEmbedCode: () => coreRef.current?.getVideoEmbedCode() ?? Promise.resolve(''), - getPlaybackRate: () => coreRef.current?.getPlaybackRate() ?? Promise.resolve(1), - setPlaybackRate: (rate: number) => coreRef.current?.setPlaybackRate(rate), - getAvailablePlaybackRates: () => coreRef.current?.getAvailablePlaybackRates() ?? Promise.resolve([1]), - getPlayerState: () => coreRef.current?.getPlayerState() ?? Promise.resolve(PlayerState.UNSTARTED), - getVideoLoadedFraction: () => coreRef.current?.getVideoLoadedFraction() ?? Promise.resolve(0), - loadVideoById: (videoId: string, startSeconds?: number, endSeconds?: number) => - coreRef.current?.loadVideoById(videoId, startSeconds, endSeconds), - cueVideoById: (videoId: string, startSeconds?: number, endSeconds?: number) => - coreRef.current?.cueVideoById(videoId, startSeconds, endSeconds), - setSize: (width: number, height: number) => coreRef.current?.setSize(width, height), - }), - [], - ); + return () => { + if (playerRef.current && !isFastRefresh.current) { + playerRef.current?.destroy(); + } + }; + }, []); - return { - containerRef, - controls: playerControls, - isReady, - cleanup, - }; + return player; }; export default useYouTubePlayer; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e896e06..7618d5f 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1 @@ -export { useYouTubePlayer, useYouTubeVideoId, useYoutubeOEmbed } from './hooks'; +export { useYouTubePlayer, useYouTubeVideoId, useYoutubeOEmbed, useYouTubeEvent } from './hooks'; diff --git a/packages/web/src/YoutubePlayer.tsx b/packages/web/src/YoutubePlayer.tsx index ae08c3e..696e944 100644 --- a/packages/web/src/YoutubePlayer.tsx +++ b/packages/web/src/YoutubePlayer.tsx @@ -1,5 +1,7 @@ -import { useYouTubePlayer, useYouTubeVideoId } from '@react-native-youtube-bridge/react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { useYouTubeVideoId } from '@react-native-youtube-bridge/react'; +import { WebYoutubePlayerController } from '@react-native-youtube-bridge/core'; import { useWebView } from './hooks/useWebView'; @@ -18,75 +20,114 @@ function YoutubePlayer() { const rel = urlParams.get('rel') === 'true'; const origin = urlParams.get('origin') ?? ''; - const [progressInterval, setProgressInterval] = useState(0); + const [isInitialized, setIsInitialized] = useState(false); const { sendMessage, onMessage } = useWebView(); const youtubeVideoId = useYouTubeVideoId(videoId); - const { - containerRef, - controls: playerControls, - cleanup, - } = useYouTubePlayer({ - videoId: youtubeVideoId, - progressInterval, - playerVars: { - origin, - controls, - autoplay, - muted, - playsinline, - loop, - rel, - startTime: Number.isNaN(Number(startTime)) ? 0 : Number(startTime), - endTime: Number.isNaN(Number(endTime)) ? undefined : Number(endTime), - }, - onReady: (playerInfo) => { - sendMessage({ - type: 'ready', - playerInfo, - }); - }, - onStateChange: (state) => { - sendMessage({ - type: 'stateChange', - state, - }); - }, - onError: (error) => { - sendMessage({ - type: 'error', - error, - }); - }, - onPlaybackRateChange: (playbackRate) => { - sendMessage({ - type: 'playbackRateChange', - playbackRate, - }); - }, - onPlaybackQualityChange: (playbackQuality) => { - sendMessage({ - type: 'playbackQualityChange', - quality: playbackQuality, - }); - }, - onAutoplayBlocked: () => { - sendMessage({ - type: 'autoplayBlocked', - }); - }, - onProgress: (progress) => { - sendMessage({ - type: 'progress', - currentTime: progress.currentTime, - duration: progress.duration, - percentage: progress.percentage, - loadedFraction: progress.loadedFraction, - }); - }, - }); + const startTimeNumber = Number.isNaN(Number(startTime)) ? 0 : Number(startTime); + const endTimeNumber = Number.isNaN(Number(endTime)) ? undefined : Number(endTime); + + const containerRef = useRef(null); + const playerRef = useRef(null); + + useEffect(() => { + WebYoutubePlayerController.initialize().then(() => { + setIsInitialized(true); + + const controller = WebYoutubePlayerController.getInstance(); + playerRef.current = controller; + }); + }, []); + + useEffect(() => { + if (!isInitialized || !containerRef.current) { + return; + } + + const containerId = 'youtube-player-container'; + containerRef.current.id = containerId; + + playerRef.current?.updateCallbacks({ + onReady: (playerInfo) => { + sendMessage({ + type: 'ready', + playerInfo, + }); + }, + onStateChange: (state) => { + sendMessage({ + type: 'stateChange', + state, + }); + }, + onError: (error) => { + sendMessage({ + type: 'error', + error, + }); + }, + onPlaybackRateChange: (playbackRate) => { + sendMessage({ + type: 'playbackRateChange', + playbackRate, + }); + }, + onPlaybackQualityChange: (playbackQuality) => { + sendMessage({ + type: 'playbackQualityChange', + quality: playbackQuality, + }); + }, + onAutoplayBlocked: () => { + sendMessage({ + type: 'autoplayBlocked', + }); + }, + onProgress: (progress) => { + sendMessage({ + type: 'progress', + progress, + }); + }, + }); + + playerRef.current?.createPlayer(containerId, { + videoId: youtubeVideoId, + playerVars: { + origin, + controls, + autoplay, + muted, + playsinline, + loop, + rel, + startTime: startTimeNumber, + endTime: endTimeNumber, + }, + }); + + return () => { + if (playerRef.current) { + playerRef.current?.destroy(); + playerRef.current = null; + } + }; + }, [ + sendMessage, + isInitialized, + youtubeVideoId, + origin, + controls, + autoplay, + muted, + playsinline, + loop, + rel, + startTimeNumber, + endTimeNumber, + ]); useEffect(() => { onMessage((message) => { @@ -95,17 +136,22 @@ function YoutubePlayer() { const interval = Number(args[0]) > 0 ? Number(args[0]) : 0; - setProgressInterval(interval); + playerRef.current?.updateProgressInterval(interval); + return; + } + + if (!playerRef.current) { return; } if (message.command === 'cleanup') { - cleanup(); + playerRef.current?.destroy(); + playerRef.current = null; return; } - if (message.command in playerControls) { - const command = playerControls[message.command]; + if (message.command in playerRef.current) { + const command = playerRef.current[message.command]; const args = message.args || []; if (typeof command !== 'function') { @@ -128,11 +174,11 @@ function YoutubePlayer() { return; } - playerControls.setVolume(volume); + playerRef.current?.setVolume(volume); return; } - const result = (command as (...args: unknown[]) => unknown)(...args); + const result = (command as (...args: unknown[]) => unknown).apply(playerRef.current, args); if (result instanceof Promise) { result @@ -172,7 +218,7 @@ function YoutubePlayer() { } } }); - }, [onMessage, playerControls, cleanup]); + }, [onMessage]); return (
From 7998f7da5b33954788cf7bc786e3e2f83a450715 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 14:09:22 +0900 Subject: [PATCH 2/5] refactor: move code that doesn't belong in the core package --- packages/core/src/index.ts | 7 ------- packages/core/src/types/index.ts | 2 -- .../react-native-youtube-bridge/src/YoutubeView.tsx | 10 ++++------ .../src/YoutubeView.web.tsx | 8 +++----- .../src/hooks/useCreateLocalPlayerHtml.ts | 2 +- .../src/hooks/useYouTubeEvent.ts | 10 ++++------ .../src/hooks/useYouTubePlayer.ts} | 11 ++++------- packages/react-native-youtube-bridge/src/index.tsx | 6 ++++-- .../src/modules}/WebviewYoutubePlayerController.ts | 2 +- .../src/modules}/YoutubePlayer.ts | 11 +++++++++-- .../react-native-youtube-bridge/src/types/youtube.ts | 3 ++- packages/react/src/hooks/index.ts | 2 -- packages/react/src/index.ts | 2 +- 13 files changed, 33 insertions(+), 43 deletions(-) rename packages/{react => react-native-youtube-bridge}/src/hooks/useYouTubeEvent.ts (93%) rename packages/{react/src/hooks/useYoutubePlayer.ts => react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts} (87%) rename packages/{core/src => react-native-youtube-bridge/src/modules}/WebviewYoutubePlayerController.ts (98%) rename packages/{core/src => react-native-youtube-bridge/src/modules}/YoutubePlayer.ts (95%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 96ba257..664a34b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,12 +14,5 @@ export { } from './types'; export type { MessageData } from './types/webview'; export { default as WebYoutubePlayerController } from './WebYoutubePlayerController'; -export { default as WebviewYoutubePlayerController } from './WebviewYoutubePlayerController'; -export { - default as YoutubePlayer, - INTERNAL_SET_CONTROLLER_INSTANCE, - INTERNAL_UPDATE_PROGRESS_INTERVAL, - INTERNAL_SET_PROGRESS_INTERVAL, -} from './YoutubePlayer'; export { escapeHtml, extractVideoIdFromUrl, safeNumber, validateVideoId } from './utils'; export { ERROR_CODES } from './constants'; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 10da8fb..797bccf 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -202,6 +202,4 @@ export type YoutubePlayerEvents = { autoplayBlocked: undefined; }; -export type YoutubeEventType = keyof YoutubePlayerEvents; - export type EventCallback = (data: Data) => any; diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.tsx index 0d49a94..0195839 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -1,16 +1,14 @@ -import { - INTERNAL_SET_CONTROLLER_INSTANCE, - INTERNAL_UPDATE_PROGRESS_INTERVAL, - type MessageData, - WebviewYoutubePlayerController, -} from '@react-native-youtube-bridge/core'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type DataDetectorTypes, Dimensions, StyleSheet } from 'react-native'; import WebView, { type WebViewMessageEvent } from 'react-native-webview'; +import type { MessageData } from '@react-native-youtube-bridge/core'; + import YoutubeViewWrapper from './YoutubeViewWrapper'; import useCreateLocalPlayerHtml from './hooks/useCreateLocalPlayerHtml'; import type { YoutubeViewProps } from './types/youtube'; import { getYoutubeWebViewUrl } from './utils/youtube'; +import WebviewYoutubePlayerController from './modules/WebviewYoutubePlayerController'; +import { INTERNAL_SET_CONTROLLER_INSTANCE, INTERNAL_UPDATE_PROGRESS_INTERVAL } from './modules/YoutubePlayer'; const { width: screenWidth } = Dimensions.get('window'); diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx index 18654ff..316943e 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -1,10 +1,8 @@ -import { - INTERNAL_SET_CONTROLLER_INSTANCE, - INTERNAL_UPDATE_PROGRESS_INTERVAL, - WebYoutubePlayerController, -} from '@react-native-youtube-bridge/core'; import { useEffect, useRef, useState } from 'react'; import { useWindowDimensions } from 'react-native'; +import { WebYoutubePlayerController } from '@react-native-youtube-bridge/core'; + +import { INTERNAL_SET_CONTROLLER_INSTANCE, INTERNAL_UPDATE_PROGRESS_INTERVAL } from './modules/YoutubePlayer'; import YoutubeViewWrapper from './YoutubeViewWrapper'; import type { YoutubeViewProps } from './types/youtube'; diff --git a/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts b/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts index 1091efc..0eae4e1 100644 --- a/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useCreateLocalPlayerHtml.ts @@ -1,5 +1,5 @@ -import { type YoutubePlayerVars, escapeHtml, safeNumber, validateVideoId } from '@react-native-youtube-bridge/core'; import { useCallback } from 'react'; +import { type YoutubePlayerVars, escapeHtml, safeNumber, validateVideoId } from '@react-native-youtube-bridge/core'; import { youtubeIframeScripts } from './youtubeIframeScripts'; diff --git a/packages/react/src/hooks/useYouTubeEvent.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts similarity index 93% rename from packages/react/src/hooks/useYouTubeEvent.ts rename to packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts index 75e60a3..cfdb4c8 100644 --- a/packages/react/src/hooks/useYouTubeEvent.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts @@ -1,10 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import { - INTERNAL_SET_PROGRESS_INTERVAL, - type EventCallback, - type YoutubePlayer, - type YoutubePlayerEvents, -} from '@react-native-youtube-bridge/core'; +import type { EventCallback, YoutubePlayerEvents } from '@react-native-youtube-bridge/core'; + +import type YoutubePlayer from '../modules/YoutubePlayer'; +import { INTERNAL_SET_PROGRESS_INTERVAL } from '../modules/YoutubePlayer'; const DEFAULT_PROGRESS_INTERVAL = 1000; diff --git a/packages/react/src/hooks/useYoutubePlayer.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts similarity index 87% rename from packages/react/src/hooks/useYoutubePlayer.ts rename to packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts index 1de1b75..077df32 100644 --- a/packages/react/src/hooks/useYoutubePlayer.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts @@ -1,11 +1,8 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { - YoutubePlayer, - type YouTubeError, - type YoutubePlayerVars, - type YouTubeSource, -} from '@react-native-youtube-bridge/core'; -import useYouTubeVideoId from './useYoutubeVideoId'; +import type { YouTubeError, YoutubePlayerVars, YouTubeSource } from '@react-native-youtube-bridge/core'; +import { useYouTubeVideoId } from '@react-native-youtube-bridge/react'; + +import YoutubePlayer from '../modules/YoutubePlayer'; /** * @param source - The source of the Youtube video. diff --git a/packages/react-native-youtube-bridge/src/index.tsx b/packages/react-native-youtube-bridge/src/index.tsx index a28cff0..bb13e40 100644 --- a/packages/react-native-youtube-bridge/src/index.tsx +++ b/packages/react-native-youtube-bridge/src/index.tsx @@ -10,10 +10,12 @@ export { type PlayerInfo, type PlayerEvents, type PlayerControls, - type YoutubePlayer, } from '@react-native-youtube-bridge/core'; -export { useYoutubeOEmbed, useYouTubeEvent, useYouTubePlayer } from '@react-native-youtube-bridge/react'; +export type { default as YoutubePlayer } from './modules/YoutubePlayer'; +export { useYoutubeOEmbed } from '@react-native-youtube-bridge/react'; +export { default as useYouTubeEvent } from './hooks/useYouTubeEvent'; +export { default as useYouTubePlayer } from './hooks/useYouTubePlayer'; export { default as YoutubeView } from './YoutubeView'; export type { YoutubeViewProps }; diff --git a/packages/core/src/WebviewYoutubePlayerController.ts b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts similarity index 98% rename from packages/core/src/WebviewYoutubePlayerController.ts rename to packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts index 20d34af..569af00 100644 --- a/packages/core/src/WebviewYoutubePlayerController.ts +++ b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts @@ -1,5 +1,5 @@ import type WebView from 'react-native-webview'; -import type { PlayerEvents } from './types'; +import type { PlayerEvents } from '@react-native-youtube-bridge/core'; class WebviewYoutubePlayerController { private webViewRef: React.RefObject; diff --git a/packages/core/src/YoutubePlayer.ts b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts similarity index 95% rename from packages/core/src/YoutubePlayer.ts rename to packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts index 3158719..4171669 100644 --- a/packages/core/src/YoutubePlayer.ts +++ b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts @@ -1,11 +1,18 @@ -import type { YoutubeEventType, YoutubePlayerEvents, YoutubePlayerVars, EventCallback } from './types'; +import type { + YoutubePlayerEvents, + YoutubePlayerVars, + EventCallback, + WebYoutubePlayerController, +} from '@react-native-youtube-bridge/core'; + import type WebviewYoutubePlayerController from './WebviewYoutubePlayerController'; -import type WebYoutubePlayerController from './WebYoutubePlayerController'; export const INTERNAL_SET_CONTROLLER_INSTANCE = Symbol('setControllerInstance'); export const INTERNAL_UPDATE_PROGRESS_INTERVAL = Symbol('updateProgressInterval'); export const INTERNAL_SET_PROGRESS_INTERVAL = Symbol('setProgressInterval'); +type YoutubeEventType = keyof YoutubePlayerEvents; + class YoutubePlayer { private listeners = new Map>(); // private eventStates = new Map(); diff --git a/packages/react-native-youtube-bridge/src/types/youtube.ts b/packages/react-native-youtube-bridge/src/types/youtube.ts index f8217f6..dd80dd7 100644 --- a/packages/react-native-youtube-bridge/src/types/youtube.ts +++ b/packages/react-native-youtube-bridge/src/types/youtube.ts @@ -1,8 +1,9 @@ -import type { PlayerEvents, YouTubeSource, YoutubePlayer, YoutubePlayerVars } from '@react-native-youtube-bridge/core'; +import type { PlayerEvents, YouTubeSource, YoutubePlayerVars } from '@react-native-youtube-bridge/core'; import type { CSSProperties } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import type { WebViewProps } from 'react-native-webview'; import type { WebViewSourceUri } from 'react-native-webview/lib/WebViewTypes'; +import type YoutubePlayer from '../modules/YoutubePlayer'; export type YoutubePlayerProps = { /** diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 172b788..b395be3 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,4 +1,2 @@ -export { default as useYouTubePlayer } from './useYoutubePlayer'; export { default as useYouTubeVideoId } from './useYoutubeVideoId'; export { default as useYoutubeOEmbed } from './useYoutubeOEmbed'; -export { default as useYouTubeEvent } from './useYouTubeEvent'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 7618d5f..5b653d5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1 @@ -export { useYouTubePlayer, useYouTubeVideoId, useYoutubeOEmbed, useYouTubeEvent } from './hooks'; +export { useYouTubeVideoId, useYoutubeOEmbed } from './hooks'; From 0f4bf256a94a9cc30364f4d0c1c8aa71c1c3c752 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 14:25:26 +0900 Subject: [PATCH 3/5] refactor: suggested codereview --- .../src/YoutubeView.web.tsx | 2 +- .../src/hooks/useYouTubeEvent.ts | 2 +- .../src/hooks/useYouTubePlayer.ts | 4 ++-- .../modules/WebviewYoutubePlayerController.ts | 4 ++-- .../src/modules/YoutubePlayer.ts | 4 ++-- packages/web/src/YoutubePlayer.tsx | 23 +++++++++++-------- packages/web/src/utils.ts | 8 +++++++ 7 files changed, 29 insertions(+), 18 deletions(-) create mode 100644 packages/web/src/utils.ts diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx index 316943e..98225d1 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -27,7 +27,7 @@ function YoutubeView({ player, height, width, style, iframeStyle }: YoutubeViewP const videoId = player.getVideoId(); - const containerId = 'youtube-player-container'; + const containerId = `youtube-player-${videoId}`; containerRef.current.id = containerId; const options = player.getOptions(); const controller = WebYoutubePlayerController.getInstance(); diff --git a/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts index cfdb4c8..b31dd4e 100644 --- a/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts @@ -83,7 +83,7 @@ function useYouTubeEvent( } }, [throttleMs, player]); - // biome-ignore lint/correctness/useExhaustiveDependencies: + // biome-ignore lint/correctness/useExhaustiveDependencies: defaultValue is intentionally excluded to prevent unnecessary re-subscriptions useEffect(() => { if (!player) { return; diff --git a/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts index 077df32..ccec8fe 100644 --- a/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts @@ -25,7 +25,7 @@ const useYouTubePlayer = (source: YouTubeSource, config?: YoutubePlayerVars): Yo playerRef.current = new YoutubePlayer(videoId, config); } - // biome-ignore lint/correctness/useExhaustiveDependencies: only once + // biome-ignore lint/correctness/useExhaustiveDependencies: videoId changes trigger re-creation const player = useMemo(() => { let newPlayer = playerRef.current; @@ -40,7 +40,7 @@ const useYouTubePlayer = (source: YouTubeSource, config?: YoutubePlayerVars): Yo isFastRefresh.current = true; return newPlayer; - }, [videoId, JSON.stringify(config)]); + }, [videoId]); useEffect(() => { isFastRefresh.current = false; diff --git a/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts index 569af00..8161b07 100644 --- a/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts +++ b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts @@ -165,9 +165,9 @@ class WebviewYoutubePlayerController { // no-op only for web } - destroy(): void { + async destroy(): Promise { this.pendingCommands.clear(); - this.cleanup(); + await this.cleanup(); WebviewYoutubePlayerController.instance = null; } diff --git a/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts index 4171669..ecfc8ef 100644 --- a/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts +++ b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts @@ -152,10 +152,10 @@ class YoutubePlayer { return this.controller?.setSize(width, height); } destroy() { - this.controller?.destroy(); - this.progressInterval = null; this.listeners.clear(); + this.controller?.destroy(); this.controller = null; + this.progressInterval = null; } } diff --git a/packages/web/src/YoutubePlayer.tsx b/packages/web/src/YoutubePlayer.tsx index 696e944..f5a2cce 100644 --- a/packages/web/src/YoutubePlayer.tsx +++ b/packages/web/src/YoutubePlayer.tsx @@ -4,6 +4,7 @@ import { useYouTubeVideoId } from '@react-native-youtube-bridge/react'; import { WebYoutubePlayerController } from '@react-native-youtube-bridge/core'; import { useWebView } from './hooks/useWebView'; +import { parseTimeParam } from './utils'; import './YoutubePlayer.css'; @@ -26,8 +27,8 @@ function YoutubePlayer() { const youtubeVideoId = useYouTubeVideoId(videoId); - const startTimeNumber = Number.isNaN(Number(startTime)) ? 0 : Number(startTime); - const endTimeNumber = Number.isNaN(Number(endTime)) ? undefined : Number(endTime); + const startTimeNumber = parseTimeParam(startTime, 0); + const endTimeNumber = parseTimeParam(endTime); const containerRef = useRef(null); const playerRef = useRef(null); @@ -46,7 +47,7 @@ function YoutubePlayer() { return; } - const containerId = 'youtube-player-container'; + const containerId = `youtube-player-${youtubeVideoId}`; containerRef.current.id = containerId; playerRef.current?.updateCallbacks({ @@ -107,13 +108,6 @@ function YoutubePlayer() { endTime: endTimeNumber, }, }); - - return () => { - if (playerRef.current) { - playerRef.current?.destroy(); - playerRef.current = null; - } - }; }, [ sendMessage, isInitialized, @@ -129,6 +123,15 @@ function YoutubePlayer() { endTimeNumber, ]); + useEffect(() => { + return () => { + if (playerRef.current) { + playerRef.current?.destroy(); + playerRef.current = null; + } + }; + }, []); + useEffect(() => { onMessage((message) => { if (message.command === 'updateProgressInterval') { diff --git a/packages/web/src/utils.ts b/packages/web/src/utils.ts new file mode 100644 index 0000000..7a93915 --- /dev/null +++ b/packages/web/src/utils.ts @@ -0,0 +1,8 @@ +export const parseTimeParam = (time: string | null, defaultValue?: number): number | undefined => { + if (!time) { + return defaultValue; + } + + const parsed = Number(time); + return Number.isNaN(parsed) ? defaultValue : parsed; +}; From 7ca24fae2b321b0937474e12a9f11474bc663bed Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 15:04:36 +0900 Subject: [PATCH 4/5] fix: remove singleton pattern to support multiple instances - Replace getInstance() with createInstance() factory method - Remove static instance property and related cleanup - Each YoutubePlayer now gets its own controller instance - Fixes WebView reference conflicts when multiple players exist --- .../core/src/WebYoutubePlayerController.ts | 14 +++----------- .../src/YoutubeView.tsx | 2 +- .../src/YoutubeView.web.tsx | 2 +- .../modules/WebviewYoutubePlayerController.ts | 18 +++++++----------- packages/web/src/YoutubePlayer.tsx | 2 +- 5 files changed, 13 insertions(+), 25 deletions(-) diff --git a/packages/core/src/WebYoutubePlayerController.ts b/packages/core/src/WebYoutubePlayerController.ts index b646b88..41f64c1 100644 --- a/packages/core/src/WebYoutubePlayerController.ts +++ b/packages/core/src/WebYoutubePlayerController.ts @@ -14,14 +14,8 @@ class WebYoutubePlayerController { private progressIntervalMs = 1000; private seekTimeout: NodeJS.Timeout | null = null; - private static instance: WebYoutubePlayerController | null = null; - - static getInstance(): WebYoutubePlayerController { - if (!WebYoutubePlayerController.instance) { - WebYoutubePlayerController.instance = new WebYoutubePlayerController(); - } - - return WebYoutubePlayerController.instance; + static createInstance(): WebYoutubePlayerController { + return new WebYoutubePlayerController(); } static async initialize(): Promise { @@ -55,7 +49,7 @@ class WebYoutubePlayerController { return; } - window.window.onYouTubeIframeAPIReady = () => { + window.onYouTubeIframeAPIReady = () => { resolve(); }; @@ -379,8 +373,6 @@ class WebYoutubePlayerController { } this.player = null; } - - WebYoutubePlayerController.instance = null; } } diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.tsx index 0195839..4497b2a 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -105,7 +105,7 @@ function YoutubeView({ useEffect(() => { if (isReady && webViewRef.current) { - const controller = WebviewYoutubePlayerController.getInstance(webViewRef); + const controller = WebviewYoutubePlayerController.createInstance(webViewRef); playerRef.current = controller; diff --git a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx index 98225d1..c4b8994 100644 --- a/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -30,7 +30,7 @@ function YoutubeView({ player, height, width, style, iframeStyle }: YoutubeViewP const containerId = `youtube-player-${videoId}`; containerRef.current.id = containerId; const options = player.getOptions(); - const controller = WebYoutubePlayerController.getInstance(); + const controller = WebYoutubePlayerController.createInstance(); playerRef.current = controller; player[INTERNAL_SET_CONTROLLER_INSTANCE](playerRef.current); diff --git a/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts index 8161b07..582e57c 100644 --- a/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts +++ b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts @@ -5,20 +5,13 @@ class WebviewYoutubePlayerController { private webViewRef: React.RefObject; private commandId = 0; private pendingCommands: Map void> = new Map(); - private static instance: WebviewYoutubePlayerController | null = null; constructor(webViewRef: React.RefObject) { this.webViewRef = webViewRef; } - static getInstance(webViewRef: React.RefObject): WebviewYoutubePlayerController { - if (!WebviewYoutubePlayerController.instance) { - WebviewYoutubePlayerController.instance = new WebviewYoutubePlayerController(webViewRef); - } else { - WebviewYoutubePlayerController.instance.webViewRef = webViewRef; - } - - return WebviewYoutubePlayerController.instance; + static createInstance(webViewRef: React.RefObject): WebviewYoutubePlayerController { + return new WebviewYoutubePlayerController(webViewRef); } getPendingCommands(): Map void> { @@ -161,6 +154,11 @@ class WebviewYoutubePlayerController { }); } + /** + * Updates player event callbacks. No-op in WebView implementation. + * This method exists for interface compatibility with web implementation. + * @param _newCallbacks - Event callbacks (ignored in WebView) + */ updateCallbacks(_newCallbacks: Partial): void { // no-op only for web } @@ -168,8 +166,6 @@ class WebviewYoutubePlayerController { async destroy(): Promise { this.pendingCommands.clear(); await this.cleanup(); - - WebviewYoutubePlayerController.instance = null; } } diff --git a/packages/web/src/YoutubePlayer.tsx b/packages/web/src/YoutubePlayer.tsx index f5a2cce..57404aa 100644 --- a/packages/web/src/YoutubePlayer.tsx +++ b/packages/web/src/YoutubePlayer.tsx @@ -37,7 +37,7 @@ function YoutubePlayer() { WebYoutubePlayerController.initialize().then(() => { setIsInitialized(true); - const controller = WebYoutubePlayerController.getInstance(); + const controller = WebYoutubePlayerController.createInstance(); playerRef.current = controller; }); }, []); From cdf7bdd0edebd38cd18297b6cdcc03c4ebcc8c89 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Tue, 15 Jul 2025 15:06:01 +0900 Subject: [PATCH 5/5] refactor: useYouTubeEvent initial state --- .../src/hooks/useYouTubeEvent.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts index b31dd4e..dd45c96 100644 --- a/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts @@ -58,12 +58,27 @@ function useYouTubeEvent( ): YoutubePlayerEvents[T] | null | undefined { const isProgress = eventType === 'progress'; const isCallback = typeof callbackOrThrottleOrDefaultValue === 'function'; - const throttleMs = isProgress - ? typeof callbackOrThrottleOrDefaultValue === 'number' + + const getThrottleMs = (): number | undefined => { + if (!isProgress) { + return undefined; + } + + return typeof callbackOrThrottleOrDefaultValue === 'number' ? callbackOrThrottleOrDefaultValue - : DEFAULT_PROGRESS_INTERVAL - : undefined; - const defaultValue = isCallback || isProgress ? null : (callbackOrThrottleOrDefaultValue ?? null); + : DEFAULT_PROGRESS_INTERVAL; + }; + + const getDefaultValue = () => { + if (isCallback || isProgress) { + return null; + } + + return callbackOrThrottleOrDefaultValue ?? null; + }; + + const throttleMs = getThrottleMs(); + const defaultValue = getDefaultValue(); const callbackRef = useRef | null>( isCallback ? callbackOrThrottleOrDefaultValue : null,