diff --git a/static/app-strings.json b/static/app-strings.json index 9125daa73..d80c31d52 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -2275,5 +2275,6 @@ "Use https": "Use https", "Custom Servers": "Custom Servers", "Add A Server": "Add A Server", + "Autoplay Next is off.": "Autoplay Next is off.", "--end--": "--end--" } diff --git a/ui/analytics.js b/ui/analytics.js index e9089d789..2733ed606 100644 --- a/ui/analytics.js +++ b/ui/analytics.js @@ -24,6 +24,8 @@ if (isProduction) { } type Analytics = { + appStartTime: number, // needed? + eventStartTime: any, // needed? error: (string) => Promise, sentryError: ({} | string, {}) => Promise, pageView: (string, ?string) => void, @@ -49,9 +51,6 @@ type Analytics = { readyState: number, } ) => Promise, - adsFetchedEvent: () => void, - adsReceivedEvent: (any) => void, - adsErrorEvent: (any) => void, emailProvidedEvent: () => void, emailVerifiedEvent: () => void, rewardEligibleEvent: () => void, @@ -168,13 +167,13 @@ async function sendWatchmanData(body) { }); return response; - } catch (err) { - console.log('ERROR FROM WATCHMAN BACKEND'); - console.log(err); - } + } catch (err) {} } const analytics: Analytics = { + appStartTime: 0, // ? + eventStartTime: {}, // ? + // receive buffer events from tracking plugin and save buffer amounts and times for backend call videoBufferEvent: async (claim, data) => { amountOfBufferEvents = amountOfBufferEvents + 1; @@ -213,7 +212,7 @@ const analytics: Analytics = { startWatchmanIntervalIfNotRunning(); } }, - videoStartEvent: (claimId, duration, poweredBy, passedUserId, canonicalUrl, passedPlayer, videoBitrate) => { + videoStartEvent: (claimId, timeToStartVideo, poweredBy, passedUserId, canonicalUrl, passedPlayer, videoBitrate) => { // populate values for watchman when video starts userId = passedUserId; claimUrl = canonicalUrl; @@ -224,7 +223,7 @@ const analytics: Analytics = { bitrateAsBitsPerSecond = videoBitrate; // sendPromMetric('time_to_start', duration); - sendMatomoEvent('Media', 'TimeToStart', claimId, duration); + sendMatomoEvent('Media', 'TimeToStart', claimId, timeToStartVideo); }, error: (message) => { return new Promise((resolve) => { diff --git a/ui/component/viewers/videoViewer/internal/lbry-volume-bar.js b/ui/component/viewers/videoViewer/internal/lbry-volume-bar.js new file mode 100644 index 000000000..573ef86ca --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/lbry-volume-bar.js @@ -0,0 +1,46 @@ +// **************************************************************************** +// LbryVolumeBarClass +// **************************************************************************** + +import videojs from 'video.js'; + +const isDev = process.env.NODE_ENV !== 'production'; + +const VIDEOJS_CONTROL_BAR_CLASS = 'ControlBar'; +const VIDEOJS_VOLUME_PANEL_CLASS = 'VolumePanel'; +const VIDEOJS_VOLUME_CONTROL_CLASS = 'VolumeControl'; +const VIDEOJS_VOLUME_BAR_CLASS = 'VolumeBar'; + +class LbryVolumeBarClass extends videojs.getComponent(VIDEOJS_VOLUME_BAR_CLASS) { + constructor(player, options = {}) { + super(player, options); + } + + static replaceExisting(player) { + try { + const volumeControl = player + .getChild(VIDEOJS_CONTROL_BAR_CLASS) + .getChild(VIDEOJS_VOLUME_PANEL_CLASS) + .getChild(VIDEOJS_VOLUME_CONTROL_CLASS); + const volumeBar = volumeControl.getChild(VIDEOJS_VOLUME_BAR_CLASS); + volumeControl.removeChild(volumeBar); + volumeControl.addChild(new LbryVolumeBarClass(player)); + } catch (error) { + // In case it slips in 'Production', the original volume bar will be used and the site should still be working + // (just not exactly the way we want). + if (isDev) throw Error('\n\nvideojs.jsx: Volume Panel hierarchy changed?\n\n' + error); + } + } + + handleMouseDown(event) { + super.handleMouseDown(event); + event.stopPropagation(); + } + + handleMouseMove(event) { + super.handleMouseMove(event); + event.stopPropagation(); + } +} + +export default LbryVolumeBarClass; diff --git a/ui/component/viewers/videoViewer/internal/videojs-events.jsx b/ui/component/viewers/videoViewer/internal/videojs-events.jsx new file mode 100644 index 000000000..1c665a962 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/videojs-events.jsx @@ -0,0 +1,236 @@ +// @flow +import { useEffect } from 'react'; + +const isDev = process.env.NODE_ENV !== 'production'; + +const TAP = { + NONE: 'NONE', + UNMUTE: 'UNMUTE', + RETRY: 'RETRY', +}; + +const setLabel = (controlBar, childName, label) => { + const c = controlBar.getChild(childName); + if (c) { + c.controlText(label); + } +}; + +// $FlowFixMe +const VideoJsEvents = ({ + tapToUnmuteRef, + tapToRetryRef, + setReload, + videoTheaterMode, + playerRef, + autoplaySetting, + replay, +}: { + tapToUnmuteRef: any, // DOM element + tapToRetryRef: any, // DOM element + setReload: any, // react hook + videoTheaterMode: any, // dispatch function + playerRef: any, // DOM element + autoplaySetting: boolean, + replay: boolean, +}) => { + // Override the player's control text. We override to: + // 1. Add keyboard shortcut to the tool-tip. + // 2. Override videojs' i18n and use our own (don't want to have 2 systems). + // + // Notes: + // - For dynamic controls (e.g. play/pause), those unfortunately need to be + // updated again at their event-listener level (that's just the way videojs + // updates the text), hence the need to listen to 'play', 'pause' and 'volumechange' + // on top of just 'loadstart'. + // - videojs changes the MuteToggle text at 'loadstart', so this was chosen + // as the listener to update static texts. + + function resolveCtrlText(e) { + const player = playerRef.current; + if (player) { + const ctrlBar = player.getChild('controlBar'); + switch (e.type) { + case 'play': + setLabel(ctrlBar, 'PlayToggle', __('Pause (space)')); + break; + case 'pause': + setLabel(ctrlBar, 'PlayToggle', __('Play (space)')); + break; + case 'volumechange': + ctrlBar + .getChild('VolumePanel') + .getChild('MuteToggle') + .controlText(player.muted() || player.volume() === 0 ? __('Unmute (m)') : __('Mute (m)')); + break; + case 'fullscreenchange': + setLabel( + ctrlBar, + 'FullscreenToggle', + player.isFullscreen() ? __('Exit Fullscreen (f)') : __('Fullscreen (f)') + ); + break; + case 'loadstart': + // --- Do everything --- + setLabel(ctrlBar, 'PlaybackRateMenuButton', __('Playback Rate (<, >)')); + setLabel(ctrlBar, 'QualityButton', __('Quality')); + setLabel(ctrlBar, 'PlayNextButton', __('Play Next (SHIFT+N)')); + setLabel(ctrlBar, 'PlayPreviousButton', __('Play Previous (SHIFT+P)')); + setLabel(ctrlBar, 'TheaterModeButton', videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); + setLabel(ctrlBar, 'AutoplayNextButton', autoplaySetting ? __('Autoplay Next On') : __('Autoplay Next Off')); + + resolveCtrlText({ type: 'play' }); + resolveCtrlText({ type: 'pause' }); + resolveCtrlText({ type: 'volumechange' }); + resolveCtrlText({ type: 'fullscreenchange' }); + break; + default: + if (isDev) throw Error('Unexpected: ' + e.type); + break; + } + } + } + + function onInitialPlay() { + const player = playerRef.current; + if (player && (player.muted() || player.volume() === 0)) { + // The css starts as "hidden". We make it visible here without + // re-rendering the whole thing. + showTapButton(TAP.UNMUTE); + } else { + showTapButton(TAP.NONE); + } + } + + function onVolumeChange() { + const player = playerRef.current; + if (player && !player.muted()) { + showTapButton(TAP.NONE); + } + } + + function onError() { + const player = playerRef.current; + showTapButton(TAP.RETRY); + + // reattach initial play listener in case we recover from error successfully + // $FlowFixMe + player.one('play', onInitialPlay); + + if (player && player.loadingSpinner) { + player.loadingSpinner.hide(); + } + } + + // const onEnded = React.useCallback(() => { + // if (!adUrl) { + // showTapButton(TAP.NONE); + // } + // }, [adUrl]); + + useEffect(() => { + const player = playerRef.current; + if (player) { + const controlBar = player.getChild('controlBar'); + controlBar + .getChild('TheaterModeButton') + .controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); + } + }, [videoTheaterMode]); + + // when user clicks 'Unmute' button, turn audio on and hide unmute button + function unmuteAndHideHint() { + const player = playerRef.current; + if (player) { + player.muted(false); + if (player.volume() === 0) { + player.volume(1.0); + } + } + showTapButton(TAP.NONE); + } + + function retryVideoAfterFailure() { + const player = playerRef.current; + if (player) { + setReload(Date.now()); + showTapButton(TAP.NONE); + } + } + + function showTapButton(tapButton) { + const setButtonVisibility = (theRef, newState) => { + // Use the DOM to control the state of the button to prevent re-renders. + if (theRef.current) { + const curState = theRef.current.style.visibility === 'visible'; + if (newState !== curState) { + theRef.current.style.visibility = newState ? 'visible' : 'hidden'; + } + } + }; + + switch (tapButton) { + case TAP.NONE: + setButtonVisibility(tapToUnmuteRef, false); + setButtonVisibility(tapToRetryRef, false); + break; + case TAP.UNMUTE: + setButtonVisibility(tapToUnmuteRef, true); + setButtonVisibility(tapToRetryRef, false); + break; + case TAP.RETRY: + setButtonVisibility(tapToUnmuteRef, false); + setButtonVisibility(tapToRetryRef, true); + break; + default: + if (isDev) throw new Error('showTapButton: unexpected ref'); + break; + } + } + + useEffect(() => { + const player = playerRef.current; + if (player) { + const touchOverlay = player.getChild('TouchOverlay'); + const controlBar = player.getChild('controlBar') || touchOverlay.getChild('controlBar'); + const autoplayButton = controlBar.getChild('AutoplayNextButton'); + + if (autoplayButton) { + const title = autoplaySetting ? __('Autoplay Next On') : __('Autoplay Next Off'); + + autoplayButton.controlText(title); + autoplayButton.setAttribute('aria-label', title); + autoplayButton.setAttribute('aria-checked', autoplaySetting); + } + } + }, [autoplaySetting]); + + useEffect(() => { + const player = playerRef.current; + if (replay && player) { + player.play(); + } + }, [replay]); + + function initializeEvents() { + const player = playerRef.current; + // Add various event listeners to player + player.one('play', onInitialPlay); + player.on('play', resolveCtrlText); + player.on('pause', resolveCtrlText); + player.on('loadstart', resolveCtrlText); + player.on('fullscreenchange', resolveCtrlText); + player.on('volumechange', resolveCtrlText); + player.on('volumechange', onVolumeChange); + player.on('error', onError); + // player.on('ended', onEnded); + } + + return { + retryVideoAfterFailure, + unmuteAndHideHint, + initializeEvents, + }; +}; + +export default VideoJsEvents; diff --git a/ui/component/viewers/videoViewer/internal/videojs-functions.jsx b/ui/component/viewers/videoViewer/internal/videojs-functions.jsx new file mode 100644 index 000000000..a4e2cabe5 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/videojs-functions.jsx @@ -0,0 +1,69 @@ +// @flow +const VideoJsFunctions = ({ + source, + sourceType, + videoJsOptions, + isAudio, +}: { + source: string, + sourceType: string, + videoJsOptions: Object, + isAudio: boolean, +}) => { + function detectFileType() { + // $FlowFixMe + return new Promise(async (res, rej) => { + try { + const response = await fetch(source, { method: 'HEAD', cache: 'no-store' }); + + // Temp variables to hold results + let finalType = sourceType; + let finalSource = source; + + // override type if we receive an .m3u8 (transcoded mp4) + // do we need to check if explicitly redirected + // or is checking extension only a safer method + if (response && response.redirected && response.url && response.url.endsWith('m3u8')) { + finalType = 'application/x-mpegURL'; + finalSource = response.url; + } + + // Modify video source in options + videoJsOptions.sources = [ + { + src: finalSource, + type: finalType, + }, + ]; + + return res(videoJsOptions); + } catch (error) { + return rej(error); + } + }); + } + + // TODO: can remove this function as well + // Create the video DOM element and wrapper + function createVideoPlayerDOM(container: any) { + if (!container) return; + + // This seems like a poor way to generate the DOM for video.js + const wrapper = document.createElement('div'); + wrapper.setAttribute('data-vjs-player', 'true'); + const el = document.createElement(isAudio ? 'audio' : 'video'); + el.className = 'video-js vjs-big-play-centered '; + wrapper.appendChild(el); + + container.appendChild(wrapper); + + return el; + } + + return { + detectFileType, + createVideoPlayerDOM, + }; +}; + +export default VideoJsFunctions; diff --git a/ui/component/viewers/videoViewer/internal/videojs-keyboard-shortcuts.jsx b/ui/component/viewers/videoViewer/internal/videojs-keyboard-shortcuts.jsx new file mode 100644 index 000000000..c78f122b3 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/videojs-keyboard-shortcuts.jsx @@ -0,0 +1,157 @@ +// @flow +import * as OVERLAY from './overlays'; +import * as KEYCODES from 'constants/keycodes'; +import isUserTyping from 'util/detect-typing'; + +const SEEK_STEP_5 = 5; +const SEEK_STEP = 10; // time to seek in seconds + +const videoPlaybackRates = [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 1.75, 2]; + +// check if active (clicked) element is part of video div, used for keyboard shortcuts (volume etc) +function activeElementIsPartOfVideoElement() { + const videoElementParent = document.getElementsByClassName('video-js-parent')[0]; + const activeElement = document.activeElement; + return videoElementParent.contains(activeElement); +} + +function volumeUp(event, playerRef) { + // dont run if video element is not active element (otherwise runs when scrolling using keypad) + const videoElementIsActive = activeElementIsPartOfVideoElement(); + const player = playerRef.current; + if (!player || !videoElementIsActive) return; + event.preventDefault(); + player.volume(player.volume() + 0.05); + OVERLAY.showVolumeverlay(player, Math.round(player.volume() * 100)); + player.userActive(true); +} + +function volumeDown(event, playerRef) { + // dont run if video element is not active element (otherwise runs when scrolling using keypad) + const videoElementIsActive = activeElementIsPartOfVideoElement(); + const player = playerRef.current; + if (!player || !videoElementIsActive) return; + event.preventDefault(); + player.volume(player.volume() - 0.05); + OVERLAY.showVolumeverlay(player, Math.round(player.volume() * 100)); + player.userActive(true); +} + +function seekVideo(stepSize: number, playerRef, containerRef) { + const player = playerRef.current; + const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); + if (!videoNode || !player) return; + const duration = videoNode.duration; + const currentTime = videoNode.currentTime; + const newDuration = currentTime + stepSize; + if (newDuration < 0) { + videoNode.currentTime = 0; + } else if (newDuration > duration) { + videoNode.currentTime = duration; + } else { + videoNode.currentTime = newDuration; + } + OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0); + player.userActive(true); +} + +function toggleFullscreen(playerRef) { + const player = playerRef.current; + if (!player) return; + if (!player.isFullscreen()) { + player.requestFullscreen(); + } else { + player.exitFullscreen(); + } +} + +function toggleMute(containerRef) { + const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); + if (!videoNode) return; + videoNode.muted = !videoNode.muted; +} + +function togglePlay(containerRef) { + const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); + if (!videoNode) return; + videoNode.paused ? videoNode.play() : videoNode.pause(); +} + +function changePlaybackSpeed(shouldSpeedUp: boolean, playerRef) { + const player = playerRef.current; + if (!player) return; + const isSpeedUp = shouldSpeedUp; + const rate = player.playbackRate(); + let rateIndex = videoPlaybackRates.findIndex((x) => x === rate); + if (rateIndex >= 0) { + rateIndex = isSpeedUp ? Math.min(rateIndex + 1, videoPlaybackRates.length - 1) : Math.max(rateIndex - 1, 0); + const nextRate = videoPlaybackRates[rateIndex]; + + OVERLAY.showPlaybackRateOverlay(player, nextRate, isSpeedUp); + player.userActive(true); + player.playbackRate(nextRate); + } +} + +const VideoJsKeyboardShorcuts = ({ + playNext, + playPrevious, + toggleVideoTheaterMode, +}: { + playNext: any, // function + playPrevious: any, // function + toggleVideoTheaterMode: any, // function +}) => { + function toggleTheaterMode(playerRef) { + const player = playerRef.current; + if (!player) return; + // TODO: have to fix this + toggleVideoTheaterMode(); + if (player.isFullscreen()) { + player.exitFullscreen(); + } + } + + function handleKeyDown(e: KeyboardEvent, playerRef, containerRef) { + const player = playerRef.current; + const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); + if (!videoNode || !player || isUserTyping()) return; + handleSingleKeyActions(e, playerRef, containerRef); + handleShiftKeyActions(e, playerRef); + } + + function handleShiftKeyActions(e: KeyboardEvent, playerRef) { + if (e.altKey || e.ctrlKey || e.metaKey || !e.shiftKey) return; + if (e.keyCode === KEYCODES.PERIOD) changePlaybackSpeed(true, playerRef); + if (e.keyCode === KEYCODES.COMMA) changePlaybackSpeed(false, playerRef); + if (e.keyCode === KEYCODES.N) playNext(); + if (e.keyCode === KEYCODES.P) playPrevious(); + } + + // eslint-disable-next-line flowtype/no-types-missing-file-annotation + function handleSingleKeyActions(e: KeyboardEvent, playerRef, containerRef) { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + if (e.keyCode === KEYCODES.SPACEBAR || e.keyCode === KEYCODES.K) togglePlay(containerRef); + if (e.keyCode === KEYCODES.F) toggleFullscreen(playerRef); + if (e.keyCode === KEYCODES.M) toggleMute(containerRef); + if (e.keyCode === KEYCODES.UP) volumeUp(e, playerRef); + if (e.keyCode === KEYCODES.DOWN) volumeDown(e, playerRef); + if (e.keyCode === KEYCODES.T) toggleTheaterMode(playerRef); + if (e.keyCode === KEYCODES.L) seekVideo(SEEK_STEP, playerRef, containerRef); + if (e.keyCode === KEYCODES.J) seekVideo(-SEEK_STEP, playerRef, containerRef); + if (e.keyCode === KEYCODES.RIGHT) seekVideo(SEEK_STEP_5, playerRef, containerRef); + if (e.keyCode === KEYCODES.LEFT) seekVideo(-SEEK_STEP_5, playerRef, containerRef); + } + + var curried_function = function (playerRef: any, containerRef: any) { + return function curried_func(e: any) { + handleKeyDown(e, playerRef, containerRef); + }; + }; + + return { + curried_function, + }; +}; + +export default VideoJsKeyboardShorcuts; diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index 7fb0cde92..11e77a892 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react'; import Button from 'component/button'; import * as ICONS from 'constants/icons'; -import * as KEYCODES from 'constants/keycodes'; import classnames from 'classnames'; import videojs from 'video.js'; import 'video.js/dist/alt/video-js-cdn.min.css'; @@ -12,9 +11,10 @@ import './plugins/videojs-mobile-ui/plugin'; import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin'; import recsys from './plugins/videojs-recsys/plugin'; import qualityLevels from 'videojs-contrib-quality-levels'; -import isUserTyping from 'util/detect-typing'; - -const isDev = process.env.NODE_ENV !== 'production'; +import LbryVolumeBarClass from './lbry-volume-bar'; +import keyboardShorcuts from './videojs-keyboard-shortcuts'; +import events from './videojs-events'; +import functions from './videojs-functions'; export type Player = { on: (string, (any) => void) => void, @@ -61,15 +61,6 @@ type Props = { playPrevious: () => void, }; -// type VideoJSOptions = { -// controls: boolean, -// preload: string, -// playbackRates: Array, -// responsive: boolean, -// poster?: string, -// muted?: boolean, -// }; - const videoPlaybackRates = [0.25, 0.5, 0.75, 1, 1.1, 1.25, 1.5, 1.75, 2]; const IS_IOS = @@ -89,9 +80,6 @@ const VIDEO_JS_OPTIONS = { }, }; -const SEEK_STEP_5 = 5; -const SEEK_STEP = 10; // time to seek in seconds - if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) { videojs.registerPlugin('eventTracking', eventTracking); } @@ -108,47 +96,6 @@ if (!Object.keys(videojs.getPlugins()).includes('recsys')) { videojs.registerPlugin('recsys', recsys); } -// **************************************************************************** -// LbryVolumeBarClass -// **************************************************************************** - -const VIDEOJS_CONTROL_BAR_CLASS = 'ControlBar'; -const VIDEOJS_VOLUME_PANEL_CLASS = 'VolumePanel'; -const VIDEOJS_VOLUME_CONTROL_CLASS = 'VolumeControl'; -const VIDEOJS_VOLUME_BAR_CLASS = 'VolumeBar'; - -class LbryVolumeBarClass extends videojs.getComponent(VIDEOJS_VOLUME_BAR_CLASS) { - constructor(player, options = {}) { - super(player, options); - } - - static replaceExisting(player) { - try { - const volumeControl = player - .getChild(VIDEOJS_CONTROL_BAR_CLASS) - .getChild(VIDEOJS_VOLUME_PANEL_CLASS) - .getChild(VIDEOJS_VOLUME_CONTROL_CLASS); - const volumeBar = volumeControl.getChild(VIDEOJS_VOLUME_BAR_CLASS); - volumeControl.removeChild(volumeBar); - volumeControl.addChild(new LbryVolumeBarClass(player)); - } catch (error) { - // In case it slips in 'Production', the original volume bar will be used and the site should still be working - // (just not exactly the way we want). - if (isDev) throw Error('\n\nvideojs.jsx: Volume Panel hierarchy changed?\n\n' + error); - } - } - - handleMouseDown(event) { - super.handleMouseDown(event); - event.stopPropagation(); - } - - handleMouseMove(event) { - super.handleMouseMove(event); - event.stopPropagation(); - } -} - // **************************************************************************** // VideoJs // **************************************************************************** @@ -175,9 +122,18 @@ export default React.memo(function VideoJs(props: Props) { playPrevious, } = props; - const [reload, setReload] = useState('initial'); + // will later store the videojs player const playerRef = useRef(); const containerRef = useRef(); + + const tapToUnmuteRef = useRef(); + const tapToRetryRef = useRef(); + + // initiate keyboard shortcuts + const { curried_function } = keyboardShorcuts({ toggleVideoTheaterMode, playNext, playPrevious }); + + const [reload, setReload] = useState('initial'); + const videoJsOptions = { ...VIDEO_JS_OPTIONS, autoplay: autoplay, @@ -199,322 +155,17 @@ export default React.memo(function VideoJs(props: Props) { }, }; - const tapToUnmuteRef = useRef(); - const tapToRetryRef = useRef(); + const { detectFileType, createVideoPlayerDOM } = functions({ source, sourceType, videoJsOptions, isAudio }); - const TAP = { - NONE: 'NONE', - UNMUTE: 'UNMUTE', - RETRY: 'RETRY', - }; - - function showTapButton(tapButton) { - const setButtonVisibility = (theRef, newState) => { - // Use the DOM to control the state of the button to prevent re-renders. - if (theRef.current) { - const curState = theRef.current.style.visibility === 'visible'; - if (newState !== curState) { - theRef.current.style.visibility = newState ? 'visible' : 'hidden'; - } - } - }; - - switch (tapButton) { - case TAP.NONE: - setButtonVisibility(tapToUnmuteRef, false); - setButtonVisibility(tapToRetryRef, false); - break; - case TAP.UNMUTE: - setButtonVisibility(tapToUnmuteRef, true); - setButtonVisibility(tapToRetryRef, false); - break; - case TAP.RETRY: - setButtonVisibility(tapToUnmuteRef, false); - setButtonVisibility(tapToRetryRef, true); - break; - default: - if (isDev) throw new Error('showTapButton: unexpected ref'); - break; - } - } - - function unmuteAndHideHint() { - const player = playerRef.current; - if (player) { - player.muted(false); - if (player.volume() === 0) { - player.volume(1.0); - } - } - showTapButton(TAP.NONE); - } - - function retryVideoAfterFailure() { - const player = playerRef.current; - if (player) { - setReload(Date.now()); - showTapButton(TAP.NONE); - } - } - - function resolveCtrlText(e) { - // Override the player's control text. We override to: - // 1. Add keyboard shortcut to the tool-tip. - // 2. Override videojs' i18n and use our own (don't want to have 2 systems). - // - // Notes: - // - For dynamic controls (e.g. play/pause), those unfortunately need to be - // updated again at their event-listener level (that's just the way videojs - // updates the text), hence the need to listen to 'play', 'pause' and 'volumechange' - // on top of just 'loadstart'. - // - videojs changes the MuteToggle text at 'loadstart', so this was chosen - // as the listener to update static texts. - - const setLabel = (controlBar, childName, label) => { - const c = controlBar.getChild(childName); - if (c) { - c.controlText(label); - } - }; - - const player = playerRef.current; - if (player) { - const ctrlBar = player.getChild('controlBar'); - switch (e.type) { - case 'play': - setLabel(ctrlBar, 'PlayToggle', __('Pause (space)')); - break; - case 'pause': - setLabel(ctrlBar, 'PlayToggle', __('Play (space)')); - break; - case 'volumechange': - ctrlBar - .getChild('VolumePanel') - .getChild('MuteToggle') - .controlText(player.muted() || player.volume() === 0 ? __('Unmute (m)') : __('Mute (m)')); - break; - case 'fullscreenchange': - setLabel( - ctrlBar, - 'FullscreenToggle', - player.isFullscreen() ? __('Exit Fullscreen (f)') : __('Fullscreen (f)') - ); - break; - case 'loadstart': - // --- Do everything --- - setLabel(ctrlBar, 'PlaybackRateMenuButton', __('Playback Rate (<, >)')); - setLabel(ctrlBar, 'QualityButton', __('Quality')); - setLabel(ctrlBar, 'PlayNextButton', __('Play Next (SHIFT+N)')); - setLabel(ctrlBar, 'PlayPreviousButton', __('Play Previous (SHIFT+P)')); - setLabel(ctrlBar, 'TheaterModeButton', videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); - setLabel(ctrlBar, 'AutoplayNextButton', autoplaySetting ? __('Autoplay Next On') : __('Autoplay Next Off')); - - resolveCtrlText({ type: 'play' }); - resolveCtrlText({ type: 'pause' }); - resolveCtrlText({ type: 'volumechange' }); - resolveCtrlText({ type: 'fullscreenchange' }); - break; - default: - if (isDev) throw Error('Unexpected: ' + e.type); - break; - } - } - } - - function onInitialPlay() { - const player = playerRef.current; - if (player && (player.muted() || player.volume() === 0)) { - // The css starts as "hidden". We make it visible here without - // re-rendering the whole thing. - showTapButton(TAP.UNMUTE); - } else { - showTapButton(TAP.NONE); - } - } - - function onVolumeChange() { - const player = playerRef.current; - if (player && !player.muted()) { - showTapButton(TAP.NONE); - } - } - - function onError() { - const player = playerRef.current; - showTapButton(TAP.RETRY); - - // reattach initial play listener in case we recover from error successfully - // $FlowFixMe - player.one('play', onInitialPlay); - - if (player && player.loadingSpinner) { - player.loadingSpinner.hide(); - } - } - - const onEnded = React.useCallback(() => { - // not sure if this is necessary - used to be dependent on !adUrl - showTapButton(TAP.NONE); - }, []); - - function handleKeyDown(e: KeyboardEvent) { - const player = playerRef.current; - const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); - if (!videoNode || !player || isUserTyping()) return; - handleSingleKeyActions(e); - handleShiftKeyActions(e); - } - - function handleShiftKeyActions(e: KeyboardEvent) { - if (e.altKey || e.ctrlKey || e.metaKey || !e.shiftKey) return; - if (e.keyCode === KEYCODES.PERIOD) changePlaybackSpeed(true); - if (e.keyCode === KEYCODES.COMMA) changePlaybackSpeed(false); - if (e.keyCode === KEYCODES.N) playNext(); - if (e.keyCode === KEYCODES.P) playPrevious(); - } - - function handleSingleKeyActions(e: KeyboardEvent) { - if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; - if (e.keyCode === KEYCODES.SPACEBAR || e.keyCode === KEYCODES.K) togglePlay(); - if (e.keyCode === KEYCODES.F) toggleFullscreen(); - if (e.keyCode === KEYCODES.M) toggleMute(); - if (e.keyCode === KEYCODES.UP) volumeUp(); - if (e.keyCode === KEYCODES.DOWN) volumeDown(); - if (e.keyCode === KEYCODES.T) toggleTheaterMode(); - if (e.keyCode === KEYCODES.L) seekVideo(SEEK_STEP); - if (e.keyCode === KEYCODES.J) seekVideo(-SEEK_STEP); - if (e.keyCode === KEYCODES.RIGHT) seekVideo(SEEK_STEP_5); - if (e.keyCode === KEYCODES.LEFT) seekVideo(-SEEK_STEP_5); - } - - function seekVideo(stepSize: number) { - const player = playerRef.current; - const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); - if (!videoNode || !player) return; - const duration = videoNode.duration; - const currentTime = videoNode.currentTime; - const newDuration = currentTime + stepSize; - if (newDuration < 0) { - videoNode.currentTime = 0; - } else if (newDuration > duration) { - videoNode.currentTime = duration; - } else { - videoNode.currentTime = newDuration; - } - OVERLAY.showSeekedOverlay(player, Math.abs(stepSize), stepSize > 0); - player.userActive(true); - } - - function changePlaybackSpeed(shouldSpeedUp: boolean) { - const player = playerRef.current; - if (!player) return; - const isSpeedUp = shouldSpeedUp; - const rate = player.playbackRate(); - let rateIndex = videoPlaybackRates.findIndex((x) => x === rate); - if (rateIndex >= 0) { - rateIndex = isSpeedUp ? Math.min(rateIndex + 1, videoPlaybackRates.length - 1) : Math.max(rateIndex - 1, 0); - const nextRate = videoPlaybackRates[rateIndex]; - - OVERLAY.showPlaybackRateOverlay(player, nextRate, isSpeedUp); - player.userActive(true); - player.playbackRate(nextRate); - } - } - - function toggleFullscreen() { - const player = playerRef.current; - if (!player) return; - if (!player.isFullscreen()) { - player.requestFullscreen(); - } else { - player.exitFullscreen(); - } - } - - function toggleTheaterMode() { - const player = playerRef.current; - if (!player) return; - toggleVideoTheaterMode(); - if (player.isFullscreen()) { - player.exitFullscreen(); - } - } - - function toggleMute() { - const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); - if (!videoNode) return; - videoNode.muted = !videoNode.muted; - } - - function togglePlay() { - const videoNode = containerRef.current && containerRef.current.querySelector('video, audio'); - if (!videoNode) return; - videoNode.paused ? videoNode.play() : videoNode.pause(); - } - - function volumeUp() { - const player = playerRef.current; - if (!player) return; - player.volume(player.volume() + 0.05); - OVERLAY.showVolumeverlay(player, Math.round(player.volume() * 100)); - player.userActive(true); - } - - function volumeDown() { - const player = playerRef.current; - if (!player) return; - player.volume(player.volume() - 0.05); - OVERLAY.showVolumeverlay(player, Math.round(player.volume() * 100)); - player.userActive(true); - } - - // Create the video DOM element and wrapper - function createVideoPlayerDOM(container) { - if (!container) return; - - // This seems like a poor way to generate the DOM for video.js - const wrapper = document.createElement('div'); - wrapper.setAttribute('data-vjs-player', 'true'); - const el = document.createElement(isAudio ? 'audio' : 'video'); - el.className = 'video-js vjs-big-play-centered '; - wrapper.appendChild(el); - - container.appendChild(wrapper); - - return el; - } - - function detectFileType() { - return new Promise(async (res, rej) => { - try { - const response = await fetch(source, { method: 'HEAD', cache: 'no-store' }); - - // Temp variables to hold results - let finalType = sourceType; - let finalSource = source; - - // override type if we receive an .m3u8 (transcoded mp4) - // do we need to check if explicitly redirected - // or is checking extension only a safer method - if (response && response.redirected && response.url && response.url.endsWith('m3u8')) { - finalType = 'application/x-mpegURL'; - finalSource = response.url; - } - - // Modify video source in options - videoJsOptions.sources = [ - { - src: finalSource, - type: finalType, - }, - ]; - - return res(videoJsOptions); - } catch (error) { - return rej(error); - } - }); - } + const { unmuteAndHideHint, retryVideoAfterFailure, initializeEvents } = events({ + tapToUnmuteRef, + tapToRetryRef, + setReload, + videoTheaterMode, + playerRef, + autoplaySetting, + replay, + }); // Initialize video.js function initializeVideoPlayer(el) { @@ -526,16 +177,7 @@ export default React.memo(function VideoJs(props: Props) { // this seems like a weird thing to have to check for here if (!player) return; - // Add various event listeners to player - player.one('play', onInitialPlay); - player.on('play', resolveCtrlText); - player.on('pause', resolveCtrlText); - player.on('loadstart', resolveCtrlText); - player.on('fullscreenchange', resolveCtrlText); - player.on('volumechange', resolveCtrlText); - player.on('volumechange', onVolumeChange); - player.on('error', onError); - player.on('ended', onEnded); + initializeEvents(); // Replace volume bar with custom LBRY volume bar LbryVolumeBarClass.replaceExisting(player); @@ -560,7 +202,6 @@ export default React.memo(function VideoJs(props: Props) { } // set playsinline for mobile - // TODO: make this better player.children_[0].setAttribute('playsinline', ''); // I think this is a callback function @@ -568,45 +209,14 @@ export default React.memo(function VideoJs(props: Props) { onPlayerReady(player, videoNode); }); + // fixes #3498 (https://github.com/lbryio/lbry-desktop/issues/3498) + // summary: on firefox the focus would stick to the fullscreen button which caused buggy behavior with spacebar vjs.on('fullscreenchange', () => document.activeElement && document.activeElement.blur()); return vjs; } - useEffect(() => { - const player = playerRef.current; - if (replay && player) { - player.play(); - } - }, [replay]); - - useEffect(() => { - const player = playerRef.current; - if (player) { - const controlBar = player.getChild('controlBar'); - controlBar - .getChild('TheaterModeButton') - .controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); - } - }, [videoTheaterMode]); - - useEffect(() => { - const player = playerRef.current; - if (player) { - const touchOverlay = player.getChild('TouchOverlay'); - const controlBar = player.getChild('controlBar') || touchOverlay.getChild('controlBar'); - const autoplayButton = controlBar.getChild('AutoplayNextButton'); - - if (autoplayButton) { - const title = autoplaySetting ? __('Autoplay Next On') : __('Autoplay Next Off'); - - autoplayButton.controlText(title); - autoplayButton.setAttribute('aria-label', title); - autoplayButton.setAttribute('aria-checked', autoplaySetting); - } - } - }, [autoplaySetting]); - + /** instantiate videoJS and dispose of it when done with code **/ // This lifecycle hook is only called once (on mount), or when `isAudio` or `source` changes. useEffect(() => { const vjsElement = createVideoPlayerDOM(containerRef.current); @@ -622,13 +232,12 @@ export default React.memo(function VideoJs(props: Props) { // Set reference in component state playerRef.current = vjsPlayer; - // Add event listener for keyboard shortcuts - window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', curried_function(playerRef, containerRef)); }); // Cleanup return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', curried_function); const player = playerRef.current; if (player) {