Skip to content

Commit

Permalink
feat(series): add new series-api
Browse files Browse the repository at this point in the history
- we still support old approach with series as media items
- new Switcher component added to check whether new api can be used
- SeriesNew component now uses new api to show series
- dev host content-portal added to test demo features
- dev config added for dev-api
- fixed player bahaviour to automatically play next series episode if needed
- docs updates
- (!) we use features.favoritesList to get media items (temporary)
  • Loading branch information
“Anton committed Jun 30, 2022
1 parent 606dab4 commit 29e8599
Show file tree
Hide file tree
Showing 20 changed files with 653 additions and 96 deletions.
54 changes: 22 additions & 32 deletions docs/features/series.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,24 +91,26 @@ GET playlist/xdAqW8ya
}
```

## Native series - Coming soon
## Native series

JW Player will get native series management from the JW Dashboard:
JW Player has native series management from the JW Dashboard:

- simplifies the series creation workflow
- automatically calculate the number of episodes and duration of series
- contains trailers and bonus content
This section describes how this will work.

This section describes how this will work.

### Creating native series in the dashboard

1. Customers define series
2. Customers publish their series by putting an episode into a playlist
3. The episode can be recognized with the tag `Series`
2. Customers create media items each of which represents a series
3. Customers add a `seriesId` custom param to created media items
4. Customers publish their series by putting media items into a playlist

### Native series in shelves and libraries

[Shelves and libraries](shelves-and-libraries.md) load their data using the [GET playlist endpoint](https://developer.jwplayer.com/jwplayer/reference/get_v2-playlists-playlist-id). Some items in this playlis refer to series. These can be recognized with the tag `Series`
[Shelves and libraries](shelves-and-libraries.md) load their data using the [GET playlist endpoint](https://developer.jwplayer.com/jwplayer/reference/get_v2-playlists-playlist-id). Some items in this playlis refer to series. These can be recognized with the `seriesId` custom param.

```
GET playlist\<playlistid>
Expand All @@ -117,47 +119,29 @@ GET playlist\<playlistid>
"medaid":"dwEE1oBP",
"title":"Video Title",
"description":"Lorem ipsum",
"tags":["Series"],
"seriesId": "aSZZ1oBP",
"images":[],
"sources":[],
"tracks":[]
}
]
```

Since the playlist includes an episode metadata, it needs to be overwritten with a series metadata. This series metadata is retrieve using the the the following endpoint:

```
GET series?media_ids=dwEE1oBP,1q2w3e4r
{
"dwEE1oBP": {
"series": [
{
"serieid": "ssFF1oBP",
"title": "My series",
"description": "Lorem Ipsum",
"total_duration": 9000,
"episode_count": 15,
"season_count": 2,
"custom_field": "abc"
}
]
},
"1q2w3e4r": null
}
```
We do not show episode number and season number for separate series episodes. If you click on such an item, you will see a simple movie page.

### Native series detail window

The serie detail window loads the series playlist using a GET Series endpoint:
The series detail window loads the series playlist using a GET Series endpoint:

```
GET series/{series_id}
GET /apps/series/{series_id}
{
"title": "A Series of Unfortunate Events",
"description": "The series follow’
"showrunner": "Mark Hudis"
"series_id": "12345678",
"total_duration": 12,
"episode_count": 2,
"episodes": [],
"seasons": [
{
"season_id": "abcdefgh",
Expand All @@ -179,7 +163,7 @@ The serie detail window loads the series playlist using a GET Series endpoint:
},
]
}
]}]
]}]
```

Notice that the episodes don't include metadata (title, description, image, etc. ). That needs be retrieved seperately. This can be done one-by-one using [GET Media](https://developer.jwplayer.com/jwplayer/reference/get_v2-media-media-id), but to do this more efficiently we use the a [watchlist playlist](https://developer.jwplayer.com/jwplayer/reference/get_apps-watchlists-playlist-id):
Expand All @@ -201,8 +185,14 @@ Notice that the episodes don't include metadata (title, description, image, etc.
}
```

**(!)** By default now `features.favoritesList` is used. This is to be changed in the future.

This playlist type is developed for [user watchlists](user-watchlist.md) but will also work here. Make sure the watchlist is created.

## Native series and search

Customer are advised to exclude series episodes from the search playlists by using tags. Likewise customers should ensure the series title and description are part of the first episode.

## Coming soon

We will add full Favorites and Continue Watching support
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"scripts": {
"prepare": "husky install",
"start": "vite",
"start:dev-api": "APP_API_ENV='dev' vite",
"build": "vite build",
"test": "TZ=UTC vitest run",
"test-watch": "TZ=UTC vitest",
Expand Down Expand Up @@ -117,4 +118,4 @@
"glob-parent": "^5.1.2",
"codeceptjs/**/ansi-regex": "^4.1.1"
}
}
}
4 changes: 2 additions & 2 deletions src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Route, Switch } from 'react-router-dom';
import { useTranslation } from 'react-i18next';

import User from '../../screens/User/User';
import Series from '../../screens/Series/Series';
import SeriesSwitcher from '../../containers/SeriesSwitcher/SeriesSwitcher';
import Layout from '../../containers/Layout/Layout';
import Home from '../../screens/Home/Home';
import Playlist from '../../screens/Playlist/Playlist';
Expand Down Expand Up @@ -34,7 +34,7 @@ const Root: FC<Props> = ({ error }: Props) => {
<Route path="/" component={Home} exact />
<Route path="/p/:id" component={Playlist} exact />
<Route path="/m/:id/:slug?" component={Movie} exact />
<Route path="/s/:id/:slug?" component={Series} />
<Route path="/s/:id/:slug?" component={SeriesSwitcher} />
<Route path="/q/:query?" component={Search} />
<Route path="/u/:page?" component={User} />
<Route path="/o/about" component={About} />
Expand Down
3 changes: 2 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const API_BASE_URL = 'https://content.jwplatform.com';
// Use yarn start:dev-api to expose APP_API_ENV variable and use dev api host
export const API_BASE_URL = import.meta.env.APP_API_ENV === 'dev' ? 'https://content-portal.jwplatform.com' : 'https://content.jwplatform.com';

export const VideoProgressMinMax = {
Min: 0.05,
Expand Down
69 changes: 37 additions & 32 deletions src/containers/Cinema/Cinema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import classNames from 'classnames';

import styles from './Cinema.module.scss';

import { VideoProgressMinMax } from '#src/config';
import { API_BASE_URL, VideoProgressMinMax } from '#src/config';
import { useWatchHistoryListener } from '#src/hooks/useWatchHistoryListener';
import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';
import { ConfigContext } from '#src/providers/ConfigProvider';
Expand Down Expand Up @@ -37,7 +37,7 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi
const loadingRef = useRef(false);
const seekToRef = useRef(-1);
const [libLoaded, setLibLoaded] = useState(!!window.jwplayer);
const scriptUrl = `https://content.jwplatform.com/libraries/${config.player}.js`;
const scriptUrl = `${API_BASE_URL}/libraries/${config.player}.js`;
const enableWatchHistory = config.content.some((el) => el.type === PersonalShelf.ContinueWatching) && !isTrailer;
const setPlayer = useOttAnalytics(item, feedId);
const handlePlaylistItemCallback = usePlaylistItemCallback();
Expand All @@ -53,7 +53,9 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi

useWatchHistoryListener(() => (enableWatchHistory ? saveItem(item, getProgress()) : null));

const handlePlay = useCallback(() => onPlay && onPlay(), [onPlay]);
const handlePlay = useCallback(() => {
onPlay && onPlay();
}, [onPlay]);

const handlePause = useCallback(() => {
enableWatchHistory && saveItem(item, getProgress());
Expand All @@ -69,23 +71,29 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi

const handleUserInactive = useCallback(() => onUserInActive && onUserInActive(), [onUserInActive]);

useEffect(() => {
if (!playerRef.current) return;

playerRef.current.on('complete', handleComplete);
playerRef.current.on('play', handlePlay);
playerRef.current.on('pause', handlePause);
playerRef.current.on('userActive', handleUserActive);
playerRef.current.on('userInactive', handleUserInactive);

return () => {
playerRef.current?.off('complete', handleComplete);
playerRef.current?.off('play', handlePlay);
playerRef.current?.off('pause', handlePause);
playerRef.current?.off('userActive', handleUserActive);
playerRef.current?.off('userInactive', handleUserInactive);
};
}, [handleComplete, handlePause, handlePlay, handleUserActive, handleUserInactive]);
const handleBeforePlay = useCallback(() => {
if (seekToRef.current > 0) {
playerRef.current?.seek(seekToRef.current);
seekToRef.current = -1;
}
}, [seekToRef, playerRef]);

const attachEvents = useCallback(() => {
playerRef.current?.on('beforePlay', handleBeforePlay);
playerRef.current?.on('complete', handleComplete);
playerRef.current?.on('play', handlePlay);
playerRef.current?.on('pause', handlePause);
playerRef.current?.on('userActive', handleUserActive);
playerRef.current?.on('userInactive', handleUserInactive);
}, [playerRef, handleComplete, handlePlay, handlePause, handleUserActive, handleUserInactive, handleBeforePlay]);

const detachEvents = useCallback(() => {
playerRef.current?.off('complete');
playerRef.current?.off('play');
playerRef.current?.off('pause');
playerRef.current?.off('userActive');
playerRef.current?.off('userInactive');
}, []);

useEffect(() => {
if (!window.jwplayer && !loadingRef.current) {
Expand Down Expand Up @@ -130,9 +138,10 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi
if (currentItem && currentItem.mediaid === item.mediaid) {
return;
}

// load new item
playerRef.current.setConfig({ playlist: [deepCopy(item)], autostart: true });
playerRef.current.load([deepCopy(item)]);
detachEvents();
attachEvents();
calculateWatchHistoryProgress();
};

Expand All @@ -148,19 +157,13 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi
height: '100%',
mute: false,
autostart: true,
repeat: false,
});

attachEvents();
calculateWatchHistoryProgress();
setPlayer(playerRef.current);

const handleBeforePlay = () => {
if (seekToRef.current > 0) {
playerRef.current?.seek(seekToRef.current);
seekToRef.current = -1;
}
};

playerRef.current.on('beforePlay', handleBeforePlay);
playerRef.current.setPlaylistItemCallback(handlePlaylistItemCallback);
};

Expand All @@ -171,15 +174,17 @@ const Cinema: React.FC<Props> = ({ item, onPlay, onPause, onComplete, onUserActi
if (libLoaded) {
initializePlayer();
}
}, [libLoaded, item, onPlay, onPause, onUserActive, onUserInActive, onComplete, config.player, enableWatchHistory, setPlayer, handlePlaylistItemCallback]);
}, [libLoaded, item, config.player, enableWatchHistory, setPlayer, handlePlaylistItemCallback, detachEvents, attachEvents]);

useEffect(() => {
return () => {
if (playerRef.current) {
// Detaching events before component unmount
detachEvents();
playerRef.current.remove();
}
};
}, []);
}, [detachEvents]);

return (
<div className={classNames(styles.cinema, { [styles.fill]: !isTrailer })}>
Expand Down
26 changes: 26 additions & 0 deletions src/containers/SeriesSwitcher/SeriesSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react';
import type { RouteComponentProps } from 'react-router-dom';

import { useSeriesData } from '#src/hooks/useSeries';
import Series from '#src/screens/Series/Series';
import SeriesNew from '#src/screens/SeriesNew/SeriesNew';
import LoadingOverlay from '#src/components/LoadingOverlay/LoadingOverlay';

type Params = {
id: string;
};

const SeriesSwitcher = (params: RouteComponentProps<Params>): JSX.Element => {
const seriesId = params.match.params.id;

const { isLoading, isFetching, error } = useSeriesData(seriesId);

if (isLoading || isFetching) return <LoadingOverlay />;

// In case we have not found series using id, we assume it is a v2.0 seriesId
if (error?.code === 404) return <Series {...params} />;

return <SeriesNew {...params} />;
};

export default SeriesSwitcher;
9 changes: 5 additions & 4 deletions src/containers/StartWatchingButton/StartWatchingButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ import { episodeURLFromEpisode, videoUrl } from '#src/utils/formatting';

type Props = {
item: PlaylistItem;
seriesId?: string;
};

const StartWatchingButton: React.VFC<Props> = ({ item }) => {
const StartWatchingButton: React.VFC<Props> = ({ item, seriesId }) => {
const { t } = useTranslation('video');
const history = useHistory();
const location = useLocation();
Expand Down Expand Up @@ -49,16 +50,16 @@ const StartWatchingButton: React.VFC<Props> = ({ item }) => {
}, [isEntitled, isLoggedIn, hasMediaOffers, videoProgress, t]);

const handleStartWatchingClick = useCallback(() => {
const seriesId = getSeriesIdFromEpisode(item);
const parsedSeriesId = seriesId || getSeriesIdFromEpisode(item);
const playlistId = searchParams.get('r');
const videoPlayUrl = seriesId ? episodeURLFromEpisode(item, seriesId, playlistId, true) : videoUrl(item, playlistId, true);
const videoPlayUrl = parsedSeriesId ? episodeURLFromEpisode(item, parsedSeriesId, playlistId, true) : videoUrl(item, playlistId, true);

if (isEntitled) return videoPlayUrl && history.push(videoPlayUrl);
if (!isLoggedIn) return history.push(addQueryParam(history, 'u', 'create-account'));
if (hasMediaOffers) return history.push(addQueryParam(history, 'u', 'choose-offer'));

return history.push('/u/payments');
}, [item, searchParams, isEntitled, history, isLoggedIn, hasMediaOffers]);
}, [item, seriesId, searchParams, isEntitled, history, isLoggedIn, hasMediaOffers]);

useEffect(() => {
// set the TVOD mediaOffers in the checkout store
Expand Down
41 changes: 41 additions & 0 deletions src/hooks/useSeries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useQuery, UseQueryResult } from 'react-query';

import { getSeries } from '#src/services/series.service';
import { getMediaByWatchlist } from '#src/services/api.service';
import { enrichMediaItems } from '#src/utils/series';
import type { Season, Series } from '#types/series';
import type { PlaylistItem } from '#types/playlist';
import type { ApiError } from '#src/utils/api';

export const useSeriesData = (id: string, season?: number): UseQueryResult<Series | undefined, ApiError> =>
useQuery(`series-${id}`, async () => getSeries(id, { season }), {
staleTime: Infinity,
retry: 0,
});

export const useSeriesMediaItems = (
seriesId: string,
watchlistId: string | null | undefined,
): UseQueryResult<{ mediaItems: PlaylistItem[]; series: Series }, ApiError> =>
useQuery(
`series-watchlist-${seriesId}`,
async () => {
if (!watchlistId) {
throw Error('Please set features.favoritesList property');
}

const series = await getSeries(seriesId);
const mediaIds = (series?.seasons || []).reduce((acc: string[], season: Season) => {
const ids = season.episodes.map((el) => el.media_id);
return [...acc, ...ids];
}, []);

const mediaItems = await getMediaByWatchlist(watchlistId, mediaIds);

return { series, mediaItems: enrichMediaItems(series, mediaItems) };
},
{
staleTime: Infinity,
retry: 0,
},
);
Loading

0 comments on commit 29e8599

Please sign in to comment.