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;