diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index 65e538e..cba8281 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -10,6 +10,7 @@ jobs: matrix: dir: - packages/next + - packages/react - packages/sanity-next steps: diff --git a/README.md b/README.md index 33e4421..254459d 100644 --- a/README.md +++ b/README.md @@ -45,5 +45,7 @@ export default function Example({ background }) { This project uses Lerna to release versions, using [the default versioning strategy](https://lerna.js.org/docs/features/version-and-publish#versioning-strategies). +- `yarn lerna run test` - Run all tests - `yarn lerna version` - Tag a new version -- `yarn lerna publish from-package` - Publish versions to NPM +- `yarn lerna publish [major|minor|path]` - Tag and publish a version +- `yarn lerna publish from-package` - Publish the current version diff --git a/packages/next/cypress/support/commands.ts b/packages/next/cypress/support/commands.ts index 6307497..012519e 100644 --- a/packages/next/cypress/support/commands.ts +++ b/packages/next/cypress/support/commands.ts @@ -15,7 +15,7 @@ Cypress.Commands.add('hasDimensions', Cypress.Commands.add('isPlaying', { prevSubject: true }, (subject) => { - cy.wrap(subject).should('have.prop', 'paused', true) + cy.wrap(subject).should('have.prop', 'paused', false) }) // Add Typescript support for custom commaands diff --git a/packages/next/src/NextVisual.tsx b/packages/next/src/NextVisual.tsx index 8432227..198f1bf 100644 --- a/packages/next/src/NextVisual.tsx +++ b/packages/next/src/NextVisual.tsx @@ -29,6 +29,7 @@ export default function NextVisual( priority, sizes, imageLoader, + playing, alt, className = '', style = {}, @@ -67,7 +68,8 @@ export default function NextVisual( fit, position, priority, - noPoster: !!image // Use `image` as poster frame + noPoster: !!image, // Use `image` as poster frame + playing, }} /> } diff --git a/packages/next/src/types/nextVisualTypes.ts b/packages/next/src/types/nextVisualTypes.ts index 8a23dfa..bd3bf06 100644 --- a/packages/next/src/types/nextVisualTypes.ts +++ b/packages/next/src/types/nextVisualTypes.ts @@ -17,6 +17,8 @@ export type NextVisualProps = { sizes?: string imageLoader?: Function + playing?: boolean + alt: string className?: string diff --git a/packages/react/cypress.config.ts b/packages/react/cypress.config.ts new file mode 100644 index 0000000..27593d4 --- /dev/null +++ b/packages/react/cypress.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + viewportWidth: 500, + viewportHeight: 500, + component: { + specPattern: "cypress/component/**/*.cy.{js,jsx,ts,tsx}", + devServer: { + framework: "next", + bundler: "webpack", + }, + }, +}); diff --git a/packages/react/cypress/component/LazyVideo.cy.tsx b/packages/react/cypress/component/LazyVideo.cy.tsx new file mode 100644 index 0000000..5313caa --- /dev/null +++ b/packages/react/cypress/component/LazyVideo.cy.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react' +import { LazyVideo } from '../../src' + +// Make an instance of LazyVideo that can be controlled +const Player = function({ autoplay }: any) { + + const [playing, setPlaying] = useState(autoplay) + + return (<> + +
+ + +
+ ) +} + + +describe('playback', () => { + + it('can be paused and then restarted', () => { + cy.mount() + cy.get('video').isPlaying() + cy.get('button').contains('Pause').click() + cy.get('video').isPaused() + cy.get('button').contains('Play').click() + cy.get('video').isPlaying() + }) + + it('can be started and then paused', () => { + cy.mount() + cy.get('video').isPaused() + cy.get('button').contains('Play').click() + cy.get('video').isPlaying() + cy.get('button').contains('Pause').click() + cy.get('video').isPaused() + }) + +}) diff --git a/packages/react/cypress/support/commands.ts b/packages/react/cypress/support/commands.ts new file mode 100644 index 0000000..062cc2c --- /dev/null +++ b/packages/react/cypress/support/commands.ts @@ -0,0 +1,37 @@ +/// + +// Check that a video is playing +// https://glebbahmutov.com/blog/test-video-play/ +Cypress.Commands.add('isPlaying', + { prevSubject: true }, + (subject) => { + cy.wrap(subject).should('have.prop', 'paused', false) +}) + +// Check that a video is playing +// https://glebbahmutov.com/blog/test-video-play/ +Cypress.Commands.add('isPaused', + { prevSubject: true }, + (subject) => { + cy.wrap(subject).should('have.prop', 'paused', true) +}) + +// Add Typescript support for custom commaands +// https://docs.cypress.io/guides/tooling/typescript-support#Types-for-Custom-Commands +export {}; +declare global { + namespace Cypress { + interface Chainable { + + hasDimensions( + width: number, + height: number + ): Chainable + + isPlaying(): Chainable + isPaused(): Chainable + } + } +} + + diff --git a/packages/react/cypress/support/component-index.html b/packages/react/cypress/support/component-index.html new file mode 100644 index 0000000..e9a4010 --- /dev/null +++ b/packages/react/cypress/support/component-index.html @@ -0,0 +1,20 @@ + + + + + + + Components App + +
+ + + +
+ + diff --git a/packages/react/cypress/support/component.ts b/packages/react/cypress/support/component.ts new file mode 100644 index 0000000..41b935d --- /dev/null +++ b/packages/react/cypress/support/component.ts @@ -0,0 +1,21 @@ +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') + +import { mount } from 'cypress/react18' + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount + } + } +} + +Cypress.Commands.add('mount', mount) diff --git a/packages/react/cypress/tsconfig.json b/packages/react/cypress/tsconfig.json new file mode 100644 index 0000000..537c251 --- /dev/null +++ b/packages/react/cypress/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es5", + "jsx": "preserve", + "lib": [ + "es5", + "dom" + ], + "types": [ + "cypress", + "node" + ] + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ] +} diff --git a/packages/react/package.json b/packages/react/package.json index cf60e04..6c426b7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -10,7 +10,8 @@ "dist/**/*" ], "scripts": { - "build": "rm -rf dist/* && tsc" + "build": "rm -rf dist/* && tsc", + "test": "cypress run --component" }, "dependencies": { "react-intersection-observer": "^9" diff --git a/packages/react/src/LazyVideo.tsx b/packages/react/src/LazyVideo.tsx index 12e544f..8cbcbaf 100644 --- a/packages/react/src/LazyVideo.tsx +++ b/packages/react/src/LazyVideo.tsx @@ -2,20 +2,54 @@ "use client"; import { useInView } from 'react-intersection-observer' -import type { ReactElement } from 'react' +import { useEffect, type ReactElement, useRef, useCallback } from 'react' +import type { LazyVideoProps } from './types/lazyVideoTypes'; import { fillStyles, transparentGif } from './lib/styles' // An video rendered within a Visual that supports lazy loading export default function LazyVideo({ - src, alt, fit, position, priority, noPoster -}: any): ReactElement { + src, alt, fit, position, priority, noPoster, playing = true +}: LazyVideoProps): ReactElement { + + // Make a ref to the video so it can be controlled + const videoRef = useRef() // Watch for in viewport to load video unless using priority - const { ref, inView } = useInView({ + const { ref: inViewRef, inView } = useInView({ skip: priority }) + // Support multiple refs on the video. This is from the + // react-intersection-observer docs + const setRefs = useCallback((node: HTMLVideoElement) => { + videoRef.current = node + inViewRef(node) + }, [inViewRef]) + + // Store the promise that is returned from play to prevent errors when + // pause() is called while video is benginning to play. + const playPromise = useRef>() + + // Play the video, waiting until it's safe to play it. And capture any + // errors while trying to play. + const play = async () => { + if (playPromise.current) await playPromise.current + try { playPromise.current = videoRef.current?.play()} + catch (e) { console.error(e) } + } + + // Pause the video, waiting until it's safe to play it + const pause = async () => { + if (playPromise.current) await playPromise.current + videoRef.current?.pause() + } + + // Respond to playing prop and call methods that control the video playback + useEffect(() => { + playing ? play() : pause() + }, [ playing ]) + // Simplify logic for whether to load sources const shouldLoad = priority || inView @@ -24,17 +58,19 @@ export default function LazyVideo({ // Props that allow us to autoplay videos like a gif playsInline - autoPlay muted loop + // Whether to autoplay + autoPlay={ playing } + // Load a transparent gif as a poster if an `image` was specified so // the image is used as poster rather than the first frame of video. This // lets us all use responsive poster images (via `next/image`). poster={ noPoster ? transparentGif : undefined } // Straightforward props - ref={ref} + ref={setRefs} preload={ shouldLoad ? 'auto' : 'none' } aria-label={ alt } style={{ diff --git a/packages/react/src/types/lazyVideoTypes.ts b/packages/react/src/types/lazyVideoTypes.ts new file mode 100644 index 0000000..aff6d15 --- /dev/null +++ b/packages/react/src/types/lazyVideoTypes.ts @@ -0,0 +1,21 @@ +import type { CSSProperties } from 'react' + +export type LazyVideoProps = { + + // Source props + src: HTMLVideoElement['src'] + alt: string + + // Don't lazy load + priority?: boolean + + // Use a transparent gif poster image + noPoster?: boolean + + // Controls autoplaying and current playing state + playing?: boolean + + // Display props + fit?: CSSProperties['objectFit'] + position?: CSSProperties['objectPosition'] +} diff --git a/packages/sanity-next/cypress/support/commands.ts b/packages/sanity-next/cypress/support/commands.ts index 6307497..012519e 100644 --- a/packages/sanity-next/cypress/support/commands.ts +++ b/packages/sanity-next/cypress/support/commands.ts @@ -15,7 +15,7 @@ Cypress.Commands.add('hasDimensions', Cypress.Commands.add('isPlaying', { prevSubject: true }, (subject) => { - cy.wrap(subject).should('have.prop', 'paused', true) + cy.wrap(subject).should('have.prop', 'paused', false) }) // Add Typescript support for custom commaands