Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
<JWPlayer library={library} playlist={selectedPlaylist} />
```

`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
<JWPlayer
library={library}
playlist={playlist}
advertising={advertising}
key={adConfigId}
/>
```

`on<Event>` handler props are re-subscribed on every change, so the player always calls the latest one, and changing one never reloads the player. `once<Event>` 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).

Expand Down
6 changes: 6 additions & 0 deletions src/jwplayer-react.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
56 changes: 46 additions & 10 deletions src/jwplayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand All @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) || ['', ''];

Expand Down
153 changes: 150 additions & 3 deletions test/jwplayer-react.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(<JWPlayer ref={ref} library={library} {...props} />);
});
const rerenderPlayer = async (nextProps) => {
await act(async () => {
result.rerender(<JWPlayer ref={ref} library={library} {...nextProps} />);
});
};
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(<JWPlayer ref={ref} library={library} playlist={playlist} />);
});
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(<JWPlayer ref={ref} library={library} playlist={otherPlaylist} />);
});

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);
});
});
3 changes: 2 additions & 1 deletion test/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading