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