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