Files
2026-06-26 07:41:09 -07:00

126 lines
3.7 KiB
TypeScript

import {createRef, PureComponent} from 'react'
import {StyleSheet} from 'react-native'
import {type GifViewProps} from './GifView.types'
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLVideoElement | null> =
createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
componentDidMount() {
document.addEventListener('visibilitychange', this.onVisibilityChange)
}
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
void this.playAsync()
} else {
void this.pauseAsync()
}
}
}
componentWillUnmount() {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
}
// eslint-disable-next-line @typescript-eslint/require-await
static async prefetchAsync(_: string[]): Promise<void> {
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 = () => {
this.props.onPlayerStateChange?.({
nativeEvent: {
isPlaying: !this.videoPlayerRef.current?.paused,
isLoaded: this.isLoaded,
},
})
}
private onLoad = () => {
// Prevent multiple calls to onLoad because onCanPlay will fire after each loop
if (this.isLoaded) {
return
}
this.isLoaded = true
this.firePlayerStateChangeEvent()
}
async playAsync(): Promise<void> {
try {
await this.videoPlayerRef.current?.play()
} catch (err) {
// `play()` rejects with a NotAllowedError when the browser blocks
// playback (e.g. Safari low-power mode or autoplay policy). This is
// expected and benign - the GIF simply stays paused - so swallow it
// rather than letting it surface as an unhandled rejection.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
return
}
throw err
}
}
// eslint-disable-next-line @typescript-eslint/require-await
async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause()
}
async toggleAsync(): Promise<void> {
if (this.videoPlayerRef.current?.paused) {
await this.playAsync()
} else {
await this.pauseAsync()
}
}
render() {
const {sources, source, autoplay, accessibilityLabel, style} = this.props
const useSources = sources && sources.length > 0
return (
<video
// When `<source>` children are present, omit `src` so the browser
// walks the source list and picks via canPlayType.
src={useSources ? undefined : source}
autoPlay={autoplay ? true : undefined}
preload={autoplay ? 'auto' : undefined}
playsInline={true}
loop={true}
muted={true}
style={StyleSheet.flatten(style) as React.CSSProperties}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
aria-label={accessibilityLabel}
ref={this.videoPlayerRef}>
{useSources
? sources.map(s => <source key={s.src} src={s.src} type={s.type} />)
: null}
</video>
)
}
}