#6504: Optimize channel banner
This commit is contained in:
commit
03a218e288
4 changed files with 117 additions and 42 deletions
|
@ -5,12 +5,7 @@ import classnames from 'classnames';
|
||||||
import Gerbil from './gerbil.png';
|
import Gerbil from './gerbil.png';
|
||||||
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
|
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
|
||||||
import ChannelStakedIndicator from 'component/channelStakedIndicator';
|
import ChannelStakedIndicator from 'component/channelStakedIndicator';
|
||||||
import { getThumbnailCdnUrl } from 'util/thumbnail';
|
import OptimizedImage from 'component/optimizedImage';
|
||||||
|
|
||||||
const FONT_PX = 16.0;
|
|
||||||
const IMG_XSMALL_REM = 2.1;
|
|
||||||
const IMG_SMALL_REM = 3.0;
|
|
||||||
const IMG_NORMAL_REM = 10.0;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
thumbnail: ?string,
|
thumbnail: ?string,
|
||||||
|
@ -53,8 +48,6 @@ function ChannelThumbnail(props: Props) {
|
||||||
const channelThumbnail = thumbnail || thumbnailPreview;
|
const channelThumbnail = thumbnail || thumbnailPreview;
|
||||||
const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
|
const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
|
||||||
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview;
|
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
|
// Generate a random color class based on the first letter of the channel name
|
||||||
const { channelName } = parseURI(uri);
|
const { channelName } = parseURI(uri);
|
||||||
|
@ -67,20 +60,6 @@ function ChannelThumbnail(props: Props) {
|
||||||
colorClassName = `channel-thumbnail__default--4`;
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (shouldResolve && uri) {
|
if (shouldResolve && uri) {
|
||||||
doResolveUri(uri);
|
doResolveUri(uri);
|
||||||
|
@ -94,15 +73,6 @@ function ChannelThumbnail(props: Props) {
|
||||||
</FreezeframeWrapper>
|
</FreezeframeWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classnames('channel-thumbnail', className, {
|
className={classnames('channel-thumbnail', className, {
|
||||||
|
@ -113,13 +83,10 @@ function ChannelThumbnail(props: Props) {
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!showThumb && (
|
{!showThumb && (
|
||||||
<img
|
<OptimizedImage
|
||||||
ref={thumbnailRef}
|
|
||||||
alt={__('Channel profile picture')}
|
alt={__('Channel profile picture')}
|
||||||
className="channel-thumbnail__default"
|
className="channel-thumbnail__default"
|
||||||
src={!thumbError && url ? url : Gerbil}
|
src={!thumbError && channelThumbnail ? channelThumbnail : Gerbil}
|
||||||
width={thumbnailSize}
|
|
||||||
height={thumbnailSize}
|
|
||||||
loading={noLazyLoad ? undefined : 'lazy'}
|
loading={noLazyLoad ? undefined : 'lazy'}
|
||||||
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
|
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
|
||||||
/>
|
/>
|
||||||
|
@ -129,13 +96,10 @@ function ChannelThumbnail(props: Props) {
|
||||||
{showDelayedMessage && thumbError ? (
|
{showDelayedMessage && thumbError ? (
|
||||||
<div className="chanel-thumbnail--waiting">{__('This will be visible in a few minutes.')}</div>
|
<div className="chanel-thumbnail--waiting">{__('This will be visible in a few minutes.')}</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<OptimizedImage
|
||||||
ref={thumbnailRef}
|
|
||||||
alt={__('Channel profile picture')}
|
alt={__('Channel profile picture')}
|
||||||
className="channel-thumbnail__custom"
|
className="channel-thumbnail__custom"
|
||||||
src={!thumbError && url ? url : Gerbil}
|
src={!thumbError && channelThumbnail ? channelThumbnail : Gerbil}
|
||||||
width={thumbnailSize}
|
|
||||||
height={thumbnailSize}
|
|
||||||
loading={noLazyLoad ? undefined : 'lazy'}
|
loading={noLazyLoad ? undefined : 'lazy'}
|
||||||
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
|
onError={() => setThumbError(true)} // if thumb fails (including due to https replace, show gerbil.
|
||||||
/>
|
/>
|
||||||
|
|
2
ui/component/optimizedImage/index.js
Normal file
2
ui/component/optimizedImage/index.js
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
import OptimizedImage from './view';
|
||||||
|
export default OptimizedImage;
|
108
ui/component/optimizedImage/view.jsx
Normal file
108
ui/component/optimizedImage/view.jsx
Normal file
|
@ -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<any>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<img
|
||||||
|
ref={ref}
|
||||||
|
{...imgProps}
|
||||||
|
src={optimizedSrc}
|
||||||
|
onLoad={() => adjustOptimizationIfNeeded(ref.current, objectFit, src)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OptimizedImage;
|
|
@ -21,6 +21,7 @@ import HelpLink from 'component/common/help-link';
|
||||||
import ClaimSupportButton from 'component/claimSupportButton';
|
import ClaimSupportButton from 'component/claimSupportButton';
|
||||||
import ChannelStakedIndicator from 'component/channelStakedIndicator';
|
import ChannelStakedIndicator from 'component/channelStakedIndicator';
|
||||||
import ClaimMenuList from 'component/claimMenuList';
|
import ClaimMenuList from 'component/claimMenuList';
|
||||||
|
import OptimizedImage from 'component/optimizedImage';
|
||||||
import Yrbl from 'component/yrbl';
|
import Yrbl from 'component/yrbl';
|
||||||
import I18nMessage from 'component/i18nMessage';
|
import I18nMessage from 'component/i18nMessage';
|
||||||
// $FlowFixMe cannot resolve ...
|
// $FlowFixMe cannot resolve ...
|
||||||
|
@ -220,7 +221,7 @@ function ChannelPage(props: Props) {
|
||||||
<ClaimMenuList uri={claim.permanent_url} channelUri={claim.permanent_url} inline isChannelPage />
|
<ClaimMenuList uri={claim.permanent_url} channelUri={claim.permanent_url} inline isChannelPage />
|
||||||
</div>
|
</div>
|
||||||
{cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />}
|
{cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />}
|
||||||
{cover && <img className={classnames('channel-cover__custom')} src={cover} />}
|
{cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />}
|
||||||
<div className="channel__primary-info">
|
<div className="channel__primary-info">
|
||||||
<ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs hideStakedIndicator />
|
<ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs hideStakedIndicator />
|
||||||
<h1 className="channel__title">
|
<h1 className="channel__title">
|
||||||
|
|
Loading…
Reference in a new issue