Skip to content

Commit

Permalink
feat(series): watch history and favorites for series
Browse files Browse the repository at this point in the history
- update continue watching functionality for new series flow
- update favorites functionality for new series flow
  • Loading branch information
AntonLantukh committed May 6, 2023
1 parent 1e38107 commit acd9075
Show file tree
Hide file tree
Showing 14 changed files with 162 additions and 101 deletions.
32 changes: 21 additions & 11 deletions docs/features/user-watchlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,42 @@

<img title="" src="./../_images/watchlist.jpg" alt="continue-watchting" width="542">

######
######

## Favorites watchlist

This watchlist contains movies a user would like to watch in the future. It has the following behavior:

- In the video detail screen, the user can 'favorite' a movie
- In the video detail screen, the user can 'favorite' a movie or series.
- In case new series flow is used the whole series item is marked as a favorite one.
- In case old series flow us used only selected episode is marked as a favorite one
- On the homepage, a 'favorite' shelf appears, allowing the user to watch a media item
- The user menu shows a link to the list of favorites, including a 'clear' button

## Continue watchlist

This watchlist contains movies a user has not entirely watched. It has the following behavior:

Across the app
**Across the app**

- A progress bar shows how much of the content a viewer has watched.
- When a partially watched video is completed, it is removed from the shelf and the progress bar disappears
- Just started (<5%) and almost completed (>95%) plays are ignored for the best experience.
- For series
- A selected episode being watched is used.
- In case new series functionality is used only the last watched is stored
- In case old series functionality multiple episodes of the same series can be shown in Continue Watching section

On the homepage
**On the homepage**

- A “Continue Watching" shelf is added to the home page when there are incomplete items
- The most recent views appear first
- When empty, the shelf is hidden from the home page
- When empty, the shelf is hidden from the home page.
- For series
- In case old series functionality is used separate episodes are shown in Continue Watching section
- In case new series functionality is used the whole series object is shown. When clicking on it a customer get redirected to the episodes which has been watched recently.

The player
**The player**

- The player automatically resumes from the previous drop off point

Expand All @@ -38,14 +47,15 @@ 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**

To ensure a **cross-device experience**, we standardize on the following dataformat:
To ensure a **cross-device experience**, we standardize on the following dataformat:

### Watch history format

```
"history":[
{
"mediaid":"JfDmsRlE",
"seriesId":"kDsDas31", // optional param for the new series functionality
"progress":0.1168952164107527
}
]
Expand All @@ -62,8 +72,8 @@ To ensure a **cross-device experience**, we standardize on the following datafor
```

## Watchlist playlist
The media metadata for the stored media ids an be retrieved through a [watchlist playlist](https://developer.jwplayer.com/jwplayer/docs/creating-and-using-a-watchlist-playlist):

The media metadata for the stored media ids an be retrieved through a [watchlist playlist](https://developer.jwplayer.com/jwplayer/docs/creating-and-using-a-watchlist-playlist):

```
curl 'https://cdn.jwplayer.com/apps/watchlists/<watchlist-id>?media_ids=<media-ids-comma-seperated>'
Expand All @@ -75,16 +85,16 @@ Note that a watchlist need to be created first:
curl 'https://api.jwplayer.com/v2/sites/<property-id>/playlists/watchlist_playlist' \
-H 'authorization: <property-api-key>' \
-H 'content-type: application/json' \
--data-raw '{"metadata": {}}'
--data-raw '{"metadata": {}}'
```

## Configuration

The continue watching and favorites features can be enabled and disabled in the [app config](/docs/configuration.md).
The continue watching and favorites features can be enabled and disabled in the [app config](/docs/configuration.md).

## Cleeng

https://cleeng.com is a subscription management system, which pre-integrated in the web-app.
https://cleeng.com is a subscription management system, which pre-integrated in the web-app.

For Cleeng we store the watch history in the `customer externalData` attribute. See [here](https://developers.cleeng.com/reference/fetch-customers-data)

Expand Down
14 changes: 9 additions & 5 deletions src/containers/SeriesRedirect/SeriesRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getEpisodeToRedirect } from '#src/utils/series';
import Loading from '#src/pages/Loading/Loading';
import ErrorPage from '#components/ErrorPage/ErrorPage';
import { useSeriesEpisodes } from '#src/hooks/series/useEpisodes';
import { useEpisode } from '#src/hooks/series/useEpisode';

type Props = {
seriesId: string;
Expand All @@ -28,20 +29,23 @@ const SeriesRedirect = ({ seriesId, episodeId, mediaId }: Props) => {
const { t } = useTranslation('video');

const { isLoading, isPlaylistError, data } = useSeriesData(seriesId, mediaId);
const { series, seriesPlaylist } = data || {};
const { newSeries, playlist } = data || {};
const { series } = newSeries || {};

const { data: episodesData, isLoading: isEpisodesLoading } = useSeriesEpisodes(mediaId, !!series);
const { data: episodeData, isLoading: isEpisodeLoading } = useEpisode(episodeId, series);
// Only request list of episodes if we have no episode provided for the new flow
const { data: episodesData, isLoading: isEpisodesLoading } = useSeriesEpisodes(mediaId, !!series && !episodeId);

const play = useQueryParam('play') === '1';
const feedId = useQueryParam('r');

if (isLoading || isEpisodesLoading) {
if (isLoading || isEpisodesLoading || isEpisodeLoading) {
return <Loading />;
}

const toEpisode = getEpisodeToRedirect(episodeId, seriesPlaylist, !!data.series, episodesData?.pages);
const toEpisode = getEpisodeToRedirect(episodeId, playlist, episodeData, episodesData?.pages, !!series);

if (isPlaylistError || !seriesPlaylist || !toEpisode) {
if (isPlaylistError || !playlist || !toEpisode) {
return <ErrorPage title={t('series_error')} />;
}

Expand Down
2 changes: 1 addition & 1 deletion src/containers/ShelfList/ShelfList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const ShelfList = ({ rows }: Props) => {
const { accessModel } = useConfigStore(({ accessModel }) => ({ accessModel }), shallow);
const [rowCount, setRowCount] = useState(INITIAL_ROW_COUNT);

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

// User
const { user, subscription } = useAccountStore(({ user, subscription }) => ({ user, subscription }), shallow);
Expand Down
5 changes: 2 additions & 3 deletions src/hooks/series/useEpisode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,11 @@ export const useEpisode = (episodeId: string | undefined, series: Series | undef
if (!episodeId) {
throw Error('No episode id provided');
}
// Get all series for the given media id
const seriesDictionary = await getSeriesByMediaIds([episodeId]);
// Get an item details of the associated series (we need its episode and season)
const { season_number, episode_number } = (seriesDictionary?.[episodeId] || []).find((el) => el.series_id === series?.series_id) || {};

return { ...media, episodeNumber: String(episode_number || 0), seasonNumber: String(season_number || 0) };
// Add seriesId to work with watch history
return { ...media, episodeNumber: String(episode_number || 0), seasonNumber: String(season_number || 0), seriesId: series?.series_id };
},
{
// Only enable this query when having new series flow
Expand Down
5 changes: 3 additions & 2 deletions src/hooks/series/useSeries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ import { useQuery, UseQueryResult } from 'react-query';

import { getEpisodes, getMediaById, getSeries } from '#src/services/api.service';
import type { Series } from '#types/series';
import type { Playlist } from '#types/playlist';
import type { Playlist, PlaylistItem } from '#types/playlist';
import type { ApiError } from '#src/utils/api';

// Series and media items have the same id when creating via dashboard using new flow
export default (seriesId: string | undefined): UseQueryResult<{ series: Series; playlist: Playlist }, ApiError> => {
export default (seriesId: string | undefined): UseQueryResult<{ series: Series; media: PlaylistItem; playlist: Playlist }, ApiError> => {
return useQuery(
['series', seriesId],
async () => {
const [series, media, episodesData] = await Promise.all([getSeries(seriesId || ''), getMediaById(seriesId || ''), getEpisodes(seriesId || '', 0)]);

return {
series,
media,
playlist: {
playlist: episodesData.episodes,
title: media?.title,
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/series/useSeriesData.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import usePlaylist from '#src/hooks/usePlaylist';
import useSeries from '#src/hooks/series/useSeries';
import type { Playlist } from '#types/playlist';
import type { Playlist, PlaylistItem } from '#types/playlist';
import type { Series } from '#types/series';

const DEFAULT_DATA = { title: '', playlist: [] };

type Data = {
seriesPlaylist: Playlist;
series: Series | undefined;
playlist: Playlist;
newSeries: { series: Series | undefined; media: PlaylistItem | undefined };
};

export const useSeriesData = (
Expand All @@ -27,7 +27,7 @@ export const useSeriesData = (
const seriesPlaylist = seriesData?.playlist || playlistData || DEFAULT_DATA;

return {
data: { seriesPlaylist, series: seriesData?.series },
data: { playlist: seriesPlaylist, newSeries: { series: seriesData?.series, media: seriesData?.media } },
isPlaylistError: Boolean(seriesError && playlistError),
isLoading: isSeriesLoading || isPlaylistLoading,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ import { getSeriesIdFromCustomParams } from '#src/utils/media';
import SeriesRedirect from '#src/containers/SeriesRedirect/SeriesRedirect';
import type { PlaylistItem } from '#types/playlist';
import Loading from '#src/pages/Loading/Loading';
import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';

/**
* This media screen is used to redirect a series linking media item to an episode page.
*/
const MediaSeries: ScreenComponent<PlaylistItem> = ({ data: media, isLoading }) => {
const seriesId = getSeriesIdFromCustomParams(media) || '';

// prevent rendering the SeriesRedirect multiple times when we are loading data
// Retrieve watch history for new flow and find an episode of the selected series (if present)
const watchHistoryDictionary = useWatchHistoryStore((state) => state.watchHistory);
const episodeInProgress = watchHistoryDictionary.find((episode) => episode?.seriesId === media.mediaid);

// Prevent rendering the SeriesRedirect multiple times when we are loading data
if (isLoading) {
return <Loading />;
}

return <SeriesRedirect seriesId={seriesId} mediaId={media.mediaid} />;
return <SeriesRedirect seriesId={seriesId} mediaId={media.mediaid} episodeId={episodeInProgress?.mediaid} />;
};

export default MediaSeries;
Loading

0 comments on commit acd9075

Please sign in to comment.