recsys v0.2 #6977

Merged
jessopb merged 3 commits from recsysV0.2 into master 2021-09-03 00:39:40 +02:00
13 changed files with 340 additions and 238 deletions

View file

@ -31,6 +31,7 @@ module.name_mapper='^modal\(.*\)$' -> '<PROJECT_ROOT>/ui/modal\1'
module.name_mapper='^app\(.*\)$' -> '<PROJECT_ROOT>/ui/app\1' module.name_mapper='^app\(.*\)$' -> '<PROJECT_ROOT>/ui/app\1'
module.name_mapper='^native\(.*\)$' -> '<PROJECT_ROOT>/ui/native\1' module.name_mapper='^native\(.*\)$' -> '<PROJECT_ROOT>/ui/native\1'
module.name_mapper='^analytics\(.*\)$' -> '<PROJECT_ROOT>/ui/analytics\1' module.name_mapper='^analytics\(.*\)$' -> '<PROJECT_ROOT>/ui/analytics\1'
module.name_mapper='^recsys\(.*\)$' -> '<PROJECT_ROOT>/ui/recsys\1'
module.name_mapper='^rewards\(.*\)$' -> '<PROJECT_ROOT>/ui/rewards\1' module.name_mapper='^rewards\(.*\)$' -> '<PROJECT_ROOT>/ui/rewards\1'
module.name_mapper='^i18n\(.*\)$' -> '<PROJECT_ROOT>/ui/i18n\1' module.name_mapper='^i18n\(.*\)$' -> '<PROJECT_ROOT>/ui/i18n\1'
module.name_mapper='^effects\(.*\)$' -> '<PROJECT_ROOT>/ui/effects\1' module.name_mapper='^effects\(.*\)$' -> '<PROJECT_ROOT>/ui/effects\1'

View file

@ -27,7 +27,6 @@ type Props = {
onScrollBottom?: (any) => void, onScrollBottom?: (any) => void,
page?: number, page?: number,
pageSize?: number, pageSize?: number,
id?: string,
// If using the default header, this is a unique ID needed to persist the state of the filter setting // If using the default header, this is a unique ID needed to persist the state of the filter setting
persistedStorageKey?: string, persistedStorageKey?: string,
showHiddenByUser: boolean, showHiddenByUser: boolean,
@ -47,7 +46,7 @@ type Props = {
searchOptions?: any, searchOptions?: any,
collectionId?: string, collectionId?: string,
showNoSourceClaims?: boolean, showNoSourceClaims?: boolean,
onClick?: (e: any, index?: number) => void, onClick?: (e: any, claim?: ?Claim, index?: number) => void,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -56,7 +55,6 @@ export default function ClaimList(props: Props) {
uris, uris,
headerAltControls, headerAltControls,
loading, loading,
id,
persistedStorageKey, persistedStorageKey,
empty, empty,
defaultSort, defaultSort,
@ -110,9 +108,9 @@ export default function ClaimList(props: Props) {
setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW); setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW);
} }
function handleClaimClicked(e, index) { function handleClaimClicked(e, claim, index) {
if (onClick) { if (onClick) {
onClick(e, index); onClick(e, claim, index);
} }
} }
@ -200,7 +198,6 @@ export default function ClaimList(props: Props) {
{injectedItem && index === 4 && <li>{injectedItem}</li>} {injectedItem && index === 4 && <li>{injectedItem}</li>}
<ClaimPreview <ClaimPreview
uri={uri} uri={uri}
containerId={id}
indexInContainer={index} indexInContainer={index}
type={type} type={type}
active={activeUri && uri === activeUri} active={activeUri && uri === activeUri}
@ -220,7 +217,7 @@ export default function ClaimList(props: Props) {
return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch'; return claim.name.length === 24 && !claim.name.includes(' ') && claim.value.author === 'Spee.ch';
}} }}
live={resolveLive(index)} live={resolveLive(index)}
onClick={handleClaimClicked} onClick={(e, claim, index) => handleClaimClicked(e, claim, index)}
/> />
</React.Fragment> </React.Fragment>
))} ))}

View file

@ -583,7 +583,6 @@ function ClaimListDiscover(props: Props) {
)} )}
<ClaimList <ClaimList
tileLayout tileLayout
id={mainSearchKey}
loading={loading} loading={loading}
uris={finalUris} uris={finalUris}
onScrollBottom={handleScrollBottom} onScrollBottom={handleScrollBottom}
@ -617,7 +616,6 @@ function ClaimListDiscover(props: Props) {
</div> </div>
)} )}
<ClaimList <ClaimList
id={mainSearchKey}
type={type} type={type}
loading={loading} loading={loading}
uris={finalUris} uris={finalUris}

View file

@ -29,7 +29,6 @@ import ClaimPreviewNoContent from './claim-preview-no-content';
import { ENABLE_NO_SOURCE_CLAIMS } from 'config'; import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
import Button from 'component/button'; import Button from 'component/button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import { CONTAINER_ID } from 'constants/navigation';
const AbandonedChannelPreview = lazyImport(() => const AbandonedChannelPreview = lazyImport(() =>
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */) import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
@ -67,7 +66,7 @@ type Props = {
actions: boolean | Node | string | number, actions: boolean | Node | string | number,
properties: boolean | Node | string | number | ((Claim) => Node), properties: boolean | Node | string | number | ((Claim) => Node),
empty?: Node, empty?: Node,
onClick?: (e: any, index?: number) => any, onClick?: (e: any, claim?: ?Claim, index?: number) => any,
streamingUrl: ?string, streamingUrl: ?string,
getFile: (string) => void, getFile: (string) => void,
customShouldHide?: (Claim) => boolean, customShouldHide?: (Claim) => boolean,
@ -90,7 +89,6 @@ type Props = {
disableNavigation?: boolean, disableNavigation?: boolean,
mediaDuration?: string, mediaDuration?: string,
date?: any, date?: any,
containerId?: string, // ID or name of the container (e.g. ClaimList, HOC, etc.) that this is in.
indexInContainer?: number, // The index order of this component within 'containerId'. indexInContainer?: number, // The index order of this component within 'containerId'.
channelSubCount?: number, channelSubCount?: number,
}; };
@ -154,7 +152,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
isCollectionMine, isCollectionMine,
collectionUris, collectionUris,
disableNavigation, disableNavigation,
containerId,
indexInContainer, indexInContainer,
channelSubCount, channelSubCount,
} = props; } = props;
@ -223,7 +220,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
const handleNavLinkClick = (e) => { const handleNavLinkClick = (e) => {
if (onClick) { if (onClick) {
onClick(e, indexInContainer); onClick(e, claim, indexInContainer); // not sure indexInContainer is used for anything.
} }
e.stopPropagation(); e.stopPropagation();
}; };
@ -232,10 +229,9 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
to: { to: {
pathname: navigateUrl, pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '', search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
}, },
onClick: (e) => handleNavLinkClick(e), onClick: handleNavLinkClick,
onAuxClick: (e) => handleNavLinkClick(e), onAuxClick: handleNavLinkClick,
}; };
// do not block abandoned and nsfw claims if showUserBlocked is passed // do not block abandoned and nsfw claims if showUserBlocked is passed
@ -281,14 +277,13 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
function handleOnClick(e) { function handleOnClick(e) {
if (onClick) { if (onClick) {
onClick(e, indexInContainer); onClick(e, claim, indexInContainer);
} }
if (claim && !pending && !disableNavigation) { if (claim && !pending && !disableNavigation) {
history.push({ history.push({
pathname: navigateUrl, pathname: navigateUrl,
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '', search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
}); });
} }
} }

View file

@ -7,14 +7,19 @@ import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content'; import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content';
import RecommendedContent from './view'; import RecommendedContent from './view';
const select = (state, props) => ({ const select = (state, props) => {
const claim = makeSelectClaimForUri(props.uri)(state);
const { claim_id: claimId } = claim;
return {
mature: makeSelectClaimIsNsfw(props.uri)(state), mature: makeSelectClaimIsNsfw(props.uri)(state),
recommendedContent: makeSelectRecommendedContentForUri(props.uri)(state), recommendedContentUris: makeSelectRecommendedContentForUri(props.uri)(state),
nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state), nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state),
isSearching: selectIsSearching(state), isSearching: selectIsSearching(state),
isAuthenticated: selectUserVerifiedEmail(state), isAuthenticated: selectUserVerifiedEmail(state),
claim: makeSelectClaimForUri(props.uri)(state), claim,
}); claimId,
};
};
const perform = (dispatch) => ({ const perform = (dispatch) => ({
doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)), doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)),

View file

@ -1,8 +1,6 @@
// @flow // @flow
import { SHOW_ADS } from 'config'; import { SHOW_ADS } from 'config';
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom';
import { v4 as uuidv4 } from 'uuid';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import ClaimListDiscover from 'component/claimListDiscover'; import ClaimListDiscover from 'component/claimListDiscover';
import Ads from 'web/component/ads'; import Ads from 'web/component/ads';
@ -10,14 +8,14 @@ import Card from 'component/common/card';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize'; import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import Button from 'component/button'; import Button from 'component/button';
import classnames from 'classnames'; import classnames from 'classnames';
import { CONTAINER_ID } from 'constants/navigation'; import RecSys from 'recsys';
const VIEW_ALL_RELATED = 'view_all_related'; const VIEW_ALL_RELATED = 'view_all_related';
const VIEW_MORE_FROM = 'view_more_from'; const VIEW_MORE_FROM = 'view_more_from';
type Props = { type Props = {
uri: string, uri: string,
recommendedContent: Array<string>, recommendedContentUris: Array<string>,
nextRecommendedUri: string, nextRecommendedUri: string,
isSearching: boolean, isSearching: boolean,
doFetchRecommendedContent: (string, boolean) => void, doFetchRecommendedContent: (string, boolean) => void,
@ -25,7 +23,7 @@ type Props = {
isAuthenticated: boolean, isAuthenticated: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
doRecommendationUpdate: (claimId: string, urls: Array<string>, id: string, parentId: string) => void, doRecommendationUpdate: (claimId: string, urls: Array<string>, id: string, parentId: string) => void,
doRecommendationClicked: (claimId: string, index: number) => void, claimId: string,
}; };
export default React.memo<Props>(function RecommendedContent(props: Props) { export default React.memo<Props>(function RecommendedContent(props: Props) {
@ -33,23 +31,20 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
uri, uri,
doFetchRecommendedContent, doFetchRecommendedContent,
mature, mature,
recommendedContent, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching, isSearching,
isAuthenticated, isAuthenticated,
claim, claim,
doRecommendationUpdate, claimId,
doRecommendationClicked,
} = props; } = props;
const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED); const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED);
const [recommendationId, setRecommendationId] = React.useState('');
const [recommendationUrls, setRecommendationUrls] = React.useState(); const [recommendationUrls, setRecommendationUrls] = React.useState();
const history = useHistory();
const signingChannel = claim && claim.signing_channel; const signingChannel = claim && claim.signing_channel;
const channelName = signingChannel ? signingChannel.name : null; const channelName = signingChannel ? signingChannel.name : null;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMedium = useIsMediumScreen(); const isMedium = useIsMediumScreen();
const { onRecsLoaded: onRecommendationsLoaded, onClickedRecommended: onRecommendationClicked } = RecSys;
React.useEffect(() => { React.useEffect(() => {
function moveAutoplayNextItemToTop(recommendedContent) { function moveAutoplayNextItemToTop(recommendedContent) {
let newList = recommendedContent; let newList = recommendedContent;
@ -72,33 +67,27 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
} }
} }
const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContent); const newRecommendationUrls = moveAutoplayNextItemToTop(recommendedContentUris);
if (claim && !listEq(recommendationUrls, newRecommendationUrls)) { if (claim && !listEq(recommendationUrls, newRecommendationUrls)) {
const parentId = (history.location.state && history.location.state[CONTAINER_ID]) || '';
const id = uuidv4();
setRecommendationId(id);
setRecommendationUrls(newRecommendationUrls); setRecommendationUrls(newRecommendationUrls);
doRecommendationUpdate(claim.claim_id, newRecommendationUrls, id, parentId);
} }
}, [ }, [recommendedContentUris, nextRecommendedUri, recommendationUrls, setRecommendationUrls, claim]);
recommendedContent,
nextRecommendedUri,
recommendationUrls,
setRecommendationUrls,
claim,
doRecommendationUpdate,
history.location.state,
]);
React.useEffect(() => { React.useEffect(() => {
doFetchRecommendedContent(uri, mature); doFetchRecommendedContent(uri, mature);
}, [uri, mature, doFetchRecommendedContent]); }, [uri, mature, doFetchRecommendedContent]);
function handleRecommendationClicked(e: any, index: number) { React.useEffect(() => {
// Right now we only want to record the recs if they actually saw them.
if (recommendationUrls && recommendationUrls.length && nextRecommendedUri && viewMode === VIEW_ALL_RELATED) {
onRecommendationsLoaded(claimId, recommendationUrls);
}
}, [recommendationUrls, onRecommendationsLoaded, claimId, nextRecommendedUri, viewMode]);
function handleRecommendationClicked(e, clickedClaim, index: number) {
if (claim) { if (claim) {
doRecommendationClicked(claim.claim_id, index); onRecommendationClicked(claim.claim_id, clickedClaim.claim_id);
} }
} }
@ -133,7 +122,6 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
<div> <div>
{viewMode === VIEW_ALL_RELATED && ( {viewMode === VIEW_ALL_RELATED && (
<ClaimList <ClaimList
id={recommendationId}
type="small" type="small"
loading={isSearching} loading={isSearching}
uris={recommendationUrls} uris={recommendationUrls}
@ -177,8 +165,8 @@ function areEqual(prevProps: Props, nextProps: Props) {
a.isAuthenticated !== b.isAuthenticated || a.isAuthenticated !== b.isAuthenticated ||
a.isSearching !== b.isSearching || a.isSearching !== b.isSearching ||
a.mature !== b.mature || a.mature !== b.mature ||
(a.recommendedContent && !b.recommendedContent) || (a.recommendedContentUris && !b.recommendedContentUris) ||
(!a.recommendedContent && b.recommendedContent) || (!a.recommendedContentUris && b.recommendedContentUris) ||
(a.claim && !b.claim) || (a.claim && !b.claim) ||
(!a.claim && b.claim) (!a.claim && b.claim)
) { ) {
@ -189,14 +177,14 @@ function areEqual(prevProps: Props, nextProps: Props) {
return false; return false;
} }
if (a.recommendedContent && b.recommendedContent) { if (a.recommendedContentUris && b.recommendedContentUris) {
if (a.recommendedContent.length !== b.recommendedContent.length) { if (a.recommendedContentUris.length !== b.recommendedContentUris.length) {
return false; return false;
} }
let i = a.recommendedContent.length; let i = a.recommendedContentUris.length;
while (i--) { while (i--) {
if (a.recommendedContent[i] !== b.recommendedContent[i]) { if (a.recommendedContentUris[i] !== b.recommendedContentUris[i]) {
return false; return false;
} }
} }

View file

@ -1,54 +1,20 @@
// Created by xander on 6/21/2021 // Created by xander on 6/21/2021
import videojs from 'video.js'; import videojs from 'video.js';
import {
makeSelectRecommendationId,
makeSelectRecommendationParentId,
makeSelectRecommendedClaimIds,
makeSelectRecommendationClicks,
} from 'redux/selectors/content';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
import RecSys from 'recsys';
const VERSION = '0.0.1'; const VERSION = '0.0.1';
const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
const recsysId = 'lighthouse-v0';
/* RecSys */ /* RecSys */
const RecsysData = { const PlayerEvent = {
event: { event: {
start: 0, start: 0, // event types
stop: 1, stop: 1,
scrub: 2, scrub: 2,
speed: 3, speed: 3,
}, },
}; };
function createRecsys(claimId, userId, events, loadedAt, isEmbed) { function newRecsysPlayerEvent(eventType, offset, arg) {
const pageLoadedAt = loadedAt;
const pageExitedAt = Date.now();
if (window.store) {
const state = window.store.getState();
return {
uuid: makeSelectRecommendationId(claimId)(state),
parentUuid: makeSelectRecommendationParentId(claimId)(state),
uid: userId,
claimId: claimId,
pageLoadedAt: pageLoadedAt,
pageExitedAt: pageExitedAt,
recsysId: makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId,
recClaimIds: makeSelectRecommendedClaimIds(claimId)(state),
recClickedVideoIdx: makeSelectRecommendationClicks(claimId)(state),
events: events,
isEmbed: isEmbed,
};
}
return undefined;
}
function newRecsysEvent(eventType, offset, arg) {
if (arg) { if (arg) {
return { return {
event: eventType, event: eventType,
@ -63,30 +29,11 @@ function newRecsysEvent(eventType, offset, arg) {
} }
} }
function sendRecsysEvents(recsys) {
const requestOptions = {
method: 'POST',
headers: { 'Content-Type': 'text/plain' }, // application/json
body: JSON.stringify(recsys),
};
try {
fetch(recsysEndpoint, requestOptions)
.then((response) => response.json())
.then((data) => {
// console.log(`Recsys response data:`, data);
});
} catch (error) {
// console.error(`Recsys Error`, error);
}
}
const defaults = { const defaults = {
endpoint: recsysEndpoint,
recsysId: recsysId,
videoId: null, videoId: null,
userId: 0, userId: 0,
debug: false, debug: false,
embedded: false,
}; };
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent('Component');
@ -98,16 +45,13 @@ class RecsysPlugin extends Component {
// Plugin started // Plugin started
if (options.debug) { if (options.debug) {
this.log(`Created recsys plugin for: videoId:${options.videoId}, userId:${options.userId}`); this.log(`Created recsys plugin for: videoId:${options.videoId}`);
} }
// To help with debugging, we'll add a global vjs object with the video js player // To help with debugging, we'll add a global vjs object with the video js player
window.vjs = player; window.vjs = player;
this.player = player; this.player = player;
this.recsysEvents = [];
this.loadedAt = Date.now();
this.lastTimeUpdate = null; this.lastTimeUpdate = null;
this.currentTimeUpdate = null; this.currentTimeUpdate = null;
this.inPause = false; this.inPause = false;
@ -124,57 +68,37 @@ class RecsysPlugin extends Component {
player.on('dispose', (event) => this.onDispose(event)); player.on('dispose', (event) => this.onDispose(event));
} }
addRecsysEvent(recsysEvent) {
// For now, don't do client-side preprocessing. I think there
// are browser inconsistencies and preprocessing loses too much info.
this.recsysEvents.push(recsysEvent);
}
getRecsysEvents() {
return this.recsysEvents;
}
sendRecsysEvents() {
const event = createRecsys(
this.options_.videoId,
this.options_.userId,
this.getRecsysEvents(),
this.loadedAt,
false
);
if (event) {
sendRecsysEvents(event);
}
}
onPlay(event) { onPlay(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.start, this.player.currentTime());
this.log('onPlay', recsysEvent); this.log('onPlay', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent, this.options_.embedded);
this.inPause = false; this.inPause = false;
this.lastTimeUpdate = recsysEvent.offset; this.lastTimeUpdate = recsysEvent.offset;
} }
onPause(event) { onPause(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.stop, this.player.currentTime());
this.log('onPause', recsysEvent); this.log('onPause', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
this.inPause = true; this.inPause = true;
} }
onEnded(event) { onEnded(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.stop, this.player.currentTime()); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.stop, this.player.currentTime());
this.log('onEnded', recsysEvent); this.log('onEnded', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
onRateChange(event) { onRateChange(event) {
const recsysEvent = newRecsysEvent(RecsysData.event.speed, this.player.currentTime(), this.player.playbackRate()); const recsysEvent = newRecsysPlayerEvent(
PlayerEvent.event.speed,
this.player.currentTime(),
this.player.playbackRate()
);
this.log('onRateChange', recsysEvent); this.log('onRateChange', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
onTimeUpdate(event) { onTimeUpdate(event) {
@ -212,19 +136,19 @@ class RecsysPlugin extends Component {
if (fromTime !== curTime) { if (fromTime !== curTime) {
// This removes duplicates that aren't useful. // This removes duplicates that aren't useful.
const recsysEvent = newRecsysEvent(RecsysData.event.scrub, fromTime, curTime); const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.scrub, fromTime, curTime);
this.log('onSeeked', recsysEvent); this.log('onSeeked', recsysEvent);
this.addRecsysEvent(recsysEvent); RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
} }
} }
onDispose(event) { onDispose(event) {
this.sendRecsysEvents(); RecSys.onPlayerDispose(this.options_.videoId, this.options_.embedded);
} }
log(...args) { log(...args) {
if (this.options_.debug) { if (this.options_.debug) {
// console.log(`Recsys Debug:`, JSON.stringify(args)); console.log(`Recsys Player Debug:`, JSON.stringify(args));
} }
} }
} }

View file

@ -54,6 +54,7 @@ type Props = {
startMuted: boolean, startMuted: boolean,
autoplay: boolean, autoplay: boolean,
autoplaySetting: boolean, autoplaySetting: boolean,
embedded: boolean,
toggleVideoTheaterMode: () => void, toggleVideoTheaterMode: () => void,
adUrl: ?string, adUrl: ?string,
claimId: ?string, claimId: ?string,
@ -194,6 +195,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
const { const {
autoplay, autoplay,
autoplaySetting, autoplaySetting,
embedded,
startMuted, startMuted,
source, source,
sourceType, sourceType,
@ -590,6 +592,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
player.recsys({ player.recsys({
videoId: claimId, videoId: claimId,
userId: userId, userId: userId,
embedded: embedded,
}); });
} }

View file

@ -125,7 +125,7 @@ function VideoViewer(props: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [ended, setEnded] = useState(false); const [ended, setEnded] = useState(false);
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false); const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
const [isEndededEmbed, setIsEndededEmbed] = useState(false); const [isEndedEmbed, setIsEndedEmbed] = useState(false);
const vjsCallbackDataRef: any = React.useRef(); const vjsCallbackDataRef: any = React.useRef();
const previousUri = usePrevious(uri); const previousUri = usePrevious(uri);
const embedded = useContext(EmbedContext); const embedded = useContext(EmbedContext);
@ -142,7 +142,7 @@ function VideoViewer(props: Props) {
useEffect(() => { useEffect(() => {
if (uri && previousUri && uri !== previousUri) { if (uri && previousUri && uri !== previousUri) {
setShowAutoplayCountdown(false); setShowAutoplayCountdown(false);
setIsEndededEmbed(false); setIsEndedEmbed(false);
setIsLoading(false); setIsLoading(false);
} }
}, [uri, previousUri]); }, [uri, previousUri]);
@ -236,7 +236,7 @@ function VideoViewer(props: Props) {
} }
if (embedded) { if (embedded) {
setIsEndededEmbed(true); setIsEndedEmbed(true);
} else if (!collectionId && autoplayNext) { } else if (!collectionId && autoplayNext) {
setShowAutoplayCountdown(true); setShowAutoplayCountdown(true);
} else if (collectionId) { } else if (collectionId) {
@ -247,7 +247,7 @@ function VideoViewer(props: Props) {
} }
}, [ }, [
embedded, embedded,
setIsEndededEmbed, setIsEndedEmbed,
autoplayMedia, autoplayMedia,
setShowAutoplayCountdown, setShowAutoplayCountdown,
adUrl, adUrl,
@ -264,7 +264,7 @@ function VideoViewer(props: Props) {
setIsLoading(false); setIsLoading(false);
setIsPlaying(true); setIsPlaying(true);
setShowAutoplayCountdown(false); setShowAutoplayCountdown(false);
setIsEndededEmbed(false); setIsEndedEmbed(false);
setReplay(false); setReplay(false);
setDoNavigate(false); setDoNavigate(false);
analytics.videoIsPlaying(true, player); analytics.videoIsPlaying(true, player);
@ -391,7 +391,7 @@ function VideoViewer(props: Props) {
<div <div
className={classnames('file-viewer', { className={classnames('file-viewer', {
'file-viewer--is-playing': isPlaying, 'file-viewer--is-playing': isPlaying,
'file-viewer--ended-embed': isEndededEmbed, 'file-viewer--ended-embed': isEndedEmbed,
})} })}
onContextMenu={stopContextMenu} onContextMenu={stopContextMenu}
> >
@ -403,8 +403,8 @@ function VideoViewer(props: Props) {
doReplay={() => setReplay(true)} doReplay={() => setReplay(true)}
/> />
)} )}
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />} {isEndedEmbed && <FileViewerEmbeddedEnded uri={uri} />}
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />} {embedded && !isEndedEmbed && <FileViewerEmbeddedTitle uri={uri} />}
{/* disable this loading behavior because it breaks when player.play() promise hangs */} {/* disable this loading behavior because it breaks when player.play() promise hangs */}
{isLoading && <LoadingScreen status={__('Loading')} />} {isLoading && <LoadingScreen status={__('Loading')} />}
@ -458,6 +458,7 @@ function VideoViewer(props: Props) {
videoTheaterMode={videoTheaterMode} videoTheaterMode={videoTheaterMode}
playNext={doPlayNext} playNext={doPlayNext}
playPrevious={doPlayPrevious} playPrevious={doPlayPrevious}
embedded={embedded}
/> />
)} )}
</div> </div>

254
ui/recsys.js Normal file
View file

@ -0,0 +1,254 @@
import { selectUser } from 'redux/selectors/user';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
import { v4 as Uuidv4 } from 'uuid';
import { parseURI, SETTINGS, makeSelectClaimForUri } from 'lbry-redux';
import { selectPlayingUri, selectPrimaryUri } from 'redux/selectors/content';
import { makeSelectClientSetting, selectDaemonSettings } from 'redux/selectors/settings';
import { history } from './store';
const recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
const recsysId = 'lighthouse-v0';
const getClaimIdsFromUris = (uris) => {
return uris
? uris.map((uri) => {
try {
const { claimId } = parseURI(uri);
return claimId;
} catch (e) {
return [];
}
})
: [];
};
const recsys = {
entries: {},
debug: false,
/**
* Provides for creating, updating, and sending Clickstream data object Entries.
* Entries are Created either when recommendedContent loads, or when recommendedContent is clicked.
* If recommended content is clicked, An Entry with parentUuid is created.
* On page load, find an empty entry with your claimId, or create a new entry and record to it.
* The entry will be populated with the following:
* - parentUuid // optional
* - Uuid
* - claimId
* - recommendedClaims [] // optionally empty
* - playerEvents [] // optionally empty
* - recommendedClaimsIndexClicked [] // optionally empty
* - UserId
* - pageLoadedAt
* - isEmbed
* - pageExitedAt
* - recsysId // optional
*/
/**
* Function: onClickedRecommended()
* Called when RecommendedContent was clicked.
* Adds index of clicked recommendation to parent entry
* Adds new Entry with parentUuid for destination page
* @param parentClaimId: string,
* @param newClaimId: string,
*/
onClickedRecommended: function (parentClaimId, newClaimId) {
const parentEntry = recsys.entries[parentClaimId] ? recsys.entries[parentClaimId] : null;
const parentUuid = parentEntry['uuid'];
const parentRecommendedClaims = parentEntry['recClaimIds'] || [];
const parentClickedIndexes = parentEntry['recClickedVideoIdx'] || [];
const indexClicked = parentRecommendedClaims.indexOf(newClaimId);
if (parentUuid) {
recsys.createRecsysEntry(newClaimId, parentUuid);
}
parentClickedIndexes.push(indexClicked);
recsys.log('onClickedRecommended', { parentClaimId, newClaimId });
},
/**
* Page was loaded. Get or Create entry and populate it with default data, plus recommended content, recsysId, etc.
* Called from recommendedContent component
*/
onRecsLoaded: function (claimId, uris) {
if (window.store) {
const state = window.store.getState();
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId);
}
const claimIds = getClaimIdsFromUris(uris);
recsys.entries[claimId]['recsysId'] = makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId;
recsys.entries[claimId]['pageLoadedAt'] = Date.now();
recsys.entries[claimId]['recClaimIds'] = claimIds;
}
recsys.log('onRecsLoaded', claimId);
},
/**
* Creates an Entry with optional parentUuid
* @param: claimId: string
* @param: parentUuid: string (optional)
*/
createRecsysEntry: function (claimId, parentUuid) {
if (window.store && claimId) {
const state = window.store.getState();
const { id: userId } = selectUser(state);
if (parentUuid) {
// Make a stub entry that will be filled out on page load
recsys.entries[claimId] = {
uuid: Uuidv4(),
parentUuid: parentUuid,
uid: userId || null, // selectUser
claimId: claimId,
recClickedVideoIdx: [],
pageLoadedAt: Date.now(),
events: [],
};
} else {
recsys.entries[claimId] = {
uuid: Uuidv4(),
uid: userId, // selectUser
claimId: claimId,
pageLoadedAt: Date.now(),
recsysId: makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId,
recClaimIds: [],
recClickedVideoIdx: [],
events: [],
};
}
}
recsys.log('createRecsysEntry', claimId);
},
/**
* Send event for claimId
* @param claimId
* @param isTentative
*/
sendRecsysEntry: function (claimId, isTentative) {
const shareTelemetry =
IS_WEB || (window && window.store && selectDaemonSettings(window.store.getState()).share_usage_data);
if (recsys.entries[claimId] && shareTelemetry) {
const data = JSON.stringify(recsys.entries[claimId]);
try {
navigator.sendBeacon(recsysEndpoint, data);
if (!isTentative) {
delete recsys.entries[claimId];
}
} catch (error) {
console.log('no beacon for you', error);
}
}
recsys.log('sendRecsysEntry', claimId);
},
/**
* A player event fired. Get the Entry for the claimId, and add the events
* @param claimId
* @param event
*/
onRecsysPlayerEvent: function (claimId, event, isEmbedded) {
if (!recsys.entries[claimId]) {
recsys.createRecsysEntry(claimId);
// do something to show it's floating or autoplay
}
if (isEmbedded) {
recsys.entries[claimId]['isEmbed'] = true;
}
recsys.entries[claimId].events.push(event);
recsys.log('onRecsysPlayerEvent', claimId);
},
log: function (callName, claimId) {
if (recsys.debug) {
console.log(`Call: ***${callName}***, ClaimId: ${claimId}, Recsys Entries`, Object.assign({}, recsys.entries));
}
},
/**
* Player closed. Check to see if primaryUri = playingUri
* if so, send the Entry.
*/
onPlayerDispose: function (claimId, isEmbedded) {
if (window.store) {
const state = window.store.getState();
const playingUri = selectPlayingUri(state);
const primaryUri = selectPrimaryUri(state);
const onFilePage = playingUri === primaryUri;
if (!onFilePage || isEmbedded) {
if (isEmbedded) {
recsys.entries[claimId]['isEmbed'] = true;
}
recsys.sendRecsysEntry(claimId);
}
}
recsys.log('PlayerDispose', claimId);
},
// /**
// * File page unmount or change event
// * Check to see if playingUri, floatingEnabled, primaryUri === playingUri
// * If not, send the Entry.
// * If floating enabled, leaving file page will pop out player, leading to
// * more events until player is disposed. Don't send unless floatingPlayer playingUri
// */
// onLeaveFilePage: function (primaryUri) {
// if (window.store) {
// const state = window.store.getState();
// const claim = makeSelectClaimForUri(primaryUri)(state);
// const claimId = claim ? claim.claim_id : null;
// const playingUri = selectPlayingUri(state);
// const actualPlayingUri = playingUri && playingUri.uri;
// // const primaryUri = selectPrimaryUri(state);
// const floatingPlayer = makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state);
// // When leaving page, if floating player is enabled, play will continue.
// if (claimId) {
// recsys.entries[claimId]['pageExitedAt'] = Date.now();
// }
// const shouldSend =
// (claimId && floatingPlayer && actualPlayingUri && actualPlayingUri !== primaryUri) || !floatingPlayer || !actualPlayingUri;
// if (shouldSend) {
// recsys.sendRecsysEntry(claimId);
// }
// recsys.log('LeaveFile', claimId);
// }
// },
/**
* Navigate event
* Send all claimIds that aren't currently playing.
*/
onNavigate: function () {
if (window.store) {
const state = window.store.getState();
const playingUri = selectPlayingUri(state);
const actualPlayingUri = playingUri && playingUri.uri;
const claim = makeSelectClaimForUri(actualPlayingUri)(state);
const playingClaimId = claim ? claim.claim_id : null;
// const primaryUri = selectPrimaryUri(state);
const floatingPlayer = makeSelectClientSetting(SETTINGS.FLOATING_PLAYER)(state);
// When leaving page, if floating player is enabled, play will continue.
Object.keys(recsys.entries).forEach((claimId) => {
const shouldSkip = recsys.entries[claimId].parentUuid && !recsys.entries[claimId].recClaimIds;
if (!shouldSkip && ((claimId !== playingClaimId && floatingPlayer) || !floatingPlayer)) {
recsys.entries[claimId]['pageExitedAt'] = Date.now();
recsys.sendRecsysEntry(claimId);
}
recsys.log('OnNavigate', claimId);
});
}
},
};
// @if TARGET='web'
document.addEventListener('visibilitychange', function logData() {
if (document.visibilityState === 'hidden') {
Object.keys(recsys.entries).map((claimId) => recsys.sendRecsysEntry(claimId, true));
}
});
// @endif
history.listen(() => {
recsys.onNavigate();
});
export default recsys;

View file

@ -116,43 +116,6 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => {
reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] }); reducers[ACTIONS.CLEAR_CONTENT_HISTORY_ALL] = (state) => ({ ...state, history: [] });
reducers[ACTIONS.RECOMMENDATION_UPDATED] = (state, action) => {
const { claimId, urls, id, parentId } = action.data;
const recommendationId = Object.assign({}, state.recommendationId);
const recommendationParentId = Object.assign({}, state.recommendationParentId);
const recommendationUrls = Object.assign({}, state.recommendationUrls);
const recommendationClicks = Object.assign({}, state.recommendationClicks);
if (urls && urls.length > 0) {
recommendationId[claimId] = id;
recommendationParentId[claimId] = parentId;
recommendationUrls[claimId] = urls;
recommendationClicks[claimId] = [];
} else {
delete recommendationId[claimId];
delete recommendationParentId[claimId];
delete recommendationUrls[claimId];
delete recommendationClicks[claimId];
}
return { ...state, recommendationId, recommendationParentId, recommendationUrls, recommendationClicks };
};
reducers[ACTIONS.RECOMMENDATION_CLICKED] = (state, action) => {
const { claimId, index } = action.data;
const recommendationClicks = Object.assign({}, state.recommendationClicks);
if (state.recommendationUrls[claimId] && index >= 0 && index < state.recommendationUrls[claimId].length) {
if (recommendationClicks[claimId]) {
recommendationClicks[claimId].push(index);
} else {
recommendationClicks[claimId] = [index];
}
}
return { ...state, recommendationClicks };
};
// reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => { // reducers[LBRY_REDUX_ACTIONS.PURCHASE_URI_FAILED] = (state, action) => {
// return { // return {
// ...state, // ...state,

View file

@ -3,7 +3,6 @@ import { createSelector } from 'reselect';
import { import {
makeSelectClaimForUri, makeSelectClaimForUri,
selectClaimsByUri, selectClaimsByUri,
makeSelectClaimsInChannelForCurrentPageState,
makeSelectClaimIsNsfw, makeSelectClaimIsNsfw,
makeSelectClaimIsMine, makeSelectClaimIsMine,
makeSelectMediaTypeForUri, makeSelectMediaTypeForUri,
@ -11,7 +10,6 @@ import {
parseURI, parseURI,
makeSelectContentTypeForUri, makeSelectContentTypeForUri,
makeSelectFileNameForUri, makeSelectFileNameForUri,
selectClaimIdsByUri,
} from 'lbry-redux'; } from 'lbry-redux';
import { makeSelectRecommendedContentForUri } from 'redux/selectors/search'; import { makeSelectRecommendedContentForUri } from 'redux/selectors/search';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
@ -155,18 +153,6 @@ export const selectRecentHistory = createSelector(selectHistory, (history) => {
return history.slice(0, RECENT_HISTORY_AMOUNT); return history.slice(0, RECENT_HISTORY_AMOUNT);
}); });
export const makeSelectCategoryListUris = (uris: ?Array<string>, channel: string) =>
createSelector(makeSelectClaimsInChannelForCurrentPageState(channel), (channelClaims) => {
if (uris) return uris;
if (channelClaims) {
const CATEGORY_LIST_SIZE = 10;
return channelClaims.slice(0, CATEGORY_LIST_SIZE).map(({ name, claim_id: claimId }) => `${name}#${claimId}`);
}
return null;
});
export const makeSelectShouldObscurePreview = (uri: string) => export const makeSelectShouldObscurePreview = (uri: string) =>
createSelector(selectShowMatureContent, makeSelectClaimIsNsfw(uri), (showMatureContent, isClaimMature) => { createSelector(selectShowMatureContent, makeSelectClaimIsNsfw(uri), (showMatureContent, isClaimMature) => {
return isClaimMature && !showMatureContent; return isClaimMature && !showMatureContent;
@ -247,21 +233,3 @@ export const makeSelectInsufficientCreditsForUri = (uri: string) =>
return !isMine && costInfo && costInfo.cost > 0 && costInfo.cost > balance; return !isMine && costInfo && costInfo.cost > 0 && costInfo.cost > balance;
} }
); );
export const makeSelectRecommendationId = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationId[claimId]);
export const makeSelectRecommendationParentId = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationParentId[claimId]);
export const makeSelectRecommendedClaimIds = (claimId: string) =>
createSelector(selectState, selectClaimIdsByUri, (state, claimIdsByUri) => {
const recommendationUrls = state.recommendationUrls[claimId];
if (recommendationUrls) {
return recommendationUrls.map((url) => claimIdsByUri[url]);
}
return undefined;
});
export const makeSelectRecommendationClicks = (claimId: string) =>
createSelector(selectState, (state) => state.recommendationClicks[claimId]);

View file

@ -98,7 +98,7 @@ export const makeSelectRecommendedRecsysIdForClaimId = (claimId: string) =>
createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => { createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => {
// TODO: DRY this out. // TODO: DRY this out.
let poweredBy; let poweredBy;
if (claim) { if (claim && claimId) {
const isMature = isClaimNsfw(claim); const isMature = isClaimNsfw(claim);
const { title } = claim.value; const { title } = claim.value;
@ -204,3 +204,8 @@ export const makeSelectIsResolvingWinningUri = (query: string = '') => {
} }
); );
}; };
export const makeSelectUrlForClaimId = (claimId: string) =>
createSelector(
makeSelectClaimForClaimId(claimId), (claim) => claim ? claim.canonical_url || claim.permanent_url : null
);