forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {Dimensions} from 'react-native'
2
3import {isSafari} from '#/lib/browser'
4import {isWeb} from '#/platform/detection'
5
6const {height: SCREEN_HEIGHT} = Dimensions.get('window')
7
8const IFRAME_HOST = isWeb
9 ? // @ts-ignore only for web
10 window.location.host === 'localhost:8100'
11 ? 'http://localhost:8100'
12 : 'https://witchsky.app'
13 : __DEV__ && !process.env.JEST_WORKER_ID
14 ? 'http://localhost:8100'
15 : 'https://witchsky.app'
16
17export const embedPlayerSources = [
18 'youtube',
19 'youtubeShorts',
20 'twitch',
21 'spotify',
22 'soundcloud',
23 'appleMusic',
24 'vimeo',
25 'giphy',
26 'tenor',
27 'flickr',
28 'streamplace',
29] as const
30
31export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
32
33export type EmbedPlayerType =
34 | 'youtube_video'
35 | 'youtube_short'
36 | 'twitch_video'
37 | 'spotify_album'
38 | 'spotify_playlist'
39 | 'spotify_song'
40 | 'soundcloud_track'
41 | 'soundcloud_set'
42 | 'apple_music_playlist'
43 | 'apple_music_album'
44 | 'apple_music_song'
45 | 'vimeo_video'
46 | 'giphy_gif'
47 | 'tenor_gif'
48 | 'flickr_album'
49 | 'streamplace_stream'
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 streamplace: 'Streamplace',
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 = isWeb
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 if (urlp.hostname === 'stream.place') {
468 return {
469 type: 'streamplace_stream',
470 source: 'streamplace',
471 playerUri: `https://stream.place/embed${urlp.pathname}`,
472 }
473 }
474}
475
476export function getPlayerAspect({
477 type,
478 hasThumb,
479 width,
480}: {
481 type: EmbedPlayerParams['type']
482 hasThumb: boolean
483 width: number
484}): {aspectRatio?: number; height?: number} {
485 if (!hasThumb) return {aspectRatio: 16 / 9}
486
487 switch (type) {
488 case 'youtube_video':
489 case 'twitch_video':
490 case 'vimeo_video':
491 return {aspectRatio: 16 / 9}
492 case 'youtube_short':
493 if (SCREEN_HEIGHT < 600) {
494 return {aspectRatio: (9 / 16) * 1.75}
495 } else {
496 return {aspectRatio: (9 / 16) * 1.5}
497 }
498 case 'spotify_album':
499 case 'apple_music_album':
500 case 'apple_music_playlist':
501 case 'spotify_playlist':
502 case 'soundcloud_set':
503 return {height: 380}
504 case 'spotify_song':
505 if (width <= 300) {
506 return {height: 155}
507 }
508 return {height: 232}
509 case 'soundcloud_track':
510 return {height: 165}
511 case 'apple_music_song':
512 return {height: 150}
513 default:
514 return {aspectRatio: 16 / 9}
515 }
516}
517
518export function getGifDims(
519 originalHeight: number,
520 originalWidth: number,
521 viewWidth: number,
522) {
523 const scaledHeight = (originalHeight / originalWidth) * viewWidth
524
525 return {
526 height: scaledHeight > 250 ? 250 : scaledHeight,
527 width: (250 / scaledHeight) * viewWidth,
528 }
529}
530
531export function getGiphyMetaUri(url: URL) {
532 if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
533 const params = parseEmbedPlayerFromUrl(url.toString())
534 if (params && params.type === 'giphy_gif') {
535 return params.metaUri
536 }
537 }
538}
539
540export function parseTenorGif(urlp: URL):
541 | {success: false}
542 | {
543 success: true
544 playerUri: string
545 dimensions: {height: number; width: number}
546 } {
547 if (urlp.hostname !== 'media.tenor.com') {
548 return {success: false}
549 }
550
551 let [__, id, filename] = urlp.pathname.split('/')
552
553 if (!id || !filename) {
554 return {success: false}
555 }
556
557 if (!id.includes('AAAAC')) {
558 return {success: false}
559 }
560
561 const h = urlp.searchParams.get('hh')
562 const w = urlp.searchParams.get('ww')
563
564 if (!h || !w) {
565 return {success: false}
566 }
567
568 const dimensions = {
569 height: Number(h),
570 width: Number(w),
571 }
572
573 if (isWeb) {
574 if (isSafari) {
575 id = id.replace('AAAAC', 'AAAP1')
576 filename = filename.replace('.gif', '.mp4')
577 } else {
578 id = id.replace('AAAAC', 'AAAP3')
579 filename = filename.replace('.gif', '.webm')
580 }
581 } else {
582 id = id.replace('AAAAC', 'AAAAM')
583 }
584
585 return {
586 success: true,
587 playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
588 dimensions,
589 }
590}
591
592export function isTenorGifUri(url: URL | string) {
593 try {
594 return parseTenorGif(typeof url === 'string' ? new URL(url) : url).success
595 } catch {
596 // Invalid URL
597 return false
598 }
599}