pre-roll ads
This commit is contained in:
parent
ae980bc142
commit
e08b71774c
11 changed files with 328 additions and 99 deletions
|
@ -31,6 +31,7 @@ ENABLE_COMMENT_REACTIONS=true
|
|||
ENABLE_FILE_REACTIONS=false
|
||||
ENABLE_CREATOR_REACTIONS=false
|
||||
ENABLE_NO_SOURCE_CLAIMS=false
|
||||
ENABLE_PREROLL_ADS=false
|
||||
CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS=4
|
||||
CHANNEL_STAKED_LEVEL_LIVESTREAM=5
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ const config = {
|
|||
ENABLE_FILE_REACTIONS: process.env.ENABLE_FILE_REACTIONS === 'true',
|
||||
ENABLE_CREATOR_REACTIONS: process.env.ENABLE_CREATOR_REACTIONS === 'true',
|
||||
ENABLE_NO_SOURCE_CLAIMS: process.env.ENABLE_NO_SOURCE_CLAIMS === 'true',
|
||||
ENABLE_PREROLL_ADS: process.env.ENABLE_PREROLL_ADS === 'true',
|
||||
CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS: process.env.CHANNEL_STAKED_LEVEL_VIDEO_COMMENTS,
|
||||
CHANNEL_STAKED_LEVEL_LIVESTREAM: process.env.CHANNEL_STAKED_LEVEL_LIVESTREAM,
|
||||
SIMPLE_SITE: process.env.SIMPLE_SITE === 'true',
|
||||
|
|
|
@ -203,6 +203,7 @@
|
|||
"tree-kill": "^1.1.0",
|
||||
"unist-util-visit": "^2.0.3",
|
||||
"uuid": "^8.3.2",
|
||||
"vast-client": "^3.1.1",
|
||||
"video.js": "^7.10.1",
|
||||
"videojs-contrib-quality-levels": "^2.0.9",
|
||||
"videojs-event-tracking": "^1.0.1",
|
||||
|
|
|
@ -51,6 +51,9 @@ type Analytics = {
|
|||
readyState: number,
|
||||
}
|
||||
) => void,
|
||||
adsFetchedEvent: () => void,
|
||||
adsReceivedEvent: (any) => void,
|
||||
adsErrorEvent: (any) => void,
|
||||
emailProvidedEvent: () => void,
|
||||
emailVerifiedEvent: () => void,
|
||||
rewardEligibleEvent: () => void,
|
||||
|
@ -231,6 +234,15 @@ const analytics: Analytics = {
|
|||
});
|
||||
}
|
||||
},
|
||||
adsFetchedEvent: () => {
|
||||
sendMatomoEvent('Media', 'AdsFetched');
|
||||
},
|
||||
adsReceivedEvent: (response) => {
|
||||
sendMatomoEvent('Media', 'AdsReceived', JSON.stringify(response));
|
||||
},
|
||||
adsErrorEvent: (response) => {
|
||||
sendMatomoEvent('Media', 'AdsError', JSON.stringify(response));
|
||||
},
|
||||
playerLoadedEvent: (embedded) => {
|
||||
sendMatomoEvent('Player', 'Loaded', embedded ? 'embedded' : 'onsite');
|
||||
},
|
||||
|
|
|
@ -7,8 +7,9 @@ import { makeSelectContentPositionForUri } from 'redux/selectors/content';
|
|||
import VideoViewer from './view';
|
||||
import { withRouter } from 'react-router';
|
||||
import { doClaimEligiblePurchaseRewards } from 'redux/actions/rewards';
|
||||
import { makeSelectClientSetting } from 'redux/selectors/settings';
|
||||
import { makeSelectClientSetting, selectHomepageData } from 'redux/selectors/settings';
|
||||
import { toggleVideoTheaterMode, doSetClientSetting } from 'redux/actions/settings';
|
||||
import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
||||
|
||||
const select = (state, props) => {
|
||||
const { search } = props.location;
|
||||
|
@ -26,19 +27,21 @@ const select = (state, props) => {
|
|||
hasFileInfo: Boolean(makeSelectFileInfoForUri(props.uri)(state)),
|
||||
thumbnail: makeSelectThumbnailForUri(props.uri)(state),
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
homepageData: selectHomepageData(state),
|
||||
authenticated: selectUserVerifiedEmail(state),
|
||||
};
|
||||
};
|
||||
|
||||
const perform = dispatch => ({
|
||||
changeVolume: volume => dispatch(doChangeVolume(volume)),
|
||||
const perform = (dispatch) => ({
|
||||
changeVolume: (volume) => dispatch(doChangeVolume(volume)),
|
||||
savePosition: (uri, position) => dispatch(savePosition(uri, position)),
|
||||
clearPosition: uri => dispatch(clearPosition(uri)),
|
||||
changeMute: muted => dispatch(doChangeMute(muted)),
|
||||
clearPosition: (uri) => dispatch(clearPosition(uri)),
|
||||
changeMute: (muted) => dispatch(doChangeMute(muted)),
|
||||
doAnalyticsView: (uri, timeToStart) => dispatch(doAnalyticsView(uri, timeToStart)),
|
||||
doAnalyticsBuffer: (uri, bufferData) => dispatch(doAnalyticsBuffer(uri, bufferData)),
|
||||
claimRewards: () => dispatch(doClaimEligiblePurchaseRewards()),
|
||||
toggleVideoTheaterMode: () => dispatch(toggleVideoTheaterMode()),
|
||||
setVideoPlaybackRate: rate => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)),
|
||||
setVideoPlaybackRate: (rate) => dispatch(doSetClientSetting(SETTINGS.VIDEO_PLAYBACK_RATE, rate)),
|
||||
});
|
||||
|
||||
export default withRouter(connect(select, perform)(VideoViewer));
|
||||
|
|
|
@ -49,6 +49,7 @@ type Props = {
|
|||
startMuted: boolean,
|
||||
autoplay: boolean,
|
||||
toggleVideoTheaterMode: () => void,
|
||||
adUrl: ?string,
|
||||
};
|
||||
|
||||
type VideoJSOptions = {
|
||||
|
@ -171,7 +172,17 @@ class LbryVolumeBarClass extends videojs.getComponent(VIDEOJS_VOLUME_BAR_CLASS)
|
|||
properties for this component should be kept to ONLY those that if changed should REQUIRE an entirely new videojs element
|
||||
*/
|
||||
export default React.memo<Props>(function VideoJs(props: Props) {
|
||||
const { autoplay, startMuted, source, sourceType, poster, isAudio, onPlayerReady, toggleVideoTheaterMode } = props;
|
||||
const {
|
||||
autoplay,
|
||||
startMuted,
|
||||
source,
|
||||
sourceType,
|
||||
poster,
|
||||
isAudio,
|
||||
onPlayerReady,
|
||||
toggleVideoTheaterMode,
|
||||
adUrl,
|
||||
} = props;
|
||||
|
||||
const [reload, setReload] = useState('initial');
|
||||
|
||||
|
@ -333,9 +344,11 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
const onEnded = React.useCallback(() => {
|
||||
if (!adUrl) {
|
||||
showTapButton(TAP.NONE);
|
||||
}
|
||||
}, [adUrl]);
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const player = playerRef.current;
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
// @flow
|
||||
import { ENABLE_PREROLL_ADS } from 'config';
|
||||
import * as PAGES from 'constants/pages';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import React, { useEffect, useState, useContext, useCallback } from 'react';
|
||||
import { stopContextMenu } from 'util/context-menu';
|
||||
import type { Player } from './internal/videojs';
|
||||
|
@ -13,6 +16,10 @@ import FileViewerEmbeddedEnded from 'web/component/fileViewerEmbeddedEnded';
|
|||
import FileViewerEmbeddedTitle from 'component/fileViewerEmbeddedTitle';
|
||||
import LoadingScreen from 'component/common/loading-screen';
|
||||
import { addTheaterModeButton } from './internal/theater-mode';
|
||||
import { useGetAds } from 'effects/use-get-ads';
|
||||
import Button from 'component/button';
|
||||
import I18nMessage from 'component/i18nMessage';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
const PLAY_TIMEOUT_ERROR = 'play_timeout_error';
|
||||
const PLAY_TIMEOUT_LIMIT = 2000;
|
||||
|
@ -39,6 +46,16 @@ type Props = {
|
|||
clearPosition: (string) => void,
|
||||
toggleVideoTheaterMode: () => void,
|
||||
setVideoPlaybackRate: (number) => void,
|
||||
authenticated: boolean,
|
||||
homepageData: {
|
||||
PRIMARY_CONTENT_CHANNEL_IDS?: Array<string>,
|
||||
ENLIGHTENMENT_CHANNEL_IDS?: Array<string>,
|
||||
GAMING_CHANNEL_IDS?: Array<string>,
|
||||
SCIENCE_CHANNEL_IDS?: Array<string>,
|
||||
TECHNOLOGY_CHANNEL_IDS?: Array<string>,
|
||||
COMMUNITY_CHANNEL_IDS?: Array<string>,
|
||||
FINCANCE_CHANNEL_IDS?: Array<string>,
|
||||
},
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -69,22 +86,47 @@ function VideoViewer(props: Props) {
|
|||
desktopPlayStartTime,
|
||||
toggleVideoTheaterMode,
|
||||
setVideoPlaybackRate,
|
||||
homepageData,
|
||||
authenticated,
|
||||
} = props;
|
||||
const {
|
||||
PRIMARY_CONTENT_CHANNEL_IDS = [],
|
||||
ENLIGHTENMENT_CHANNEL_IDS = [],
|
||||
GAMING_CHANNEL_IDS = [],
|
||||
SCIENCE_CHANNEL_IDS = [],
|
||||
TECHNOLOGY_CHANNEL_IDS = [],
|
||||
COMMUNITY_CHANNEL_IDS = [],
|
||||
FINCANCE_CHANNEL_IDS = [],
|
||||
} = homepageData;
|
||||
const adApprovedChannelIds = [
|
||||
...PRIMARY_CONTENT_CHANNEL_IDS,
|
||||
...ENLIGHTENMENT_CHANNEL_IDS,
|
||||
...GAMING_CHANNEL_IDS,
|
||||
...SCIENCE_CHANNEL_IDS,
|
||||
...TECHNOLOGY_CHANNEL_IDS,
|
||||
...COMMUNITY_CHANNEL_IDS,
|
||||
...FINCANCE_CHANNEL_IDS,
|
||||
];
|
||||
const claimId = claim && claim.claim_id;
|
||||
const channelClaimId = claim && claim.signing_channel && claim.signing_channel.claim_id;
|
||||
const isAudio = contentType.includes('audio');
|
||||
const forcePlayer = FORCE_CONTENT_TYPE_PLAYER.includes(contentType);
|
||||
const {
|
||||
location: { pathname },
|
||||
} = useHistory();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
|
||||
const [isEndededEmbed, setIsEndededEmbed] = useState(false);
|
||||
const vjsCallbackDataRef: any = React.useRef();
|
||||
|
||||
const previousUri = usePrevious(uri);
|
||||
const embedded = useContext(EmbedContext);
|
||||
const approvedVideo = Boolean(channelClaimId) && adApprovedChannelIds.includes(channelClaimId);
|
||||
const adsEnabled = ENABLE_PREROLL_ADS && !authenticated && !embedded && approvedVideo;
|
||||
const [adUrl, setAdUrl, isFetchingAd] = useGetAds(adsEnabled);
|
||||
/* isLoading was designed to show loading screen on first play press, rather than completely black screen, but
|
||||
breaks because some browsers (e.g. Firefox) block autoplay but leave the player.play Promise pending */
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const previousUri = usePrevious(uri);
|
||||
const embedded = useContext(EmbedContext);
|
||||
|
||||
// force everything to recent when URI changes, can cause weird corner cases otherwise (e.g. navigate while autoplay is true)
|
||||
useEffect(() => {
|
||||
if (uri && previousUri && uri !== previousUri) {
|
||||
|
@ -123,13 +165,18 @@ function VideoViewer(props: Props) {
|
|||
});
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
const onEnded = React.useCallback(() => {
|
||||
if (adUrl) {
|
||||
setAdUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (embedded) {
|
||||
setIsEndededEmbed(true);
|
||||
} else if (autoplaySetting) {
|
||||
setShowAutoplayCountdown(true);
|
||||
}
|
||||
}
|
||||
}, [embedded, setIsEndededEmbed, autoplaySetting, setShowAutoplayCountdown, adUrl, setAdUrl]);
|
||||
|
||||
function onPlay() {
|
||||
setIsLoading(false);
|
||||
|
@ -152,8 +199,12 @@ function VideoViewer(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
const onPlayerReady = useCallback(
|
||||
(player: Player) => {
|
||||
const playerReadyDependencyList = [uri, adUrl, embedded, autoplayIfEmbedded];
|
||||
if (!IS_WEB) {
|
||||
playerReadyDependencyList.push(desktopPlayStartTime);
|
||||
}
|
||||
|
||||
const onPlayerReady = useCallback((player: Player) => {
|
||||
if (!embedded) {
|
||||
player.muted(muted);
|
||||
player.volume(volume);
|
||||
|
@ -228,9 +279,7 @@ function VideoViewer(props: Props) {
|
|||
player.on('dispose', () => {
|
||||
handlePosition(player);
|
||||
});
|
||||
},
|
||||
IS_WEB ? [uri] : [uri, desktopPlayStartTime]
|
||||
);
|
||||
}, playerReadyDependencyList);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -245,16 +294,50 @@ function VideoViewer(props: Props) {
|
|||
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />}
|
||||
{/* disable this loading behavior because it breaks when player.play() promise hangs */}
|
||||
{isLoading && <LoadingScreen status={__('Loading')} />}
|
||||
|
||||
{!isFetchingAd && adUrl && (
|
||||
<>
|
||||
<span className="ads__video-notify">
|
||||
{__('Advertisement')}{' '}
|
||||
<Button
|
||||
className="ads__video-close"
|
||||
icon={ICONS.REMOVE}
|
||||
title={__('Close')}
|
||||
onClick={() => setAdUrl(null)}
|
||||
/>
|
||||
</span>
|
||||
<span className="ads__video-nudge">
|
||||
<I18nMessage
|
||||
tokens={{
|
||||
sign_up: (
|
||||
<Button
|
||||
button="secondary"
|
||||
className="ads__video-link"
|
||||
label={__('Sign Up')}
|
||||
navigate={`/$/${PAGES.AUTH}?redirect=${pathname}&src=video-ad`}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
%sign_up% to turn ads off.
|
||||
</I18nMessage>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isFetchingAd && (
|
||||
<VideoJs
|
||||
source={source}
|
||||
adUrl={adUrl}
|
||||
source={adUrl || source}
|
||||
sourceType={forcePlayer || adUrl ? 'video/mp4' : contentType}
|
||||
isAudio={isAudio}
|
||||
poster={isAudio || (embedded && !autoplayIfEmbedded) ? thumbnail : ''}
|
||||
sourceType={forcePlayer ? 'video/mp4' : contentType}
|
||||
onPlayerReady={onPlayerReady}
|
||||
startMuted={autoplayIfEmbedded}
|
||||
toggleVideoTheaterMode={toggleVideoTheaterMode}
|
||||
autoplay={!embedded || autoplayIfEmbedded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
53
ui/effects/use-get-ads.js
Normal file
53
ui/effects/use-get-ads.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
// @flow
|
||||
import React from 'react';
|
||||
import { VASTClient } from 'vast-client';
|
||||
import analytics from 'analytics';
|
||||
|
||||
const PRE_ROLL_ADS_PROVIDER = '`https://tag.targeting.unrulymedia.com/rmp/216276/0/vast2?vastfw=vpaid&w=300&h=500&url=';
|
||||
|
||||
// Ignores any call made 1 minutes or less after the last successful ad
|
||||
const ADS_CAP_LEVEL = 1 * 60 * 1000;
|
||||
const vastClient = new VASTClient(0, ADS_CAP_LEVEL);
|
||||
|
||||
export function useGetAds(adsEnabled: boolean): [?string, (?string) => void, boolean] {
|
||||
const [isFetching, setIsFetching] = React.useState(true);
|
||||
const [adUrl, setAdUrl] = React.useState();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!adsEnabled) {
|
||||
setIsFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
analytics.adsFetchedEvent();
|
||||
const encodedHref = encodeURI(window.location.href);
|
||||
const url = `${PRE_ROLL_ADS_PROVIDER}${encodedHref}`;
|
||||
// Used for testing on local dev
|
||||
// const url = 'https://raw.githubusercontent.com/dailymotion/vast-client-js/master/test/vastfiles/sample.xml';
|
||||
|
||||
vastClient
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
if (res.ads.length > 0) {
|
||||
// Let this line error if res.ads is empty
|
||||
// I took this from an example response from Dailymotion
|
||||
// It will be caught below and sent to matomo to figure out if there if this needs to be something changed to deal with unrulys data
|
||||
const adUrl = res.ads[0].creatives[0].mediaFiles[0].fileURL;
|
||||
|
||||
// Dummy video file
|
||||
// const adUrl = 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4';
|
||||
|
||||
if (adUrl) {
|
||||
setAdUrl(adUrl);
|
||||
}
|
||||
}
|
||||
|
||||
setIsFetching(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsFetching(false);
|
||||
});
|
||||
}, [adsEnabled]);
|
||||
|
||||
return [adUrl, setAdUrl, isFetching];
|
||||
}
|
|
@ -83,3 +83,52 @@
|
|||
.ads__claim-text--small {
|
||||
font-size: var(--font-small);
|
||||
}
|
||||
|
||||
// Pre-roll ads
|
||||
.ads__video-nudge,
|
||||
.ads__video-notify {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.ads__video-nudge {
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
font-weight: bold;
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.ads__video-notify {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background-color: black;
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
color: var(--color-white);
|
||||
font-size: var(--font-small);
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.ads__video-link.button--secondary {
|
||||
font-size: var(--font-small);
|
||||
padding: var(--spacing-xs);
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.ads__video-close {
|
||||
margin-left: var(--spacing-s);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
.icon {
|
||||
stroke: var(--color-white);
|
||||
|
||||
&:hover {
|
||||
stroke: var(--color-black);
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -137,6 +137,7 @@
|
|||
padding-right: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-top: 0;
|
||||
width: 100vw;
|
||||
max-width: none;
|
||||
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -11498,6 +11498,13 @@ vary@~1.1.2:
|
|||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
|
||||
vast-client@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/vast-client/-/vast-client-3.1.1.tgz#75f044ff1554e0f193302dfa1c20c7f7006fd1f8"
|
||||
integrity sha512-ED32RnLthWgAjQiEPsbqqC4LkN8+EhFyevHVh2SsmlPr6auugjswdbv+VgaQ/d7KUH/vpZ675HzVkIqkB2ibiQ==
|
||||
dependencies:
|
||||
xmldom "^0.3.0"
|
||||
|
||||
vendors@^1.0.0:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e"
|
||||
|
@ -11998,6 +12005,11 @@ xmldom@^0.1.27:
|
|||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
|
||||
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
|
||||
|
||||
xmldom@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.3.0.tgz#e625457f4300b5df9c2e1ecb776147ece47f3e5a"
|
||||
integrity sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g==
|
||||
|
||||
xpipe@*:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/xpipe/-/xpipe-1.0.5.tgz#8dd8bf45fc3f7f55f0e054b878f43a62614dafdf"
|
||||
|
|
Loading…
Reference in a new issue