Skip to content

Commit

Permalink
fix(watchhistory): improve watch history storage calls and fix bugs
Browse files Browse the repository at this point in the history
* fix(watchhistory): wrong item saved when clicking the next video

* fix(watchhistory): only the last item being saved

* chore: fix prettier

* refactor(watchhistory): use position and duration from time event

* refactor(watchhistory): separate useWatchHistory hook and add progressive save interval

* refactor(watchhistory): pass the series item instead of fetching each save

* chore(watchhistory): enable keepalive to update personal shelves request
  • Loading branch information
ChristiaanScheermeijer authored Jul 26, 2023
1 parent d8b6988 commit 9fd1774
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 113 deletions.
3 changes: 3 additions & 0 deletions src/containers/Cinema/Cinema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PlayerContainer from '#src/containers/PlayerContainer/PlayerContainer';
type Props = {
open: boolean;
item: PlaylistItem;
seriesItem?: PlaylistItem;
onPlay?: () => void;
onPause?: () => void;
onComplete?: () => void;
Expand All @@ -29,6 +30,7 @@ type Props = {
const Cinema: React.FC<Props> = ({
open,
item,
seriesItem,
title,
primaryMetadata,
secondaryMetadata,
Expand Down Expand Up @@ -86,6 +88,7 @@ const Cinema: React.FC<Props> = ({
<div className={styles.cinema}>
<PlayerContainer
item={item}
seriesItem={seriesItem}
feedId={feedId}
autostart={true}
onPlay={handlePlay}
Expand Down
3 changes: 3 additions & 0 deletions src/containers/InlinePlayer/InlinePlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import PlayerContainer from '#src/containers/PlayerContainer/PlayerContainer';

type Props = {
item: PlaylistItem;
seriesItem?: PlaylistItem;
onPlay?: () => void;
onPause?: () => void;
onComplete?: () => void;
Expand All @@ -31,6 +32,7 @@ type Props = {

const InlinePlayer: React.FC<Props> = ({
item,
seriesItem,
onPlay,
onPause,
onComplete,
Expand Down Expand Up @@ -72,6 +74,7 @@ const InlinePlayer: React.FC<Props> = ({
{!paywall && playable && (
<PlayerContainer
item={item}
seriesItem={seriesItem}
feedId={feedId}
autostart={autostart}
onPlay={onPlay}
Expand Down
82 changes: 9 additions & 73 deletions src/containers/PlayerContainer/PlayerContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';

import { useWatchHistoryListener } from '#src/hooks/useWatchHistoryListener';
import { useWatchHistory } from '#src/hooks/useWatchHistory';
import type { PlaylistItem } from '#types/playlist';
import { saveItem } from '#src/stores/WatchHistoryController';
import { usePlaylistItemCallback } from '#src/hooks/usePlaylistItemCallback';
import { useConfigStore } from '#src/stores/ConfigStore';
import Player from '#components/Player/Player';
import type { JWPlayer } from '#types/jwplayer';
import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';
import { VideoProgressMinMax } from '#src/config';
import useContentProtection from '#src/hooks/useContentProtection';
import { getMediaById } from '#src/services/api.service';
import LoadingOverlay from '#components/LoadingOverlay/LoadingOverlay';
import useEventCallback from '#src/hooks/useEventCallback';
import { logDev } from '#src/utils/common';
import { useSettingsStore } from '#src/stores/SettingsStore';

type Props = {
item: PlaylistItem;
seriesItem?: PlaylistItem;
onPlay?: () => void;
onPause?: () => void;
onComplete?: () => void;
Expand All @@ -34,6 +29,7 @@ type Props = {

const PlayerContainer: React.FC<Props> = ({
item,
seriesItem,
feedId,
onPlay,
onPause,
Expand All @@ -46,45 +42,15 @@ const PlayerContainer: React.FC<Props> = ({
liveStartDateTime,
autostart,
}: Props) => {
const { features } = useConfigStore((s) => s.config);
const continueWatchingList = features?.continueWatchingList;
const watchHistoryEnabled = !!continueWatchingList;
// data
const { data: playableItem, isLoading } = useContentProtection('media', item.mediaid, (token, drmPolicyId) => getMediaById(item.mediaid, token, drmPolicyId));
const { playerId, playerLicenseKey } = useSettingsStore((s) => s);

// state
const [playerInstance, setPlayerInstance] = useState<JWPlayer>();

// watch history
const watchHistoryItem = useWatchHistoryStore((state) => (!!item && watchHistoryEnabled ? state.getItem(item) : undefined));

const { playerId, playerLicenseKey } = useSettingsStore((s) => s);

const startTime = useMemo(() => {
const videoProgress = watchHistoryItem?.progress;

if (videoProgress && videoProgress > VideoProgressMinMax.Min && videoProgress < VideoProgressMinMax.Max) {
return videoProgress * item.duration;
}

// start at the beginning of the video (only for VOD content)
return 0;
}, [item.duration, watchHistoryItem?.progress]);

const getProgress = useCallback((): number | null => {
if (!playerInstance) {
return null;
}

// this call may fail when the player is being removed due to a race condition
try {
return playerInstance.getPosition() / item.duration;
} catch (error: unknown) {
logDev('Error caught while calling `getPosition`');
return null;
}
}, [playerInstance, item.duration]);

useWatchHistoryListener(() => (watchHistoryEnabled ? saveItem(item, getProgress()) : null));
const startTime = useWatchHistory(playerInstance, item, seriesItem);

// player events
const handleReady = useCallback((player?: JWPlayer) => {
Expand All @@ -100,37 +66,8 @@ const PlayerContainer: React.FC<Props> = ({
}
}, [liveFromBeginning, playerInstance]);

const handleWatchHistory = useEventCallback(() => {
if (watchHistoryEnabled) {
saveItem(item, getProgress());
}
});

const handlePause = useCallback(() => {
handleWatchHistory();
onPause && onPause();
}, [handleWatchHistory, onPause]);

const handleComplete = useCallback(() => {
handleWatchHistory();
onComplete && onComplete();
}, [handleWatchHistory, onComplete]);

const handleRemove = useCallback(() => {
handleWatchHistory();
setPlayerInstance(undefined);
}, [handleWatchHistory]);

const handlePlaylistItemCallback = usePlaylistItemCallback(liveStartDateTime, liveEndDateTime);

// Effects

// use layout effect to prevent a JWPlayer error when the instance has been removed while loading the entitlement
useLayoutEffect(() => {
// save watch history when the item changes
return () => handleWatchHistory();
}, [handleWatchHistory, item]);

if (!playableItem || isLoading) {
return <LoadingOverlay inline />;
}
Expand All @@ -144,9 +81,8 @@ const PlayerContainer: React.FC<Props> = ({
onReady={handleReady}
onFirstFrame={handleFirstFrame}
onPlay={onPlay}
onPause={handlePause}
onComplete={handleComplete}
onRemove={handleRemove}
onPause={onPause}
onComplete={onComplete}
onUserActive={onUserActive}
onUserInActive={onUserInActive}
onNext={onNext}
Expand Down
33 changes: 33 additions & 0 deletions src/hooks/useWatchHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useMemo } from 'react';

import type { PlaylistItem } from '#types/playlist';
import { useConfigStore } from '#src/stores/ConfigStore';
import { useWatchHistoryStore } from '#src/stores/WatchHistoryStore';
import { VideoProgressMinMax } from '#src/config';
import { useWatchHistoryListener } from '#src/hooks/useWatchHistoryListener';
import type { JWPlayer } from '#types/jwplayer';

export const useWatchHistory = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => {
// config
const { features } = useConfigStore((s) => s.config);
const continueWatchingList = features?.continueWatchingList;
const watchHistoryEnabled = !!continueWatchingList;

// watch history listener
useWatchHistoryListener(player, item, seriesItem);

// watch History
const watchHistoryItem = useWatchHistoryStore((state) => (!!item && watchHistoryEnabled ? state.getItem(item) : undefined));

// calculate the `startTime` of the current item based on the watch progress
return useMemo(() => {
const videoProgress = watchHistoryItem?.progress;

if (videoProgress && videoProgress > VideoProgressMinMax.Min && videoProgress < VideoProgressMinMax.Max) {
return videoProgress * item.duration;
}

// start at the beginning of the video (only for VOD content)
return 0;
}, [item.duration, watchHistoryItem?.progress]);
};
109 changes: 101 additions & 8 deletions src/hooks/useWatchHistoryListener.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,112 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';

import type { JWPlayer } from '#types/jwplayer';
import type { PlaylistItem } from '#types/playlist';
import useEventCallback from '#src/hooks/useEventCallback';
import { saveItem } from '#src/stores/WatchHistoryController';
import { useConfigStore } from '#src/stores/ConfigStore';

export const useWatchHistoryListener = (saveItem: () => void): void => {
const saveItemEvent = useEventCallback(saveItem);
type QueuedProgress = {
item: PlaylistItem;
seriesItem?: PlaylistItem;
progress: number;
timestamp: number;
};

const PROGRESSIVE_SAVE_INTERVAL = 300_000; // 5 minutes

/**
* The `useWatchHistoryListener` hook has the responsibility to save the players watch progress on key moments.
*
* __The problem:__
*
* There are multiple events that trigger a save. This results in duplicate (unnecessary) saves and API calls. Another
* problem is that some events are triggered when the `item` to update has changed. For example, when clicking a media
* item in the "Related shelf". This causes the wrong item to be saved in the watch history.
*
* __The solution:__
*
* This hook listens to the player time update event and queues a watch history entry with the current progress and
* item. When this needs to be saved, the queue is used to look up the last progress and item and save it when
* necessary. The queue is then cleared to prevent duplicate saves and API calls.
*/
export const useWatchHistoryListener = (player: JWPlayer | undefined, item: PlaylistItem, seriesItem?: PlaylistItem) => {
const queuedWatchProgress = useRef<QueuedProgress | null>(null);

// config
const { features } = useConfigStore((s) => s.config);
const continueWatchingList = features?.continueWatchingList;
const watchHistoryEnabled = !!continueWatchingList;

// maybe store the watch progress when we have a queued watch progress
const maybeSaveWatchProgress = useCallback(() => {
if (!watchHistoryEnabled || !queuedWatchProgress.current) return;

const { item, seriesItem, progress } = queuedWatchProgress.current;

// save the queued watch progress
saveItem(item, seriesItem, progress);

// clear the queue
queuedWatchProgress.current = null;
}, [watchHistoryEnabled]);

// update the queued watch progress on each time update event
const handleTimeUpdate = useEventCallback((event: jwplayer.TimeParam) => {
// live streams have a negative duration, we ignore these for now
if (event.duration < 0) return;

const progress = event.position / event.duration;

if (!isNaN(progress) && isFinite(progress)) {
queuedWatchProgress.current = {
item,
seriesItem,
progress,
timestamp: queuedWatchProgress.current?.timestamp || Date.now(),
};

// save the progress when we haven't done so in the last X minutes
if (Date.now() - queuedWatchProgress.current.timestamp > PROGRESSIVE_SAVE_INTERVAL) {
maybeSaveWatchProgress();
}
}
});

// listen for certain player events
useEffect(() => {
const visibilityListener = () => document.visibilityState === 'hidden' && saveItemEvent();
window.addEventListener('beforeunload', saveItemEvent);
if (!player || !watchHistoryEnabled) return;

player.on('time', handleTimeUpdate);
player.on('pause', maybeSaveWatchProgress);
player.on('complete', maybeSaveWatchProgress);
player.on('remove', maybeSaveWatchProgress);

return () => {
player.off('time', handleTimeUpdate);
player.off('pause', maybeSaveWatchProgress);
player.off('complete', maybeSaveWatchProgress);
player.off('remove', maybeSaveWatchProgress);
};
}, [player, watchHistoryEnabled, maybeSaveWatchProgress, handleTimeUpdate]);

useEffect(() => {
return () => {
// store watch progress on unmount and when the media item changes
maybeSaveWatchProgress();
};
}, [item?.mediaid, maybeSaveWatchProgress]);

// add event listeners for unload and visibility change to ensure the latest watch progress is saved
useLayoutEffect(() => {
const visibilityListener = () => document.visibilityState === 'hidden' && maybeSaveWatchProgress();

window.addEventListener('pagehide', maybeSaveWatchProgress);
window.addEventListener('visibilitychange', visibilityListener);

return () => {
saveItemEvent();
window.removeEventListener('beforeunload', saveItemEvent);
window.removeEventListener('pagehide', maybeSaveWatchProgress);
window.removeEventListener('visibilitychange', visibilityListener);
};
}, [saveItemEvent]);
}, [maybeSaveWatchProgress]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ const MediaSeries: ScreenComponent<PlaylistItem> = ({ data: seriesMedia }) => {
<InlinePlayer
isLoggedIn={isLoggedIn}
item={episode || firstEpisode}
seriesItem={seriesMedia}
onComplete={handleComplete}
feedId={feedId ?? undefined}
onPlay={handleInlinePlay}
Expand All @@ -281,6 +282,7 @@ const MediaSeries: ScreenComponent<PlaylistItem> = ({ data: seriesMedia }) => {
open={play && isEntitled}
onClose={goBack}
item={episode || firstEpisode}
seriesItem={seriesMedia}
title={seriesMedia.title}
primaryMetadata={primaryMetadata}
secondaryMetadata={secondaryMetadata}
Expand Down
3 changes: 2 additions & 1 deletion src/services/cleeng.account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ export const updateCustomer: UpdateCustomer = async (payload, sandbox) => {
id,
...rest,
};
return patch(sandbox, `/customers/${id}`, JSON.stringify(params), { authenticate: true });
// enable keepalive to ensure data is persisted when closing the browser/tab
return patch(sandbox, `/customers/${id}`, JSON.stringify(params), { authenticate: true, keepalive: true });
};

export const getCustomer: GetCustomer = async (payload, sandbox) => {
Expand Down
Loading

0 comments on commit 9fd1774

Please sign in to comment.