Skip to content
This repository has been archived by the owner on Jan 9, 2023. It is now read-only.

feat: More advanced YouTube pre-load decisions based on device #309

Merged
merged 10 commits into from
Dec 2, 2021
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@types/jest": "^26.0.15",
"@types/lodash.debounce": "^4.0.6",
"@types/react": "^17.0.2",
"@types/react-dom": "^17.0.1",
"@types/requestidlecallback": "^0.3.4",
"@types/youtube": "^0.0.39",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^3.10.1",
Expand All @@ -68,7 +70,7 @@
"react-dom": "^17.0.1",
"ts-jest": "^24.3.0",
"ts-loader": "^8.0.18",
"typescript": "^4.1.3",
"typescript": "4.1.3",
mxdvl marked this conversation as resolved.
Show resolved Hide resolved
"web-vitals": "^2.1.2"
},
"eslintConfig": {
Expand Down Expand Up @@ -140,6 +142,7 @@
]
},
"dependencies": {
"lodash.debounce": "^4.0.8",
"youtube-player": "^5.5.2"
}
}
58 changes: 58 additions & 0 deletions src/YoutubeAtom.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const NoConsent = (): JSX.Element => {
pillar={ArticlePillar.Culture}
height={450}
width={800}
isMainMedia={false}
/>
</div>
);
Expand All @@ -53,6 +54,7 @@ export const NoOverlay = (): JSX.Element => {
pillar={ArticlePillar.Culture}
height={450}
width={800}
isMainMedia={false}
/>
</div>
);
Expand Down Expand Up @@ -87,6 +89,7 @@ export const WithOverrideImage = (): JSX.Element => {
],
},
]}
isMainMedia={false}
/>
</div>
);
Expand Down Expand Up @@ -138,6 +141,7 @@ export const WithPosterImage = (): JSX.Element => {
]}
height={450}
width={800}
isMainMedia={false}
/>
</div>
);
Expand Down Expand Up @@ -200,6 +204,7 @@ export const WithOverlayAndPosterImage = (): JSX.Element => {
]}
height={450}
width={800}
isMainMedia={false}
/>
</div>
);
Expand Down Expand Up @@ -239,8 +244,61 @@ export const GiveConsent = (): JSX.Element => {
]}
height={450}
width={800}
isMainMedia={false}
/>
</div>
</>
);
};

export const WhenMainMedia = (): JSX.Element => {
return (
<div
css={css`
width: 800px;
margin: 25px;
`}
>
<YoutubeAtom
assetId="-ZCvZmYlQD8"
alt=""
role="inline"
eventEmitters={[
(e) => console.log(`analytics event ${e} called`),
]}
duration={252}
pillar={ArticlePillar.Culture}
height={450}
width={800}
isMainMedia={true}
consentState={{}}
posterImage={[
{
srcSet: [
{
src:
'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1000.jpg',
width: 1000,
},
{
src:
'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/500.jpg',
width: 500,
},
{
src:
'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/140.jpg',
width: 140,
},
{
src:
'https://media.guim.co.uk/757dd4db5818984fd600b41cdaf687668497051d/0_0_1920_1080/1920.jpg',
width: 1920,
},
],
},
]}
/>
</div>
);
};
56 changes: 43 additions & 13 deletions src/YoutubeAtom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { SvgPlay } from '@guardian/source-react-components';
import { MaintainAspectRatio } from './common/MaintainAspectRatio';
import { Placeholder } from './common/Placeholder';
import { formatTime } from './lib/formatTime';
import { useHasBeenSeen } from './lib/useHasBeenSeen';
import { useOnce } from './lib/useOnce';
import { whenIdle } from './lib/whenIdle';
import { Picture } from './Picture';
import { AdTargeting, ImageSource, RoleType } from './types';
import { ArticleTheme } from '@guardian/libs';
Expand All @@ -39,6 +42,7 @@ type Props = {
origin?: string;
eventEmitters: ((event: VideoEventKey) => void)[];
pillar: ArticleTheme;
isMainMedia: boolean;
mxdvl marked this conversation as resolved.
Show resolved Hide resolved
};
declare global {
interface Window {
Expand Down Expand Up @@ -160,6 +164,7 @@ export const YoutubeAtom = ({
origin,
eventEmitters,
pillar,
isMainMedia,
}: Props): JSX.Element => {
const [iframeSrc, setIframeSrc] = useState<string | undefined>(undefined);
const [hasUserLaunchedPlay, setHasUserLaunchedPlay] = useState<boolean>(
Expand All @@ -168,6 +173,19 @@ export const YoutubeAtom = ({
const [hasUserHovered, setHasUserHovered] = useState<boolean>(false);
const player = useRef<YoutubePlayerType>();

const [hasBeenSeen, setNode] = useHasBeenSeen({
threshold: 0,
debounce: true,
});

const [isIdle, setIsIdle] = useState<boolean>(false);

useOnce(() => {
whenIdle(() => {
setIsIdle(true);
});
}, []);

const hasOverlay = overrideImage || posterImage;

/**
Expand All @@ -192,20 +210,31 @@ export const YoutubeAtom = ({
*/
const showPlaceholder = !iframeSrc && (!hasOverlay || hasUserLaunchedPlay);
/**
* Load the you tube iframe if:
*
* - We have a source string defined (i.e. We have consent)
*
* and
*
* - One of these 3 things are true
* - We don't have an overlay - so we have to load the video straight away
* - The user has clicked on the overlay, so load the video iframe!
* - The user has moved their mouse over the overlay so lets pre load
* the content
* Load the YouTube iframe if:
mxdvl marked this conversation as resolved.
Show resolved Hide resolved
*/
const loadIframe =
iframeSrc && (!hasOverlay || hasUserHovered || hasUserLaunchedPlay);
let loadIframe: boolean;
const isMobile = /Mobi/.test(navigator.userAgent);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is more about testing touch v pointer than it is about mobile devices. Maybe the variable name should reflect that.

I know I was the one originally mentioning touch devices, but thinking about this again, I’m wondering if the best isn’t to just listen to a touchstart event that would act as a hover state.

It’s important to note that these types of optimisations based on the availability of touch have a fundamental flaw: they make assumptions about user behavior based on device capabilities. More explicitly, the example above assumes that because a device is capable of touch input, a user will in fact use touch as the only way to interact with it.

from 2013: Detecting touch: it's the 'why', not the 'how'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a really interesting, and probably very valid, point. I'm going to look into this some more (thanks for the links!) and chat it through with team 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I discussed this with @OllysCoding and we both agreed your points are spot on @mxdvl and so I've refactored this PR to use onTouchStart in addition to onHover. The result of this is a my previously rather large PR is now basically a one-liner.

It's true that this means we sometimes delay the point at which a video is downloaded but as per your points and the linked articles I don't think we were accurately making the decisions about when to do this before and waiting until the user actually interacts is more accurate.

if (!iframeSrc) {
// Never try to load the iframe if we don't have a source value for it
loadIframe = false;
} else if (!hasOverlay) {
// Always load the iframe if there is no overlay
loadIframe = true;
} else if (hasUserLaunchedPlay) {
// The overlay has been clicked so we should load the iframe
loadIframe = true;
} else if (isMobile && isMainMedia) {
// We're on a mobile device and the video is the main media so load
// the iframe early if idle
loadIframe = isIdle;
} else if (isMobile && !isMainMedia) {
// We're on a mobile device and the video is in the article body so
// load the iframe early when visible
loadIframe = hasBeenSeen;
} else {
// Not on a mobile device so load early when the mouse over event is fired
loadIframe = hasUserHovered;
}

useEffect(() => {
/**
Expand Down Expand Up @@ -393,6 +422,7 @@ export const YoutubeAtom = ({
player.current.playVideo();
}
}}
ref={setNode} // Used by useHasBeenSeen
onMouseEnter={() => setHasUserHovered(true)}
css={[
overlayStyles,
Expand Down
47 changes: 47 additions & 0 deletions src/lib/useHasBeenSeen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useEffect, useState, useRef } from 'react';
import libDebounce from 'lodash.debounce';

const useHasBeenSeen = (
options: IntersectionObserverInit & { debounce?: boolean },
): [boolean, React.Dispatch<React.SetStateAction<HTMLElement | null>>] => {
const [hasBeenSeen, setHasBeenSeen] = useState<boolean>(false);
const [node, setNode] = useState<HTMLElement | null>(null);

const observer = useRef<IntersectionObserver | null>(null);

// Enabling debouncing ensures the target element intersects for at least
// 200ms before the callback is executed
const intersectionFn: IntersectionObserverCallback = ([entry]) => {
if (entry.isIntersecting) {
setHasBeenSeen(true);
}
};
const intersectionCallback = options.debounce
? libDebounce(intersectionFn, 200)
: intersectionFn;

useEffect(() => {
if (observer.current) {
observer.current.disconnect();
}

if (window.IntersectionObserver) {
observer.current = new window.IntersectionObserver(
intersectionCallback,
options,
);

const { current: currentObserver } = observer;

if (node) {
currentObserver.observe(node);
}

return () => currentObserver.disconnect();
}
}, [node, options, intersectionCallback]);

return [hasBeenSeen, setNode];
};

export { useHasBeenSeen };
17 changes: 17 additions & 0 deletions src/lib/useOnce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';

/**
* Ensures that the given task is only run once and only after all items in waitFor are defined
* @param {Function} task - The task to execute once
* @param {Array} waitFor - An array of variables that must be defined before the task is executed
* */
export const useOnce = (task: () => void, waitFor: unknown[]): void => {
const [alreadyRun, setAlreadyRun] = useState(false);
const isReady = waitFor.every((dep) => dep !== undefined);
useEffect(() => {
if (!alreadyRun && isReady) {
task();
setAlreadyRun(true);
}
}, [alreadyRun, isReady, task]);
};
21 changes: 21 additions & 0 deletions src/lib/whenIdle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* whenIdle executes the given callback when the browser is 'idle'
*
* @param callback Fired when requestIdleCallback runs or, if requestIdleCallback is not available, after 300ms
* @param options Options for requestIdleCallback
* @param options.timeout How long to wait for requestIdleCallback to return, defaults to 500ms
*/
export const whenIdle = (
callback: () => void,
options: {
timeout: number;
} = {
timeout: 500,
},
): void => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(callback, options);
} else {
setTimeout(callback, 300);
}
};
24 changes: 23 additions & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2622,6 +2622,18 @@
dependencies:
"@types/node" "*"

"@types/lodash.debounce@^4.0.6":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60"
integrity sha512-4WTmnnhCfDvvuLMaF3KV4Qfki93KebocUF45msxhYyjMttZDQYzHkO639ohhk8+oco2cluAFL3t5+Jn4mleylQ==
dependencies:
"@types/lodash" "*"

"@types/lodash@*":
version "4.14.177"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578"
integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw==

"@types/markdown-to-jsx@^6.11.0":
version "6.11.3"
resolved "https://registry.yarnpkg.com/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.3.tgz#cdd1619308fecbc8be7e6a26f3751260249b020e"
Expand Down Expand Up @@ -2764,6 +2776,11 @@
dependencies:
"@types/react" "*"

"@types/requestidlecallback@^0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@types/requestidlecallback/-/requestidlecallback-0.3.4.tgz#819e22b8994cf547e1f17f9cb79805a5a3bb1db5"
integrity sha512-aTSyiZuRemRLTQkJPb25L7A4/eR2Teo5l4yJ1V6P3+MFxEZckTDkNKNtr/V1zEOMzS6H8DgxF22U6jPAPrzQvw==

"@types/responselike@*":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"
Expand Down Expand Up @@ -9414,6 +9431,11 @@ lodash-es@^4.17.15:
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==

lodash.debounce@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=

lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
Expand Down Expand Up @@ -13471,7 +13493,7 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=

typescript@^4.1.3:
typescript@4.1.3:
version "4.1.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7"
integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==
Expand Down