From 119068e7fe9b33e8ee494a323998661ecd7565e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 1 May 2022 16:46:51 +0200 Subject: [PATCH 01/15] Rewrite it with hooks --- .babelrc.js | 30 +-- .browserslistrc | 30 +++ .eslintrc.js | 5 + example/app.js | 6 +- example/package.json | 2 +- index.d.ts | 197 ++++-------------- package.json | 10 +- src/eventNames.js | 16 -- src/index.js | 438 +++++++++++++++++++++++---------------- test/test.js | 5 +- test/util/createVimeo.js | 7 +- test/util/render.js | 27 ++- 12 files changed, 386 insertions(+), 387 deletions(-) create mode 100644 .browserslistrc delete mode 100644 src/eventNames.js diff --git a/.babelrc.js b/.babelrc.js index a29ad66..9c25728 100644 --- a/.babelrc.js +++ b/.babelrc.js @@ -1,16 +1,18 @@ -const TEST = process.env.BABEL_ENV === 'test'; -const CJS = process.env.BABEL_ENV === 'cjs'; +'use strict'; -module.exports = { - presets: [ - ['@babel/env', { - modules: TEST || CJS ? 'commonjs' : false, - loose: true, - targets: TEST ? { node: 'current' } : {}, - }], - '@babel/react', - ], - plugins: TEST ? [ - 'dynamic-import-node', - ] : [], +module.exports = (api) => { + const isTest = api.caller((caller) => caller.name === '@babel/register'); + + return { + targets: isTest ? { node: 'current' } : {}, + presets: [ + ['@babel/env', { + modules: isTest ? 'commonjs' : false, + }], + '@babel/react', + ], + plugins: isTest ? [ + 'dynamic-import-node', + ] : [], + }; }; diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..8f6c8a6 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,30 @@ +and_chr 92 +and_chr 91 +and_ff 90 +and_ff 89 +and_qq 10.4 +and_uc 12.12 +android 92 +android 91 +baidu 7.12 +chrome 92 +chrome 91 +chrome 90 +edge 92 +edge 91 +firefox 90 +firefox 89 +firefox 78 +ios_saf 14.5-14.7 +ios_saf 14.0-14.4 +ios_saf 13.4-13.7 +kaios 2.5 +op_mini all +op_mob 77 +op_mob 76 +opera 77 +opera 76 +safari 14.1 +safari 14 +samsung 14.0 +samsung 13.0 diff --git a/.eslintrc.js b/.eslintrc.js index 2a91a70..d0ed7c3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -11,6 +11,11 @@ module.exports = { 'react/require-default-props': 'off', // Our babel config doesn't support class properties 'react/state-in-constructor': 'off', + // I disagree + 'react/function-component-definition': ['error', { + namedComponents: 'function-declaration', + unnamedComponents: 'arrow-function', + }], 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 'jsx-a11y/label-has-for': ['error', { components: [], diff --git a/example/app.js b/example/app.js index 469bb81..72195e5 100644 --- a/example/app.js +++ b/example/app.js @@ -1,6 +1,6 @@ /* global document */ import React from 'react'; -import ReactDOM from 'react-dom'; +import ReactDOM from 'react-dom/client'; import Vimeo from '@u-wave/react-vimeo'; // eslint-disable-line import/no-unresolved const videos = [ @@ -113,5 +113,5 @@ class App extends React.Component { } } -// eslint-disable-next-line react/no-deprecated -ReactDOM.render(, document.getElementById('example')); +const root = ReactDOM.createRoot(document.querySelector('#example')); +root.render(); diff --git a/example/package.json b/example/package.json index 4af99e3..cf964e0 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,7 @@ "version": "0.0.0-example", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "esbuild --bundle app.js --loader:.js=jsx --sourcemap=inline --minify > bundle.js", + "build": "esbuild --bundle app.js --loader:.js=jsx --sourcemap=inline --minify-whitespace --minify-syntax > bundle.js", "start": "serve ." }, "dependencies": { diff --git a/index.d.ts b/index.d.ts index 3a15f58..cc9647d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,158 +1,16 @@ import * as React from 'react' -import Player, { Error } from '@vimeo/player' -export type PlayEvent = { - /** - * The length of the video in seconds. - */ - duration: number - /** - * The amount of the video, in seconds, that has played. - */ - seconds: number - /** - * The amount of the video that has played in comparison to the length of the video; - * multiply by 100 to obtain the percentage. - */ - percent: number -} - -export type PlayingEvent = PlayEvent; - -export type PauseEvent = { - /** - * The length of the video in seconds. - */ - duration: number - /** - * The amount of the video, in seconds, that has played to the pause position. - */ - seconds: number - /** - * The amount of the video that has played to the pause position in comparison to the length of the video; multiply by 100 to obtain the percentage. - */ - percent: number -} - -export type EndEvent = PauseEvent - -export type TimeUpdateEvent = { - /** - * The length of the video in seconds. - */ - duration: number - /** - * The amount of the video, in seconds, that has played from the current playback position. - */ - seconds: number - /** - * The amount of the video that has played from the current playback position in comparison to the length of the video; multiply by 100 to obtain the percentage. - */ - percent: number -} - -export type ProgressEvent = { - /** - * The length of the video in seconds. - */ - duration: number - /** - * The amount of the video, in seconds, that has buffered. - */ - seconds: number - /** - * The amount of the video that has buffered in comparison to the length of the video; - * multiply by 100 to obtain the percentage. - */ - percent: number -} - -export type SeekedEvent = { - /** - * The length of the video in seconds. - */ - duration: number - /** - * The amount of the video, in seconds, that has played from the new seek position. - */ - seconds: number - /** - * The amount of the video that has played from the new seek position in comparison to the length of the video; multiply by 100 to obtain the percentage. - */ - percent: number -} - -export type TextTrackEvent = { - kind: 'captions' | 'subtitles' - label: string - language: string -} - -export type Cue = { - html: string - text: string -} - -export type CueChangeEvent = { - cues: Cue[] - kind: 'captions' | 'subtitles' - label: string - language: string -} +import Player,{ + Error, + EventMap, + VimeoVideoQuality, +} from '@vimeo/player' -export type CuePointEvent = { - /** - * The location of the cue point in seconds. - */ - time: number - /** - * The ID of the cue point. - */ - id: string - /** - * The custom data from the `addCuePoint()` call, or an empty object. - */ - data: object -} - -export type VolumeEvent = { - /** - * The new volume level. - */ - volume: number -} - -export type PlaybackRateEvent = { - /** - * The new playback rate. - */ - playbackRate: number -} - -export type LoadEvent = { - /** - * The ID of the new video. - */ - id: number -} - -export interface VimeoProps { +export interface VimeoOptions { /** * A Vimeo video ID or URL. */ video: number | string - /** - * DOM ID for the player element. - */ - id?: string - /** - * CSS className for the player element. - */ - className?: string - /** - * Inline style for container element. - */ - style?: React.CSSProperties /** * Width of the player element. */ @@ -271,7 +129,7 @@ export interface VimeoProps { * Vimeo Plus, PRO, and Business members can default * an embedded video to a specific quality on desktop. */ - quality?: string + quality?: VimeoVideoQuality /** * Turn captions/subtitles on for a specific language by default. @@ -296,64 +154,79 @@ export interface VimeoProps { /** * Triggered when video playback is initiated. */ - onPlay?: (event: PlayEvent) => void + onPlay?: (event: EventMap['play']) => void /** * Triggered when the video starts playing. */ - onPlaying?: (event: PlayingEvent) => void + onPlaying?: (event: EventMap['playing']) => void /** * Triggered when the video pauses. */ - onPause?: (event: PauseEvent) => void + onPause?: (event: EventMap['pause']) => void /** * Triggered any time the video playback reaches the end. * Note: when `loop` is turned on, the ended event will not fire. */ - onEnd?: (event: EndEvent) => void + onEnd?: (event: EventMap['ended']) => void /** * Triggered as the `currentTime` of the video updates. It generally fires * every 250ms, but it may vary depending on the browser. */ - onTimeUpdate?: (event: TimeUpdateEvent) => void + onTimeUpdate?: (event: EventMap['timeupdate']) => void /** * Triggered as the video is loaded. Reports back the amount of the video * that has been buffered. */ - onProgress?: (event: ProgressEvent) => void + onProgress?: (event: EventMap['progress']) => void /** * Triggered when the player seeks to a specific time. An `onTimeUpdate` * event will also be fired at the same time. */ - onSeeked?: (event: SeekedEvent) => void + onSeeked?: (event: EventMap['seeked']) => void /** * Triggered when the active text track (captions/subtitles) changes. The * values will be `null` if text tracks are turned off. */ - onTextTrackChange?: (event: TextTrackEvent) => void + onTextTrackChange?: (event: EventMap['texttrackchange']) => void /** * Triggered when the active cue for the current text track changes. It also * fires when the active text track changes. There may be multiple cues * active. */ - onCueChange?: (event: CueChangeEvent) => void + onCueChange?: (event: EventMap['cuechange']) => void /** * Triggered when the current time hits a registered cue point. */ - onCuePoint?: (event: CuePointEvent) => void + onCuePoint?: (event: EventMap['cuepoint']) => void /** * Triggered when the volume in the player changes. Some devices do not * support setting the volume of the video independently from the system * volume, so this event will never fire on those devices. */ - onVolumeChange?: (event: VolumeEvent) => void + onVolumeChange?: (event: EventMap['volumechange']) => void /** * Triggered when the playback rate in the player changes. */ - onPlaybackRateChange?: (event: PlaybackRateEvent) => void + onPlaybackRateChange?: (event: EventMap['playbackratechange']) => void /** * Triggered when a new video is loaded in the player. */ - onLoaded?: (event: LoadEvent) => void + onLoaded?: (event: EventMap['loaded']) => void +} + +export interface VimeoProps extends VimeoOptions { + /** + * DOM ID for the player element. + */ + id?: string + /** + * CSS className for the player element. + */ + className?: string + /** + * Inline style for container element. + */ + style?: React.CSSProperties } /** diff --git a/package.json b/package.json index 624981b..db2acaa 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,14 @@ "name": "@u-wave/react-vimeo", "version": "0.9.11", "description": "Vimeo player component for React.", - "main": "dist/react-vimeo.js", - "module": "dist/react-vimeo.es.js", + "main": "./dist/react-vimeo.js", + "module": "./dist/react-vimeo.mjs", + "exports": { + ".": { + "require": "./dist/react-vimeo.js", + "import": "./dist/react-vimeo.mjs" + } + }, "types": "index.d.ts", "scripts": { "prepare": "npm run build", diff --git a/src/eventNames.js b/src/eventNames.js deleted file mode 100644 index 97f1f9b..0000000 --- a/src/eventNames.js +++ /dev/null @@ -1,16 +0,0 @@ -export default { - play: 'onPlay', - playing: 'onPlaying', - pause: 'onPause', - ended: 'onEnd', - timeupdate: 'onTimeUpdate', - progress: 'onProgress', - seeked: 'onSeeked', - texttrackchange: 'onTextTrackChange', - cuechange: 'onCueChange', - cuepoint: 'onCuePoint', - volumechange: 'onVolumeChange', - playbackratechange: 'onPlaybackRateChange', - error: 'onError', - loaded: 'onLoaded', -}; diff --git a/src/index.js b/src/index.js index 9f1b43b..64a9278 100644 --- a/src/index.js +++ b/src/index.js @@ -1,192 +1,283 @@ +// @ts-check import React from 'react'; import PropTypes from 'prop-types'; import Player from '@vimeo/player'; -import eventNames from './eventNames'; - -class Vimeo extends React.Component { - constructor(props) { - super(props); - - this.refContainer = this.refContainer.bind(this); - } - - componentDidMount() { - this.createPlayer(); - } - - componentDidUpdate(prevProps) { - // eslint-disable-next-line react/destructuring-assignment - const changes = Object.keys(this.props).filter((name) => this.props[name] !== prevProps[name]); - - this.updateProps(changes); - } - - componentWillUnmount() { - this.player.destroy(); - } - - /** - * @private - */ - getInitialOptions() { - const { video } = this.props; - const videoType = /^https?:/i.test(video) ? 'url' : 'id'; - /* eslint-disable react/destructuring-assignment */ - return { - [videoType]: video, - width: this.props.width, - height: this.props.height, - autopause: this.props.autopause, - autoplay: this.props.autoplay, - byline: this.props.showByline, - color: this.props.color, - controls: this.props.controls, - loop: this.props.loop, - portrait: this.props.showPortrait, - title: this.props.showTitle, - muted: this.props.muted, - background: this.props.background, - responsive: this.props.responsive, - dnt: this.props.dnt, - speed: this.props.speed, - keyboard: this.props.keyboard, - pip: this.props.pip, - playsinline: this.props.playsInline, - quality: this.props.quality, - texttrack: this.props.textTrack, - transparent: this.props.transparent, + +/** @typedef {import('@vimeo/player').EventMap} EventMap */ +/** + * @template {any} Data + * @typedef {import('@vimeo/player').EventCallback} EventCallback + */ + +const { + useEffect, + useRef, + useState, +} = React; + +/** + * @param {React.RefObject} container + * @param {import('@vimeo/player').Options} options + */ +function useVimeoPlayer(container, options) { + // Storing the player in the very first hook makes it easier to + // find in React DevTools :) + const [player, setPlayer] = useState(/** @type {Player | null} */ (null)); + + // The effect that manages the player's lifetime. + useEffect(() => { + const instance = new Player(container.current, options); + setPlayer(instance); + + return () => { + instance.destroy(); }; - /* eslint-enable react/destructuring-assignment */ - } - - /** - * @private - */ - updateProps(propNames) { - const { player } = this; - propNames.forEach((name) => { - // eslint-disable-next-line react/destructuring-assignment - const value = this.props[name]; - switch (name) { - case 'autopause': - player.setAutopause(value); - break; - case 'color': - player.setColor(value); - break; - case 'loop': - player.setLoop(value); - break; - case 'volume': - player.setVolume(value); - break; - case 'paused': - player.getPaused().then((paused) => { - if (value && !paused) { - return player.pause(); - } - if (!value && paused) { - return player.play(); - } - return null; - }); - break; - case 'width': - case 'height': - player.element[name] = value; - break; - case 'video': - if (value) { - const { start } = this.props; - const loaded = player.loadVideo(value); - // Set the start time only when loading a new video. - // It seems like this has to be done after the video has loaded, else it just starts at - // the beginning! - if (typeof start === 'number') { - loaded.then(() => { - player.setCurrentTime(start); - }); - } - } else { - player.unload(); - } - break; - case 'playbackRate': - player.setPlaybackRate(value); - break; - case 'quality': - player.setQuality(value); - break; - default: - // Nothing + }, []); + + return player; +} + +/** + * Use an effect with a maybe-existing player. + * + * @param {Player|null} player + * @param {() => void | (() => void)} callback + * @param {unknown[]} dependencies + */ +function usePlayerEffect(player, callback, dependencies) { + useEffect(() => { + if (player) callback(); + }, [player, ...dependencies]); +} + +/** + * Attach an event listener to a Vimeo player. + * + * @template {keyof EventMap} K + * @param {Player} player + * @param {K} event + * @param {EventCallback} handler + */ +function useEventHandler(player, event, handler) { + usePlayerEffect(player, () => { + if (handler) { + player.on(event, handler); + } + return () => { + if (handler) { + player.off(event, handler); } - }); - } - - /** - * @private - */ - createPlayer() { - const { start, volume, playbackRate } = this.props; - - this.player = new Player(this.container, this.getInitialOptions()); - - Object.keys(eventNames).forEach((dmName) => { - const reactName = eventNames[dmName]; - this.player.on(dmName, (event) => { - // eslint-disable-next-line react/destructuring-assignment - const handler = this.props[reactName]; - if (handler) { - handler(event); - } - }); - }); + }; + }, [event, handler]); +} - const { onError, onReady } = this.props; - this.player.ready().then(() => { - if (onReady) { - onReady(this.player); +/** + * @param {React.RefObject} container + * @param {import('../index').VimeoOptions} options + */ +function useVimeo(container, { + video, + width, + height, + autopause, + autoplay, + showByline, + color, + controls, + loop, + showPortrait, + showTitle, + muted, + background, + responsive, + dnt, + speed, + keyboard, + pip, + playsInline, + quality, + textTrack, + transparent, + paused, + volume, + playbackRate, + start, + onReady, + onError, + onPlay, + onPause, + onEnd, + onTimeUpdate, + onProgress, + onSeeked, + onTextTrackChange, + onCueChange, + onCuePoint, + onVolumeChange, + onPlaybackRateChange, + onLoaded, +}) { + const isFirstRender = useRef(true); + const player = useVimeoPlayer(container, { + [typeof video === 'string' ? 'url' : 'id']: video, + // The Vimeo player officially only supports integer width/height. + // If a "100%" string was provided we apply it afterwards in an effect. + width: typeof width === 'number' ? width : undefined, + height: typeof height === 'number' ? height : undefined, + autopause, + autoplay, + byline: showByline, + color, + controls, + loop, + portrait: showPortrait, + title: showTitle, + muted, + background, + responsive, + dnt, + speed, + keyboard, + pip, + playsinline: playsInline, + quality, + texttrack: textTrack, + transparent, + }); + + // Initial player setup. + // This effect should only run once *and* it's async, + // so the most reliable thing to do is to put all its dependencies in a mutable ref. + const initState = useRef({ onReady, onError, start }); + Object.assign(initState.current, { onReady, onError, start }); + usePlayerEffect(player, () => { + let cancelled = false; + + player.ready().then(() => { + if (cancelled) { + return; + } + if (initState.current.start) { + player.setCurrentTime(initState.current.start); } + + initState.current.onReady?.(player); }, (err) => { - if (onError) { - onError(err); + if (cancelled) { + return; + } + if (initState.current.onError) { + initState.current.onError(err); } else { throw err; } }); - - if (typeof start === 'number') { - this.player.setCurrentTime(start); + return () => { + cancelled = true; + }; + }, []); + + useEventHandler(player, 'play', onPlay); + useEventHandler(player, 'pause', onPause); + useEventHandler(player, 'ended', onEnd); + useEventHandler(player, 'timeupdate', onTimeUpdate); + useEventHandler(player, 'progress', onProgress); + useEventHandler(player, 'seeked', onSeeked); + useEventHandler(player, 'texttrackchange', onTextTrackChange); + useEventHandler(player, 'cuechange', onCueChange); + useEventHandler(player, 'cuepoint', onCuePoint); + useEventHandler(player, 'volumechange', onVolumeChange); + useEventHandler(player, 'playbackratechange', onPlaybackRateChange); + useEventHandler(player, 'error', onError); + useEventHandler(player, 'loaded', onLoaded); + + usePlayerEffect(player, () => { + player.setAutopause(autopause); + }, [autopause]); + usePlayerEffect(player, () => { + if (color) player.setColor(color); + }, [color]); + usePlayerEffect(player, () => { + player.setLoop(loop); + }, [loop]); + usePlayerEffect(player, () => { + player.setVolume(volume); + }, [volume]); + usePlayerEffect(player, () => { + if (playbackRate != null) { + player?.setPlaybackRate(playbackRate); } - - if (typeof volume === 'number') { - this.updateProps(['volume']); + }, [playbackRate]); + usePlayerEffect(player, () => { + player.getPaused().then((prevPaused) => { + if (paused && !prevPaused) { + return player.pause(); + } + if (!paused && prevPaused) { + return player.play(); + } + return null; + }); + }, [paused]); + usePlayerEffect(player, () => { + /** @type {HTMLIFrameElement} */ (/** @type {any} */ (player).element).width = String(width); + }, [width]); + usePlayerEffect(player, () => { + /** @type {HTMLIFrameElement} */ (/** @type {any} */ (player).element).height = String(height); + }, [height]); + + usePlayerEffect(player, () => { + if (isFirstRender.current) { + isFirstRender.current = false; + return () => {}; } - if (typeof playbackRate === 'number') { - this.updateProps(['playbackRate']); + let cancelled = false; + if (video) { + const loaded = player.loadVideo(video); + // Set the start time only when loading a new video. + // It seems like this has to be done after the video has loaded, else it just starts at + // the beginning! + if (typeof start === 'number') { + loaded.then(() => { + if (cancelled) { + return; + } + player.setCurrentTime(start); + }); + } + } else { + player.unload(); } - } - - /** - * @private - */ - refContainer(container) { - this.container = container; - } - - render() { - const { id, className, style } = this.props; - - return ( -
- ); - } + return () => { + cancelled = true; + }; + }, [video]); + + return player; +} + +/** + * @param {import('../index').VimeoProps} props + */ +function Vimeo({ + id, + className, + style, + ...options +}) { + /** @type {React.RefObject} */ + const container = useRef(null); + useVimeo(container, options); + + return ( +
+ ); } if (process.env.NODE_ENV !== 'production') { @@ -446,4 +537,5 @@ Vimeo.defaultProps = { transparent: true, }; +export { useVimeo }; export default Vimeo; diff --git a/test/test.js b/test/test.js index 018f91d..ac3291d 100644 --- a/test/test.js +++ b/test/test.js @@ -21,14 +21,13 @@ describe('Vimeo', () => { expect(sdkMock.calls[0].arguments[1]).toMatch({ url: 'https://vimeo.com/179290396' }); }); - it('should all onError when `ready()` fails', async () => { + it('should call onError when `ready()` fails', async () => { const onError = createSpy(); const { sdkMock } = await render({ video: 404, shouldFail: true, onError, }); - await Promise.resolve(); expect(sdkMock).toHaveBeenCalled(); expect(sdkMock.calls[0].arguments[1]).toMatch({ id: 404 }); expect(onError).toHaveBeenCalled(); @@ -137,7 +136,7 @@ describe('Vimeo', () => { }); expect(playerMock.setWidth).toHaveBeenCalledWith('100%'); - expect(playerMock.setHeight).toHaveBeenCalledWith(800); + expect(playerMock.setHeight).toHaveBeenCalledWith('800'); }); it('should set the playback rate using the "playbackRate" props', async () => { diff --git a/test/util/createVimeo.js b/test/util/createVimeo.js index df9248d..8e51296 100644 --- a/test/util/createVimeo.js +++ b/test/util/createVimeo.js @@ -50,8 +50,11 @@ export default function createVimeo({ shouldFail = false } = {}) { }); const Vimeo = proxyquire.noCallThru().load('../../src/index.js', { - '@vimeo/player': function Player(...args) { - return sdkMock(...args); + '@vimeo/player': { + __esModule: true, + default: function Player(...args) { + return sdkMock(...args); + }, }, }).default; diff --git a/test/util/render.js b/test/util/render.js index 23bbca5..fe8c4d4 100644 --- a/test/util/render.js +++ b/test/util/render.js @@ -22,7 +22,11 @@ async function render(initialProps) { shouldFail: initialProps.shouldFail, }); - let component; + let resolveReady; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + // Emulate changes to component.props using a container component's state class Container extends React.Component { constructor(ytProps) { @@ -31,14 +35,16 @@ async function render(initialProps) { this.state = { props: ytProps }; } + componentDidMount() { + // Wait for the initial `setPlayer()` to be rendered. + setTimeout(() => resolveReady()); + } + render() { const { props } = this.state; return ( - { component = vimeo; }} - {...props} - /> + ); } } @@ -60,9 +66,11 @@ async function render(initialProps) { }, }; } + const container = await new Promise((resolve) => { root.render(); }); + await readyPromise; function rerender(newProps) { return (act || noAct)(async () => { @@ -70,16 +78,13 @@ async function render(initialProps) { }); } - function unmount() { - root.unmount(); - } - return { sdkMock, playerMock, - component, rerender, - unmount, + unmount() { + root.unmount(); + }, }; } From 84b116ee8d3a00da02aff8fd9a628f95c3964c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 1 May 2022 16:55:27 +0200 Subject: [PATCH 02/15] ci: update matrices --- .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba0b859..31e5d60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: - name: Install React ${{matrix.react-version}} if: matrix.react-version != '18.x' run: | - npm install --save-dev \ + npm install --force --save-dev \ react@${{matrix.react-version}} \ react-dom@${{matrix.react-version}} - name: Run tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 609658f..bf043ee 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,7 +19,7 @@ jobs: run: npm test - name: Build example run: | - npm run example + npm run --prefix example build mkdir _deploy cp example/bundle.js example/index.html _deploy - name: Publish site From 773a92b0bd580db62f44da29d8ee641c7e1afbb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 1 May 2022 18:20:25 +0200 Subject: [PATCH 03/15] fix event names --- index.d.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/index.d.ts b/index.d.ts index cc9647d..56dcfdf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -155,10 +155,6 @@ export interface VimeoOptions { * Triggered when video playback is initiated. */ onPlay?: (event: EventMap['play']) => void - /** - * Triggered when the video starts playing. - */ - onPlaying?: (event: EventMap['playing']) => void /** * Triggered when the video pauses. */ From e24959ae4caa7d514f4470edd079b072a7bdd00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 1 May 2022 18:28:59 +0200 Subject: [PATCH 04/15] add props for missing events --- README.md | 11 ++++++++ index.d.ts | 46 ++++++++++++++++++++++++++++++++- src/index.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50e332f..7e12b8c 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,24 @@ import Vimeo from '@u-wave/react-vimeo'; | onEnd | function | | Triggered any time the video playback reaches the end. Note: when `loop` is turned on, the ended event will not fire. | | onTimeUpdate | function | | Triggered as the `currentTime` of the video updates. It generally fires every 250ms, but it may vary depending on the browser. | | onProgress | function | | Triggered as the video is loaded. Reports back the amount of the video that has been buffered. | +| onSeeking | function | | Triggered when the player starts seeking to a specific time. An `onTimeUpdate` event will also be fired at the same time. | | onSeeked | function | | Triggered when the player seeks to a specific time. An `onTimeUpdate` event will also be fired at the same time. | | onTextTrackChange | function | | Triggered when the active text track (captions/subtitles) changes. The values will be `null` if text tracks are turned off. | +| onChapterChange | function | | Triggered when the current chapter changes. | | onCueChange | function | | Triggered when the active cue for the current text track changes. It also fires when the active text track changes. There may be multiple cues active. | | onCuePoint | function | | Triggered when the current time hits a registered cue point. | | onVolumeChange | function | | Triggered when the volume in the player changes. Some devices do not support setting the volume of the video independently from the system volume, so this event will never fire on those devices. | | onPlaybackRateChange | function | | Triggered when the playback rate changes. | +| onBufferStart | function | | Triggered when buffering starts in the player. This is also triggered during preload and while seeking. | +| onBufferEnd | function | | Triggered when buffering ends in the player. This is also triggered at the end of preload and seeking. | | onLoaded | function | | Triggered when a new video is loaded in the player. | +| onDurationChange | function | | Triggered when the duration attribute has been updated. | +| onFullscreenChange | function | | Triggered when the player enters or exits fullscreen. | +| onQualityChange | function | | Triggered when the set quality changes. | +| onCameraChange | function | | Triggered when any of the camera properties change for 360° videos. | +| onResize | function | | Triggered when the intrinsic size of the media changes. | +| onEnterPictureInPicture | function | | Triggered when the player enters picture-in-picture. | +| onLeavePictureInPicture | function | | Triggered when the player leaves picture-in-picture. | ## Related diff --git a/index.d.ts b/index.d.ts index 56dcfdf..966d425 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,4 @@ import * as React from 'react' - import Player,{ Error, EventMap, @@ -174,6 +173,11 @@ export interface VimeoOptions { * that has been buffered. */ onProgress?: (event: EventMap['progress']) => void + /** + * Triggered when the player starts seeking to a specific time. An + * `onTimeUpdate` event will also be fired at the same time. + */ + onSeeking?: (event: TimeEvent) => void /** * Triggered when the player seeks to a specific time. An `onTimeUpdate` * event will also be fired at the same time. @@ -184,6 +188,10 @@ export interface VimeoOptions { * values will be `null` if text tracks are turned off. */ onTextTrackChange?: (event: EventMap['texttrackchange']) => void + /** + * Triggered when the current chapter changes. + */ + onChapterChange?: (event: ChapterChangeEvent) => void /** * Triggered when the active cue for the current text track changes. It also * fires when the active text track changes. There may be multiple cues @@ -204,10 +212,46 @@ export interface VimeoOptions { * Triggered when the playback rate in the player changes. */ onPlaybackRateChange?: (event: EventMap['playbackratechange']) => void + /** + * Triggered when buffering starts in the player. This is also triggered during preload and while seeking. + */ + onBufferStart?: () => void + /** + * Triggered when buffering ends in the player. This is also triggered at the end of preload and seeking. + */ + onBufferEnd?: () => void /** * Triggered when a new video is loaded in the player. */ onLoaded?: (event: EventMap['loaded']) => void + /** + * Triggered when the duration attribute has been updated. + */ + onDurationChange?: (event: EventMap['durationchange']) => void + /** + * Triggered when the player enters or exits fullscreen. + */ + onFullscreenChange?: (event: EventMap['fullscreenchange']) => void + /** + * Triggered when the set quality changes. + */ + onQualityChange?: (event: EventMap['qualitychange']) => void + /** + * Triggered when any of the camera properties change for 360° videos. + */ + onCameraChange?: (event: EventMap['camerachange']) => void + /** + * Triggered when the intrinsic size of the media changes. + */ + onResize?: (event: EventMap['resize']) => void + /** + * Triggered when the player enters picture-in-picture. + */ + onEnterPictureInPicture?: () => void + /** + * Triggered when the player leaves picture-in-picture. + */ + onLeavePictureInPicture?: () => void } export interface VimeoProps extends VimeoOptions { diff --git a/src/index.js b/src/index.js index 64a9278..d851c3e 100644 --- a/src/index.js +++ b/src/index.js @@ -102,6 +102,8 @@ function useVimeo(container, { volume, playbackRate, start, + + // Events onReady, onError, onPlay, @@ -109,13 +111,24 @@ function useVimeo(container, { onEnd, onTimeUpdate, onProgress, + onSeeking, onSeeked, onTextTrackChange, + onChapterChange, onCueChange, onCuePoint, onVolumeChange, onPlaybackRateChange, + onBufferStart, + onBufferEnd, onLoaded, + onDurationChange, + onFullscreenChange, + onQualityChange, + onCameraChange, + onResize, + onEnterPictureInPicture, + onLeavePictureInPicture, }) { const isFirstRender = useRef(true); const player = useVimeoPlayer(container, { @@ -177,18 +190,29 @@ function useVimeo(container, { }; }, []); + useEventHandler(player, 'error', onError); useEventHandler(player, 'play', onPlay); useEventHandler(player, 'pause', onPause); useEventHandler(player, 'ended', onEnd); useEventHandler(player, 'timeupdate', onTimeUpdate); useEventHandler(player, 'progress', onProgress); + useEventHandler(player, 'seeking', onSeeking); useEventHandler(player, 'seeked', onSeeked); useEventHandler(player, 'texttrackchange', onTextTrackChange); + useEventHandler(player, 'chapterchange', onChapterChange); useEventHandler(player, 'cuechange', onCueChange); useEventHandler(player, 'cuepoint', onCuePoint); useEventHandler(player, 'volumechange', onVolumeChange); useEventHandler(player, 'playbackratechange', onPlaybackRateChange); - useEventHandler(player, 'error', onError); + useEventHandler(player, 'bufferstart', onBufferStart); + useEventHandler(player, 'bufferend', onBufferEnd); + useEventHandler(player, 'durationchange', onDurationChange); + useEventHandler(player, 'fullscreenchange', onFullscreenChange); + useEventHandler(player, 'qualitychange', onQualityChange); + useEventHandler(player, 'camerachange', onCameraChange); + useEventHandler(player, 'resize', onResize); + useEventHandler(player, 'enterpictureinpicture', onEnterPictureInPicture); + useEventHandler(player, 'leavepictureinpicture', onLeavePictureInPicture); useEventHandler(player, 'loaded', onLoaded); usePlayerEffect(player, () => { @@ -479,6 +503,11 @@ if (process.env.NODE_ENV !== 'production') { * that has been buffered. */ onProgress: PropTypes.func, + /** + * Triggered when the player starts seeking to a specific time. An + * `onTimeUpdate` event will also be fired at the same time. + */ + onSeeking: PropTypes.func, /** * Triggered when the player seeks to a specific time. An `onTimeUpdate` * event will also be fired at the same time. @@ -489,6 +518,10 @@ if (process.env.NODE_ENV !== 'production') { * values will be `null` if text tracks are turned off. */ onTextTrackChange: PropTypes.func, + /** + * Triggered when the current chapter changes. + */ + onChapterChange: PropTypes.func, /** * Triggered when the active cue for the current text track changes. It also * fires when the active text track changes. There may be multiple cues @@ -509,10 +542,48 @@ if (process.env.NODE_ENV !== 'production') { * Triggered when the playback rate changes. */ onPlaybackRateChange: PropTypes.func, + /** + * Triggered when buffering starts in the player. + * This is also triggered during preload and while seeking. + */ + onBufferStart: PropTypes.func, + /** + * Triggered when buffering ends in the player. + * This is also triggered at the end of preload and seeking. + */ + onBufferEnd: PropTypes.func, /** * Triggered when a new video is loaded in the player. */ onLoaded: PropTypes.func, + /** + * Triggered when the duration attribute has been updated. + */ + onDurationChange: PropTypes.func, + /** + * Triggered when the player enters or exits fullscreen. + */ + onFullscreenChange: PropTypes.func, + /** + * Triggered when the set quality changes. + */ + onQualityChange: PropTypes.func, + /** + * Triggered when any of the camera properties change for 360° videos. + */ + onCameraChange: PropTypes.func, + /** + * Triggered when the intrinsic size of the media changes. + */ + onResize: PropTypes.func, + /** + * Triggered when the player enters picture-in-picture. + */ + onEnterPictureInPicture: PropTypes.func, + /** + * Triggered when the player leaves picture-in-picture. + */ + onLeavePictureInPicture: PropTypes.func, /* eslint-enable react/no-unused-prop-types */ }; From 9343f722c45c3c379ad411616f367e6312e029d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Wed, 11 May 2022 15:51:20 +0200 Subject: [PATCH 05/15] fix types --- index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.d.ts b/index.d.ts index 966d425..91550a4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,5 @@ import * as React from 'react' -import Player,{ +import Player, { Error, EventMap, VimeoVideoQuality, @@ -191,7 +191,7 @@ export interface VimeoOptions { /** * Triggered when the current chapter changes. */ - onChapterChange?: (event: ChapterChangeEvent) => void + onChapterChange?: (event: VimeoChapter) => void /** * Triggered when the active cue for the current text track changes. It also * fires when the active text track changes. There may be multiple cues From 1e53b049ec0b3f79e2dc430c8b26a5caea89de49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Mon, 17 Oct 2022 11:36:49 +0200 Subject: [PATCH 06/15] add code coverage --- .browserslistrc | 83 ++++++++++++++++++++++++++++++++----------------- .gitignore | 1 + index.d.ts | 4 +-- package.json | 3 +- 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/.browserslistrc b/.browserslistrc index 8f6c8a6..a9d9a0f 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,30 +1,57 @@ -and_chr 92 -and_chr 91 -and_ff 90 -and_ff 89 -and_qq 10.4 -and_uc 12.12 -android 92 -android 91 -baidu 7.12 -chrome 92 -chrome 91 -chrome 90 -edge 92 -edge 91 -firefox 90 -firefox 89 -firefox 78 -ios_saf 14.5-14.7 -ios_saf 14.0-14.4 -ios_saf 13.4-13.7 +and_chr 122 +and_chr 121 +and_ff 123 +and_ff 122 +and_qq 14.9 +and_uc 15.5 +android 122 +android 121 +chrome 122 +chrome 121 +chrome 120 +chrome 119 +chrome 109 +edge 122 +edge 121 +firefox 123 +firefox 122 +firefox 115 +ios_saf 17.4 +ios_saf 17.3 +ios_saf 17.2 +ios_saf 17.1 +ios_saf 17.0 +ios_saf 16.6-16.7 +ios_saf 16.5 +ios_saf 16.4 +ios_saf 16.3 +ios_saf 16.2 +ios_saf 16.1 +ios_saf 16.0 +ios_saf 15.6-15.8 +ios_saf 15.5 +ios_saf 15.4 +kaios 3.0-3.1 kaios 2.5 op_mini all -op_mob 77 -op_mob 76 -opera 77 -opera 76 -safari 14.1 -safari 14 -samsung 14.0 -samsung 13.0 +op_mob 80 +opera 108 +opera 107 +opera 106 +safari 17.4 +safari 17.3 +safari 17.2 +safari 17.1 +safari 17.0 +safari 16.6 +safari 16.5 +safari 16.4 +safari 16.3 +safari 16.2 +safari 16.1 +safari 16.0 +safari 15.6 +safari 15.5 +safari 15.4 +samsung 23 +samsung 22 diff --git a/.gitignore b/.gitignore index 00e7a7f..50855dd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules /generated-docs.md .eslintcache package-lock.json +coverage diff --git a/index.d.ts b/index.d.ts index 91550a4..bb3e883 100644 --- a/index.d.ts +++ b/index.d.ts @@ -177,7 +177,7 @@ export interface VimeoOptions { * Triggered when the player starts seeking to a specific time. An * `onTimeUpdate` event will also be fired at the same time. */ - onSeeking?: (event: TimeEvent) => void + onSeeking?: (event: EventMap['seeking']) => void /** * Triggered when the player seeks to a specific time. An `onTimeUpdate` * event will also be fired at the same time. @@ -191,7 +191,7 @@ export interface VimeoOptions { /** * Triggered when the current chapter changes. */ - onChapterChange?: (event: VimeoChapter) => void + onChapterChange?: (event: EventMap['chapterchange']) => void /** * Triggered when the active cue for the current text track changes. It also * fires when the active text track changes. There may be multiple cues diff --git a/package.json b/package.json index db2acaa..d047a5d 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "build": "rollup -c", "lint": "eslint --cache .", "test": "npm run lint && npm run tests-only && npm run tsd", - "tests-only": "cross-env BABEL_ENV=test mocha --require @babel/register test/*.js", + "tests-only": "cross-env BABEL_ENV=test c8 mocha --require @babel/register test/*.js", "tsd": "tsd", "docs": "prop-types-table src/index.js | md-insert README.md --header Props -i", "example": "npm run --prefix example build" @@ -55,6 +55,7 @@ "@rollup/plugin-babel": "^6.0.0", "@u-wave/react-vimeo-example": "file:example", "babel-plugin-dynamic-import-node": "^2.3.3", + "c8": "^7.12.0", "cross-env": "^7.0.3", "eslint": "^8.2.0", "eslint-config-airbnb": "^19.0.0", From 895fe1f7868427e37c98cef1673c6ea75bbb6036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sat, 12 Nov 2022 15:45:05 +0100 Subject: [PATCH 07/15] fix tests --- src/index.js | 4 ++++ test/util/render.js | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index d851c3e..cc90331 100644 --- a/src/index.js +++ b/src/index.js @@ -90,6 +90,7 @@ function useVimeo(container, { muted, background, responsive, + playbackRate, dnt, speed, keyboard, @@ -221,6 +222,9 @@ function useVimeo(container, { usePlayerEffect(player, () => { if (color) player.setColor(color); }, [color]); + usePlayerEffect(player, () => { + player.setPlaybackRate(playbackRate); + }, [playbackRate]); usePlayerEffect(player, () => { player.setLoop(loop); }, [loop]); diff --git a/test/util/render.js b/test/util/render.js index fe8c4d4..637c255 100644 --- a/test/util/render.js +++ b/test/util/render.js @@ -68,7 +68,9 @@ async function render(initialProps) { } const container = await new Promise((resolve) => { - root.render(); + (act || noAct)(() => { + root.render(); + }); }); await readyPromise; From 6274d5635f973403373669406298344addbeca72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 12:11:27 +0100 Subject: [PATCH 08/15] deps: supporting react 16 and up --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index d047a5d..ae9d01a 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ }, "homepage": "https://github.com/u-wave/react-vimeo#readme", "dependencies": { - "@types/react": "^17.0.0 || ^18.0.0", + "@types/react": ">= 16.0.0", "@types/vimeo__player": "^2.10.0", "@vimeo/player": "^2.16.4", "prop-types": "^15.7.2" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": ">= 16.0.0" }, "devDependencies": { "@babel/core": "^7.12.10", From c1f85af2e6d8b542c127d4674cead43ce3583a76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 12:12:02 +0100 Subject: [PATCH 09/15] may need to act() this --- test/util/render.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/util/render.js b/test/util/render.js index 637c255..5609b48 100644 --- a/test/util/render.js +++ b/test/util/render.js @@ -85,7 +85,9 @@ async function render(initialProps) { playerMock, rerender, unmount() { - root.unmount(); + act(() => { + root.unmount(); + }); }, }; } From 6c0dbcfc3dad96da6a41ce7144ac725f365a48bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 12:13:42 +0100 Subject: [PATCH 10/15] ci: update dependabot config --- .github/dependabot.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f43925b..02ee901 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,16 @@ version: 2 updates: -- package-ecosystem: npm +- package-ecosystem: github-actions directory: "/" schedule: - interval: weekly + interval: daily open-pull-requests-limit: 10 -- package-ecosystem: github-actions + commit-message: + prefix: "ci" +- package-ecosystem: npm directory: "/" schedule: interval: weekly + open-pull-requests-limit: 10 + commit-message: + prefix: "deps" From c86966a009206d404c5319e41ee60f26a590bfaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 12:32:51 +0100 Subject: [PATCH 11/15] update docs --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7e12b8c..5c438cf 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Vimeo player component for React. +Supported React versions: the latest releases of 16.x, 17.x, and 18.x. + [Install][] - [Usage][] - [Demo][] - [Props][] ## Install @@ -47,7 +49,7 @@ import Vimeo from '@u-wave/react-vimeo'; | muted | bool | false | Starts in a muted state to help with autoplay | | background | bool | false | Starts in a background state with no controls to help with autoplay | | responsive | bool | false | Enable responsive mode and resize according to parent element (experimental) | -| playbackRate | number | | Specify playback rate (requires Vimeo PRO / Business account) +| playbackRate | number | | Specify playback rate (requires Vimeo PRO / Business account) | | speed | bool | false | Enable playback rate controls (requires Vimeo PRO / Business account) | | keyboard | bool | true | Allows for keyboard input to trigger player events. | | pip | bool | false | Show the picture-in-picture button in the controlbar and enable the picture-in-picture API. | From bc776cfc32d5403ebfcb5fe89ed41c3954037e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 13:08:29 +0100 Subject: [PATCH 12/15] handle video= prop in the same way everywhere --- src/index.js | 20 +++++++++++++++++--- test/test.js | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index cc90331..b20db73 100644 --- a/src/index.js +++ b/src/index.js @@ -71,6 +71,19 @@ function useEventHandler(player, event, handler) { }, [event, handler]); } +/** + * @param {string|number|null} video + */ +function getVideoProps(video) { + if (video == null) { + return undefined; + } + + return typeof video === 'number' || /^\d+$/.test(video) + ? { id: Number(video) } + : { url: video }; +} + /** * @param {React.RefObject} container * @param {import('../index').VimeoOptions} options @@ -133,7 +146,7 @@ function useVimeo(container, { }) { const isFirstRender = useRef(true); const player = useVimeoPlayer(container, { - [typeof video === 'string' ? 'url' : 'id']: video, + ...getVideoProps(video), // The Vimeo player officially only supports integer width/height. // If a "100%" string was provided we apply it afterwards in an effect. width: typeof width === 'number' ? width : undefined, @@ -261,8 +274,9 @@ function useVimeo(container, { } let cancelled = false; - if (video) { - const loaded = player.loadVideo(video); + const videoProps = getVideoProps(video); + if (videoProps) { + const loaded = player.loadVideo(videoProps); // Set the start time only when loading a new video. // It seems like this has to be done after the video has loaded, else it just starts at // the beginning! diff --git a/test/test.js b/test/test.js index ac3291d..9ce4537 100644 --- a/test/test.js +++ b/test/test.js @@ -44,7 +44,7 @@ describe('Vimeo', () => { await rerender({ video: 162959050 }); expect(playerMock.loadVideo).toHaveBeenCalled(); - expect(playerMock.loadVideo.calls[0].arguments[0]).toEqual(162959050); + expect(playerMock.loadVideo.calls[0].arguments[0]).toMatch({ id: 162959050 }); }); it('should pause the video using the "paused" prop', async () => { From 85a2673fd778d0a08e16f2c03ea13e1bf3f10cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9e=20Kooi?= Date: Sun, 13 Nov 2022 13:19:08 +0100 Subject: [PATCH 13/15] example: fix logo url --- example/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/index.html b/example/index.html index 0d192b9..8784144 100644 --- a/example/index.html +++ b/example/index.html @@ -20,7 +20,7 @@