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