UNPKG

expo-video

Version:

A cross-platform, performant video component for React Native and Expo with Web support

376 lines 13.7 kB
import { useMemo } from 'react'; import resolveAssetSource from './resolveAssetSource'; export function useVideoPlayer(source, setup) { const parsedSource = typeof source === 'string' ? { uri: source } : source; return useMemo(() => { const player = new VideoPlayerWeb(parsedSource); setup?.(player); return player; }, [JSON.stringify(source)]); } export function getSourceUri(source) { if (typeof source === 'string') { return source; } if (typeof source === 'number') { return resolveAssetSource(source)?.uri ?? null; } if (typeof source?.assetId === 'number' && !source?.uri) { return resolveAssetSource(source.assetId)?.uri ?? null; } return source?.uri ?? null; } export function createVideoPlayer(source) { const parsedSource = typeof source === 'string' ? { uri: source } : source; return new VideoPlayerWeb(parsedSource); } export default class VideoPlayerWeb extends globalThis.expo.SharedObject { constructor(source) { super(); this.src = source; } src = null; previousSrc = null; _mountedVideos = new Set(); _audioNodes = new Set(); playing = false; _muted = false; _volume = 1; _loop = false; _playbackRate = 1.0; _preservesPitch = true; _status = 'idle'; _error = null; _timeUpdateLoop = null; _timeUpdateEventInterval = 0; audioMixingMode = 'auto'; // Not supported on web. Dummy to match the interface. allowsExternalPlayback = false; // Not supported on web. Dummy to match the interface. staysActiveInBackground = false; // Not supported on web. Dummy to match the interface. showNowPlayingNotification = false; // Not supported on web. Dummy to match the interface. currentLiveTimestamp = null; // Not supported on web. Dummy to match the interface. currentOffsetFromLive = null; // Not supported on web. Dummy to match the interface. targetOffsetFromLive = 0; // Not supported on web. Dummy to match the interface. bufferOptions = {}; // Not supported on web. Dummy to match the interface. subtitleTrack = null; // Embedded subtitles are not supported by the html web player. Dummy to match the interface. availableSubtitleTracks = []; // Embedded subtitles are not supported by the html web player. Dummy to match the interface. audioTrack = null; // Not supported on web. Dummy to match the interface. availableAudioTracks = []; // Not supported on web. Dummy to match the interface. videoTrack = null; // Not supported on web. Dummy to match the interface. availableVideoTracks = []; // Not supported on web. Dummy to match the interface. set muted(value) { this._mountedVideos.forEach((video) => { video.muted = value; }); this._muted = value; } get muted() { return this._muted; } set playbackRate(value) { this._mountedVideos.forEach((video) => { video.playbackRate = value; }); this._playbackRate = value; } get playbackRate() { return this._playbackRate; } get isLive() { return [...this._mountedVideos][0]?.duration === Infinity; } set volume(value) { this._mountedVideos.forEach((video) => { video.volume = value; }); this._volume = value; } get volume() { return this._volume; } set loop(value) { this._mountedVideos.forEach((video) => { video.loop = value; }); this._loop = value; } get loop() { return this._loop; } get currentTime() { // All videos should be synchronized, so we return the position of the first video. return [...this._mountedVideos][0]?.currentTime ?? 0; } set currentTime(value) { this._mountedVideos.forEach((video) => { video.currentTime = value; }); } get duration() { // All videos should have the same duration, so we return the duration of the first video. return [...this._mountedVideos][0]?.duration ?? 0; } get preservesPitch() { return this._preservesPitch; } set preservesPitch(value) { this._mountedVideos.forEach((video) => { video.preservesPitch = value; }); this._preservesPitch = value; } get timeUpdateEventInterval() { return this._timeUpdateEventInterval; } set timeUpdateEventInterval(value) { this._timeUpdateEventInterval = value; if (this._timeUpdateLoop) { clearInterval(this._timeUpdateLoop); } if (value > 0) { // Emit the first event immediately like on other platforms this.emit('timeUpdate', { currentTime: this.currentTime, currentLiveTimestamp: null, currentOffsetFromLive: null, bufferedPosition: this.bufferedPosition, }); this._timeUpdateLoop = setInterval(() => { this.emit('timeUpdate', { currentTime: this.currentTime, currentLiveTimestamp: null, currentOffsetFromLive: null, bufferedPosition: this.bufferedPosition, }); }, value * 1000); } } get status() { return this._status; } get bufferedPosition() { if (this._mountedVideos.size === 0 || this.status === 'error') { return -1; } const buffered = [...this._mountedVideos][0]?.buffered; for (let i = 0; i < buffered.length; i++) { if (buffered.start(i) <= this.currentTime && buffered.end(i) >= this.currentTime) { return buffered.end(i); } } return 0; } set status(value) { if (this._status === value) return; if (value === 'error' && this._error) { this.emit('statusChange', { status: value, oldStatus: this._status, error: this._error, }); } else { this.emit('statusChange', { status: value, oldStatus: this._status, }); this._error = null; } this._status = value; } mountVideoView(video) { // The video will be the first video, it should inherit the properties set in the setup() function if (this._mountedVideos.size === 0) { video.preservesPitch = this._preservesPitch; video.loop = this._loop; video.volume = this._volume; video.muted = this._muted; video.playbackRate = this._playbackRate; } this._mountedVideos.add(video); this._addListeners(video); this._synchronizeWithFirstVideo(video); } unmountVideoView(video) { this._mountedVideos.delete(video); } mountAudioNode(audioContext, zeroGainNode, audioSourceNode) { if (!audioContext || !zeroGainNode) return; this._audioNodes.add(audioSourceNode); // First mounted video should be connected to the audio context. All other videos have to be muted. if (this._audioNodes.size === 1) { audioSourceNode.connect(audioContext.destination); } else { audioSourceNode.connect(zeroGainNode); } } unmountAudioNode(video, audioContext, audioSourceNode) { const mountedVideos = [...this._mountedVideos]; const videoPlayingAudio = mountedVideos[0]; this._audioNodes.delete(audioSourceNode); audioSourceNode.disconnect(); // If video playing audio has been removed, select a new video to be the audio player by disconnecting it from the mute node. if (videoPlayingAudio === video && this._audioNodes.size > 0 && audioContext) { const newMainAudioSource = [...this._audioNodes][0]; newMainAudioSource.disconnect(); newMainAudioSource.connect(audioContext.destination); } } play() { this._mountedVideos.forEach((video) => { video.play(); }); } pause() { this._mountedVideos.forEach((video) => { video.pause(); }); } replace(source) { this._mountedVideos.forEach((video) => { const uri = getSourceUri(source); video.pause(); if (uri) { video.setAttribute('src', uri); video.load(); video.play(); } else { video.removeAttribute('src'); video.load(); } }); // TODO @behenate: this won't work when we add support for playlists this.previousSrc = this.src; this.src = source; this.playing = true; } // The HTML5 player already offloads loading of the asset onto a different thread so we can keep the same // implementation until `replace` is deprecated and removed. async replaceAsync(source) { return this.replace(source); } seekBy(seconds) { this._mountedVideos.forEach((video) => { video.currentTime += seconds; }); } replay() { this._mountedVideos.forEach((video) => { video.currentTime = 0; video.play(); }); this.playing = true; } generateThumbnailsAsync(times) { throw new Error('Generating video thumbnails is not supported on Web yet'); } _synchronizeWithFirstVideo(video) { const firstVideo = [...this._mountedVideos][0]; if (!firstVideo) return; if (firstVideo.paused) { video.pause(); } else { video.play(); } video.currentTime = firstVideo.currentTime; video.volume = firstVideo.volume; video.muted = firstVideo.muted; video.playbackRate = firstVideo.playbackRate; } /** * If there are multiple mounted videos, all of them will emit an event, as they are synchronised. * We want to avoid this, so we only emit the event if it came from the first video. */ _emitOnce(eventSource, eventName, ...args) { const mountedVideos = [...this._mountedVideos]; if (mountedVideos[0] === eventSource) { this.emit(eventName, ...args); } } _addListeners(video) { video.onplay = () => { this._emitOnce(video, 'playingChange', { isPlaying: true, oldIsPlaying: this.playing, }); this.playing = true; this._mountedVideos.forEach((mountedVideo) => { mountedVideo.play(); }); }; video.onpause = () => { this._emitOnce(video, 'playingChange', { isPlaying: false, oldIsPlaying: this.playing, }); this.playing = false; this._mountedVideos.forEach((mountedVideo) => { mountedVideo.pause(); }); }; video.onvolumechange = () => { this._emitOnce(video, 'volumeChange', { volume: video.volume, oldVolume: this.volume }); this._emitOnce(video, 'mutedChange', { muted: video.muted, oldMuted: this.muted }); this.volume = video.volume; this.muted = video.muted; }; video.onseeking = () => { this._mountedVideos.forEach((mountedVideo) => { if (mountedVideo === video || mountedVideo.currentTime === video.currentTime) return; mountedVideo.currentTime = video.currentTime; }); }; video.onseeked = () => { this._mountedVideos.forEach((mountedVideo) => { if (mountedVideo === video || mountedVideo.currentTime === video.currentTime) return; mountedVideo.currentTime = video.currentTime; }); }; video.onratechange = () => { this._emitOnce(video, 'playbackRateChange', { playbackRate: video.playbackRate, oldPlaybackRate: this.playbackRate, }); this._mountedVideos.forEach((mountedVideo) => { if (mountedVideo.playbackRate === video.playbackRate) return; this._playbackRate = video.playbackRate; mountedVideo.playbackRate = video.playbackRate; }); this._playbackRate = video.playbackRate; }; video.onerror = () => { this._error = { message: video.error?.message ?? 'Unknown player error', }; this.status = 'error'; }; video.oncanplay = () => { const allCanPlay = [...this._mountedVideos].reduce((previousValue, video) => { return previousValue && video.readyState >= 3; }, true); if (!allCanPlay) return; this.status = 'readyToPlay'; }; video.onwaiting = () => { if (this._status === 'loading') return; this.status = 'loading'; }; video.onended = () => { this._emitOnce(video, 'playToEnd'); }; video.onloadstart = () => { this._emitOnce(video, 'sourceChange', { source: this.src, oldSource: this.previousSrc }); }; } } //# sourceMappingURL=VideoPlayer.web.js.map