diff --git a/src/bloom-player-core.tsx b/src/bloom-player-core.tsx index ee17b9eb..2c7b7e48 100644 --- a/src/bloom-player-core.tsx +++ b/src/bloom-player-core.tsx @@ -1051,65 +1051,6 @@ export class BloomPlayerCore extends React.Component { } } - private showReplayButton(pageVideoData: IPageVideoComplete | undefined) { - const pageNumber = - pageVideoData?.page.getAttribute("data-page-number") ?? 0; - pageVideoData?.videos.forEach((video, index) => { - const parent = video.parentElement!; - let replayButton = document.getElementById( - `replay-button-${pageNumber}-${index}` - ); - if (!replayButton) { - if ( - video.networkState === HTMLMediaElement.NETWORK_NO_SOURCE && - video.readyState === HTMLMediaElement.HAVE_NOTHING - ) { - return; // don't create replay button if video is bad - } - replayButton = document.createElement("div"); - replayButton.setAttribute( - "id", - `replay-button-${pageNumber}-${index}` - ); - replayButton.classList.add("replay-button"); - replayButton.style.position = "absolute"; - replayButton.style.display = "none"; - ReactDOM.render( - { - // in storybook, I was seeing the page jump around as I clicked the button. - // Guessing it was somehow caused by something higher up also responding to - // the click, I put these in to try to stop it, but didn't succeed. - // If we get the behavior in production, we'll need to try some more. - args.preventDefault(); - args.stopPropagation(); - - video.style.display = "block"; - if (replayButton) - // replayButton is always defined, but TS doesn't know that. - replayButton.style.display = "none"; - this.video.replaySingleVideo(video); - }} - onTouchStart={args => { - // This prevents the toolbar from toggling if we start a touch on the Replay button. - // If the touch ends up being a tap, then onClick will get processed too. - this.setState({ ignorePhonyClick: true }); - }} - onMouseDown={args => { - // another attempt to stop the jumping around. - args.stopPropagation(); - }} - />, - replayButton - ); - } - replayButton.style.position = "absolute"; - parent.appendChild(replayButton); - replayButton!.style.display = "block"; - }); - } - // We need named functions for each LiteEvent handler, so that we can unsubscribe them // when we are about to unmount. private handlePageVideoComplete = pageVideoData => { @@ -1117,14 +1058,6 @@ export class BloomPlayerCore extends React.Component { // If the user if flipping pages rapidly, video completed events can overlap. if (pageVideoData!.page === BloomPlayerCore.currentPage) { this.playAudioAndAnimation(pageVideoData!.page); // play audio after video finishes - if (!this.props.hideSwiperButtons) { - // Replay isn't technically a swiper button, but it's the same sort of navigational - // control and wants to be hidden in the same circumstances, currently both preview - // and recording-in-progress in publish-to-video. - this.showReplayButton(pageVideoData); - } - // } else { - // console.log(`DEBUG: ignoring out of sequence page audio`); } }; @@ -2819,19 +2752,6 @@ export class BloomPlayerCore extends React.Component { if (this.props.paused) { return; // shouldn't call when paused } - const replayButtons = document.getElementsByClassName("replay-button"); - if (replayButtons) { - for (let i = 0; i < replayButtons.length; i++) { - const replayButton = replayButtons[i] as HTMLElement; - replayButton.style.display = "none"; - const video = replayButton.parentElement?.getElementsByTagName( - "video" - )[0]; - if (video) { - video.style.display = ""; - } - } - } this.animation.PlayAnimation(); // get rid of classes that made it pause setCurrentNarrationPage(bloomPage); // State must be set before calling HandlePageVisible() and related methods. diff --git a/src/bloom-player-ui.less b/src/bloom-player-ui.less index a2690348..7507bc54 100644 --- a/src/bloom-player-ui.less +++ b/src/bloom-player-ui.less @@ -247,24 +247,6 @@ We just make them follow the main content normally. */ } } -.bloomPlayer .replay-button { - font-size: 45px; - left: calc(50% - 22px); - top: calc(50% - 22px); -} - -.fade-out { - animation: fadeOut ease 1s; -} -@keyframes fadeOut { - 0% { - opacity: 1; - } - 100% { - opacity: 0.2; - } -} - // I positioned this absolutely so that, although we allow space for it when computing the // overall zoom factor for the main page slider, it doesn't otherwise affect the positioning // of everything, which is quite complex. Also, this will work even if we later decide that @@ -446,3 +428,54 @@ body, video::-webkit-media-controls-overlay-play-button { display: none; } + +@videoIconSize: 30px; +@videoIconPadding: 0px; + +div.videoControlContainer { + position: absolute; + top: calc(50% - @videoIconSize / 2); + height: @videoIconSize + 2 * @videoIconPadding; + width: @videoIconSize + 2 * @videoIconPadding; + padding: @videoIconPadding; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + box-sizing: border-box; + justify-content: center; + align-items: center; + display: none; +} + +svg.videoControl { + height: @videoIconSize; +} + +// never shown at the same time, so same location is fine. +.videoPlayIcon, +.videoPauseIcon { + left: calc(50% - @videoIconSize - 5px); // always the left of two icons +} + +.videoReplayIcon { + left: calc(50% + 5px); // always the right of two icons +} + +// Show play and replay when paused +.bloom-videoContainer.paused { + .videoControlContainer { + &.videoPlayIcon, + &.videoReplayIcon { + display: flex; + } + } +} + +// Show pause and replay when playing and hovered. +.bloom-videoContainer.playing:hover { + .videoControlContainer { + &.videoPauseIcon, + &.videoReplayIcon { + display: flex; + } + } +} diff --git a/src/pauseIcon.ts b/src/pauseIcon.ts new file mode 100644 index 00000000..7f534ed6 --- /dev/null +++ b/src/pauseIcon.ts @@ -0,0 +1,9 @@ +export const getPauseIcon = (color: string) => { + const elt = document.createElement("div"); + // From MUI Pause + elt.innerHTML = ` + + +`; + return elt.firstChild as HTMLElement; +}; diff --git a/src/playIcon.ts b/src/playIcon.ts new file mode 100644 index 00000000..a5bab580 --- /dev/null +++ b/src/playIcon.ts @@ -0,0 +1,8 @@ +export const getPlayIcon = (color: string) => { + const elt = document.createElement("div"); + // from MUI PlayArrow + elt.innerHTML = ` + + `; + return elt.firstChild as HTMLElement; +}; diff --git a/src/replayIcon.ts b/src/replayIcon.ts new file mode 100644 index 00000000..ed55b5d3 --- /dev/null +++ b/src/replayIcon.ts @@ -0,0 +1,9 @@ +export const getReplayIcon = (color: string) => { + const elt = document.createElement("div"); + // from MUI Replay + elt.innerHTML = ` + + +`; + return elt.firstChild as HTMLElement; +}; diff --git a/src/video.ts b/src/video.ts index e4d19771..4d607d4d 100644 --- a/src/video.ts +++ b/src/video.ts @@ -8,6 +8,9 @@ import { hideVideoError, showVideoError } from "./narration"; +import { getPlayIcon } from "./playIcon"; +import { getPauseIcon } from "./pauseIcon"; +import { getReplayIcon } from "./replayIcon"; // class Video contains functionality to get videos to play properly in bloom-player @@ -28,6 +31,22 @@ export class Video { return !!Video.getVideoElements(page).length; } + // configure one of the icons we display over videos. We put a div around it and apply + // various classes and append it to the parent of the video. + private wrapVideoIcon( + videoElement: HTMLVideoElement, + icon: HTMLElement, + iconClass: string + ): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.classList.add("videoControlContainer"); + wrapper.appendChild(icon); + wrapper.classList.add(iconClass); + icon.classList.add("videoControl"); + videoElement.parentElement?.appendChild(wrapper); + return icon; + } + // Work we prefer to do before the page is visible. This makes sure that when the video // is loaded it will begin to play automatically. public HandlePageBeforeVisible(page: HTMLElement) { @@ -37,15 +56,41 @@ export class Video { return; } this.getVideoElements().forEach(videoElement => { - videoElement.setAttribute("controls", "controls"); - videoElement.setAttribute("disablepictureinpicture", "true"); - videoElement.setAttribute( - "controlsList", - "noplaybackrate nofullscreen nodownload noremoteplayback" + videoElement.removeAttribute("controls"); + const playButton = this.wrapVideoIcon( + videoElement, + // Alternatively, we could import the Material UI icon, make this file a TSX, and use + // ReactDom.render to render the icon into the div. But just creating the SVG + // ourselves (as these methods do) seems more natural to me. We would not be using + // React for anything except to make use of an image which unfortunately is only + // available by default as a component. + getPlayIcon("#ffffff"), + "videoPlayIcon" ); - if (!videoElement.hasAttribute("playsinline")) { - videoElement.setAttribute("playsinline", "true"); - } + playButton.addEventListener("click", this.handlePlayClick); + videoElement.addEventListener("click", this.handlePlayClick); + const pauseButton = this.wrapVideoIcon( + videoElement, + getPauseIcon("#ffffff"), + "videoPauseIcon" + ); + pauseButton.addEventListener("click", this.handlePauseClick); + const replayButton = this.wrapVideoIcon( + videoElement, + getReplayIcon("#ffffff"), + "videoReplayIcon" + ); + replayButton.addEventListener("click", this.handleReplayClick); + + // These settings are useful if we use the built-in controls. + // videoElement.setAttribute("disablepictureinpicture", "true"); + // videoElement.setAttribute( + // "controlsList", + // "noplaybackrate nofullscreen nodownload noremoteplayback" + // ); + // if (!videoElement.hasAttribute("playsinline")) { + // videoElement.setAttribute("playsinline", "true"); + // } if (videoElement.currentTime !== 0) { // in case we previously played this video and are returning to this page... videoElement.currentTime = 0; @@ -93,6 +138,36 @@ export class Video { return Video.getVideoElements(this.currentPage); } + // Handles a click on the play button, or simply a click on the video itself. + private handlePlayClick = (ev: MouseEvent) => { + ev.stopPropagation(); // we don't want the navigation bar to toggle on and off + ev.preventDefault(); + const video = (ev.target as HTMLElement) + ?.closest(".bloom-videoContainer") + ?.getElementsByTagName("video")[0]; + if (!video) { + return; // should not happen + } + if (video === this.currentVideoElement) { + this.play(); // we may have paused before all videos played, resume the sequence. + } else { + // a video we were not currently playing, start from the beginning. + this.replaySingleVideo(video); + } + }; + + private handleReplayClick = (ev: MouseEvent) => { + ev.stopPropagation(); // we don't want the navigation bar to toggle on and off + ev.preventDefault(); + const video = (ev.target as HTMLElement) + ?.closest(".bloom-videoContainer") + ?.getElementsByTagName("video")[0]; + if (!video) { + return; // should not happen + } + this.replaySingleVideo(video); + }; + public play() { if (currentPlaybackMode === PlaybackMode.VideoPlaying) { return; // no change. @@ -116,6 +191,14 @@ export class Video { this.playAllVideo(videoElements); } + // This is called when the user clicks the pause button on a video. + // Unlike when pause is done from the control bar, we add a class that shows some buttons. + public handlePauseClick = (ev: MouseEvent) => { + ev.stopPropagation(); // we don't want the navigation bar to toggle on and off + ev.preventDefault(); + this.pause(); + }; + public pause() { if (currentPlaybackMode == PlaybackMode.VideoPaused) { return; @@ -125,6 +208,12 @@ export class Video { } private pauseCurrentVideo() { + // This also cleans up after the last one finishes. + if (this.currentPage) { + Array.from( + this.currentPage.getElementsByClassName("playing") + ).forEach(element => element.classList.remove("playing")); + } const videoElement = this.currentVideoElement; if (!videoElement) { return; // no change @@ -139,6 +228,7 @@ export class Video { videoElement.currentTime - this.currentVideoStartTime ); } + videoElement?.closest(".bloom-videoContainer")?.classList.add("paused"); videoElement.pause(); } @@ -151,6 +241,7 @@ export class Video { } public replaySingleVideo(video: HTMLVideoElement) { + video.currentTime = 0; this.isPlayingSingleVideo = true; this.playAllVideo([video]); } @@ -160,6 +251,9 @@ export class Video { // Note, there is a very similar function in narration.ts. It would be nice to combine them, but // this one must be here and must be part of the Video class so it can handle play/pause, analytics, etc. public playAllVideo(elements: HTMLVideoElement[]) { + Array.from( + this.currentPage.getElementsByClassName("playing") + ).forEach(element => element.classList.remove("playing")); if (elements.length === 0) { this.currentVideoElement = undefined; this.isPlayingSingleVideo = false; @@ -172,6 +266,11 @@ export class Video { return; } + // Remove the paused class from all videos on the page. We're playing. + Array.from( + this.currentPage.getElementsByClassName("paused") + ).forEach(element => element.classList.remove("paused")); + const video = elements[0]; // If we somehow get into a state where the video is not on the current page, don't continue. @@ -193,6 +292,7 @@ export class Video { hideVideoError(video); setCurrentPlaybackMode(PlaybackMode.VideoPlaying); this.currentVideoStartTime = video.currentTime || 0; + video.closest(".bloom-videoContainer")?.classList.add("playing"); const promise = video.play(); promise .then(() => { @@ -203,6 +303,11 @@ export class Video { this.reportVideoPlayed( video.currentTime - this.currentVideoStartTime ); + // reset it, to make it obvious it can be replayed. + // Note: if we decide to show a play (or any) button here, we should do it only if + // bloom-player-core's props.hideSwiperButtons is false, to make sure we don't + // get it when auto-playing to make a video. + video.currentTime = 0; this.playAllVideo(elements.slice(1)); }, { once: true }