diff --git a/src/components/MediaPlayer/MediaPlayer.js b/src/components/MediaPlayer/MediaPlayer.js index 9350f9d0..5d3f0571 100644 --- a/src/components/MediaPlayer/MediaPlayer.js +++ b/src/components/MediaPlayer/MediaPlayer.js @@ -13,6 +13,7 @@ import { } from '../../context/player-context'; import { useErrorBoundary } from "react-error-boundary"; import './MediaPlayer.scss'; +import { IS_MOBILE, IS_IPAD } from '@Services/browser'; const MediaPlayer = ({ enableFileDownload = false, enablePIP = false }) => { const manifestState = useManifestState(); @@ -281,6 +282,10 @@ const MediaPlayer = ({ enableFileDownload = false, enablePIP = false }) => { aspectRatio: isVideo ? '16:9' : '1:0', autoplay: false, bigPlayButton: isVideo, + // Setting inactivity timeout to zero in mobile and tablet devices translates to + // user is always active. And the control bar is not hidden when user is active. + // With this user can always use the controls when the media is playing. + inactivityTimeout: (IS_MOBILE || IS_IPAD) ? 0 : 2000, poster: isVideo ? getPlaceholderCanvas(manifest, canvasIndex, true) : null, controls: true, fluid: true, diff --git a/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js b/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js index e2878516..9239bf29 100644 --- a/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js +++ b/src/components/MediaPlayer/VideoJS/VideoJSPlayer.js @@ -20,6 +20,7 @@ import { getCanvasIndex, } from '@Services/iiif-parser'; import { checkSrcRange, getMediaFragment, playerHotKeys } from '@Services/utility-helpers'; +import { IS_IPAD, IS_MOBILE } from '@Services/browser'; /** VideoJS custom components */ import VideoJSProgress from './components/js/VideoJSProgress'; @@ -175,6 +176,11 @@ function VideoJSPlayer({ if (!isVideo) { player.getChild('controlBar').getChild('VolumePanel').addClass('vjs-slider-active'); } + // Add this class in mobile/tablet devices to always show the control bar, + // since the inactivityTimeout is flaky in some browsers + if (IS_MOBILE || IS_IPAD) { + player.controlBar.addClass('vjs-mobile-visible'); + } }); player.on('ended', () => { playerDispatch({ isEnded: true, type: 'setIsEnded' }); diff --git a/src/components/MediaPlayer/VideoJS/components/js/VideoJSCurrentTime.js b/src/components/MediaPlayer/VideoJS/components/js/VideoJSCurrentTime.js index 2e70e089..e615acd8 100644 --- a/src/components/MediaPlayer/VideoJS/components/js/VideoJSCurrentTime.js +++ b/src/components/MediaPlayer/VideoJS/components/js/VideoJSCurrentTime.js @@ -83,7 +83,7 @@ function CurrentTimeDisplay({ player, options }) { * because Safari stops firing the timeupdate event consistently while it works * with other browsers. */ - player.on('play', () => { + player.on('loadedmetadata', () => { playerEventListener = setInterval(() => { handleTimeUpdate(); }, 100); diff --git a/src/components/MediaPlayer/VideoJS/components/js/VideoJSProgress.js b/src/components/MediaPlayer/VideoJS/components/js/VideoJSProgress.js index 8e9f7daa..47f158d6 100644 --- a/src/components/MediaPlayer/VideoJS/components/js/VideoJSProgress.js +++ b/src/components/MediaPlayer/VideoJS/components/js/VideoJSProgress.js @@ -3,6 +3,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import videojs from 'video.js'; import '../styles/VideoJSProgress.scss'; +import { IS_MOBILE, IS_IPAD } from '@Services/browser'; const vjsComponent = videojs.getComponent('Component'); @@ -34,7 +35,7 @@ class VideoJSProgress extends vjsComponent { this.state = { startTime: null, endTime: null }; this.times = options.targets[options.srcIndex]; - player.on('loadedmetadata', () => { + player.ready(() => { this.mount(); this.setTimes(); this.initProgressBar(); @@ -229,6 +230,15 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options setProgress(curTime); setCurrentTime(curTime + targets[srcIndex].altStart); + /** + * Using a time interval instead of 'timeupdate event in VideoJS, because Safari + * and other browsers in MacOS stops firing the 'timeupdate' event consistently + * after a while + */ + playerEventListener = setInterval(() => { + timeUpdateHandler(); + }, 100); + // Get the pixel ratio for the range const ratio = sliderRangeRef.current.offsetWidth / (end - start); @@ -244,6 +254,10 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options if (sliderIndex < srcIndex) leftWidth += slider.offsetWidth; } + // Hide the timetooltip on mobile/tablet devices + if (IS_IPAD || IS_MOBILE) { + timeToolRef.current.style.display = 'none'; + } timeToolRef.current.style.left = leftWidth - timeToolRef.current.offsetWidth / 2 + 'px'; }); @@ -276,28 +290,6 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options } }); - /** - * Using play event with a time interval instead of timeupdate event in VideoJS, - * because Safari stops firing the timeupdate event consistently while it works - * with other browsers. - */ - player.on('play', () => { - // Start interval to listen to timeupdate with playback - playerEventListener = setInterval(() => { - timeUpdateHandler(); - }, 100); - }); - - /** - * Update progress bar when using structured navigation and transcripts component - * to navigate to a certain timestamp when the media is paused - */ - player.on('timeupdate', () => { - if (player.paused()) { - timeUpdateHandler(); - } - }); - /** * Convert mouseover event to respective time in seconds * @param {Object} e mouseover event for input range @@ -355,7 +347,9 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options const sliderIndex = slider.dataset.srcindex; if (sliderIndex < currentSrcIndex) leftWidth += slider.offsetWidth; } - timeToolRef.current.style.left = leftWidth + 'px'; + if (e.pointerType != 'touch') { + timeToolRef.current.style.left = leftWidth + 'px'; + } }; /** @@ -388,6 +382,14 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options } }; + /** + * Handle touch events on the progress bar + * @param {Object} e touch event + */ + const handleTouchEvent = (e) => { + handleMouseMove(e, false); + }; + /** * Build input ranges for the inactive source segments * in the manifest @@ -407,7 +409,7 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options role="slider" data-srcindex={t.sIndex} className="vjs-custom-progress-inactive" - onPointerMove={(e) => handleMouseMove(e, true)} + onMouseMove={(e) => handleMouseMove(e, true)} onClick={handleClick} key={t.sIndex} tabIndex={0} @@ -444,7 +446,9 @@ function ProgressBar({ player, handleTimeUpdate, initCurrentTime, times, options data-srcindex={srcIndex} className="vjs-custom-progress" onChange={updateProgress} - onPointerDown={(e) => handleMouseMove(e, false)} + onTouchEnd={handleTouchEvent} + onTouchStart={handleTouchEvent} + onMouseDown={(e) => handleMouseMove(e, false)} onPointerMove={(e) => handleMouseMove(e, false)} id="slider-range" ref={sliderRangeRef} diff --git a/src/components/MediaPlayer/VideoJS/components/js/VideoJSTrackScrubber.js b/src/components/MediaPlayer/VideoJS/components/js/VideoJSTrackScrubber.js index 847ebc20..ac16c28b 100644 --- a/src/components/MediaPlayer/VideoJS/components/js/VideoJSTrackScrubber.js +++ b/src/components/MediaPlayer/VideoJS/components/js/VideoJSTrackScrubber.js @@ -4,6 +4,7 @@ import videojs from 'video.js'; import '../styles/VideoJSTrackScrubber.scss'; import '../styles/VideoJSProgress.scss'; import { timeToHHmmss } from '@Services/utility-helpers'; +import { IS_MOBILE, IS_IPAD } from '@Services/browser'; const vjsComponent = videojs.getComponent('Component'); @@ -101,6 +102,15 @@ function TrackScrubberButton({ player, trackScrubberRef, timeToolRef }) { _setCurrentTrack(t); }; + let playerEventListener; + + // Clean up interval on component unmount + React.useEffect(() => { + return () => { + clearInterval(playerEventListener); + }; + }, []); + /** * Keydown event handler for the track button on the player controls, * when using keyboard navigation @@ -138,28 +148,58 @@ function TrackScrubberButton({ player, trackScrubberRef, timeToolRef }) { populateTrackScrubber(); trackScrubberRef.current.classList.remove('hidden'); + let pointerDragged = false; // Attach mouse pointer events to track scrubber progress bar let [_, progressBar, __] = trackScrubberRef.current.children; progressBar.addEventListener('mouseenter', (e) => { handleMouseMove(e); }); - progressBar.addEventListener('mousemove', (e) => { + /* + Using pointerup, pointermove, pointerdown events instead of + mouseup, mousemove, mousedown events to make it work with both + mouse pointer and touch events + */ + progressBar.addEventListener('pointerup', (e) => { + if (pointerDragged) { + handleSetProgress(e); + } + }); + progressBar.addEventListener('pointermove', (e) => { handleMouseMove(e); + pointerDragged = true; }); - progressBar.addEventListener('mousedown', (e) => { + progressBar.addEventListener('pointerdown', (e) => { // Only handle left click event if (e.which === 1) { handleSetProgress(e); + pointerDragged = false; } }); } }, [zoomedOut]); + player.on('loadedmetadata', () => { + // Hide the timetooltip on mobile/tablet devices + if (IS_IPAD || IS_MOBILE) { + timeToolRef.current.style.display = 'none'; + } + playerEventListener = setInterval(() => { + timeUpdateHandler(); + }, 100); + }); + + // Hide track scrubber if it is displayed when player is going fullscreen + player.on("fullscreenchange", () => { + if (player.isFullscreen() && !zoomedOut) { + setZoomedOut(zoomedOut => !zoomedOut); + } + }); + /** * Event handler for VideoJS player instance's 'timeupdate' event, which * updates the track scrubber from player state. */ - player.on('timeupdate', () => { + const timeUpdateHandler = () => { if (player.isDisposed()) return; // Get the current track from the player.markers created from the structure timespans if (player.markers && player.markers.getMarkers()?.length > 0) { @@ -179,7 +219,7 @@ function TrackScrubberButton({ player, trackScrubberRef, timeToolRef }) { }); } updateTrackScrubberProgressBar(player.currentTime(), player); - }); + }; /** * Update the track scrubber's current time, duration and played percentage @@ -263,21 +303,23 @@ function TrackScrubberButton({ player, trackScrubberRef, timeToolRef }) { return; } let trackoffset = getTrackTime(e); - // Calculate percentage of the progress based on the pointer position's - // time and duration of the track - let trackpercent = Math.min( - 100, - Math.max(0, 100 * trackoffset / currentTrackRef.current.duration) - ); - // Set the elapsed time in the scrubber progress bar - document.documentElement.style.setProperty( - '--range-scrubber', - `calc(${trackpercent}%)` - ); - - // Set player's current time as addition of start time of the track and offset - player.currentTime(currentTrackRef.current.time + trackoffset); + if (trackoffset != undefined) { + // Calculate percentage of the progress based on the pointer position's + // time and duration of the track + let trackpercent = Math.min( + 100, + Math.max(0, 100 * trackoffset / currentTrackRef.current.duration) + ); + + // Set the elapsed time in the scrubber progress bar + document.documentElement.style.setProperty( + '--range-scrubber', + `calc(${trackpercent}%)` + ); + // Set player's current time as addition of start time of the track and offset + player.currentTime(currentTrackRef.current.time + trackoffset); + } }; /** @@ -289,17 +331,13 @@ function TrackScrubberButton({ player, trackScrubberRef, timeToolRef }) { if (!currentTrackRef.current) { return; } - let offsetx = 0; - // Use touch position information in touch devices - if (e.changedTouches?.length > 0) { - offsetx = e.changedTouches[0].pageX; - } else { - offsetx = e.offsetX; + let offsetx = e.offsetX; + if (offsetx && offsetx != undefined) { + let time = + (offsetx / e.target.clientWidth) * currentTrackRef.current.duration + ; + return time; } - let time = - (offsetx / e.target.clientWidth) * currentTrackRef.current.duration - ; - return time; }; return ( diff --git a/src/services/browser.js b/src/services/browser.js new file mode 100644 index 00000000..0e5802a2 --- /dev/null +++ b/src/services/browser.js @@ -0,0 +1,279 @@ +/** Copied from: https://github.com/videojs/video.js/blob/main/src/js/utils/browser.js */ + +/** + * Whether or not this device is an iPod. + * + * @static + * @type {Boolean} + */ +export let IS_IPOD = false; + +/** + * The detected iOS version - or `null`. + * + * @static + * @type {string|null} + */ +export let IOS_VERSION = null; + +/** + * Whether or not this is an Android device. + * + * @static + * @type {Boolean} + */ +export let IS_ANDROID = false; + +/** + * The detected Android version - or `null` if not Android or indeterminable. + * + * @static + * @type {number|string|null} + */ +export let ANDROID_VERSION = null; + +/** + * Whether or not this is Mozilla Firefox. + * + * @static + * @type {Boolean} + */ +export let IS_FIREFOX = false; + +/** + * Whether or not this is Microsoft Edge. + * + * @static + * @type {Boolean} + */ +export let IS_EDGE = false; + +/** + * Whether or not this is any Chromium Browser + * + * @static + * @type {Boolean} + */ +export let IS_CHROMIUM = false; + +/** + * Whether or not this is any Chromium browser that is not Edge. + * + * This will also be `true` for Chrome on iOS, which will have different support + * as it is actually Safari under the hood. + * + * Deprecated, as the behaviour to not match Edge was to prevent Legacy Edge's UA matching. + * IS_CHROMIUM should be used instead. + * "Chromium but not Edge" could be explicitly tested with IS_CHROMIUM && !IS_EDGE + * + * @static + * @deprecated + * @type {Boolean} + */ +export let IS_CHROME = false; + +/** + * The detected Chromium version - or `null`. + * + * @static + * @type {number|null} + */ +export let CHROMIUM_VERSION = null; + +/** + * The detected Google Chrome version - or `null`. + * This has always been the _Chromium_ version, i.e. would return on Chromium Edge. + * Deprecated, use CHROMIUM_VERSION instead. + * + * @static + * @deprecated + * @type {number|null} + */ +export let CHROME_VERSION = null; + +/** + * The detected Internet Explorer version - or `null`. + * + * @static + * @deprecated + * @type {number|null} + */ +export let IE_VERSION = null; + +/** + * Whether or not this is desktop Safari. + * + * @static + * @type {Boolean} + */ +export let IS_SAFARI = false; + +/** + * Whether or not this is a Windows machine. + * + * @static + * @type {Boolean} + */ +export let IS_WINDOWS = false; + +/** + * Whether or not this device is an iPad. + * + * @static + * @type {Boolean} + */ +export let IS_IPAD = false; + +/** + * Whether or not this is a mobile device. + * + * @static + * @type {Boolean} + */ +export let IS_MOBILE = false; + +/** + * Whether or not this device is an iPhone. + * + * @static + * @type {Boolean} + */ +// The Facebook app's UIWebView identifies as both an iPhone and iPad, so +// to identify iPhones, we need to exclude iPads. +// http://artsy.github.io/blog/2012/10/18/the-perils-of-ios-user-agent-sniffing/ +export let IS_IPHONE = false; + + +/** + * Whether or not this is an iOS device. + * + * @static + * @const + * @type {Boolean} + */ +export let IS_IOS = false; + +/** + * Whether or not this is a Tizen device. + * + * @static + * @type {Boolean} + */ +export let IS_TIZEN = false; + +/** + * Whether or not this is a WebOS device. + * + * @static + * @type {Boolean} + */ +export let IS_WEBOS = false; + +const UAD = window.navigator && window.navigator.userAgentData; + +if (UAD && UAD.platform && UAD.brands) { + // If userAgentData is present, use it instead of userAgent to avoid warnings + // Currently only implemented on Chromium + // userAgentData does not expose Android version, so ANDROID_VERSION remains `null` + + IS_ANDROID = UAD.platform === 'Android'; + IS_EDGE = Boolean(UAD.brands.find(b => b.brand === 'Microsoft Edge')); + IS_CHROMIUM = Boolean(UAD.brands.find(b => b.brand === 'Chromium')); + IS_CHROME = !IS_EDGE && IS_CHROMIUM; + CHROMIUM_VERSION = CHROME_VERSION = (UAD.brands.find(b => b.brand === 'Chromium') || {}).version || null; + IS_WINDOWS = UAD.platform === 'Windows'; +} + +// If the browser is not Chromium, either userAgentData is not present which could be an old Chromium browser, +// or it's a browser that has added userAgentData since that we don't have tests for yet. In either case, +// the checks need to be made agiainst the regular userAgent string. +if (!IS_CHROMIUM) { + const USER_AGENT = window.navigator && window.navigator.userAgent || ''; + + IS_IPOD = (/iPod/i).test(USER_AGENT); + + IOS_VERSION = (function () { + const match = USER_AGENT.match(/OS (\d+)_/i); + + if (match && match[1]) { + return match[1]; + } + return null; + }()); + + IS_ANDROID = (/Android/i).test(USER_AGENT); + + ANDROID_VERSION = (function () { + // This matches Android Major.Minor.Patch versions + // ANDROID_VERSION is Major.Minor as a Number, if Minor isn't available, then only Major is returned + const match = USER_AGENT.match(/Android (\d+)(?:\.(\d+))?(?:\.(\d+))*/i); + + if (!match) { + return null; + } + + const major = match[1] && parseFloat(match[1]); + const minor = match[2] && parseFloat(match[2]); + + if (major && minor) { + return parseFloat(match[1] + '.' + match[2]); + } else if (major) { + return major; + } + return null; + }()); + + IS_FIREFOX = (/Firefox/i).test(USER_AGENT); + + IS_EDGE = (/Edg/i).test(USER_AGENT); + + IS_CHROMIUM = ((/Chrome/i).test(USER_AGENT) || (/CriOS/i).test(USER_AGENT)); + + IS_CHROME = !IS_EDGE && IS_CHROMIUM; + + CHROMIUM_VERSION = CHROME_VERSION = (function () { + const match = USER_AGENT.match(/(Chrome|CriOS)\/(\d+)/); + + if (match && match[2]) { + return parseFloat(match[2]); + } + return null; + }()); + + IE_VERSION = (function () { + const result = (/MSIE\s(\d+)\.\d/).exec(USER_AGENT); + let version = result && parseFloat(result[1]); + + if (!version && (/Trident\/7.0/i).test(USER_AGENT) && (/rv:11.0/).test(USER_AGENT)) { + // IE 11 has a different user agent string than other IE versions + version = 11.0; + } + + return version; + }()); + + IS_TIZEN = (/Tizen/i).test(USER_AGENT); + + IS_WEBOS = (/Web0S/i).test(USER_AGENT); + + IS_SAFARI = (/Safari/i).test(USER_AGENT) && !IS_CHROME && !IS_ANDROID && !IS_EDGE && !IS_TIZEN && !IS_WEBOS; + + IS_WINDOWS = (/Windows/i).test(USER_AGENT); + + IS_IPAD = (/iPad/i).test(USER_AGENT); + + IS_IPHONE = (/iPhone/i).test(USER_AGENT) && !IS_IPAD; + + IS_IOS = IS_IPHONE || IS_IPAD || IS_IPOD; + + IS_MOBILE = IS_ANDROID || IS_IOS || IS_IPHONE || (/Mobi/i).test(USER_AGENT); +} + +/** + * Whether or not this is any flavor of Safari - including iOS. + * + * @static + * @const + * @type {Boolean} + */ +export const IS_ANY_SAFARI = (IS_SAFARI || IS_IOS) && !IS_CHROME; diff --git a/src/styles/main.scss b/src/styles/main.scss index 4e73e8f6..23897dfe 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -44,6 +44,13 @@ font-size: 120%; } +/* Control bar styling for mobile/tablet devices */ +.vjs-mobile-visible { + opacity: 1 !important; + display: inline; + z-index: 10001; +} + /* Make VideoJS control bar buttons smaller */ .video-js .vjs-volume-panel .vjs-volume-panel-vertical, .video-js .vjs-control { @@ -130,4 +137,4 @@ video/poster area the controls are displayed correctly. */ border-radius: 5px; background-color: rgba(0, 0, 0, .5); -webkit-box-shadow: 0 0 1px rgba(255, 255, 255, .5); -} +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index ce3205a7..7f53dc70 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -62,7 +62,7 @@ module.exports = { } }, devServer: { - host: 'localhost', + host: '0.0.0.0', port: 3003, liveReload: true, static: {