diff --git a/README.md b/README.md index d10da68..92131ef 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ * [Props](#props) * [Required Props](#required-props) * [Optional Props](#optional-props) + * [Changing Props](#changing-props) * [API Functionality](#api-functionality) * [Advanced Implementation Examples](#advanced-implementation-examples) * [Development](#development) @@ -121,6 +122,26 @@ If you are not using a cloud hosted player you will need to provide a license ke * Type: `({ player: PlayerAPI, id: string }) => void` * Example: See [advanced implementation example](#advanced-implementation-examples) +### Changing Props + +Two props keep working after the player is created: + +* `playlist` loads the new content. The player is reused, so playback moves to the new media. A playlist that is empty, `null`, or `undefined` is ignored, so content that arrives later (from a fetch, for example) does not blank out a playing player. +* `on` handlers are re-subscribed, so the player always calls the latest one. `once` handlers are subscribed at mount only. + +Every other config prop, `file` and `sources` included, is only read when the player is created. Use `playlist` for content that changes: + +``` javascript +// Reuses the player and loads the new item + +``` + +To change any other config prop, give the component a new `key` so React unmounts the old player and creates a new one: + +``` javascript + +``` + ### API Functionality For advanced usage,`jwplayer-react` creates an instance of the player API when mounted, and sets it to `this.player`, exposing all api functionality listed [here](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference). diff --git a/src/jwplayer-react.d.ts b/src/jwplayer-react.d.ts index 651fd1e..0f89cbf 100644 --- a/src/jwplayer-react.d.ts +++ b/src/jwplayer-react.d.ts @@ -25,6 +25,8 @@ export interface JWPlayerApi { off(event?: string, callback?: EventCallback): JWPlayerApi; remove(): void; setup(config: JWPlayerConfig | object): JWPlayerApi; + /** A playlist feed url, or an array of playlist items */ + load(playlist: string | object[]): JWPlayerApi; [member: string]: unknown; } diff --git a/src/jwplayer.jsx b/src/jwplayer.jsx index 2a199c4..646b1d7 100644 --- a/src/jwplayer.jsx +++ b/src/jwplayer.jsx @@ -3,7 +3,7 @@ import { ALL, ON_REGEX, ONCE_REGEX, } from './const'; import { - generateConfig, generateUniqueId, loadPlayer, getHandlerName, + generateConfig, generateUniqueId, loadPlayer, getHandlerName, isDeepEqual, } from './util'; function createOnEventHandler(props) { @@ -61,10 +61,20 @@ class JWPlayer extends React.Component { } shouldComponentUpdate(nextProps) { + const prevPlaylist = this.config.playlist; + + // Track the latest props even before the player exists: a change that lands + // while the library is still loading has to reach the pending setup() call. + this.config = generateConfig(nextProps); + if (!this.player) { return false; } + // The rendered output is always the same container div, so a playlist + // change reaches the player through its API here, not through a re-render. + this.loadPlaylist(prevPlaylist); + if (this.didOnEventsChange(nextProps)) { this.updateOnEventListener(nextProps); return false; @@ -94,6 +104,26 @@ class JWPlayer extends React.Component { return window.jwplayer(view.id).setup(setupConfig); } + // Load new content when the playlist changes. Only the playlist is applied + // after setup: the rest of the config is read by the player when it is + // created, and re-running setup() to change it would tear down playback. + // https://docs.jwplayer.com/players/reference/javascript-player-api-introduction + loadPlaylist(prevPlaylist) { + const { playlist } = this.config; + + // An absent or empty playlist means the content has not resolved yet, not + // that a playing player should be blanked out. + if (playlist == null || playlist.length === 0) { + return; + } + + if (isDeepEqual(prevPlaylist, playlist)) { + return; + } + + this.player.load(playlist); + } + didOnEventsChange(nextProps) { const onEventFilter = (prop) => prop.match(ON_REGEX); const currEvents = Object.keys(this.props).filter(onEventFilter).sort(); diff --git a/src/util.js b/src/util.js index f552a5d..f058fe4 100644 --- a/src/util.js +++ b/src/util.js @@ -76,6 +76,24 @@ export function generateConfig(props) { return { ...props.config, ...config, isReactComponent: true }; } +// A playlist is plain data (a feed url, or an array of playlist items), so a +// structural comparison is what tells a real content change apart from a new +// array or object literal rendered with the same values. +export function isDeepEqual(a, b) { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) return false; + + return aKeys.every((key) => ( + Object.prototype.hasOwnProperty.call(b, key) && isDeepEqual(a[key], b[key]) + )); +} + export function getHandlerName(prop, regex) { const match = prop.match(regex) || ['', '']; diff --git a/test/jwplayer-react.test.js b/test/jwplayer-react.test.js index a78c314..dc5152a 100644 --- a/test/jwplayer-react.test.js +++ b/test/jwplayer-react.test.js @@ -5,7 +5,7 @@ import React from 'react'; import { render, act } from '@testing-library/react'; import JWPlayer from '../src/jwplayer'; -import { loadPlayer } from '../src/util'; +import { loadPlayer, isDeepEqual } from '../src/util'; import { mockLibrary, players } from './util'; const noop = () => {}; @@ -244,6 +244,125 @@ describe('methods', () => { }); }); + describe('isDeepEqual', () => { + it.each([ + ['identical primitives', 'a.mp4', 'a.mp4', true], + ['different primitives', 'a.mp4', 'b.mp4', false], + ['a primitive and an object', 'a.mp4', { file: 'a.mp4' }, false], + ['null and an object', null, {}, false], + ['an array and an object', [], {}, false], + ['objects with different key counts', { a: 1 }, { a: 1, b: 2 }, false], + ['objects with different keys', { a: undefined }, { b: undefined }, false], + ['nested equal values', { a: [{ b: 1 }] }, { a: [{ b: 1 }] }, true], + ['nested different values', { a: [{ b: 1 }] }, { a: [{ b: 2 }] }, false], + ])('compares %s', (_name, a, b, expected) => { + expect(isDeepEqual(a, b)).toBe(expected); + }); + }); + + describe('loadPlaylist', () => { + const nextPlaylist = 'https://cdn.jwplayer.com/v2/media/QcK3l9Uv'; + + // Mount with the given props, then re-render with the next set of props. + const rerenderWith = async (props, nextProps) => { + const ref = React.createRef(); + let rerender; + await act(async () => { + ({ rerender } = render()); + }); + const { player } = ref.current; + + await act(async () => { + rerender(); + }); + + return player.load; + }; + + it('loads the new playlist when the playlist prop changes', async () => { + const load = await rerenderWith({ playlist }, { playlist: nextPlaylist }); + expect(load.mock.calls).toEqual([[nextPlaylist]]); + }); + + it('loads the new playlist when playlist items change', async () => { + const load = await rerenderWith( + { playlist: [{ file: 'first.mp4' }] }, + { playlist: [{ file: 'second.mp4' }] } + ); + expect(load.mock.calls).toEqual([[[{ file: 'second.mp4' }]]]); + }); + + it('does not reload when a new playlist array holds the same items', async () => { + const load = await rerenderWith( + { playlist: [{ file: 'same.mp4', tracks: [{ label: 'English' }] }] }, + { playlist: [{ file: 'same.mp4', tracks: [{ label: 'English' }] }] } + ); + expect(load).not.toHaveBeenCalled(); + }); + + it('loads the new playlist when it is nested in the config prop', async () => { + const load = await rerenderWith( + { config: { playlist } }, + { config: { playlist: nextPlaylist } } + ); + expect(load.mock.calls).toEqual([[nextPlaylist]]); + }); + + it('does not reload when a config prop other than the playlist changes', async () => { + const load = await rerenderWith({ playlist, width: 400 }, { playlist, width: 500 }); + expect(load).not.toHaveBeenCalled(); + }); + + it.each([ + ['removed', undefined], + ['null', null], + ['an empty string', ''], + ['an empty array', []], + ])('does not reload when the playlist becomes %s', async (_name, nextValue) => { + const load = await rerenderWith({ playlist }, { playlist: nextValue }); + expect(load).not.toHaveBeenCalled(); + }); + + it('loads the new playlist when an event handler changes in the same update', async () => { + const load = await rerenderWith( + { playlist, onPlay: noop }, + { playlist: nextPlaylist, onPlay: () => {} } + ); + expect(load.mock.calls).toEqual([[nextPlaylist]]); + }); + + it('sets up with the latest playlist when it changed before the library loaded', async () => { + window.jwplayer = null; + const ref = React.createRef(); + let rerender; + act(() => { + ({ rerender } = render()); + }); + const script = Array.from(document.getElementsByTagName('script')).pop(); + + // The playlist resolves while the library request is still in flight. + act(() => { + rerender(); + }); + + window.jwplayer = mockLibrary; + await act(async () => { + script.onload(); + }); + + const { id, player } = ref.current; + expect(window.jwplayer(id).setup.mock.calls) + .toEqual([[{ playlist: nextPlaylist, isReactComponent: true }]]); + expect(player.load).not.toHaveBeenCalled(); + }); + + it('tracks the latest config even when the playlist is unchanged', async () => { + const { instance } = await createMountedComponent(); + instance.shouldComponentUpdate({ playlist, width: 500 }); + expect(instance.config).toEqual({ playlist, width: 500, isReactComponent: true }); + }); + }); + describe('lifecycle', () => { it('mounts with callback', async () => { const spy = jest.fn(); diff --git a/test/util.js b/test/util.js index ad09d33..2d797e4 100644 --- a/test/util.js +++ b/test/util.js @@ -7,8 +7,9 @@ const createMockAPI = (id) => { const off = jest.fn(() => api); const remove = jest.fn(() => api); const setup = jest.fn(() => api); + const load = jest.fn(() => api); - Object.assign(api, { on, once, off, remove, setup }); + Object.assign(api, { on, once, off, remove, setup, load }); players[id] = api; return api;