diff --git a/docs/README.md b/docs/README.md index 45c28fd..f9b59c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,9 +18,11 @@ A React component that makes it simple to set up a video that will play when the - Out-of-the-box support for both mouse and touchscreen interactions - Easily add custom thumbnails and loading states -- Clean, error-free handling of async video playback - Lightweight and fast - No dependencies +- Cleanly handles edge cases that can arise from managing async video playback, including: + - Avoids play promise interruption errors whenever possible + - Gracefully uses fallback behavior if browser policies block a video from playing with sound on ## How It Works @@ -437,6 +439,13 @@ const [isVideoPlaying, setIsVideoPlaying] = useState(false); `muted` accepts a boolean value which toggles whether or not the video should be muted. +Note that if the video is unmuted, you may encounter issues with [browser autoplay policies](https://developer.chrome.com/blog/autoplay/) blocking the video +from playing with sound. This is an unfortunate limitation stemming from the fact that modern browsers will block playing +audio until the user has "interacted" with the page by doing something like clicking or tapping anywhere at least once. + +If playback is initially blocked for an unmuted video, the component will fall back by muting the video and attempting to play again without audio; +if the user clicks on the page, the video will be unmuted again and continue playing. + ```jsx { mutableVideoState.current.isPlayAttemptInProgress = true; - videoRef.current.play(); + const videoElement = videoRef.current; + + videoElement.play().catch((error) => { + // Additional handling for when browsers block playback for unmuted videos. + // This is unfortunately necessary because most modern browsers do not allow playing videos with audio + // until the user has "interacted" with the page by clicking somewhere at least once; mouseenter events + // don't count. + + // If the video isn't muted and playback failed with a `NotAllowedError`, this means the browser blocked + // playing the video because the user hasn't clicked anywhere on the page yet. + if (!videoElement.muted && error.name === 'NotAllowedError') { + console.warn( + 'HoverVideoPlayer: Playback with sound was blocked by the browser. Attempting to play again with the video muted; audio will be restored if the user clicks on the page.' + ); + // Mute the video and attempt to play again + videoElement.muted = true; + videoElement.play(); + + // When the user clicks on the document, unmute the video since we should now + // be free to play audio + const onClickDocument = () => { + videoElement.muted = false; + + // Clean up the event listener so it is only fired once + document.removeEventListener('click', onClickDocument); + }; + document.addEventListener('click', onClickDocument); + } + }); }, [videoRef]); // Method attempts to pause the video, if it is safe to do so without interrupting a pending play promise @@ -200,8 +228,8 @@ export default function useManageVideoPlayback( videoRef, ]); - // Effect adds starts an update loop if a playback range is set to ensure - // the video stays within the bounds of its playback range + // Effect starts an update loop while the video is playing + // to ensure the video stays within the bounds of its playback range useEffect(() => { if ( // If we don't have a playback range set, we don't need to do anything here @@ -231,7 +259,7 @@ export default function useManageVideoPlayback( // If the video is paused, start playing it again (when the video reaches the end // of the playback range for the first time, most browsers will pause it) - if (shouldPlayVideo && videoElement.paused) { + if (shouldPlayVideo && (videoElement.paused || videoElement.ended)) { attemptToPlayVideo(); } } else { @@ -248,10 +276,14 @@ export default function useManageVideoPlayback( videoElement.currentTime = playbackRangeMinTime; } - animationFrameId = requestAnimationFrame(checkPlaybackRangeTime); + // If the video is playing, keep the update loop going for the next frame + if (shouldPlayVideo) { + animationFrameId = requestAnimationFrame(checkPlaybackRangeTime); + } }; - // Start the animation frame loop + // Run our update loop at least once; if the video is playing, + // it will continue running every frame until the video is paused again animationFrameId = requestAnimationFrame(checkPlaybackRangeTime); return () => { diff --git a/tests/cypress/component/audioPlayback.spec.tsx b/tests/cypress/component/audioPlayback.spec.tsx new file mode 100644 index 0000000..8ab25a8 --- /dev/null +++ b/tests/cypress/component/audioPlayback.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { mount } from '@cypress/react'; +import HoverVideoPlayer from 'react-hover-video-player'; + +import { makeMockVideoSrc } from '../utils'; +import { videoElementSelector } from '../constants'; + +// BEWARE: running these tests in the interactive test runner can be perilous since +// any clicks around the UI can disrupt the assumptions these tests are making about +// the browser's autoplay policy +describe('attempting to play the video with audio should work as expected', () => { + it('if the user has not interacted with the page, the video will initially start playing without sound until the user clicks on the page', () => { + mount(); + + cy.triggerEventOnPlayer('mouseenter'); + + // The video should start playing, but it will be muted + cy.get(videoElementSelector).invoke('prop', 'muted').should('be.true'); + cy.checkVideoPlaybackState('playing'); + cy.get(videoElementSelector).invoke('prop', 'muted').should('be.true'); + + // Click on the body to "interact" with the page for the first time + cy.get('body').click(); + + // The video should no longer be muted + cy.get(videoElementSelector).invoke('prop', 'muted').should('be.false'); + }); +}); diff --git a/tests/cypress/component/playbackRangeEnd.spec.tsx b/tests/cypress/component/playbackRangeEnd.spec.tsx index da0ca8f..ef4e684 100644 --- a/tests/cypress/component/playbackRangeEnd.spec.tsx +++ b/tests/cypress/component/playbackRangeEnd.spec.tsx @@ -181,6 +181,13 @@ describe('Playback works as expected when only playbackRangeEnd is set', () => { ); cy.get(videoElementSelector).invoke('prop', 'currentTime', 5); + cy.get(videoElementSelector) + .invoke('prop', 'currentTime') + .should('equal', 5); + + // Make the video start playing + cy.triggerEventOnPlayer('mouseenter'); + cy.log( 'The video should have been set back to the end of the playback range' ); diff --git a/tests/cypress/plugins/index.ts b/tests/cypress/plugins/index.ts index b774f6a..90b7476 100644 --- a/tests/cypress/plugins/index.ts +++ b/tests/cypress/plugins/index.ts @@ -52,5 +52,24 @@ export default ( injectCodeCoverage(on, config); + // Override the args passed to the browser to ensure we + // can test against its autoplay policy + on('before:browser:launch', (browser, launchOptions) => { + launchOptions.args = launchOptions.args.filter( + (arg) => !arg.startsWith('--autoplay-policy') + ); + + const autoplayPolicyOptionIndex = launchOptions.args.indexOf( + '--autoplay-policy=no-user-gesture-required' + ); + + if (autoplayPolicyOptionIndex >= 0) { + launchOptions.args[autoplayPolicyOptionIndex] = + '--disable-features=PreloadMediaEngagementData, MediaEngagementBypassAutoplayPolicies'; + } + + return launchOptions; + }); + return config; // IMPORTANT to return a config };