Skip to content

Commit f875f53

Browse files
committed
ss
1 parent 386079b commit f875f53

4 files changed

Lines changed: 143 additions & 152 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ cd ios && pod install
4242
```
4343

4444
`react-native-svg` is a peer dependency (used by the built-in control icons).
45-
Add `react-native-webview` too **only if** you use YouTube sources
46-
(`npm install react-native-webview`) — it's an optional peer dependency.
45+
For YouTube sources add **`react-native-youtube-iframe` + `react-native-webview`**
46+
(`npm install react-native-youtube-iframe react-native-webview`) — optional
47+
peer dependencies, only needed if you use `type: 'youtube'`.
4748

4849
Requires the New Architecture. Works on React Native **0.79+** — the
4950
TurboModule spec uses direct codegen-type imports so it parses on 0.79's
@@ -88,7 +89,8 @@ YouTube plays in a WebView (IFrame API) but uses the **same built-in
8889
`VideoControls`** (YouTube's own UI is hidden) and the same fullscreen host —
8990
so it looks and behaves like a native source. `usePlayback`, `useVideoEvents`,
9091
`play()/pause()/seek()` all work the same. Native (`type: 'url'`, the default)
91-
and YouTube sources are interchangeable. Requires `react-native-webview`.
92+
and YouTube sources are interchangeable. Requires `react-native-youtube-iframe`
93+
+ `react-native-webview`.
9294

9395
> YouTube can't re-parent its WebView the way the native engine re-parents its
9496
> view, so entering/exiting fullscreen re-creates the WebView (it resumes at

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,15 @@
8888
"react": "*",
8989
"react-native": "*",
9090
"react-native-svg": ">=13.0.0",
91-
"react-native-webview": ">=13.0.0"
91+
"react-native-webview": ">=13.0.0",
92+
"react-native-youtube-iframe": ">=2.0.0"
9293
},
9394
"peerDependenciesMeta": {
9495
"react-native-webview": {
9596
"optional": true
97+
},
98+
"react-native-youtube-iframe": {
99+
"optional": true
96100
}
97101
},
98102
"workspaces": [

src/components/YouTubeView.tsx

Lines changed: 130 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
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';
39
import { useVideoManager } from '../provider/VideoContext';
410
import 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;
917
try {
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

2023
export 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
*/
3739
export 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-
205191
const 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',

yarn.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7918,9 +7918,12 @@ __metadata:
79187918
react-native: "*"
79197919
react-native-svg: ">=13.0.0"
79207920
react-native-webview: ">=13.0.0"
7921+
react-native-youtube-iframe: ">=2.0.0"
79217922
peerDependenciesMeta:
79227923
react-native-webview:
79237924
optional: true
7925+
react-native-youtube-iframe:
7926+
optional: true
79247927
languageName: unknown
79257928
linkType: soft
79267929

0 commit comments

Comments
 (0)