import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import {
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import mem from '../utils/mem';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import { formatDuration } from './status';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
/*
Media type
===
unknown = unsupported or unrecognized file type
image = Static image
gifv = Looping, soundless animation
video = Video clip
audio = Audio track
*/
const dataAltLabel = 'ALT';
const AltBadge = (props) => {
const { alt, lang, index, ...rest } = props;
if (!alt || !alt.trim()) return null;
return (
);
};
const MEDIA_CAPTION_LIMIT = 140;
const MEDIA_CAPTION_LIMIT_LONGER = 280;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
/[\n\r].*[\n\r]/.test(caption.trim())
: false,
);
function Media({
class: className = '',
media,
to,
lang,
showOriginal,
autoAnimate,
showCaption,
allowLongerCaption,
altIndex,
onClick = () => {},
}) {
const {
blurhash,
description,
meta,
previewRemoteUrl,
previewUrl,
remoteUrl,
url,
type,
} = media;
const { original = {}, small, focus } = meta || {};
const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height;
const mediaURL = showOriginal ? url : previewUrl || url;
const remoteMediaURL = showOriginal
? remoteUrl
: previewRemoteUrl || remoteUrl;
const orientation = width >= height ? 'landscape' : 'portrait';
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
const videoRef = useRef();
let focalPosition;
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;
focalPosition = `${x.toFixed(0)}% ${y.toFixed(0)}%`;
}
const mediaRef = useRef();
const onUpdate = useCallback(({ x, y, scale }) => {
const { current: media } = mediaRef;
if (media) {
const value = make3dTransformValue({ x, y, scale });
if (scale === 1) {
media.style.removeProperty('transform');
} else {
media.style.setProperty('transform', value);
}
media.closest('.media-zoom').style.touchAction =
scale <= 1.01 ? 'pan-x' : '';
}
}, []);
const [pinchZoomEnabled, setPinchZoomEnabled] = useState(false);
const quickPinchZoomProps = {
enabled: pinchZoomEnabled,
draggableUnZoomed: false,
inertiaFriction: 0.9,
doubleTapZoomOutOnMaxScale: true,
containerProps: {
className: 'media-zoom',
style: {
overflow: 'visible',
// width: 'inherit',
// height: 'inherit',
// justifyContent: 'inherit',
// alignItems: 'inherit',
// display: 'inherit',
},
},
onUpdate,
};
const Parent = useMemo(
() => (to ? (props) => : 'div'),
[to],
);
const remoteMediaURLObj = remoteMediaURL ? new URL(remoteMediaURL) : null;
const isVideoMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
/\.(mp4|m4r|m4v|mov|webm)$/i.test(remoteMediaURLObj.pathname);
const isAudioMaybe =
type === 'unknown' &&
remoteMediaURLObj &&
/\.(mp3|ogg|wav|m4a|m4p|m4b)$/i.test(remoteMediaURLObj.pathname);
const isImage =
type === 'image' ||
(type === 'unknown' && previewUrl && !isVideoMaybe && !isAudioMaybe);
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]);
const maxAspectHeight =
window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33);
const maxHeight = orientation === 'portrait' ? 0 : 160;
const averageColorStyle = {
'--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
};
const mediaStyles =
width && height
? {
'--width': `${width}px`,
'--height': `${height}px`,
// Calculate '--aspectWidth' based on aspect ratio calculated from '--width' and '--height', max height has to be 160px
'--aspectWidth': `${
(width / height) * Math.max(maxHeight, maxAspectHeight)
}px`,
aspectRatio: `${width} / ${height}`,
...averageColorStyle,
}
: {
...averageColorStyle,
};
const longDesc = isMediaCaptionLong(description);
let showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
if (
allowLongerCaption &&
!showInlineDesc &&
description?.length <= MEDIA_CAPTION_LIMIT_LONGER
) {
showInlineDesc = true;
}
const Figure = !showInlineDesc
? Fragment
: (props) => {
const { children, ...restProps } = props;
return (
);
};
if (isImage) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
useLayoutEffect(() => {
if (!isSafari) return;
if (!showOriginal) return;
(async () => {
try {
await fetch(mediaURL, { mode: 'no-cors' });
mediaRef.current.src = mediaURL;
} catch (e) {
// Ignore
}
})();
}, [mediaURL]);
return (
);
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
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;
const showProgress = original.duration > 5;
const videoHTML = `
`;
return (
);
} else if (type === 'audio' || isAudioMaybe) {
const formattedDuration = formatDuration(original.duration);
return (
);
}
}
export default Media;