mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {Dimensions, Platform} from 'react-native'
2
3import {isSafari} from 'lib/browser'
4import {isWeb} from 'platform/detection'
5const {height: SCREEN_HEIGHT} = Dimensions.get('window')
6
7const IFRAME_HOST = isWeb
8 ? // @ts-ignore only for web
9 window.location.host === 'localhost:8100'
10 ? 'http://localhost:8100'
11 : 'https://bsky.app'
12 : __DEV__ && !process.env.JEST_WORKER_ID
13 ? 'http://localhost:8100'
14 : 'https://bsky.app'
15
16export const embedPlayerSources = [
17 'youtube',
18 'youtubeShorts',
19 'twitch',
20 'spotify',
21 'soundcloud',
22 'appleMusic',
23 'vimeo',
24 'giphy',
25 'tenor',
26 'flickr',
27] as const
28
29export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
30
31export type EmbedPlayerType =
32 | 'youtube_video'
33 | 'youtube_short'
34 | 'twitch_video'
35 | 'spotify_album'
36 | 'spotify_playlist'
37 | 'spotify_song'
38 | 'soundcloud_track'
39 | 'soundcloud_set'
40 | 'apple_music_playlist'
41 | 'apple_music_album'
42 | 'apple_music_song'
43 | 'vimeo_video'
44 | 'giphy_gif'
45 | 'tenor_gif'
46 | 'flickr_album'
47
48export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
49 youtube: 'YouTube',
50 youtubeShorts: 'YouTube Shorts',
51 vimeo: 'Vimeo',
52 twitch: 'Twitch',
53 giphy: 'GIPHY',
54 tenor: 'Tenor',
55 spotify: 'Spotify',
56 appleMusic: 'Apple Music',
57 soundcloud: 'SoundCloud',
58 flickr: 'Flickr',
59}
60
61export interface EmbedPlayerParams {
62 type: EmbedPlayerType
63 playerUri: string
64 isGif?: boolean
65 source: EmbedPlayerSource
66 metaUri?: string
67 hideDetails?: boolean
68 dimensions?: {
69 height: number
70 width: number
71 }
72}
73
74const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
75const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
76
77export function parseEmbedPlayerFromUrl(
78 url: string,
79): EmbedPlayerParams | undefined {
80 let urlp
81 try {
82 urlp = new URL(url)
83 } catch (e) {
84 return undefined
85 }
86
87 // youtube
88 if (urlp.hostname === 'youtu.be') {
89 const videoId = urlp.pathname.split('/')[1]
90 const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
91 if (videoId) {
92 return {
93 type: 'youtube_video',
94 source: 'youtube',
95 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
96 }
97 }
98 }
99 if (
100 urlp.hostname === 'www.youtube.com' ||
101 urlp.hostname === 'youtube.com' ||
102 urlp.hostname === 'm.youtube.com' ||
103 urlp.hostname === 'music.youtube.com'
104 ) {
105 const [_, page, shortVideoId] = urlp.pathname.split('/')
106 const videoId =
107 page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
108 const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
109
110 if (videoId) {
111 return {
112 type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
113 source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
114 hideDetails: page === 'shorts' ? true : undefined,
115 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
116 }
117 }
118 }
119
120 // twitch
121 if (
122 urlp.hostname === 'twitch.tv' ||
123 urlp.hostname === 'www.twitch.tv' ||
124 urlp.hostname === 'm.twitch.tv'
125 ) {
126 const parent = isWeb
127 ? // @ts-ignore only for web
128 window.location.hostname
129 : 'localhost'
130
131 const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
132
133 if (channelOrVideo === 'videos') {
134 return {
135 type: 'twitch_video',
136 source: 'twitch',
137 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
138 }
139 } else if (clipOrId === 'clip') {
140 return {
141 type: 'twitch_video',
142 source: 'twitch',
143 playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
144 }
145 } else if (channelOrVideo) {
146 return {
147 type: 'twitch_video',
148 source: 'twitch',
149 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
150 }
151 }
152 }
153
154 // spotify
155 if (urlp.hostname === 'open.spotify.com') {
156 const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
157
158 if (idOrType) {
159 if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
160 return {
161 type: 'spotify_playlist',
162 source: 'spotify',
163 playerUri: `https://open.spotify.com/embed/playlist/${
164 id ?? idOrType
165 }`,
166 }
167 }
168 if (typeOrLocale === 'album' || idOrType === 'album') {
169 return {
170 type: 'spotify_album',
171 source: 'spotify',
172 playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
173 }
174 }
175 if (typeOrLocale === 'track' || idOrType === 'track') {
176 return {
177 type: 'spotify_song',
178 source: 'spotify',
179 playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
180 }
181 }
182 }
183 }
184
185 // soundcloud
186 if (
187 urlp.hostname === 'soundcloud.com' ||
188 urlp.hostname === 'www.soundcloud.com'
189 ) {
190 const [_, user, trackOrSets, set] = urlp.pathname.split('/')
191
192 if (user && trackOrSets) {
193 if (trackOrSets === 'sets' && set) {
194 return {
195 type: 'soundcloud_set',
196 source: 'soundcloud',
197 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
198 }
199 }
200
201 return {
202 type: 'soundcloud_track',
203 source: 'soundcloud',
204 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
205 }
206 }
207 }
208
209 if (
210 urlp.hostname === 'music.apple.com' ||
211 urlp.hostname === 'music.apple.com'
212 ) {
213 // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
214 // to check if the length is correct
215 const pathParams = urlp.pathname.split('/')
216 const type = pathParams[2]
217 const songId = urlp.searchParams.get('i')
218
219 if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) {
220 // We want to append the songId to the end of the url if it exists
221 const embedUri = `https://embed.music.apple.com${urlp.pathname}${
222 urlp.search ? '?i=' + songId : ''
223 }`
224
225 if (type === 'playlist') {
226 return {
227 type: 'apple_music_playlist',
228 source: 'appleMusic',
229 playerUri: embedUri,
230 }
231 } else if (type === 'album') {
232 if (songId) {
233 return {
234 type: 'apple_music_song',
235 source: 'appleMusic',
236 playerUri: embedUri,
237 }
238 } else {
239 return {
240 type: 'apple_music_album',
241 source: 'appleMusic',
242 playerUri: embedUri,
243 }
244 }
245 }
246 }
247 }
248
249 if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
250 const [_, videoId] = urlp.pathname.split('/')
251 if (videoId) {
252 return {
253 type: 'vimeo_video',
254 source: 'vimeo',
255 playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
256 }
257 }
258 }
259
260 if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
261 const [_, gifs, nameAndId] = urlp.pathname.split('/')
262
263 /*
264 * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
265 * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
266 * use it in an <Image> component
267 */
268
269 if (gifs === 'gifs' && nameAndId) {
270 const gifId = nameAndId.split('-').pop()
271
272 if (gifId) {
273 return {
274 type: 'giphy_gif',
275 source: 'giphy',
276 isGif: true,
277 hideDetails: true,
278 metaUri: `https://giphy.com/gifs/${gifId}`,
279 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
280 }
281 }
282 }
283 }
284
285 // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
286 // These can include (presumably) a tracking id in the path name, so we have to check for that as well
287 if (giphyRegex.test(urlp.hostname)) {
288 // We can link directly to the gif, if its a proper link
289 const [_, media, trackingOrId, idOrFilename, filename] =
290 urlp.pathname.split('/')
291
292 if (media === 'media') {
293 if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
294 return {
295 type: 'giphy_gif',
296 source: 'giphy',
297 isGif: true,
298 hideDetails: true,
299 metaUri: `https://giphy.com/gifs/${trackingOrId}`,
300 playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
301 }
302 } else if (filename && gifFilenameRegex.test(filename)) {
303 return {
304 type: 'giphy_gif',
305 source: 'giphy',
306 isGif: true,
307 hideDetails: true,
308 metaUri: `https://giphy.com/gifs/${idOrFilename}`,
309 playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
310 }
311 }
312 }
313 }
314
315 // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
316 // be .webp
317 if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
318 const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
319
320 if (mediaOrFilename === 'media' && filename) {
321 const gifId = filename.split('.')[0]
322 return {
323 type: 'giphy_gif',
324 source: 'giphy',
325 isGif: true,
326 hideDetails: true,
327 metaUri: `https://giphy.com/gifs/${gifId}`,
328 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
329 }
330 } else if (mediaOrFilename) {
331 const gifId = mediaOrFilename.split('.')[0]
332 return {
333 type: 'giphy_gif',
334 source: 'giphy',
335 isGif: true,
336 hideDetails: true,
337 metaUri: `https://giphy.com/gifs/${gifId}`,
338 playerUri: `https://i.giphy.com/media/${
339 mediaOrFilename.split('.')[0]
340 }/200.webp`,
341 }
342 }
343 }
344
345 if (urlp.hostname === 'media.tenor.com') {
346 let [_, id, filename] = urlp.pathname.split('/')
347
348 const h = urlp.searchParams.get('hh')
349 const w = urlp.searchParams.get('ww')
350 let dimensions
351 if (h && w) {
352 dimensions = {
353 height: Number(h),
354 width: Number(w),
355 }
356 }
357
358 if (id && filename && dimensions && id.includes('AAAAC')) {
359 if (Platform.OS === 'web') {
360 if (isSafari) {
361 id = id.replace('AAAAC', 'AAAP1')
362 filename = filename.replace('.gif', '.mp4')
363 } else {
364 id = id.replace('AAAAC', 'AAAP3')
365 filename = filename.replace('.gif', '.webm')
366 }
367 } else {
368 id = id.replace('AAAAC', 'AAAAM')
369 }
370
371 return {
372 type: 'tenor_gif',
373 source: 'tenor',
374 isGif: true,
375 hideDetails: true,
376 playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
377 dimensions,
378 }
379 }
380 }
381
382 // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
383 if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
384 let i = urlp.pathname.length - 1
385 while (i > 0 && urlp.pathname.charAt(i) === '/') {
386 --i
387 }
388
389 const path_components = urlp.pathname.slice(1, i + 1).split('/')
390 if (path_components.length === 4) {
391 // discard username - it's not relevant
392 const [photos, _, albums, id] = path_components
393 if (photos === 'photos' && albums === 'albums') {
394 // this at least has the shape of a valid photo-album URL!
395 return {
396 type: 'flickr_album',
397 source: 'flickr',
398 playerUri: `https://embedr.flickr.com/photosets/${id}`,
399 }
400 }
401 }
402
403 if (path_components.length === 3) {
404 const [groups, id, pool] = path_components
405 if (groups === 'groups' && pool === 'pool') {
406 return {
407 type: 'flickr_album',
408 source: 'flickr',
409 playerUri: `https://embedr.flickr.com/groups/${id}`,
410 }
411 }
412 }
413 // not an album or a group pool, don't know what to do with this!
414 return undefined
415 }
416
417 // link shortened flickr path
418 if (urlp.hostname === 'flic.kr') {
419 const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
420 let [_, type, idBase58Enc] = urlp.pathname.split('/')
421 let id = 0n
422 for (const char of idBase58Enc) {
423 const nextIdx = b58alph.indexOf(char)
424 if (nextIdx >= 0) {
425 id = id * 58n + BigInt(nextIdx)
426 } else {
427 // not b58 encoded, ergo not a valid link to embed
428 return undefined
429 }
430 }
431
432 switch (type) {
433 case 'go':
434 const formattedGroupId = `${id}`
435 return {
436 type: 'flickr_album',
437 source: 'flickr',
438 playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice(
439 0,
440 -2,
441 )}@N${formattedGroupId.slice(-2)}`,
442 }
443 case 's':
444 return {
445 type: 'flickr_album',
446 source: 'flickr',
447 playerUri: `https://embedr.flickr.com/photosets/${id}`,
448 }
449 default:
450 // we don't know what this is so we can't embed it
451 return undefined
452 }
453 }
454}
455
456export function getPlayerAspect({
457 type,
458 hasThumb,
459 width,
460}: {
461 type: EmbedPlayerParams['type']
462 hasThumb: boolean
463 width: number
464}): {aspectRatio?: number; height?: number} {
465 if (!hasThumb) return {aspectRatio: 16 / 9}
466
467 switch (type) {
468 case 'youtube_video':
469 case 'twitch_video':
470 case 'vimeo_video':
471 return {aspectRatio: 16 / 9}
472 case 'youtube_short':
473 if (SCREEN_HEIGHT < 600) {
474 return {aspectRatio: (9 / 16) * 1.75}
475 } else {
476 return {aspectRatio: (9 / 16) * 1.5}
477 }
478 case 'spotify_album':
479 case 'apple_music_album':
480 case 'apple_music_playlist':
481 case 'spotify_playlist':
482 case 'soundcloud_set':
483 return {height: 380}
484 case 'spotify_song':
485 if (width <= 300) {
486 return {height: 155}
487 }
488 return {height: 232}
489 case 'soundcloud_track':
490 return {height: 165}
491 case 'apple_music_song':
492 return {height: 150}
493 default:
494 return {aspectRatio: 16 / 9}
495 }
496}
497
498export function getGifDims(
499 originalHeight: number,
500 originalWidth: number,
501 viewWidth: number,
502) {
503 const scaledHeight = (originalHeight / originalWidth) * viewWidth
504
505 return {
506 height: scaledHeight > 250 ? 250 : scaledHeight,
507 width: (250 / scaledHeight) * viewWidth,
508 }
509}
510
511export function getGiphyMetaUri(url: URL) {
512 if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
513 const params = parseEmbedPlayerFromUrl(url.toString())
514 if (params && params.type === 'giphy_gif') {
515 return params.metaUri
516 }
517 }
518}