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