Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Frazee ff265204f3 Implement embed player for YT, spotify, and twitch 2023-10-06 16:53:52 -07:00
8 changed files with 405 additions and 98 deletions
+3
View File
@@ -146,6 +146,9 @@
"react-native-version-number": "^0.3.6",
"react-native-web": "~0.19.6",
"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",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.0",
+83
View File
@@ -0,0 +1,83 @@
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}
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}`,
}
}
}
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
const videoId = urlp.searchParams.get('v') as string
if (videoId) {
return {
type: 'youtube_video',
videoId,
playerUri: `https://www.youtube.com/embed/${videoId}`,
}
}
}
// 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}`,
}
}
}
}
}
-29
View File
@@ -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
}
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
if (!labelDomain) {
@@ -9,10 +9,8 @@ import {toNiceDomain} from 'lib/strings/url-helpers'
export const ExternalLinkEmbed = ({
link,
imageChild,
}: {
link: AppBskyEmbedExternal.ViewExternal
imageChild?: React.ReactNode
}) => {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
@@ -45,7 +43,6 @@ export const ExternalLinkEmbed = ({
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
{imageChild}
</View>
) : undefined}
<View
@@ -0,0 +1,272 @@
import React from 'react'
import {
ActivityIndicator,
Dimensions,
Pressable,
StyleProp,
View,
ViewStyle,
} 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} from 'lib/strings/embed-player'
import {usePalette} from 'lib/hooks/usePalette'
import {Text} from '../text/Text'
import {EventStopper} from '../EventStopper'
import {AppBskyEmbedExternal} from '@atproto/api'
import {isNative} from 'platform/detection'
const ALL_ORIGINS = ['*']
interface ShouldStartLoadRequest {
url: string
}
export function ExternalPlayerEmbed({
link,
params,
style,
}: {
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
const [isPlayerActive, setPlayerActive] = React.useState(false)
const [dim, setDim] = React.useState({
width: 0,
height: 0,
})
// measure the layout to set sizing
const onLayout = (event: {
nativeEvent: {layout: {width: any; height: any}}
}) => {
setDim({
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height,
})
}
// calculate height for the player and the screen size
const height = React.useMemo(() => {
if (params.type === 'youtube_video') {
return (dim.width / 16) * 9
}
if (params.type === 'spotify_song') {
if (dim.width <= 300) {
return 180
}
return 232
}
if (params.type === 'spotify_playlist') {
return 420
}
if (params.type === 'spotify_album') {
return 420
}
return dim.width
}, [params.type, dim])
// HACK
if (link.thumb && link.thumb.includes('bsky.public.url')) {
link.thumb = link.thumb.replace(
'https://bsky.public.url',
'http://localhost:56022',
)
}
if (isPlayerActive) {
return (
<View style={[{marginTop: 4}, style]} onLayout={onLayout}>
<EventStopper>
<Player
width={dim.width}
height={height}
link={link}
params={params}
onLeaveViewport={() => setPlayerActive(false)}
/>
</EventStopper>
</View>
)
}
return (
<Pressable
style={[
{
borderRadius: 8,
marginTop: 4,
},
pal.view,
style,
]}
onPress={() => setPlayerActive(true)}
accessibilityRole="button"
accessibilityLabel={link.title}
accessibilityHint=""
onLayout={onLayout}>
<Placeholder
width={dim.width}
height={height}
link={link}
params={params}
isLoading={false}
/>
</Pressable>
)
}
function Placeholder({
width,
height,
params,
link,
isLoading,
}: {
width: number
height: number
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
isLoading: boolean
}) {
return (
<View>
{link.thumb ? (
<Image
style={{width, height, borderRadius: 6}}
source={{uri: link.thumb}}
accessibilityIgnoresInvertColors
/>
) : (
<View />
)}
<View
style={{
position: 'absolute',
bottom: 0,
left: 0,
width,
backgroundColor: '#000c',
paddingHorizontal: 20,
paddingVertical: 18,
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
flexDirection: 'row',
gap: 10,
}}>
<View style={{paddingTop: 6, width: 30}}>
{isLoading ? (
<ActivityIndicator />
) : (
<FontAwesomeIcon icon="play" size={24} color="white" />
)}
</View>
<View style={{flex: 1}}>
<Text type="lg-bold" numberOfLines={2} style={{color: '#fff'}}>
{link.title || link.uri}
</Text>
{params.type.startsWith('youtube') && (
<Text style={{color: '#fff'}}>YouTube</Text>
)}
{params.type.startsWith('twitch') && (
<Text style={{color: '#fff'}}>Twitch.tv</Text>
)}
{params.type.startsWith('spotify') && (
<Text style={{color: '#fff'}}>Spotify</Text>
)}
</View>
</View>
</View>
)
}
function Player({
width,
height,
link,
params,
onLeaveViewport,
}: {
width: number
height: number
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
onLeaveViewport: () => void
}) {
const ref = React.useRef<View>(null)
const [loading, setLoading] = React.useState(true)
// watch for leaving the viewport due to scrolling
React.useEffect(() => {
const interval = setInterval(() => {
ref.current?.measure((x, y, width, height, pageX, pageY) => {
const window = Dimensions.get('window')
const top = pageY
const bot = pageY + height
const isVisible = isNative
? top >= 0 && bot <= window.height
: !(top >= window.height || bot <= 0)
if (!isVisible) {
onLeaveViewport()
}
})
}, 1e3)
return () => {
clearInterval(interval)
}
}, [ref, onLeaveViewport])
// ensures we only load what's requested
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => event.url === params.playerUri,
[params.playerUri],
)
// TODO: is this needed?
const originWhitelist =
params.type === 'spotify_album' ||
params.type === 'spotify_playlist' ||
params.type === 'spotify_song'
? ALL_ORIGINS
: undefined
return (
<View ref={ref} style={{height}}>
{isNative && params.type === 'youtube_video' ? (
<YoutubePlayer
videoId={params.videoId}
play
height={height}
onReady={() => setLoading(false)}
/>
) : (
<WebView
javaScriptEnabled={true}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
originWhitelist={originWhitelist}
mediaPlaybackRequiresUserAction={false}
allowsInlineMediaPlayback
bounces={false}
allowsFullscreenVideo
source={{uri: params.playerUri}}
onLoad={() => setLoading(false)}
/>
)}
{loading && (
<View style={{position: 'absolute', left: 0, top: 0}}>
<Placeholder
width={width}
height={height}
params={params}
link={link}
isLoading
/>
</View>
)}
</View>
)
}
@@ -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',
},
})
+12 -5
View File
@@ -23,9 +23,9 @@ import {ImagesLightbox} from 'state/models/ui/shell'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {YoutubeEmbed} from './YoutubeEmbed'
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import {ExternalPlayerEmbed} from './ExternalPlayerEmbed'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {CustomFeedEmbed} from './CustomFeedEmbed'
@@ -154,10 +154,17 @@ export function PostEmbeds({
// =
if (AppBskyEmbedExternal.isView(embed)) {
const link = embed.external
const youtubeVideoId = getYoutubeVideoId(link.uri)
// TODO factor this out!
const embedPlayerParams = parseEmbedPlayerFromUrl(link.uri)
if (youtubeVideoId) {
return <YoutubeEmbed link={link} style={style} />
if (embedPlayerParams) {
return (
<ExternalPlayerEmbed
link={link}
params={embedPlayerParams}
style={style}
/>
)
}
return (
+35 -6
View File
@@ -8693,16 +8693,16 @@ escape-html@~1.0.3:
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
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:
version "1.0.5"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
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:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
@@ -10690,7 +10690,7 @@ interpret@^3.1.1:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
invariant@*, invariant@^2.2.4:
invariant@*, invariant@2.2.4, invariant@^2.2.4:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
@@ -15486,6 +15486,13 @@ qs@6.11.0:
dependencies:
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:
version "7.1.3"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
@@ -15833,6 +15840,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"
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:
version "0.19.8"
resolved "https://registry.yarnpkg.com/react-native-web/-/react-native-web-0.19.8.tgz#46127f8b310148fde11e4fef67fe625603599d47"
@@ -15847,6 +15861,21 @@ react-native-web@~0.19.6:
postcss-value-parser "^4.2.0"
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:
version "0.72.5"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.72.5.tgz#2c343fa6f3ead362cf07376634a33a4078864357"