diff --git a/ui/component/channelThumbnail/view.jsx b/ui/component/channelThumbnail/view.jsx
index 7f3c97b80..19c15fc2e 100644
--- a/ui/component/channelThumbnail/view.jsx
+++ b/ui/component/channelThumbnail/view.jsx
@@ -5,12 +5,7 @@ import classnames from 'classnames';
import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
import ChannelStakedIndicator from 'component/channelStakedIndicator';
-import { getThumbnailCdnUrl } from 'util/thumbnail';
-
-const FONT_PX = 16.0;
-const IMG_XSMALL_REM = 2.1;
-const IMG_SMALL_REM = 3.0;
-const IMG_NORMAL_REM = 10.0;
+import OptimizedImage from 'component/optimizedImage';
type Props = {
thumbnail: ?string,
@@ -53,8 +48,6 @@ function ChannelThumbnail(props: Props) {
const channelThumbnail = thumbnail || thumbnailPreview;
const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview;
- const thumbnailRef = React.useRef(null);
- const thumbnailSize = calcRenderedImgWidth(); // currently always 1:1
// Generate a random color class based on the first letter of the channel name
const { channelName } = parseURI(uri);
@@ -67,20 +60,6 @@ function ChannelThumbnail(props: Props) {
colorClassName = `channel-thumbnail__default--4`;
}
- function calcRenderedImgWidth() {
- let rem;
- if (xsmall) {
- rem = IMG_XSMALL_REM;
- } else if (small) {
- rem = IMG_SMALL_REM;
- } else {
- rem = IMG_NORMAL_REM;
- }
-
- const devicePixelRatio = window.devicePixelRatio || 1.0;
- return Math.ceil(rem * devicePixelRatio * FONT_PX);
- }
-
React.useEffect(() => {
if (shouldResolve && uri) {
doResolveUri(uri);
@@ -94,15 +73,6 @@ function ChannelThumbnail(props: Props) {
);
}
-
- let url = channelThumbnail;
- // @if TARGET='web'
- // Pass image urls through a compression proxy, except for GIFs.
- if (thumbnail && !(isGif && allowGifs)) {
- url = getThumbnailCdnUrl({ thumbnail, width: thumbnailSize, height: thumbnailSize, quality: 85 });
- }
- // @endif
-
return (
{!showThumb && (
-
setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
/>
@@ -129,13 +96,10 @@ function ChannelThumbnail(props: Props) {
{showDelayedMessage && thumbError ? (
{__('This will be visible in a few minutes.')}
) : (
-
setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
/>
diff --git a/ui/component/optimizedImage/index.js b/ui/component/optimizedImage/index.js
new file mode 100644
index 000000000..0ec9fdc1a
--- /dev/null
+++ b/ui/component/optimizedImage/index.js
@@ -0,0 +1,2 @@
+import OptimizedImage from './view';
+export default OptimizedImage;
diff --git a/ui/component/optimizedImage/view.jsx b/ui/component/optimizedImage/view.jsx
new file mode 100644
index 000000000..1cc8743f6
--- /dev/null
+++ b/ui/component/optimizedImage/view.jsx
@@ -0,0 +1,108 @@
+// @flow
+import React from 'react';
+import { getThumbnailCdnUrl } from 'util/thumbnail';
+
+function scaleToDevicePixelRatio(value: number, window: any) {
+ const devicePixelRatio = window.devicePixelRatio || 1.0;
+ return Math.ceil(value * devicePixelRatio);
+}
+
+type Props = {
+ src: string,
+ objectFit?: string,
+};
+
+function OptimizedImage(props: Props) {
+ const { objectFit, src, ...imgProps } = props;
+ const [optimizedSrc, setOptimizedSrc] = React.useState('');
+ const ref = React.useRef
();
+
+ function getOptimizedImgUrl(url, width, height) {
+ let optimizedUrl = url;
+ if (url && !url.startsWith('/public/')) {
+ optimizedUrl = url.trim().replace(/^http:\/\//i, 'https://');
+
+ // @if TARGET='web'
+ if (!optimizedUrl.endsWith('.gif')) {
+ optimizedUrl = getThumbnailCdnUrl({ thumbnail: optimizedUrl, width, height, quality: 85 });
+ }
+ // @endif
+ }
+ return optimizedUrl;
+ }
+
+ function getOptimumSize(elem) {
+ if (!elem || !elem.parentElement || !elem.parentElement.clientWidth || !elem.parentElement.clientHeight) {
+ return null;
+ }
+
+ let width = elem.parentElement.clientWidth;
+ let height = elem.parentElement.clientHeight;
+
+ width = scaleToDevicePixelRatio(width, window);
+ height = scaleToDevicePixelRatio(height, window);
+
+ // Round to next 100px for better caching
+ width = Math.ceil(width / 100) * 100;
+ height = Math.ceil(height / 100) * 100;
+
+ // Reminder: CDN expects integers.
+ return { width, height };
+ }
+
+ function adjustOptimizationIfNeeded(elem, objectFit, origSrc) {
+ if (objectFit === 'cover' && elem) {
+ const containerSize = getOptimumSize(elem);
+ if (containerSize) {
+ // $FlowFixMe
+ if (elem.naturalWidth < containerSize.width) {
+ // For 'cover', we don't want to stretch the image. We started off by
+ // filling up the container height, but the width still has a gap for
+ // this instance (usually due to aspect ratio mismatch).
+ // If the original image is much larger, we can request for a larger
+ // image so that "objectFit=cover" will center it without stretching and
+ // making it blur. The double fetch might seem wasteful, but on
+ // average the total transferred bytes is still less than the original.
+ const probablyMaxedOut = elem.naturalHeight < containerSize.height;
+ if (!probablyMaxedOut) {
+ const newOptimizedSrc = getOptimizedImgUrl(origSrc, containerSize.width, 0);
+ if (newOptimizedSrc && newOptimizedSrc !== optimizedSrc) {
+ setOptimizedSrc(newOptimizedSrc);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ React.useEffect(() => {
+ const containerSize = getOptimumSize(ref.current);
+ if (containerSize) {
+ const width = 0; // The CDN will fill the zeroed attribute per image's aspect ratio.
+ const height = containerSize.height;
+
+ const newOptimizedSrc = getOptimizedImgUrl(src, width, height);
+
+ if (newOptimizedSrc !== optimizedSrc) {
+ setOptimizedSrc(newOptimizedSrc);
+ }
+ } else {
+ setOptimizedSrc(src);
+ }
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ if (!src) {
+ return null;
+ }
+
+ return (
+ adjustOptimizationIfNeeded(ref.current, objectFit, src)}
+ />
+ );
+}
+
+export default OptimizedImage;
diff --git a/ui/page/channel/view.jsx b/ui/page/channel/view.jsx
index a7bf858e7..6dd52cf26 100644
--- a/ui/page/channel/view.jsx
+++ b/ui/page/channel/view.jsx
@@ -21,6 +21,7 @@ import HelpLink from 'component/common/help-link';
import ClaimSupportButton from 'component/claimSupportButton';
import ChannelStakedIndicator from 'component/channelStakedIndicator';
import ClaimMenuList from 'component/claimMenuList';
+import OptimizedImage from 'component/optimizedImage';
import Yrbl from 'component/yrbl';
import I18nMessage from 'component/i18nMessage';
// $FlowFixMe cannot resolve ...
@@ -220,7 +221,7 @@ function ChannelPage(props: Props) {
{cover && }
- {cover && }
+ {cover && }