Bluesky app fork with some witchin' additions 💫
1import {Dimensions} from 'react-native'
2import {isDid} from '@atproto/api'
3
4import {isValidHandle} from '#/lib/strings/handles'
5import {IS_WEB, IS_WEB_SAFARI} from '#/env'
6
7const {height: SCREEN_HEIGHT} = Dimensions.get('window')
8
9const IFRAME_HOST = IS_WEB
10 ? // @ts-ignore only for web
11 window.location.host === 'localhost:8100'
12 ? 'http://localhost:8100'
13 : 'https://witchsky.app'
14 : __DEV__ && !process.env.JEST_WORKER_ID
15 ? 'http://localhost:8100'
16 : 'https://bsky.app'
17
18export const embedPlayerSources = [
19 'youtube',
20 'youtubeShorts',
21 'twitch',
22 'spotify',
23 'soundcloud',
24 'appleMusic',
25 'vimeo',
26 'giphy',
27 'tenor',
28 'klipy',
29 'flickr',
30 'bandcamp',
31 'streamplace',
32] as const
33
34export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
35
36export type EmbedPlayerType =
37 | 'youtube_video'
38 | 'youtube_short'
39 | 'twitch_video'
40 | 'spotify_album'
41 | 'spotify_playlist'
42 | 'spotify_song'
43 | 'soundcloud_track'
44 | 'soundcloud_set'
45 | 'apple_music_playlist'
46 | 'apple_music_album'
47 | 'apple_music_song'
48 | 'vimeo_video'
49 | 'giphy_gif'
50 | 'tenor_gif'
51 | 'klipy_gif'
52 | 'flickr_album'
53 | 'bandcamp_album'
54 | 'bandcamp_track'
55 | 'streamplace_stream'
56
57export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
58 youtube: 'YouTube',
59 youtubeShorts: 'YouTube Shorts',
60 vimeo: 'Vimeo',
61 twitch: 'Twitch',
62 giphy: 'GIPHY',
63 tenor: 'Tenor',
64 klipy: 'KLIPY',
65 spotify: 'Spotify',
66 appleMusic: 'Apple Music',
67 soundcloud: 'SoundCloud',
68 flickr: 'Flickr',
69 bandcamp: 'Bandcamp',
70 streamplace: 'Streamplace',
71}
72
73export interface EmbedPlayerParams {
74 type: EmbedPlayerType
75 playerUri: string
76 isGif?: boolean
77 source: EmbedPlayerSource
78 metaUri?: string
79 hideDetails?: boolean
80 dimensions?: {
81 height: number
82 width: number
83 }
84}
85
86const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
87const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
88
89export function parseEmbedPlayerFromUrl(
90 url: string,
91): EmbedPlayerParams | undefined {
92 let urlp
93 try {
94 urlp = new URL(url)
95 } catch (e) {
96 return undefined
97 }
98
99 // youtube
100 if (urlp.hostname === 'youtu.be') {
101 const videoId = urlp.pathname.split('/')[1]
102 const t = urlp.searchParams.get('t') ?? '0'
103 const seek = encodeURIComponent(t.replace(/s$/, ''))
104
105 if (videoId) {
106 return {
107 type: 'youtube_video',
108 source: 'youtube',
109 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
110 }
111 }
112 }
113 if (
114 urlp.hostname === 'www.youtube.com' ||
115 urlp.hostname === 'youtube.com' ||
116 urlp.hostname === 'm.youtube.com' ||
117 urlp.hostname === 'music.youtube.com'
118 ) {
119 const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/')
120
121 const isShorts = page === 'shorts'
122 const isLive = page === 'live'
123 const videoId =
124 isShorts || isLive
125 ? shortOrLiveVideoId
126 : (urlp.searchParams.get('v') as string)
127 const t = urlp.searchParams.get('t') ?? '0'
128 const seek = encodeURIComponent(t.replace(/s$/, ''))
129
130 if (videoId) {
131 return {
132 type: isShorts ? 'youtube_short' : 'youtube_video',
133 source: isShorts ? 'youtubeShorts' : 'youtube',
134 hideDetails: isShorts ? true : undefined,
135 playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
136 }
137 }
138 }
139
140 // twitch
141 if (
142 urlp.hostname === 'twitch.tv' ||
143 urlp.hostname === 'www.twitch.tv' ||
144 urlp.hostname === 'm.twitch.tv'
145 ) {
146 const parent = IS_WEB
147 ? // @ts-ignore only for web
148 window.location.hostname
149 : 'localhost'
150
151 const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
152
153 if (channelOrVideo === 'videos') {
154 return {
155 type: 'twitch_video',
156 source: 'twitch',
157 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
158 }
159 } else if (clipOrId === 'clip') {
160 return {
161 type: 'twitch_video',
162 source: 'twitch',
163 playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
164 }
165 } else if (channelOrVideo) {
166 return {
167 type: 'twitch_video',
168 source: 'twitch',
169 playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
170 }
171 }
172 }
173
174 // spotify
175 if (urlp.hostname === 'open.spotify.com') {
176 const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
177
178 if (idOrType) {
179 if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
180 return {
181 type: 'spotify_playlist',
182 source: 'spotify',
183 playerUri: `https://open.spotify.com/embed/playlist/${
184 id ?? idOrType
185 }`,
186 }
187 }
188 if (typeOrLocale === 'album' || idOrType === 'album') {
189 return {
190 type: 'spotify_album',
191 source: 'spotify',
192 playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
193 }
194 }
195 if (typeOrLocale === 'track' || idOrType === 'track') {
196 return {
197 type: 'spotify_song',
198 source: 'spotify',
199 playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
200 }
201 }
202 if (typeOrLocale === 'episode' || idOrType === 'episode') {
203 return {
204 type: 'spotify_song',
205 source: 'spotify',
206 playerUri: `https://open.spotify.com/embed/episode/${id ?? idOrType}`,
207 }
208 }
209 if (typeOrLocale === 'show' || idOrType === 'show') {
210 return {
211 type: 'spotify_song',
212 source: 'spotify',
213 playerUri: `https://open.spotify.com/embed/show/${id ?? idOrType}`,
214 }
215 }
216 }
217 }
218
219 // soundcloud
220 if (
221 urlp.hostname === 'soundcloud.com' ||
222 urlp.hostname === 'www.soundcloud.com'
223 ) {
224 const [__, user, trackOrSets, set] = urlp.pathname.split('/')
225
226 if (user && trackOrSets) {
227 if (trackOrSets === 'sets' && set) {
228 return {
229 type: 'soundcloud_set',
230 source: 'soundcloud',
231 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
232 }
233 }
234
235 return {
236 type: 'soundcloud_track',
237 source: 'soundcloud',
238 playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
239 }
240 }
241 }
242
243 if (
244 urlp.hostname === 'music.apple.com' ||
245 urlp.hostname === 'music.apple.com'
246 ) {
247 // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
248 // to check if the length is correct
249 const pathParams = urlp.pathname.split('/')
250 const type = pathParams[2]
251 const songId = urlp.searchParams.get('i')
252
253 if (
254 pathParams.length === 5 &&
255 (type === 'playlist' || type === 'album' || type === 'song')
256 ) {
257 // We want to append the songId to the end of the url if it exists
258 const embedUri = `https://embed.music.apple.com${urlp.pathname}${
259 songId ? `?i=${songId}` : ''
260 }`
261
262 if (type === 'playlist') {
263 return {
264 type: 'apple_music_playlist',
265 source: 'appleMusic',
266 playerUri: embedUri,
267 }
268 } else if (type === 'album') {
269 if (songId) {
270 return {
271 type: 'apple_music_song',
272 source: 'appleMusic',
273 playerUri: embedUri,
274 }
275 } else {
276 return {
277 type: 'apple_music_album',
278 source: 'appleMusic',
279 playerUri: embedUri,
280 }
281 }
282 } else if (type === 'song') {
283 return {
284 type: 'apple_music_song',
285 source: 'appleMusic',
286 playerUri: embedUri,
287 }
288 }
289 }
290 }
291
292 if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
293 const [__, videoId] = urlp.pathname.split('/')
294 if (videoId) {
295 return {
296 type: 'vimeo_video',
297 source: 'vimeo',
298 playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
299 }
300 }
301 }
302
303 if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
304 const [__, gifs, nameAndId] = urlp.pathname.split('/')
305
306 /*
307 * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
308 * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
309 * use it in an <Image> component
310 */
311
312 if (gifs === 'gifs' && nameAndId) {
313 const gifId = nameAndId.split('-').pop()
314
315 if (gifId) {
316 return {
317 type: 'giphy_gif',
318 source: 'giphy',
319 isGif: true,
320 hideDetails: true,
321 metaUri: `https://giphy.com/gifs/${gifId}`,
322 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
323 }
324 }
325 }
326 }
327
328 // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
329 // These can include (presumably) a tracking id in the path name, so we have to check for that as well
330 if (giphyRegex.test(urlp.hostname)) {
331 // We can link directly to the gif, if its a proper link
332 const [__, media, trackingOrId, idOrFilename, filename] =
333 urlp.pathname.split('/')
334
335 if (media === 'media') {
336 if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
337 return {
338 type: 'giphy_gif',
339 source: 'giphy',
340 isGif: true,
341 hideDetails: true,
342 metaUri: `https://giphy.com/gifs/${trackingOrId}`,
343 playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
344 }
345 } else if (filename && gifFilenameRegex.test(filename)) {
346 return {
347 type: 'giphy_gif',
348 source: 'giphy',
349 isGif: true,
350 hideDetails: true,
351 metaUri: `https://giphy.com/gifs/${idOrFilename}`,
352 playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
353 }
354 }
355 }
356 }
357
358 // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
359 // be .webp
360 if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
361 const [__, mediaOrFilename, filename] = urlp.pathname.split('/')
362
363 if (mediaOrFilename === 'media' && filename) {
364 const gifId = filename.split('.')[0]
365 return {
366 type: 'giphy_gif',
367 source: 'giphy',
368 isGif: true,
369 hideDetails: true,
370 metaUri: `https://giphy.com/gifs/${gifId}`,
371 playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
372 }
373 } else if (mediaOrFilename) {
374 const gifId = mediaOrFilename.split('.')[0]
375 return {
376 type: 'giphy_gif',
377 source: 'giphy',
378 isGif: true,
379 hideDetails: true,
380 metaUri: `https://giphy.com/gifs/${gifId}`,
381 playerUri: `https://i.giphy.com/media/${
382 mediaOrFilename.split('.')[0]
383 }/200.webp`,
384 }
385 }
386 }
387
388 const tenorGif = parseTenorGif(urlp)
389 if (tenorGif.success) {
390 const {playerUri, dimensions} = tenorGif
391
392 return {
393 type: 'tenor_gif',
394 source: 'tenor',
395 isGif: true,
396 hideDetails: true,
397 playerUri,
398 dimensions,
399 }
400 }
401
402 const klipyGif = parseKlipyGif(urlp)
403 if (klipyGif.success) {
404 const {playerUri, dimensions} = klipyGif
405
406 return {
407 type: 'klipy_gif',
408 source: 'klipy',
409 isGif: true,
410 hideDetails: true,
411 playerUri,
412 dimensions,
413 }
414 }
415
416 // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
417 if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
418 let i = urlp.pathname.length - 1
419 while (i > 0 && urlp.pathname.charAt(i) === '/') {
420 --i
421 }
422
423 const path_components = urlp.pathname.slice(1, i + 1).split('/')
424 if (path_components.length === 4) {
425 // discard username - it's not relevant
426 const [photos, __, albums, id] = path_components
427 if (photos === 'photos' && albums === 'albums') {
428 // this at least has the shape of a valid photo-album URL!
429 return {
430 type: 'flickr_album',
431 source: 'flickr',
432 playerUri: `https://embedr.flickr.com/photosets/${id}`,
433 }
434 }
435 }
436
437 if (path_components.length === 3) {
438 const [groups, id, pool] = path_components
439 if (groups === 'groups' && pool === 'pool') {
440 return {
441 type: 'flickr_album',
442 source: 'flickr',
443 playerUri: `https://embedr.flickr.com/groups/${id}`,
444 }
445 }
446 }
447 // not an album or a group pool, don't know what to do with this!
448 return undefined
449 }
450
451 // link shortened flickr path
452 if (urlp.hostname === 'flic.kr') {
453 const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ'
454 let [__, type, idBase58Enc] = urlp.pathname.split('/')
455 let id = 0n
456 for (const char of idBase58Enc) {
457 const nextIdx = b58alph.indexOf(char)
458 if (nextIdx >= 0) {
459 id = id * 58n + BigInt(nextIdx)
460 } else {
461 // not b58 encoded, ergo not a valid link to embed
462 return undefined
463 }
464 }
465
466 switch (type) {
467 case 'go':
468 const formattedGroupId = `${id}`
469 return {
470 type: 'flickr_album',
471 source: 'flickr',
472 playerUri: `https://embedr.flickr.com/groups/${formattedGroupId.slice(
473 0,
474 -2,
475 )}@N${formattedGroupId.slice(-2)}`,
476 }
477 case 's':
478 return {
479 type: 'flickr_album',
480 source: 'flickr',
481 playerUri: `https://embedr.flickr.com/photosets/${id}`,
482 }
483 default:
484 // we don't know what this is so we can't embed it
485 return undefined
486 }
487 }
488
489 const bandcampRegex = /^[a-z\d][a-z\d-]{2,}[a-z\d]\.bandcamp\.com$/i
490
491 if (bandcampRegex.test(urlp.hostname)) {
492 const pathComponents = urlp.pathname.split('/')
493 switch (pathComponents[1]) {
494 case 'album':
495 return {
496 type: 'bandcamp_album',
497 source: 'bandcamp',
498 playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
499 urlp.href,
500 )}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
501 }
502 case 'track':
503 return {
504 type: 'bandcamp_track',
505 source: 'bandcamp',
506 playerUri: `https://bandcamp.com/EmbeddedPlayer/url=${encodeURIComponent(
507 urlp.href,
508 )}/size=large/bgcol=ffffff/linkcol=0687f5/minimal=true/transparent=true/`,
509 }
510 default:
511 return undefined
512 }
513 }
514
515 if (urlp.hostname === 'stream.place') {
516 if (isValidStreamPlaceUrl(urlp)) {
517 return {
518 type: 'streamplace_stream',
519 source: 'streamplace',
520 playerUri: `https://stream.place/embed${urlp.pathname}`,
521 }
522 }
523 }
524}
525
526function isValidStreamPlaceUrl(urlp: URL): boolean {
527 // stream.place URLs should have a path like /did:plc:xxx/... or /handle.bsky.social/...
528 const pathParts = urlp.pathname.split('/').filter(Boolean)
529 if (pathParts.length === 0) {
530 return false
531 }
532
533 // The first part of the path should be either a valid DID or a valid handle
534 const identifier = pathParts[0]
535 return isDid(identifier) || isValidHandle(identifier)
536}
537
538export function getPlayerAspect({
539 type,
540 hasThumb,
541 width,
542}: {
543 type: EmbedPlayerParams['type']
544 hasThumb: boolean
545 width: number
546}): {aspectRatio?: number; height?: number} {
547 if (!hasThumb) return {aspectRatio: 16 / 9}
548
549 switch (type) {
550 case 'youtube_video':
551 case 'twitch_video':
552 case 'vimeo_video':
553 return {aspectRatio: 16 / 9}
554 case 'youtube_short':
555 if (SCREEN_HEIGHT < 600) {
556 return {aspectRatio: (9 / 16) * 1.75}
557 } else {
558 return {aspectRatio: (9 / 16) * 1.5}
559 }
560 case 'spotify_album':
561 case 'apple_music_album':
562 case 'apple_music_playlist':
563 case 'spotify_playlist':
564 case 'soundcloud_set':
565 return {height: 380}
566 case 'spotify_song':
567 if (width <= 300) {
568 return {height: 155}
569 }
570 return {height: 232}
571 case 'soundcloud_track':
572 return {height: 165}
573 case 'apple_music_song':
574 return {height: 150}
575 case 'bandcamp_album':
576 case 'bandcamp_track':
577 return {aspectRatio: 1}
578 default:
579 return {aspectRatio: 16 / 9}
580 }
581}
582
583export function getGifDims(
584 originalHeight: number,
585 originalWidth: number,
586 viewWidth: number,
587) {
588 const scaledHeight = (originalHeight / originalWidth) * viewWidth
589
590 return {
591 height: scaledHeight > 250 ? 250 : scaledHeight,
592 width: (250 / scaledHeight) * viewWidth,
593 }
594}
595
596export function getGiphyMetaUri(url: URL) {
597 if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
598 const params = parseEmbedPlayerFromUrl(url.toString())
599 if (params && params.type === 'giphy_gif') {
600 return params.metaUri
601 }
602 }
603}
604
605export function parseTenorGif(urlp: URL):
606 | {success: false}
607 | {
608 success: true
609 playerUri: string
610 dimensions: {height: number; width: number}
611 } {
612 if (urlp.hostname !== 'media.tenor.com') {
613 return {success: false}
614 }
615
616 let [__, id, filename] = urlp.pathname.split('/')
617
618 if (!id || !filename) {
619 return {success: false}
620 }
621
622 if (!id.includes('AAAAC')) {
623 return {success: false}
624 }
625
626 const h = urlp.searchParams.get('hh')
627 const w = urlp.searchParams.get('ww')
628
629 if (!h || !w) {
630 return {success: false}
631 }
632
633 const dimensions = {
634 height: Number(h),
635 width: Number(w),
636 }
637
638 // Validate dimensions are valid positive numbers
639 if (
640 isNaN(dimensions.height) ||
641 isNaN(dimensions.width) ||
642 dimensions.height <= 0 ||
643 dimensions.width <= 0
644 ) {
645 return {success: false}
646 }
647
648 if (IS_WEB) {
649 if (IS_WEB_SAFARI) {
650 id = id.replace('AAAAC', 'AAAP1')
651 filename = filename.replace('.gif', '.mp4')
652 } else {
653 id = id.replace('AAAAC', 'AAAP3')
654 filename = filename.replace('.gif', '.webm')
655 }
656 } else {
657 id = id.replace('AAAAC', 'AAAAM')
658 }
659
660 return {
661 success: true,
662 playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
663 dimensions,
664 }
665}
666
667export function isTenorGifUri(url: URL | string) {
668 try {
669 return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success
670 } catch {
671 // Invalid URL
672 return false
673 }
674}
675
676export function parseKlipyGif(urlp: URL):
677 | {success: false}
678 | {
679 success: true
680 playerUri: string
681 dimensions: {height: number; width: number}
682 } {
683 if (urlp.hostname !== 'static.klipy.com') {
684 return {success: false}
685 }
686
687 if (!urlp.pathname.startsWith('/ii/')) {
688 return {success: false}
689 }
690
691 const h = urlp.searchParams.get('hh')
692 const w = urlp.searchParams.get('ww')
693
694 if (!h || !w) {
695 return {success: false}
696 }
697
698 const dimensions = {
699 height: Number(h),
700 width: Number(w),
701 }
702
703 // Validate dimensions are valid positive numbers
704 if (
705 isNaN(dimensions.height) ||
706 isNaN(dimensions.width) ||
707 dimensions.height <= 0 ||
708 dimensions.width <= 0
709 ) {
710 return {success: false}
711 }
712
713 const playerUrl = new URL(urlp.href)
714 playerUrl.hostname = 'k.gifs.bsky.app'
715
716 // On web, swap the gif filename for a video format so the <video>
717 // element can play it. Klipy uses different filename slugs per
718 // format (unlike Tenor's ID-based scheme), so the slugs are
719 // embedded as query params at composition time by resolveGif().
720 if (IS_WEB) {
721 const webmSlug = playerUrl.searchParams.get('webm')
722 const mp4Slug = playerUrl.searchParams.get('mp4')
723 const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
724 const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
725
726 // Without a slug we can't produce a playable video URL on web,
727 // so fall back to the link card instead of returning a broken player.
728 if (!slug) {
729 return {success: false}
730 }
731
732 const parts = playerUrl.pathname.split('/')
733 parts[parts.length - 1] = `${slug}.${ext}`
734 playerUrl.pathname = parts.join('/')
735 }
736
737 // Strip all metadata params — only the path matters for the CDN
738 playerUrl.searchParams.delete('hh')
739 playerUrl.searchParams.delete('ww')
740 playerUrl.searchParams.delete('mp4')
741 playerUrl.searchParams.delete('webm')
742
743 return {
744 success: true,
745 playerUri: playerUrl.href,
746 dimensions,
747 }
748}
749
750export function isKlipyGifUri(url: URL | string) {
751 try {
752 return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success
753 } catch {
754 // Invalid URL
755 return false
756 }
757}
758
759export function isGifEmbed(url: URL | string) {
760 return isTenorGifUri(url) || isKlipyGifUri(url)
761}