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