Skip to content
Closed
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
21 changes: 21 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](#changing-props)
* [API Functionality](#api-functionality)
* [Advanced Implementation Examples](#advanced-implementation-examples)
* [Development](#development)
Expand Down Expand Up @@ -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<Event>` handlers are re-subscribed, so the player always calls the latest one. `once<Event>` 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
<JWPlayer library={library} playlist={[{ file, image }]} />
```

To change any other config prop, give the component a new `key` so React unmounts the old player and creates a new one:

``` javascript
<JWPlayer library={library} playlist={playlist} width={width} key={width} />
```

### 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
2 changes: 2 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 Down
32 changes: 31 additions & 1 deletion 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, isDeepEqual,
} from './util';

function createOnEventHandler(props) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) || ['', ''];

Expand Down
121 changes: 120 additions & 1 deletion 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, isDeepEqual } from '../src/util';
import { mockLibrary, players } from './util';

const noop = () => {};
Expand Down Expand Up @@ -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(<JWPlayer ref={ref} library={library} {...props} />));
});
const { player } = ref.current;

await act(async () => {
rerender(<JWPlayer ref={ref} library={library} {...nextProps} />);
});

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(<JWPlayer ref={ref} library={library} playlist={playlist} />));
});
const script = Array.from(document.getElementsByTagName('script')).pop();

// The playlist resolves while the library request is still in flight.
act(() => {
rerender(<JWPlayer ref={ref} library={library} playlist={nextPlaylist} />);
});

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();
Expand Down
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