Skip to content

Instantly share code, notes, and snippets.

@ShishKabab
Last active March 23, 2022 18:28
Show Gist options
  • Save ShishKabab/9883e9d0fb903cc2abbb00f0132ffaf8 to your computer and use it in GitHub Desktop.
Save ShishKabab/9883e9d0fb903cc2abbb00f0132ffaf8 to your computer and use it in GitHub Desktop.
React component for a pausable image-based animation (call it a non-GIF player)

I was looking for an easy to use GIF player to play a GIF made out of a few screenshots. In the end, I decided to just make a compoment that takes in a list of image URLs, preloads them and plays them when playing the play button.

Features:

  • Loads first frame first, then the rest of them
  • Loading indicator while loading all frames
  • Seperately configurable pauses between frames and after a loop

Usage:

<AnimationFrames
  width={1614} // explicitly set dimensions so we don't get jumping while loading
  height={1250}
  playSpeed={1000} // miliseconds between frames
  loopPause={2000} // miliseconds between loops
  frames={[
    "/frame-1.png",
    "/frame-2.png",
    "/frame-3.png",
  ]}
/>
import React from "react";
import styled from "styled-components";
import LoadingIndicator from "./loading-indicator";
import PlayButton from "./play-button";
export interface AnimationFramesProps {
width: number;
height: number;
frames: string[];
playSpeed?: number;
loopPause?: number;
}
export interface AnimationFramesState {
currentFrame: number;
playing: boolean;
loaded: boolean;
}
const StyledContainer = styled.div`
position: relative;
`;
const StyledImage = styled.img`
max-width: 100%;
height: auto;
cursor: pointer;
`;
const PlaybackControls = styled.div`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
`;
const DEFAULT_PLAY_SPEED = 1000;
const DEFAULT_LOOP_PAUSE = 2000;
async function preloadImage(src: string) {
return new Promise<void>((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => {
resolve();
});
img.addEventListener("error", (err) => {
reject(err);
});
img.src = src;
});
}
async function preloadImages(srcs: string[]) {
return Promise.all(srcs.map(preloadImage));
}
export default class AnimationFrames extends React.Component<
AnimationFramesProps,
AnimationFramesState
> {
mounted = false;
frameImages?: HTMLImageElement;
doFrameTimeout?: ReturnType<typeof setTimeout>;
constructor(props: AnimationFramesProps) {
super(props);
this.state = {
currentFrame: 0,
playing: false,
loaded: false,
};
}
async componentDidMount() {
const { props } = this;
this.mounted = true;
await preloadImages(props.frames.slice(0, 1))
.then(() => preloadImages(props.frames.slice(1)))
.then(() => {});
this.setState({
loaded: true,
});
}
componentWillUnmount() {
this.mounted = false;
}
togglePlay() {
if (!this.state.playing) {
this.startPlay();
} else {
this.pausePlay();
}
}
startPlay() {
this.setState(
{
playing: true,
},
this.doFrame
);
}
pausePlay() {
if (this.doFrameTimeout) {
clearTimeout(this.doFrameTimeout);
}
this.setState({
playing: false,
});
}
doFrame = () => {
if (!this.mounted || !this.state.playing) {
return;
}
const nextFrame = this.state.loaded
? (this.state.currentFrame + 1) % this.props.frames.length
: this.state.currentFrame;
this.setState({ currentFrame: nextFrame });
const timeoutMs =
nextFrame < this.props.frames.length - 1
? this.props.playSpeed ?? DEFAULT_PLAY_SPEED
: this.props.loopPause ?? DEFAULT_LOOP_PAUSE;
this.doFrameTimeout = setTimeout(this.doFrame, timeoutMs);
};
render() {
const { props, state } = this;
const onClick = state.loaded ? () => this.togglePlay() : () => {};
return (
<StyledContainer>
<StyledImage
src={props.frames[state.currentFrame]}
width={props.width}
height={props.height}
onClick={onClick}
/>
<PlaybackControls>
{!state.loaded && <LoadingIndicator color="black" />}
{state.loaded && !this.state.playing && (
<PlayButton color="black" onClick={onClick} />
)}
</PlaybackControls>
</StyledContainer>
);
}
}
// Adapted from https://loading.io/css/
import React from "react";
import styled, { keyframes } from "styled-components";
const rotate = keyframes`
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
`;
const Ring = styled.div<{ color: string }>`
display: inline-block;
&:after {
content: " ";
display: block;
width: 64px;
height: 64px;
border-radius: 50%;
border: 6px solid ${(props) => props.color};
border-color: ${(props) => props.color} transparent
${(props) => props.color} transparent;
animation: ${rotate} 1.2s linear infinite;
}
`;
export default function LoadingIndicator(props: { color: string }) {
return <Ring color={props.color} />;
}
import React from "react";
import styled from "styled-components";
const Container = styled.div`
display: inline-block;
position: relative;
`;
const Ring = styled.div<{ color: string }>`
display: block;
width: 64px;
height: 64px;
border-radius: 50%;
border: 6px solid ${(props) => props.color};
`;
const TriangleRight = styled.div<{ color: string }>`
position: absolute;
display: block;
left: calc(50% + 2px);
top: 50%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-left: 20px solid ${(props) => props.color};
border-top: 20px solid transparent;
border-bottom: 20px solid transparent;
`;
export default function PlayButton(props: {
color: string;
onClick?: () => void;
}) {
return (
<Container onClick={props.onClick}>
<Ring color={props.color} />
<TriangleRight color={props.color} />
</Container>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment