Bluesky app fork with some witchin' additions 馃挮
at post-text-option 599 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://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}