diff --git a/ui/component/viewers/videoViewer/internal/autoplay-next.js b/ui/component/viewers/videoViewer/internal/autoplay-next.js deleted file mode 100644 index 63d8ceb0b..000000000 --- a/ui/component/viewers/videoViewer/internal/autoplay-next.js +++ /dev/null @@ -1,33 +0,0 @@ -// @flow -import type { Player } from './videojs'; -import videojs from 'video.js'; - -class AutoplayNextButton extends videojs.getComponent('Button') { - constructor(player, options = {}, autoplayNext) { - super(player, options, autoplayNext); - const title = autoplayNext ? 'Autoplay Next On' : 'Autoplay Next Off'; - - this.controlText(title); - this.setAttribute('aria-label', title); - this.addClass('vjs-button--autoplay-next'); - this.setAttribute('aria-checked', autoplayNext); - } -} - -export function addAutoplayNextButton(player: Player, toggleAutoplayNext: () => void, autoplayNext: boolean) { - const controlBar = player.getChild('controlBar'); - - const autoplayButton = new AutoplayNextButton( - player, - { - name: 'AutoplayNextButton', - text: 'Autoplay Next', - clickHandler: () => { - toggleAutoplayNext(); - }, - }, - autoplayNext - ); - - controlBar.addChild(autoplayButton); -} diff --git a/ui/component/viewers/videoViewer/internal/effects/use-autoplay-next.js b/ui/component/viewers/videoViewer/internal/effects/use-autoplay-next.js new file mode 100644 index 000000000..4f11bda45 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/effects/use-autoplay-next.js @@ -0,0 +1,84 @@ +/** + * Videojs "Autoplay Next" button. + * + * --- How to use --- + * Apply `useAutoplayNext` in your React component. It registers an effect that + * listens to the given Redux state, and returns a callback for you to mount the + * custom videojs component. + * + * --- Notes --- + * Usually, custom videojs components can just listen to videojs events, query + * states from `player` (e.g. player.paused()) and update accordingly. But since + * the state comes from Redux, there will be a need to listen and pass the info + * to videojs somehow. + * + * Instead of going through an 'effect->css->videojs' trip, we'll just listen to + * the Redux state through a normal effect to update the component. + * + * This file aims to encapsulate both the React and Videojs side of things + * through a single `useAutoplayNext` call. + */ + +// @flow +import React from 'react'; +import videojs from 'video.js'; +import type { Player } from '../videojs'; + +// **************************************************************************** +// AutoplayNextButton +// **************************************************************************** + +class AutoplayNextButton extends videojs.getComponent('Button') { + constructor(player, options = {}, autoplayNext) { + super(player, options, autoplayNext); + const title = __(autoplayNext ? 'Autoplay Next On' : 'Autoplay Next Off'); + + this.controlText(title); + this.addClass('vjs-button--autoplay-next'); + this.setAttribute('aria-label', title); + this.setAttribute('aria-checked', autoplayNext); + } +} + +function addAutoplayNextButton(player: Player, toggleAutoplayNext: () => void, autoplayNext: boolean) { + const controlBar = player.getChild('controlBar'); + + const autoplayButton = new AutoplayNextButton( + player, + { + name: 'AutoplayNextButton', + text: 'Autoplay Next', + clickHandler: () => { + toggleAutoplayNext(); + }, + }, + autoplayNext + ); + + controlBar.addChild(autoplayButton); +} + +// **************************************************************************** +// useAutoplayNext +// **************************************************************************** + +export default function useAutoplayNext(playerRef: any, autoplayNext: boolean) { + React.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 = autoplayNext ? __('Autoplay Next On') : __('Autoplay Next Off'); + + autoplayButton.controlText(title); + autoplayButton.setAttribute('aria-label', title); + autoplayButton.setAttribute('aria-checked', autoplayNext); + } + } + }, [autoplayNext]); + + return addAutoplayNextButton; +} diff --git a/ui/component/viewers/videoViewer/internal/effects/use-theater-mode.js b/ui/component/viewers/videoViewer/internal/effects/use-theater-mode.js new file mode 100644 index 000000000..d9fafcfbe --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/effects/use-theater-mode.js @@ -0,0 +1,70 @@ +/** + * Videojs "Theater Mode" button. + * + * --- How to use --- + * Apply `useTheaterMode` in your React component. It registers an effect that + * listens to the given Redux state, and returns a callback for you to mount the + * custom videojs component. + * + * --- Notes --- + * Usually, custom videojs components can just listen to videojs events, query + * states from `player` (e.g. player.paused()) and update accordingly. But since + * the state comes from Redux, there will be a need to listen and pass the info + * to videojs somehow. + * + * Instead of going through an 'effect->css->videojs' trip, we'll just listen to + * the Redux state through a normal effect to update the component. + * + * This file aims to encapsulate both the React and Videojs side of things + * through a single `useAutoplayNext` call. + */ + +// @flow +import React from 'react'; +import videojs from 'video.js'; +import type { Player } from '../videojs'; + +// **************************************************************************** +// TheaterModeButton +// **************************************************************************** + +class TheaterModeButton extends videojs.getComponent('Button') { + constructor(player, options = {}) { + super(player, options); + this.addClass('vjs-button--theater-mode'); + this.controlText(__('Theater Mode (t)')); + } +} + +function addTheaterModeButton(player: Player, toggleVideoTheaterMode: () => void) { + const controlBar = player.getChild('controlBar'); + + const theaterMode = new TheaterModeButton(player, { + name: 'TheaterModeButton', + text: 'Theater mode', + clickHandler: () => { + toggleVideoTheaterMode(); + }, + }); + + controlBar.addChild(theaterMode); +} + +// **************************************************************************** +// useAutoplayNext +// **************************************************************************** + +export default function useTheaterMode(playerRef: any, videoTheaterMode: boolean) { + React.useEffect(() => { + const player = playerRef.current; + if (player) { + const controlBar = player.getChild('controlBar'); + const theaterButton = controlBar.getChild('TheaterModeButton'); + if (theaterButton) { + theaterButton.controlText(videoTheaterMode ? __('Default Mode (t)') : __('Theater Mode (t)')); + } + } + }, [videoTheaterMode]); + + return addTheaterModeButton; +} diff --git a/ui/component/viewers/videoViewer/internal/plugins/videojs-i18n/plugin.js b/ui/component/viewers/videoViewer/internal/plugins/videojs-i18n/plugin.js new file mode 100644 index 000000000..2ca795992 --- /dev/null +++ b/ui/component/viewers/videoViewer/internal/plugins/videojs-i18n/plugin.js @@ -0,0 +1,103 @@ +/** + * Although videojs has its own i18n system, it is not trivial to pass our + * translation data to it. Instead of maintaining two sets of localization + * systems, we'll just ignore the videojs one and update the strings ourselves + * through events. + * + * We use events because that's the only way to update components with dynamic + * text like "Play/Pause" in videojs. We can't just define the set of texts at + * initialization, unless we use custom components to do that. + * + * For the case of 'MuteToggle', videojs changes the text at 'loadstart', so + * this event was chosen as the "lowest common denominator" to update the other + * static-text components. + * + * --- Notes --- + * (1) 'AutoplayNextButton' and 'TheaterModeButton' handles i18n (among other + * things) on its own. + */ + +// @flow +import videojs from 'video.js'; +import type { Player } from '../../videojs'; + +const VERSION = '1.0.0'; +const defaultOptions = {}; + +function setLabel(controlBar, childName, label) { + try { + controlBar.getChild(childName).controlText(label); + } catch (e) { + // We want to be notified, at least on dev, over any structural changes, + // so don't check for null children and let the error surface. + // @if process.env.NODE_ENV!='production' + console.error(e); + // @endif + } +} + +function resolveCtrlText(e, player) { + 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)')); + ctrlBar + .getChild('VolumePanel') + .getChild('MuteToggle') + .controlText(player.muted() || player.volume() === 0 ? __('Unmute (m)') : __('Mute (m)')); + + resolveCtrlText({ type: 'play' }); + resolveCtrlText({ type: 'pause' }); + resolveCtrlText({ type: 'volumechange' }); + resolveCtrlText({ type: 'fullscreenchange' }); + break; + default: + // @if process.env.NODE_ENV!='production' + throw Error('Unexpected: ' + e.type); + // @endif + } + } +} + +function onPlayerReady(player: Player, options: {}) { + const h = (e) => resolveCtrlText(e, player); + + player.on('play', h); + player.on('pause', h); + player.on('loadstart', h); + player.on('fullscreenchange', h); + player.on('volumechange', h); +} + +/** + * Odysee custom i18n plugin. + * @param options + */ +function i18n(options: {}) { + this.ready(() => onPlayerReady(this, videojs.mergeOptions(defaultOptions, options))); +} + +videojs.registerPlugin('i18n', i18n); +i18n.VERSION = VERSION; + +export default i18n; diff --git a/ui/component/viewers/videoViewer/internal/theater-mode.js b/ui/component/viewers/videoViewer/internal/theater-mode.js deleted file mode 100644 index 8eeaa9201..000000000 --- a/ui/component/viewers/videoViewer/internal/theater-mode.js +++ /dev/null @@ -1,25 +0,0 @@ -// @flow -import type { Player } from './videojs'; -import videojs from 'video.js'; - -class TheaterModeButton extends videojs.getComponent('Button') { - constructor(player, options = {}) { - super(player, options); - this.addClass('vjs-button--theater-mode'); - this.controlText('Theater Mode'); - } -} - -export function addTheaterModeButton(player: Player, toggleVideoTheaterMode: () => void) { - const controlBar = player.getChild('controlBar'); - - const theaterMode = new TheaterModeButton(player, { - name: 'TheaterModeButton', - text: 'Theater mode', - clickHandler: () => { - toggleVideoTheaterMode(); - }, - }); - - controlBar.addChild(theaterMode); -} diff --git a/ui/component/viewers/videoViewer/internal/videojs-events.jsx b/ui/component/viewers/videoViewer/internal/videojs-events.jsx index eb0f04a89..9c7d13e35 100644 --- a/ui/component/viewers/videoViewer/internal/videojs-events.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs-events.jsx @@ -10,20 +10,11 @@ const TAP = { RETRY: 'RETRY', }; -const setLabel = (controlBar, childName, label) => { - const c = controlBar.getChild(childName); - if (c) { - c.controlText(label); - } -}; - const VideoJsEvents = ({ tapToUnmuteRef, tapToRetryRef, setReload, - videoTheaterMode, playerRef, - autoplaySetting, replay, claimId, userId, @@ -38,9 +29,7 @@ const VideoJsEvents = ({ tapToUnmuteRef: any, // DOM element tapToRetryRef: any, // DOM element setReload: any, // react hook - videoTheaterMode: any, // dispatch function playerRef: any, // DOM element - autoplaySetting: boolean, replay: boolean, claimId: ?string, userId: ?number, @@ -96,63 +85,6 @@ const VideoJsEvents = ({ }); } - // 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; @@ -194,17 +126,6 @@ const VideoJsEvents = ({ // } // }, [adUrl]); - useEffect(() => { - const player = playerRef.current; - if (player) { - const controlBar = player.getChild('controlBar'); - const theaterButton = controlBar.getChild('TheaterModeButton'); - if (theaterButton) { - theaterButton.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; @@ -255,23 +176,6 @@ const VideoJsEvents = ({ } } - 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) { @@ -281,13 +185,8 @@ const VideoJsEvents = ({ 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); // custom tracking plugin, event used for watchman data, and marking view/getting rewards @@ -316,7 +215,6 @@ const VideoJsEvents = ({ setTimeout(() => { // Do not jump ahead if user has paused the player if (player.paused()) return; - player.liveTracker.seekToLiveEdge(); }, 5 * 1000); }); diff --git a/ui/component/viewers/videoViewer/internal/videojs.jsx b/ui/component/viewers/videoViewer/internal/videojs.jsx index cd082c5a7..9a5ce1e03 100644 --- a/ui/component/viewers/videoViewer/internal/videojs.jsx +++ b/ui/component/viewers/videoViewer/internal/videojs.jsx @@ -20,6 +20,7 @@ import Chromecast from './chromecast'; import playerjs from 'player.js'; import qualityLevels from 'videojs-contrib-quality-levels'; import React, { useEffect, useRef, useState } from 'react'; +import i18n from './plugins/videojs-i18n/plugin'; import recsys from './plugins/videojs-recsys/plugin'; // import runAds from './ads'; import videojs from 'video.js'; @@ -37,12 +38,16 @@ export type Player = { claimSrcOriginal: ?{ src: string, type: string }, claimSrcVhs: ?{ src: string, type: string }, isLivestream?: boolean, - // -- original -- + // -- plugins --- + mobileUi: (any) => void, + chromecast: (any) => void, + overlay: (any) => void, + hlsQualitySelector: ?any, + i18n: (any) => void, + // -- base videojs -- controlBar: { addChild: (string, any) => void }, loadingSpinner: any, autoplay: (any) => boolean, - chromecast: (any) => void, - hlsQualitySelector: ?any, tech: (?boolean) => { vhs: ?any }, currentTime: (?number) => number, dispose: () => void, @@ -52,11 +57,9 @@ export type Player = { exitFullscreen: () => boolean, getChild: (string) => any, isFullscreen: () => boolean, - mobileUi: (any) => void, muted: (?boolean) => boolean, on: (string, (any) => void) => void, one: (string, (any) => void) => void, - overlay: (any) => void, play: () => Promise, playbackRate: (?number) => number, readyState: () => number, @@ -71,7 +74,6 @@ type Props = { adUrl: ?string, allowPreRoll: ?boolean, autoplay: boolean, - autoplaySetting: boolean, claimId: ?string, title: ?string, channelName: ?string, @@ -85,7 +87,6 @@ type Props = { sourceType: string, startMuted: boolean, userId: ?number, - videoTheaterMode: boolean, defaultQuality: ?string, onPlayerReady: (Player, any) => void, playNext: () => void, @@ -104,21 +105,19 @@ type Props = { const IS_IOS = platform.isIOS(); -if (!Object.keys(videojs.getPlugins()).includes('eventTracking')) { - videojs.registerPlugin('eventTracking', eventTracking); -} +const PLUGIN_MAP = { + eventTracking: eventTracking, + hlsQualitySelector: hlsQualitySelector, + qualityLevels: qualityLevels, + recsys: recsys, + i18n: i18n, +}; -if (!Object.keys(videojs.getPlugins()).includes('hlsQualitySelector')) { - videojs.registerPlugin('hlsQualitySelector', hlsQualitySelector); -} - -if (!Object.keys(videojs.getPlugins()).includes('qualityLevels')) { - videojs.registerPlugin('qualityLevels', qualityLevels); -} - -if (!Object.keys(videojs.getPlugins()).includes('recsys')) { - videojs.registerPlugin('recsys', recsys); -} +Object.entries(PLUGIN_MAP).forEach(([pluginName, plugin]) => { + if (!Object.keys(videojs.getPlugins()).includes(pluginName)) { + videojs.registerPlugin(pluginName, plugin); + } +}); // **************************************************************************** // VideoJs @@ -132,7 +131,6 @@ export default React.memo(function VideoJs(props: Props) { // adUrl, // TODO: this ad functionality isn't used, can be pulled out // allowPreRoll, autoplay, - autoplaySetting, claimId, title, channelName, @@ -146,7 +144,6 @@ export default React.memo(function VideoJs(props: Props) { sourceType, startMuted, userId, - videoTheaterMode, defaultQuality, onPlayerReady, playNext, @@ -199,9 +196,7 @@ export default React.memo(function VideoJs(props: Props) { tapToUnmuteRef, tapToRetryRef, setReload, - videoTheaterMode, playerRef, - autoplaySetting, replay, claimValues, userId, @@ -277,6 +272,8 @@ export default React.memo(function VideoJs(props: Props) { // Initialize mobile UI. player.mobileUi(); + player.i18n(); + if (!embedded) { window.player.bigPlayButton && window.player.bigPlayButton.hide(); } else { diff --git a/ui/component/viewers/videoViewer/view.jsx b/ui/component/viewers/videoViewer/view.jsx index 2b9a82fb8..425f13c32 100644 --- a/ui/component/viewers/videoViewer/view.jsx +++ b/ui/component/viewers/videoViewer/view.jsx @@ -15,8 +15,8 @@ import AutoplayCountdown from 'component/autoplayCountdown'; import usePrevious from 'effects/use-previous'; import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded'; import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle'; -import { addTheaterModeButton } from './internal/theater-mode'; -import { addAutoplayNextButton } from './internal/autoplay-next'; +import useAutoplayNext from './internal/effects/use-autoplay-next'; +import useTheaterMode from './internal/effects/use-theater-mode'; import { addPlayNextButton } from './internal/play-next'; import { addPlayPreviousButton } from './internal/play-previous'; import { useGetAds } from 'effects/use-get-ads'; @@ -154,6 +154,9 @@ function VideoViewer(props: Props) { const isFirstRender = React.useRef(true); const playerRef = React.useRef(null); + const addAutoplayNextButton = useAutoplayNext(playerRef, autoplayNext); + const addTheaterModeButton = useTheaterMode(playerRef, videoTheaterMode); + React.useEffect(() => { if (isPlaying) { doSetContentHistoryItem(claim.permanent_url); @@ -503,7 +506,6 @@ function VideoViewer(props: Props) { startMuted={autoplayIfEmbedded} toggleVideoTheaterMode={toggleVideoTheaterMode} autoplay={!embedded || autoplayIfEmbedded} - autoplaySetting={localAutoplayNext} claimId={claimId} title={claim && ((claim.value && claim.value.title) || claim.name)} channelName={channelName} @@ -512,7 +514,6 @@ function VideoViewer(props: Props) { internalFeatureEnabled={internalFeature} shareTelemetry={shareTelemetry} replay={replay} - videoTheaterMode={videoTheaterMode} playNext={doPlayNext} playPrevious={doPlayPrevious} embedded={embedded}