Skip to content

Commit

Permalink
feat(project): support watchlists
Browse files Browse the repository at this point in the history
- Series Favorites warning added
- Config validation changes
- Review fixed
  • Loading branch information
“Anton committed Jun 21, 2022
1 parent b506b52 commit bff95ab
Show file tree
Hide file tree
Showing 10 changed files with 107 additions and 104 deletions.
39 changes: 4 additions & 35 deletions docs/features/user-watchlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The player

## Storage

For non-logged in users, the watch history is stored clientside in a cookie
For non-logged in users, the watch history is stored clientside in local storage.

For logged in users, the favorites and watch history are stored server side at the subscription or authentication provider to enable **cross-device watch history**

Expand All @@ -46,9 +46,6 @@ To ensure a **cross-device experience**, we standardize on the following datafor
"history":[ //todo formalize
{
"mediaid":"JfDmsRlE",
"title":"Agent 327",
"tags":"movie,Action",
"duration":231.458,
"progress":0.1168952164107527
}
]
Expand All @@ -60,9 +57,6 @@ To ensure a **cross-device experience**, we standardize on the following datafor
"favorites":[ //todo formalize
{
"mediaid":"JfDmsRlE",
"title":"Agent 327",
"tags":"movie,Action",
"duration":231
}
]
```
Expand Down Expand Up @@ -112,44 +106,19 @@ Example data format
"history":[
{
"mediaid":"JfDmsRlE",
"title":"Agent 327",
"tags":"movie,Action",
"duration":231.458,
"progress":0.1168952164107527
}
],
"favorites":[
{
"mediaid":"JfDmsRlE",
"title":"Agent 327",
"tags":"movie,Action",
"duration":231
}
]
}
```

### Max 25 items
### Max 48 items

The externalData attribute of Cleeng can contain max 5000 characters. This is 50-75 objects.
The length of one stringified object of Continue Watching equals to 52 symbols, one Favorites object equals to 22 symbols. Taking into account only Continue Watching objects, we get 5000 / 52 = ~96, so 48 for Favorites and 48 for Continue Watching. We also leave some extra space for possible further updates.

So we maximize the number of history and favorite objects to 25 and rotate the oldest one out based on the 'updated' attribute

The following storage optimized format is under discussion to ensure more items can be stored:

## Optimized format (draft)

```
"history":[ //todo formalize
{
"mediaid":"JfDmsRlE",
"progress":0.1168952164107527,
"updated":1643900003
},
{
"mediaid":"3qMpbJM6",
"progress":0.81687652164107234,
"updated":1643900203
}
]
```
We rotate the oldest continue watching object to the first item position after its progress property gets a new value.
9 changes: 5 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { restoreWatchHistory } from '#src/stores/WatchHistoryController';
import { initializeAccount } from '#src/stores/AccountController';
import { initializeFavorites } from '#src/stores/FavoritesController';
import { logDev } from '#src/utils/common';
import { PersonalShelf } from '#src/enum/PersonalShelf';

import '#src/i18n/config';
import '#src/styles/main.scss';
Expand All @@ -32,13 +33,13 @@ class App extends Component {
await initializeAccount();
}

// We only request favorites and continue_watching data if these features are enabled
// We first initialize the account otherwise if we have favorites saved as externalData and in a local storage the sections may blink if data differs
if (config?.features?.continueWatchingList) {
// We only request favorites and continue_watching data if there is a corresponding content item
// We first initialize the account otherwise if we have favorites saved as externalData and in a local storage the sections may blink
if (config.content.some((el) => el.type === PersonalShelf.ContinueWatching)) {
await restoreWatchHistory();
}

if (config?.features?.favoritesList) {
if (config.content.some((el) => el.type === PersonalShelf.Favorites)) {
await initializeFavorites();
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export const VideoProgressMinMax = {

export const PLAYLIST_LIMIT = 25;

export const MAX_WATCHLIST_ITEMS_COUNT = 30;
// The externalData attribute of Cleeng can contain max 5000 characters
export const MAX_WATCHLIST_ITEMS_COUNT = 48;

export const ADYEN_TEST_CLIENT_KEY = 'test_I4OFGUUCEVB5TI222AS3N2Y2LY6PJM3K';

Expand Down
12 changes: 7 additions & 5 deletions src/containers/Cinema/Cinema.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import classNames from 'classnames';

import styles from './Cinema.module.scss';
Expand All @@ -8,9 +8,11 @@ import { useWatchHistoryListener } from '#src/hooks/useWatchHistoryListener';
import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';
import { addScript } from '#src/utils/dom';
import useOttAnalytics from '#src/hooks/useOttAnalytics';
import { ConfigContext } from '#src/providers/ConfigProvider';
import { deepCopy } from '#src/utils/collection';
import type { JWPlayer } from '#types/jwplayer';
import type { PlaylistItem } from '#types/playlist';
import type { Config } from '#types/Config';
import { useConfigStore } from '#src/stores/ConfigStore';
import { saveItem } from '#src/stores/WatchHistoryController';
import { usePlaylistItemCallback } from '#src/hooks/usePlaylistItemCallback';
Expand All @@ -28,10 +30,10 @@ type Props = {
};

const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActive, onUserInActive, feedId, isTrailer = false }: Props) => {
const { player, continueWatchingList } = useConfigStore(({ config }) => ({
player: config.player,
continueWatchingList: config.features?.continueWatchingList,
}));
const config: Config = useContext(ConfigContext);
const player = config.player;
const continueWatchingList = config.features?.continueWatchingList;

const playerElementRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<JWPlayer>();
const loadingRef = useRef(false);
Expand Down
35 changes: 11 additions & 24 deletions src/screens/Movie/Movie.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import shallow from 'zustand/shallow';
import styles from './Movie.module.scss';

import { useFavoritesStore } from '#src/stores/FavoritesStore';
import { toggleFavorite } from '#src/stores/FavoritesController';
import useBlurImageUpdater from '#src/hooks/useBlurImageUpdater';
import { cardUrl, movieURL, videoUrl } from '#src/utils/formatting';
import type { PlaylistItem } from '#types/playlist';
Expand All @@ -22,10 +23,8 @@ import LoadingOverlay from '#src/components/LoadingOverlay/LoadingOverlay';
import { useConfigStore } from '#src/stores/ConfigStore';
import { useAccountStore } from '#src/stores/AccountStore';
import { addConfigParamToUrl } from '#src/utils/configOverride';
import { removeItem, saveItem } from '#src/stores/FavoritesController';
import usePlaylist from '#src/hooks/usePlaylist';
import useEntitlement from '#src/hooks/useEntitlement';
import useToggle from '#src/hooks/useToggle';
import StartWatchingButton from '#src/containers/StartWatchingButton/StartWatchingButton';
import { MAX_WATCHLIST_ITEMS_COUNT } from '#src/config';

Expand All @@ -37,7 +36,6 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
const { t } = useTranslation('video');
const [hasShared, setHasShared] = useState<boolean>(false);
const [playTrailer, setPlayTrailer] = useState<boolean>(false);
const [isFavoritesWarningShown, onFavoritesWarningToggle] = useToggle();

// Routing
const history = useHistory();
Expand All @@ -61,9 +59,10 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
const { data: playlist } = usePlaylist(features?.recommendationsPlaylist || '', { related_media_id: id });

// Favorite
const { isFavorited, favoritesCount } = useFavoritesStore((state) => ({
const { isFavorited, toggleWarning, isWarningShown } = useFavoritesStore((state) => ({
isFavorited: !!item && state.hasItem(item),
favoritesCount: state.favorites?.length || 0,
isWarningShown: state.isWarningShown,
toggleWarning: state.toggleWarning,
}));

// User, entitlement
Expand All @@ -72,24 +71,12 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.

// Handlers
const onFavoriteButtonClick = useCallback(() => {
if (!item) {
return;
}

if (isFavorited) {
removeItem(item);

return;
}

// If we exceed the max available number of favorites, we show a warning
if (favoritesCount >= MAX_WATCHLIST_ITEMS_COUNT) {
onFavoritesWarningToggle();
return;
}
toggleFavorite(item);
}, [item]);

saveItem(item);
}, [item, isFavorited, favoritesCount, onFavoritesWarningToggle]);
const onToggleWarning = useCallback(() => {
toggleWarning();
}, [toggleWarning]);

const goBack = () => item && history.push(videoUrl(item, searchParams.get('r'), false));
const onCardClick = (item: PlaylistItem) => history.push(cardUrl(item));
Expand Down Expand Up @@ -201,10 +188,10 @@ const Movie = ({ match, location }: RouteComponentProps<MovieRouteParams>): JSX.
) : undefined}

<Alert
open={isFavoritesWarningShown}
open={isWarningShown}
title={t('video:favorites_warning.title')}
body={t('video:favorites_warning.body', { count: MAX_WATCHLIST_ITEMS_COUNT })}
onClose={onFavoritesWarningToggle}
onClose={onToggleWarning}
/>
</>
</VideoComponent>
Expand Down
27 changes: 24 additions & 3 deletions src/screens/Series/Series.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import styles from './Series.module.scss';

import useEntitlement from '#src/hooks/useEntitlement';
import CardGrid from '#src/components/CardGrid/CardGrid';
import { MAX_WATCHLIST_ITEMS_COUNT } from '#src/config';
import useBlurImageUpdater from '#src/hooks/useBlurImageUpdater';
import { episodeURL } from '#src/utils/formatting';
import Filter from '#src/components/Filter/Filter';
Expand All @@ -25,8 +26,9 @@ import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';
import { useConfigStore } from '#src/stores/ConfigStore';
import { useAccountStore } from '#src/stores/AccountStore';
import { useFavoritesStore } from '#src/stores/FavoritesStore';
import { removeItem, saveItem } from '#src/stores/FavoritesController';
import { toggleFavorite } from '#src/stores/FavoritesController';
import StartWatchingButton from '#src/containers/StartWatchingButton/StartWatchingButton';
import Alert from '#src/components/Alert/Alert';

type SeriesRouteParams = {
id: string;
Expand Down Expand Up @@ -61,14 +63,27 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
const filters = getFiltersFromSeries(seriesPlaylist.playlist);
const filteredPlaylist = useMemo(() => filterSeries(seriesPlaylist.playlist, seasonFilter), [seriesPlaylist, seasonFilter]);

const isFavorited = useFavoritesStore((state) => !!item && state.hasItem(item));
// Favorite
const { isFavorited, toggleWarning, isWarningShown } = useFavoritesStore((state) => ({
isFavorited: !!item && state.hasItem(item),
isWarningShown: state.isWarningShown,
toggleWarning: state.toggleWarning,
}));

const watchHistoryDictionary = useWatchHistoryStore((state) => state.getDictionary());

// User, entitlement
const { user, subscription } = useAccountStore(({ user, subscription }) => ({ user, subscription }), shallow);
const { isEntitled } = useEntitlement(item);

// Handlers
const onFavoriteButtonClick = useCallback(() => {
toggleFavorite(item);
}, [item]);

const onToggleWarning = useCallback(() => {
toggleWarning();
}, [toggleWarning]);
const goBack = () => item && seriesPlaylist && history.push(episodeURL(seriesPlaylist, item.mediaid, false));
const onCardClick = (item: PlaylistItem) => seriesPlaylist && history.push(episodeURL(seriesPlaylist, item.mediaid));
const onShareClick = (): void => {
Expand Down Expand Up @@ -162,7 +177,7 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
onTrailerClose={() => setPlayTrailer(false)}
isFavorited={isFavorited}
isFavoritesEnabled={isFavoritesEnabled}
onFavoriteButtonClick={() => (isFavorited ? removeItem(item) : saveItem(item))}
onFavoriteButtonClick={onFavoriteButtonClick}
startWatchingButton={<StartWatchingButton item={item} />}
isSeries
>
Expand Down Expand Up @@ -192,6 +207,12 @@ const Series = ({ match, location }: RouteComponentProps<SeriesRouteParams>): JS
isLoggedIn={!!user}
hasSubscription={!!subscription}
/>
<Alert
open={isWarningShown}
title={t('video:favorites_warning.title')}
body={t('video:favorites_warning.body', { count: MAX_WATCHLIST_ITEMS_COUNT })}
onClose={onToggleWarning}
/>
</>
</VideoComponent>
</React.Fragment>
Expand Down
48 changes: 23 additions & 25 deletions src/services/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,32 @@ import { string, boolean, array, object, SchemaOf, StringSchema, mixed } from 'y
import type { Config, Content, Menu, Styling, Features, Cleeng } from '#types/Config';
import { PersonalShelf } from '#src/enum/PersonalShelf';
import i18n from '#src/i18n/config';
import { logDev } from '#src/utils/common';

/**
* Set config setup changes in both config.services.ts and config.d.ts
* */

/**
* We check here that we:
* 1. Added favoritesList / continueWatchingList feature
* 2. Included a corresponding element (with favorites or continue_watching type) in the content array
* We check here that if we added a content item with favorites / continue_watching type,
* then we also set up a corresponding playlistId (favoritesList / continueWatchingList)
*/
const checkAdditionalFeatures = (content: Content[], playlistId: string | undefined | null, type: PersonalShelf) => {
const hasAdditionalRowInContent = content.some((el) => el.type === type);

if (playlistId && !hasAdditionalRowInContent) {
throw new Error(`Please add an item with a '${type}' type to "content" array`);
}

if (!playlistId && hasAdditionalRowInContent) {
throw new Error(`Please add an additional feature ${type === PersonalShelf.Favorites ? 'favoritesList' : 'continueWatchingList'}`);
}

return true;
const checkContentItems = (config: Config) => {
const { content, features } = config;

[PersonalShelf.ContinueWatching, PersonalShelf.Favorites].forEach((type) => {
const hasAdditionalRowInContent = content.some((el) => el.type === type);
const isFavoritesRow = type === PersonalShelf.Favorites;
const playlistId = isFavoritesRow ? features?.favoritesList : features?.continueWatchingList;

if (!playlistId && hasAdditionalRowInContent) {
logDev(
`If you want to use a ${isFavoritesRow ? 'favorites' : 'continue_watching'} row please add a corresponding playlistId ${
isFavoritesRow ? 'favoritesList' : 'continueWatchingList'
} in a features section`,
);
}
});
};

const contentSchema: SchemaOf<Content> = object({
Expand All @@ -48,16 +52,8 @@ const featuresSchema: SchemaOf<Features> = object({
enableSharing: boolean().notRequired(),
recommendationsPlaylist: string().nullable(),
searchPlaylist: string().nullable(),
continueWatchingList: string().test('has-continue_watching-list-element', 'errorMessage', (value, context) => {
// @ts-expect-error https://github.com/jquense/yup/issues/1631
const { content, features } = context.from[1].value as Config;
return checkAdditionalFeatures(content, value, PersonalShelf.ContinueWatching);
}),
favoritesList: string().test('has-continue_watching-list-element', 'errorMessage', (value, context) => {
// @ts-expect-error https://github.com/jquense/yup/issues/1631
const { content, features } = context.from[1].value as Config;
return checkAdditionalFeatures(content, value, PersonalShelf.Favorites);
}),
continueWatchingList: string().nullable(),
favoritesList: string().nullable(),
});

const cleengSchema: SchemaOf<Cleeng> = object({
Expand Down Expand Up @@ -124,6 +120,8 @@ const loadConfig = async (configLocation: string) => {
throw new Error('No config found');
}

checkContentItems(data);

return enrichConfig(data);
};

Expand Down
Loading

0 comments on commit bff95ab

Please sign in to comment.