Skip to content

Commit

Permalink
Merge pull request #65 from Gyanreyer/fix/fix-playback-with-audio-bei…
Browse files Browse the repository at this point in the history
…ng-blocked

Fix playback for unmuted videos getting blocked by browser autoplay policies
  • Loading branch information
Gyanreyer committed Oct 6, 2021
2 parents 616404a + 3c10998 commit 3cf9fa8
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 8 deletions.
11 changes: 10 additions & 1 deletion docs/README.md
Expand Up @@ -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

Expand Down Expand Up @@ -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
<HoverVideoPlayer
videoSrc="video.mp4"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"build": "rollup -c rollup/prod.config.ts",
"dev": "rollup -c rollup/dev.config.ts -w",
"test": "nyc --check-coverage --reporter=lcov --reporter=text cypress run-ct --config-file=tests/cypress/cypress.json",
"test": "nyc --check-coverage --reporter=lcov --reporter=text cypress run-ct --config-file=tests/cypress/cypress.json --browser=chrome",
"test:smoke": "npm run build && BABEL_ENV=production npm test -- --browser=chrome",
"test-runner": "cypress open-ct --config-file=tests/cypress/cypress.json",
"docs:dev": "vuepress dev docs",
Expand Down
44 changes: 38 additions & 6 deletions src/hooks/useManageVideoPlayback.ts
Expand Up @@ -101,7 +101,35 @@ export default function useManageVideoPlayback(
const attemptToPlayVideo = useCallback(() => {
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 () => {
Expand Down
28 changes: 28 additions & 0 deletions 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(<HoverVideoPlayer videoSrc={makeMockVideoSrc()} muted={false} />);

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');
});
});
7 changes: 7 additions & 0 deletions tests/cypress/component/playbackRangeEnd.spec.tsx
Expand Up @@ -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'
);
Expand Down
19 changes: 19 additions & 0 deletions tests/cypress/plugins/index.ts
Expand Up @@ -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
};

0 comments on commit 3cf9fa8

Please sign in to comment.