recsys v0.2 #6977
13 changed files with 340 additions and 238 deletions
|
@ -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='^native\(.*\)$' -> '<PROJECT_ROOT>/ui/native\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='^i18n\(.*\)$' -> '<PROJECT_ROOT>/ui/i18n\1'
|
||||
module.name_mapper='^effects\(.*\)$' -> '<PROJECT_ROOT>/ui/effects\1'
|
||||
|
|
|
@ -27,7 +27,6 @@ type Props = {
|
|||
onScrollBottom?: (any) => void,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
id?: string,
|
||||
// If using the default header, this is a unique ID needed to persist the state of the filter setting
|
||||
persistedStorageKey?: string,
|
||||
showHiddenByUser: boolean,
|
||||
|
@ -47,7 +46,7 @@ type Props = {
|
|||
searchOptions?: any,
|
||||
collectionId?: string,
|
||||
showNoSourceClaims?: boolean,
|
||||
onClick?: (e: any, index?: number) => void,
|
||||
onClick?: (e: any, claim?: ?Claim, index?: number) => void,
|
||||
};
|
||||
|
||||
export default function ClaimList(props: Props) {
|
||||
|
@ -56,7 +55,6 @@ export default function ClaimList(props: Props) {
|
|||
uris,
|
||||
headerAltControls,
|
||||
loading,
|
||||
id,
|
||||
persistedStorageKey,
|
||||
empty,
|
||||
defaultSort,
|
||||
|
@ -110,9 +108,9 @@ export default function ClaimList(props: Props) {
|
|||
setCurrentSort(currentSort === SORT_NEW ? SORT_OLD : SORT_NEW);
|
||||
}
|
||||
|
||||
function handleClaimClicked(e, index) {
|
||||
function handleClaimClicked(e, claim, index) {
|
||||
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>}
|
||||
<ClaimPreview
|
||||
uri={uri}
|
||||
containerId={id}
|
||||
indexInContainer={index}
|
||||
type={type}
|
||||
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';
|
||||
}}
|
||||
live={resolveLive(index)}
|
||||
onClick={handleClaimClicked}
|
||||
onClick={(e, claim, index) => handleClaimClicked(e, claim, index)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
|
|
@ -583,7 +583,6 @@ function ClaimListDiscover(props: Props) {
|
|||
)}
|
||||
<ClaimList
|
||||
tileLayout
|
||||
id={mainSearchKey}
|
||||
loading={loading}
|
||||
uris={finalUris}
|
||||
onScrollBottom={handleScrollBottom}
|
||||
|
@ -617,7 +616,6 @@ function ClaimListDiscover(props: Props) {
|
|||
</div>
|
||||
)}
|
||||
<ClaimList
|
||||
id={mainSearchKey}
|
||||
type={type}
|
||||
loading={loading}
|
||||
uris={finalUris}
|
||||
|
|
|
@ -29,7 +29,6 @@ import ClaimPreviewNoContent from './claim-preview-no-content';
|
|||
import { ENABLE_NO_SOURCE_CLAIMS } from 'config';
|
||||
import Button from 'component/button';
|
||||
import * as ICONS from 'constants/icons';
|
||||
import { CONTAINER_ID } from 'constants/navigation';
|
||||
|
||||
const AbandonedChannelPreview = lazyImport(() =>
|
||||
import('component/abandonedChannelPreview' /* webpackChunkName: "abandonedChannelPreview" */)
|
||||
|
@ -67,7 +66,7 @@ type Props = {
|
|||
actions: boolean | Node | string | number,
|
||||
properties: boolean | Node | string | number | ((Claim) => Node),
|
||||
empty?: Node,
|
||||
onClick?: (e: any, index?: number) => any,
|
||||
onClick?: (e: any, claim?: ?Claim, index?: number) => any,
|
||||
streamingUrl: ?string,
|
||||
getFile: (string) => void,
|
||||
customShouldHide?: (Claim) => boolean,
|
||||
|
@ -90,7 +89,6 @@ type Props = {
|
|||
disableNavigation?: boolean,
|
||||
mediaDuration?: string,
|
||||
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'.
|
||||
channelSubCount?: number,
|
||||
};
|
||||
|
@ -154,7 +152,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
isCollectionMine,
|
||||
collectionUris,
|
||||
disableNavigation,
|
||||
containerId,
|
||||
indexInContainer,
|
||||
channelSubCount,
|
||||
} = props;
|
||||
|
@ -223,7 +220,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
|
||||
const handleNavLinkClick = (e) => {
|
||||
if (onClick) {
|
||||
onClick(e, indexInContainer);
|
||||
onClick(e, claim, indexInContainer); // not sure indexInContainer is used for anything.
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
@ -232,10 +229,9 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
|
|||
to: {
|
||||
pathname: navigateUrl,
|
||||
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
|
||||
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
|
||||
},
|
||||
onClick: (e) => handleNavLinkClick(e),
|
||||
onAuxClick: (e) => handleNavLinkClick(e),
|
||||
onClick: handleNavLinkClick,
|
||||
onAuxClick: handleNavLinkClick,
|
||||
};
|
||||
|
||||
// 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) {
|
||||
if (onClick) {
|
||||
onClick(e, indexInContainer);
|
||||
onClick(e, claim, indexInContainer);
|
||||
}
|
||||
|
||||
if (claim && !pending && !disableNavigation) {
|
||||
history.push({
|
||||
pathname: navigateUrl,
|
||||
search: navigateSearch.toString() ? '?' + navigateSearch.toString() : '',
|
||||
state: containerId ? { [CONTAINER_ID]: containerId } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,14 +7,19 @@ import { selectUserVerifiedEmail } from 'redux/selectors/user';
|
|||
import { makeSelectNextUnplayedRecommended } from 'redux/selectors/content';
|
||||
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),
|
||||
recommendedContent: makeSelectRecommendedContentForUri(props.uri)(state),
|
||||
recommendedContentUris: makeSelectRecommendedContentForUri(props.uri)(state),
|
||||
nextRecommendedUri: makeSelectNextUnplayedRecommended(props.uri)(state),
|
||||
isSearching: selectIsSearching(state),
|
||||
isAuthenticated: selectUserVerifiedEmail(state),
|
||||
claim: makeSelectClaimForUri(props.uri)(state),
|
||||
});
|
||||
claim,
|
||||
claimId,
|
||||
};
|
||||
};
|
||||
|
||||
const perform = (dispatch) => ({
|
||||
doFetchRecommendedContent: (uri, mature) => dispatch(doFetchRecommendedContent(uri, mature)),
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
// @flow
|
||||
import { SHOW_ADS } from 'config';
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import ClaimList from 'component/claimList';
|
||||
import ClaimListDiscover from 'component/claimListDiscover';
|
||||
import Ads from 'web/component/ads';
|
||||
|
@ -10,14 +8,14 @@ import Card from 'component/common/card';
|
|||
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
|
||||
import Button from 'component/button';
|
||||
import classnames from 'classnames';
|
||||
import { CONTAINER_ID } from 'constants/navigation';
|
||||
import RecSys from 'recsys';
|
||||
|
||||
const VIEW_ALL_RELATED = 'view_all_related';
|
||||
const VIEW_MORE_FROM = 'view_more_from';
|
||||
|
||||
type Props = {
|
||||
uri: string,
|
||||
recommendedContent: Array<string>,
|
||||
recommendedContentUris: Array<string>,
|
||||
nextRecommendedUri: string,
|
||||
isSearching: boolean,
|
||||
doFetchRecommendedContent: (string, boolean) => void,
|
||||
|
@ -25,7 +23,7 @@ type Props = {
|
|||
isAuthenticated: boolean,
|
||||
claim: ?StreamClaim,
|
||||
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) {
|
||||
|
@ -33,23 +31,20 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
|
|||
uri,
|
||||
doFetchRecommendedContent,
|
||||
mature,
|
||||
recommendedContent,
|
||||
recommendedContentUris,
|
||||
nextRecommendedUri,
|
||||
isSearching,
|
||||
isAuthenticated,
|
||||
claim,
|
||||
doRecommendationUpdate,
|
||||
doRecommendationClicked,
|
||||
claimId,
|
||||
} = props;
|
||||
const [viewMode, setViewMode] = React.useState(VIEW_ALL_RELATED);
|
||||
const [recommendationId, setRecommendationId] = React.useState('');
|
||||
const [recommendationUrls, setRecommendationUrls] = React.useState();
|
||||
const history = useHistory();
|
||||
const signingChannel = claim && claim.signing_channel;
|
||||
const channelName = signingChannel ? signingChannel.name : null;
|
||||
const isMobile = useIsMobile();
|
||||
const isMedium = useIsMediumScreen();
|
||||
|
||||
const { onRecsLoaded: onRecommendationsLoaded, onClickedRecommended: onRecommendationClicked } = RecSys;
|
||||
React.useEffect(() => {
|
||||
function moveAutoplayNextItemToTop(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)) {
|
||||
const parentId = (history.location.state && history.location.state[CONTAINER_ID]) || '';
|
||||
const id = uuidv4();
|
||||
setRecommendationId(id);
|
||||
setRecommendationUrls(newRecommendationUrls);
|
||||
|
||||
doRecommendationUpdate(claim.claim_id, newRecommendationUrls, id, parentId);
|
||||
}
|
||||
}, [
|
||||
recommendedContent,
|
||||
nextRecommendedUri,
|
||||
recommendationUrls,
|
||||
setRecommendationUrls,
|
||||
claim,
|
||||
doRecommendationUpdate,
|
||||
history.location.state,
|
||||
]);
|
||||
}, [recommendedContentUris, nextRecommendedUri, recommendationUrls, setRecommendationUrls, claim]);
|
||||
|
||||
React.useEffect(() => {
|
||||
doFetchRecommendedContent(uri, mature);
|
||||
}, [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) {
|
||||
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>
|
||||
{viewMode === VIEW_ALL_RELATED && (
|
||||
<ClaimList
|
||||
id={recommendationId}
|
||||
type="small"
|
||||
loading={isSearching}
|
||||
uris={recommendationUrls}
|
||||
|
@ -177,8 +165,8 @@ function areEqual(prevProps: Props, nextProps: Props) {
|
|||
a.isAuthenticated !== b.isAuthenticated ||
|
||||
a.isSearching !== b.isSearching ||
|
||||
a.mature !== b.mature ||
|
||||
(a.recommendedContent && !b.recommendedContent) ||
|
||||
(!a.recommendedContent && b.recommendedContent) ||
|
||||
(a.recommendedContentUris && !b.recommendedContentUris) ||
|
||||
(!a.recommendedContentUris && b.recommendedContentUris) ||
|
||||
(a.claim && !b.claim) ||
|
||||
(!a.claim && b.claim)
|
||||
) {
|
||||
|
@ -189,14 +177,14 @@ function areEqual(prevProps: Props, nextProps: Props) {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (a.recommendedContent && b.recommendedContent) {
|
||||
if (a.recommendedContent.length !== b.recommendedContent.length) {
|
||||
if (a.recommendedContentUris && b.recommendedContentUris) {
|
||||
if (a.recommendedContentUris.length !== b.recommendedContentUris.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let i = a.recommendedContent.length;
|
||||
let i = a.recommendedContentUris.length;
|
||||
while (i--) {
|
||||
if (a.recommendedContent[i] !== b.recommendedContent[i]) {
|
||||
if (a.recommendedContentUris[i] !== b.recommendedContentUris[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,54 +1,20 @@
|
|||
// Created by xander on 6/21/2021
|
||||
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 recsysEndpoint = 'https://clickstream.odysee.com/log/video/view';
|
||||
const recsysId = 'lighthouse-v0';
|
||||
|
||||
/* RecSys */
|
||||
const RecsysData = {
|
||||
const PlayerEvent = {
|
||||
event: {
|
||||
start: 0,
|
||||
start: 0, // event types
|
||||
stop: 1,
|
||||
scrub: 2,
|
||||
speed: 3,
|
||||
},
|
||||
};
|
||||
|
||||
function createRecsys(claimId, userId, events, loadedAt, isEmbed) {
|
||||
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) {
|
||||
function newRecsysPlayerEvent(eventType, offset, arg) {
|
||||
if (arg) {
|
||||
return {
|
||||
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 = {
|
||||
endpoint: recsysEndpoint,
|
||||
recsysId: recsysId,
|
||||
videoId: null,
|
||||
userId: 0,
|
||||
debug: false,
|
||||
embedded: false,
|
||||
};
|
||||
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
@ -98,16 +45,13 @@ class RecsysPlugin extends Component {
|
|||
|
||||
// Plugin started
|
||||
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
|
||||
window.vjs = player;
|
||||
|
||||
this.player = player;
|
||||
|
||||
this.recsysEvents = [];
|
||||
this.loadedAt = Date.now();
|
||||
this.lastTimeUpdate = null;
|
||||
this.currentTimeUpdate = null;
|
||||
this.inPause = false;
|
||||
|
@ -124,57 +68,37 @@ class RecsysPlugin extends Component {
|
|||
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) {
|
||||
const recsysEvent = newRecsysEvent(RecsysData.event.start, this.player.currentTime());
|
||||
const recsysEvent = newRecsysPlayerEvent(PlayerEvent.event.start, this.player.currentTime());
|
||||
this.log('onPlay', recsysEvent);
|
||||
this.addRecsysEvent(recsysEvent);
|
||||
RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent, this.options_.embedded);
|
||||
|
||||
this.inPause = false;
|
||||
this.lastTimeUpdate = recsysEvent.offset;
|
||||
}
|
||||
|
||||
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.addRecsysEvent(recsysEvent);
|
||||
RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
|
||||
|
||||
this.inPause = true;
|
||||
}
|
||||
|
||||
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.addRecsysEvent(recsysEvent);
|
||||
RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
|
||||
}
|
||||
|
||||
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.addRecsysEvent(recsysEvent);
|
||||
RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
|
||||
}
|
||||
|
||||
onTimeUpdate(event) {
|
||||
|
@ -212,19 +136,19 @@ class RecsysPlugin extends Component {
|
|||
|
||||
if (fromTime !== curTime) {
|
||||
// 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.addRecsysEvent(recsysEvent);
|
||||
RecSys.onRecsysPlayerEvent(this.options_.videoId, recsysEvent);
|
||||
}
|
||||
}
|
||||
|
||||
onDispose(event) {
|
||||
this.sendRecsysEvents();
|
||||
RecSys.onPlayerDispose(this.options_.videoId, this.options_.embedded);
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
if (this.options_.debug) {
|
||||
// console.log(`Recsys Debug:`, JSON.stringify(args));
|
||||
console.log(`Recsys Player Debug:`, JSON.stringify(args));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ type Props = {
|
|||
startMuted: boolean,
|
||||
autoplay: boolean,
|
||||
autoplaySetting: boolean,
|
||||
embedded: boolean,
|
||||
toggleVideoTheaterMode: () => void,
|
||||
adUrl: ?string,
|
||||
claimId: ?string,
|
||||
|
@ -194,6 +195,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
const {
|
||||
autoplay,
|
||||
autoplaySetting,
|
||||
embedded,
|
||||
startMuted,
|
||||
source,
|
||||
sourceType,
|
||||
|
@ -590,6 +592,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
|
|||
player.recsys({
|
||||
videoId: claimId,
|
||||
userId: userId,
|
||||
embedded: embedded,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -125,7 +125,7 @@ function VideoViewer(props: Props) {
|
|||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [showAutoplayCountdown, setShowAutoplayCountdown] = useState(false);
|
||||
const [isEndededEmbed, setIsEndededEmbed] = useState(false);
|
||||
const [isEndedEmbed, setIsEndedEmbed] = useState(false);
|
||||
const vjsCallbackDataRef: any = React.useRef();
|
||||
const previousUri = usePrevious(uri);
|
||||
const embedded = useContext(EmbedContext);
|
||||
|
@ -142,7 +142,7 @@ function VideoViewer(props: Props) {
|
|||
useEffect(() => {
|
||||
if (uri && previousUri && uri !== previousUri) {
|
||||
setShowAutoplayCountdown(false);
|
||||
setIsEndededEmbed(false);
|
||||
setIsEndedEmbed(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [uri, previousUri]);
|
||||
|
@ -236,7 +236,7 @@ function VideoViewer(props: Props) {
|
|||
}
|
||||
|
||||
if (embedded) {
|
||||
setIsEndededEmbed(true);
|
||||
setIsEndedEmbed(true);
|
||||
} else if (!collectionId && autoplayNext) {
|
||||
setShowAutoplayCountdown(true);
|
||||
} else if (collectionId) {
|
||||
|
@ -247,7 +247,7 @@ function VideoViewer(props: Props) {
|
|||
}
|
||||
}, [
|
||||
embedded,
|
||||
setIsEndededEmbed,
|
||||
setIsEndedEmbed,
|
||||
autoplayMedia,
|
||||
setShowAutoplayCountdown,
|
||||
adUrl,
|
||||
|
@ -264,7 +264,7 @@ function VideoViewer(props: Props) {
|
|||
setIsLoading(false);
|
||||
setIsPlaying(true);
|
||||
setShowAutoplayCountdown(false);
|
||||
setIsEndededEmbed(false);
|
||||
setIsEndedEmbed(false);
|
||||
setReplay(false);
|
||||
setDoNavigate(false);
|
||||
analytics.videoIsPlaying(true, player);
|
||||
|
@ -391,7 +391,7 @@ function VideoViewer(props: Props) {
|
|||
<div
|
||||
className={classnames('file-viewer', {
|
||||
'file-viewer--is-playing': isPlaying,
|
||||
'file-viewer--ended-embed': isEndededEmbed,
|
||||
'file-viewer--ended-embed': isEndedEmbed,
|
||||
})}
|
||||
onContextMenu={stopContextMenu}
|
||||
>
|
||||
|
@ -403,8 +403,8 @@ function VideoViewer(props: Props) {
|
|||
doReplay={() => setReplay(true)}
|
||||
/>
|
||||
)}
|
||||
{isEndededEmbed && <FileViewerEmbeddedEnded uri={uri} />}
|
||||
{embedded && !isEndededEmbed && <FileViewerEmbeddedTitle uri={uri} />}
|
||||
{isEndedEmbed && <FileViewerEmbeddedEnded uri={uri} />}
|
||||
{embedded && !isEndedEmbed && <FileViewerEmbeddedTitle uri={uri} />}
|
||||
{/* disable this loading behavior because it breaks when player.play() promise hangs */}
|
||||
{isLoading && <LoadingScreen status={__('Loading')} />}
|
||||
|
||||
|
@ -458,6 +458,7 @@ function VideoViewer(props: Props) {
|
|||
videoTheaterMode={videoTheaterMode}
|
||||
playNext={doPlayNext}
|
||||
playPrevious={doPlayPrevious}
|
||||
embedded={embedded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
254
ui/recsys.js
Normal file
254
ui/recsys.js
Normal 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;
|
|
@ -116,43 +116,6 @@ reducers[ACTIONS.CLEAR_CONTENT_HISTORY_URI] = (state, action) => {
|
|||
|
||||
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) => {
|
||||
// return {
|
||||
// ...state,
|
||||
|
|
|
@ -3,7 +3,6 @@ import { createSelector } from 'reselect';
|
|||
import {
|
||||
makeSelectClaimForUri,
|
||||
selectClaimsByUri,
|
||||
makeSelectClaimsInChannelForCurrentPageState,
|
||||
makeSelectClaimIsNsfw,
|
||||
makeSelectClaimIsMine,
|
||||
makeSelectMediaTypeForUri,
|
||||
|
@ -11,7 +10,6 @@ import {
|
|||
parseURI,
|
||||
makeSelectContentTypeForUri,
|
||||
makeSelectFileNameForUri,
|
||||
selectClaimIdsByUri,
|
||||
} from 'lbry-redux';
|
||||
import { makeSelectRecommendedContentForUri } from 'redux/selectors/search';
|
||||
import { selectMutedChannels } from 'redux/selectors/blocked';
|
||||
|
@ -155,18 +153,6 @@ export const selectRecentHistory = createSelector(selectHistory, (history) => {
|
|||
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) =>
|
||||
createSelector(selectShowMatureContent, makeSelectClaimIsNsfw(uri), (showMatureContent, isClaimMature) => {
|
||||
return isClaimMature && !showMatureContent;
|
||||
|
@ -247,21 +233,3 @@ export const makeSelectInsufficientCreditsForUri = (uri: string) =>
|
|||
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]);
|
||||
|
|
|
@ -98,7 +98,7 @@ export const makeSelectRecommendedRecsysIdForClaimId = (claimId: string) =>
|
|||
createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => {
|
||||
// TODO: DRY this out.
|
||||
let poweredBy;
|
||||
if (claim) {
|
||||
if (claim && claimId) {
|
||||
const isMature = isClaimNsfw(claim);
|
||||
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
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue