videojs: refactor i18n (breaking up 'events' file)

## Issue
I couldn't find where the i18n went because the "events" refactor is just as confusing as the original -- unrelated things are still lumped together in a file.

Also, factoring based on events isn't useful -- it is features that drive what events are needed, not the other way around. This forces features to register events here, and do other things elsewhere? It will be more intuitive to have a one-file-per-feature structure.

## Change
Use existing frameworks to encapsulate things to manageable units/features:
(1) the React useEffect files (can be used isolate out React features like 'tap-to-mute' handling).
(2) the videojs plugins framework.
This commit is contained in:
infinite-persistence 2022-05-17 16:17:04 +08:00
commit 97be848554
No known key found for this signature in database
GPG key ID: B9C3252EDC3D0AA0
8 changed files with 285 additions and 190 deletions

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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);
});

View file

@ -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<any>,
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<Props>(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<Props>(function VideoJs(props: Props) {
sourceType,
startMuted,
userId,
videoTheaterMode,
defaultQuality,
onPlayerReady,
playNext,
@ -199,9 +196,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
tapToUnmuteRef,
tapToRetryRef,
setReload,
videoTheaterMode,
playerRef,
autoplaySetting,
replay,
claimValues,
userId,
@ -277,6 +272,8 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// Initialize mobile UI.
player.mobileUi();
player.i18n();
if (!embedded) {
window.player.bigPlayButton && window.player.bigPlayButton.hide();
} else {

View file

@ -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}