diff --git a/README.md b/README.md index dbe8f759..618d6f4c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Key | Options `facebook` | `appId`: Your own [Facebook app ID](https://developers.facebook.com/docs/apps/register#app-id)
`version`: Facebook SDK version
`playerId`: Override player ID for consistent server-side rendering (use with [`react-uid`](https://github.com/thearnica/react-uid))
`attributes`: Extra data attributes to pass to the `fb-video` element `soundcloud` | `options`: Override the [default player options](https://developers.soundcloud.com/docs/api/html5-widget#params) `vimeo` | `playerOptions`: Override the [default params](https://developer.vimeo.com/player/sdk/embed)
`title`: Set the player `iframe` title attribute +`mux` | `attributes`: Apply [element attributes](https://github.com/muxinc/elements/blob/main/packages/mux-player/REFERENCE.md#attributes)
`version`: Mux player version `wistia` | `options`: Override the [default player options](https://wistia.com/doc/embed-options#options_list)
`playerId`: Override player ID for consistent server-side rendering (use with [`react-uid`](https://github.com/thearnica/react-uid)) `mixcloud` | `options`: Override the [default player options](https://www.mixcloud.com/developers/widget/#methods) `dailymotion` | `params`: Override the [default player vars](https://developer.dailymotion.com/player#player-parameters) @@ -327,8 +328,8 @@ ReactPlayer `v2.0` changes single player imports and adds lazy loading players. * Facebook videos use the [Facebook Embedded Video Player API](https://developers.facebook.com/docs/plugins/embedded-video-player/api) * SoundCloud tracks use the [SoundCloud Widget API](https://developers.soundcloud.com/docs/api/html5-widget) * Streamable videos use [`Player.js`](https://github.com/embedly/player.js) -* Vidme videos are [no longer supported](https://medium.com/vidme/goodbye-for-now-120b40becafa) * Vimeo videos use the [Vimeo Player API](https://developer.vimeo.com/player/sdk) +* Mux videos use the [``](https://github.com/muxinc/elements/blob/main/packages/mux-player/README.md) element * Wistia videos use the [Wistia Player API](https://wistia.com/doc/player-api) * Twitch videos use the [Twitch Interactive Frames API](https://dev.twitch.tv/docs/embed#interactive-frames-for-live-streams-and-vods) * DailyMotion videos use the [DailyMotion Player API](https://developer.dailymotion.com/player) diff --git a/examples/react/src/App.js b/examples/react/src/App.js index e921b3b4..3ba82719 100644 --- a/examples/react/src/App.js +++ b/examples/react/src/App.js @@ -298,6 +298,13 @@ class App extends Component { {this.renderLoadButton('https://vimeo.com/169599296', 'Test B')} + + Mux + + {this.renderLoadButton('https://stream.mux.com/maVbJv2GSYNRgS02kPXOOGdJMWGU1mkA019ZUjYE7VU7k', 'Test A')} + {this.renderLoadButton('https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008', 'Test B')} + + Twitch diff --git a/src/patterns.js b/src/patterns.js index 8549eb49..25a96c84 100644 --- a/src/patterns.js +++ b/src/patterns.js @@ -3,6 +3,7 @@ import { isMediaStream, isBlobUrl } from './utils' export const MATCH_URL_YOUTUBE = /(?:youtu\.be\/|youtube(?:-nocookie|education)?\.com\/(?:embed\/|v\/|watch\/|watch\?v=|watch\?.+&v=|shorts\/|live\/))((\w|-){11})|youtube\.com\/playlist\?list=|youtube\.com\/user\// export const MATCH_URL_SOUNDCLOUD = /(?:soundcloud\.com|snd\.sc)\/[^.]+$/ export const MATCH_URL_VIMEO = /vimeo\.com\/(?!progressive_redirect).+/ +export const MATCH_URL_MUX = /stream\.mux\.com\/(\w+)/ export const MATCH_URL_FACEBOOK = /^https?:\/\/(www\.)?facebook\.com.*\/(video(s)?|watch|story)(\.php?|\/).+$/ export const MATCH_URL_FACEBOOK_WATCH = /^https?:\/\/fb\.watch\/.+$/ export const MATCH_URL_STREAMABLE = /streamable\.com\/([a-z0-9]+)$/ @@ -52,6 +53,7 @@ export const canPlay = { }, soundcloud: url => MATCH_URL_SOUNDCLOUD.test(url) && !AUDIO_EXTENSIONS.test(url), vimeo: url => MATCH_URL_VIMEO.test(url) && !VIDEO_EXTENSIONS.test(url) && !HLS_EXTENSIONS.test(url), + mux: url => MATCH_URL_MUX.test(url), facebook: url => MATCH_URL_FACEBOOK.test(url) || MATCH_URL_FACEBOOK_WATCH.test(url), streamable: url => MATCH_URL_STREAMABLE.test(url), wistia: url => MATCH_URL_WISTIA.test(url), diff --git a/src/players/Mux.js b/src/players/Mux.js new file mode 100644 index 00000000..693f3048 --- /dev/null +++ b/src/players/Mux.js @@ -0,0 +1,210 @@ +import React, { Component } from 'react' + +import { canPlay, MATCH_URL_MUX } from '../patterns' + +const SDK_URL = 'https://cdn.jsdelivr.net/npm/@mux/mux-player@VERSION/dist/mux-player.mjs' + +export default class Mux extends Component { + static displayName = 'Mux' + static canPlay = canPlay.mux + + componentDidMount () { + this.props.onMount && this.props.onMount(this) + this.addListeners(this.player) + const playbackId = this.getPlaybackId(this.props.url) // Ensure src is set in strict mode + if (playbackId) { + this.player.playbackId = playbackId + } + } + + componentWillUnmount () { + this.player.playbackId = null + this.removeListeners(this.player) + } + + addListeners (player) { + const { playsinline } = this.props + player.addEventListener('play', this.onPlay) + player.addEventListener('waiting', this.onBuffer) + player.addEventListener('playing', this.onBufferEnd) + player.addEventListener('pause', this.onPause) + player.addEventListener('seeked', this.onSeek) + player.addEventListener('ended', this.onEnded) + player.addEventListener('error', this.onError) + player.addEventListener('ratechange', this.onPlayBackRateChange) + player.addEventListener('enterpictureinpicture', this.onEnablePIP) + player.addEventListener('leavepictureinpicture', this.onDisablePIP) + player.addEventListener('webkitpresentationmodechanged', this.onPresentationModeChange) + player.addEventListener('canplay', this.onReady) + if (playsinline) { + player.setAttribute('playsinline', '') + } + } + + removeListeners (player) { + player.removeEventListener('canplay', this.onReady) + player.removeEventListener('play', this.onPlay) + player.removeEventListener('waiting', this.onBuffer) + player.removeEventListener('playing', this.onBufferEnd) + player.removeEventListener('pause', this.onPause) + player.removeEventListener('seeked', this.onSeek) + player.removeEventListener('ended', this.onEnded) + player.removeEventListener('error', this.onError) + player.removeEventListener('ratechange', this.onPlayBackRateChange) + player.removeEventListener('enterpictureinpicture', this.onEnablePIP) + player.removeEventListener('leavepictureinpicture', this.onDisablePIP) + player.removeEventListener('canplay', this.onReady) + } + + // Proxy methods to prevent listener leaks + onReady = (...args) => this.props.onReady(...args) + onPlay = (...args) => this.props.onPlay(...args) + onBuffer = (...args) => this.props.onBuffer(...args) + onBufferEnd = (...args) => this.props.onBufferEnd(...args) + onPause = (...args) => this.props.onPause(...args) + onEnded = (...args) => this.props.onEnded(...args) + onError = (...args) => this.props.onError(...args) + onPlayBackRateChange = (event) => this.props.onPlaybackRateChange(event.target.playbackRate) + onEnablePIP = (...args) => this.props.onEnablePIP(...args) + + onSeek = e => { + this.props.onSeek(e.target.currentTime) + } + + async load (url) { + const { onError, config } = this.props + + if (!globalThis.customElements?.get('mux-player')) { + try { + await import(SDK_URL.replace('VERSION', config.version)) + this.props.onLoaded() + } catch (error) { + onError(error) + } + } + + const [, id] = url.match(MATCH_URL_MUX) + this.player.playbackId = id + } + + onDurationChange = () => { + const duration = this.getDuration() + this.props.onDuration(duration) + } + + play () { + const promise = this.player.play() + if (promise) { + promise.catch(this.props.onError) + } + } + + pause () { + this.player.pause() + } + + stop () { + this.player.playbackId = null + } + + seekTo (seconds, keepPlaying = true) { + this.player.currentTime = seconds + if (!keepPlaying) { + this.pause() + } + } + + setVolume (fraction) { + this.player.volume = fraction + } + + mute = () => { + this.player.muted = true + } + + unmute = () => { + this.player.muted = false + } + + enablePIP () { + if (this.player.requestPictureInPicture && document.pictureInPictureElement !== this.player) { + this.player.requestPictureInPicture() + } + } + + disablePIP () { + if (document.exitPictureInPicture && document.pictureInPictureElement === this.player) { + document.exitPictureInPicture() + } + } + + setPlaybackRate (rate) { + try { + this.player.playbackRate = rate + } catch (error) { + this.props.onError(error) + } + } + + getDuration () { + if (!this.player) return null + const { duration, seekable } = this.player + // on iOS, live streams return Infinity for the duration + // so instead we use the end of the seekable timerange + if (duration === Infinity && seekable.length > 0) { + return seekable.end(seekable.length - 1) + } + return duration + } + + getCurrentTime () { + if (!this.player) return null + return this.player.currentTime + } + + getSecondsLoaded () { + if (!this.player) return null + const { buffered } = this.player + if (buffered.length === 0) { + return 0 + } + const end = buffered.end(buffered.length - 1) + const duration = this.getDuration() + if (end > duration) { + return duration + } + return end + } + + getPlaybackId (url) { + const [, id] = url.match(MATCH_URL_MUX) + return id + } + + ref = player => { + this.player = player + } + + render () { + const { url, playing, loop, controls, muted, config, width, height } = this.props + const style = { + width: width === 'auto' ? width : '100%', + height: height === 'auto' ? height : '100%' + } + if (controls === false) { + style['--controls'] = 'none' + } + return ( + + ) + } +} diff --git a/src/players/index.js b/src/players/index.js index f1c31bdc..ba2e25fe 100644 --- a/src/players/index.js +++ b/src/players/index.js @@ -20,6 +20,12 @@ export default [ canPlay: canPlay.vimeo, lazyPlayer: lazy(() => import(/* webpackChunkName: 'reactPlayerVimeo' */'./Vimeo')) }, + { + key: 'mux', + name: 'Mux', + canPlay: canPlay.mux, + lazyPlayer: lazy(() => import(/* webpackChunkName: 'reactPlayerMux' */'./Mux')) + }, { key: 'facebook', name: 'Facebook', diff --git a/src/props.js b/src/props.js index a029e293..62c9b17a 100644 --- a/src/props.js +++ b/src/props.js @@ -50,6 +50,10 @@ export const propTypes = { playerOptions: object, title: string }), + mux: shape({ + attributes: object, + version: string + }), file: shape({ attributes: object, tracks: array, @@ -165,6 +169,10 @@ export const defaultProps = { }, title: null }, + mux: { + attributes: {}, + version: '2' + }, file: { attributes: {}, tracks: [],