diff --git a/src/plugins/index.js b/src/plugins/index.js index d254b833..a7e1c9b0 100644 --- a/src/plugins/index.js +++ b/src/plugins/index.js @@ -7,6 +7,7 @@ import cloudinary from './cloudinary'; import cloudinaryAnalytics from './cloudinary-analytics'; import contextMenu from './context-menu'; import floatingPlayer from './floating-player'; +import mobileTouchControls from './mobile-touch-controls'; import sourceSwitcher from './source-switcher'; import styledTextTracks from './styled-text-tracks'; import vttThumbnails from './vtt-thumbnails'; @@ -31,6 +32,7 @@ const plugins = { cloudinaryAnalytics, contextMenu, floatingPlayer, + mobileTouchControls, sourceSwitcher, styledTextTracks, vttThumbnails, diff --git a/src/plugins/mobile-touch-controls/index.js b/src/plugins/mobile-touch-controls/index.js new file mode 100644 index 00000000..7b9891f1 --- /dev/null +++ b/src/plugins/mobile-touch-controls/index.js @@ -0,0 +1,123 @@ +import videojs from 'video.js'; +import './mobile-touch-controls.scss'; + +const mobileTouchControls = (options, player) => { + if (!(videojs.browser.IS_IOS || videojs.browser.IS_ANDROID || + /Mobi|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent))) { + return {}; + } + + let isInteractionActive = false; + let inactivityHandler = null; + let pauseHandler = null; + let pauseButton = null; + let originalHandleClick = null; + + const removePauseHandler = () => { + if (!pauseHandler) return; + pauseButton?.removeEventListener('click', pauseHandler, { capture: true }); + pauseButton?.removeEventListener('touchend', pauseHandler, { capture: true }); + + const component = player.getChild('bigPlayButton'); + if (component && originalHandleClick) component.handleClick = originalHandleClick; + + pauseHandler = pauseButton = originalHandleClick = null; + }; + + const updateTouchState = () => { + if (!player.paused()) { + player.addClass('cld-mobile-touch-playing'); + removePauseHandler(); + + const btn = player.el().querySelector('.vjs-big-play-button'); + if (!btn) return; + + pauseHandler = (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + if (!player.paused()) { + player.pause(); + setTimeout(() => { + updateTouchState(); + if (player.hasClass('cld-mobile-touch-active')) { + player.setTimeout(() => player.removeClass('cld-mobile-touch-active'), 2000); + } + }, 50); + } + return false; + }; + + btn.addEventListener('click', pauseHandler, { capture: true }); + btn.addEventListener('touchend', pauseHandler, { capture: true }); + + const component = player.getChild('bigPlayButton'); + if (component) { + originalHandleClick = component.handleClick; + component.handleClick = () => pauseHandler({ preventDefault: () => {}, stopImmediatePropagation: () => {} }); + } + pauseButton = btn; + } else { + player.removeClass('cld-mobile-touch-playing'); + removePauseHandler(); + } + }; + + const setupInactivityHandler = () => { + if (inactivityHandler) player.off('userinactive', inactivityHandler); + + inactivityHandler = () => { + player.removeClass('cld-mobile-touch-active'); + setTimeout(() => { + if (!player.hasClass('cld-mobile-touch-active')) { + player.removeClass('cld-mobile-touch-playing'); + removePauseHandler(); + isInteractionActive = false; + } + if (inactivityHandler) { + player.off('userinactive', inactivityHandler); + inactivityHandler = null; + } + }, 250); + }; + + player.one('userinactive', inactivityHandler); + }; + + player.ready(() => { + const el = player.el(); + if (!el) return; + + const onTouch = (e) => { + if (e.target.closest('.vjs-control-bar, .vjs-menu') || + e.target.matches('.vjs-control, .vjs-button') || + !player.hasStarted()) return; + + player.addClass('cld-mobile-touch-active'); + if (!isInteractionActive) { + isInteractionActive = true; + updateTouchState(); + } + setupInactivityHandler(); + }; + + const onPlayPause = () => isInteractionActive && updateTouchState(); + + el.addEventListener('touchend', onTouch, { passive: true }); + player.on('play', onPlayPause); + player.on('pause', onPlayPause); + + player.on('dispose', () => { + el.removeEventListener('touchend', onTouch); + removePauseHandler(); + if (inactivityHandler) player.off('userinactive', inactivityHandler); + player.off('play', onPlayPause); + player.off('pause', onPlayPause); + }); + }); + + return {}; +}; + +export default function(opts = {}) { + return mobileTouchControls(opts, this); +} \ No newline at end of file diff --git a/src/plugins/mobile-touch-controls/mobile-touch-controls.scss b/src/plugins/mobile-touch-controls/mobile-touch-controls.scss new file mode 100644 index 00000000..2c3d2053 --- /dev/null +++ b/src/plugins/mobile-touch-controls/mobile-touch-controls.scss @@ -0,0 +1,22 @@ +.video-js { + &.cld-mobile-touch-active .vjs-big-play-button { + display: block; + opacity: 1; + visibility: visible; + } + + &.cld-mobile-touch-playing .vjs-big-play-button .vjs-icon-placeholder:before { + content: ''; + position: absolute; + border: none; + margin: 0; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) translate(-5.5px, 0); + width: 4px; + height: 22px; + background: currentColor; + border-radius: 2px; + box-shadow: 7px 0 0 0 currentColor; + } +} \ No newline at end of file diff --git a/src/video-player.js b/src/video-player.js index 86ad21ea..92281b62 100644 --- a/src/video-player.js +++ b/src/video-player.js @@ -93,6 +93,7 @@ class VideoPlayer extends Utils.mixin(Eventable) { this._initPlugins(); this._initJumpButtons(); this._initPictureInPicture(); + this._initMobileTouchControls(); this._setVideoJsListeners(ready); } @@ -406,6 +407,10 @@ class VideoPlayer extends Utils.mixin(Eventable) { } } + _initMobileTouchControls() { + this.videojs?.mobileTouchControls?.(); + } + _initCloudinary() { const cloudinaryConfig = this.playerOptions.cloudinary; cloudinaryConfig.chainTarget = this;