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 94% rename from packages/core/src/YoutubePlayerCore.ts rename to packages/core/src/WebYoutubePlayerController.ts index 46e02c5..41f64c1 100644 --- a/packages/core/src/YoutubePlayerCore.ts +++ b/packages/core/src/WebYoutubePlayerController.ts @@ -7,18 +7,18 @@ 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; + static createInstance(): WebYoutubePlayerController { + return new WebYoutubePlayerController(); } - static async loadAPI(): Promise { + static async initialize(): Promise { if (typeof window === 'undefined' || window.YT?.Player) { return Promise.resolve(); } @@ -49,7 +49,7 @@ class YoutubePlayerCore { return; } - window.window.onYouTubeIframeAPIReady = () => { + window.onYouTubeIframeAPIReady = () => { resolve(); }; @@ -234,6 +234,10 @@ class YoutubePlayerCore { }); } + getPlayer(): YouTubePlayer | null { + return this.player; + } + play(): void { this.player?.playVideo(); } @@ -314,8 +318,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 +339,7 @@ class YoutubePlayerCore { this.player?.setSize(width, height); } - setProgressInterval(intervalMs: number): void { + updateProgressInterval(intervalMs: number): void { this.progressIntervalMs = intervalMs; if (this.progressInterval) { this.stopProgressTracking(); @@ -372,4 +376,4 @@ class YoutubePlayerCore { } } -export default YoutubePlayerCore; +export default WebYoutubePlayerController; 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..664a34b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,10 @@ 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 { 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..797bccf 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; @@ -202,4 +202,4 @@ export type YoutubePlayerEvents = { autoplayBlocked: undefined; }; -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..4497b2a --- /dev/null +++ b/packages/react-native-youtube-bridge/src/YoutubeView.tsx @@ -0,0 +1,174 @@ +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'); + +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.createInstance(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..c4b8994 --- /dev/null +++ b/packages/react-native-youtube-bridge/src/YoutubeView.web.tsx @@ -0,0 +1,90 @@ +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'; + +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-${videoId}`; + containerRef.current.id = containerId; + const options = player.getOptions(); + const controller = WebYoutubePlayerController.createInstance(); + 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/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-native-youtube-bridge/src/hooks/useYouTubeEvent.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts new file mode 100644 index 0000000..dd45c96 --- /dev/null +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubeEvent.ts @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react'; +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; + +/** + * @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 getThrottleMs = (): number | undefined => { + if (!isProgress) { + return undefined; + } + + return typeof callbackOrThrottleOrDefaultValue === 'number' + ? callbackOrThrottleOrDefaultValue + : 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, + ); + + 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: defaultValue is intentionally excluded to prevent unnecessary re-subscriptions + 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-native-youtube-bridge/src/hooks/useYouTubePlayer.ts b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts new file mode 100644 index 0000000..ccec8fe --- /dev/null +++ b/packages/react-native-youtube-bridge/src/hooks/useYouTubePlayer.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +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. + * @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); + }, []); + + const videoId = useYouTubeVideoId(source, onError); + + if (playerRef.current == null) { + playerRef.current = new YoutubePlayer(videoId, config); + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: videoId changes trigger re-creation + const player = useMemo(() => { + let newPlayer = playerRef.current; + + if (!newPlayer || previousVideoId.current !== videoId) { + playerRef.current?.destroy(); + newPlayer = new YoutubePlayer(videoId, config); + playerRef.current = newPlayer; + previousVideoId.current = videoId; + return newPlayer; + } + + isFastRefresh.current = true; + + return newPlayer; + }, [videoId]); + + useEffect(() => { + isFastRefresh.current = false; + + return () => { + if (playerRef.current && !isFastRefresh.current) { + playerRef.current?.destroy(); + } + }; + }, []); + + return player; +}; + +export default useYouTubePlayer; 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..bb13e40 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, @@ -13,9 +11,11 @@ export { type PlayerEvents, type PlayerControls, } from '@react-native-youtube-bridge/core'; +export type { default as YoutubePlayer } from './modules/YoutubePlayer'; export { useYoutubeOEmbed } from '@react-native-youtube-bridge/react'; -export const YoutubePlayer: React.ForwardRefExoticComponent> = - YoutubePlayerComponent; +export { default as useYouTubeEvent } from './hooks/useYouTubeEvent'; +export { default as useYouTubePlayer } from './hooks/useYouTubePlayer'; +export { default as YoutubeView } from './YoutubeView'; -export type { YoutubePlayerProps }; +export type { YoutubeViewProps }; diff --git a/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts new file mode 100644 index 0000000..582e57c --- /dev/null +++ b/packages/react-native-youtube-bridge/src/modules/WebviewYoutubePlayerController.ts @@ -0,0 +1,172 @@ +import type WebView from 'react-native-webview'; +import type { PlayerEvents } from '@react-native-youtube-bridge/core'; + +class WebviewYoutubePlayerController { + private webViewRef: React.RefObject; + private commandId = 0; + private pendingCommands: Map void> = new Map(); + + constructor(webViewRef: React.RefObject) { + this.webViewRef = webViewRef; + } + + static createInstance(webViewRef: React.RefObject): WebviewYoutubePlayerController { + return new WebviewYoutubePlayerController(webViewRef); + } + + 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); + } + }); + } + + /** + * 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 + } + + async destroy(): Promise { + this.pendingCommands.clear(); + await this.cleanup(); + } +} + +export default WebviewYoutubePlayerController; diff --git a/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts new file mode 100644 index 0000000..ecfc8ef --- /dev/null +++ b/packages/react-native-youtube-bridge/src/modules/YoutubePlayer.ts @@ -0,0 +1,162 @@ +import type { + YoutubePlayerEvents, + YoutubePlayerVars, + EventCallback, + WebYoutubePlayerController, +} from '@react-native-youtube-bridge/core'; + +import type WebviewYoutubePlayerController from './WebviewYoutubePlayerController'; + +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(); + 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.listeners.clear(); + this.controller?.destroy(); + this.controller = null; + this.progressInterval = null; + } +} + +export default YoutubePlayer; diff --git a/packages/react-native-youtube-bridge/src/types/youtube.ts b/packages/react-native-youtube-bridge/src/types/youtube.ts index 0e873a2..dd80dd7 100644 --- a/packages/react-native-youtube-bridge/src/types/youtube.ts +++ b/packages/react-native-youtube-bridge/src/types/youtube.ts @@ -3,6 +3,7 @@ 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 = { /** @@ -66,3 +67,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..b395be3 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,3 +1,2 @@ -export { default as useYouTubePlayer } from './useYoutubePlayer'; export { default as useYouTubeVideoId } from './useYoutubeVideoId'; export { default as useYoutubeOEmbed } from './useYoutubeOEmbed'; diff --git a/packages/react/src/hooks/useYoutubePlayer.ts b/packages/react/src/hooks/useYoutubePlayer.ts deleted file mode 100644 index 2da395b..0000000 --- a/packages/react/src/hooks/useYoutubePlayer.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - ERROR_CODES, - type PlayerControls, - PlayerState, - type YoutubePlayerConfig, - YoutubePlayerCore, -} 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; - }, []); - - // 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; - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: update only playerVars - useEffect(() => { - const initialize = async () => { - if (!containerRef.current) { - return; - } - - try { - await YoutubePlayerCore.loadAPI(); - const containerId = `youtube-player-${videoId}`; - containerRef.current.id = containerId; - - 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); - } - } - }, [videoId, isReady, startTime, endTime]); - - useEffect(() => { - if (coreRef.current) { - coreRef.current.setProgressInterval(progressInterval); - } - }, [progressInterval]); - - useEffect(() => { - coreRef.current?.updateCallbacks({ - onReady: (playerInfo) => { - setIsReady(true); - onReady?.(playerInfo); - }, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - }); - }, [onReady, onStateChange, onError, onProgress, onPlaybackRateChange, onPlaybackQualityChange, onAutoplayBlocked]); - - 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 { - containerRef, - controls: playerControls, - isReady, - cleanup, - }; -}; - -export default useYouTubePlayer; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e896e06..5b653d5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1 +1 @@ -export { useYouTubePlayer, useYouTubeVideoId, useYoutubeOEmbed } from './hooks'; +export { useYouTubeVideoId, useYoutubeOEmbed } from './hooks'; diff --git a/packages/web/src/YoutubePlayer.tsx b/packages/web/src/YoutubePlayer.tsx index ae08c3e..57404aa 100644 --- a/packages/web/src/YoutubePlayer.tsx +++ b/packages/web/src/YoutubePlayer.tsx @@ -1,7 +1,10 @@ -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'; +import { parseTimeParam } from './utils'; import './YoutubePlayer.css'; @@ -18,75 +21,116 @@ 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 = parseTimeParam(startTime, 0); + const endTimeNumber = parseTimeParam(endTime); + + const containerRef = useRef(null); + const playerRef = useRef(null); + + useEffect(() => { + WebYoutubePlayerController.initialize().then(() => { + setIsInitialized(true); + + const controller = WebYoutubePlayerController.createInstance(); + playerRef.current = controller; + }); + }, []); + + useEffect(() => { + if (!isInitialized || !containerRef.current) { + return; + } + + const containerId = `youtube-player-${youtubeVideoId}`; + 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, + }, + }); + }, [ + sendMessage, + isInitialized, + youtubeVideoId, + origin, + controls, + autoplay, + muted, + playsinline, + loop, + rel, + startTimeNumber, + endTimeNumber, + ]); + + useEffect(() => { + return () => { + if (playerRef.current) { + playerRef.current?.destroy(); + playerRef.current = null; + } + }; + }, []); useEffect(() => { onMessage((message) => { @@ -95,17 +139,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 +177,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 +221,7 @@ function YoutubePlayer() { } } }); - }, [onMessage, playerControls, cleanup]); + }, [onMessage]); return (
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; +};