gif content filtering and safari fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import {ViewProps} from 'react-native'
|
import {type ViewProps} from 'react-native'
|
||||||
|
|
||||||
export interface GifViewStateChangeEvent {
|
export interface GifViewStateChangeEvent {
|
||||||
nativeEvent: {
|
nativeEvent: {
|
||||||
@@ -10,6 +10,12 @@ export interface GifViewStateChangeEvent {
|
|||||||
export interface GifViewProps extends ViewProps {
|
export interface GifViewProps extends ViewProps {
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
source?: string
|
source?: string
|
||||||
|
/**
|
||||||
|
* Web-only ordered list of `<source>` tags rendered inside `<video>`. The
|
||||||
|
* browser uses `canPlayType` to pick the first one it supports. Ignored on
|
||||||
|
* native (which uses `source` directly).
|
||||||
|
*/
|
||||||
|
sources?: ReadonlyArray<{src: string; type: string}>
|
||||||
placeholderSource?: string
|
placeholderSource?: string
|
||||||
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
|
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import * as React from 'react'
|
import {createRef, PureComponent, type RefObject} from 'react'
|
||||||
import {StyleSheet} from 'react-native'
|
import {StyleSheet} from 'react-native'
|
||||||
|
|
||||||
import {GifViewProps} from './GifView.types'
|
import {type GifViewProps} from './GifView.types'
|
||||||
|
|
||||||
export class GifView extends React.PureComponent<GifViewProps> {
|
export class GifView extends PureComponent<GifViewProps> {
|
||||||
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
|
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
|
||||||
React.createRef()
|
|
||||||
private isLoaded = false
|
private isLoaded = false
|
||||||
|
|
||||||
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
constructor(props: GifViewProps | Readonly<GifViewProps>) {
|
||||||
super(props)
|
super(props)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
|
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
|
||||||
if (prevProps.autoplay !== this.props.autoplay) {
|
if (prevProps.autoplay !== this.props.autoplay) {
|
||||||
if (this.props.autoplay) {
|
if (this.props.autoplay) {
|
||||||
@@ -22,10 +25,28 @@ export class GifView extends React.PureComponent<GifViewProps> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
|
}
|
||||||
|
|
||||||
static async prefetchAsync(_: string[]): Promise<void> {
|
static async prefetchAsync(_: string[]): Promise<void> {
|
||||||
console.warn('prefetchAsync is not supported on web')
|
console.warn('prefetchAsync is not supported on web')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safari pauses backgrounded `<video>` elements when the tab becomes
|
||||||
|
// inactive and does not resume them automatically when the tab is shown
|
||||||
|
// again, leaving GIFs frozen on a still frame. Resume playback when the
|
||||||
|
// page becomes visible if the consumer expects autoplay.
|
||||||
|
private onVisibilityChange = () => {
|
||||||
|
if (
|
||||||
|
document.visibilityState === 'visible' &&
|
||||||
|
this.props.autoplay &&
|
||||||
|
this.videoPlayerRef.current?.paused
|
||||||
|
) {
|
||||||
|
void this.playAsync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private firePlayerStateChangeEvent = () => {
|
private firePlayerStateChangeEvent = () => {
|
||||||
this.props.onPlayerStateChange?.({
|
this.props.onPlayerStateChange?.({
|
||||||
nativeEvent: {
|
nativeEvent: {
|
||||||
@@ -62,21 +83,29 @@ export class GifView extends React.PureComponent<GifViewProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {sources, source, autoplay, accessibilityLabel, style} = this.props
|
||||||
|
const useSources = sources && sources.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
src={this.props.source}
|
// When `<source>` children are present, omit `src` so the browser
|
||||||
autoPlay={this.props.autoplay ? 'autoplay' : undefined}
|
// walks the source list and picks via canPlayType.
|
||||||
preload={this.props.autoplay ? 'auto' : undefined}
|
src={useSources ? undefined : source}
|
||||||
|
autoPlay={autoplay ? 'autoplay' : undefined}
|
||||||
|
preload={autoplay ? 'auto' : undefined}
|
||||||
playsInline={true}
|
playsInline={true}
|
||||||
loop="loop"
|
loop="loop"
|
||||||
muted="muted"
|
muted="muted"
|
||||||
style={StyleSheet.flatten(this.props.style)}
|
style={StyleSheet.flatten(style)}
|
||||||
onCanPlay={this.onLoad}
|
onCanPlay={this.onLoad}
|
||||||
onPlay={this.firePlayerStateChangeEvent}
|
onPlay={this.firePlayerStateChangeEvent}
|
||||||
onPause={this.firePlayerStateChangeEvent}
|
onPause={this.firePlayerStateChangeEvent}
|
||||||
aria-label={this.props.accessibilityLabel}
|
aria-label={accessibilityLabel}
|
||||||
ref={this.videoPlayerRef}
|
ref={this.videoPlayerRef}>
|
||||||
/>
|
{useSources
|
||||||
|
? sources.map(s => <source key={s.src} src={s.src} type={s.type} />)
|
||||||
|
: null}
|
||||||
|
</video>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export enum Features {
|
|||||||
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
|
||||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export function GifEmbed({
|
|||||||
/>
|
/>
|
||||||
<GifView
|
<GifView
|
||||||
source={params.playerUri}
|
source={params.playerUri}
|
||||||
|
sources={params.playerSources}
|
||||||
placeholderSource={thumb}
|
placeholderSource={thumb}
|
||||||
style={[a.flex_1]}
|
style={[a.flex_1]}
|
||||||
autoplay={!autoplayDisabled}
|
autoplay={!autoplayDisabled}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function createKlipyApi<Input extends object>(
|
|||||||
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
|
// 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
|
||||||
params.set('limit', '30')
|
params.set('limit', '30')
|
||||||
|
|
||||||
params.set('contentfilter', 'high')
|
params.set('contentfilter', 'low')
|
||||||
|
|
||||||
const locale = getLocales?.()?.[0]
|
const locale = getLocales?.()?.[0]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Dimensions} from 'react-native'
|
import {Dimensions} from 'react-native'
|
||||||
|
|
||||||
import {IS_WEB, IS_WEB_SAFARI} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
|
|
||||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||||
|
|
||||||
@@ -68,6 +68,13 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
|||||||
export interface EmbedPlayerParams {
|
export interface EmbedPlayerParams {
|
||||||
type: EmbedPlayerType
|
type: EmbedPlayerType
|
||||||
playerUri: string
|
playerUri: string
|
||||||
|
/**
|
||||||
|
* Web-only ordered list of `<source>` tags for `<video>` playback. When
|
||||||
|
* present, the browser uses `canPlayType` to pick the first one it supports,
|
||||||
|
* which avoids UA sniffing for codec selection. `playerUri` is used as the
|
||||||
|
* native source and as a fallback `<video src>` when this is empty.
|
||||||
|
*/
|
||||||
|
playerSources?: ReadonlyArray<{src: string; type: string}>
|
||||||
isGif?: boolean
|
isGif?: boolean
|
||||||
source: EmbedPlayerSource
|
source: EmbedPlayerSource
|
||||||
metaUri?: string
|
metaUri?: string
|
||||||
@@ -382,7 +389,7 @@ export function parseEmbedPlayerFromUrl(
|
|||||||
|
|
||||||
const tenorGif = parseTenorGif(urlp)
|
const tenorGif = parseTenorGif(urlp)
|
||||||
if (tenorGif.success) {
|
if (tenorGif.success) {
|
||||||
const {playerUri, dimensions} = tenorGif
|
const {playerUri, playerSources, dimensions} = tenorGif
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'tenor_gif',
|
type: 'tenor_gif',
|
||||||
@@ -390,13 +397,14 @@ export function parseEmbedPlayerFromUrl(
|
|||||||
isGif: true,
|
isGif: true,
|
||||||
hideDetails: true,
|
hideDetails: true,
|
||||||
playerUri,
|
playerUri,
|
||||||
|
playerSources,
|
||||||
dimensions,
|
dimensions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const klipyGif = parseKlipyGif(urlp)
|
const klipyGif = parseKlipyGif(urlp)
|
||||||
if (klipyGif.success) {
|
if (klipyGif.success) {
|
||||||
const {playerUri, dimensions} = klipyGif
|
const {playerUri, playerSources, dimensions} = klipyGif
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'klipy_gif',
|
type: 'klipy_gif',
|
||||||
@@ -404,6 +412,7 @@ export function parseEmbedPlayerFromUrl(
|
|||||||
isGif: true,
|
isGif: true,
|
||||||
hideDetails: true,
|
hideDetails: true,
|
||||||
playerUri,
|
playerUri,
|
||||||
|
playerSources,
|
||||||
dimensions,
|
dimensions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,13 +589,14 @@ export function parseTenorGif(urlp: URL):
|
|||||||
| {
|
| {
|
||||||
success: true
|
success: true
|
||||||
playerUri: string
|
playerUri: string
|
||||||
|
playerSources?: ReadonlyArray<{src: string; type: string}>
|
||||||
dimensions: {height: number; width: number}
|
dimensions: {height: number; width: number}
|
||||||
} {
|
} {
|
||||||
if (urlp.hostname !== 'media.tenor.com') {
|
if (urlp.hostname !== 'media.tenor.com') {
|
||||||
return {success: false}
|
return {success: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
let [__, id, filename] = urlp.pathname.split('/')
|
const [__, id, filename] = urlp.pathname.split('/')
|
||||||
|
|
||||||
if (!id || !filename) {
|
if (!id || !filename) {
|
||||||
return {success: false}
|
return {success: false}
|
||||||
@@ -619,20 +629,25 @@ export function parseTenorGif(urlp: URL):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
if (IS_WEB_SAFARI) {
|
// Tenor encodes the format in the ID prefix: AAAP3 = webm, AAAP1 = mp4.
|
||||||
id = id.replace('AAAAC', 'AAAP1')
|
// Provide both as <source> tags so the browser picks via canPlayType
|
||||||
filename = filename.replace('.gif', '.mp4')
|
// instead of relying on user-agent sniffing.
|
||||||
} else {
|
const webmUrl = `https://t.gifs.bsky.app/${id.replace('AAAAC', 'AAAP3')}/${filename.replace('.gif', '.webm')}`
|
||||||
id = id.replace('AAAAC', 'AAAP3')
|
const mp4Url = `https://t.gifs.bsky.app/${id.replace('AAAAC', 'AAAP1')}/${filename.replace('.gif', '.mp4')}`
|
||||||
filename = filename.replace('.gif', '.webm')
|
return {
|
||||||
|
success: true,
|
||||||
|
playerUri: mp4Url,
|
||||||
|
playerSources: [
|
||||||
|
{src: webmUrl, type: 'video/webm'},
|
||||||
|
{src: mp4Url, type: 'video/mp4'},
|
||||||
|
],
|
||||||
|
dimensions,
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
id = id.replace('AAAAC', 'AAAAM')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
|
playerUri: `https://t.gifs.bsky.app/${id.replace('AAAAC', 'AAAAM')}/${filename}`,
|
||||||
dimensions,
|
dimensions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,6 +666,7 @@ export function parseKlipyGif(urlp: URL):
|
|||||||
| {
|
| {
|
||||||
success: true
|
success: true
|
||||||
playerUri: string
|
playerUri: string
|
||||||
|
playerSources?: ReadonlyArray<{src: string; type: string}>
|
||||||
dimensions: {height: number; width: number}
|
dimensions: {height: number; width: number}
|
||||||
} {
|
} {
|
||||||
if (urlp.hostname !== 'static.klipy.com') {
|
if (urlp.hostname !== 'static.klipy.com') {
|
||||||
@@ -683,36 +699,62 @@ export function parseKlipyGif(urlp: URL):
|
|||||||
return {success: false}
|
return {success: false}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webmSlug = urlp.searchParams.get('webm')
|
||||||
|
const mp4Slug = urlp.searchParams.get('mp4')
|
||||||
|
|
||||||
const playerUrl = new URL(urlp.href)
|
const playerUrl = new URL(urlp.href)
|
||||||
playerUrl.hostname = 'k.gifs.bsky.app'
|
playerUrl.hostname = 'k.gifs.bsky.app'
|
||||||
|
|
||||||
// On web, swap the gif filename for a video format so the <video>
|
|
||||||
// element can play it. Klipy uses different filename slugs per
|
|
||||||
// format (unlike Tenor's ID-based scheme), so the slugs are
|
|
||||||
// embedded as query params at composition time by resolveGif().
|
|
||||||
if (IS_WEB) {
|
|
||||||
const webmSlug = playerUrl.searchParams.get('webm')
|
|
||||||
const mp4Slug = playerUrl.searchParams.get('mp4')
|
|
||||||
const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
|
|
||||||
const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
|
|
||||||
|
|
||||||
// Without a slug we can't produce a playable video URL on web,
|
|
||||||
// so fall back to the link card instead of returning a broken player.
|
|
||||||
if (!slug) {
|
|
||||||
return {success: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = playerUrl.pathname.split('/')
|
|
||||||
parts[parts.length - 1] = `${slug}.${ext}`
|
|
||||||
playerUrl.pathname = parts.join('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip all metadata params — only the path matters for the CDN
|
// Strip all metadata params — only the path matters for the CDN
|
||||||
playerUrl.searchParams.delete('hh')
|
playerUrl.searchParams.delete('hh')
|
||||||
playerUrl.searchParams.delete('ww')
|
playerUrl.searchParams.delete('ww')
|
||||||
playerUrl.searchParams.delete('mp4')
|
playerUrl.searchParams.delete('mp4')
|
||||||
playerUrl.searchParams.delete('webm')
|
playerUrl.searchParams.delete('webm')
|
||||||
|
|
||||||
|
// On web, swap the gif filename for a video format so the <video>
|
||||||
|
// element can play it. Klipy uses different filename slugs per
|
||||||
|
// format (unlike Tenor's ID-based scheme), so the slugs are
|
||||||
|
// embedded as query params at composition time by resolveGif().
|
||||||
|
if (IS_WEB) {
|
||||||
|
// Without any slug we can't produce a playable video URL on web,
|
||||||
|
// so fall back to the link card instead of returning a broken player.
|
||||||
|
if (!webmSlug && !mp4Slug) {
|
||||||
|
return {success: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildVideoUrl = (slug: string, ext: string) => {
|
||||||
|
const u = new URL(playerUrl.href)
|
||||||
|
const parts = u.pathname.split('/')
|
||||||
|
parts[parts.length - 1] = `${slug}.${ext}`
|
||||||
|
u.pathname = parts.join('/')
|
||||||
|
return u.href
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources: {src: string; type: string}[] = []
|
||||||
|
if (webmSlug) {
|
||||||
|
sources.push({
|
||||||
|
src: buildVideoUrl(webmSlug, 'webm'),
|
||||||
|
type: 'video/webm',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (mp4Slug) {
|
||||||
|
sources.push({src: buildVideoUrl(mp4Slug, 'mp4'), type: 'video/mp4'})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer mp4 as the fallback `playerUri` for `<video src>` since it has
|
||||||
|
// wider codec support across legacy browsers.
|
||||||
|
const fallback = mp4Slug
|
||||||
|
? buildVideoUrl(mp4Slug, 'mp4')
|
||||||
|
: buildVideoUrl(webmSlug!, 'webm')
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
playerUri: fallback,
|
||||||
|
playerSources: sources,
|
||||||
|
dimensions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
playerUri: playerUrl.href,
|
playerUri: playerUrl.href,
|
||||||
|
|||||||
Reference in New Issue
Block a user