3rd party embed player (#2217)
* Implement embed player for YT, spotify, and twitch * fix: handle blur event * fix: use video dimensions for twitch * fix: remove hack (?) * fix: remove origin whitelist (?) * fix: prevent ads from opening in browser * fix: handle embeds that don't have a thumb * feat: handle dark/light mode * fix: ts warning * fix: adjust height of no-thumb label * fix: adjust height of no-thumb label * fix: remove debug log, set collapsable to false for player view * fix: fix dimensions "flash" * chore: remove old youtube link test * tests: add tests * fix: thumbless embed position when loading * fix: remove background from webview * cleanup embeds (almost) * more refactoring - Use separate layers for player and overlay to prevent weird sizing issues - Be sure the image is not visible under the player - Clean up some * cleanup styles * parse youtube shorts urls * remove debug * add soundcloud tracks and sets (playlists) * move logic into `ExternalLinkEmbed` * border radius for yt player on native * fix styling on web * allow scrolling in webview on android * remove unnecessary check * autoplay yt on web * fix tests after adding autoplay * move `useNavigation` to top of component --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>zio/stable
parent
7ab188dc1f
commit
fedb94dd70
|
@ -1,6 +1,5 @@
|
||||||
import {RichText} from '@atproto/api'
|
import {RichText} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
getYoutubeVideoId,
|
|
||||||
makeRecordUri,
|
makeRecordUri,
|
||||||
toNiceDomain,
|
toNiceDomain,
|
||||||
toShortUrl,
|
toShortUrl,
|
||||||
|
@ -12,6 +11,7 @@ import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
|
||||||
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
|
||||||
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
|
import {makeValidHandle, createFullHandle} from '../../src/lib/strings/handles'
|
||||||
import {cleanError} from '../../src/lib/strings/errors'
|
import {cleanError} from '../../src/lib/strings/errors'
|
||||||
|
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||||
|
|
||||||
describe('detectLinkables', () => {
|
describe('detectLinkables', () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
|
@ -335,32 +335,6 @@ describe('toShareUrl', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getYoutubeVideoId', () => {
|
|
||||||
it(' should return undefined for invalid youtube links', () => {
|
|
||||||
expect(getYoutubeVideoId('')).toBeUndefined()
|
|
||||||
expect(getYoutubeVideoId('https://www.google.com')).toBeUndefined()
|
|
||||||
expect(getYoutubeVideoId('https://www.youtube.com')).toBeUndefined()
|
|
||||||
expect(
|
|
||||||
getYoutubeVideoId('https://www.youtube.com/channelName'),
|
|
||||||
).toBeUndefined()
|
|
||||||
expect(
|
|
||||||
getYoutubeVideoId('https://www.youtube.com/channel/channelName'),
|
|
||||||
).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getYoutubeVideoId should return video id for valid youtube links', () => {
|
|
||||||
expect(getYoutubeVideoId('https://www.youtube.com/watch?v=videoId')).toBe(
|
|
||||||
'videoId',
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
getYoutubeVideoId(
|
|
||||||
'https://www.youtube.com/watch?v=videoId&feature=share',
|
|
||||||
),
|
|
||||||
).toBe('videoId')
|
|
||||||
expect(getYoutubeVideoId('https://youtu.be/videoId')).toBe('videoId')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('shortenLinks', () => {
|
describe('shortenLinks', () => {
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'start https://middle.com/foo/bar?baz=bux#hash end',
|
'start https://middle.com/foo/bar?baz=bux#hash end',
|
||||||
|
@ -396,6 +370,7 @@ describe('shortenLinks', () => {
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
it('correctly shortens rich text while preserving facet URIs', () => {
|
it('correctly shortens rich text while preserving facet URIs', () => {
|
||||||
for (let i = 0; i < inputs.length; i++) {
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
const input = inputs[i]
|
const input = inputs[i]
|
||||||
|
@ -410,3 +385,141 @@ describe('shortenLinks', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseEmbedPlayerFromUrl', () => {
|
||||||
|
const inputs = [
|
||||||
|
'https://youtu.be/videoId',
|
||||||
|
'https://www.youtube.com/watch?v=videoId',
|
||||||
|
'https://www.youtube.com/watch?v=videoId&feature=share',
|
||||||
|
'https://youtube.com/watch?v=videoId',
|
||||||
|
'https://youtube.com/watch?v=videoId&feature=share',
|
||||||
|
'https://youtube.com/shorts/videoId',
|
||||||
|
|
||||||
|
'https://youtube.com/shorts/',
|
||||||
|
'https://youtube.com/',
|
||||||
|
'https://youtube.com/random',
|
||||||
|
|
||||||
|
'https://twitch.tv/channelName',
|
||||||
|
'https://www.twitch.tv/channelName',
|
||||||
|
|
||||||
|
'https://open.spotify.com/playlist/playlistId',
|
||||||
|
'https://open.spotify.com/playlist/playlistId?param=value',
|
||||||
|
|
||||||
|
'https://open.spotify.com/track/songId',
|
||||||
|
'https://open.spotify.com/track/songId?param=value',
|
||||||
|
|
||||||
|
'https://open.spotify.com/album/albumId',
|
||||||
|
'https://open.spotify.com/album/albumId?param=value',
|
||||||
|
|
||||||
|
'https://soundcloud.com/user/track',
|
||||||
|
'https://soundcloud.com/user/sets/set',
|
||||||
|
'https://soundcloud.com/user/',
|
||||||
|
]
|
||||||
|
|
||||||
|
const outputs = [
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId: 'videoId',
|
||||||
|
playerUri: 'https://www.youtube.com/embed/videoId?autoplay=1',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'twitch_live',
|
||||||
|
channelId: 'channelName',
|
||||||
|
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'twitch_live',
|
||||||
|
channelId: 'channelName',
|
||||||
|
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=channelName&parent=localhost`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'spotify_playlist',
|
||||||
|
playlistId: 'playlistId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'spotify_playlist',
|
||||||
|
playlistId: 'playlistId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/playlist/playlistId`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'spotify_song',
|
||||||
|
songId: 'songId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'spotify_song',
|
||||||
|
songId: 'songId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/track/songId`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'spotify_album',
|
||||||
|
albumId: 'albumId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'spotify_album',
|
||||||
|
albumId: 'albumId',
|
||||||
|
playerUri: `https://open.spotify.com/embed/album/albumId`,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'soundcloud_track',
|
||||||
|
user: 'user',
|
||||||
|
track: 'track',
|
||||||
|
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/track&auto_play=true&visual=false&hide_related=true`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'soundcloud_set',
|
||||||
|
user: 'user',
|
||||||
|
set: 'set',
|
||||||
|
playerUri: `https://w.soundcloud.com/player/?url=https://soundcloud.com/user/sets/set&auto_play=true&visual=false&hide_related=true`,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
]
|
||||||
|
|
||||||
|
it('correctly grabs the correct id from uri', () => {
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
const input = inputs[i]
|
||||||
|
const output = outputs[i]
|
||||||
|
|
||||||
|
const res = parseEmbedPlayerFromUrl(input)
|
||||||
|
|
||||||
|
console.log(input)
|
||||||
|
|
||||||
|
expect(res).toEqual(output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -160,6 +160,9 @@
|
||||||
"react-native-version-number": "^0.3.6",
|
"react-native-version-number": "^0.3.6",
|
||||||
"react-native-web": "~0.19.6",
|
"react-native-web": "~0.19.6",
|
||||||
"react-native-web-linear-gradient": "^1.1.2",
|
"react-native-web-linear-gradient": "^1.1.2",
|
||||||
|
"react-native-web-webview": "^1.0.2",
|
||||||
|
"react-native-webview": "^13.6.2",
|
||||||
|
"react-native-youtube-iframe": "^2.3.0",
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"rn-fetch-blob": "^0.12.0",
|
"rn-fetch-blob": "^0.12.0",
|
||||||
"sentry-expo": "~7.0.1",
|
"sentry-expo": "~7.0.1",
|
||||||
|
|
|
@ -0,0 +1,147 @@
|
||||||
|
export type EmbedPlayerParams =
|
||||||
|
| {type: 'youtube_video'; videoId: string; playerUri: string}
|
||||||
|
| {type: 'twitch_live'; channelId: string; playerUri: string}
|
||||||
|
| {type: 'spotify_album'; albumId: string; playerUri: string}
|
||||||
|
| {
|
||||||
|
type: 'spotify_playlist'
|
||||||
|
playlistId: string
|
||||||
|
playerUri: string
|
||||||
|
}
|
||||||
|
| {type: 'spotify_song'; songId: string; playerUri: string}
|
||||||
|
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
|
||||||
|
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
|
||||||
|
|
||||||
|
export function parseEmbedPlayerFromUrl(
|
||||||
|
url: string,
|
||||||
|
): EmbedPlayerParams | undefined {
|
||||||
|
let urlp
|
||||||
|
try {
|
||||||
|
urlp = new URL(url)
|
||||||
|
} catch (e) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// youtube
|
||||||
|
if (urlp.hostname === 'youtu.be') {
|
||||||
|
const videoId = urlp.pathname.split('/')[1]
|
||||||
|
if (videoId) {
|
||||||
|
return {
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId,
|
||||||
|
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
|
||||||
|
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||||
|
const videoId =
|
||||||
|
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||||
|
|
||||||
|
if (videoId) {
|
||||||
|
return {
|
||||||
|
type: 'youtube_video',
|
||||||
|
videoId,
|
||||||
|
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// twitch
|
||||||
|
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
|
||||||
|
const parts = urlp.pathname.split('/')
|
||||||
|
if (parts.length === 2 && parts[1]) {
|
||||||
|
return {
|
||||||
|
type: 'twitch_live',
|
||||||
|
channelId: parts[1],
|
||||||
|
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=localhost`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotify
|
||||||
|
if (urlp.hostname === 'open.spotify.com') {
|
||||||
|
const [_, type, id] = urlp.pathname.split('/')
|
||||||
|
if (type && id) {
|
||||||
|
if (type === 'playlist') {
|
||||||
|
return {
|
||||||
|
type: 'spotify_playlist',
|
||||||
|
playlistId: id,
|
||||||
|
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'album') {
|
||||||
|
return {
|
||||||
|
type: 'spotify_album',
|
||||||
|
albumId: id,
|
||||||
|
playerUri: `https://open.spotify.com/embed/album/${id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'track') {
|
||||||
|
return {
|
||||||
|
type: 'spotify_song',
|
||||||
|
songId: id,
|
||||||
|
playerUri: `https://open.spotify.com/embed/track/${id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// soundcloud
|
||||||
|
if (
|
||||||
|
urlp.hostname === 'soundcloud.com' ||
|
||||||
|
urlp.hostname === 'www.soundcloud.com'
|
||||||
|
) {
|
||||||
|
const [_, user, trackOrSets, set] = urlp.pathname.split('/')
|
||||||
|
|
||||||
|
if (user && trackOrSets) {
|
||||||
|
if (trackOrSets === 'sets' && set) {
|
||||||
|
return {
|
||||||
|
type: 'soundcloud_set',
|
||||||
|
user,
|
||||||
|
set: set,
|
||||||
|
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'soundcloud_track',
|
||||||
|
user,
|
||||||
|
track: trackOrSets,
|
||||||
|
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayerHeight({
|
||||||
|
type,
|
||||||
|
width,
|
||||||
|
hasThumb,
|
||||||
|
}: {
|
||||||
|
type: EmbedPlayerParams['type']
|
||||||
|
width: number
|
||||||
|
hasThumb: boolean
|
||||||
|
}) {
|
||||||
|
if (!hasThumb) return (width / 16) * 9
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'youtube_video':
|
||||||
|
case 'twitch_live':
|
||||||
|
return (width / 16) * 9
|
||||||
|
case 'spotify_album':
|
||||||
|
return 380
|
||||||
|
case 'spotify_playlist':
|
||||||
|
return 360
|
||||||
|
case 'spotify_song':
|
||||||
|
if (width <= 300) {
|
||||||
|
return 180
|
||||||
|
}
|
||||||
|
return 232
|
||||||
|
case 'soundcloud_track':
|
||||||
|
return 165
|
||||||
|
case 'soundcloud_set':
|
||||||
|
return 360
|
||||||
|
default:
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
}
|
|
@ -139,35 +139,6 @@ export function feedUriToHref(url: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getYoutubeVideoId(link: string): string | undefined {
|
|
||||||
let url
|
|
||||||
try {
|
|
||||||
url = new URL(link)
|
|
||||||
} catch (e) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
url.hostname !== 'www.youtube.com' &&
|
|
||||||
url.hostname !== 'youtube.com' &&
|
|
||||||
url.hostname !== 'youtu.be'
|
|
||||||
) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if (url.hostname === 'youtu.be') {
|
|
||||||
const videoId = url.pathname.split('/')[1]
|
|
||||||
if (!videoId) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return videoId
|
|
||||||
}
|
|
||||||
const videoId = url.searchParams.get('v') as string
|
|
||||||
if (!videoId) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return videoId
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the label in the post text matches the host of the link facet.
|
* Checks if the label in the post text matches the host of the link facet.
|
||||||
*
|
*
|
||||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -6,22 +6,28 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||||
|
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
|
||||||
|
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||||
|
|
||||||
export const ExternalLinkEmbed = ({
|
export const ExternalLinkEmbed = ({
|
||||||
link,
|
link,
|
||||||
imageChild,
|
|
||||||
}: {
|
}: {
|
||||||
link: AppBskyEmbedExternal.ViewExternal
|
link: AppBskyEmbedExternal.ViewExternal
|
||||||
imageChild?: React.ReactNode
|
|
||||||
}) => {
|
}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const embedPlayerParams = React.useMemo(
|
||||||
|
() => parseEmbedPlayerFromUrl(link.uri),
|
||||||
|
[link.uri],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
flexDirection: isMobile ? 'column' : 'row',
|
flexDirection: !isMobile && !embedPlayerParams ? 'row' : 'column',
|
||||||
}}>
|
}}>
|
||||||
{link.thumb ? (
|
{link.thumb && !embedPlayerParams ? (
|
||||||
<View
|
<View
|
||||||
style={
|
style={
|
||||||
!isMobile
|
!isMobile
|
||||||
|
@ -45,9 +51,11 @@ export const ExternalLinkEmbed = ({
|
||||||
source={{uri: link.thumb}}
|
source={{uri: link.thumb}}
|
||||||
accessibilityIgnoresInvertColors
|
accessibilityIgnoresInvertColors
|
||||||
/>
|
/>
|
||||||
{imageChild}
|
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
{embedPlayerParams && (
|
||||||
|
<ExternalPlayer link={link} params={embedPlayerParams} />
|
||||||
|
)}
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: isMobile ? 10 : 14,
|
paddingHorizontal: isMobile ? 10 : 14,
|
||||||
|
|
|
@ -0,0 +1,251 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Dimensions,
|
||||||
|
GestureResponderEvent,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {WebView} from 'react-native-webview'
|
||||||
|
import YoutubePlayer from 'react-native-youtube-iframe'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
|
||||||
|
import {EventStopper} from '../EventStopper'
|
||||||
|
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||||
|
import {isNative} from 'platform/detection'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
|
|
||||||
|
interface ShouldStartLoadRequest {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// This renders the overlay when the player is either inactive or loading as a separate layer
|
||||||
|
function PlaceholderOverlay({
|
||||||
|
isLoading,
|
||||||
|
isPlayerActive,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
isLoading: boolean
|
||||||
|
isPlayerActive: boolean
|
||||||
|
onPress: (event: GestureResponderEvent) => void
|
||||||
|
}) {
|
||||||
|
// If the player is active and not loading, we don't want to show the overlay.
|
||||||
|
if (isPlayerActive && !isLoading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.layer, styles.overlayLayer]}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Play Video"
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onPress}
|
||||||
|
style={[styles.overlayContainer, styles.topRadius]}>
|
||||||
|
{!isPlayerActive ? (
|
||||||
|
<FontAwesomeIcon icon="play" size={42} color="white" />
|
||||||
|
) : (
|
||||||
|
<ActivityIndicator size="large" color="white" />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This renders the webview/youtube player as a separate layer
|
||||||
|
function Player({
|
||||||
|
height,
|
||||||
|
params,
|
||||||
|
onLoad,
|
||||||
|
isPlayerActive,
|
||||||
|
}: {
|
||||||
|
isPlayerActive: boolean
|
||||||
|
params: EmbedPlayerParams
|
||||||
|
height: number
|
||||||
|
onLoad: () => void
|
||||||
|
}) {
|
||||||
|
// ensures we only load what's requested
|
||||||
|
const onShouldStartLoadWithRequest = React.useCallback(
|
||||||
|
(event: ShouldStartLoadRequest) => event.url === params.playerUri,
|
||||||
|
[params.playerUri],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Don't show the player until it is active
|
||||||
|
if (!isPlayerActive) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.layer, styles.playerLayer]}>
|
||||||
|
<EventStopper>
|
||||||
|
{isNative && params.type === 'youtube_video' ? (
|
||||||
|
<YoutubePlayer
|
||||||
|
videoId={params.videoId}
|
||||||
|
play
|
||||||
|
height={height}
|
||||||
|
onReady={onLoad}
|
||||||
|
webViewStyle={[styles.webview, styles.topRadius]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={{height, width: '100%'}}>
|
||||||
|
<WebView
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||||
|
mediaPlaybackRequiresUserAction={false}
|
||||||
|
allowsInlineMediaPlayback
|
||||||
|
bounces={false}
|
||||||
|
allowsFullscreenVideo
|
||||||
|
nestedScrollEnabled
|
||||||
|
source={{uri: params.playerUri}}
|
||||||
|
onLoad={onLoad}
|
||||||
|
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
|
||||||
|
style={[styles.webview, styles.topRadius]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</EventStopper>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This renders the player area and handles the logic for when to show the player and when to show the overlay
|
||||||
|
export function ExternalPlayer({
|
||||||
|
link,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
link: AppBskyEmbedExternal.ViewExternal
|
||||||
|
params: EmbedPlayerParams
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
|
const [isPlayerActive, setPlayerActive] = React.useState(false)
|
||||||
|
const [isLoading, setIsLoading] = React.useState(true)
|
||||||
|
const [dim, setDim] = React.useState({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const viewRef = React.useRef<View>(null)
|
||||||
|
|
||||||
|
// watch for leaving the viewport due to scrolling
|
||||||
|
React.useEffect(() => {
|
||||||
|
// Interval for scrolling works in most cases, However, for twitch embeds, if we navigate away from the screen the webview will
|
||||||
|
// continue playing. We need to watch for the blur event
|
||||||
|
const unsubscribe = navigation.addListener('blur', () => {
|
||||||
|
setPlayerActive(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
viewRef.current?.measure((x, y, w, h, pageX, pageY) => {
|
||||||
|
const window = Dimensions.get('window')
|
||||||
|
const top = pageY
|
||||||
|
const bot = pageY + h
|
||||||
|
const isVisible = isNative
|
||||||
|
? top >= 0 && bot <= window.height
|
||||||
|
: !(top >= window.height || bot <= 0)
|
||||||
|
if (!isVisible) {
|
||||||
|
setPlayerActive(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 1e3)
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [viewRef, navigation])
|
||||||
|
|
||||||
|
// calculate height for the player and the screen size
|
||||||
|
const height = React.useMemo(
|
||||||
|
() =>
|
||||||
|
getPlayerHeight({
|
||||||
|
type: params.type,
|
||||||
|
width: dim.width,
|
||||||
|
hasThumb: !!link.thumb,
|
||||||
|
}),
|
||||||
|
[params.type, dim.width, link.thumb],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onLoad = React.useCallback(() => {
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
|
||||||
|
// Prevent this from propagating upward on web
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
setPlayerActive(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// measure the layout to set sizing
|
||||||
|
const onLayout = React.useCallback(
|
||||||
|
(event: {nativeEvent: {layout: {width: any; height: any}}}) => {
|
||||||
|
setDim({
|
||||||
|
width: event.nativeEvent.layout.width,
|
||||||
|
height: event.nativeEvent.layout.height,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
ref={viewRef}
|
||||||
|
style={{height}}
|
||||||
|
collapsable={false}
|
||||||
|
onLayout={onLayout}>
|
||||||
|
{link.thumb && (!isPlayerActive || isLoading) && (
|
||||||
|
<Image
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: dim.width,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
styles.topRadius,
|
||||||
|
]}
|
||||||
|
source={{uri: link.thumb}}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PlaceholderOverlay
|
||||||
|
isLoading={isLoading}
|
||||||
|
isPlayerActive={isPlayerActive}
|
||||||
|
onPress={onPlayPress}
|
||||||
|
/>
|
||||||
|
<Player
|
||||||
|
isPlayerActive={isPlayerActive}
|
||||||
|
params={params}
|
||||||
|
height={height}
|
||||||
|
onLoad={onLoad}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
topRadius: {
|
||||||
|
borderTopLeftRadius: 6,
|
||||||
|
borderTopRightRadius: 6,
|
||||||
|
},
|
||||||
|
layer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
overlayContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
overlayLayer: {
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
playerLayer: {
|
||||||
|
zIndex: 3,
|
||||||
|
},
|
||||||
|
webview: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
})
|
|
@ -1,55 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
|
||||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
|
||||||
import {Link} from '../Link'
|
|
||||||
|
|
||||||
export const YoutubeEmbed = ({
|
|
||||||
link,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
link: AppBskyEmbedExternal.ViewExternal
|
|
||||||
style?: StyleProp<ViewStyle>
|
|
||||||
}) => {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
|
|
||||||
const imageChild = (
|
|
||||||
<View style={styles.playButton}>
|
|
||||||
<FontAwesomeIcon icon="play" size={24} color="white" />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
asAnchor
|
|
||||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
|
||||||
href={link.uri}>
|
|
||||||
<ExternalLinkEmbed link={link} imageChild={imageChild} />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
extOuter: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
playButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
alignSelf: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
top: '44%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'black',
|
|
||||||
padding: 10,
|
|
||||||
borderRadius: 50,
|
|
||||||
opacity: 0.8,
|
|
||||||
},
|
|
||||||
webView: {
|
|
||||||
alignItems: 'center',
|
|
||||||
alignContent: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -23,9 +23,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||||
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
|
import {useLightboxControls, ImagesLightbox} from '#/state/lightbox'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {YoutubeEmbed} from './YoutubeEmbed'
|
|
||||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
|
||||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||||
import {ListEmbed} from './ListEmbed'
|
import {ListEmbed} from './ListEmbed'
|
||||||
|
@ -168,19 +166,13 @@ export function PostEmbeds({
|
||||||
// =
|
// =
|
||||||
if (AppBskyEmbedExternal.isView(embed)) {
|
if (AppBskyEmbedExternal.isView(embed)) {
|
||||||
const link = embed.external
|
const link = embed.external
|
||||||
const youtubeVideoId = getYoutubeVideoId(link.uri)
|
|
||||||
|
|
||||||
if (youtubeVideoId) {
|
|
||||||
return <YoutubeEmbed link={link} style={style} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<View style={[styles.extOuter, pal.view, pal.border, style]}>
|
||||||
asAnchor
|
<Link asAnchor href={link.uri}>
|
||||||
style={[styles.extOuter, pal.view, pal.border, style]}
|
<ExternalLinkEmbed link={link} />
|
||||||
href={link.uri}>
|
</Link>
|
||||||
<ExternalLinkEmbed link={link} />
|
</View>
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
41
yarn.lock
41
yarn.lock
|
@ -10433,16 +10433,16 @@ escape-html@~1.0.3:
|
||||||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
|
||||||
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
|
||||||
|
|
||||||
|
escape-string-regexp@2.0.0, escape-string-regexp@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
|
||||||
|
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
||||||
|
|
||||||
escape-string-regexp@^1.0.5:
|
escape-string-regexp@^1.0.5:
|
||||||
version "1.0.5"
|
version "1.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||||
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
|
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
|
||||||
|
|
||||||
escape-string-regexp@^2.0.0:
|
|
||||||
version "2.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344"
|
|
||||||
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
|
|
||||||
|
|
||||||
escape-string-regexp@^4.0.0:
|
escape-string-regexp@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||||
|
@ -12501,7 +12501,7 @@ interpret@^3.1.1:
|
||||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
|
resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
|
||||||
integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
|
integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
|
||||||
|
|
||||||
invariant@*, invariant@^2.2.4:
|
invariant@*, invariant@2.2.4, invariant@^2.2.4:
|
||||||
version "2.2.4"
|
version "2.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||||
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
|
||||||
|
@ -17330,6 +17330,13 @@ qs@6.11.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel "^1.0.4"
|
side-channel "^1.0.4"
|
||||||
|
|
||||||
|
qs@^6.5.1:
|
||||||
|
version "6.11.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
|
||||||
|
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
|
||||||
|
dependencies:
|
||||||
|
side-channel "^1.0.4"
|
||||||
|
|
||||||
query-string@^7.1.3:
|
query-string@^7.1.3:
|
||||||
version "7.1.3"
|
version "7.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
|
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
|
||||||
|
@ -17680,6 +17687,13 @@ react-native-web-linear-gradient@^1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz#33f85f7085a0bb5ffa5106faf02ed105b92a9ed7"
|
resolved "https://registry.yarnpkg.com/react-native-web-linear-gradient/-/react-native-web-linear-gradient-1.1.2.tgz#33f85f7085a0bb5ffa5106faf02ed105b92a9ed7"
|
||||||
integrity sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==
|
integrity sha512-SmUnpwT49CEe78pXvIvYf72Es8Pv+ZYKCnEOgb2zAKpEUDMo0+xElfRJhwt5nfI8krJ5WbFPKnoDgD0uUjAN1A==
|
||||||
|
|
||||||
|
react-native-web-webview@^1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-web-webview/-/react-native-web-webview-1.0.2.tgz#c215efa70c17589f2c8d640b1f1dc669b18c6e02"
|
||||||
|
integrity sha512-oNAYNuqUqeqTuAAdIejzDqvUtYA+k5lrvhUYmASdUznZNmyIaoQFA6OKoA4K9F3wdMvark42vUXkUWIp875ewg==
|
||||||
|
dependencies:
|
||||||
|
qs "^6.5.1"
|
||||||
|
|
||||||
react-native-web@~0.19.6:
|
react-native-web@~0.19.6:
|
||||||
version "0.19.8"
|
version "0.19.8"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.8.tgz#46127f8b310148fde11e4fef67fe625603599d47"
|
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.8.tgz#46127f8b310148fde11e4fef67fe625603599d47"
|
||||||
|
@ -17694,6 +17708,21 @@ react-native-web@~0.19.6:
|
||||||
postcss-value-parser "^4.2.0"
|
postcss-value-parser "^4.2.0"
|
||||||
styleq "^0.1.3"
|
styleq "^0.1.3"
|
||||||
|
|
||||||
|
react-native-webview@^13.6.2:
|
||||||
|
version "13.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.6.2.tgz#0a9b18793e915add5b5dbdbf32509d7751b49167"
|
||||||
|
integrity sha512-QzhQ5JCU+Nf2W285DtvCZOVQy/MkJXMwNDYPZvOWQbAOgxJMSSO+BtqXTMA1UPugDsko6PxJ0TxSlUwIwJijDg==
|
||||||
|
dependencies:
|
||||||
|
escape-string-regexp "2.0.0"
|
||||||
|
invariant "2.2.4"
|
||||||
|
|
||||||
|
react-native-youtube-iframe@^2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-native-youtube-iframe/-/react-native-youtube-iframe-2.3.0.tgz#40ca8e55db929b91bfa8e8d30e411658cbc304c5"
|
||||||
|
integrity sha512-M+z63xwXVtS4dX3k8PbtHUUcWN+gRZt6J1EtPE7Y60BMOB979KjpkdrHqeR96or9pNR2W8K5tQhIkMXW2jwo7Q==
|
||||||
|
dependencies:
|
||||||
|
events "^3.2.0"
|
||||||
|
|
||||||
react-native@0.72.5:
|
react-native@0.72.5:
|
||||||
version "0.72.5"
|
version "0.72.5"
|
||||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"
|
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"
|
||||||
|
|
Loading…
Reference in New Issue