diff --git a/CHANGELOG.md b/CHANGELOG.md index 24b5556..ca8fff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Example ([#10](https://github.com/bitmovin/bitmovin-player-react/pull/10)) - Contribution guide ([#10](https://github.com/bitmovin/bitmovin-player-react/pull/10)) - Issue template ([#10](https://github.com/bitmovin/bitmovin-player-react/pull/10)) +- Reinitialize the player on changes in the player config or UI config ([#12](https://github.com/bitmovin/bitmovin-player-react/pull/12)) ### Changed diff --git a/README.md b/README.md index 0e7ad07..4d09d57 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ export function MyComponent() { ## Dynamically update player source config -`BitmovinPlayer` keeps track of the source config and reloads the player when the source config changes: +`BitmovinPlayer` keeps track of the source config and reloads the source on changes: ```tsx const playerSources: Array = [ @@ -87,6 +87,56 @@ export function MyComponent() { } ``` +## Dynamically update player config and UI config + +`BitmovinPlayer` keeps track of the player config and UI config and reinitializes the player (destroys the old instance and creates a new one) on changes : + +```ts +const playerConfigs: Array = [ + { + key: "", + playback: { + autoplay: true, + muted: true, + }, + }, + { + key: "", + playback: { + autoplay: false, + muted: false, + }, + }, +]; + +export function MyComponent() { + const [playerConfig, setPlayerConfig] = useState(playerConfigs[0]); + + useEffect(() => { + let lastConfigIndex = 0; + + const intervalId = setInterval(() => { + const newIndex = ++lastConfigIndex % playerConfigs.length; + + console.log(`Switching to player config ${newIndex}`, playerConfigs[newIndex]); + + setPlayerConfig(playerConfigs[newIndex]); + }, 15_000); + + return () => clearInterval(intervalId); + }, []); + + return ( + +

Dynamic player config demo

+ +
+ ); +} +``` + +The same applies to the `customUi` object. + ## Attach event listeners ```tsx @@ -145,7 +195,7 @@ export function MyComponent() { You can use `UIContainer` from https://www.npmjs.com/package/bitmovin-player-ui to customize the player UI: ```tsx -import { PlaybackToggleOverlay, UIContainer } from "bitmovin-player-ui"; +import { PlaybackToggleOverlay, UIContainer, CustomUi } from "bitmovin-player-ui"; // Ensure this function returns a new instance of the `UIContainer` on every call. const uiContainerFactory = () => @@ -153,6 +203,10 @@ const uiContainerFactory = () => components: [new PlaybackToggleOverlay()], }); +const customUi: CustomUi = { + containerFactory: uiContainerFactory +}; + export function MyComponent() { return ( @@ -160,9 +214,7 @@ export function MyComponent() { ); @@ -174,7 +226,7 @@ export function MyComponent() { You can use `UIVariant`s from https://www.npmjs.com/package/bitmovin-player-ui to customize the player UI: ```tsx -import { UIVariant } from "bitmovin-player-ui"; +import { UIVariant, CustomUi } from "bitmovin-player-ui"; // Ensure this function returns a new instance of the `UIVariant[]` on every call. const uiVariantsFactory = (): UIVariant[] => [ @@ -198,6 +250,10 @@ const uiVariantsFactory = (): UIVariant[] => [ }, ]; +const customUi: CustomUi = { + variantsFactory: uiVariantsFactory +}; + export function MyComponent() { return ( @@ -205,9 +261,7 @@ export function MyComponent() { ); @@ -274,7 +328,7 @@ export function MyComponent() { ## Possible pitfalls -### Avoid source object recreation on every render +### Avoid player config, UI config, and source objects recreation on every render ```tsx export function MyComponent() { @@ -308,6 +362,8 @@ Instead do one of the following: - Use `useState` (refer to the "Dynamic source demo" above) - Use `useMemo`: +The same applies to the `config`, `source`, and `customUi` objects. + ```tsx export function MyComponent() { const [_counter, setCounter] = useState(0); diff --git a/example/package-lock.json b/example/package-lock.json index 1da03f5..d9c9f01 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -12,12 +12,15 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.1", "vite": "^5.2.7" } }, "..": { - "version": "1.0.0", + "name": "bitmovin-player-react", + "version": "1.0.0-beta.2", "license": "MIT", "devDependencies": { "@testing-library/jest-dom": "^6.4.2", @@ -27,6 +30,7 @@ "@types/react-dom": "^18.2.24", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", + "concurrently": "^8.2.2", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", @@ -35,8 +39,10 @@ "eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-require-extensions": "^0.1.3", "eslint-plugin-simple-import-sort": "^12.0.0", + "husky": "^9.0.11", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.2.2", "prettier": "^3.2.5", "ts-jest": "^29.1.2", "typescript": "^5.4.4" @@ -1059,6 +1065,31 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.2.1.tgz", @@ -1191,6 +1222,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/example/package.json b/example/package.json index 9b97398..258e529 100644 --- a/example/package.json +++ b/example/package.json @@ -12,6 +12,8 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.1", "vite": "^5.2.7" } diff --git a/example/src/App.tsx b/example/src/App.tsx index fdd4c26..54c6a37 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,5 +1,5 @@ import { PlayerConfig, SourceConfig } from 'bitmovin-player'; -import { BitmovinPlayer } from 'bitmovin-player-react'; +import { BitmovinPlayer, CustomUi } from 'bitmovin-player-react'; import { ControlBar, PlaybackToggleOverlay, SeekBar, UIContainer, UIVariant } from 'bitmovin-player-ui'; import { Fragment } from 'react'; @@ -39,6 +39,10 @@ const uiVariantsFactory = (): UIVariant[] => [ }, ]; +const customUi: CustomUi = { + variantsFactory: uiVariantsFactory, +}; + export function App() { return ( @@ -49,13 +53,7 @@ export function App() { maxWidth: '800px', }} > - + ); diff --git a/src/BitmovinPlayer.test.tsx b/src/BitmovinPlayer.test.tsx index 7bdb0cf..6aedc8a 100644 --- a/src/BitmovinPlayer.test.tsx +++ b/src/BitmovinPlayer.test.tsx @@ -30,58 +30,94 @@ beforeEach(() => { }); describe('BitmovinPlayer', () => { - it('should render the player', async () => { - const { getBySelector, getAllBySelector } = render(, { - queries, + describe('Player config', () => { + it('should render the player on mount', async () => { + const { getBySelector, getAllBySelector } = render(, { + queries, + }); + + expect(getAllBySelector('video')).toHaveLength(1); + expect(getBySelector('video')).toBeInTheDocument(); + expect(getBySelector(`.${FakePlayer.containerClassName}`)).toBeInTheDocument(); }); - expect(getAllBySelector('video')).toHaveLength(1); - expect(getBySelector('video')).toBeInTheDocument(); - expect(getBySelector(`.${FakePlayer.containerClassName}`)).toBeInTheDocument(); - }); + it('should destroy the player on unmount', () => { + jest.spyOn(FakePlayer.prototype, 'destroy'); - it('should load the source initially', async () => { - jest.spyOn(FakePlayer.prototype, 'load'); + const { unmount } = render(); - render(); + unmount(); - await waitFor(() => { - expect(FakePlayer.prototype.load).toHaveBeenCalled(); + expect(FakePlayer.prototype.destroy).toHaveBeenCalled(); }); - }); - it('should unload the source', async () => { - jest.spyOn(FakePlayer.prototype, 'load'); - jest.spyOn(FakePlayer.prototype, 'unload'); + it('should reinitialize the player on changes in the player config', async () => { + jest.spyOn(FakePlayer.prototype, 'destroy'); + + const { getBySelector, rerender } = render(, { + queries, + }); - const { rerender } = render(); + rerender(); - rerender(); + await FakePlayer.ensureLatestDestroyFinished(); - await waitFor(() => { - expect(FakePlayer.prototype.load).toHaveBeenCalled(); - expect(FakePlayer.prototype.unload).toHaveBeenCalled(); + expect(FakePlayer.prototype.destroy).toHaveBeenCalled(); + expect(getBySelector(`.${FakePlayer.containerClassName}`)).toBeInTheDocument(); }); }); - it("should not unload the source if it's empty initially", async () => { - jest.spyOn(FakePlayer.prototype, 'unload'); + describe('Source config', () => { + it('should load the source initially', async () => { + jest.spyOn(FakePlayer.prototype, 'load'); - render(); + render(); - await expectNeverOccurs(() => { - expect(FakePlayer.prototype.unload).toHaveBeenCalled(); + await waitFor(() => { + expect(FakePlayer.prototype.load).toHaveBeenCalled(); + }); }); - }); - it('should destroy the player', () => { - jest.spyOn(FakePlayer.prototype, 'destroy'); + it('should unload the source', async () => { + jest.spyOn(FakePlayer.prototype, 'load'); + jest.spyOn(FakePlayer.prototype, 'unload'); + + const { rerender } = render(); + + rerender(); - const { unmount } = render(); + await waitFor(() => { + expect(FakePlayer.prototype.load).toHaveBeenCalled(); + expect(FakePlayer.prototype.unload).toHaveBeenCalled(); + }); + }); + + it("should not unload the source if it's empty initially", async () => { + jest.spyOn(FakePlayer.prototype, 'unload'); - unmount(); + render(); - expect(FakePlayer.prototype.destroy).toHaveBeenCalled(); + await expectNeverOccurs(() => { + expect(FakePlayer.prototype.unload).toHaveBeenCalled(); + }); + }); + + it('should load the source again on changes in the player config', async () => { + jest.spyOn(FakePlayer.prototype, 'load'); + jest.spyOn(FakePlayer.prototype, 'unload'); + + const { rerender } = render(, { + queries, + }); + + rerender(); + + await waitFor(() => { + expect(FakePlayer.prototype.load).toHaveBeenCalledTimes(2); + // The player is simply destroyer, so the `unload` should not be invoked. + expect(FakePlayer.prototype.unload).not.toHaveBeenCalled(); + }); + }); }); describe('UI', () => { @@ -215,6 +251,21 @@ describe('BitmovinPlayer', () => { expect(playerRefCallback).toHaveBeenCalledWith(expect.any(FakePlayer)); }); + + it('should not reinitialize the player on ref changes', () => { + jest.spyOn(FakePlayer.prototype, 'destroy'); + + const playerRefCallback: RefCallback = jest.fn(); + + const { rerender } = render(); + + rerender(); + rerender(); + + expect(playerRefCallback).toHaveBeenCalledWith(expect.any(FakePlayer)); + expect(playerRefCallback).toHaveBeenCalledTimes(2); + expect(FakePlayer.prototype.destroy).not.toHaveBeenCalled(); + }); }); /** diff --git a/src/BitmovinPlayer.tsx b/src/BitmovinPlayer.tsx index 57c4370..9888922 100644 --- a/src/BitmovinPlayer.tsx +++ b/src/BitmovinPlayer.tsx @@ -2,7 +2,11 @@ import { Player, PlayerAPI, PlayerConfig, SourceConfig } from 'bitmovin-player'; import { UIContainer, UIFactory, UIManager, UIVariant } from 'bitmovin-player-ui'; import { ForwardedRef, forwardRef, MutableRefObject, RefCallback, useEffect, useRef, useState } from 'react'; -interface BitmovinPlayerProps { +export type UiContainerFactory = () => UIContainer; +export type UiVariantsFactory = () => UIVariant[]; +export type CustomUi = { containerFactory: UiContainerFactory } | { variantsFactory: UiVariantsFactory }; + +export interface BitmovinPlayerProps { config: PlayerConfig; source?: SourceConfig; className?: string; @@ -22,13 +26,7 @@ interface BitmovinPlayerProps { * - https://www.npmjs.com/package/bitmovin-player-ui. * - https://cdn.bitmovin.com/player/web/8/docs/interfaces/Core.PlayerConfig.html#ui. * */ - customUi?: - | { - containerFactory: () => UIContainer; - } - | { - variantsFactory: () => UIVariant[]; - }; + customUi?: CustomUi; } export const BitmovinPlayer = forwardRef(function BitmovinPlayer( @@ -51,42 +49,33 @@ export const BitmovinPlayer = forwardRef(function BitmovinPlayer( const latestPlayerRef = useRef(); // Initialize the player on mount. - useEffect( - () => { - const rootContainerElement = rootContainerElementRef.current; + useEffect(() => { + const rootContainerElement = rootContainerElementRef.current; - if (!rootContainerElement) { - return; - } + if (!rootContainerElement) { + return; + } - // We create elements manually to workaround the React strict mode. - // In the strict mode the mount hook is invoked twice. Since the destroy method is async - // the next mount hook is invoked before the previous destroy method is finished and the new player instance - // messes up the old one. This workaround ensures that each player instance has its own container and video elements. - // This should be improved in the future if possible. - const { createdPlayerContainerElement, createdVideoElement } = preparePlayerElements(rootContainerElement); + // We create elements manually to workaround the React strict mode. + // In the strict mode the mount hook is invoked twice. Since the destroy method is async + // the next mount hook is invoked before the previous destroy method is finished and the new player instance + // messes up the old one. This workaround ensures that each player instance has its own container and video elements. + // This should be improved in the future if possible. + const { createdPlayerContainerElement, createdVideoElement } = preparePlayerElements(rootContainerElement); - const convertedConfig = convertConfig(config); - const initializedPlayer = initializePlayer(createdPlayerContainerElement, createdVideoElement, convertedConfig); + const convertedConfig = convertConfig(config); + const initializedPlayer = initializePlayer(createdPlayerContainerElement, createdVideoElement, convertedConfig); - initializePlayerUi(initializedPlayer, config, customUi); + initializePlayerUi(initializedPlayer, config, customUi); - latestPlayerRef.current = initializedPlayer; + latestPlayerRef.current = initializedPlayer; - if (playerRefProp) { - setRef(playerRefProp, initializedPlayer); - } + setPlayer(initializedPlayer); - setPlayer(initializedPlayer); - - return () => { - destroyPlayer(initializedPlayer, rootContainerElement, createdPlayerContainerElement); - }; - }, - // Ignore the dependencies, as the effect should run only once (on mount). - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + return () => { + destroyPlayer(initializedPlayer, rootContainerElement, createdPlayerContainerElement); + }; + }, [config, customUi]); // Load or reload the source. useEffect(() => { @@ -118,6 +107,14 @@ export const BitmovinPlayer = forwardRef(function BitmovinPlayer( } }, [source, player]); + useEffect(() => { + if (!player || !playerRefProp) { + return; + } + + setRef(playerRefProp, player); + }, [player, playerRefProp]); + return
; });