impress/app/javascript/wardrobe-2020/components/OutfitPreview.js

544 lines
17 KiB
JavaScript
Raw Normal View History

import React from "react";
import {
Box,
DarkMode,
Flex,
Text,
useColorModeValue,
useToast,
} from "@chakra-ui/react";
import LRU from "lru-cache";
import { WarningIcon } from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import OutfitMovieLayer, {
buildMovieClip,
hasAnimations,
loadMovieLibrary,
} from "./OutfitMovieLayer";
import HangerSpinner from "./HangerSpinner";
import { loadImage, safeImageUrl, useLocalStorage } from "../util";
import useOutfitAppearance from "./useOutfitAppearance";
import usePreferArchive from "./usePreferArchive";
/**
* OutfitPreview is for rendering a full outfit! It accepts outfit data,
* fetches the appearance data for it, and preloads and renders the layers
* together.
*
* If the species/color/pose fields are null and a `placeholder` node is
* provided instead, we'll render the placeholder. And then, once those props
* become non-null, we'll keep showing the placeholder below the loading
* overlay until loading completes. (We use this on the homepage to show the
* beach splash until outfit data arrives!)
*
* TODO: There's some duplicate work happening in useOutfitAppearance and
* useOutfitState both getting appearance data on first load...
*/
function OutfitPreview(props) {
const { preview } = useOutfitPreview(props);
return preview;
}
/**
* useOutfitPreview is like `<OutfitPreview />`, but a bit more power!
*
* It takes the same props and returns a `preview` field, which is just like
* `<OutfitPreview />` - but it also returns `appearance` data too, in case you
* want to show some additional UI that uses the appearance data we loaded!
*/
export function useOutfitPreview({
speciesId,
colorId,
pose,
altStyleId,
wornItemIds,
appearanceId = null,
isLoading = false,
placeholder = null,
loadingDelayMs,
spinnerVariant,
onChangeHasAnimations = null,
...props
}) {
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
const toast = useToast();
const appearance = useOutfitAppearance({
speciesId,
colorId,
pose,
altStyleId,
appearanceId,
wornItemIds,
});
const { loading, error, visibleLayers } = appearance;
const {
loading: loading2,
error: error2,
loadedLayers,
layersHaveAnimations,
} = usePreloadLayers(visibleLayers);
const onMovieError = React.useCallback(() => {
if (!toast.isActive("outfit-preview-on-movie-error")) {
toast({
id: "outfit-preview-on-movie-error",
status: "warning",
title: "Oops, we couldn't load one of these animations.",
description: "We'll show a static image version instead.",
duration: null,
isClosable: true,
});
}
}, [toast]);
const onLowFps = React.useCallback(
(fps) => {
setIsPaused(true);
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
if (!toast.isActive("outfit-preview-on-low-fps")) {
toast({
id: "outfit-preview-on-low-fps",
status: "warning",
title: "Sorry, the animation was lagging, so we paused it! 😖",
description:
"We do this to help make sure your machine doesn't lag too much! " +
"You can unpause the preview to try again.",
duration: null,
isClosable: true,
});
}
},
[setIsPaused, toast],
);
React.useEffect(() => {
if (onChangeHasAnimations) {
onChangeHasAnimations(layersHaveAnimations);
}
}, [layersHaveAnimations, onChangeHasAnimations]);
const textColor = useColorModeValue("green.700", "white");
let preview;
if (error || error2) {
preview = (
<FullScreenCenter>
<Text color={textColor} d="flex" alignItems="center">
<WarningIcon />
<Box width={2} />
Could not load preview. Try again?
</Text>
</FullScreenCenter>
);
} else {
preview = (
<OutfitLayers
loading={isLoading || loading || loading2}
visibleLayers={loadedLayers}
placeholder={placeholder}
loadingDelayMs={loadingDelayMs}
spinnerVariant={spinnerVariant}
onMovieError={onMovieError}
onLowFps={onLowFps}
doTransitions
isPaused={isPaused}
{...props}
/>
);
}
return { appearance, preview };
}
/**
* OutfitLayers is the raw UI component for rendering outfit layers. It's
* used both in the main outfit preview, and in other minor UIs!
*/
export function OutfitLayers({
loading,
visibleLayers,
placeholder = null,
loadingDelayMs = 500,
spinnerVariant = "overlay",
doTransitions = false,
isPaused = true,
onMovieError = null,
onLowFps = null,
...props
}) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive();
const containerRef = React.useRef(null);
const [canvasSize, setCanvasSize] = React.useState(0);
const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
React.useState(false);
// When we start in a loading state, or re-enter a loading state, start the
// loading delay timer.
React.useEffect(() => {
if (loading) {
setLoadingDelayHasPassed(false);
const t = setTimeout(
() => setLoadingDelayHasPassed(true),
loadingDelayMs,
);
return () => clearTimeout(t);
}
}, [loadingDelayMs, loading]);
React.useLayoutEffect(() => {
function computeAndSaveCanvasSize() {
setCanvasSize(
// Follow an algorithm similar to the <img> sizing: a square that
// covers the available space, without exceeding the natural image size
// (which is 600px).
//
// TODO: Once we're entirely off PNGs, we could drop the 600
// requirement, and let SVGs and movies scale up as far as they
// want...
Math.min(
containerRef.current.offsetWidth,
containerRef.current.offsetHeight,
600,
),
);
}
computeAndSaveCanvasSize();
window.addEventListener("resize", computeAndSaveCanvasSize);
return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
}, [setCanvasSize]);
return (
<ClassNames>
{({ css }) => (
<Box
pos="relative"
height="100%"
width="100%"
maxWidth="600px"
maxHeight="600px"
// Create a stacking context, so the z-indexed layers don't escape!
zIndex="0"
ref={containerRef}
data-loading={loading ? true : undefined}
{...props}
>
{placeholder && (
<FullScreenCenter>
<Box
// We show the placeholder until there are visible layers, at which
// point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s"
width="100%"
height="100%"
maxWidth="600px"
maxHeight="600px"
>
{placeholder}
</Box>
</FullScreenCenter>
)}
<TransitionGroup enter={false} exit={doTransitions}>
{visibleLayers.map((layer) => (
<CSSTransition
// We manage the fade-in and fade-out separately! The fade-out
// happens here, when the layer exits the DOM.
key={layer.id}
timeout={200}
>
<FadeInOnLoad
as={FullScreenCenter}
zIndex={layer.zone.depth}
className={css`
&.exit {
opacity: 1;
}
&.exit-active {
opacity: 0;
transition: opacity 0.2s;
}
`}
>
{layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode,
})}
width={canvasSize}
height={canvasSize}
isPaused={isPaused}
onError={onMovieError}
onLowFps={onLowFps}
/>
) : (
<Box
as="img"
src={safeImageUrl(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive },
)}
alt=""
objectFit="contain"
maxWidth="100%"
maxHeight="100%"
/>
)}
</FadeInOnLoad>
</CSSTransition>
))}
</TransitionGroup>
<FullScreenCenter
zIndex="9000"
// This is similar to our Delay util component, but Delay disappears
// immediately on load, whereas we want this to fade out smoothly. We
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
// fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!)
opacity={loading && loadingDelayHasPassed ? 1 : 0}
transition="opacity 0.2s"
>
{spinnerVariant === "overlay" && (
<>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
backgroundColor="gray.900"
opacity="0.7"
/>
{/* Against the dark overlay, use the Dark Mode spinner. */}
<DarkMode>
<HangerSpinner />
</DarkMode>
</>
)}
{spinnerVariant === "corner" && (
<HangerSpinner
size="sm"
position="absolute"
bottom="2"
right="2"
/>
)}
</FullScreenCenter>
</Box>
)}
</ClassNames>
);
}
export function FullScreenCenter({ children, ...otherProps }) {
return (
<Flex
pos="absolute"
top="0"
right="0"
bottom="0"
left="0"
alignItems="center"
justifyContent="center"
{...otherProps}
>
{children}
</Flex>
);
}
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
if (hiResMode && layer.svgUrl) {
return layer.svgUrl;
} else {
return layer.imageUrl;
}
}
/**
* usePreloadLayers preloads the images for the given layers, and yields them
* when done. This enables us to keep the old outfit preview on screen until
* all the new layers are ready, then show them all at once!
*/
export function usePreloadLayers(layers) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive();
const [error, setError] = React.useState(null);
const [loadedLayers, setLoadedLayers] = React.useState([]);
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
// NOTE: This condition would need to change if we started loading one at a
// time, or if the error case would need to show a partial state!
const loading = layers.length > 0 && loadedLayers !== layers;
React.useEffect(() => {
// HACK: Don't clear the preview when we have zero layers, because it
// usually means the parent is still loading data. I feel like this isn't
// the right abstraction, though...
if (layers.length === 0) {
return;
}
let canceled = false;
setError(null);
setLayersHaveAnimations(false);
const minimalAssetPromises = [];
const imageAssetPromises = [];
const movieAssetPromises = [];
for (const layer of layers) {
const imageAssetPromise = loadImage(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive },
);
imageAssetPromises.push(imageAssetPromise);
if (layer.canvasMovieLibraryUrl) {
// Start preloading the movie. But we won't block on it! The blocking
// request will still be the image, which we'll show as a
// placeholder, which should usually be noticeably faster!
const movieLibraryPromise = loadMovieLibrary(
layer.canvasMovieLibraryUrl,
{ preferArchive },
);
const movieAssetPromise = movieLibraryPromise.then((library) => ({
library,
libraryUrl: layer.canvasMovieLibraryUrl,
}));
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
movieAssetPromises.push(movieAssetPromise);
// The minimal asset for the movie case is *either* the image *or*
// the movie, because we can start rendering when either is ready.
minimalAssetPromises.push(
Promise.any([imageAssetPromise, movieAssetPromise]),
);
} else {
minimalAssetPromises.push(imageAssetPromise);
}
}
// When the minimal assets have loaded, we can say the layers have
// loaded, and allow the UI to start showing them!
Promise.all(minimalAssetPromises)
.then(() => {
if (canceled) return;
setLoadedLayers(layers);
})
.catch((e) => {
if (canceled) return;
console.error("Error preloading outfit layers", e);
setError(e);
// Cancel any remaining promises, if cancelable.
imageAssetPromises.forEach((p) => p.cancel && p.cancel());
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
});
// As the movie assets come in, check them for animations, to decide
// whether to show the Play/Pause button.
const checkHasAnimations = (asset) => {
if (canceled) return;
let assetHasAnimations;
try {
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
} catch (e) {
console.error("Error testing layers for animations", e);
setError(e);
return;
}
setLayersHaveAnimations(
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
);
};
movieAssetPromises.forEach((p) =>
p.then(checkHasAnimations).catch((e) => {
console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
}),
);
return () => {
canceled = true;
};
}, [layers, hiResMode, preferArchive]);
return { loading, error, loadedLayers, layersHaveAnimations };
}
// This cache is large because it's only storing booleans; mostly just capping
// it to put *some* upper bound on memory growth.
const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
// This operation can be pretty expensive! We store a cache to only do it
// once per layer per session ish, instead of on each outfit change.
const cachedHasAnimations =
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
if (cachedHasAnimations) {
return cachedHasAnimations;
}
const movieClip = buildMovieClip(library, libraryUrl);
// Some movie clips require you to tick to the first frame of the movie
// before the children mount onto the stage. If we detect animations
// without doing this, we'll incorrectly say no, because we see no children!
// Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
movieClip.advance();
const movieClipHasAnimations = hasAnimations(movieClip);
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
return movieClipHasAnimations;
}
/**
* FadeInOnLoad attaches an `onLoad` handler to its single child, and fades in
* the container element once it triggers.
*/
function FadeInOnLoad({ children, ...props }) {
const [isLoaded, setIsLoaded] = React.useState(false);
const onLoad = React.useCallback(() => setIsLoaded(true), []);
const child = React.Children.only(children);
const wrappedChild = React.cloneElement(child, { onLoad });
return (
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
{wrappedChild}
</Box>
);
}
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any
// NOTE: Normally I would've considered Promise.any within our support browser
// range… but it's affected 25 users in the past two months, which is
// surprisingly high. And the polyfill is small, so let's do it! (11/2021)
Promise.any =
Promise.any ||
function ($) {
return new Promise(function (D, E, A, L) {
A = [];
L = $.map(function ($, i) {
return Promise.resolve($).then(D, function (O) {
return ((A[i] = O), --L) || E({ errors: A });
});
}).length;
});
};
export default OutfitPreview;