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