diff --git a/docs/features/user-watchlists.md b/docs/features/user-watchlists.md index 184984108..b63a136ee 100644 --- a/docs/features/user-watchlists.md +++ b/docs/features/user-watchlists.md @@ -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** @@ -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 } ] @@ -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 } ] ``` @@ -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. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 60fe3bc49..ab1ed3435 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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(); } } diff --git a/src/config.ts b/src/config.ts index f08d71415..b3f489fda 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'; diff --git a/src/containers/Cinema/Cinema.tsx b/src/containers/Cinema/Cinema.tsx index b43e5bde1..b4ca4e653 100644 --- a/src/containers/Cinema/Cinema.tsx +++ b/src/containers/Cinema/Cinema.tsx @@ -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'; @@ -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'; @@ -28,10 +30,10 @@ type Props = { }; const Cinema: React.FC = ({ 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(null); const playerRef = useRef(); const loadingRef = useRef(false); diff --git a/src/screens/Movie/Movie.tsx b/src/screens/Movie/Movie.tsx index 111d7f4d8..828212380 100644 --- a/src/screens/Movie/Movie.tsx +++ b/src/screens/Movie/Movie.tsx @@ -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'; @@ -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'; @@ -37,7 +36,6 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. const { t } = useTranslation('video'); const [hasShared, setHasShared] = useState(false); const [playTrailer, setPlayTrailer] = useState(false); - const [isFavoritesWarningShown, onFavoritesWarningToggle] = useToggle(); // Routing const history = useHistory(); @@ -61,9 +59,10 @@ const Movie = ({ match, location }: RouteComponentProps): 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 @@ -72,24 +71,12 @@ const Movie = ({ match, location }: RouteComponentProps): 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)); @@ -201,10 +188,10 @@ const Movie = ({ match, location }: RouteComponentProps): JSX. ) : undefined} diff --git a/src/screens/Series/Series.tsx b/src/screens/Series/Series.tsx index 423b65ba4..3f727c536 100644 --- a/src/screens/Series/Series.tsx +++ b/src/screens/Series/Series.tsx @@ -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'; @@ -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; @@ -61,7 +63,13 @@ const Series = ({ match, location }: RouteComponentProps): 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 @@ -69,6 +77,13 @@ const Series = ({ match, location }: RouteComponentProps): JS 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 => { @@ -162,7 +177,7 @@ const Series = ({ match, location }: RouteComponentProps): JS onTrailerClose={() => setPlayTrailer(false)} isFavorited={isFavorited} isFavoritesEnabled={isFavoritesEnabled} - onFavoriteButtonClick={() => (isFavorited ? removeItem(item) : saveItem(item))} + onFavoriteButtonClick={onFavoriteButtonClick} startWatchingButton={} isSeries > @@ -192,6 +207,12 @@ const Series = ({ match, location }: RouteComponentProps): JS isLoggedIn={!!user} hasSubscription={!!subscription} /> + diff --git a/src/services/config.service.ts b/src/services/config.service.ts index a934371d5..954ed3824 100644 --- a/src/services/config.service.ts +++ b/src/services/config.service.ts @@ -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 = object({ @@ -48,16 +52,8 @@ const featuresSchema: SchemaOf = 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 = object({ @@ -124,6 +120,8 @@ const loadConfig = async (configLocation: string) => { throw new Error('No config found'); } + checkContentItems(data); + return enrichConfig(data); }; diff --git a/src/stores/FavoritesController.ts b/src/stores/FavoritesController.ts index f9b0a4080..72f9b564b 100644 --- a/src/stores/FavoritesController.ts +++ b/src/stores/FavoritesController.ts @@ -5,6 +5,7 @@ import { useFavoritesStore } from '#src/stores/FavoritesStore'; import { useConfigStore } from '#src/stores/ConfigStore'; import type { Favorite, SerializedFavorite } from '#types/favorite'; import type { PlaylistItem } from '#types/playlist'; +import { MAX_WATCHLIST_ITEMS_COUNT } from '#src/config'; const PERSIST_KEY_FAVORITES = `favorites${window.configId ? `-${window.configId}` : ''}`; @@ -63,6 +64,30 @@ export const removeItem = (item: PlaylistItem) => { persistFavorites(); }; +export const toggleFavorite = (item: PlaylistItem | undefined) => { + const { favorites, hasItem, toggleWarning } = useFavoritesStore.getState(); + + if (!item) { + return; + } + + const isFavorited = hasItem(item); + + if (isFavorited) { + removeItem(item); + + return; + } + + // If we exceed the max available number of favorites, we show a warning + if (favorites?.length >= MAX_WATCHLIST_ITEMS_COUNT) { + toggleWarning(); + return; + } + + saveItem(item); +}; + export const clear = () => { useFavoritesStore.setState({ favorites: [] }); diff --git a/src/stores/FavoritesStore.ts b/src/stores/FavoritesStore.ts index a07a0eb5b..bc92b628d 100644 --- a/src/stores/FavoritesStore.ts +++ b/src/stores/FavoritesStore.ts @@ -6,12 +6,16 @@ import type { Playlist, PlaylistItem } from '#types/playlist'; type FavoritesState = { favorites: Favorite[]; + isWarningShown: boolean; hasItem: (item: PlaylistItem) => boolean; getPlaylist: () => Playlist; + toggleWarning: () => void; }; -export const useFavoritesStore = createStore('FavoritesState', (_, get) => ({ +export const useFavoritesStore = createStore('FavoritesState', (set, get) => ({ favorites: [], + isWarningShown: false, + toggleWarning: () => set({ isWarningShown: !get().isWarningShown }), hasItem: (item: PlaylistItem) => get().favorites.some((favoriteItem) => favoriteItem.mediaid === item.mediaid), getPlaylist: () => ({ diff --git a/src/stores/WatchHistoryController.ts b/src/stores/WatchHistoryController.ts index b367efe79..beee2fe65 100644 --- a/src/stores/WatchHistoryController.ts +++ b/src/stores/WatchHistoryController.ts @@ -79,15 +79,10 @@ export const saveItem = (item: PlaylistItem, videoProgress: number | null) => { if (!videoProgress) return; const watchHistoryItem = createWatchHistoryItem(item, videoProgress); - const index = watchHistory.findIndex(({ mediaid }) => mediaid === watchHistoryItem.mediaid); let updatedHistory = watchHistory; - if (index > -1) { - updatedHistory = [watchHistoryItem, ...watchHistory.filter(({ mediaid }) => mediaid !== watchHistoryItem.mediaid)]; - } else { - updatedHistory = [watchHistoryItem, ...watchHistory]; - } + updatedHistory = [watchHistoryItem, ...watchHistory.filter(({ mediaid }) => mediaid !== watchHistoryItem.mediaid)]; if (watchHistory.length > MAX_WATCHLIST_ITEMS_COUNT) { updatedHistory = updatedHistory.slice(0, MAX_WATCHLIST_ITEMS_COUNT - 1);