diff --git a/.changeset/orange-snakes-wonder.md b/.changeset/orange-snakes-wonder.md new file mode 100644 index 0000000..1df94ae --- /dev/null +++ b/.changeset/orange-snakes-wonder.md @@ -0,0 +1,7 @@ +--- +"react-native-youtube-bridge": patch +--- + +refactor(web): extract YouTubePlayerCore class for better architecture +- Separate business logic from React lifecycle +- Create framework-agnostic core with useYouTubePlayer hook diff --git a/example/src/App.tsx b/example/src/App.tsx index 8010420..3433773 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -32,7 +32,6 @@ function App() { console.log('Player is ready!'); Alert.alert('알림', 'YouTube 플레이어가 준비되었습니다!'); - // 플레이어 준비 완료 후 정보 가져오기 console.log('rates', playerInfo.availablePlaybackRates); console.log('vol', playerInfo.volume); console.log('muted', playerInfo.muted); diff --git a/src/YoutubePlayer.tsx b/src/YoutubePlayer.tsx index fac4b08..dfe469d 100644 --- a/src/YoutubePlayer.tsx +++ b/src/YoutubePlayer.tsx @@ -221,10 +221,8 @@ const YoutubePlayer = forwardRef( useEffect(() => { return () => { - // 모든 pending commands 정리 pendingCommandsRef.current.clear(); - // WebView 내부 cleanup 호출 if (webViewRef.current && isReady) { sendCommand('cleanup'); } diff --git a/src/YoutubePlayer.web.tsx b/src/YoutubePlayer.web.tsx index d15eeb0..5ffdf49 100644 --- a/src/YoutubePlayer.web.tsx +++ b/src/YoutubePlayer.web.tsx @@ -1,487 +1,27 @@ -import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react'; +import { forwardRef, useImperativeHandle } from 'react'; import { useWindowDimensions } from 'react-native'; import YoutubePlayerWrapper from './YoutubePlayerWrapper'; -import type { YouTubePlayer } from './types/iframe'; -import { ERROR_CODES, type PlayerControls, PlayerState, type YoutubePlayerProps } from './types/youtube'; -import { validateVideoId } from './utils/validate'; - -const YoutubePlayer = forwardRef( - ( - { - videoId, - width, - height = 200, - progressInterval: interval, - onReady, - onStateChange, - onError, - onProgress, - onPlaybackRateChange, - onPlaybackQualityChange, - onAutoplayBlocked, - style, - playerVars = { - startTime: 0, - autoplay: false, - controls: true, - loop: false, - muted: false, - playsinline: true, - rel: false, - }, - iframeStyle, - }, - ref, - ) => { - const { startTime = 0, endTime, autoplay, controls, loop, playsinline, rel } = playerVars; - const { width: screenWidth } = useWindowDimensions(); - - const playerRef = useRef(null); - const containerRef = useRef(null); - const createPlayerRef = useRef<() => void>(null); - const progressInterval = useRef(null); - const intervalRef = useRef(interval); - const seekTimeoutRef = useRef(null); - - const stopProgressTracking = useCallback(() => { - if (progressInterval.current) { - clearInterval(progressInterval.current); - progressInterval.current = null; - } - }, []); - - const sendProgress = useCallback(async () => { - if (!playerRef.current || !playerRef.current.getCurrentTime) { - return; - } - - const currentTime = await playerRef.current.getCurrentTime(); - const duration = await playerRef.current.getDuration(); - const percentage = duration > 0 ? (currentTime / duration) * 100 : 0; - const loadedFraction = await playerRef.current.getVideoLoadedFraction(); - - onProgress?.({ - currentTime, - duration, - percentage, - loadedFraction, - }); - }, [onProgress]); - - const startProgressTracking = useCallback(() => { - if (!intervalRef.current) { - return; - } - - if (progressInterval.current) { - clearInterval(progressInterval.current); - } - - progressInterval.current = setInterval(async () => { - if (!playerRef.current || !playerRef.current.getCurrentTime) { - stopProgressTracking(); - return; - } - - try { - const currentTime = await playerRef.current.getCurrentTime(); - const duration = await playerRef.current.getDuration(); - const percentage = duration > 0 ? (currentTime / duration) * 100 : 0; - const loadedFraction = await playerRef.current.getVideoLoadedFraction(); - - onProgress?.({ - currentTime, - duration, - percentage, - loadedFraction, - }); - } catch (error) { - console.error('Progress tracking error:', error); - stopProgressTracking(); - } - }, intervalRef.current); - }, [onProgress, stopProgressTracking]); - - const loadYouTubeAPI = useCallback(() => { - if (window.YT?.Player) { - return Promise.resolve(); - } - - if (window._ytApiPromise) { - return window._ytApiPromise; - } - - window._ytApiPromise = new Promise((resolve) => { - if (document.querySelector('script[src*="youtube.com/iframe_api"]')) { - const checkAPI = () => { - if (window.YT?.Player) { - resolve(); - return; - } - - setTimeout(checkAPI, 100); - }; - - checkAPI(); - return; - } - - window.onYouTubeIframeAPIReady = () => { - resolve(); - }; - - const script = document.createElement('script'); - script.src = 'https://www.youtube.com/iframe_api'; - script.async = true; - document.head.appendChild(script); - }); - - return window._ytApiPromise; - }, []); - - createPlayerRef.current = () => { - if (!containerRef.current || !window.YT?.Player) { - return; - } - - if (!validateVideoId(videoId)) { - onError?.({ code: 1002, message: 'INVALID_YOUTUBE_VIDEO_ID' }); - return; - } - - if (playerRef.current) { - try { - playerRef.current.destroy(); - } catch (error) { - console.warn('Error destroying YouTube player:', error); - } - } - - const playerId = `youtube-player-${videoId}`; - containerRef.current.id = playerId; - - playerRef.current = new window.YT.Player(playerId, { - width: '100%', - height: '100%', - videoId, - playerVars: { - autoplay: autoplay ? 1 : 0, - controls: controls ? 1 : 0, - loop: loop ? 1 : 0, - start: startTime, - end: endTime, - playsinline: playsinline ? 1 : 0, - rel: rel ? 1 : 0, - enablejsapi: 1, - }, - events: { - onReady: (event) => { - const { playerInfo } = event.target; - - 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, - }); - startProgressTracking(); - }, - onStateChange: (event) => { - const state = event.data; - onStateChange?.(state); - - if (state === PlayerState.ENDED) { - stopProgressTracking(); - sendProgress(); - return; - } - - if (state === PlayerState.PLAYING) { - startProgressTracking(); - return; - } - - if (state === PlayerState.PAUSED) { - stopProgressTracking(); - sendProgress(); - return; - } - - if (state === PlayerState.BUFFERING) { - startProgressTracking(); - return; - } - - if (state === PlayerState.CUED) { - stopProgressTracking(); - sendProgress(); - return; - } - - stopProgressTracking(); - }, - onError: (event) => { - console.error('YouTube player error:', event.data); - const errorCode = event.data; - - if (ERROR_CODES[errorCode]) { - onError?.({ - code: errorCode, - message: ERROR_CODES[errorCode], - }); - return; - } - - onError?.({ - code: 1004, - message: 'UNKNOWN_ERROR', - }); - }, - onPlaybackQualityChange: (event) => { - onPlaybackQualityChange?.(event.data); - }, - onPlaybackRateChange: (event) => { - onPlaybackRateChange?.(event.data); - }, - onAutoplayBlocked, - }, - }); - }; - - const createPlayer = useCallback(async () => { - try { - await loadYouTubeAPI(); - createPlayerRef.current?.(); - } catch (error) { - console.error('Failed to create YouTube player:', error); - onError?.({ - code: 1003, - message: 'FAILED_TO_LOAD_YOUTUBE_API', - }); - } - }, [loadYouTubeAPI, onError]); - - useEffect(() => { - createPlayer(); - - return () => { - stopProgressTracking(); - if (playerRef.current) { - try { - playerRef.current.destroy(); - } catch (error) { - console.warn('Error destroying YouTube player on unmount:', error); - } - } - }; - }, [createPlayer, stopProgressTracking]); - - useEffect(() => { - if (playerRef.current && validateVideoId(videoId)) { - try { - playerRef.current.loadVideoById(videoId); - } catch (error) { - console.warn('Error loading new video:', error); - createPlayer(); - } - } - }, [videoId, createPlayer]); - - useEffect(() => { - intervalRef.current = interval; - - if (interval) { - startProgressTracking(); - return; - } - - stopProgressTracking(); - }, [interval, startProgressTracking, stopProgressTracking]); - - const play = useCallback(() => { - playerRef.current?.playVideo(); - }, []); - - const pause = useCallback(() => { - playerRef.current?.pauseVideo(); - }, []); - - const stop = useCallback(() => { - playerRef.current?.stopVideo(); - }, []); - - const seekTo = useCallback( - (seconds: number, allowSeekAhead = true) => { - playerRef.current?.seekTo(seconds, allowSeekAhead); - - if (seekTimeoutRef.current) { - clearTimeout(seekTimeoutRef.current); - } - - seekTimeoutRef.current = setTimeout(() => { - sendProgress(); - }, 200); - }, - [sendProgress], - ); - - const setVolume = useCallback((volume: number) => { - playerRef.current?.setVolume(volume); - }, []); - - const getVolume = useCallback(async (): Promise => { - const volume = await playerRef.current?.getVolume(); - - return volume ?? 0; - }, []); - - const mute = useCallback(() => { - playerRef.current?.mute(); - }, []); - - const unMute = useCallback(() => { - playerRef.current?.unMute(); - }, []); - - const isMuted = useCallback(async (): Promise => { - const isMuted = await playerRef.current?.isMuted(); - - return isMuted ?? false; - }, []); - - const getCurrentTime = useCallback(async (): Promise => { - const currentTime = await playerRef.current?.getCurrentTime(); - - return currentTime ?? 0; - }, []); - - const getDuration = useCallback(async (): Promise => { - const duration = await playerRef.current?.getDuration(); - - return duration ?? 0; - }, []); - - const getVideoUrl = useCallback(async (): Promise => { - const videoUrl = await playerRef.current?.getVideoUrl(); - - return videoUrl ?? ''; - }, []); - - const getVideoEmbedCode = useCallback(async (): Promise => { - const videoEmbedCode = await playerRef.current?.getVideoEmbedCode(); - - return videoEmbedCode ?? ''; - }, []); - - const getPlaybackRate = useCallback(async (): Promise => { - const playbackRate = await playerRef.current?.getPlaybackRate(); - - return playbackRate ?? 1; - }, []); - - const getAvailablePlaybackRates = useCallback(async (): Promise => { - const availablePlaybackRates = await playerRef.current?.getAvailablePlaybackRates(); - - return availablePlaybackRates ?? [1]; - }, []); - - const getPlayerState = useCallback(async (): Promise => { - const playerState = await playerRef.current?.getPlayerState(); - - return playerState ?? PlayerState.UNSTARTED; - }, []); - - const setPlaybackRate = useCallback((suggestedRate: number) => { - playerRef.current?.setPlaybackRate(suggestedRate); - }, []); - - const getVideoLoadedFraction = useCallback(async (): Promise => { - const videoLoadedFraction = await playerRef.current?.getVideoLoadedFraction(); - - return videoLoadedFraction ?? 0; - }, []); - - const loadVideoById = useCallback((videoId: string, startSeconds?: number, endSeconds?: number) => { - playerRef.current?.loadVideoById(videoId, startSeconds, endSeconds); - }, []); - - const cueVideoById = useCallback((videoId: string, startSeconds?: number, endSeconds?: number) => { - playerRef.current?.cueVideoById(videoId, startSeconds, endSeconds); - }, []); - - const setSize = useCallback((width: number, height: number) => { - playerRef.current?.setSize(width, height); - }, []); - - useImperativeHandle( - ref, - (): PlayerControls => ({ - play, - pause, - stop, - getCurrentTime, - getDuration, - seekTo, - setVolume, - getVolume, - mute, - unMute, - isMuted, - getVideoUrl, - getVideoEmbedCode, - getPlaybackRate, - setPlaybackRate, - getAvailablePlaybackRates, - getPlayerState, - getVideoLoadedFraction, - loadVideoById, - cueVideoById, - setSize, - }), - [ - play, - pause, - stop, - getCurrentTime, - getDuration, - seekTo, - setVolume, - getVolume, - mute, - unMute, - isMuted, - getVideoUrl, - getVideoEmbedCode, - getPlaybackRate, - setPlaybackRate, - getAvailablePlaybackRates, - getPlayerState, - getVideoLoadedFraction, - loadVideoById, - cueVideoById, - setSize, - ], - ); - - return ( - -
- - ); - }, -); +import useYouTubePlayer from './hooks/useYoutubePlayer'; +import type { PlayerControls, YoutubePlayerProps } from './types/youtube'; + +const YoutubePlayer = forwardRef((props, ref) => { + const { containerRef, controls } = useYouTubePlayer(props); + const { width: screenWidth } = useWindowDimensions(); + + useImperativeHandle(ref, () => controls, [controls]); + + return ( + +
+ + ); +}); export default YoutubePlayer; diff --git a/src/hooks/useYoutubePlayer.ts b/src/hooks/useYoutubePlayer.ts new file mode 100644 index 0000000..3713260 --- /dev/null +++ b/src/hooks/useYoutubePlayer.ts @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import YouTubePlayerCore from '../modules/YouTubePlayerCore'; +import { ERROR_CODES, type PlayerControls, PlayerState, type YoutubePlayerConfig } from '../types/youtube'; + +const useYouTubePlayer = (config: YoutubePlayerConfig) => { + const coreRef = useRef(null); + const containerRef = useRef(null); + const [isReady, setIsReady] = useState(false); + + const { + videoId, + progressInterval = 1000, + playerVars = {}, + onReady, + onStateChange, + onError, + onProgress, + onPlaybackRateChange, + onPlaybackQualityChange, + onAutoplayBlocked, + } = config; + + const { startTime, endTime, autoplay, controls, loop, muted, playsinline, rel } = playerVars; + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (!coreRef.current) { + coreRef.current = new YouTubePlayerCore({ + onReady: (playerInfo) => { + setIsReady(true); + onReady?.(playerInfo); + }, + onStateChange, + onError, + onProgress, + onPlaybackRateChange, + onPlaybackQualityChange, + onAutoplayBlocked, + }); + } + return () => { + coreRef.current?.destroy(); + coreRef.current = null; + }; + }, []); + + 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, + }, + }); + } catch (error) { + console.error('Failed to create YouTube player:', error); + onError?.({ + code: 1003, + message: ERROR_CODES[1003], + }); + } + }; + + initialize(); + + return () => { + coreRef.current?.destroy(); + }; + }, [videoId, startTime, endTime, autoplay, controls, loop, muted, playsinline, rel, onError]); + + 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, + }; +}; + +export default useYouTubePlayer; diff --git a/src/index.tsx b/src/index.tsx index 28b6367..012af60 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,4 +9,5 @@ export { type PlayerControls, type YouTubeError, type PlayerInfo, + type PlayerEvents, } from './types/youtube'; diff --git a/src/modules/YouTubePlayerCore.tsx b/src/modules/YouTubePlayerCore.tsx new file mode 100644 index 0000000..dc60507 --- /dev/null +++ b/src/modules/YouTubePlayerCore.tsx @@ -0,0 +1,369 @@ +import type { YouTubePlayer } from '../types/iframe'; +import { ERROR_CODES, type YoutubePlayerProps as PlayerConfig, type PlayerEvents, PlayerState } from '../types/youtube'; +import { validateVideoId } from '../utils/validate'; + +class YouTubePlayerCore { + 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 async loadAPI(): Promise { + if (typeof window === 'undefined' || window.YT?.Player) { + return Promise.resolve(); + } + + if (window._ytApiPromise) { + return window._ytApiPromise; + } + + window._ytApiPromise = new Promise((resolve) => { + if (document.querySelector('script[src*="youtube.com/iframe_api"]')) { + let attempts = 0; + const maxAttempts = 100; + + const checkAPI = () => { + if (window.YT?.Player) { + resolve(); + return; + } + if (attempts >= maxAttempts) { + console.error('YouTube API failed to load after timeout'); + resolve(); + return; + } + attempts++; + setTimeout(checkAPI, 100); + }; + checkAPI(); + return; + } + + window.onYouTubeIframeAPIReady = () => { + resolve(); + }; + + const script = document.createElement('script'); + script.src = 'https://www.youtube.com/iframe_api'; + script.async = true; + document.head.appendChild(script); + }); + + return window._ytApiPromise; + } + + createPlayer(containerId: string, config: PlayerConfig): void { + if (typeof window === 'undefined' || !window.YT?.Player) { + return; + } + + const container = document.getElementById(containerId); + + if (!container) { + return; + } + + if (!validateVideoId(config.videoId)) { + this.callbacks.onError?.({ + code: 1002, + message: ERROR_CODES[1002], + }); + return; + } + + if (this.player) { + try { + this.player.destroy(); + } catch (error) { + console.warn('Error destroying YouTube player:', error); + } + } + + this.player = new window.YT.Player(containerId, { + width: '100%', + height: '100%', + videoId: config.videoId, + playerVars: { + autoplay: config.playerVars?.autoplay ? 1 : 0, + controls: config.playerVars?.controls ? 1 : 0, + loop: config.playerVars?.loop ? 1 : 0, + start: config.playerVars?.startTime, + end: config.playerVars?.endTime, + playsinline: config.playerVars?.playsinline ? 1 : 0, + rel: config.playerVars?.rel ? 1 : 0, + enablejsapi: 1, + }, + events: { + onReady: (event) => { + const { playerInfo } = event.target; + + this.callbacks.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, + }); + + this.startProgressTracking(); + }, + onStateChange: (event) => { + const state = event.data; + this.callbacks.onStateChange?.(state); + + this.handleStateChange(state); + }, + onError: (event) => { + console.error('YouTube player error:', event.data); + const errorCode = event.data; + + if (ERROR_CODES[errorCode]) { + this.callbacks.onError?.({ + code: errorCode, + message: ERROR_CODES[errorCode], + }); + return; + } + + this.callbacks.onError?.({ + code: 1004, + message: 'UNKNOWN_ERROR', + }); + }, + onPlaybackQualityChange: (event) => { + this.callbacks.onPlaybackQualityChange?.(event.data); + }, + onPlaybackRateChange: (event) => { + this.callbacks.onPlaybackRateChange?.(event.data); + }, + onAutoplayBlocked: this.callbacks.onAutoplayBlocked, + }, + }); + } + + private handleStateChange(state: number): void { + if (state === PlayerState.ENDED) { + this.stopProgressTracking(); + this.sendProgress(); + return; + } + + if (state === PlayerState.PLAYING) { + this.startProgressTracking(); + return; + } + + if (state === PlayerState.PAUSED) { + this.stopProgressTracking(); + this.sendProgress(); + return; + } + + if (state === PlayerState.BUFFERING) { + this.startProgressTracking(); + return; + } + + if (state === PlayerState.CUED) { + this.stopProgressTracking(); + this.sendProgress(); + return; + } + + this.stopProgressTracking(); + } + + startProgressTracking(): void { + if (!this.progressIntervalMs || this.progressInterval) { + return; + } + + this.progressInterval = setInterval(async () => { + if (!this.player || !this.player.getCurrentTime) { + this.stopProgressTracking(); + return; + } + + try { + await this.sendProgress(); + } catch (error) { + console.error('Progress tracking error:', error); + this.stopProgressTracking(); + } + }, this.progressIntervalMs); + } + + stopProgressTracking(): void { + if (this.progressInterval) { + clearInterval(this.progressInterval); + this.progressInterval = null; + } + } + + private async sendProgress(): Promise { + if (!this.player || !this.player.getCurrentTime) { + return; + } + + const currentTime = await this.player.getCurrentTime(); + const duration = await this.player.getDuration(); + const percentage = duration > 0 ? (currentTime / duration) * 100 : 0; + const loadedFraction = await this.player.getVideoLoadedFraction(); + + this.callbacks.onProgress?.({ + currentTime, + duration, + percentage, + loadedFraction, + }); + } + + play(): void { + this.player?.playVideo(); + } + pause(): void { + this.player?.pauseVideo(); + } + + stop(): void { + this.player?.stopVideo(); + } + + async seekTo(seconds: number, allowSeekAhead = true): Promise { + this.player?.seekTo(seconds, allowSeekAhead); + + if (this.seekTimeout) { + clearTimeout(this.seekTimeout); + } + + this.seekTimeout = setTimeout(() => { + this.sendProgress(); + }, 200); + } + + setVolume(volume: number): void { + this.player?.setVolume(volume); + } + + async getVolume(): Promise { + const volume = await this.player?.getVolume(); + return volume ?? 0; + } + + mute(): void { + this.player?.mute(); + } + + unMute(): void { + this.player?.unMute(); + } + + async isMuted(): Promise { + const isMuted = await this.player?.isMuted(); + return isMuted ?? false; + } + + async getCurrentTime(): Promise { + const currentTime = await this.player?.getCurrentTime(); + return currentTime ?? 0; + } + + async getDuration(): Promise { + const duration = await this.player?.getDuration(); + return duration ?? 0; + } + + async getVideoUrl(): Promise { + const videoUrl = await this.player?.getVideoUrl(); + return videoUrl ?? ''; + } + + async getVideoEmbedCode(): Promise { + const videoEmbedCode = await this.player?.getVideoEmbedCode(); + return videoEmbedCode ?? ''; + } + + async getPlaybackRate(): Promise { + const playbackRate = await this.player?.getPlaybackRate(); + return playbackRate ?? 1; + } + + async getAvailablePlaybackRates(): Promise { + const availablePlaybackRates = await this.player?.getAvailablePlaybackRates(); + return availablePlaybackRates ?? [1]; + } + + async getPlayerState(): Promise { + const playerState = await this.player?.getPlayerState(); + return playerState ?? PlayerState.UNSTARTED; + } + + setPlaybackRate(suggestedRate: number): void { + this.player?.setPlaybackRate(suggestedRate); + } + + async getVideoLoadedFraction(): Promise { + const videoLoadedFraction = await this.player?.getVideoLoadedFraction(); + return videoLoadedFraction ?? 0; + } + + loadVideoById(videoId: string, startSeconds?: number, endSeconds?: number): void { + this.player?.loadVideoById(videoId, startSeconds, endSeconds); + } + + cueVideoById(videoId: string, startSeconds?: number, endSeconds?: number): void { + this.player?.cueVideoById(videoId, startSeconds, endSeconds); + } + + setSize(width: number, height: number): void { + this.player?.setSize(width, height); + } + + setProgressInterval(intervalMs: number): void { + this.progressIntervalMs = intervalMs; + if (this.progressInterval) { + this.stopProgressTracking(); + } + + if (intervalMs) { + this.startProgressTracking(); + return; + } + + this.stopProgressTracking(); + } + + updateCallbacks(newCallbacks: Partial): void { + this.callbacks = { ...this.callbacks, ...newCallbacks }; + } + + destroy(): void { + this.stopProgressTracking(); + + if (this.seekTimeout) { + clearTimeout(this.seekTimeout); + this.seekTimeout = null; + } + + if (this.player) { + try { + this.player.destroy(); + } catch (error) { + console.warn('Error destroying YouTube player:', error); + } + this.player = null; + } + } +} + +export default YouTubePlayerCore; diff --git a/src/types/youtube.ts b/src/types/youtube.ts index 60929d3..4861a57 100644 --- a/src/types/youtube.ts +++ b/src/types/youtube.ts @@ -33,6 +33,24 @@ export type YoutubePlayerVars = { origin?: string; }; +export type PlayerEvents = { + /** + * @description Callback function called when the player is ready. + */ + onReady?: (playerInfo: PlayerInfo) => void; + onStateChange?: (state: PlayerState) => void; + onError?: (error: YouTubeError) => void; + /** + * @description Callback function called at the specified `progressInterval` + * or when `seekTo` is invoked. Only triggered when `progressInterval` is + * provided as a positive number. + */ + onProgress?: (progress: ProgressData) => void; + onPlaybackRateChange?: (playbackRate: number) => void; + onPlaybackQualityChange?: (quality: string) => void; + onAutoplayBlocked?: () => void; +}; + // YouTube IFrame API official documentation based export type YoutubePlayerProps = { videoId: string; @@ -55,25 +73,14 @@ export type YoutubePlayerProps = { * @platform web */ iframeStyle?: CSSProperties; + playerVars?: YoutubePlayerVars; +} & PlayerEvents; - // Events - /** - * @description Callback function called when the player is ready. - */ - onReady?: (playerInfo: PlayerInfo) => void; - onStateChange?: (state: PlayerState) => void; - onError?: (error: YouTubeError) => void; - /** - * @description Callback function called at the specified `progressInterval` - * or when `seekTo` is invoked. Only triggered when `progressInterval` is - * provided as a positive number. - */ - onProgress?: (progress: ProgressData) => void; - onPlaybackRateChange?: (playbackRate: number) => void; - onPlaybackQualityChange?: (quality: string) => void; - onAutoplayBlocked?: () => void; +export type YoutubePlayerConfig = { + videoId: string; + progressInterval?: number; playerVars?: YoutubePlayerVars; -}; +} & PlayerEvents; export enum PlayerState { UNSTARTED = -1,