Bluesky app fork with some witchin' additions 馃挮
at 2200cc95d841c4de3adf30cebb7508745fdfafec 588 lines 16 kB view raw
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}