1- import { useEffect , useMemo , useRef } from 'react' ;
2- import { StyleSheet , Text , View , type ViewProps } from 'react-native' ;
1+ import { useCallback , useEffect , useRef , useState } from 'react' ;
2+ import {
3+ StyleSheet ,
4+ Text ,
5+ View ,
6+ type LayoutChangeEvent ,
7+ type ViewProps ,
8+ } from 'react-native' ;
39import { useVideoManager } from '../provider/VideoContext' ;
410import type { YouTubeController } from '../core/YouTubeController' ;
511
612// Optional peer dependency: only required when a `type: 'youtube'` source is
7- // actually rendered, so apps that never use YouTube needn't install it.
8- let WebView : any ;
13+ // actually rendered, so apps that never use YouTube needn't install it (it
14+ // pulls in react-native-webview). It handles the embed referrer/origin setup
15+ // that a hand-rolled iframe gets wrong (e.g. "Error 153").
16+ let YoutubePlayer : any ;
917try {
10- WebView = require ( 'react-native-webview ' ) . WebView ;
18+ YoutubePlayer = require ( 'react-native-youtube-iframe ' ) . default ;
1119} catch {
12- WebView = null ;
13- }
14-
15- /** Minimal shape of react-native-webview's onMessage event. */
16- interface WebViewMessage {
17- nativeEvent : { data : string } ;
20+ YoutubePlayer = null ;
1821}
1922
2023export interface YouTubeViewProps extends ViewProps {
@@ -28,11 +31,10 @@ export interface YouTubeViewProps extends ViewProps {
2831}
2932
3033/**
31- * Plays a YouTube video through the YouTube IFrame API inside a WebView, and
32- * bridges it to the shared VideoManager so `usePlayback`, events and the
33- * command API work the same as native sources. YouTube's own player UI (incl.
34- * its native fullscreen + rotation) is used — the native engine and this one
35- * are mutually exclusive per active source.
34+ * Plays a YouTube video via `react-native-youtube-iframe`, bridged to the
35+ * shared VideoManager so `usePlayback`, events and the command API work the
36+ * same as native sources. YouTube's own UI is hidden (`controls: false`) — the
37+ * app draws `<VideoControls>` on top, like a native source.
3638 */
3739export function YouTubeView ( {
3840 videoId,
@@ -44,173 +46,153 @@ export function YouTubeView({
4446 ...rest
4547} : YouTubeViewProps ) {
4648 const manager = useVideoManager ( ) ;
47- const ref = useRef < { injectJavaScript : ( js : string ) => void } | null > ( null ) ;
49+ const ref = useRef < {
50+ seekTo : ( s : number , allowAhead : boolean ) => void ;
51+ getDuration : ( ) => Promise < number > ;
52+ getCurrentTime : ( ) => Promise < number > ;
53+ } | null > ( null ) ;
54+
55+ // Desired (controlled) state — driven by the manager via the controller.
56+ const [ playing , setPlaying ] = useState ( autoplay ) ;
57+ const [ isMuted , setIsMuted ] = useState ( muted ) ;
58+ const [ rate , setRate ] = useState ( 1 ) ;
59+ const [ volume , setVolume ] = useState ( 100 ) ;
60+ const [ size , setSize ] = useState ( { width : 0 , height : 0 } ) ;
61+
4862 const repeatRef = useRef ( repeat ) ;
4963 repeatRef . current = repeat ;
50- // Captured once at mount so a live position prop can't rebuild the HTML
51- // (which would reload the WebView).
5264 const startRef = useRef ( Math . floor ( startSeconds ) ) ;
5365
54- const html = useMemo (
55- ( ) => buildHtml ( videoId , autoplay , muted , startRef . current ) ,
56- [ videoId , autoplay , muted ]
57- ) ;
58-
5966 useEffect ( ( ) => {
60- const inject = ( js : string ) => ref . current ?. injectJavaScript ( `${ js } ;true;` ) ;
6167 const controller : YouTubeController = {
6268 videoId,
63- play : ( ) => inject ( 'player&&player.playVideo()' ) ,
64- pause : ( ) => inject ( 'player&&player.pauseVideo()' ) ,
65- stop : ( ) => inject ( 'player&&(player.pauseVideo(),player.seekTo(0,true))' ) ,
66- seekTo : ( s ) => inject ( `player&&player.seekTo(${ s } ,true)` ) ,
67- setRate : ( r ) => inject ( `player&&player.setPlaybackRate(${ r } )` ) ,
68- setVolume : ( v ) =>
69- inject ( `player&&player.setVolume(${ Math . round ( v * 100 ) } )` ) ,
70- setMuted : ( m ) => inject ( `player&&player.${ m ? 'mute' : 'unMute' } ()` ) ,
71- setRepeat : ( ) => { } , // handled on the 'ended' message via repeatRef
69+ play : ( ) => setPlaying ( true ) ,
70+ pause : ( ) => setPlaying ( false ) ,
71+ stop : ( ) => {
72+ setPlaying ( false ) ;
73+ ref . current ?. seekTo ( 0 , true ) ;
74+ } ,
75+ seekTo : ( s ) => ref . current ?. seekTo ( s , true ) ,
76+ setRate : ( r ) => setRate ( r ) ,
77+ setVolume : ( v ) => setVolume ( Math . round ( v * 100 ) ) ,
78+ setMuted : ( m ) => setIsMuted ( m ) ,
79+ setRepeat : ( ) => { } , // handled on the 'ended' state via repeatRef
7280 } ;
7381 manager . registerYouTube ( controller ) ;
7482 return ( ) => manager . unregisterYouTube ( controller ) ;
7583 } , [ manager , videoId ] ) ;
7684
77- const onMessage = ( e : WebViewMessage ) => {
78- let msg : { type : string ; [ k : string ] : unknown } ;
85+ // Progress ticker (the library is imperative for time).
86+ useEffect ( ( ) => {
87+ const timer = setInterval ( async ( ) => {
88+ const p = ref . current ;
89+ if ( ! p ) {
90+ return ;
91+ }
92+ try {
93+ const [ position , duration ] = await Promise . all ( [
94+ p . getCurrentTime ( ) ,
95+ p . getDuration ( ) ,
96+ ] ) ;
97+ manager . ytProgress ( position || 0 , duration || 0 ) ;
98+ } catch {
99+ // player not ready yet
100+ }
101+ } , 500 ) ;
102+ return ( ) => clearInterval ( timer ) ;
103+ } , [ manager ] ) ;
104+
105+ const onReady = useCallback ( async ( ) => {
106+ let duration = 0 ;
79107 try {
80- msg = JSON . parse ( e . nativeEvent . data ) ;
108+ duration = ( await ref . current ?. getDuration ( ) ) ?? 0 ;
81109 } catch {
82- return ;
110+ // ignore
83111 }
84- switch ( msg . type ) {
85- case 'ready' :
86- manager . ytLoad ( Number ( msg . duration ) || 0 ) ;
87- break ;
88- case 'state' :
89- handleState ( Number ( msg . state ) ) ;
90- break ;
91- case 'time' :
92- manager . ytProgress (
93- Number ( msg . position ) || 0 ,
94- Number ( msg . duration ) || 0
95- ) ;
96- break ;
97- case 'error' :
98- manager . ytError ( 'youtube' , String ( msg . code ?? 'YouTube error' ) ) ;
99- break ;
100- }
101- } ;
112+ manager . ytLoad ( duration ) ;
113+ } , [ manager ] ) ;
102114
103- // YT.PlayerState: -1 unstarted, 0 ended, 1 playing, 2 paused, 3 buffering.
104- const handleState = ( state : number ) => {
105- switch ( state ) {
106- case 1 :
107- manager . ytStatus ( 'playing' ) ;
108- break ;
109- case 2 :
110- manager . ytStatus ( 'paused' ) ;
111- break ;
112- case 3 :
113- manager . ytStatus ( 'buffering' ) ;
114- break ;
115- case 0 :
116- if ( repeatRef . current ) {
117- ref . current ?. injectJavaScript (
118- 'player&&(player.seekTo(0,true),player.playVideo());true;'
119- ) ;
120- } else {
121- manager . ytEnded ( ) ;
122- }
123- break ;
124- }
125- } ;
115+ const onChangeState = useCallback (
116+ ( state : string ) => {
117+ switch ( state ) {
118+ case 'playing' :
119+ manager . ytStatus ( 'playing' ) ;
120+ break ;
121+ case 'paused' :
122+ manager . ytStatus ( 'paused' ) ;
123+ break ;
124+ case 'buffering' :
125+ manager . ytStatus ( 'buffering' ) ;
126+ break ;
127+ case 'ended' :
128+ if ( repeatRef . current ) {
129+ ref . current ?. seekTo ( 0 , true ) ;
130+ setPlaying ( true ) ;
131+ } else {
132+ manager . ytEnded ( ) ;
133+ }
134+ break ;
135+ }
136+ } ,
137+ [ manager ]
138+ ) ;
139+
140+ const onError = useCallback (
141+ ( code : string ) => manager . ytError ( 'youtube' , code ) ,
142+ [ manager ]
143+ ) ;
126144
127- if ( ! WebView ) {
145+ const onLayout = useCallback ( ( e : LayoutChangeEvent ) => {
146+ const { width, height } = e . nativeEvent . layout ;
147+ setSize ( { width, height } ) ;
148+ } , [ ] ) ;
149+
150+ if ( ! YoutubePlayer ) {
128151 return (
129152 < View style = { [ styles . fallback , style ] } { ...rest } >
130153 < Text style = { styles . fallbackText } >
131- react-native-webview is required for YouTube sources. Install it:
132- { '\n' } npm install react-native-webview
154+ react-native-youtube-iframe is required for YouTube sources. Install
155+ it: { '\n' } npm install react-native-youtube-iframe react-native-webview
133156 </ Text >
134157 </ View >
135158 ) ;
136159 }
137160
138161 return (
139- < View style = { [ styles . container , style ] } { ...rest } >
140- < WebView
141- ref = { ref }
142- // baseUrl gives the page a real https origin — the YouTube IFrame API
143- // rejects about:blank (null origin) with a config error.
144- source = { { html, baseUrl : 'https://www.youtube.com' } }
145- style = { styles . web }
146- originWhitelist = { [ '*' ] }
147- javaScriptEnabled
148- domStorageEnabled
149- allowsInlineMediaPlayback
150- allowsFullscreenVideo = { false }
151- mediaPlaybackRequiresUserAction = { false }
152- mixedContentMode = "always"
153- androidLayerType = "hardware"
154- setSupportMultipleWindows = { false }
155- scalesPageToFit = { false }
156- scrollEnabled = { false }
157- bounces = { false }
158- showsVerticalScrollIndicator = { false }
159- showsHorizontalScrollIndicator = { false }
160- onMessage = { onMessage }
161- />
162+ < View style = { [ styles . container , style ] } onLayout = { onLayout } { ...rest } >
163+ { size . width > 0 ? (
164+ < YoutubePlayer
165+ ref = { ref }
166+ height = { size . height }
167+ width = { size . width }
168+ play = { playing }
169+ mute = { isMuted }
170+ volume = { volume }
171+ playbackRate = { rate }
172+ videoId = { videoId }
173+ initialPlayerParams = { {
174+ controls : false ,
175+ modestbranding : true ,
176+ rel : false ,
177+ preventFullScreen : true ,
178+ iv_load_policy : 3 ,
179+ start : startRef . current ,
180+ } }
181+ webViewProps = { { androidLayerType : 'hardware' } }
182+ onReady = { onReady }
183+ onChangeState = { onChangeState }
184+ onError = { onError }
185+ />
186+ ) : null }
162187 </ View >
163188 ) ;
164189}
165190
166- function buildHtml (
167- videoId : string ,
168- autoplay : boolean ,
169- muted : boolean ,
170- start : number
171- ) : string {
172- // controls:0 → hide YouTube's own UI; the app draws <VideoControls>.
173- // origin/enablejsapi are required for the IFrame API to accept commands.
174- return `<!DOCTYPE html><html><head>
175- <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
176- <style>html,body{margin:0;padding:0;background:#000;height:100%;overflow:hidden}#p{width:100%;height:100%}
177- /* Swallow taps on the iframe so <VideoControls> gestures win. */
178- #p iframe{pointer-events:none}</style>
179- </head><body>
180- <div id="p"></div>
181- <script>
182- var player;
183- function post(m){try{window.ReactNativeWebView.postMessage(JSON.stringify(m))}catch(e){}}
184- var tag=document.createElement('script');tag.src='https://www.youtube.com/iframe_api';
185- document.body.appendChild(tag);
186- function onYouTubeIframeAPIReady(){
187- player=new YT.Player('p',{
188- videoId:'${ videoId } ',
189- host:'https://www.youtube.com',
190- playerVars:{autoplay:${ autoplay ? 1 : 0 } ,controls:0,playsinline:1,rel:0,modestbranding:1,fs:0,disablekb:1,iv_load_policy:3,enablejsapi:1,origin:'https://www.youtube.com',start:${ start } ,mute:${ muted ? 1 : 0 } },
191- events:{
192- onReady:function(){post({type:'ready',duration:player.getDuration()});},
193- onStateChange:function(e){post({type:'state',state:e.data});},
194- onError:function(e){post({type:'error',code:e.data});}
195- }
196- });
197- }
198- setInterval(function(){
199- if(player&&player.getCurrentTime){post({type:'time',position:player.getCurrentTime(),duration:player.getDuration()});}
200- },500);
201- </script>
202- </body></html>` ;
203- }
204-
205191const styles = StyleSheet . create ( {
206192 container : {
207193 backgroundColor : '#000' ,
208194 overflow : 'hidden' ,
209195 } ,
210- web : {
211- flex : 1 ,
212- backgroundColor : '#000' ,
213- } ,
214196 fallback : {
215197 backgroundColor : '#000' ,
216198 alignItems : 'center' ,
0 commit comments