2023-01-29 10:23:53 +03:00
|
|
|
import { getBlurHashAverageColor } from 'fast-blurhash';
|
2023-06-13 19:09:26 +03:00
|
|
|
import {
|
|
|
|
useCallback,
|
|
|
|
useLayoutEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'preact/hooks';
|
2023-03-27 19:29:01 +03:00
|
|
|
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
2023-01-29 10:23:53 +03:00
|
|
|
|
2023-03-13 18:40:08 +03:00
|
|
|
import Icon from './icon';
|
2023-04-14 10:30:04 +03:00
|
|
|
import Link from './link';
|
2023-01-29 10:23:53 +03:00
|
|
|
import { formatDuration } from './status';
|
|
|
|
|
2023-06-28 18:35:22 +03:00
|
|
|
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
|
|
|
|
2023-01-29 10:23:53 +03:00
|
|
|
/*
|
|
|
|
Media type
|
|
|
|
===
|
|
|
|
unknown = unsupported or unrecognized file type
|
|
|
|
image = Static image
|
|
|
|
gifv = Looping, soundless animation
|
|
|
|
video = Video clip
|
|
|
|
audio = Audio track
|
|
|
|
*/
|
|
|
|
|
2023-04-14 10:30:04 +03:00
|
|
|
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
2023-04-28 14:28:36 +03:00
|
|
|
const {
|
|
|
|
blurhash,
|
|
|
|
description,
|
|
|
|
meta,
|
|
|
|
previewRemoteUrl,
|
|
|
|
previewUrl,
|
|
|
|
remoteUrl,
|
|
|
|
url,
|
|
|
|
type,
|
|
|
|
} = media;
|
2023-02-23 12:01:33 +03:00
|
|
|
const { original = {}, small, focus } = meta || {};
|
2023-01-29 10:23:53 +03:00
|
|
|
|
|
|
|
const width = showOriginal ? original?.width : small?.width;
|
|
|
|
const height = showOriginal ? original?.height : small?.height;
|
2023-04-28 14:28:36 +03:00
|
|
|
const mediaURL = showOriginal ? url : previewUrl || url;
|
|
|
|
const remoteMediaURL = showOriginal
|
|
|
|
? remoteUrl
|
|
|
|
: previewRemoteUrl || remoteUrl;
|
2023-04-14 16:14:08 +03:00
|
|
|
const orientation = width >= height ? 'landscape' : 'portrait';
|
2023-01-29 10:23:53 +03:00
|
|
|
|
|
|
|
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
|
|
|
|
|
|
|
const videoRef = useRef();
|
|
|
|
|
|
|
|
let focalBackgroundPosition;
|
|
|
|
if (focus) {
|
|
|
|
// Convert focal point to CSS background position
|
|
|
|
// Formula from jquery-focuspoint
|
|
|
|
// x = -1, y = 1 => 0% 0%
|
|
|
|
// x = 0, y = 0 => 50% 50%
|
|
|
|
// x = 1, y = -1 => 100% 100%
|
|
|
|
const x = ((focus.x + 1) / 2) * 100;
|
|
|
|
const y = ((1 - focus.y) / 2) * 100;
|
|
|
|
focalBackgroundPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
|
|
|
|
}
|
|
|
|
|
2023-03-28 18:24:43 +03:00
|
|
|
const mediaRef = useRef();
|
2023-03-27 19:29:01 +03:00
|
|
|
const onUpdate = useCallback(({ x, y, scale }) => {
|
2023-03-28 18:24:43 +03:00
|
|
|
const { current: media } = mediaRef;
|
2023-03-27 19:29:01 +03:00
|
|
|
|
2023-03-28 18:24:43 +03:00
|
|
|
if (media) {
|
2023-03-27 19:29:01 +03:00
|
|
|
const value = make3dTransformValue({ x, y, scale });
|
|
|
|
|
2023-04-14 20:30:20 +03:00
|
|
|
if (scale === 1) {
|
|
|
|
media.style.removeProperty('transform');
|
|
|
|
} else {
|
|
|
|
media.style.setProperty('transform', value);
|
|
|
|
}
|
2023-03-28 05:26:27 +03:00
|
|
|
|
2023-03-28 18:24:43 +03:00
|
|
|
media.closest('.media-zoom').style.touchAction =
|
2023-04-15 06:45:34 +03:00
|
|
|
scale <= 1.01 ? 'pan-x' : '';
|
2023-03-27 19:29:01 +03:00
|
|
|
}
|
|
|
|
}, []);
|
|
|
|
|
2023-04-09 14:46:49 +03:00
|
|
|
const [pinchZoomEnabled, setPinchZoomEnabled] = useState(false);
|
2023-03-28 18:24:43 +03:00
|
|
|
const quickPinchZoomProps = {
|
2023-04-09 14:46:49 +03:00
|
|
|
enabled: pinchZoomEnabled,
|
2023-03-28 18:24:43 +03:00
|
|
|
draggableUnZoomed: false,
|
|
|
|
inertiaFriction: 0.9,
|
|
|
|
containerProps: {
|
|
|
|
className: 'media-zoom',
|
|
|
|
style: {
|
2023-03-29 15:53:48 +03:00
|
|
|
overflow: 'visible',
|
|
|
|
// width: 'inherit',
|
|
|
|
// height: 'inherit',
|
|
|
|
// justifyContent: 'inherit',
|
|
|
|
// alignItems: 'inherit',
|
|
|
|
// display: 'inherit',
|
2023-03-28 18:24:43 +03:00
|
|
|
},
|
|
|
|
},
|
|
|
|
onUpdate,
|
|
|
|
};
|
|
|
|
|
2023-04-24 14:27:12 +03:00
|
|
|
const Parent = useMemo(
|
|
|
|
() => (to ? (props) => <Link to={to} {...props} /> : 'div'),
|
|
|
|
[to],
|
|
|
|
);
|
2023-04-14 10:30:04 +03:00
|
|
|
|
2023-06-13 19:09:26 +03:00
|
|
|
const isImage = type === 'image' || (type === 'unknown' && previewUrl);
|
|
|
|
|
|
|
|
const parentRef = useRef();
|
|
|
|
const [imageSmallerThanParent, setImageSmallerThanParent] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
if (!isImage) return;
|
|
|
|
if (!showOriginal) return;
|
|
|
|
if (!parentRef.current) return;
|
|
|
|
const { offsetWidth, offsetHeight } = parentRef.current;
|
|
|
|
const smaller = width < offsetWidth && height < offsetHeight;
|
|
|
|
if (smaller) setImageSmallerThanParent(smaller);
|
|
|
|
}, [width, height]);
|
|
|
|
|
2023-07-30 16:28:17 +03:00
|
|
|
const mediaStyles = {
|
|
|
|
'--width': `${width}px`,
|
|
|
|
'--height': `${height}px`,
|
|
|
|
aspectRatio: `${width} / ${height}`,
|
|
|
|
};
|
|
|
|
|
2023-06-13 19:09:26 +03:00
|
|
|
if (isImage) {
|
2023-01-29 10:23:53 +03:00
|
|
|
// Note: type: unknown might not have width/height
|
2023-03-28 18:24:43 +03:00
|
|
|
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
2023-06-28 18:35:22 +03:00
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
if (!isSafari) return;
|
2023-06-29 13:55:17 +03:00
|
|
|
if (!showOriginal) return;
|
2023-06-28 18:35:22 +03:00
|
|
|
(async () => {
|
|
|
|
try {
|
2023-06-28 19:27:15 +03:00
|
|
|
await fetch(mediaURL, { mode: 'no-cors' });
|
2023-06-28 18:35:22 +03:00
|
|
|
mediaRef.current.src = mediaURL;
|
|
|
|
} catch (e) {
|
|
|
|
// Ignore
|
|
|
|
}
|
|
|
|
})();
|
|
|
|
}, [mediaURL]);
|
|
|
|
|
2023-01-29 10:23:53 +03:00
|
|
|
return (
|
2023-04-14 10:30:04 +03:00
|
|
|
<Parent
|
2023-06-13 19:09:26 +03:00
|
|
|
ref={parentRef}
|
2023-08-01 13:20:54 +03:00
|
|
|
class={`media media-image`}
|
2023-01-29 10:23:53 +03:00
|
|
|
onClick={onClick}
|
2023-07-30 16:28:17 +03:00
|
|
|
data-orientation={orientation}
|
2023-01-29 10:23:53 +03:00
|
|
|
style={
|
2023-07-30 16:28:17 +03:00
|
|
|
showOriginal
|
|
|
|
? {
|
|
|
|
backgroundImage: `url(${previewUrl})`,
|
|
|
|
backgroundSize: imageSmallerThanParent
|
|
|
|
? `${width}px ${height}px`
|
|
|
|
: undefined,
|
|
|
|
}
|
|
|
|
: mediaStyles
|
2023-01-29 10:23:53 +03:00
|
|
|
}
|
|
|
|
>
|
2023-03-27 19:29:01 +03:00
|
|
|
{showOriginal ? (
|
2023-03-28 18:24:43 +03:00
|
|
|
<QuickPinchZoom {...quickPinchZoomProps}>
|
2023-03-27 19:29:01 +03:00
|
|
|
<img
|
2023-03-28 18:24:43 +03:00
|
|
|
ref={mediaRef}
|
2023-03-27 19:29:01 +03:00
|
|
|
src={mediaURL}
|
|
|
|
alt={description}
|
|
|
|
width={width}
|
|
|
|
height={height}
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation={orientation}
|
2023-03-27 20:07:46 +03:00
|
|
|
loading="eager"
|
2023-06-07 14:48:38 +03:00
|
|
|
decoding="sync"
|
2023-03-27 19:29:01 +03:00
|
|
|
onLoad={(e) => {
|
2023-03-27 20:16:49 +03:00
|
|
|
e.target.closest('.media-image').style.backgroundImage = '';
|
2023-03-28 06:11:07 +03:00
|
|
|
e.target.closest('.media-zoom').style.display = '';
|
2023-04-09 14:46:49 +03:00
|
|
|
setPinchZoomEnabled(true);
|
2023-03-27 19:29:01 +03:00
|
|
|
}}
|
2023-04-28 14:28:36 +03:00
|
|
|
onError={(e) => {
|
|
|
|
const { src } = e.target;
|
|
|
|
if (src === mediaURL) {
|
|
|
|
e.target.src = remoteMediaURL;
|
|
|
|
}
|
|
|
|
}}
|
2023-03-27 19:29:01 +03:00
|
|
|
/>
|
|
|
|
</QuickPinchZoom>
|
|
|
|
) : (
|
|
|
|
<img
|
|
|
|
src={mediaURL}
|
|
|
|
alt={description}
|
|
|
|
width={width}
|
|
|
|
height={height}
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation={orientation}
|
2023-03-27 19:29:01 +03:00
|
|
|
loading="lazy"
|
|
|
|
style={{
|
2023-01-29 10:23:53 +03:00
|
|
|
backgroundColor:
|
|
|
|
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
|
|
backgroundPosition: focalBackgroundPosition || 'center',
|
2023-07-30 16:28:17 +03:00
|
|
|
// Duration based on width or height in pixels
|
|
|
|
// 100px per second (rough estimate)
|
|
|
|
// Clamp between 5s and 120s
|
|
|
|
'--anim-duration': `${Math.min(
|
|
|
|
Math.max(Math.max(width, height) / 100, 5),
|
|
|
|
120,
|
|
|
|
)}s`,
|
2023-03-27 19:29:01 +03:00
|
|
|
}}
|
|
|
|
onLoad={(e) => {
|
2023-03-27 20:16:49 +03:00
|
|
|
e.target.closest('.media-image').style.backgroundImage = '';
|
2023-06-29 13:55:17 +03:00
|
|
|
e.target.dataset.loaded = true;
|
2023-03-27 19:29:01 +03:00
|
|
|
}}
|
2023-04-28 14:28:36 +03:00
|
|
|
onError={(e) => {
|
|
|
|
const { src } = e.target;
|
|
|
|
if (src === mediaURL) {
|
|
|
|
e.target.src = remoteMediaURL;
|
|
|
|
}
|
|
|
|
}}
|
2023-03-27 19:29:01 +03:00
|
|
|
/>
|
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
</Parent>
|
2023-01-29 10:23:53 +03:00
|
|
|
);
|
|
|
|
} else if (type === 'gifv' || type === 'video') {
|
|
|
|
const shortDuration = original.duration < 31;
|
|
|
|
const isGIF = type === 'gifv' && shortDuration;
|
|
|
|
// If GIF is too long, treat it as a video
|
|
|
|
const loopable = original.duration < 61;
|
|
|
|
const formattedDuration = formatDuration(original.duration);
|
|
|
|
const hoverAnimate = !showOriginal && !autoAnimate && isGIF;
|
|
|
|
const autoGIFAnimate = !showOriginal && autoAnimate && isGIF;
|
2023-03-28 18:24:43 +03:00
|
|
|
|
|
|
|
const videoHTML = `
|
|
|
|
<video
|
|
|
|
src="${url}"
|
|
|
|
poster="${previewUrl}"
|
|
|
|
width="${width}"
|
|
|
|
height="${height}"
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation="${orientation}"
|
2023-03-28 18:24:43 +03:00
|
|
|
preload="auto"
|
|
|
|
autoplay
|
|
|
|
muted="${isGIF}"
|
|
|
|
${isGIF ? '' : 'controls'}
|
|
|
|
playsinline
|
|
|
|
loop="${loopable}"
|
|
|
|
${isGIF ? 'ondblclick="this.paused ? this.play() : this.pause()"' : ''}
|
|
|
|
></video>
|
|
|
|
`;
|
|
|
|
|
2023-01-29 10:23:53 +03:00
|
|
|
return (
|
2023-04-14 10:30:04 +03:00
|
|
|
<Parent
|
2023-01-29 10:23:53 +03:00
|
|
|
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
|
|
|
autoGIFAnimate ? 'media-contain' : ''
|
2023-08-01 13:20:54 +03:00
|
|
|
}`}
|
2023-07-30 16:28:17 +03:00
|
|
|
data-orientation={orientation}
|
2023-01-29 10:23:53 +03:00
|
|
|
data-formatted-duration={formattedDuration}
|
|
|
|
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
2023-04-20 14:10:07 +03:00
|
|
|
// style={{
|
|
|
|
// backgroundColor:
|
|
|
|
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
|
|
|
// }}
|
2023-07-30 16:28:17 +03:00
|
|
|
style={!showOriginal && mediaStyles}
|
2023-01-29 10:23:53 +03:00
|
|
|
onClick={(e) => {
|
|
|
|
if (hoverAnimate) {
|
|
|
|
try {
|
|
|
|
videoRef.current.pause();
|
|
|
|
} catch (e) {}
|
|
|
|
}
|
|
|
|
onClick(e);
|
|
|
|
}}
|
|
|
|
onMouseEnter={() => {
|
|
|
|
if (hoverAnimate) {
|
|
|
|
try {
|
|
|
|
videoRef.current.play();
|
|
|
|
} catch (e) {}
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
onMouseLeave={() => {
|
|
|
|
if (hoverAnimate) {
|
|
|
|
try {
|
|
|
|
videoRef.current.pause();
|
|
|
|
} catch (e) {}
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{showOriginal || autoGIFAnimate ? (
|
2023-04-03 06:54:46 +03:00
|
|
|
isGIF && showOriginal ? (
|
2023-04-09 14:46:49 +03:00
|
|
|
<QuickPinchZoom {...quickPinchZoomProps} enabled>
|
2023-03-28 18:24:43 +03:00
|
|
|
<div
|
|
|
|
ref={mediaRef}
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
__html: videoHTML,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</QuickPinchZoom>
|
|
|
|
) : (
|
|
|
|
<div
|
2023-04-03 06:54:46 +03:00
|
|
|
class="video-container"
|
2023-03-28 18:24:43 +03:00
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
__html: videoHTML,
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
)
|
2023-01-29 10:23:53 +03:00
|
|
|
) : isGIF ? (
|
|
|
|
<video
|
|
|
|
ref={videoRef}
|
|
|
|
src={url}
|
|
|
|
poster={previewUrl}
|
|
|
|
width={width}
|
|
|
|
height={height}
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation={orientation}
|
2023-01-29 10:23:53 +03:00
|
|
|
preload="auto"
|
|
|
|
// controls
|
|
|
|
playsinline
|
|
|
|
loop
|
|
|
|
muted
|
|
|
|
/>
|
|
|
|
) : (
|
2023-03-13 18:40:08 +03:00
|
|
|
<>
|
|
|
|
<img
|
|
|
|
src={previewUrl}
|
|
|
|
alt={description}
|
|
|
|
width={width}
|
|
|
|
height={height}
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation={orientation}
|
2023-03-13 18:40:08 +03:00
|
|
|
loading="lazy"
|
|
|
|
/>
|
|
|
|
<div class="media-play">
|
|
|
|
<Icon icon="play" size="xxl" />
|
|
|
|
</div>
|
|
|
|
</>
|
2023-01-29 10:23:53 +03:00
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
</Parent>
|
2023-01-29 10:23:53 +03:00
|
|
|
);
|
|
|
|
} else if (type === 'audio') {
|
|
|
|
const formattedDuration = formatDuration(original.duration);
|
|
|
|
return (
|
2023-04-14 10:30:04 +03:00
|
|
|
<Parent
|
2023-01-29 10:23:53 +03:00
|
|
|
class="media media-audio"
|
|
|
|
data-formatted-duration={formattedDuration}
|
|
|
|
onClick={onClick}
|
2023-07-30 16:28:17 +03:00
|
|
|
style={!showOriginal && mediaStyles}
|
2023-01-29 10:23:53 +03:00
|
|
|
>
|
|
|
|
{showOriginal ? (
|
|
|
|
<audio src={remoteUrl || url} preload="none" controls autoplay />
|
|
|
|
) : previewUrl ? (
|
|
|
|
<img
|
|
|
|
src={previewUrl}
|
|
|
|
alt={description}
|
|
|
|
width={width}
|
|
|
|
height={height}
|
2023-04-14 16:14:08 +03:00
|
|
|
data-orientation={orientation}
|
2023-01-29 10:23:53 +03:00
|
|
|
loading="lazy"
|
|
|
|
/>
|
|
|
|
) : null}
|
2023-03-19 16:08:09 +03:00
|
|
|
{!showOriginal && (
|
|
|
|
<div class="media-play">
|
|
|
|
<Icon icon="play" size="xxl" />
|
|
|
|
</div>
|
|
|
|
)}
|
2023-04-14 10:30:04 +03:00
|
|
|
</Parent>
|
2023-01-29 10:23:53 +03:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default Media;
|