diff --git a/README.md b/README.md
index d10da68..d6bff9f 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
* [Props](#props)
* [Required Props](#required-props)
* [Optional Props](#optional-props)
+ * [Changing Props After Mount](#changing-props-after-mount)
* [API Functionality](#api-functionality)
* [Advanced Implementation Examples](#advanced-implementation-examples)
* [Development](#development)
@@ -121,6 +122,30 @@ 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 After Mount
+
+The `playlist` prop updates the existing player. Change it and the component calls the player's [`load`](https://developer.jwplayer.com/jwplayer/docs/jw8-javascript-api-reference) method, so the new media plays:
+
+```jsx
+// Switching playlist swaps the content in the player already on the page.
+
+```
+
+`playlist` is compared by value, so passing an equal array built fresh on every render does not reload the player. An empty or absent playlist is ignored, so it never blanks out a player that is already playing.
+
+Every other config option, including `file`, is read once when the player is set up. Changing one after mount has no effect, because the player cannot reconfigure itself in place. To apply a new config, force a remount by giving the component a `key` that changes with it:
+
+```jsx
+
+```
+
+`on` handler props are re-subscribed on every change, so the player always calls the latest one, and changing one never reloads the player. `once` handlers are subscribed at mount only.
+
### 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..edb85f2 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;
}
@@ -46,6 +48,10 @@ export interface UnmountCallbackArguments {
*
* Exception: React reserves the `key` prop and never forwards it, so a player
* license key only works via `config={{ key: ... }}`.
+ *
+ * After mount, only `playlist` updates the running player, via its load() method.
+ * Every other option — `file` included — is read once at setup, so changing it
+ * requires a remount. See "Changing Props After Mount" in the README.
*/
export interface JWPlayerProps extends JWPlayerConfig {
didMountCallback?: (args: MountCallbackArguments) => void;
diff --git a/src/jwplayer.jsx b/src/jwplayer.jsx
index 2a199c4..bec6917 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, deepEqual,
} from './util';
function createOnEventHandler(props) {
@@ -60,17 +60,23 @@ class JWPlayer extends React.Component {
}
}
- shouldComponentUpdate(nextProps) {
+ // The rendered output is a static container div, so an update is only ever
+ // worth running to sync props onto the player in componentDidUpdate. Without
+ // a player there is nothing to sync.
+ shouldComponentUpdate() {
+ return Boolean(this.player);
+ }
+
+ componentDidUpdate(prevProps) {
if (!this.player) {
- return false;
+ return;
}
- if (this.didOnEventsChange(nextProps)) {
- this.updateOnEventListener(nextProps);
- return false;
+ if (this.didOnEventsChange(prevProps)) {
+ this.updateOnEventListener(this.props);
}
- return true;
+ this.syncContent();
}
componentWillUnmount() {
@@ -87,13 +93,43 @@ class JWPlayer extends React.Component {
}
createPlayer() {
- const { config, ref } = this;
- const setupConfig = { ...window.jwDefaults, ...config };
- const view = ref.current;
+ // Props can change while the library loads, so set up from the current
+ // props rather than the constructor-time snapshot.
+ this.config = generateConfig(this.props);
+ const setupConfig = { ...window.jwDefaults, ...this.config };
+ const view = this.ref.current;
return window.jwplayer(view.id).setup(setupConfig);
}
+ // The playlist is the only setup option the player can swap in place. Every
+ // other config prop is read once at setup, so changing it needs a remount —
+ // give the component a new `key`.
+ //
+ // `file` is deliberately excluded. Reloading it would mean rebuilding a
+ // playlist item, which silently drops the sibling top-level item options
+ // (image, tracks, title, ...) that this package forwards without knowing their
+ // schema.
+ syncContent() {
+ const nextConfig = generateConfig(this.props);
+ const prevPlaylist = this.config.playlist;
+ const { playlist } = nextConfig;
+
+ this.config = nextConfig;
+
+ if (deepEqual(prevPlaylist, playlist)) {
+ return;
+ }
+
+ // An empty or absent playlist must not blank out a playing player; content
+ // that arrives later still triggers a load.
+ if (!playlist || playlist.length === 0) {
+ 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..24fabec 100644
--- a/src/util.js
+++ b/src/util.js
@@ -76,6 +76,22 @@ export function generateConfig(props) {
return { ...props.config, ...config, isReactComponent: true };
}
+// Structural comparison of player config values — strings, numbers, arrays and
+// plain objects. Not a general-purpose deep equal: functions, dates and class
+// instances compare by identity, which is all the config surface needs.
+export function deepEqual(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 keys = Object.keys(a);
+ if (keys.length !== Object.keys(b).length) return false;
+
+ return keys.every((key) => (
+ Object.prototype.hasOwnProperty.call(b, key) && deepEqual(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..aa891f7 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, deepEqual } from '../src/util';
import { mockLibrary, players } from './util';
const noop = () => {};
@@ -155,6 +155,22 @@ describe('methods', () => {
});
});
+ describe('deepEqual', () => {
+ 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(deepEqual(a, b)).toBe(expected);
+ });
+ });
+
it('createEventListeners', async () => {
const { instance } = await createMountedComponent({ onReady: noop, onPlay: noop, oncePause: noop });
const id = instance.id;
@@ -282,11 +298,14 @@ describe('methods', () => {
expect(shouldUpdate).toBe(true);
});
- it('should not update component if on event props change', async () => {
+ // Event listeners used to be re-subscribed from shouldComponentUpdate,
+ // which then returned false. That dropped any config change landing in
+ // the same update, so the work moved to componentDidUpdate.
+ it('should update component if on event props change', async () => {
const { instance } = await createMountedComponent();
const nextProps = { onPlay: noop };
const shouldUpdate = instance.shouldComponentUpdate(nextProps);
- expect(shouldUpdate).toBe(false);
+ expect(shouldUpdate).toBe(true);
});
it('should not update component if player does not exist', async () => {
@@ -412,3 +431,131 @@ describe('methods', () => {
});
});
});
+
+// Content props must reach an already-created player.
+// See https://github.com/jwplayer/jwplayer-react/issues/20
+describe('content updates', () => {
+ const otherPlaylist = 'https://cdn.jwplayer.com/v2/media/abcd1234';
+
+ const renderPlayer = async (props) => {
+ const ref = React.createRef();
+ let result;
+ await act(async () => {
+ result = render();
+ });
+ const rerenderPlayer = async (nextProps) => {
+ await act(async () => {
+ result.rerender();
+ });
+ };
+ return { instance: ref.current, rerenderPlayer };
+ };
+
+ it('loads the new playlist when the playlist prop changes', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist });
+
+ await rerenderPlayer({ playlist: otherPlaylist });
+
+ expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]);
+ });
+
+ it('loads the new playlist when an inline playlist array changes', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ file: 'a.mp4' }] });
+
+ await rerenderPlayer({ playlist: [{ file: 'b.mp4' }] });
+
+ expect(instance.player.load.mock.calls).toEqual([[[{ file: 'b.mp4' }]]]);
+ });
+
+ // Reloading a bare file would mean rebuilding a playlist item and dropping
+ // its sibling top-level options, so file stays setup-only.
+ it('does not reload when only the file prop changes', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ file: 'a.mp4', image: 'a.jpg' });
+
+ await rerenderPlayer({ file: 'b.mp4', image: 'a.jpg' });
+
+ expect(instance.player.load).not.toHaveBeenCalled();
+ });
+
+ it('reads content out of the config prop too', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ config: { playlist } });
+
+ await rerenderPlayer({ config: { playlist: otherPlaylist } });
+
+ expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]);
+ });
+
+ it('does not reload when a deep-equal playlist is passed as a new array', async () => {
+ const item = { file: 'a.mp4', tracks: [{ label: 'English', kind: 'captions' }] };
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist: [{ ...item }] });
+
+ await rerenderPlayer({ playlist: [{ ...item }] });
+
+ expect(instance.player.load).not.toHaveBeenCalled();
+ });
+
+ it('does not reload when an unrelated prop changes', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist, width: 500 });
+
+ await rerenderPlayer({ playlist, width: 640 });
+
+ expect(instance.player.load).not.toHaveBeenCalled();
+ });
+
+ // Inline arrow handlers get a new identity on every parent render, so this
+ // is the common case, not an edge case.
+ it('loads new content even when an on event handler changed in the same update', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist, onPlay: () => {} });
+
+ await rerenderPlayer({ playlist: otherPlaylist, onPlay: () => {} });
+
+ expect(instance.player.load.mock.calls).toEqual([[otherPlaylist]]);
+ });
+
+ it('keeps the current content when the new config has none', async () => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist });
+
+ await rerenderPlayer({ advertising: { outstream: true } });
+
+ expect(instance.player.load).not.toHaveBeenCalled();
+ });
+
+ // Content that has not resolved yet must not blank out a playing player.
+ it.each([
+ ['removed', undefined],
+ ['null', null],
+ ['an empty string', ''],
+ ['an empty array', []],
+ ])('keeps the current content when the playlist becomes %s', async (_name, nextValue) => {
+ const { instance, rerenderPlayer } = await renderPlayer({ playlist });
+
+ await rerenderPlayer({ playlist: nextValue });
+
+ expect(instance.player.load).not.toHaveBeenCalled();
+ });
+
+ it('sets up with the props current at setup time, not at construction', async () => {
+ window.jwplayer = null;
+ const ref = React.createRef();
+ let result;
+
+ await act(async () => {
+ result = render();
+ });
+ const [script] = Array.from(document.getElementsByTagName('script'))
+ .filter((tag) => tag.src === library);
+
+ // The playlist changes while the library is still loading.
+ await act(async () => {
+ result.rerender();
+ });
+
+ window.jwplayer = mockLibrary;
+ await act(async () => {
+ script.onload();
+ });
+
+ const setupConfig = window.jwplayer(ref.current.id).setup.mock.calls[0][0];
+ expect(setupConfig.playlist).toBe(otherPlaylist);
+ });
+});
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;