From ff265204f3064494a95abb1ec081c11546e043ed Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Fri, 6 Oct 2023 16:53:52 -0700 Subject: [PATCH] Implement embed player for YT, spotify, and twitch --- package.json | 3 + src/lib/strings/embed-player.ts | 83 ++++++ src/lib/strings/url-helpers.ts | 29 -- .../util/post-embeds/ExternalLinkEmbed.tsx | 3 - .../util/post-embeds/ExternalPlayerEmbed.tsx | 272 ++++++++++++++++++ .../com/util/post-embeds/YoutubeEmbed.tsx | 55 ---- src/view/com/util/post-embeds/index.tsx | 17 +- yarn.lock | 41 ++- 8 files changed, 405 insertions(+), 98 deletions(-) create mode 100644 src/lib/strings/embed-player.ts create mode 100644 src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx delete mode 100644 src/view/com/util/post-embeds/YoutubeEmbed.tsx diff --git a/package.json b/package.json index a886bfcd01..baeb90c100 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts new file mode 100644 index 0000000000..9e9623c742 --- /dev/null +++ b/src/lib/strings/embed-player.ts @@ -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}`, + } + } + } + } +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 3c27d86397..354674d9ee 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -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) { diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index d5bb38fb2e..c05157953c 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -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} ) : undefined} +}) { + 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 ( + + + setPlayerActive(false)} + /> + + + ) + } + + return ( + setPlayerActive(true)} + accessibilityRole="button" + accessibilityLabel={link.title} + accessibilityHint="" + onLayout={onLayout}> + + + ) +} + +function Placeholder({ + width, + height, + params, + link, + isLoading, +}: { + width: number + height: number + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams + isLoading: boolean +}) { + return ( + + {link.thumb ? ( + + ) : ( + + )} + + + {isLoading ? ( + + ) : ( + + )} + + + + {link.title || link.uri} + + {params.type.startsWith('youtube') && ( + YouTube + )} + {params.type.startsWith('twitch') && ( + Twitch.tv + )} + {params.type.startsWith('spotify') && ( + Spotify + )} + + + + ) +} + +function Player({ + width, + height, + link, + params, + onLeaveViewport, +}: { + width: number + height: number + link: AppBskyEmbedExternal.ViewExternal + params: EmbedPlayerParams + onLeaveViewport: () => void +}) { + const ref = React.useRef(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 ( + + {isNative && params.type === 'youtube_video' ? ( + setLoading(false)} + /> + ) : ( + setLoading(false)} + /> + )} + {loading && ( + + + + )} + + ) +} diff --git a/src/view/com/util/post-embeds/YoutubeEmbed.tsx b/src/view/com/util/post-embeds/YoutubeEmbed.tsx deleted file mode 100644 index 2f2da5662a..0000000000 --- a/src/view/com/util/post-embeds/YoutubeEmbed.tsx +++ /dev/null @@ -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 -}) => { - const pal = usePalette('default') - - const imageChild = ( - - - - ) - - return ( - - - - ) -} - -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', - }, -}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 2d79eed8fd..5647f5847e 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -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 + if (embedPlayerParams) { + return ( + + ) } return ( diff --git a/yarn.lock b/yarn.lock index fffbff57ea..ea84e481b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"