about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-01-04 17:37:36 -0800
committerGitHub <noreply@github.com>2024-01-04 17:37:36 -0800
commit0dae24e78ffe0b2d69349a03f669949e4d5afc21 (patch)
treef2895aebae675d0c5d6c9213626a37a98a53f724 /src
parentdb62f272412df2c34e1a57200291b53fa1cd07aa (diff)
downloadvoidsky-0dae24e78ffe0b2d69349a03f669949e4d5afc21.tar.zst
Additional embed sources and external-media consent controls (#2424)
* add apple music embed

* add vimeo embed

* add logic for tenor and giphy embeds

* keep it simple, use playerUri for images too

* add gif embed player

* lint, fix tests

* remove links that can't produce a thumb

* Revert "remove links that can't produce a thumb"

This reverts commit 985b92b4e622db936bb0c79fdf324099b9c8fcd8.

* Revert "Revert "remove links that can't produce a thumb""

This reverts commit 4895ded8b5120c4fc52b43ae85c9a01ea0b1a733.

* Revert "Revert "Revert "remove links that can't produce a thumb"""

This reverts commit 36d04b517ba5139e1639f2eda28d7f9aaa2dbfb6.

* properly obtain giphy metadata regardless of used url

* test fixes

* adjust gif player

* add all twitch embed types

* support m.youtube links

* few logic adjustments

* adjust spotify player height

* prefetch gif before showing

* use memory-disk cache policy on gifs

* use `disk` cachePolicy on ios - can't start/stop animation

* support pause/play on web

* onLoad fix

* remove extra pressable, add accessibility, fix scale issues

* improve size of embed

* add settings

* fix(?) settings

* add source to embed player params

* update tests

* better naming and settings options

* consent modal

* fix test id

* why is webstorm adding .tsx

* web modal

* simplify types

* adjust snap points

* remove unnecessary yt embed library. just use the webview always

* remove now useless WebGifStill 😭

* more type cleanup

* more type cleanup

* combine parse and prefs check in one memo

* improve dimensions of youtube shorts

* oops didn't commit the test 🫥

* add shorts as separate embed type

* fix up schema

* shorts modal

* hide gif details

* support localized spotify embeds

* more cleanup

* improve look and accessibility of gif embeds

* Update routing for the external embeds settings page

* Update and simplify the external embed preferences screen

* Update copy in embedconsent modal and add 'allow all' button

---------

Co-authored-by: Hailey <me@haileyok.com>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx9
-rw-r--r--src/lib/analytics/types.ts1
-rw-r--r--src/lib/link-meta/link-meta.ts8
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/lib/strings/embed-player.ts342
-rw-r--r--src/routes.ts1
-rw-r--r--src/state/modals/index.tsx8
-rw-r--r--src/state/persisted/legacy.ts1
-rw-r--r--src/state/persisted/schema.ts16
-rw-r--r--src/state/preferences/external-embeds-prefs.tsx54
-rw-r--r--src/state/preferences/index.tsx9
-rw-r--r--src/view/com/modals/EmbedConsent.tsx153
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/util/post-embeds/ExternalGifEmbed.tsx170
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx33
-rw-r--r--src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx84
-rw-r--r--src/view/icons/index.tsx6
-rw-r--r--src/view/screens/PreferencesExternalEmbeds.tsx138
-rw-r--r--src/view/screens/Settings.tsx33
20 files changed, 978 insertions, 96 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 27174a31f..7bb1aa0ad 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -74,6 +74,7 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {SavedFeeds} from 'view/screens/SavedFeeds'
 import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
 import {PreferencesThreads} from 'view/screens/PreferencesThreads'
+import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
 import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -243,6 +244,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         getComponent={() => PreferencesThreads}
         options={{title: title('Threads Preferences'), requireAuth: true}}
       />
+      <Stack.Screen
+        name="PreferencesExternalEmbeds"
+        getComponent={() => PreferencesExternalEmbeds}
+        options={{
+          title: title('External Media Preferences'),
+          requireAuth: true,
+        }}
+      />
     </>
   )
 }
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index 5a24c360a..c84f7979a 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
   Settings: {}
   AppPasswords: {}
   Moderation: {}
+  PreferencesExternalEmbeds: {}
   BlockedAccounts: {}
   MutedAccounts: {}
   SavedFeeds: {}
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c17dee51f..c7c8d4130 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
 import {isBskyAppUrl} from '../strings/url-helpers'
 import {extractBskyMeta} from './bsky'
 import {LINK_META_PROXY} from 'lib/constants'
+import {getGiphyMetaUri} from 'lib/strings/embed-player'
 
 export enum LikelyType {
   HTML,
@@ -34,6 +35,13 @@ export async function getLinkMeta(
   let urlp
   try {
     urlp = new URL(url)
+
+    // Get Giphy meta uri if this is any form of giphy link
+    const giphyMetaUri = getGiphyMetaUri(urlp)
+    if (giphyMetaUri) {
+      url = giphyMetaUri
+      urlp = new URL(url)
+    }
   } catch (e) {
     return {
       error: 'Invalid URL',
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index c157c0ab3..90ae75830 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
   SavedFeeds: undefined
   PreferencesHomeFeed: undefined
   PreferencesThreads: undefined
+  PreferencesExternalEmbeds: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index ec996dfa5..b27fd9e78 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -1,17 +1,59 @@
-import {Platform} from 'react-native'
-
-export type EmbedPlayerParams =
-  | {type: 'youtube_video'; videoId: string; playerUri: string}
-  | {type: 'twitch_live'; channelId: string; playerUri: string}
-  | {type: 'spotify_album'; albumId: string; playerUri: string}
-  | {
-      type: 'spotify_playlist'
-      playlistId: string
-      playerUri: string
-    }
-  | {type: 'spotify_song'; songId: string; playerUri: string}
-  | {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
-  | {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
+import {Dimensions, Platform} from 'react-native'
+const {height: SCREEN_HEIGHT} = Dimensions.get('window')
+
+export const embedPlayerSources = [
+  'youtube',
+  'youtubeShorts',
+  'twitch',
+  'spotify',
+  'soundcloud',
+  'appleMusic',
+  'vimeo',
+  'giphy',
+  'tenor',
+] as const
+
+export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
+
+export type EmbedPlayerType =
+  | 'youtube_video'
+  | 'youtube_short'
+  | 'twitch_video'
+  | 'spotify_album'
+  | 'spotify_playlist'
+  | 'spotify_song'
+  | 'soundcloud_track'
+  | 'soundcloud_set'
+  | 'apple_music_playlist'
+  | 'apple_music_album'
+  | 'apple_music_song'
+  | 'vimeo_video'
+  | 'giphy_gif'
+  | 'tenor_gif'
+
+export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
+  youtube: 'YouTube',
+  youtubeShorts: 'YouTube Shorts',
+  vimeo: 'Vimeo',
+  twitch: 'Twitch',
+  giphy: 'GIPHY',
+  tenor: 'Tenor',
+  spotify: 'Spotify',
+  appleMusic: 'Apple Music',
+  soundcloud: 'SoundCloud',
+}
+
+export interface EmbedPlayerParams {
+  type: EmbedPlayerType
+  playerUri: string
+  isGif?: boolean
+  source: EmbedPlayerSource
+  metaUri?: string
+  hideDetails?: boolean
+}
+
+const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
+const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
 
 export function parseEmbedPlayerFromUrl(
   url: string,
@@ -29,63 +71,88 @@ export function parseEmbedPlayerFromUrl(
     if (videoId) {
       return {
         type: 'youtube_video',
-        videoId,
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
+        source: 'youtube',
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
       }
     }
   }
-  if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
+  if (
+    urlp.hostname === 'www.youtube.com' ||
+    urlp.hostname === 'youtube.com' ||
+    urlp.hostname === 'm.youtube.com'
+  ) {
     const [_, page, shortVideoId] = urlp.pathname.split('/')
     const videoId =
       page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
 
     if (videoId) {
       return {
-        type: 'youtube_video',
-        videoId,
-        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
+        type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
+        source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
+        hideDetails: page === 'shorts' ? true : undefined,
+        playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
       }
     }
   }
 
   // twitch
-  if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
+  if (
+    urlp.hostname === 'twitch.tv' ||
+    urlp.hostname === 'www.twitch.tv' ||
+    urlp.hostname === 'm.twitch.tv'
+  ) {
     const parent =
       Platform.OS === 'web' ? window.location.hostname : 'localhost'
 
-    const parts = urlp.pathname.split('/')
-    if (parts.length === 2 && parts[1]) {
+    const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
+
+    if (channelOrVideo === 'videos') {
+      return {
+        type: 'twitch_video',
+        source: 'twitch',
+        playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
+      }
+    } else if (clipOrId === 'clip') {
       return {
-        type: 'twitch_live',
-        channelId: parts[1],
-        playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=${parent}`,
+        type: 'twitch_video',
+        source: 'twitch',
+        playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
+      }
+    } else if (channelOrVideo) {
+      return {
+        type: 'twitch_video',
+        source: 'twitch',
+        playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
       }
     }
   }
 
   // spotify
   if (urlp.hostname === 'open.spotify.com') {
-    const [_, type, id] = urlp.pathname.split('/')
-    if (type && id) {
-      if (type === 'playlist') {
+    const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
+
+    if (idOrType) {
+      if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
         return {
           type: 'spotify_playlist',
-          playlistId: id,
-          playerUri: `https://open.spotify.com/embed/playlist/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/playlist/${
+            id ?? idOrType
+          }`,
         }
       }
-      if (type === 'album') {
+      if (typeOrLocale === 'album' || idOrType === 'album') {
         return {
           type: 'spotify_album',
-          albumId: id,
-          playerUri: `https://open.spotify.com/embed/album/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
         }
       }
-      if (type === 'track') {
+      if (typeOrLocale === 'track' || idOrType === 'track') {
         return {
           type: 'spotify_song',
-          songId: id,
-          playerUri: `https://open.spotify.com/embed/track/${id}`,
+          source: 'spotify',
+          playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
         }
       }
     }
@@ -102,20 +169,170 @@ export function parseEmbedPlayerFromUrl(
       if (trackOrSets === 'sets' && set) {
         return {
           type: 'soundcloud_set',
-          user,
-          set: set,
+          source: 'soundcloud',
           playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
         }
       }
 
       return {
         type: 'soundcloud_track',
-        user,
-        track: trackOrSets,
+        source: 'soundcloud',
         playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
       }
     }
   }
+
+  if (
+    urlp.hostname === 'music.apple.com' ||
+    urlp.hostname === 'music.apple.com'
+  ) {
+    // This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
+    // to check if the length is correct
+    const pathParams = urlp.pathname.split('/')
+    const type = pathParams[2]
+    const songId = urlp.searchParams.get('i')
+
+    if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) {
+      // We want to append the songId to the end of the url if it exists
+      const embedUri = `https://embed.music.apple.com${urlp.pathname}${
+        urlp.search ? '?i=' + songId : ''
+      }`
+
+      if (type === 'playlist') {
+        return {
+          type: 'apple_music_playlist',
+          source: 'appleMusic',
+          playerUri: embedUri,
+        }
+      } else if (type === 'album') {
+        if (songId) {
+          return {
+            type: 'apple_music_song',
+            source: 'appleMusic',
+            playerUri: embedUri,
+          }
+        } else {
+          return {
+            type: 'apple_music_album',
+            source: 'appleMusic',
+            playerUri: embedUri,
+          }
+        }
+      }
+    }
+  }
+
+  if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
+    const [_, videoId] = urlp.pathname.split('/')
+    if (videoId) {
+      return {
+        type: 'vimeo_video',
+        source: 'vimeo',
+        playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
+      }
+    }
+  }
+
+  if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
+    const [_, gifs, nameAndId] = urlp.pathname.split('/')
+
+    /*
+     * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
+     * We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
+     * use it in an <Image> component
+     */
+
+    if (gifs === 'gifs' && nameAndId) {
+      const gifId = nameAndId.split('-').pop()
+
+      if (gifId) {
+        return {
+          type: 'giphy_gif',
+          source: 'giphy',
+          isGif: true,
+          hideDetails: true,
+          metaUri: `https://giphy.com/gifs/${gifId}`,
+          playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
+        }
+      }
+    }
+  }
+
+  // There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
+  // These can include (presumably) a tracking id in the path name, so we have to check for that as well
+  if (giphyRegex.test(urlp.hostname)) {
+    // We can link directly to the gif, if its a proper link
+    const [_, media, trackingOrId, idOrFilename, filename] =
+      urlp.pathname.split('/')
+
+    if (media === 'media') {
+      if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
+        return {
+          type: 'giphy_gif',
+          source: 'giphy',
+          isGif: true,
+          hideDetails: true,
+          metaUri: `https://giphy.com/gifs/${trackingOrId}`,
+          playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
+        }
+      } else if (filename && gifFilenameRegex.test(filename)) {
+        return {
+          type: 'giphy_gif',
+          source: 'giphy',
+          isGif: true,
+          hideDetails: true,
+          metaUri: `https://giphy.com/gifs/${idOrFilename}`,
+          playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
+        }
+      }
+    }
+  }
+
+  // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
+  // be .webp
+  if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
+    const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
+
+    if (mediaOrFilename === 'media' && filename) {
+      const gifId = filename.split('.')[0]
+      return {
+        type: 'giphy_gif',
+        source: 'giphy',
+        isGif: true,
+        hideDetails: true,
+        metaUri: `https://giphy.com/gifs/${gifId}`,
+        playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
+      }
+    } else if (mediaOrFilename) {
+      const gifId = mediaOrFilename.split('.')[0]
+      return {
+        type: 'giphy_gif',
+        source: 'giphy',
+        isGif: true,
+        hideDetails: true,
+        metaUri: `https://giphy.com/gifs/${gifId}`,
+        playerUri: `https://i.giphy.com/media/${
+          mediaOrFilename.split('.')[0]
+        }/giphy.webp`,
+      }
+    }
+  }
+
+  if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
+    const [_, path, filename] = urlp.pathname.split('/')
+
+    if (path === 'view' && filename) {
+      const includesExt = filename.split('.').pop() === 'gif'
+
+      return {
+        type: 'tenor_gif',
+        source: 'tenor',
+        isGif: true,
+        hideDetails: true,
+        playerUri: `${url}${!includesExt ? '.gif' : ''}`,
+      }
+    }
+  }
 }
 
 export function getPlayerHeight({
@@ -131,22 +348,53 @@ export function getPlayerHeight({
 
   switch (type) {
     case 'youtube_video':
-    case 'twitch_live':
+    case 'twitch_video':
+    case 'vimeo_video':
       return (width / 16) * 9
+    case 'youtube_short':
+      if (SCREEN_HEIGHT < 600) {
+        return ((width / 9) * 16) / 1.75
+      } else {
+        return ((width / 9) * 16) / 1.5
+      }
     case 'spotify_album':
-      return 380
+    case 'apple_music_album':
+    case 'apple_music_playlist':
     case 'spotify_playlist':
-      return 360
+    case 'soundcloud_set':
+      return 380
     case 'spotify_song':
       if (width <= 300) {
-        return 180
+        return 155
       }
       return 232
     case 'soundcloud_track':
       return 165
-    case 'soundcloud_set':
-      return 360
+    case 'apple_music_song':
+      return 150
     default:
       return width
   }
 }
+
+export function getGifDims(
+  originalHeight: number,
+  originalWidth: number,
+  viewWidth: number,
+) {
+  const scaledHeight = (originalHeight / originalWidth) * viewWidth
+
+  return {
+    height: scaledHeight > 250 ? 250 : scaledHeight,
+    width: (250 / scaledHeight) * viewWidth,
+  }
+}
+
+export function getGiphyMetaUri(url: URL) {
+  if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
+    const params = parseEmbedPlayerFromUrl(url.toString())
+    if (params && params.type === 'giphy_gif') {
+      return params.metaUri
+    }
+  }
+}
diff --git a/src/routes.ts b/src/routes.ts
index bb2421987..e58fddd42 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -26,6 +26,7 @@ export const router = new Router({
   AppPasswords: '/settings/app-passwords',
   PreferencesHomeFeed: '/settings/home-feed',
   PreferencesThreads: '/settings/threads',
+  PreferencesExternalEmbeds: '/settings/external-embeds',
   SavedFeeds: '/settings/saved-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 81a220d1b..8c32c472a 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {ImageModel} from '#/state/models/media/image'
 import {GalleryModel} from '#/state/models/media/gallery'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts'
 import {ThreadgateSetting} from '../queries/threadgate'
 
 export interface ConfirmModal {
@@ -180,6 +181,12 @@ export interface LinkWarningModal {
   href: string
 }
 
+export interface EmbedConsentModal {
+  name: 'embed-consent'
+  source: EmbedPlayerSource
+  onAccept: () => void
+}
+
 export type Modal =
   // Account
   | AddAppPasswordModal
@@ -223,6 +230,7 @@ export type Modal =
   // Generic
   | ConfirmModal
   | LinkWarningModal
+  | EmbedConsentModal
 
 const ModalContext = React.createContext<{
   isModalActive: boolean
diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts
index cdb542f5a..334ef1d92 100644
--- a/src/state/persisted/legacy.ts
+++ b/src/state/persisted/legacy.ts
@@ -109,6 +109,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
       step: legacy.onboarding?.step || defaults.onboarding.step,
     },
     hiddenPosts: defaults.hiddenPosts,
+    externalEmbeds: defaults.externalEmbeds,
   }
 }
 
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 27b1f26bd..6a26cedae 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -1,6 +1,8 @@
 import {z} from 'zod'
 import {deviceLocales} from '#/platform/detection'
 
+const externalEmbedOptions = ['show', 'hide'] as const
+
 // only data needed for rendering account page
 const accountSchema = z.object({
   service: z.string(),
@@ -30,6 +32,19 @@ export const schema = z.object({
     appLanguage: z.string(),
   }),
   requireAltTextEnabled: z.boolean(), // should move to server
+  externalEmbeds: z
+    .object({
+      giphy: z.enum(externalEmbedOptions).optional(),
+      tenor: z.enum(externalEmbedOptions).optional(),
+      youtube: z.enum(externalEmbedOptions).optional(),
+      youtubeShorts: z.enum(externalEmbedOptions).optional(),
+      twitch: z.enum(externalEmbedOptions).optional(),
+      vimeo: z.enum(externalEmbedOptions).optional(),
+      spotify: z.enum(externalEmbedOptions).optional(),
+      appleMusic: z.enum(externalEmbedOptions).optional(),
+      soundcloud: z.enum(externalEmbedOptions).optional(),
+    })
+    .optional(),
   mutedThreads: z.array(z.string()), // should move to server
   invites: z.object({
     copiedInvites: z.array(z.string()),
@@ -60,6 +75,7 @@ export const defaults: Schema = {
     appLanguage: deviceLocales[0] || 'en',
   },
   requireAltTextEnabled: false,
+  externalEmbeds: {},
   mutedThreads: [],
   invites: {
     copiedInvites: [],
diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx
new file mode 100644
index 000000000..0f6385fe8
--- /dev/null
+++ b/src/state/preferences/external-embeds-prefs.tsx
@@ -0,0 +1,54 @@
+import React from 'react'
+import * as persisted from '#/state/persisted'
+import {EmbedPlayerSource} from 'lib/strings/embed-player'
+
+type StateContext = persisted.Schema['externalEmbeds']
+type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void
+
+const stateContext = React.createContext<StateContext>(
+  persisted.defaults.externalEmbeds,
+)
+const setContext = React.createContext<SetContext>({} as SetContext)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState(persisted.get('externalEmbeds'))
+
+  const setStateWrapped = React.useCallback(
+    (source: EmbedPlayerSource, value: 'show' | 'hide') => {
+      setState(prev => {
+        persisted.write('externalEmbeds', {
+          ...prev,
+          [source]: value,
+        })
+
+        return {
+          ...prev,
+          [source]: value,
+        }
+      })
+    },
+    [setState],
+  )
+
+  React.useEffect(() => {
+    return persisted.onUpdate(() => {
+      setState(persisted.get('externalEmbeds'))
+    })
+  }, [setStateWrapped])
+
+  return (
+    <stateContext.Provider value={state}>
+      <setContext.Provider value={setStateWrapped}>
+        {children}
+      </setContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useExternalEmbedsPrefs() {
+  return React.useContext(stateContext)
+}
+
+export function useSetExternalEmbedPref() {
+  return React.useContext(setContext)
+}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 5ec659031..cc2d9244c 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -2,19 +2,26 @@ import React from 'react'
 import {Provider as LanguagesProvider} from './languages'
 import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
 import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
+import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
 
 export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
 export {
   useRequireAltTextEnabled,
   useSetRequireAltTextEnabled,
 } from './alt-text-required'
+export {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from './external-embeds-prefs'
 export * from './hidden-posts'
 
 export function Provider({children}: React.PropsWithChildren<{}>) {
   return (
     <LanguagesProvider>
       <AltTextRequiredProvider>
-        <HiddenPostsProvider>{children}</HiddenPostsProvider>
+        <ExternalEmbedsProvider>
+          <HiddenPostsProvider>{children}</HiddenPostsProvider>
+        </ExternalEmbedsProvider>
       </AltTextRequiredProvider>
     </LanguagesProvider>
   )
diff --git a/src/view/com/modals/EmbedConsent.tsx b/src/view/com/modals/EmbedConsent.tsx
new file mode 100644
index 000000000..04104c52e
--- /dev/null
+++ b/src/view/com/modals/EmbedConsent.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {s, colors, gradients} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {ScrollView} from './util'
+import {usePalette} from 'lib/hooks/usePalette'
+import {
+  EmbedPlayerSource,
+  embedPlayerSources,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useModalControls} from '#/state/modals'
+import {useSetExternalEmbedPref} from '#/state/preferences/external-embeds-prefs'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+
+export const snapPoints = [450]
+
+export function Component({
+  onAccept,
+  source,
+}: {
+  onAccept: () => void
+  source: EmbedPlayerSource
+}) {
+  const pal = usePalette('default')
+  const {closeModal} = useModalControls()
+  const {_} = useLingui()
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const {isMobile} = useWebMediaQueries()
+
+  const onShowAllPress = React.useCallback(() => {
+    for (const key of embedPlayerSources) {
+      setExternalEmbedPref(key, 'show')
+    }
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref])
+
+  const onShowPress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'show')
+    onAccept()
+    closeModal()
+  }, [closeModal, onAccept, setExternalEmbedPref, source])
+
+  const onHidePress = React.useCallback(() => {
+    setExternalEmbedPref(source, 'hide')
+    closeModal()
+  }, [closeModal, setExternalEmbedPref, source])
+
+  return (
+    <ScrollView
+      testID="embedConsentModal"
+      style={[
+        s.flex1,
+        pal.view,
+        isMobile
+          ? {paddingHorizontal: 20, paddingTop: 10}
+          : {paddingHorizontal: 30},
+      ]}>
+      <Text style={[pal.text, styles.title]}>
+        <Trans>External Media</Trans>
+      </Text>
+
+      <Text style={pal.text}>
+        <Trans>
+          This content is hosted by {externalEmbedLabels[source]}. Do you want
+          to enable external media?
+        </Trans>
+      </Text>
+      <View style={[s.mt10]} />
+      <Text style={pal.textLight}>
+        <Trans>
+          External media may allow websites to collect information about you and
+          your device. No information is sent or requested until you press the
+          "play" button.
+        </Trans>
+      </Text>
+      <View style={[s.mt20]} />
+      <TouchableOpacity
+        testID="enableAllBtn"
+        onPress={onShowAllPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Show embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>
+            <Trans>Enable External Media</Trans>
+          </Text>
+        </LinearGradient>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="enableSourceBtn"
+        onPress={onShowPress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>Enable {externalEmbedLabels[source]} only</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+      <View style={[s.mt10]} />
+      <TouchableOpacity
+        testID="disableSourceBtn"
+        onPress={onHidePress}
+        accessibilityRole="button"
+        accessibilityLabel={_(
+          msg`Never load embeds from ${externalEmbedLabels[source]}`,
+        )}
+        accessibilityHint=""
+        onAccessibilityEscape={closeModal}>
+        <View style={[styles.btn, pal.btn]}>
+          <Text style={[pal.text, s.bold, s.f18]}>
+            <Trans>No thanks</Trans>
+          </Text>
+        </View>
+      </TouchableOpacity>
+    </ScrollView>
+  )
+}
+
+const styles = StyleSheet.create({
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 2aac20dac..f9d211d07 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -38,6 +38,7 @@ import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as SwitchAccountModal from './SwitchAccount'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
 
 const DEFAULT_SNAPPOINTS = ['90%']
 const HANDLE_HEIGHT = 24
@@ -176,6 +177,9 @@ export function ModalsContainer() {
   } else if (activeModal?.name === 'link-warning') {
     snapPoints = LinkWarningModal.snapPoints
     element = <LinkWarningModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'embed-consent') {
+    snapPoints = EmbedConsentModal.snapPoints
+    element = <EmbedConsentModal.Component {...activeModal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 12138f54d..c43a8a6ce 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -34,6 +34,7 @@ import * as BirthDateSettingsModal from './BirthDateSettings'
 import * as VerifyEmailModal from './VerifyEmail'
 import * as ChangeEmailModal from './ChangeEmail'
 import * as LinkWarningModal from './LinkWarning'
+import * as EmbedConsentModal from './EmbedConsent'
 
 export function ModalsContainer() {
   const {isModalActive, activeModals} = useModals()
@@ -129,6 +130,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ChangeEmailModal.Component />
   } else if (modal.name === 'link-warning') {
     element = <LinkWarningModal.Component {...modal} />
+  } else if (modal.name === 'embed-consent') {
+    element = <EmbedConsentModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/util/post-embeds/ExternalGifEmbed.tsx b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
new file mode 100644
index 000000000..f06c8b794
--- /dev/null
+++ b/src/view/com/util/post-embeds/ExternalGifEmbed.tsx
@@ -0,0 +1,170 @@
+import {EmbedPlayerParams, getGifDims} from 'lib/strings/embed-player'
+import React from 'react'
+import {Image, ImageLoadEventData} from 'expo-image'
+import {
+  ActivityIndicator,
+  GestureResponderEvent,
+  LayoutChangeEvent,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import {AppBskyEmbedExternal} from '@atproto/api'
+
+export function ExternalGifEmbed({
+  link,
+  params,
+}: {
+  link: AppBskyEmbedExternal.ViewExternal
+  params: EmbedPlayerParams
+}) {
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
+  const {_} = useLingui()
+
+  const thumbHasLoaded = React.useRef(false)
+  const viewWidth = React.useRef(0)
+
+  // Tracking if the placer has been activated
+  const [isPlayerActive, setIsPlayerActive] = React.useState(false)
+  // Tracking whether the gif has been loaded yet
+  const [isPrefetched, setIsPrefetched] = React.useState(false)
+  // Tracking whether the image is animating
+  const [isAnimating, setIsAnimating] = React.useState(true)
+  const [imageDims, setImageDims] = React.useState({height: 100, width: 1})
+
+  // Used for controlling animation
+  const imageRef = React.useRef<Image>(null)
+
+  const load = React.useCallback(() => {
+    setIsPlayerActive(true)
+    Image.prefetch(params.playerUri).then(() => {
+      // Replace the image once it's fetched
+      setIsPrefetched(true)
+    })
+  }, [params.playerUri])
+
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Don't propagate on web
+      event.preventDefault()
+
+      // Show consent if this is the first load
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: load,
+        })
+        return
+      }
+      // If the player isn't active, we want to activate it and prefetch the gif
+      if (!isPlayerActive) {
+        load()
+        return
+      }
+      // Control animation on native
+      setIsAnimating(prev => {
+        if (prev) {
+          if (isNative) {
+            imageRef.current?.stopAnimating()
+          }
+          return false
+        } else {
+          if (isNative) {
+            imageRef.current?.startAnimating()
+          }
+          return true
+        }
+      })
+    },
+    [externalEmbedsPrefs, isPlayerActive, load, openModal, params.source],
+  )
+
+  const onLoad = React.useCallback((e: ImageLoadEventData) => {
+    if (thumbHasLoaded.current) return
+    setImageDims(getGifDims(e.source.height, e.source.width, viewWidth.current))
+    thumbHasLoaded.current = true
+  }, [])
+
+  const onLayout = React.useCallback((e: LayoutChangeEvent) => {
+    viewWidth.current = e.nativeEvent.layout.width
+  }, [])
+
+  return (
+    <Pressable
+      style={[
+        {height: imageDims.height},
+        styles.topRadius,
+        styles.gifContainer,
+      ]}
+      onPress={onPlayPress}
+      onLayout={onLayout}
+      accessibilityRole="button"
+      accessibilityHint={_(msg`Plays the GIF`)}
+      accessibilityLabel={_(msg`Play ${link.title}`)}>
+      {(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
+        <View style={[styles.layer, styles.overlayLayer]}>
+          <View style={[styles.overlayContainer, styles.topRadius]}>
+            {!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
+              <FontAwesomeIcon icon="play" size={42} color="white" />
+            ) : (
+              // Activity indicator while gif loads
+              <ActivityIndicator size="large" color="white" />
+            )}
+          </View>
+        </View>
+      )}
+      <Image
+        source={{
+          uri:
+            !isPrefetched || (isWeb && !isAnimating)
+              ? link.thumb
+              : params.playerUri,
+        }} // Web uses the thumb to control playback
+        style={{flex: 1}}
+        ref={imageRef}
+        onLoad={onLoad}
+        autoplay={isAnimating}
+        contentFit="contain"
+        accessibilityIgnoresInvertColors
+        accessibilityLabel={link.title}
+        accessibilityHint={link.title}
+        cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
+      />
+    </Pressable>
+  )
+}
+
+const styles = StyleSheet.create({
+  topRadius: {
+    borderTopLeftRadius: 6,
+    borderTopRightRadius: 6,
+  },
+  layer: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  overlayContainer: {
+    flex: 1,
+    justifyContent: 'center',
+    alignItems: 'center',
+    backgroundColor: 'rgba(0,0,0,0.5)',
+  },
+  overlayLayer: {
+    zIndex: 2,
+  },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
+})
diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
index 1523dcf53..af62aa2b3 100644
--- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
@@ -8,6 +8,8 @@ import {AppBskyEmbedExternal} from '@atproto/api'
 import {toNiceDomain} from 'lib/strings/url-helpers'
 import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
 import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
+import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
+import {useExternalEmbedsPrefs} from 'state/preferences'
 
 export const ExternalLinkEmbed = ({
   link,
@@ -16,11 +18,15 @@ export const ExternalLinkEmbed = ({
 }) => {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
+  const externalEmbedPrefs = useExternalEmbedsPrefs()
 
-  const embedPlayerParams = React.useMemo(
-    () => parseEmbedPlayerFromUrl(link.uri),
-    [link.uri],
-  )
+  const embedPlayerParams = React.useMemo(() => {
+    const params = parseEmbedPlayerFromUrl(link.uri)
+
+    if (params && externalEmbedPrefs?.[params.source] !== 'hide') {
+      return params
+    }
+  }, [link.uri, externalEmbedPrefs])
 
   return (
     <View style={{flexDirection: 'column'}}>
@@ -40,9 +46,12 @@ export const ExternalLinkEmbed = ({
           />
         </View>
       ) : undefined}
-      {embedPlayerParams && (
-        <ExternalPlayer link={link} params={embedPlayerParams} />
-      )}
+      {(embedPlayerParams?.isGif && (
+        <ExternalGifEmbed link={link} params={embedPlayerParams} />
+      )) ||
+        (embedPlayerParams && (
+          <ExternalPlayer link={link} params={embedPlayerParams} />
+        ))}
       <View
         style={{
           paddingHorizontal: isMobile ? 10 : 14,
@@ -55,10 +64,12 @@ export const ExternalLinkEmbed = ({
           style={[pal.textLight, styles.extUri]}>
           {toNiceDomain(link.uri)}
         </Text>
-        <Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
-          {link.title || link.uri}
-        </Text>
-        {link.description ? (
+        {!embedPlayerParams?.isGif && (
+          <Text type="lg-bold" numberOfLines={4} style={[pal.text]}>
+            {link.title || link.uri}
+          </Text>
+        )}
+        {link.description && !embedPlayerParams?.hideDetails ? (
           <Text
             type="md"
             numberOfLines={4}
diff --git a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
index ff3dc1ca4..8b0858b69 100644
--- a/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
+++ b/src/view/com/util/post-embeds/ExternalPlayerEmbed.tsx
@@ -16,14 +16,17 @@ import Animated, {
 import {Image} from 'expo-image'
 import {WebView} from 'react-native-webview'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
-import YoutubePlayer from 'react-native-youtube-iframe'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyEmbedExternal} from '@atproto/api'
 import {EmbedPlayerParams, getPlayerHeight} from 'lib/strings/embed-player'
 import {EventStopper} from '../EventStopper'
-import {AppBskyEmbedExternal} from '@atproto/api'
 import {isNative} from 'platform/detection'
-import {useNavigation} from '@react-navigation/native'
 import {NavigationProp} from 'lib/routes/types'
+import {useExternalEmbedsPrefs} from 'state/preferences'
+import {useModalControls} from 'state/modals'
 
 interface ShouldStartLoadRequest {
   url: string
@@ -39,6 +42,8 @@ function PlaceholderOverlay({
   isPlayerActive: boolean
   onPress: (event: GestureResponderEvent) => void
 }) {
+  const {_} = useLingui()
+
   // If the player is active and not loading, we don't want to show the overlay.
   if (isPlayerActive && !isLoading) return null
 
@@ -46,8 +51,8 @@ function PlaceholderOverlay({
     <View style={[styles.layer, styles.overlayLayer]}>
       <Pressable
         accessibilityRole="button"
-        accessibilityLabel="Play Video"
-        accessibilityHint=""
+        accessibilityLabel={_(msg`Play Video`)}
+        accessibilityHint={_(msg`Play Video`)}
         onPress={onPress}
         style={[styles.overlayContainer, styles.topRadius]}>
         {!isPlayerActive ? (
@@ -84,31 +89,21 @@ function Player({
   return (
     <View style={[styles.layer, styles.playerLayer]}>
       <EventStopper>
-        {isNative && params.type === 'youtube_video' ? (
-          <YoutubePlayer
-            videoId={params.videoId}
-            play
-            height={height}
-            onReady={onLoad}
-            webViewStyle={[styles.webview, styles.topRadius]}
+        <View style={{height, width: '100%'}}>
+          <WebView
+            javaScriptEnabled={true}
+            onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+            mediaPlaybackRequiresUserAction={false}
+            allowsInlineMediaPlayback
+            bounces={false}
+            allowsFullscreenVideo
+            nestedScrollEnabled
+            source={{uri: params.playerUri}}
+            onLoad={onLoad}
+            setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
+            style={[styles.webview, styles.topRadius]}
           />
-        ) : (
-          <View style={{height, width: '100%'}}>
-            <WebView
-              javaScriptEnabled={true}
-              onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
-              mediaPlaybackRequiresUserAction={false}
-              allowsInlineMediaPlayback
-              bounces={false}
-              allowsFullscreenVideo
-              nestedScrollEnabled
-              source={{uri: params.playerUri}}
-              onLoad={onLoad}
-              setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
-              style={[styles.webview, styles.topRadius]}
-            />
-          </View>
-        )}
+        </View>
       </EventStopper>
     </View>
   )
@@ -125,6 +120,8 @@ export function ExternalPlayer({
   const navigation = useNavigation<NavigationProp>()
   const insets = useSafeAreaInsets()
   const windowDims = useWindowDimensions()
+  const externalEmbedsPrefs = useExternalEmbedsPrefs()
+  const {openModal} = useModalControls()
 
   const [isPlayerActive, setPlayerActive] = React.useState(false)
   const [isLoading, setIsLoading] = React.useState(true)
@@ -194,12 +191,26 @@ export function ExternalPlayer({
     setIsLoading(false)
   }, [])
 
-  const onPlayPress = React.useCallback((event: GestureResponderEvent) => {
-    // Prevent this from propagating upward on web
-    event.preventDefault()
+  const onPlayPress = React.useCallback(
+    (event: GestureResponderEvent) => {
+      // Prevent this from propagating upward on web
+      event.preventDefault()
 
-    setPlayerActive(true)
-  }, [])
+      if (externalEmbedsPrefs?.[params.source] === undefined) {
+        openModal({
+          name: 'embed-consent',
+          source: params.source,
+          onAccept: () => {
+            setPlayerActive(true)
+          },
+        })
+        return
+      }
+
+      setPlayerActive(true)
+    },
+    [externalEmbedsPrefs, openModal, params.source],
+  )
 
   // measure the layout to set sizing
   const onLayout = React.useCallback(
@@ -231,7 +242,6 @@ export function ExternalPlayer({
           accessibilityIgnoresInvertColors
         />
       )}
-
       <PlaceholderOverlay
         isLoading={isLoading}
         isPlayerActive={isPlayerActive}
@@ -274,4 +284,8 @@ const styles = StyleSheet.create({
   webview: {
     backgroundColor: 'transparent',
   },
+  gifContainer: {
+    width: '100%',
+    overflow: 'hidden',
+  },
 })
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 089d3f0a8..221b9702c 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -29,9 +29,10 @@ import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'
 import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle'
 import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck'
 import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck'
+import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faCircleExclamation} from '@fortawesome/free-solid-svg-icons/faCircleExclamation'
+import {faCirclePlay} from '@fortawesome/free-regular-svg-icons/faCirclePlay'
 import {faCircleUser} from '@fortawesome/free-regular-svg-icons/faCircleUser'
-import {faCircleDot} from '@fortawesome/free-solid-svg-icons/faCircleDot'
 import {faClone} from '@fortawesome/free-solid-svg-icons/faClone'
 import {faClone as farClone} from '@fortawesome/free-regular-svg-icons/faClone'
 import {faComment} from '@fortawesome/free-regular-svg-icons/faComment'
@@ -129,9 +130,10 @@ library.add(
   faCircle,
   faCircleCheck,
   farCircleCheck,
+  faCircleDot,
   faCircleExclamation,
+  faCirclePlay,
   faCircleUser,
-  faCircleDot,
   faClone,
   farClone,
   faComment,
diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx
new file mode 100644
index 000000000..24e7d56df
--- /dev/null
+++ b/src/view/screens/PreferencesExternalEmbeds.tsx
@@ -0,0 +1,138 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
+import {s} from 'lib/styles'
+import {Text} from '../com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {
+  EmbedPlayerSource,
+  externalEmbedLabels,
+} from '#/lib/strings/embed-player'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {Trans} from '@lingui/macro'
+import {ScrollView} from '../com/util/Views'
+import {
+  useExternalEmbedsPrefs,
+  useSetExternalEmbedPref,
+} from 'state/preferences'
+import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
+
+type Props = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'PreferencesExternalEmbeds'
+>
+export function PreferencesExternalEmbeds({}: Props) {
+  const pal = usePalette('default')
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {isMobile} = useWebMediaQueries()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('PreferencesExternalEmbeds')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  return (
+    <View style={s.hContentRegion} testID="preferencesExternalEmbedsScreen">
+      <SimpleViewHeader
+        showBackButton={isMobile}
+        style={[
+          pal.border,
+          {borderBottomWidth: 1},
+          !isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
+        ]}>
+        <View style={{flex: 1}}>
+          <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+          <Text style={pal.textLight}>
+            <Trans>Customize media from external sites.</Trans>
+          </Text>
+        </View>
+      </SimpleViewHeader>
+      <ScrollView
+        // @ts-ignore web only -prf
+        dataSet={{'stable-gutters': 1}}
+        contentContainerStyle={[pal.viewLight, {paddingBottom: 200}]}>
+        <View style={[pal.view]}>
+          <View style={styles.infoCard}>
+            <Text style={pal.text}>
+              <Trans>
+                External media may allow websites to collect information about
+                you and your device. No information is sent or requested until
+                you press the "play" button.
+              </Trans>
+            </Text>
+          </View>
+        </View>
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          Enable media players for
+        </Text>
+        {Object.entries(externalEmbedLabels).map(([key, label]) => (
+          <PrefSelector
+            source={key as EmbedPlayerSource}
+            label={label}
+            key={key}
+          />
+        ))}
+      </ScrollView>
+    </View>
+  )
+}
+
+function PrefSelector({
+  source,
+  label,
+}: {
+  source: EmbedPlayerSource
+  label: string
+}) {
+  const pal = usePalette('default')
+  const setExternalEmbedPref = useSetExternalEmbedPref()
+  const sources = useExternalEmbedsPrefs()
+
+  return (
+    <View>
+      <View style={[pal.view, styles.toggleCard]}>
+        <ToggleButton
+          type="default-light"
+          label={label}
+          labelType="lg"
+          isSelected={sources?.[source] === 'show'}
+          onPress={() =>
+            setExternalEmbedPref(
+              source,
+              sources?.[source] === 'show' ? 'hide' : 'show',
+            )
+          }
+        />
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  heading: {
+    paddingHorizontal: 18,
+    paddingTop: 14,
+    paddingBottom: 14,
+  },
+  spacer: {
+    height: 8,
+  },
+  infoCard: {
+    paddingHorizontal: 20,
+    paddingVertical: 14,
+  },
+  toggleCard: {
+    paddingVertical: 8,
+    paddingHorizontal: 6,
+    marginBottom: 1,
+  },
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 5b381a138..fedd348e2 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -563,6 +563,39 @@ export function SettingsScreen({}: Props) {
             <Trans>Moderation</Trans>
           </Text>
         </TouchableOpacity>
+
+        <View style={styles.spacer20} />
+
+        <Text type="xl-bold" style={[pal.text, styles.heading]}>
+          <Trans>Privacy</Trans>
+        </Text>
+
+        <TouchableOpacity
+          testID="externalEmbedsBtn"
+          style={[
+            styles.linkCard,
+            pal.view,
+            isSwitchingAccounts && styles.dimmed,
+          ]}
+          onPress={
+            isSwitchingAccounts
+              ? undefined
+              : () => navigation.navigate('PreferencesExternalEmbeds')
+          }
+          accessibilityRole="button"
+          accessibilityHint=""
+          accessibilityLabel={_(msg`Opens external embeds settings`)}>
+          <View style={[styles.iconContainer, pal.btn]}>
+            <FontAwesomeIcon
+              icon={['far', 'circle-play']}
+              style={pal.text as FontAwesomeIconStyle}
+            />
+          </View>
+          <Text type="lg" style={pal.text}>
+            <Trans>External Media Preferences</Trans>
+          </Text>
+        </TouchableOpacity>
+
         <View style={styles.spacer20} />
 
         <Text type="xl-bold" style={[pal.text, styles.heading]}>