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